@alexkroman1/aai-cli 6.11.0 → 8.0.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 (234) hide show
  1. package/README.md +1 -0
  2. package/dist/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
  3. package/dist/{_bundler-C2BDwU7r.mjs → _bundler-CDuYl5Gb.mjs} +2 -2
  4. package/dist/_dev-env.d.ts +22 -2
  5. package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-Me06wgeF.mjs} +38 -47
  6. package/dist/_dev-server.d.ts +0 -17
  7. package/dist/{_init-C57bWAQ8.mjs → _init-BhEfQ6Yi.mjs} +3 -2
  8. package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
  9. package/dist/_preflight.d.ts +1 -1
  10. package/dist/_resource-commands.d.ts +23 -0
  11. package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-z-USxPtL.mjs} +16 -12
  12. package/dist/_slug-api.d.ts +5 -2
  13. package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
  14. package/dist/{_templates-Dxb_P2Wz.mjs → _templates-4WcKOjS5.mjs} +5 -6
  15. package/dist/_templates.d.ts +3 -4
  16. package/dist/_workflow-scan.d.ts +108 -0
  17. package/dist/{build-Dfza2pRI.mjs → build-BBxf8vom.mjs} +6 -3
  18. package/dist/cli.mjs +346 -349
  19. package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
  20. package/dist/client-bundler.mjs +1 -1
  21. package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
  22. package/dist/{deploy-B8lubiRT.mjs → deploy-DRC8jWr8.mjs} +5 -5
  23. package/dist/{dev-Uq5ujP8a.mjs → dev-BvcctCn2.mjs} +1 -1
  24. package/dist/eval-DfQ8cm5p.mjs +56 -0
  25. package/dist/eval.d.ts +43 -0
  26. package/dist/{init-l6xfU4xX.mjs → init-CUo87gcI.mjs} +3 -3
  27. package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
  28. package/dist/{logs-BWVGSjLR.mjs → logs-CQ2T46dr.mjs} +2 -2
  29. package/dist/project-config.mjs +1 -1
  30. package/dist/scaffold/CLAUDE.md +442 -80
  31. package/dist/scaffold/package.json +6 -3
  32. package/dist/scaffold/server.mjs +11 -4
  33. package/dist/scaffold/tsconfig.json +0 -1
  34. package/dist/{secret-BuMuFR4B.mjs → secret-Ct34kdgU.mjs} +2 -2
  35. package/dist/{storage-DzRZ-eCw.mjs → storage-i_79ZwAR.mjs} +2 -2
  36. package/dist/{studio-BCNUpDgP.mjs → studio-CzTzPqe1.mjs} +4 -4
  37. package/dist/templates/briefing-desk/agent.test.ts +350 -0
  38. package/dist/templates/briefing-desk/agent.ts +30 -0
  39. package/dist/templates/briefing-desk/shared.ts +200 -0
  40. package/dist/templates/briefing-desk/system-prompt.md +39 -0
  41. package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
  42. package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
  43. package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
  44. package/dist/templates/call-audit/agent.eval.test.ts +474 -0
  45. package/dist/templates/call-audit/agent.test.ts +57 -204
  46. package/dist/templates/call-audit/agent.ts +32 -19
  47. package/dist/templates/call-audit/client.tsx +17 -54
  48. package/dist/templates/call-audit/workflows/audit.ts +11 -18
  49. package/dist/templates/call-audit/workflows/ingest.ts +106 -114
  50. package/dist/templates/call-audit/workflows/media.ts +2 -12
  51. package/dist/templates/call-audit/workflows/summarize.ts +47 -52
  52. package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
  53. package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
  54. package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
  55. package/dist/templates/dispatch-center/agent.test.ts +149 -25
  56. package/dist/templates/dispatch-center/client.tsx +239 -129
  57. package/dist/templates/dispatch-center/shared.ts +99 -1
  58. package/dist/templates/dispatch-center/system-prompt.md +3 -1
  59. package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
  60. package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
  61. package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
  62. package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
  63. package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
  64. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
  65. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
  66. package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
  67. package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
  68. package/dist/templates/embedded-assets/agent.test.ts +17 -14
  69. package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
  70. package/dist/templates/health-assistant/agent.test.ts +22 -4
  71. package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
  72. package/dist/templates/infocom-adventure/agent.test.ts +53 -24
  73. package/dist/templates/infocom-adventure/agent.ts +21 -0
  74. package/dist/templates/infocom-adventure/client.tsx +246 -164
  75. package/dist/templates/infocom-adventure/shared.ts +20 -9
  76. package/dist/templates/infocom-adventure/system-prompt.md +1 -1
  77. package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
  78. package/dist/templates/link-digest/agent.eval.test.ts +195 -0
  79. package/dist/templates/link-digest/agent.test.ts +24 -19
  80. package/dist/templates/link-digest/client.tsx +47 -61
  81. package/dist/templates/link-digest/workflows/digest.ts +19 -31
  82. package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
  83. package/dist/templates/night-owl/agent.eval.test.ts +272 -0
  84. package/dist/templates/night-owl/agent.test.ts +70 -19
  85. package/dist/templates/night-owl/agent.ts +5 -0
  86. package/dist/templates/night-owl/client.tsx +56 -56
  87. package/dist/templates/night-owl/shared.ts +24 -0
  88. package/dist/templates/night-owl/tools/recommend.ts +18 -6
  89. package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
  90. package/dist/templates/personal-finance/system-prompt.md +4 -0
  91. package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
  92. package/dist/templates/pipeline-simple/agent.ts +2 -2
  93. package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
  94. package/dist/templates/pizza-ordering/agent.test.ts +42 -27
  95. package/dist/templates/pizza-ordering/client.tsx +9 -26
  96. package/dist/templates/pizza-ordering/shared.ts +20 -1
  97. package/dist/templates/pizza-ordering/system-prompt.md +6 -0
  98. package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
  99. package/dist/templates/plan-and-execute/agent.test.ts +99 -58
  100. package/dist/templates/plan-and-execute/agent.ts +1 -1
  101. package/dist/templates/plan-and-execute/client.tsx +12 -15
  102. package/dist/templates/plan-and-execute/shared.ts +71 -2
  103. package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
  104. package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
  105. package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
  106. package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
  107. package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
  108. package/dist/templates/podcast-digest/agent.test.ts +717 -0
  109. package/dist/templates/podcast-digest/agent.ts +139 -0
  110. package/dist/templates/podcast-digest/client.tsx +154 -0
  111. package/dist/templates/podcast-digest/workflows/digest.ts +444 -0
  112. package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
  113. package/dist/templates/podcast-digest/workflows/slack.ts +88 -0
  114. package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
  115. package/dist/templates/recap-workflow/agent.test.ts +97 -94
  116. package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
  117. package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
  118. package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
  119. package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
  120. package/dist/templates/redline/agent.eval.test.ts +209 -0
  121. package/dist/templates/redline/agent.test.ts +17 -15
  122. package/dist/templates/redline/client.tsx +12 -12
  123. package/dist/templates/redline/workflows/redline.ts +19 -31
  124. package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
  125. package/dist/templates/research-workflow/agent.test.ts +60 -59
  126. package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
  127. package/dist/templates/research-workflow/tools/research_status.ts +9 -11
  128. package/dist/templates/research-workflow/workflows/research.ts +44 -61
  129. package/dist/templates/retail/address.ts +87 -1
  130. package/dist/templates/retail/agent.eval.test.ts +314 -0
  131. package/dist/templates/retail/agent.test.ts +195 -112
  132. package/dist/templates/retail/agent.ts +35 -1
  133. package/dist/templates/retail/cancel.ts +116 -0
  134. package/dist/templates/retail/client.tsx +265 -117
  135. package/dist/templates/retail/payment.ts +107 -0
  136. package/dist/templates/retail/pending.test.ts +196 -0
  137. package/dist/templates/retail/pending.ts +137 -0
  138. package/dist/templates/retail/registry.test.ts +150 -7
  139. package/dist/templates/retail/returns.ts +102 -0
  140. package/dist/templates/retail/shared.test.ts +42 -0
  141. package/dist/templates/retail/shared.ts +35 -0
  142. package/dist/templates/retail/store.test.ts +84 -15
  143. package/dist/templates/retail/store.ts +231 -48
  144. package/dist/templates/retail/swap.test.ts +4 -4
  145. package/dist/templates/retail/swap.ts +248 -15
  146. package/dist/templates/retail/system-prompt.md +69 -28
  147. package/dist/templates/retail/tools/cancel_change.ts +33 -0
  148. package/dist/templates/retail/tools/cancel_pending_order.ts +16 -60
  149. package/dist/templates/retail/tools/confirm_change.ts +42 -0
  150. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +22 -67
  151. package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
  152. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
  153. package/dist/templates/retail/tools/get_item_details.ts +3 -4
  154. package/dist/templates/retail/tools/get_order_details.ts +3 -4
  155. package/dist/templates/retail/tools/get_product_details.ts +3 -4
  156. package/dist/templates/retail/tools/get_user_details.ts +2 -2
  157. package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
  158. package/dist/templates/retail/tools/modify_pending_order_address.ts +13 -25
  159. package/dist/templates/retail/tools/modify_pending_order_items.ts +23 -62
  160. package/dist/templates/retail/tools/modify_pending_order_payment.ts +16 -78
  161. package/dist/templates/retail/tools/modify_user_address.ts +13 -16
  162. package/dist/templates/retail/tools/return_delivered_order_items.ts +17 -72
  163. package/dist/templates/retail/tools/transfer_to_human_agents.ts +12 -6
  164. package/dist/templates/simple/agent.eval.test.ts +54 -0
  165. package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
  166. package/dist/templates/solo-rpg/agent.test.ts +227 -48
  167. package/dist/templates/solo-rpg/client.tsx +38 -37
  168. package/dist/templates/solo-rpg/shared.ts +145 -19
  169. package/dist/templates/solo-rpg/system-prompt.md +3 -2
  170. package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
  171. package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
  172. package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
  173. package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
  174. package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
  175. package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
  176. package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
  177. package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
  178. package/dist/templates/spoken-summary/agent.test.ts +68 -97
  179. package/dist/templates/spoken-summary/agent.ts +30 -17
  180. package/dist/templates/spoken-summary/client.tsx +10 -50
  181. package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
  182. package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
  183. package/dist/templates/support-line/agent.eval.test.ts +273 -0
  184. package/dist/templates/support-line/agent.test.ts +11 -16
  185. package/dist/templates/support-line/agent.ts +1 -1
  186. package/dist/templates/support-line/client.tsx +9 -9
  187. package/dist/templates/support-line/nodes.ts +100 -0
  188. package/dist/templates/support-line/procedure.ts +407 -0
  189. package/dist/templates/support-line/system-prompt.md +2 -1
  190. package/dist/templates/support-line/tools/answer_question.ts +66 -4
  191. package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
  192. package/dist/templates/transcription-workflow/agent.test.ts +94 -194
  193. package/dist/templates/transcription-workflow/agent.ts +1 -1
  194. package/dist/templates/transcription-workflow/client.tsx +17 -42
  195. package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
  196. package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
  197. package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
  198. package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
  199. package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
  200. package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
  201. package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
  202. package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
  203. package/dist/templates/travel-concierge/agent.test.ts +174 -43
  204. package/dist/templates/travel-concierge/agent.ts +18 -9
  205. package/dist/templates/travel-concierge/client.tsx +11 -23
  206. package/dist/templates/travel-concierge/routing.ts +34 -15
  207. package/dist/templates/travel-concierge/shared.ts +180 -12
  208. package/dist/templates/travel-concierge/system-prompt.md +7 -1
  209. package/dist/templates/travel-concierge/tools/book_car_rental.ts +8 -5
  210. package/dist/templates/travel-concierge/tools/book_excursion.ts +8 -5
  211. package/dist/templates/travel-concierge/tools/book_hotel.ts +8 -5
  212. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +8 -5
  213. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
  214. package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
  215. package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
  216. package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
  217. package/dist/templates/travel-concierge/tools/update_ticket.ts +8 -5
  218. package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
  219. package/dist/templates/web-researcher/system-prompt.md +15 -4
  220. package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
  221. package/dist/test.d.ts +29 -5
  222. package/dist/{worker-bundler-CGD4r8Kc.mjs → worker-bundler-CCVEDjm1.mjs} +241 -3
  223. package/dist/worker-bundler.mjs +1 -1
  224. package/dist/{workflow-CFpxOFfQ.mjs → workflow-BflATMmU.mjs} +47 -12
  225. package/dist/workflow-bundler.d.ts +6 -1
  226. package/dist/workflow.d.ts +1 -1
  227. package/package.json +5 -4
  228. package/dist/eject-31gjtaHF.mjs +0 -116
  229. package/dist/eject.d.ts +0 -60
  230. package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
  231. package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
  232. package/dist/templates/support-line/graph.ts +0 -224
  233. package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
  234. /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
@@ -1,10 +1,11 @@
1
1
  /// <reference types="vite/client" />
2
2
 
3
- import type { GenerateFn, ToolContext } from "@alexkroman1/aai";
3
+ import type { ToolContext } from "@alexkroman1/aai";
4
4
  import {
5
5
  createToolContext,
6
- runTool,
6
+ okPosition,
7
7
  stubGenerate,
8
+ toolRunner,
8
9
  withDiscoveredTools,
9
10
  } from "@alexkroman1/aai/testing";
10
11
  import { describe, expect, test } from "vitest";
@@ -23,10 +24,10 @@ const agentDef = withDiscoveredTools(
23
24
  import.meta.glob("./tools/*.ts", { eager: true }),
24
25
  );
25
26
 
26
- import { executeStep, MAX_STEP_SEARCHES, normalizeAct, planNode } from "./graph.ts";
27
+ import { executeStep, MAX_STEP_SEARCHES, normalizeAct, planNode } from "./procedure.ts";
27
28
  import { EXECUTOR_SYSTEM, PLANNER_SYSTEM, REPLANNER_SYSTEM, REVISE_SYSTEM } from "./prompts.ts";
28
29
  import type { SearchFn } from "./shared.ts";
29
- import { MAX_PAST_STEPS, planProjection, planSlot, planView } from "./shared.ts";
30
+ import { MAX_PAST_STEPS, planFlow, planProjection, planSlot, planView } from "./shared.ts";
30
31
 
31
32
  // ─── A scripted model ────────────────────────────────────────────────────────
32
33
  //
@@ -80,15 +81,11 @@ function fakeSearch(hits: Record<string, { title: string; url: string }[]>): {
80
81
  return { search, queries };
81
82
  }
82
83
 
83
- function makeCtx(generate: GenerateFn, sessionId?: string) {
84
- return createToolContext({ generate, ...(sessionId ? { sessionId } : {}) });
85
- }
86
-
87
- /** A tool by the name the model calls it by, bound to this agent. The lookup
88
- * and its "no such tool" message are `runTool`'s (`@alexkroman1/aai/testing`);
89
- * what is local is only which agent they run against. */
90
- const run = (name: string, args: Record<string, unknown>, ctx: ToolContext) =>
91
- runTool(agentDef, name, args, ctx);
84
+ /** A tool by the name the model calls it by, bound to this agent. The lookup,
85
+ * its "no such tool" message and the args-or-context shape are all
86
+ * `toolRunner`'s (`@alexkroman1/aai/testing`); what is local is only which
87
+ * agent it runs against. */
88
+ const run = toolRunner(agentDef);
92
89
 
93
90
  function stateOf(ctx: ToolContext) {
94
91
  return planSlot.get(ctx);
@@ -190,7 +187,7 @@ describe("normalizeAct", () => {
190
187
  describe("start_plan", () => {
191
188
  test("stores the objective and the steps", async () => {
192
189
  const { generate } = scriptedModel({ steps: ["Check prices", "Compare hotels", "Book"] });
193
- const ctx = makeCtx(generate);
190
+ const ctx = createToolContext({ generate });
194
191
  const result = (await run("start_plan", { objective: "a weekend in Lisbon" }, ctx)) as {
195
192
  steps: string[];
196
193
  };
@@ -214,12 +211,20 @@ describe("start_plan", () => {
214
211
  });
215
212
 
216
213
  describe("work_next_step", () => {
217
- test("refuses before there is a plan", async () => {
214
+ test("is refused before there is a plan, by the flow rather than by the body", async () => {
218
215
  const { generate } = scriptedModel();
219
- const ctx = makeCtx(generate);
220
- expect(await run("work_next_step", {}, ctx)).toEqual({
221
- error: "There is no plan yetuse start_plan first.",
216
+ const ctx = createToolContext({ generate });
217
+ // The gate is `when: "working"`, so the refusal names the state the call is
218
+ // actually in and quotes that state's instruction which is what the model
219
+ // needs in order to do the right thing on its own next turn.
220
+ expect(await run("work_next_step", ctx)).toMatchObject({
221
+ error: expect.stringContaining('this conversation is at "idle"'),
222
222
  });
223
+ expect(await run("work_next_step", ctx)).toMatchObject({
224
+ error: expect.stringContaining("use start_plan"),
225
+ });
226
+ // Refused means the body did not run: nothing was claimed off the plan.
227
+ expect(stateOf(ctx).pastSteps).toEqual([]);
223
228
  });
224
229
 
225
230
  test("does the head step, records it, and takes the replanner's next plan", async () => {
@@ -228,19 +233,21 @@ describe("work_next_step", () => {
228
233
  turns: [{ answer: "Fares are about 180 return." }],
229
234
  acts: [{ kind: "plan", steps: ["Compare hotels"] }],
230
235
  });
231
- const ctx = makeCtx(generate);
236
+ const ctx = createToolContext({ generate });
232
237
  await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
233
238
 
234
- const first = (await run("work_next_step", {}, ctx)) as {
235
- done: boolean;
239
+ const first = okPosition<{
240
+ finished: boolean;
236
241
  step: string;
237
242
  result: string;
238
243
  remaining: string[];
239
- };
240
- expect(first.done).toBe(false);
241
- expect(first.step).toBe("Check prices");
242
- expect(first.result).toContain("180");
243
- expect(first.remaining).toEqual(["Compare hotels"]);
244
+ }>(await run("work_next_step", ctx));
245
+ expect(first.result.finished).toBe(false);
246
+ expect(first.result.step).toBe("Check prices");
247
+ expect(first.result.result).toContain("180");
248
+ expect(first.result.remaining).toEqual(["Compare hotels"]);
249
+ // Still working: nothing was concluded, so the flow has not moved on.
250
+ expect(first.state).toBe("working");
244
251
 
245
252
  const state = stateOf(ctx);
246
253
  expect(state.pastSteps).toHaveLength(1);
@@ -254,19 +261,29 @@ describe("work_next_step", () => {
254
261
  turns: [{ answer: "Fares are about 180 return." }],
255
262
  acts: [{ kind: "respond", response: "Go in May — flights are about 180 return." }],
256
263
  });
257
- const ctx = makeCtx(generate);
264
+ const ctx = createToolContext({ generate });
258
265
  await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
259
- const result = (await run("work_next_step", {}, ctx)) as { done: boolean; response: string };
266
+ const answered = okPosition<{ finished: boolean; response: string }>(
267
+ await run("work_next_step", ctx),
268
+ );
269
+
270
+ expect(answered.result.finished).toBe(true);
271
+ expect(answered.result.response).toContain("180");
272
+ // A response is what sends ANSWERED — the flow and the data agree because
273
+ // one tool call moved both.
274
+ expect(answered.state).toBe("answered");
275
+ expect(planFlow.position(ctx).state).toBe("answered");
260
276
 
261
- expect(result.done).toBe(true);
262
- expect(result.response).toContain("180");
263
277
  const state = stateOf(ctx);
264
278
  // The replanner deciding early is a good outcome, so the pending step goes.
265
279
  expect(state.plan).toEqual([]);
266
- expect(state.response).toBe(result.response);
280
+ expect(state.response).toBe(answered.result.response);
267
281
 
268
- // A finished plan is not worked again.
269
- expect(await run("work_next_step", {}, ctx)).toMatchObject({ done: true });
282
+ // A finished plan is not worked again, and now it CANNOT be: the tool is
283
+ // gated out of `answered` rather than returning a done-shaped result.
284
+ expect(await run("work_next_step", ctx)).toMatchObject({
285
+ error: expect.stringContaining('this conversation is at "answered"'),
286
+ });
270
287
  expect(stateOf(ctx).pastSteps).toHaveLength(1);
271
288
  });
272
289
 
@@ -283,9 +300,9 @@ describe("work_next_step", () => {
283
300
  steps: Array.from({ length: total - i - 1 }, (_, j) => `Step ${i + j + 2}`),
284
301
  })),
285
302
  });
286
- const ctx = makeCtx(generate);
303
+ const ctx = createToolContext({ generate });
287
304
  await run("start_plan", { objective: "a long one" }, ctx);
288
- for (let i = 0; i < total; i++) await run("work_next_step", {}, ctx);
305
+ for (let i = 0; i < total; i++) await run("work_next_step", ctx);
289
306
 
290
307
  const state = stateOf(ctx);
291
308
  expect(state.pastSteps).toHaveLength(MAX_PAST_STEPS);
@@ -302,13 +319,15 @@ describe("work_next_step", () => {
302
319
  // session ids would prove nothing extra, and `sessionSlot` could stop
303
320
  // keying by session with this still passing.
304
321
  const { generate } = scriptedModel({ steps: ["Only step"] });
305
- const first = makeCtx(generate);
306
- const second = makeCtx(generate);
322
+ const first = createToolContext({ generate });
323
+ const second = createToolContext({ generate });
307
324
 
308
325
  await run("start_plan", { objective: "mine" }, first);
309
326
  expect(stateOf(second).objective).toBeNull();
310
- expect(await run("work_next_step", {}, second)).toMatchObject({
311
- error: "There is no plan yet — use start_plan first.",
327
+ // The FLOW is per-session too, not just the plan — `second` is still idle.
328
+ expect(planFlow.position(second).state).toBe("idle");
329
+ expect(await run("work_next_step", second)).toMatchObject({
330
+ error: expect.stringContaining('this conversation is at "idle"'),
312
331
  });
313
332
  });
314
333
  });
@@ -323,17 +342,19 @@ describe("revise_plan", () => {
323
342
  { kind: "plan", steps: ["Check Porto prices"] },
324
343
  ],
325
344
  });
326
- const ctx = makeCtx(generate);
345
+ const ctx = createToolContext({ generate });
327
346
  await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
328
- await run("work_next_step", {}, ctx);
347
+ await run("work_next_step", ctx);
329
348
  expect(stateOf(ctx).response).not.toBeNull();
330
349
 
331
- const revised = (await run("revise_plan", { instruction: "make it Porto instead" }, ctx)) as {
332
- done: boolean;
333
- remaining: string[];
334
- };
335
- expect(revised.done).toBe(false);
336
- expect(revised.remaining).toEqual(["Check Porto prices"]);
350
+ const revised = okPosition<{ finished: boolean; remaining: string[] }>(
351
+ await run("revise_plan", { instruction: "make it Porto instead" }, ctx),
352
+ );
353
+ expect(revised.result.finished).toBe(false);
354
+ expect(revised.result.remaining).toEqual(["Check Porto prices"]);
355
+ // REOPENED: the one transition that goes backwards, and the reason
356
+ // `revise_plan` is legal in `answered` at all.
357
+ expect(revised.state).toBe("working");
337
358
 
338
359
  const state = stateOf(ctx);
339
360
  // The old answer is no longer the answer — the caller moved the goalposts.
@@ -346,13 +367,27 @@ describe("revise_plan", () => {
346
367
  expect(calls.at(-1)?.prompt).toContain("make it Porto instead");
347
368
  });
348
369
 
349
- test("refuses before there is a plan", async () => {
370
+ test("is refused before there is a plan", async () => {
350
371
  const { generate } = scriptedModel();
351
- const ctx = makeCtx(generate);
352
- expect(await run("revise_plan", { instruction: "change it" }, ctx)).toEqual({
353
- error: "There is no plan to revise — use start_plan first.",
372
+ const ctx = createToolContext({ generate });
373
+ expect(await run("revise_plan", { instruction: "change it" }, ctx)).toMatchObject({
374
+ error: expect.stringContaining('this conversation is at "idle"'),
354
375
  });
355
376
  });
377
+
378
+ test("a revision that answers outright lands in `answered`", async () => {
379
+ const { generate } = scriptedModel({
380
+ steps: ["Check Lisbon prices"],
381
+ acts: [{ kind: "respond", response: "Nothing to do — you already booked it." }],
382
+ });
383
+ const ctx = createToolContext({ generate });
384
+ await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
385
+ const revised = okPosition<{ finished: boolean }>(
386
+ await run("revise_plan", { instruction: "never mind, it is booked" }, ctx),
387
+ );
388
+ expect(revised.result.finished).toBe(true);
389
+ expect(revised.state).toBe("answered");
390
+ });
356
391
  });
357
392
 
358
393
  describe("plan_status", () => {
@@ -362,14 +397,20 @@ describe("plan_status", () => {
362
397
  turns: [{ answer: "About 180 return." }],
363
398
  acts: [{ kind: "plan", steps: ["Book"] }],
364
399
  });
365
- const ctx = makeCtx(generate);
366
- expect(await run("plan_status", {}, ctx)).toEqual({
367
- message: "No plan yet. Ask what they want to get done.",
400
+ const ctx = createToolContext({ generate });
401
+ // Legal in every state, so it READS the position rather than being gated on
402
+ // one — and "no plan yet" is the flow's own answer, not a third derivation
403
+ // of `!objective`.
404
+ expect(await run("plan_status", ctx)).toMatchObject({
405
+ stage: "idle",
406
+ next: expect.stringContaining("start_plan"),
407
+ objective: null,
368
408
  });
369
409
 
370
410
  await run("start_plan", { objective: "a weekend in Lisbon" }, ctx);
371
- await run("work_next_step", {}, ctx);
372
- expect(await run("plan_status", {}, ctx)).toMatchObject({
411
+ await run("work_next_step", ctx);
412
+ expect(await run("plan_status", ctx)).toMatchObject({
413
+ stage: "working",
373
414
  objective: "a weekend in Lisbon",
374
415
  remaining: ["Book"],
375
416
  response: null,
@@ -399,11 +440,11 @@ describe("planView projection", () => {
399
440
  turns: [{ answer: "Done one." }],
400
441
  acts: [{ kind: "plan", steps: ["Two", "Three"] }],
401
442
  });
402
- const ctx = makeCtx(generate);
443
+ const ctx = createToolContext({ generate });
403
444
  await run("start_plan", { objective: "three things" }, ctx);
404
445
  expect(planView(stateOf(ctx)).progress).toBe(0);
405
446
 
406
- await run("work_next_step", {}, ctx);
447
+ await run("work_next_step", ctx);
407
448
  const view = planView(stateOf(ctx));
408
449
  expect(view.progress).toBeCloseTo(1 / 3, 5);
409
450
  expect(view.done[0]?.step).toBe("One");
@@ -3,7 +3,7 @@ import { planProjection } from "./shared.ts";
3
3
 
4
4
  /**
5
5
  * A planning desk you can phone: plan-and-execute, with the caller in the loop.
6
- * `prompts.ts` carries the attribution, `graph.ts` the three nodes, `shared.ts`
6
+ * `prompts.ts` carries the attribution, `procedure.ts` the three nodes, `shared.ts`
7
7
  * the state (which is their `PlanExecute`, field for field).
8
8
  *
9
9
  * **The steps do real work.** `work_next_step` runs a bounded search/answer loop
@@ -1,5 +1,5 @@
1
1
  import "@alexkroman1/aai-ui/styles.css";
2
- import { AutoScroll, client, useAgentState, useTheme } from "@alexkroman1/aai-ui";
2
+ import { AutoScroll, client, useAgentState } from "@alexkroman1/aai-ui";
3
3
  import { planProjection } from "./shared.ts";
4
4
 
5
5
  /**
@@ -10,14 +10,13 @@ import { planProjection } from "./shared.ts";
10
10
  * out. The sidebar is where that lives; the call is where the decisions happen.
11
11
  */
12
12
  function PlanSidebar() {
13
- const theme = useTheme();
14
13
  const plan = useAgentState(planProjection);
15
14
 
16
15
  if (!plan.objective) {
17
16
  return (
18
17
  <div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
19
18
  <span className="text-4xl">🗂️</span>
20
- <p className="text-sm opacity-60" style={{ color: theme.text }}>
19
+ <p className="text-sm opacity-60 text-aai-text">
21
20
  Say what you want to get done and the plan appears here.
22
21
  </p>
23
22
  </div>
@@ -25,14 +24,16 @@ function PlanSidebar() {
25
24
  }
26
25
 
27
26
  return (
28
- <div className="flex h-full min-h-0 flex-col gap-4 p-4" style={{ color: theme.text }}>
27
+ <div className="flex h-full min-h-0 flex-col gap-4 p-4 text-aai-text">
29
28
  <div className="flex flex-col gap-2">
30
29
  <h3 className="text-sm font-bold uppercase tracking-wide opacity-60">Objective</h3>
31
30
  <p className="text-sm">{plan.objective}</p>
32
- <div className="h-1.5 w-full rounded-full" style={{ background: theme.surface }}>
31
+ <div className="h-1.5 w-full rounded-full bg-aai-surface">
32
+ {/* The WIDTH is the one thing here that is genuinely computed, so it
33
+ stays inline; the colour is a token. */}
33
34
  <div
34
- className="h-1.5 rounded-full transition-all"
35
- style={{ width: `${Math.round(plan.progress * 100)}%`, background: theme.primary }}
35
+ className="h-1.5 rounded-full transition-all bg-aai-primary"
36
+ style={{ width: `${Math.round(plan.progress * 100)}%` }}
36
37
  />
37
38
  </div>
38
39
  <p className="text-xs opacity-50">
@@ -45,9 +46,9 @@ function PlanSidebar() {
45
46
  contentClassName="flex flex-col gap-2 pr-1"
46
47
  >
47
48
  {plan.done.map((past) => (
48
- <div key={past.step} className="rounded-lg p-3" style={{ background: theme.surface }}>
49
+ <div key={past.step} className="rounded-lg p-3 bg-aai-surface">
49
50
  <p className="text-sm">
50
- <span style={{ color: theme.primary }}>✓</span> {past.step}
51
+ <span className="text-aai-primary">✓</span> {past.step}
51
52
  </p>
52
53
  <p className="mt-1 text-xs opacity-70">{past.result}</p>
53
54
  {past.searches.length > 0 && (
@@ -60,8 +61,7 @@ function PlanSidebar() {
60
61
  // a revision, so the position is part of the identity.
61
62
  <div
62
63
  key={`${index}-${step}`}
63
- className="rounded-lg p-3 text-sm opacity-60"
64
- style={{ border: `1px dashed ${theme.border}` }}
64
+ className="rounded-lg p-3 text-sm opacity-60 border border-dashed border-aai-border"
65
65
  >
66
66
  <span className="opacity-50">{plan.done.length + index + 1}.</span> {step}
67
67
  </div>
@@ -69,10 +69,7 @@ function PlanSidebar() {
69
69
  </AutoScroll>
70
70
 
71
71
  {plan.response && (
72
- <div
73
- className="rounded-lg p-3"
74
- style={{ background: theme.surface, border: `1px solid ${theme.primary}` }}
75
- >
72
+ <div className="rounded-lg p-3 bg-aai-surface border border-aai-primary">
76
73
  <p className="text-[11px] font-bold uppercase tracking-wide opacity-60">Answer</p>
77
74
  <p className="mt-1 text-sm">{plan.response}</p>
78
75
  </div>
@@ -13,9 +13,16 @@
13
13
  * a different template (`solo-rpg` has the save-slot version).
14
14
  */
15
15
 
16
- import { type DeepReadonly, pushCapped, sessionSlot } from "@alexkroman1/aai";
16
+ import {
17
+ type DeepReadonly,
18
+ type DialogPosition,
19
+ type DialogSpec,
20
+ dialog,
21
+ isToolFailure,
22
+ pushCapped,
23
+ sessionSlot,
24
+ } from "@alexkroman1/aai";
17
25
  import { webSearch } from "@alexkroman1/aai/tools";
18
- import { isToolFailure } from "@alexkroman1/aai/utils";
19
26
 
20
27
  /** One completed step — their `past_steps`, as a pair rather than a tuple. */
21
28
  export interface PastStep {
@@ -61,6 +68,68 @@ export function emptyPlan(): PlanState {
61
68
 
62
69
  export const planSlot = sessionSlot("plan", emptyPlan);
63
70
 
71
+ /**
72
+ * The plan's LIFECYCLE, as a declared machine rather than a guard per tool.
73
+ *
74
+ * Three of the four tools used to open with the same shape — `if
75
+ * (!plan.objective) return toolFailure("There is no plan yet …")`, and
76
+ * `work_next_step` carried a second one for the already-answered case. Those are
77
+ * not data checks, they are the question "where is this conversation", and
78
+ * getting one wrong is silent: the tool runs, the model reads a plausible
79
+ * result, and the caller is told about a plan that does not exist. A fifth tool
80
+ * would have had to remember both.
81
+ *
82
+ * `when` is that check now, and it is the SDK's rather than this template's — so
83
+ * the refusal names where the call actually is and quotes the instruction below,
84
+ * which is what lets the model recover on its own turn instead of apologizing.
85
+ *
86
+ * The states are the notebook's own, read off `PlanExecute`: no `input` yet,
87
+ * an `input` with steps left, and a `response`. `PLANNED` is accepted from all
88
+ * three because `start_plan` is always legal — a caller may re-plan from
89
+ * scratch at any point, which is the one transition that is not a progression.
90
+ */
91
+ const planSpec = {
92
+ initial: "idle",
93
+ states: {
94
+ idle: {
95
+ instruction:
96
+ "There is no plan yet. Find out what the caller wants to get done, then use start_plan.",
97
+ on: { PLANNED: "working" },
98
+ },
99
+ working: {
100
+ instruction:
101
+ "Work the plan one step at a time with work_next_step, reporting after each step.",
102
+ on: { ANSWERED: "answered", PLANNED: "working" },
103
+ },
104
+ answered: {
105
+ instruction:
106
+ "The plan is finished — give the caller the answer. Use revise_plan if they change their mind.",
107
+ on: { REOPENED: "working", PLANNED: "working" },
108
+ },
109
+ },
110
+ } as const satisfies DialogSpec;
111
+
112
+ /**
113
+ * The flow. Its own slot key, because a flow stores an actor snapshot and
114
+ * {@link planSlot} stores the plan — the position and the payload are two
115
+ * things, and one tool call moves both.
116
+ */
117
+ export const planFlow = dialog("planFlow", planSpec);
118
+
119
+ /**
120
+ * How the stage reads to a caller, from the flow's own position.
121
+ *
122
+ * A helper over a {@link DialogPosition} rather than over {@link PlanState}, which
123
+ * is the point: "where is this call" is the machine's answer, and deriving it a
124
+ * second time from the plan's fields is what the three removed guards were
125
+ * doing. `plan_status` reads this, and so would any prompt that wants to say it
126
+ * aloud.
127
+ */
128
+ export function stageLabel(at: DialogPosition): string {
129
+ if (at.state === "idle") return "no plan yet";
130
+ return at.state === "answered" ? "finished" : "in progress";
131
+ }
132
+
64
133
  export function noteRevision(state: PlanState, entry: string): void {
65
134
  pushCapped(state.revisions, entry, MAX_REVISIONS);
66
135
  }
@@ -1,16 +1,28 @@
1
- import { planSlot } from "../shared.ts";
1
+ import { tool } from "@alexkroman1/aai";
2
+ import { planFlow, planSlot, stageLabel } from "../shared.ts";
2
3
 
3
4
  /**
4
- * Where the plan is. Read-only, so plain `tool` semantics — a synchronous
5
- * read cannot interleave with anything.
5
+ * Where the plan is. Read-only, so plain `tool` semantics — a synchronous read
6
+ * cannot interleave with anything.
7
+ *
8
+ * **Legal in every state, which is why it is not a `planFlow.tool`**, and it is
9
+ * the one tool that reports the flow's own POSITION: `stage` and `next` come
10
+ * from the machine rather than from a second reading of the plan's fields, so
11
+ * "no plan yet" is the same fact here as the refusal `work_next_step` would
12
+ * give. It used to derive that from `!plan.objective`, which was a third copy of
13
+ * the same question.
6
14
  */
7
- export default planSlot.tool({
15
+ export default tool({
8
16
  description:
9
17
  "Say where the plan has got to: what is done, what is left, and the answer " +
10
18
  "if there is one. Use it when the caller asks, or to pick a call back up.",
11
- execute(_args, plan) {
12
- if (!plan.objective) return { message: "No plan yet. Ask what they want to get done." };
19
+ execute(_args, ctx) {
20
+ const at = planFlow.position(ctx);
21
+ const plan = planSlot.get(ctx);
13
22
  return {
23
+ stage: at.state,
24
+ reads: stageLabel(at),
25
+ next: at.instruction,
14
26
  objective: plan.objective,
15
27
  done: plan.pastSteps.map((past) => ({ step: past.step, result: past.result })),
16
28
  remaining: plan.plan,
@@ -1,8 +1,8 @@
1
- import { errorMessage, tool, toolFailure } from "@alexkroman1/aai";
1
+ import { errorMessage, toolFailure } from "@alexkroman1/aai";
2
2
  import { z } from "zod";
3
- import { replanNode } from "../graph.ts";
3
+ import { replanNode } from "../procedure.ts";
4
4
  import { REVISE_SYSTEM } from "../prompts.ts";
5
- import { noteRevision, planSlot } from "../shared.ts";
5
+ import { noteRevision, planFlow, planSlot } from "../shared.ts";
6
6
 
7
7
  /**
8
8
  * The replanner, driven by the caller instead of by a step result.
@@ -16,8 +16,14 @@ import { noteRevision, planSlot } from "../shared.ts";
16
16
  * The await-then-mutate shape is `start_plan`'s, for the reason it gives — and
17
17
  * note the READ before the await is `planSlot.get`, which the replanner only
18
18
  * needs to look at.
19
+ *
20
+ * **Legal in `working` AND `answered`, which is the whole point of it.** A
21
+ * finished plan is exactly what a caller most often wants changed, so this is
22
+ * the one tool that reopens one — `REOPENED` takes the flow back to `working`,
23
+ * and the body's clearing of `plan.response` is the same decision at the data
24
+ * level. Its `!objective` guard is gone: `when` is that check now.
19
25
  */
20
- export default tool({
26
+ export default planFlow.tool({
21
27
  description:
22
28
  "Rewrite the remaining plan because the caller changed what they want. " +
23
29
  "Pass their instruction as they said it. Completed steps are never redone.",
@@ -27,11 +33,8 @@ export default tool({
27
33
  .max(400)
28
34
  .describe("What the caller now wants changed, in their own words"),
29
35
  }),
36
+ when: ["working", "answered"],
30
37
  async execute(args, ctx) {
31
- if (!planSlot.get(ctx).objective) {
32
- return toolFailure("There is no plan to revise — use start_plan first.");
33
- }
34
-
35
38
  try {
36
39
  const act = await replanNode(ctx.generate, planSlot.get(ctx), {
37
40
  system: REVISE_SYSTEM,
@@ -43,7 +46,7 @@ export default tool({
43
46
  if (act.kind === "respond") {
44
47
  plan.plan = [];
45
48
  plan.response = act.response;
46
- return { done: true, response: act.response, message: "Nothing is left to do." };
49
+ return { finished: true, response: act.response, message: "Nothing is left to do." };
47
50
  }
48
51
 
49
52
  plan.plan = act.steps;
@@ -51,7 +54,7 @@ export default tool({
51
54
  // moved the goalposts, so the old answer is no longer the answer.
52
55
  plan.response = null;
53
56
  return {
54
- done: false,
57
+ finished: false,
55
58
  remaining: act.steps,
56
59
  message: "Read the revised steps back and ask if that is right.",
57
60
  };
@@ -60,4 +63,9 @@ export default tool({
60
63
  return toolFailure(`The plan could not be revised: ${errorMessage(err)}`);
61
64
  }
62
65
  },
66
+ // Below `execute` deliberately — see the note on `work_next_step`'s own
67
+ // `sendFrom`: this body's return type is itself inferred, so a `sendFrom`
68
+ // written first has nothing to be contextually typed against.
69
+ sendFrom: (outcome) =>
70
+ outcome.finished ? ({ type: "ANSWERED" } as const) : ({ type: "REOPENED" } as const),
63
71
  });
@@ -1,7 +1,7 @@
1
1
  import { errorMessage, tool, toolFailure } from "@alexkroman1/aai";
2
2
  import { z } from "zod";
3
- import { planNode } from "../graph.ts";
4
- import { noteRevision, planSlot } from "../shared.ts";
3
+ import { planNode } from "../procedure.ts";
4
+ import { noteRevision, planFlow, planSlot } from "../shared.ts";
5
5
 
6
6
  /**
7
7
  * Their `plan_step`, as the call's opening move.
@@ -13,6 +13,12 @@ import { noteRevision, planSlot } from "../shared.ts";
13
13
  * version guarded against is unchanged (the LLM loop runs a step's tool calls
14
14
  * concurrently, so two plans started at once must not interleave); what changed
15
15
  * is that the window is now too short to interleave IN.
16
+ *
17
+ * **This one is an ordinary `tool()` and NOT a `planFlow.tool`, deliberately.**
18
+ * `PLANNED` is accepted in all three states — re-planning from scratch is always
19
+ * legal — so a `when` listing every state would be a gate that gates nothing,
20
+ * paying the wrapper for it. It sends the event itself instead, which is what
21
+ * `dialog.send` is public for.
16
22
  */
17
23
  export default tool({
18
24
  description:
@@ -32,6 +38,11 @@ export default tool({
32
38
  return toolFailure(`The planner failed: ${errorMessage(err)}`);
33
39
  }
34
40
 
41
+ // The flow moves first: `work_next_step` gates on `working`, and a plan
42
+ // written to the slot while the flow still said `idle` would be refused by
43
+ // its own next tool call.
44
+ planFlow.send(ctx, { type: "PLANNED" });
45
+
35
46
  return planSlot.update(ctx, (plan) => {
36
47
  plan.objective = args.objective;
37
48
  plan.plan = steps;