| @@ -14,26 +14,15 @@ | ||
| 14 | 14 | |
| 15 | 15 | defined('ABSPATH') || exit; |
| 16 | 16 | |
| 17 | 17 | /** |
| 18 | - * Discovery — the agent's entry point into a FluentBooking site. | |
| 18 | + * Discovery: `get-booking-context` is the "call this first" tool. One call, | |
| 19 | + * no parameters, tells the agent who it is, what it may do, the site's time | |
| 20 | + * conventions, headline counts and every valid enum, so other schemas don't | |
| 21 | + * have to restate them. | |
| 19 | 22 | * |
| 20 | - * `get-booking-context` is the documented "call this first" tool. One call tells | |
| 21 | - * the agent who it is, what it may do, the site's time conventions, headline | |
| 22 | - * counts, and every valid enum — so it never has to guess a status string or a | |
| 23 | - * timezone. It takes no parameters at all: discovery should have zero friction, | |
| 24 | - * and a no-argument schema is also the cheapest schema there is. | |
| 25 | - * | |
| 26 | - * It earns its ~350 tokens of resident context several times over. Without it, | |
| 27 | - * every other tool's schema would have to restate the status and event-type | |
| 28 | - * enums inline, and the agent would still guess wrong about week start and | |
| 29 | - * timezone. | |
| 30 | - * | |
| 31 | - * Small reference lists (hosts, calendars, event types) are inlined only while | |
| 32 | - * they stay small. Past the threshold the payload reports counts and points at | |
| 33 | - * `list-reference-data` instead — a context tool that grows with the size of | |
| 34 | - * the site is a context tool that eventually breaks the session it was meant to | |
| 35 | - * bootstrap. | |
| 23 | + * Reference lists are inlined only while small; past the limit they become a | |
| 24 | + * count plus a pointer, so the payload doesn't grow with the site. | |
| 36 | 25 | */ |
| 37 | 26 | class ContextTools |
| 38 | 27 | { |
| 39 | 28 | const CACHE_TTL = 60; |
| @@ -42,48 +31,30 @@ | ||
| 42 | 31 | |
| 43 | 32 | /** |
| 44 | 33 | * Above this many rows a reference list is summarised rather than inlined. |
| 45 | 34 | * |
| 46 | - * Derived from the response budget in docs/mcp-server-spec.md §10 (≤1,200 | |
| 47 | - * tokens for this tool), not picked by feel. Measured against a real site: | |
| 48 | - * the payload without reference lists is ~1,770 bytes, a calendar row ~55 | |
| 49 | - * and an event-type row ~115. Twelve of each is 12 × 55 + 12 × 115 = 2,040 | |
| 50 | - * bytes, for a worst case of ~3,800 bytes ≈ 1,090 tokens. Raising this | |
| 51 | - * without re-doing that arithmetic breaks the budget the whole design rests | |
| 52 | - * on — this tool is called at the start of every session. | |
| 35 | + * Sized to the ≤1,200 token budget in docs/mcp-server-spec.md §10: base | |
| 36 | + * payload ~1,770 bytes, plus 12 calendar rows (~55 bytes) and 12 event-type | |
| 37 | + * rows (~115 bytes) is ~3,800 bytes ≈ 1,090 tokens. Redo the sum before raising it. | |
| 53 | 38 | */ |
| 54 | 39 | const INLINE_LIST_LIMIT = 12; |
| 55 | 40 | |
| 56 | 41 | /** |
| 57 | - * Booking statuses the `status` column genuinely holds that | |
| 58 | - * Booking::getBookingStatus()'s label map does NOT list. | |
| 42 | + * Statuses the `status` column holds that Booking::getBookingStatus()'s | |
| 43 | + * label map doesn't list. A value missing from the input_schema enum makes | |
| 44 | + * those bookings unreachable, since the call is rejected outright. | |
| 59 | 45 | * |
| 60 | - * An enum is wrong in two directions and only one of them is loud. Listing a | |
| 61 | - * value the column can never hold gives the agent a filter that silently | |
| 62 | - * returns zero rows. OMITTING a value the column does hold is worse: those | |
| 63 | - * bookings become unreachable, and because the value is absent from the | |
| 64 | - * input_schema enum the call is rejected outright, so the agent cannot even | |
| 65 | - * discover that the rows exist. | |
| 46 | + * - reserved: written at checkout for payment-pending bookings. | |
| 47 | + * - no_show: settable via SchedulesController::patchBooking(). | |
| 48 | + * - approved: TimeSlotService::getBookedSlots() treats it as occupying a slot. | |
| 66 | 49 | * |
| 67 | - * Both of these are written by real code paths: | |
| 68 | - * - reserved: written during checkout for payment-pending bookings and | |
| 69 | - * queried by SchedulesController::addCountsForFirstPage(). | |
| 70 | - * - no_show: settable through SchedulesController::patchBooking()'s status | |
| 71 | - * whitelist and counted by the same method. | |
| 72 | - * - approved: TimeSlotService::getBookedSlots() treats it as occupying a | |
| 73 | - * slot, so rows holding it are real enough to remove | |
| 74 | - * availability — and were previously unreachable by any filter. | |
| 75 | - * | |
| 76 | - * Keep this list in step with the writers, not with the label map. | |
| 50 | + * Keep this in step with the writers, not with the label map. | |
| 77 | 51 | */ |
| 78 | 52 | const PERSISTED_ONLY_STATUSES = ['reserved', 'no_show', 'approved']; |
| 79 | 53 | |
| 80 | 54 | /** |
| 81 | - * Statuses Booking::getBookingStatus() has a label for. That method keeps | |
| 82 | - * its map private, so the list is mirrored here rather than read out of it — | |
| 83 | - * and the mirror is deliberate: adding a status there without adding it here | |
| 84 | - * only costs the agent a filter, whereas reflecting into a private array | |
| 85 | - * would break silently on any refactor. | |
| 55 | + * Statuses Booking::getBookingStatus() has a label for. Its map is private, | |
| 56 | + * so it's mirrored here rather than read by reflection. | |
| 86 | 57 | */ |
| 87 | 58 | const LABELLED_STATUSES = ['scheduled', 'rescheduled', 'completed', 'pending', 'cancelled', 'rejected']; |
| 88 | 59 | |
| 89 | 60 | /** |
| @@ -96,17 +67,12 @@ | ||
| 96 | 67 | return array_values(array_unique(array_merge(self::LABELLED_STATUSES, self::PERSISTED_ONLY_STATUSES))); |
| 97 | 68 | } |
| 98 | 69 | |
| 99 | 70 | /** |
| 100 | - * The computed period buckets list-bookings accepts. | |
| 71 | + * The computed period buckets list-bookings accepts: the helper's list | |
| 72 | + * (so filter-added buckets show up), plus the two that | |
| 73 | + * Booking::scopeApplyComputedStatus() honours but the admin dropdown omits. | |
| 101 | 74 | * |
| 102 | - * Read from the canonical helper so a bucket added by a filter shows up | |
| 103 | - * automatically, then unioned with the two that | |
| 104 | - * Booking::scopeApplyComputedStatus() honours but the admin's filter | |
| 105 | - * dropdown never renders. A period the scope supports but the enum omits is | |
| 106 | - * a filter the agent cannot reach; one the enum lists but the scope ignores | |
| 107 | - * silently returns the unfiltered set. Both directions matter. | |
| 108 | - * | |
| 109 | 75 | * @return array |
| 110 | 76 | */ |
| 111 | 77 | public static function bookingPeriods() |
| 112 | 78 | { |
| @@ -122,15 +88,14 @@ | ||
| 122 | 88 | * @return array |
| 123 | 89 | */ |
| 124 | 90 | public static function eventTypes() |
| 125 | 91 | { |
| 126 | - return ['single', 'group', 'round_robin', 'collective', 'single_event', 'group_event']; | |
| 92 | + return CalendarSlot::getEventTypes(); | |
| 127 | 93 | } |
| 128 | 94 | |
| 129 | 95 | /** |
| 130 | - * Every enum the agent is told to trust. Shared with the tool schemas, so a | |
| 131 | - * value the agent is offered in an input_schema and a value this payload | |
| 132 | - * advertises can never disagree. | |
| 96 | + * Every enum the agent is told to trust. Shared with the tool schemas so | |
| 97 | + * the two can't disagree. | |
| 133 | 98 | * |
| 134 | 99 | * @return array |
| 135 | 100 | */ |
| 136 | 101 | public static function enums() |
| @@ -144,10 +109,8 @@ | ||
| 144 | 109 | ]; |
| 145 | 110 | } |
| 146 | 111 | |
| 147 | 112 | /** |
| 148 | - * The tool definitions this class owns. | |
| 149 | - * | |
| 150 | 113 | * @return array |
| 151 | 114 | */ |
| 152 | 115 | public static function definitions() |
| 153 | 116 | { |
| @@ -156,9 +119,9 @@ | ||
| 156 | 119 | 'label' => __('Get booking context', 'fluent-booking'), |
| 157 | 120 | 'description' => __('Call this first. Returns who you are, what you may do, the site timezone and current time, valid enum values for every filter, headline counts, and small reference lists of hosts, calendars and event types.', 'fluent-booking'), |
| 158 | 121 | 'input_schema' => [ |
| 159 | 122 | 'type' => 'object', |
| 160 | - // stdClass, not [] — an empty PHP array serialises as a JSON | |
| 123 | + // stdClass, not []: an empty array serialises as a JSON | |
| 161 | 124 | // array and clients reject `"properties": []`. |
| 162 | 125 | 'properties' => new \stdClass(), |
| 163 | 126 | ], |
| 164 | 127 | 'annotations' => [ |
| @@ -172,15 +135,12 @@ | ||
| 172 | 135 | ]; |
| 173 | 136 | } |
| 174 | 137 | |
| 175 | 138 | /** |
| 176 | - * Build (or serve from cache) the context payload. | |
| 139 | + * Build (or serve from cache) the context payload. Cached per user, since | |
| 140 | + * it holds the caller's permissions and permission-scoped counts. | |
| 177 | 141 | * |
| 178 | - * Cached per user, never globally: the payload states the caller's | |
| 179 | - * permission set and permission-scoped counts, so a shared cache entry would | |
| 180 | - * hand one host another host's view of the site. | |
| 181 | - * | |
| 182 | - * @param array $params unused; the tool takes none | |
| 142 | + * @param array $params unused | |
| 183 | 143 | * @return array |
| 184 | 144 | */ |
| 185 | 145 | public static function getContext($params = []) |
| 186 | 146 | { |
| @@ -212,29 +172,24 @@ | ||
| 212 | 172 | return $payload; |
| 213 | 173 | } |
| 214 | 174 | |
| 215 | 175 | /** |
| 216 | - * Invalidate every user's cached context. | |
| 217 | - * | |
| 218 | - * Bumping a shared version counter rather than deleting a key: the cache is | |
| 219 | - * per-user, and these hooks fire as whoever made the edit, so deleting | |
| 220 | - * "the" key only ever cleared the editor's own copy and left every other | |
| 221 | - * operator reading stale reference lists until the TTL expired. The version | |
| 222 | - * is part of the key, so one write retires all of them at once. | |
| 176 | + * Invalidate every user's cached context. The cache is per user and the | |
| 177 | + * hooks fire as the editor, so bump a version in the key rather than | |
| 178 | + * deleting one user's entry. | |
| 223 | 179 | */ |
| 224 | 180 | public static function invalidateCache() |
| 225 | 181 | { |
| 226 | 182 | $option = self::CACHE_PREFIX . 'version'; |
| 227 | 183 | |
| 228 | - // Not autoloaded. Only MCP requests read it, and autoloading meant | |
| 229 | - // every calendar write flushed the site's alloptions cache. | |
| 184 | + // Not autoloaded: only MCP reads it, and autoloading would flush | |
| 185 | + // alloptions on every calendar write. | |
| 230 | 186 | update_option($option, (int) get_option($option, 0) + 1, false); |
| 231 | 187 | } |
| 232 | 188 | |
| 233 | 189 | /** |
| 234 | - * Per-user, per-site, per-version cache key. `get_current_blog_id()` is | |
| 235 | - * included because an object-cache backend fronting transients is not | |
| 236 | - * guaranteed to isolate keys per site on multisite. | |
| 190 | + * Per-user, per-site, per-version cache key. The blog id is included | |
| 191 | + * because an object cache isn't guaranteed to isolate keys per site. | |
| 237 | 192 | * |
| 238 | 193 | * @return string |
| 239 | 194 | */ |
| 240 | 195 | private static function cacheKey() |
| @@ -244,11 +199,10 @@ | ||
| 244 | 199 | return self::CACHE_PREFIX . get_current_blog_id() . '_' . get_current_user_id() . '_' . $version; |
| 245 | 200 | } |
| 246 | 201 | |
| 247 | 202 | /** |
| 248 | - * Who the agent is acting as, and what that account may do. The permission | |
| 249 | - * keys are echoed verbatim so an agent that hits a permission_denied can | |
| 250 | - * tell the user exactly which grant is missing. | |
| 203 | + * Who the agent is acting as. Permission keys are echoed verbatim so a | |
| 204 | + * permission_denied can be traced to the missing grant. | |
| 251 | 205 | * |
| 252 | 206 | * @param string $timezone |
| 253 | 207 | * @return array |
| 254 | 208 | */ |
| @@ -269,11 +223,10 @@ | ||
| 269 | 223 | ]; |
| 270 | 224 | } |
| 271 | 225 | |
| 272 | 226 | /** |
| 273 | - * Site conventions the agent would otherwise guess wrong: the zone, the | |
| 274 | - * current instant in both UTC and local form, which day the week starts on, | |
| 275 | - * and the clock format the operator reads. | |
| 227 | + * Site conventions the agent would otherwise guess: timezone, current time, | |
| 228 | + * week start and clock format. | |
| 276 | 229 | * |
| 277 | 230 | * @param string $timezone |
| 278 | 231 | * @param array $settings |
| 279 | 232 | * @return array |
| @@ -297,11 +250,9 @@ | ||
| 297 | 250 | ); |
| 298 | 251 | } |
| 299 | 252 | |
| 300 | 253 | /** |
| 301 | - * Headline counts, scoped exactly the way the list tools scope their | |
| 302 | - * queries. A count built on a wider query than the list it describes is a | |
| 303 | - * disclosure bug, so both go through the same host filter. | |
| 254 | + * Headline counts, scoped exactly like the list tools' queries. | |
| 304 | 255 | * |
| 305 | 256 | * @return array |
| 306 | 257 | */ |
| 307 | 258 | private static function counts() |
| @@ -311,14 +262,10 @@ | ||
| 311 | 262 | |
| 312 | 263 | $bookingQuery = Booking::query(); |
| 313 | 264 | |
| 314 | 265 | if (!$seesAll) { |
| 315 | - // whereHostAccess(), matching BookingTools::buildQuery() and | |
| 316 | - // BookingReportService::scoped(). A bare host_user_id filter is | |
| 317 | - // NARROWER: it misses every booking the caller is a secondary host | |
| 318 | - // on, which is most of a round-robin or collective host's work. The | |
| 319 | - // context payload would report three upcoming bookings and | |
| 320 | - // list-bookings would then return eleven. | |
| 266 | + // Matches BookingTools::buildQuery(). A bare host_user_id filter | |
| 267 | + // would miss bookings where the caller is a secondary host. | |
| 321 | 268 | $bookingQuery->whereHostAccess($userId); |
| 322 | 269 | } |
| 323 | 270 | |
| 324 | 271 | $upcoming = (clone $bookingQuery) |
| @@ -336,12 +283,8 @@ | ||
| 336 | 283 | ]; |
| 337 | 284 | } |
| 338 | 285 | |
| 339 | 286 | /** |
| 340 | - * Reference lists, inlined only while they are small enough to be free. Past | |
| 341 | - * INLINE_LIST_LIMIT the entry becomes a count plus a pointer, so the context | |
| 342 | - * payload stays flat as a site grows. | |
| 343 | - * | |
| 344 | 287 | * @return array |
| 345 | 288 | */ |
| 346 | 289 | private static function referenceLists() |
| 347 | 290 | { |
| @@ -347,10 +290,10 @@ | ||
| 347 | 290 | { |
| 348 | 291 | return [ |
| 349 | 292 | 'calendars' => self::inlineList( |
| 350 | 293 | self::visibleCalendarQuery(), |
| 351 | - // list-reference-data is in the `scheduling` toolset: on a | |
| 352 | - // core-only site nothing lists calendars, so point at nothing. | |
| 294 | + // list-reference-data is in the `scheduling` toolset; on a | |
| 295 | + // core-only site there's nothing to point at. | |
| 353 | 296 | PermissionGate::isToolsetEnabled(PermissionGate::TOOLSET_SCHEDULING) |
| 354 | 297 | ? 'fluent-booking/list-reference-data' |
| 355 | 298 | : '', |
| 356 | 299 | function ($calendar) { |
| @@ -363,10 +306,9 @@ | ||
| 363 | 306 | } |
| 364 | 307 | ), |
| 365 | 308 | 'event_types' => self::inlineList( |
| 366 | 309 | self::visibleEventTypeQuery(), |
| 367 | - // NOT list-reference-data: it has no `event_types` kind, so | |
| 368 | - // that pointer fails validation. get-event-types is in `core`. | |
| 310 | + // Not list-reference-data: it has no `event_types` kind. | |
| 369 | 311 | 'fluent-booking/get-event-types', |
| 370 | 312 | function ($slot) { |
| 371 | 313 | return [ |
| 372 | 314 | 'id' => (int) $slot->id, |
| @@ -381,9 +323,9 @@ | ||
| 381 | 323 | ]; |
| 382 | 324 | } |
| 383 | 325 | |
| 384 | 326 | /** |
| 385 | - * Inline a list, or summarise it when it is too long to be free. | |
| 327 | + * Inline a list, or summarise it past INLINE_LIST_LIMIT. | |
| 386 | 328 | * |
| 387 | 329 | * @param object $query a model query, already permission-scoped |
| 388 | 330 | * @param string $getWith the tool that returns this list in full; '' when |
| 389 | 331 | * no enabled toolset exposes one |
| @@ -422,11 +364,10 @@ | ||
| 422 | 364 | ]; |
| 423 | 365 | } |
| 424 | 366 | |
| 425 | 367 | /** |
| 426 | - * Calendars this caller may read, through the module's one visibility | |
| 427 | - * helper — so the context payload, `get-event-types` and | |
| 428 | - * `list-reference-data` cannot disagree about what exists. | |
| 368 | + * Calendars this caller may read, via the shared visibility helper so | |
| 369 | + * this and the list tools agree. | |
| 429 | 370 | * |
| 430 | 371 | * @return object |
| 431 | 372 | */ |
| 432 | 373 | private static function visibleCalendarQuery() |
| @@ -444,12 +385,10 @@ | ||
| 444 | 385 | return PermissionGate::scopeToReadableCalendars(CalendarSlot::query(), 'calendar_id'); |
| 445 | 386 | } |
| 446 | 387 | |
| 447 | 388 | /** |
| 448 | - * FluentBooking's nouns against the ones an agent is most likely to arrive | |
| 449 | - * with. An agent that has read Cal.com's docs will ask for an "event type" | |
| 450 | - * and a "schedule"; telling it the mapping once here is cheaper than every | |
| 451 | - * tool description explaining itself. | |
| 389 | + * FluentBooking's nouns mapped to the ones an agent likely arrives with | |
| 390 | + * (e.g. Cal.com's "event type"), stated once instead of in every tool. | |
| 452 | 391 | * |
| 453 | 392 | * @return array |
| 454 | 393 | */ |
| 455 | 394 | private static function terminology() |