@alexkroman1/aai-cli 7.0.0 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +1 -0
  2. package/dist/{_bundler-B4RqNF5Z.mjs → _bundler-CDuYl5Gb.mjs} +1 -1
  3. package/dist/{_dev-server-CiKFH9qw.mjs → _dev-server-Dt3TCjhj.mjs} +6 -2
  4. package/dist/{_init-CE4HKdgV.mjs → _init-BhEfQ6Yi.mjs} +1 -1
  5. package/dist/_resource-commands.d.ts +23 -0
  6. package/dist/{_slug-api-nRFaBEPJ.mjs → _slug-api-z-USxPtL.mjs} +14 -10
  7. package/dist/_slug-api.d.ts +5 -2
  8. package/dist/{_templates-FI3xPEMj.mjs → _templates-4WcKOjS5.mjs} +4 -5
  9. package/dist/_templates.d.ts +3 -4
  10. package/dist/{build-6jfaakgc.mjs → build-BBxf8vom.mjs} +2 -2
  11. package/dist/cli.mjs +338 -346
  12. package/dist/{deploy-DvYeD8ia.mjs → deploy-DRC8jWr8.mjs} +1 -1
  13. package/dist/{dev-B59JIfWO.mjs → dev-QuuOOUtv.mjs} +1 -1
  14. package/dist/eval-DfQ8cm5p.mjs +56 -0
  15. package/dist/eval.d.ts +43 -0
  16. package/dist/{init-CpJVjEg0.mjs → init-CUo87gcI.mjs} +2 -2
  17. package/dist/{logs-9q-psa4q.mjs → logs-CQ2T46dr.mjs} +1 -1
  18. package/dist/scaffold/CLAUDE.md +409 -68
  19. package/dist/scaffold/package.json +5 -4
  20. package/dist/scaffold/server.mjs +10 -3
  21. package/dist/scaffold/tsconfig.json +0 -1
  22. package/dist/{secret-CVvSLIDV.mjs → secret-Ct34kdgU.mjs} +1 -1
  23. package/dist/{storage-BvUrnvM3.mjs → storage-i_79ZwAR.mjs} +1 -1
  24. package/dist/{studio--MUV0cid.mjs → studio-CzTzPqe1.mjs} +1 -1
  25. package/dist/templates/briefing-desk/agent.test.ts +350 -0
  26. package/dist/templates/briefing-desk/agent.ts +30 -0
  27. package/dist/templates/briefing-desk/shared.ts +200 -0
  28. package/dist/templates/briefing-desk/system-prompt.md +39 -0
  29. package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
  30. package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
  31. package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
  32. package/dist/templates/call-audit/agent.eval.test.ts +474 -0
  33. package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
  34. package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
  35. package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
  36. package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
  37. package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
  38. package/dist/templates/infocom-adventure/agent.test.ts +49 -18
  39. package/dist/templates/infocom-adventure/agent.ts +21 -0
  40. package/dist/templates/infocom-adventure/shared.ts +20 -9
  41. package/dist/templates/infocom-adventure/system-prompt.md +1 -1
  42. package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
  43. package/dist/templates/link-digest/agent.eval.test.ts +195 -0
  44. package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
  45. package/dist/templates/night-owl/agent.eval.test.ts +272 -0
  46. package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
  47. package/dist/templates/personal-finance/system-prompt.md +4 -0
  48. package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
  49. package/dist/templates/pipeline-simple/agent.ts +2 -2
  50. package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
  51. package/dist/templates/pizza-ordering/agent.test.ts +15 -0
  52. package/dist/templates/pizza-ordering/shared.ts +20 -1
  53. package/dist/templates/pizza-ordering/system-prompt.md +6 -0
  54. package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
  55. package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
  56. package/dist/templates/podcast-digest/agent.test.ts +30 -59
  57. package/dist/templates/podcast-digest/agent.ts +1 -1
  58. package/dist/templates/podcast-digest/workflows/digest.ts +52 -19
  59. package/dist/templates/podcast-digest/workflows/slack.ts +48 -169
  60. package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
  61. package/dist/templates/redline/agent.eval.test.ts +209 -0
  62. package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
  63. package/dist/templates/retail/address.ts +87 -1
  64. package/dist/templates/retail/agent.eval.test.ts +314 -0
  65. package/dist/templates/retail/agent.test.ts +171 -91
  66. package/dist/templates/retail/agent.ts +35 -1
  67. package/dist/templates/retail/cancel.ts +116 -0
  68. package/dist/templates/retail/client.tsx +39 -0
  69. package/dist/templates/retail/payment.ts +107 -0
  70. package/dist/templates/retail/pending.test.ts +196 -0
  71. package/dist/templates/retail/pending.ts +137 -0
  72. package/dist/templates/retail/registry.test.ts +119 -8
  73. package/dist/templates/retail/returns.ts +102 -0
  74. package/dist/templates/retail/shared.test.ts +42 -0
  75. package/dist/templates/retail/shared.ts +35 -0
  76. package/dist/templates/retail/store.test.ts +6 -4
  77. package/dist/templates/retail/store.ts +113 -57
  78. package/dist/templates/retail/swap.test.ts +4 -4
  79. package/dist/templates/retail/swap.ts +248 -15
  80. package/dist/templates/retail/system-prompt.md +62 -30
  81. package/dist/templates/retail/tools/cancel_change.ts +33 -0
  82. package/dist/templates/retail/tools/cancel_pending_order.ts +15 -59
  83. package/dist/templates/retail/tools/confirm_change.ts +42 -0
  84. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +21 -66
  85. package/dist/templates/retail/tools/modify_pending_order_address.ts +12 -24
  86. package/dist/templates/retail/tools/modify_pending_order_items.ts +22 -60
  87. package/dist/templates/retail/tools/modify_pending_order_payment.ts +15 -77
  88. package/dist/templates/retail/tools/modify_user_address.ts +12 -15
  89. package/dist/templates/retail/tools/return_delivered_order_items.ts +16 -71
  90. package/dist/templates/retail/tools/transfer_to_human_agents.ts +4 -2
  91. package/dist/templates/simple/agent.eval.test.ts +54 -0
  92. package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
  93. package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
  94. package/dist/templates/support-line/agent.eval.test.ts +273 -0
  95. package/dist/templates/support-line/system-prompt.md +2 -1
  96. package/dist/templates/support-line/tools/answer_question.ts +56 -7
  97. package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
  98. package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
  99. package/dist/templates/travel-concierge/agent.test.ts +110 -10
  100. package/dist/templates/travel-concierge/agent.ts +18 -9
  101. package/dist/templates/travel-concierge/shared.ts +110 -9
  102. package/dist/templates/travel-concierge/system-prompt.md +7 -1
  103. package/dist/templates/travel-concierge/tools/book_car_rental.ts +6 -3
  104. package/dist/templates/travel-concierge/tools/book_excursion.ts +6 -3
  105. package/dist/templates/travel-concierge/tools/book_hotel.ts +6 -3
  106. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +6 -3
  107. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
  108. package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
  109. package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
  110. package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
  111. package/dist/templates/travel-concierge/tools/update_ticket.ts +6 -3
  112. package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
  113. package/dist/templates/web-researcher/system-prompt.md +15 -4
  114. package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
  115. package/dist/test.d.ts +29 -5
  116. package/dist/{worker-bundler-COxnqstQ.mjs → worker-bundler-CCVEDjm1.mjs} +45 -25
  117. package/dist/worker-bundler.mjs +1 -1
  118. package/dist/{workflow-D2AQf2Pl.mjs → workflow-BflATMmU.mjs} +19 -11
  119. package/package.json +5 -5
  120. package/dist/eject-C9WJyyr2.mjs +0 -116
  121. package/dist/eject.d.ts +0 -60
  122. package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
@@ -28,6 +28,7 @@ import {
28
28
  activeAssistant,
29
29
  FLIGHTS,
30
30
  gateFlow,
31
+ type SpecialistId,
31
32
  tripProjection,
32
33
  tripSlot,
33
34
  tripView,
@@ -50,6 +51,20 @@ function stateOf(ctx: ToolContext) {
50
51
  return tripSlot.get(ctx);
51
52
  }
52
53
 
54
+ /**
55
+ * Put the call at a desk the way the model has to: through that desk's own
56
+ * delegation tool.
57
+ *
58
+ * Every desk tool checks the stack first (`requireDesk`) and refuses from
59
+ * anywhere else, so a spec driving one has to stand where the model would. It is
60
+ * one line per test rather than a shared `beforeEach` deliberately — which desk
61
+ * a tool belongs to is what the block below is about, so it stays visible at the
62
+ * call site.
63
+ */
64
+ const atDesk = async (id: SpecialistId, ctx: ToolContext): Promise<void> => {
65
+ await run(`to_${id}_assistant`, { request: "what the caller asked for" }, ctx);
66
+ };
67
+
53
68
  // ─── 1. The dialog stack ─────────────────────────────────────────────────────
54
69
 
55
70
  describe("dialog stack (routing.ts)", () => {
@@ -98,6 +113,72 @@ describe("dialog stack (routing.ts)", () => {
98
113
  });
99
114
  });
100
115
 
116
+ // ─── 1b. The desk gate ───────────────────────────────────────────────────────
117
+ //
118
+ // What their graph gets from binding a tool set per node, and what this port
119
+ // used to only ask the prompt for. The eval (`agent.eval.test.ts`) is what
120
+ // measured the asking losing — 0 of 5 live runs, the model searching hotels
121
+ // while the stack said `primary` — so these are the tests that make the stack
122
+ // binding rather than descriptive.
123
+
124
+ describe("the desk gate (requireDesk)", () => {
125
+ const DESK_TOOLS = [
126
+ ["search_flights", { route: "Boston" }, "flight"],
127
+ ["update_ticket", { flightId: "LX52" }, "flight"],
128
+ ["cancel_ticket", {}, "flight"],
129
+ ["search_hotels", { city: "Boston" }, "hotel"],
130
+ ["book_hotel", { hotelId: "H1", nights: 2 }, "hotel"],
131
+ ["search_car_rentals", { city: "Boston" }, "car_rental"],
132
+ ["book_car_rental", { carId: "C2", days: 3 }, "car_rental"],
133
+ ["search_excursions", { city: "Boston" }, "excursion"],
134
+ ["book_excursion", { excursionId: "E2" }, "excursion"],
135
+ ] as const;
136
+
137
+ test.each(DESK_TOOLS)("%s refuses at the concierge desk", async (name, args, desk) => {
138
+ const ctx = makeCtx();
139
+ const refused = (await run(name, args, ctx)) as { error?: string };
140
+ // The refusal NAMES the way in, which is what the model recovers from
141
+ // inside the same turn — a bare "not allowed" would leave it guessing.
142
+ expect(refused.error).toContain(`to_${desk}_assistant`);
143
+ // And nothing happened: no search result, no staging, no stack movement.
144
+ expect(stateOf(ctx).pending).toBeNull();
145
+ expect(activeAssistant(stateOf(ctx))).toBe("primary");
146
+ expect(gateFlow.position(ctx).state).toBe("browsing");
147
+ });
148
+
149
+ test.each(DESK_TOOLS)("%s refuses from ANOTHER desk", async (name, args, desk) => {
150
+ const ctx = makeCtx();
151
+ // The excursions desk for anything that is not its own, and the flight desk
152
+ // for the excursion tools — so every case really is a wrong desk rather
153
+ // than a coincidence of ordering.
154
+ const wrong = desk === "excursion" ? "flight" : "excursion";
155
+ await atDesk(wrong, ctx);
156
+ const refused = (await run(name, args, ctx)) as { error?: string };
157
+ expect(refused.error).toContain(`to_${desk}_assistant`);
158
+ expect(refused.error).toContain(desk === "excursion" ? "flight desk" : "excursions desk");
159
+ expect(stateOf(ctx).pending).toBeNull();
160
+ });
161
+
162
+ test.each(DESK_TOOLS)("%s works once its own desk holds the call", async (name, args, desk) => {
163
+ // The other half, and the one that makes the block above non-vacuous: a
164
+ // gate that refused everywhere would pass every assertion up to here.
165
+ const ctx = makeCtx();
166
+ await atDesk(desk, ctx);
167
+ expect(await run(name, args, ctx)).not.toMatchObject({
168
+ error: expect.stringContaining("belongs to the"),
169
+ });
170
+ });
171
+
172
+ test("the concierge's own tools are NOT gated", async () => {
173
+ // `lookup_booking` is the concierge's, and the gate must not make the desk
174
+ // unable to answer "what am I holding?" from wherever the call is.
175
+ const ctx = makeCtx();
176
+ await atDesk("hotel", ctx);
177
+ const booking = (await run("lookup_booking", ctx)) as { passenger: string };
178
+ expect(booking.passenger).toBe("Nadia Rossi");
179
+ });
180
+ });
181
+
101
182
  // ─── 2. The confirmation gate ────────────────────────────────────────────────
102
183
  //
103
184
  // Their graph halts before a sensitive tool (`interrupt_before`) and resumes on
@@ -107,13 +188,14 @@ describe("dialog stack (routing.ts)", () => {
107
188
 
108
189
  describe("sensitive tools stage rather than act", () => {
109
190
  test.each([
110
- ["update_ticket", { flightId: "LX52" }],
111
- ["cancel_ticket", {}],
112
- ["book_hotel", { hotelId: "H1", nights: 2 }],
113
- ["book_car_rental", { carId: "C2", days: 3 }],
114
- ["book_excursion", { excursionId: "E2" }],
115
- ])("%s changes nothing on its own", async (name, args) => {
191
+ ["update_ticket", { flightId: "LX52" }, "flight"],
192
+ ["cancel_ticket", {}, "flight"],
193
+ ["book_hotel", { hotelId: "H1", nights: 2 }, "hotel"],
194
+ ["book_car_rental", { carId: "C2", days: 3 }, "car_rental"],
195
+ ["book_excursion", { excursionId: "E2" }, "excursion"],
196
+ ] as const)("%s changes nothing on its own", async (name, args, desk) => {
116
197
  const ctx = makeCtx();
198
+ await atDesk(desk, ctx);
117
199
  const before = structuredClone(stateOf(ctx));
118
200
 
119
201
  const staged = (await run(name, args, ctx)) as { awaitingConfirmation: boolean };
@@ -127,6 +209,7 @@ describe("sensitive tools stage rather than act", () => {
127
209
 
128
210
  test("confirm_action applies the staged change, and only then", async () => {
129
211
  const ctx = makeCtx();
212
+ await atDesk("hotel", ctx);
130
213
  await run("book_hotel", { hotelId: "H1", nights: 3 }, ctx);
131
214
 
132
215
  // Staging moved the gate, in the same window it wrote `pending`.
@@ -160,6 +243,7 @@ describe("sensitive tools stage rather than act", () => {
160
243
 
161
244
  test("cancel_action drops the staged change and leaves the booking alone", async () => {
162
245
  const ctx = makeCtx();
246
+ await atDesk("flight", ctx);
163
247
  await run("update_ticket", { flightId: "LX54" }, ctx);
164
248
  const dropped = okPosition<{ discarded: string }>(await run("cancel_action", ctx));
165
249
  expect(dropped.result.discarded).toContain("LX54");
@@ -177,6 +261,7 @@ describe("sensitive tools stage rather than act", () => {
177
261
  // Refused where the model can still recover — before the caller is asked to
178
262
  // confirm a flight the airline does not fly.
179
263
  const ctx = makeCtx();
264
+ await atDesk("flight", ctx);
180
265
  expect(await run("update_ticket", { flightId: "ZZ99" }, ctx)).toEqual({
181
266
  error: "No flight ZZ99 in the schedule.",
182
267
  });
@@ -185,6 +270,7 @@ describe("sensitive tools stage rather than act", () => {
185
270
 
186
271
  test("cancelling the ticket makes a later ticket change impossible", async () => {
187
272
  const ctx = makeCtx();
273
+ await atDesk("flight", ctx);
188
274
  await run("cancel_ticket", ctx);
189
275
  await run("confirm_action", ctx);
190
276
  expect(stateOf(ctx).ticket).toBeNull();
@@ -197,17 +283,22 @@ describe("sensitive tools stage rather than act", () => {
197
283
 
198
284
  test("a second staging is REFUSED rather than overwriting the first", async () => {
199
285
  // The LLM loop runs a step's tool calls concurrently, so two sensitive
200
- // tools in one step is ordinary: the model hears "move my flight and book
201
- // the hotel" and emits both. Assigning `pending` unconditionally made the
286
+ // tools in one step is ordinary: the model hears "move my flight and cancel
287
+ // the old one" and emits both. Assigning `pending` unconditionally made the
202
288
  // second win — both answered `awaitingConfirmation`, the caller said yes
203
289
  // once, and one of the two changes was silently dropped forever.
204
290
  const ctx = makeCtx();
291
+ await atDesk("flight", ctx);
205
292
  const first = (await run("update_ticket", { flightId: "LX52" }, ctx)) as {
206
293
  awaitingConfirmation: boolean;
207
294
  };
208
295
  expect(first.awaitingConfirmation).toBe(true);
209
296
 
210
- const second = (await run("book_hotel", { hotelId: "H1", nights: 2 }, ctx)) as {
297
+ // Both from ONE desk, which is what a concurrent pair looks like now that
298
+ // `requireDesk` is enforced. Another desk's tool would be refused by the
299
+ // DESK gate first, which is a different refusal and would leave this one
300
+ // unexercised.
301
+ const second = (await run("cancel_ticket", ctx)) as {
211
302
  error: string;
212
303
  };
213
304
  // The refusal NAMES what is already waiting, which is what lets the model
@@ -226,7 +317,8 @@ describe("sensitive tools stage rather than act", () => {
226
317
  expect(applied.applied).toContain("LX52");
227
318
  expect(stateOf(ctx).bookings).toEqual([]);
228
319
 
229
- // Once the queue is clear, the hotel can be staged after all.
320
+ // Once the queue is clear, the next desk's booking can be staged after all.
321
+ await atDesk("hotel", ctx);
230
322
  const retried = (await run("book_hotel", { hotelId: "H1", nights: 2 }, ctx)) as {
231
323
  awaitingConfirmation: boolean;
232
324
  };
@@ -235,8 +327,10 @@ describe("sensitive tools stage rather than act", () => {
235
327
 
236
328
  test("cancel_action clears the block, so a declined change does not wedge the desk", async () => {
237
329
  const ctx = makeCtx();
330
+ await atDesk("car_rental", ctx);
238
331
  await run("book_car_rental", { carId: "C2", days: 3 }, ctx);
239
332
  await run("cancel_action", ctx);
333
+ await atDesk("excursion", ctx);
240
334
  const staged = (await run("book_excursion", { excursionId: "E2" }, ctx)) as {
241
335
  awaitingConfirmation: boolean;
242
336
  };
@@ -272,6 +366,7 @@ describe("sensitive tools stage rather than act", () => {
272
366
  describe("search tools", () => {
273
367
  test("an unmatched route widens to the whole schedule rather than answering nothing", async () => {
274
368
  const ctx = makeCtx();
369
+ await atDesk("flight", ctx);
275
370
  const hit = (await run("search_flights", { route: "Zurich to Boston" }, ctx)) as {
276
371
  widened: boolean;
277
372
  flights: unknown[];
@@ -289,6 +384,7 @@ describe("search tools", () => {
289
384
 
290
385
  test("hotels come back cheapest first, and a ceiling filters them", async () => {
291
386
  const ctx = makeCtx();
387
+ await atDesk("hotel", ctx);
292
388
  const all = (await run("search_hotels", { city: "Boston" }, ctx)) as {
293
389
  hotels: { perNight: string }[];
294
390
  };
@@ -303,6 +399,7 @@ describe("search tools", () => {
303
399
 
304
400
  test("an excursion keyword that matches nothing falls back to the city", async () => {
305
401
  const ctx = makeCtx();
402
+ await atDesk("excursion", ctx);
306
403
  const result = (await run("search_excursions", { city: "Boston", keyword: "skiing" }, ctx)) as {
307
404
  widened: boolean;
308
405
  excursions: unknown[];
@@ -313,6 +410,7 @@ describe("search tools", () => {
313
410
 
314
411
  test("lookup_booking reports the ticket the caller is actually holding", async () => {
315
412
  const ctx = makeCtx();
413
+ await atDesk("flight", ctx);
316
414
  await run("update_ticket", { flightId: "LX52" }, ctx);
317
415
  await run("confirm_action", ctx);
318
416
  const booking = (await run("lookup_booking", ctx)) as {
@@ -357,8 +455,10 @@ describe("tripView projection", () => {
357
455
 
358
456
  test("totals every confirmed booking", async () => {
359
457
  const ctx = makeCtx();
458
+ await atDesk("hotel", ctx);
360
459
  await run("book_hotel", { hotelId: "H3", nights: 2 }, ctx); // 2 × 180
361
460
  await run("confirm_action", ctx);
461
+ await atDesk("excursion", ctx);
362
462
  await run("book_excursion", { excursionId: "E1" }, ctx); // 35
363
463
  await run("confirm_action", ctx);
364
464
 
@@ -7,15 +7,24 @@ import { tripProjection } from "./shared.ts";
7
7
  * two mechanisms worth reading for are the dialog stack (`routing.ts`) and the
8
8
  * confirmation gate (`stageAction` / `confirm_action`).
9
9
  *
10
- * **Every tool in `tools/` belongs to this one agent the delegation is in the
11
- * PROMPT, not in the tool list.** Their graph gives each specialist node its
12
- * own bound tool set, so a specialist physically cannot call another desk's
13
- * tools. A voice session has one model with one tool list for its whole life,
14
- * so the specialist's brief arrives as a tool result and the narrowing is
15
- * something the model is asked to honour rather than something the runtime
16
- * enforces. That is the honest limit of the port, and it is why
17
- * `complete_or_escalate` is described the way it is: the stack is what the
18
- * caller and the sidebar can both see, even when the model reaches past it.
10
+ * **Every tool in `tools/` belongs to this one agent, and the delegation is
11
+ * ENFORCED anyway.** Their graph gives each specialist node its own bound tool
12
+ * set, so a specialist physically cannot call another desk's tools. A voice
13
+ * session has one model with one tool list for its whole life, so a list cannot
14
+ * be narrowed here but a TOOL can refuse, which is the same guarantee by a
15
+ * route that works mid-call: every desk tool checks the stack first
16
+ * (`requireDesk` in `shared.ts`) and answers a `ToolFailure` naming the
17
+ * `to_…_assistant` to call. So the position the sidebar renders is always the
18
+ * position the work is being done at, and the specialist's brief has always
19
+ * been read before that desk's first search.
20
+ *
21
+ * This used to be asked for in the prompt instead, described here as the honest
22
+ * limit of the port. It was measured and the prompt lost — 0 of 5 live runs, the
23
+ * model searching hotels from the concierge desk every time — so the eval case
24
+ * that predicted it (`agent.eval.test.ts`, "a hotel request goes to the hotel
25
+ * desk") is what turned the narrowing into a mechanism. `complete_or_escalate`
26
+ * is still described the way it is for the other half of the reason: the stack
27
+ * is what the caller and the sidebar can both see.
19
28
  */
20
29
  export default agent({
21
30
  name: "Swiss Air Concierge",
@@ -14,6 +14,7 @@
14
14
  * | `ToFlightBookingAssistant` & friends (delegation tools) | the four tools {@link SPECIALISTS} generates in `routing.ts` |
15
15
  * | `CompleteOrEscalate` | `complete_or_escalate`, which pops the same stack |
16
16
  * | `interrupt_before=["…_sensitive_tools"]` | {@link stageAction} + `confirm_action` |
17
+ * | a specialist node's BOUND tool set | {@link requireDesk}, checked by every desk tool |
17
18
  * | `fetch_user_flight_information` (sqlite) | `lookup_booking` over {@link seedTrip} |
18
19
  *
19
20
  * **The dialog stack is what keeps a long call on the rails.** Their insight is
@@ -23,7 +24,9 @@
23
24
  * session's prompt is fixed at connect — so the specialist's brief arrives as
24
25
  * the DELEGATION TOOL'S RESULT, which is the last thing the model reads before
25
26
  * it answers. The stack itself is real state either way, which is what
26
- * `complete_or_escalate` pops and what the sidebar renders.
27
+ * `complete_or_escalate` pops and what the sidebar renders — and what
28
+ * {@link requireDesk} makes binding, so the position is never merely a label on
29
+ * work that happened somewhere else.
27
30
  *
28
31
  * **`interrupt_before` becomes a spoken confirmation, and that is not a
29
32
  * downgrade.** Their graph halts before a sensitive tool and waits for a human
@@ -207,6 +210,19 @@ export interface Specialist {
207
210
  instructions: string;
208
211
  }
209
212
 
213
+ /**
214
+ * Each brief spells the STAGE-then-read-back ORDER out, and that is a fix
215
+ * measured rather than a preference.
216
+ *
217
+ * The flight desk's used to say "read the fare change back to the caller before
218
+ * you change a ticket", which is true of `confirm_action` and reads as a rule
219
+ * about `update_ticket` — and live, that is how it was read: the desk searched,
220
+ * quoted a fare it had not staged, waited for a yes, and then called
221
+ * `confirm_action` with nothing waiting for it. Two of three runs never staged
222
+ * anything at all. The sentence to read back IS the staging tool's return
223
+ * value, so a brief that puts the read-back first describes a call the desk
224
+ * cannot make.
225
+ */
210
226
  export const SPECIALISTS: Record<SpecialistId, Specialist> = {
211
227
  flight: {
212
228
  title: "flight desk",
@@ -214,9 +230,22 @@ export const SPECIALISTS: Record<SpecialistId, Specialist> = {
214
230
  "You are now the flight desk. Handle searching and changing flights only.",
215
231
  "Search before you quote anything, and be persistent: if the first search",
216
232
  "finds nothing, widen it rather than telling the caller there is nothing.",
217
- "Read the flight number, the day, the departure time and the fare change",
218
- "back to the caller before you change a ticket. A ticket is only changed",
219
- "once confirm_action has run.",
233
+ "update_ticket is a QUOTE, not a change: it moves nothing, so call it as",
234
+ "soon as you know which flight they want and never ask permission first.",
235
+ "Changing a ticket is TWO steps and never one: call update_ticket first —",
236
+ "it changes nothing and hands you the sentence to say — then read the",
237
+ "flight number, the day, the departure time and the fare change back to",
238
+ "the caller and ask them plainly to confirm. A ticket is only changed once",
239
+ "confirm_action has run, and confirm_action has nothing to apply until",
240
+ "update_ticket has staged it.",
241
+ "Never quote a departure time or a fare you did not just read out of a",
242
+ "tool result, and never end a turn on 'shall I confirm?' with nothing",
243
+ "staged: update_ticket goes out in the SAME turn as that question, before",
244
+ "you stop. There is nothing for a yes to apply until it has.",
245
+ "Your next action, right now, is a TOOL and not a sentence: call",
246
+ "update_ticket if you already know which flight they want, or",
247
+ "search_flights if you do not. Do not speak until one of them has",
248
+ "answered — anything you would say before that is invented.",
220
249
  "If the caller wants a hotel, a car or something to do, or changes their",
221
250
  "mind, call complete_or_escalate instead of improvising.",
222
251
  ].join(" "),
@@ -227,8 +256,18 @@ export const SPECIALISTS: Record<SpecialistId, Specialist> = {
227
256
  "You are now the hotel desk. Handle searching and booking hotels only.",
228
257
  "Ask which city and roughly what budget, then search. Offer at most three",
229
258
  "options out loud, cheapest first, with the neighbourhood and nightly rate.",
230
- "Confirm the hotel, the number of nights and the total before booking.",
231
- "A room is only held once confirm_action has run.",
259
+ "book_hotel is a QUOTE, not a change: it holds nothing, so call it as",
260
+ "soon as you know what the caller wants and never ask permission",
261
+ "first.",
262
+ "Booking is TWO steps and never one: call book_hotel first — it holds",
263
+ "nothing and hands you the sentence to say — then read the hotel, the",
264
+ "number of nights and the total back and ask them to confirm. A room is",
265
+ "only held once confirm_action has run, and confirm_action has nothing to",
266
+ "apply until book_hotel has staged it.",
267
+ "Your next action, right now, is a TOOL and not a sentence: call",
268
+ "book_hotel if you already know which room and how many nights, or",
269
+ "search_hotels if you do not. Do not speak until one of them has",
270
+ "answered — anything you would say before that is invented.",
232
271
  "If the caller wants flights, a car or an excursion, call",
233
272
  "complete_or_escalate.",
234
273
  ].join(" "),
@@ -238,8 +277,19 @@ export const SPECIALISTS: Record<SpecialistId, Specialist> = {
238
277
  instructions: [
239
278
  "You are now the car rental desk. Handle searching and booking cars only.",
240
279
  "Ask the city and how many days, then search. Name the vendor, the tier",
241
- "and the daily rate, and confirm the total before booking.",
242
- "A car is only reserved once confirm_action has run.",
280
+ "and the daily rate.",
281
+ "book_car_rental is a QUOTE, not a change: it holds nothing, so call it as",
282
+ "soon as you know what the caller wants and never ask permission",
283
+ "first.",
284
+ "Reserving is TWO steps and never one: call book_car_rental first — it",
285
+ "reserves nothing and hands you the sentence to say — then read the total",
286
+ "back and ask them to confirm. A car is only reserved once confirm_action",
287
+ "has run, and confirm_action has nothing to apply until book_car_rental",
288
+ "has staged it.",
289
+ "Your next action, right now, is a TOOL and not a sentence: call",
290
+ "book_car_rental if you already know which car and how many days, or",
291
+ "search_car_rentals if you do not. Do not speak until one of them has",
292
+ "answered — anything you would say before that is invented.",
243
293
  "If the caller wants flights, a hotel or an excursion, call",
244
294
  "complete_or_escalate.",
245
295
  ].join(" "),
@@ -251,7 +301,17 @@ export const SPECIALISTS: Record<SpecialistId, Specialist> = {
251
301
  "to do only. Ask the city and what kind of thing they enjoy, then search.",
252
302
  "Describe two or three options in a sentence each — this is the part of",
253
303
  "the call a caller actually wants to hear about.",
254
- "A booking is only made once confirm_action has run.",
304
+ "book_excursion is a QUOTE, not a change: it holds nothing, so call it as",
305
+ "soon as you know what the caller wants and never ask permission",
306
+ "first.",
307
+ "Booking is TWO steps and never one: call book_excursion first — it books",
308
+ "nothing and hands you the sentence to say — then read it back and ask",
309
+ "them to confirm. A booking is only made once confirm_action has run, and",
310
+ "confirm_action has nothing to apply until book_excursion has staged it.",
311
+ "Your next action, right now, is a TOOL and not a sentence: call",
312
+ "book_excursion if you already know which one they want, or",
313
+ "search_excursions if you do not. Do not speak until one of them has",
314
+ "answered — anything you would say before that is invented.",
255
315
  "If the caller wants flights, a hotel or a car, call complete_or_escalate.",
256
316
  ].join(" "),
257
317
  },
@@ -347,6 +407,47 @@ export function note(state: TripState, entry: string): void {
347
407
  pushCapped(state.log, entry, MAX_LOG_ENTRIES);
348
408
  }
349
409
 
410
+ // ─── The desk gate ───────────────────────────────────────────────────────────
411
+
412
+ /**
413
+ * Is the call at `id`'s desk? A {@link ToolFailure} naming the way in if not.
414
+ *
415
+ * **This is the narrowing their graph gets for free, and it used to be prose.**
416
+ * Each specialist node there binds its own tool set, so a node physically cannot
417
+ * call another desk's tools; a voice session has ONE model with ONE tool list
418
+ * for its whole life, so for a long time the stack was real state that the model
419
+ * was merely ASKED to respect — `agent.ts` said so in place, and this guide's
420
+ * own eval predicted the failure. Measured: the prompt lost 0 of 5 live runs.
421
+ * The caller said "I need a hotel in Boston" and the model called
422
+ * `search_hotels` at the concierge desk every time, so `dialogState` said
423
+ * `primary` while the call was plainly doing hotel work — a stack the sidebar
424
+ * renders and nothing keeps true.
425
+ *
426
+ * A tool list cannot be narrowed mid-session, but a tool can REFUSE, and a
427
+ * refusal is a thing the model recovers from inside the same turn: it reads the
428
+ * failure, calls `to_hotel_assistant`, gets the desk's brief, and searches. So
429
+ * the delegation is now structural — every desk tool is reachable only from its
430
+ * own desk — and the brief the delegation returns is guaranteed to have been
431
+ * read before any of that desk's work happens, which was the whole point of
432
+ * handing it back as a tool result.
433
+ *
434
+ * It costs one wasted round trip the first time the model reaches past the
435
+ * stack, which is the right price: the alternative is a desk whose position is
436
+ * decoration.
437
+ */
438
+ export function requireDesk(state: FrozenTripState, id: SpecialistId): ToolFailure | undefined {
439
+ const at = activeAssistant(state);
440
+ if (at === id) return undefined;
441
+ const here = at === "primary" ? "the main concierge" : SPECIALISTS[at].title;
442
+ return {
443
+ error:
444
+ `That tool belongs to the ${SPECIALISTS[id].title} and this call is at ${here}. ` +
445
+ `Nothing was searched, staged or booked. Call to_${id}_assistant with what the ` +
446
+ "caller asked for — its answer is that desk's brief — and then call this tool again. " +
447
+ "Do not tell the caller about any of this; they should hear one continuous conversation.",
448
+ };
449
+ }
450
+
350
451
  // ─── The confirmation gate ───────────────────────────────────────────────────
351
452
 
352
453
  /**
@@ -10,15 +10,21 @@ You do not do the specialist work yourself. There are four desks, and each one h
10
10
  How to run a call:
11
11
 
12
12
  - Start with `lookup_booking` so you know who you are speaking to and what they are holding.
13
- - The moment the caller raises something a desk owns, hand it over with that desk's tool and pass along what they asked for. Do not mention the transfer the caller should hear one continuous conversation.
13
+ - **A desk's tools only work while the call is at that desk, and they refuse from anywhere else.** So the moment the caller raises something a desk owns — flights, hotels, cars, excursions your very next tool call is that desk's `to_…_assistant`, passing along what they asked for. Never a search or a booking first: at the concierge desk those refuse, and you have spent the caller's time on nothing.
14
+ - Do not mention the transfer — the caller should hear one continuous conversation.
14
15
  - The desk's reply tells you how it works. Follow it until it is done, then use `complete_or_escalate` to come back.
15
16
  - Answer small talk, general questions and anything about the existing booking yourself.
16
17
 
17
18
  Confirming changes — this part matters:
18
19
 
19
20
  - Every tool that would change a booking stages the change instead of making it. Nothing has happened until `confirm_action` runs.
21
+ - **Staging is a QUOTE, not a change, so it needs no permission.** `update_ticket`, `book_hotel`, `book_car_rental` and `book_excursion` price the request and hand you the sentence to say; they move nothing. Call the staging tool as soon as you know what the caller wants — you are getting them a quote, not doing the thing — and never ask whether you may.
22
+ - **So the staging tool comes before the question, always.** "Shall I confirm?" is a question about a staged change, so the tool that stages it runs first and its answer is what you read back. Asking before staging is asking about something that does not exist, and the yes it earns has nothing to apply.
23
+ - **Never end a turn on "shall I confirm?" with nothing staged.** The staging call and that question belong to the same turn: stage, then ask, in that order, before you stop. If you have already asked and the caller says anything other than no, stage it now — asking a second time does not stage it either, and a caller who has to repeat themselves is a caller nobody is helping.
24
+ - **Never say a time, a fare or a total that did not come out of a tool result on this call**, and never say you are doing something — "I'll move your ticket", "I'm booking that" — before the tool that stages it has answered. If you need a number you have not got, call the desk's search first.
20
25
  - When a tool answers with `awaitingConfirmation`, read the change back in your own words and ask a yes-or-no question. Say the price.
21
26
  - If the caller says yes, call `confirm_action`. If they say no, or want different details, call `cancel_action`.
27
+ - **`confirm_action` only ever answers a read-back you have just given.** If no tool has answered `awaitingConfirmation` on this call, there is nothing staged, `confirm_action` will refuse, and the caller's request is still waiting to be done — reach for the tool that stages it instead. A caller who says "yes" or "go ahead" before anything is staged is agreeing to the request they already made, not to a read-back.
22
28
  - Never call `confirm_action` on your own initiative, and never claim something is booked before it has run.
23
29
 
24
30
  Voice manners: numbers spoken out ("six forty" not "$640.00"), flight numbers letter by letter when reading them back, and one question at a time.
@@ -1,16 +1,19 @@
1
1
  import { z } from "zod";
2
- import { stageAction, tripSlot } from "../shared.ts";
2
+ import { requireDesk, stageAction, tripSlot } from "../shared.ts";
3
3
 
4
4
  /** SENSITIVE — their `book_car_rental`, staged rather than applied. */
5
5
  export default tripSlot.updateTool({
6
6
  description:
7
- "Reserve a rental car. This does NOT reserve anything yet it stages the " +
8
- "reservation so you can read the total back and hear a yes.",
7
+ "The CAR RENTAL DESK's booking tool: reserve a rental car. Only usable while the call " +
8
+ "is at that desk. This does NOT reserve anything yet it stages the reservation so " +
9
+ "you can read the total back and hear a yes.",
9
10
  inputSchema: z.object({
10
11
  carId: z.string().max(20).describe("The car id from search_car_rentals, e.g. 'C2'"),
11
12
  days: z.number().int().min(1).max(60).describe("How many days"),
12
13
  }),
13
14
  execute(args, trip, ctx) {
15
+ const offDesk = requireDesk(trip, "car_rental");
16
+ if (offDesk) return offDesk;
14
17
  return stageAction(ctx, trip, {
15
18
  kind: "book_car",
16
19
  carId: args.carId.toUpperCase(),
@@ -1,15 +1,18 @@
1
1
  import { z } from "zod";
2
- import { stageAction, tripSlot } from "../shared.ts";
2
+ import { requireDesk, stageAction, tripSlot } from "../shared.ts";
3
3
 
4
4
  /** SENSITIVE — their `book_excursion`, staged rather than applied. */
5
5
  export default tripSlot.updateTool({
6
6
  description:
7
- "Book an excursion. This does NOT book anything yet it stages the " +
8
- "booking so you can read it back and hear a yes.",
7
+ "The EXCURSIONS DESK's booking tool: book an excursion. Only usable while the call is " +
8
+ "at that desk. This does NOT book anything yet — it stages the booking so you can read " +
9
+ "it back and hear a yes.",
9
10
  inputSchema: z.object({
10
11
  excursionId: z.string().max(20).describe("The excursion id from search_excursions, e.g. 'E2'"),
11
12
  }),
12
13
  execute(args, trip, ctx) {
14
+ const offDesk = requireDesk(trip, "excursion");
15
+ if (offDesk) return offDesk;
13
16
  return stageAction(ctx, trip, {
14
17
  kind: "book_excursion",
15
18
  excursionId: args.excursionId.toUpperCase(),
@@ -1,16 +1,19 @@
1
1
  import { z } from "zod";
2
- import { stageAction, tripSlot } from "../shared.ts";
2
+ import { requireDesk, stageAction, tripSlot } from "../shared.ts";
3
3
 
4
4
  /** SENSITIVE — their `book_hotel`, staged rather than applied. */
5
5
  export default tripSlot.updateTool({
6
6
  description:
7
- "Hold a hotel room. This does NOT book anything yet it stages the " +
8
- "booking so you can read the total back and hear a yes.",
7
+ "The HOTEL DESK's booking tool: hold a hotel room. Only usable while the call is at " +
8
+ "that desk. This does NOT book anything yet — it stages the booking so you can read " +
9
+ "the total back and hear a yes.",
9
10
  inputSchema: z.object({
10
11
  hotelId: z.string().max(20).describe("The hotel id from search_hotels, e.g. 'H1'"),
11
12
  nights: z.number().int().min(1).max(30).describe("How many nights"),
12
13
  }),
13
14
  execute(args, trip, ctx) {
15
+ const offDesk = requireDesk(trip, "hotel");
16
+ if (offDesk) return offDesk;
14
17
  return stageAction(ctx, trip, {
15
18
  kind: "book_hotel",
16
19
  hotelId: args.hotelId.toUpperCase(),
@@ -1,4 +1,4 @@
1
- import { stageAction, tripSlot } from "../shared.ts";
1
+ import { requireDesk, stageAction, tripSlot } from "../shared.ts";
2
2
 
3
3
  /**
4
4
  * SENSITIVE — their `cancel_ticket`. The most destructive thing on the call and
@@ -7,9 +7,12 @@ import { stageAction, tripSlot } from "../shared.ts";
7
7
  */
8
8
  export default tripSlot.updateTool({
9
9
  description:
10
- "Cancel the caller's ticket outright. This does NOT cancel anything yet — " +
11
- "it stages the cancellation so you can read it back and hear a yes.",
10
+ "The FLIGHT DESK's cancellation tool: cancel the caller's ticket outright. Only usable " +
11
+ "while the call is at that desk. This does NOT cancel anything yet — it stages the " +
12
+ "cancellation so you can read it back and hear a yes.",
12
13
  execute(_args, trip, ctx) {
14
+ const offDesk = requireDesk(trip, "flight");
15
+ if (offDesk) return offDesk;
13
16
  if (!trip.ticket) return { error: "There is no ticket to cancel." };
14
17
  return stageAction(ctx, trip, { kind: "cancel_ticket" });
15
18
  },
@@ -1,15 +1,19 @@
1
- import { tool } from "@alexkroman1/aai";
2
1
  import { z } from "zod";
3
- import { CAR_RENTALS, formatPrice } from "../shared.ts";
2
+ import { CAR_RENTALS, formatPrice, requireDesk, tripSlot } from "../shared.ts";
4
3
 
5
4
  /** Their `search_car_rentals`, by city and tier. */
6
- export default tool({
7
- description: "Search rental cars by city, and optionally by tier (compact, midsize, suv).",
5
+ export default tripSlot.tool({
6
+ description:
7
+ "The CAR RENTAL DESK's search: cars by city, and optionally by tier (compact, midsize, " +
8
+ "suv). Only usable while the call is at that desk — from anywhere else it refuses, so " +
9
+ "call to_car_rental_assistant first.",
8
10
  inputSchema: z.object({
9
11
  city: z.string().max(80).describe("City to search, e.g. 'Boston'"),
10
12
  tier: z.string().max(40).describe("compact, midsize or suv").optional(),
11
13
  }),
12
- execute(args) {
14
+ execute(args, trip) {
15
+ const offDesk = requireDesk(trip, "car_rental");
16
+ if (offDesk) return offDesk;
13
17
  const city = args.city.trim().toLowerCase();
14
18
  const tier = args.tier?.trim().toLowerCase();
15
19
  const cars = CAR_RENTALS.filter(
@@ -1,11 +1,13 @@
1
- import { tool } from "@alexkroman1/aai";
2
1
  import { z } from "zod";
3
- import { EXCURSIONS, formatPrice } from "../shared.ts";
2
+ import { EXCURSIONS, formatPrice, requireDesk, tripSlot } from "../shared.ts";
4
3
 
5
4
  /** Their `search_trip_recommendations` — city plus a loose keyword, matched
6
5
  * against the name and the kind so "boat" and "sail" both land. */
7
- export default tool({
8
- description: "Find things to do in a city. The keyword is optional and matched loosely.",
6
+ export default tripSlot.tool({
7
+ description:
8
+ "The EXCURSIONS DESK's search: things to do in a city, with an optional keyword matched " +
9
+ "loosely. Only usable while the call is at that desk — from anywhere else it refuses, so " +
10
+ "call to_excursion_assistant first.",
9
11
  inputSchema: z.object({
10
12
  city: z.string().max(80).describe("City to search, e.g. 'Boston'"),
11
13
  keyword: z
@@ -14,7 +16,9 @@ export default tool({
14
16
  .describe("What they enjoy, e.g. 'food', 'boat', 'history'")
15
17
  .optional(),
16
18
  }),
17
- execute(args) {
19
+ execute(args, trip) {
20
+ const offDesk = requireDesk(trip, "excursion");
21
+ if (offDesk) return offDesk;
18
22
  const city = args.city.trim().toLowerCase();
19
23
  const keyword = args.keyword?.trim().toLowerCase();
20
24
  const inCity = EXCURSIONS.filter((e) => e.city.toLowerCase().includes(city));