@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
@@ -35,7 +35,7 @@
35
35
  *
36
36
  * ## Every stage REPORTS, and the report goes two places
37
37
  *
38
- * `report()` (`@alexkroman1/aai/utils`) writes to the run's own stream — which
38
+ * `report()` (`@alexkroman1/aai/step`) writes to the run's own stream — which
39
39
  * `research_progress` reads back down the phone and a page renders — and to the
40
40
  * server log, with the attempt number appended past the first. A pass that is
41
41
  * retrying and one that is working print the same sentences otherwise.
@@ -49,16 +49,10 @@
49
49
  * researcher CONCLUDED, which is exactly what the step returns.
50
50
  */
51
51
 
52
- import { throwStepError } from "@alexkroman1/aai/step-errors";
52
+ import { mapConcurrent, report } from "@alexkroman1/aai/step";
53
+ import { stepGenerateClassified, stepGenerateJsonClassified } from "@alexkroman1/aai/step-errors";
53
54
  import { visitWebpage, webSearch } from "@alexkroman1/aai/tools";
54
- import {
55
- errorMessage,
56
- isToolFailure,
57
- mapInBatches,
58
- report,
59
- stepGenerate,
60
- stepGenerateJson,
61
- } from "@alexkroman1/aai/utils";
55
+ import { errorMessage, isToolFailure, plural } from "@alexkroman1/aai/utils";
62
56
  import { sleep } from "workflow";
63
57
  import { z } from "zod";
64
58
  import {
@@ -108,9 +102,9 @@ export type Source = { title: string; url: string };
108
102
 
109
103
  // ---- What each stage's model call has to come back as ------------------------
110
104
  //
111
- // `stepGenerateJson` validates against these, so a reply that missed is a plain
112
- // throw and therefore a retry — where the hand-rolled `askJson<T>()` this
113
- // replaces returned a value the compiler believed and nothing checked. They are
105
+ // `stepGenerateJsonClassified` validates against these, so a reply that missed
106
+ // is a plain throw and therefore a retry — where the hand-rolled `askJson<T>()`
107
+ // this replaces returned a value the compiler believed and nothing checked. They are
114
108
  // deliberately LENIENT wherever the old hand-written coercion was: a model that
115
109
  // put one number in an array of strings should cost that element, not the whole
116
110
  // pass.
@@ -221,12 +215,14 @@ export async function researchFlow(input: { topic: string; requestedBy: string }
221
215
  // failed angle fails the RUN: its finished siblings are already journaled, so
222
216
  // the resume replays them for free and re-issues only what is missing, where
223
217
  // catching here would file a report with a silent hole in it.
224
- const first = await mapInBatches(angles, ANGLE_CONCURRENCY, (angle) => investigate(brief, angle));
218
+ const first = await mapConcurrent(angles, ANGLE_CONCURRENCY, (angle) =>
219
+ investigate(brief, angle),
220
+ );
225
221
 
226
222
  // The supervisor's second look. Usually empty — a second wave costs the caller
227
223
  // minutes, and the prompt says so.
228
224
  const gaps = await findGaps(brief, first);
229
- const second = await mapInBatches(gaps, ANGLE_CONCURRENCY, (angle) => investigate(brief, angle));
225
+ const second = await mapConcurrent(gaps, ANGLE_CONCURRENCY, (angle) => investigate(brief, angle));
230
226
 
231
227
  const notes = [...first, ...second];
232
228
  const written = await writeReport(input.topic, brief, notes);
@@ -261,10 +257,9 @@ export async function writeBrief(topic: string): Promise<Brief> {
261
257
  "use step";
262
258
 
263
259
  await report(`Working out what "${topic}" is really asking.`);
264
- const parsed = await askJson(
260
+ const parsed = await stepGenerateJsonClassified(
265
261
  `Research request, as the caller said it: ${topic}`,
266
- BRIEF_SYSTEM,
267
- BriefReply,
262
+ { system: BRIEF_SYSTEM, schema: BriefReply },
268
263
  );
269
264
  return { brief: parsed.brief || topic, criteria: parsed.criteria.slice(0, MAX_ANGLES) };
270
265
  }
@@ -279,7 +274,10 @@ export async function writeBrief(topic: string): Promise<Brief> {
279
274
  export async function planAngles(brief: Brief): Promise<string[]> {
280
275
  "use step";
281
276
 
282
- const parsed = await askJson(briefText(brief), PLAN_SYSTEM, AnglesReply);
277
+ const parsed = await stepGenerateJsonClassified(briefText(brief), {
278
+ system: PLAN_SYSTEM,
279
+ schema: AnglesReply,
280
+ });
283
281
  const angles = parsed.angles.slice(0, MAX_ANGLES);
284
282
  if (angles.length === 0) {
285
283
  // Nothing to fan out over is a plan failure, not an empty result: the brief
@@ -287,7 +285,7 @@ export async function planAngles(brief: Brief): Promise<string[]> {
287
285
  await report("No angles came back; researching the brief itself.");
288
286
  return [brief.brief];
289
287
  }
290
- await report(`Researching ${angles.length} angle${angles.length === 1 ? "" : "s"}.`);
288
+ await report(`Researching ${angles.length} ${plural(angles.length, "angle")}.`);
291
289
  return angles;
292
290
  }
293
291
 
@@ -341,16 +339,15 @@ export async function findGaps(brief: Brief, notes: readonly Note[]): Promise<st
341
339
  "use step";
342
340
 
343
341
  if (notes.length === 0) return [];
344
- const parsed = await askJson(
342
+ const parsed = await stepGenerateJsonClassified(
345
343
  `${briefText(brief)}\n\nWhat came back:\n${notes.map(noteText).join("\n\n")}`,
346
- GAPS_SYSTEM,
347
- AnglesReply,
344
+ { system: GAPS_SYSTEM, schema: AnglesReply },
348
345
  );
349
346
  const gaps = parsed.angles.slice(0, MAX_ANGLES - 1);
350
347
  await report(
351
348
  gaps.length === 0
352
349
  ? "The brief is covered; writing it up."
353
- : `Following up ${gaps.length} gap${gaps.length === 1 ? "" : "s"}.`,
350
+ : `Following up ${gaps.length} ${plural(gaps.length, "gap")}.`,
354
351
  );
355
352
  return gaps;
356
353
  }
@@ -369,12 +366,14 @@ export async function writeReport(
369
366
  ): Promise<{ report: string; summary: string }> {
370
367
  "use step";
371
368
 
372
- await report(`Writing up ${notes.length} angle${notes.length === 1 ? "" : "s"}.`);
373
- const written = await ask(
369
+ await report(`Writing up ${notes.length} ${plural(notes.length, "angle")}.`);
370
+ const written = await stepGenerateClassified(
374
371
  `${briefText(brief)}\n\nFindings:\n${notes.map(noteText).join("\n\n")}`,
375
- REPORT_SYSTEM,
372
+ { system: REPORT_SYSTEM },
376
373
  );
377
- const summary = await ask(`Topic: ${topic}\n\nReport:\n${written}`, BRIEF_SUMMARY_SYSTEM);
374
+ const summary = await stepGenerateClassified(`Topic: ${topic}\n\nReport:\n${written}`, {
375
+ system: BRIEF_SUMMARY_SYSTEM,
376
+ });
378
377
  return { report: written, summary };
379
378
  }
380
379
 
@@ -404,12 +403,11 @@ async function nextAction(
404
403
  seen: readonly string[],
405
404
  left: number,
406
405
  ): Promise<Action> {
407
- return await askJson(
406
+ return await stepGenerateJsonClassified(
408
407
  `${briefText(brief)}\n\nYour angle: ${angle}\n` +
409
408
  `Actions left: ${left}\n\n` +
410
409
  (seen.length === 0 ? "You have not looked at anything yet." : seen.join("\n\n")),
411
- RESEARCH_SYSTEM,
412
- ActionReply,
410
+ { system: RESEARCH_SYSTEM, schema: ActionReply },
413
411
  );
414
412
  }
415
413
 
@@ -474,11 +472,10 @@ async function compress(angle: string, seen: readonly string[], sources: Source[
474
472
  if (seen.length === 0) {
475
473
  return { angle, findings: "Nothing was found on this angle.", sources: [] };
476
474
  }
477
- const parsed = await askJson(
478
- `Angle: ${angle}\n\n${seen.join("\n\n")}`,
479
- COMPRESS_SYSTEM,
480
- CompressReply,
481
- );
475
+ const parsed = await stepGenerateJsonClassified(`Angle: ${angle}\n\n${seen.join("\n\n")}`, {
476
+ system: COMPRESS_SYSTEM,
477
+ schema: CompressReply,
478
+ });
482
479
  return {
483
480
  angle,
484
481
  findings: parsed.findings ?? seen.join("\n\n"),
@@ -489,31 +486,17 @@ async function compress(angle: string, seen: readonly string[], sources: Source[
489
486
  }
490
487
 
491
488
  // ---- Model plumbing ---------------------------------------------------------
492
-
493
- /**
494
- * `stepGenerate`, with this desk's retry POLICY on top.
495
- *
496
- * The SDK classifies the gateway's failure (`StepGenerateError.retryable`) and
497
- * stops there, deliberately: whether a terminal failure should burn the step's
498
- * remaining attempts is the caller's call. `throwStepError` is that call made
499
- * one way terminal stays terminal, and a rate limit becomes a `RetryableError`
500
- * carrying the delay the gateway itself named.
501
- */
502
- async function ask(prompt: string, system: string): Promise<string> {
503
- return await stepGenerate(prompt, { system }).catch(throwStepError);
504
- }
505
-
506
- /**
507
- * The same call, for a stage whose reply is JSON of a known shape.
508
- *
509
- * `stepGenerateJson` owns the four things every such stage used to re-derive —
510
- * unwrap the fence, parse, reject a non-object, check the shape — and throws
511
- * PLAINLY when any of them misses, which is what makes a malformed reply a
512
- * retry rather than a failure.
513
- */
514
- async function askJson<S extends z.ZodType>(prompt: string, system: string, schema: S) {
515
- return await stepGenerateJson(prompt, { system, schema }).catch(throwStepError);
516
- }
489
+ //
490
+ // There is none left, and its absence is the point. This desk carried an `ask()`
491
+ // and an `askJson()` whose whole body was `.catch(throwStepError)`; the SDK's
492
+ // `stepGenerateClassified` and `stepGenerateJsonClassified`
493
+ // (`@alexkroman1/aai/step-errors`) ARE that call the `/step` one with the
494
+ // gateway's verdict classified, so a terminal failure stays terminal and a rate
495
+ // limit becomes a `RetryableError` carrying the delay the gateway itself named.
496
+ // `stepGenerateJsonClassified` also owns the four things every JSON stage used
497
+ // to re-derive — unwrap the fence, parse, reject a non-object, check the shape
498
+ // and throws PLAINLY when any of them misses, which is what makes a malformed
499
+ // reply a retry rather than a failure.
517
500
 
518
501
  // ---- Pure helpers -----------------------------------------------------------
519
502
 
@@ -1,5 +1,14 @@
1
+ /**
2
+ * Addresses: the shared schema fields, and the two address changes as
3
+ * plan/apply pairs (see `cancel.ts` for why every mutating action is split
4
+ * that way).
5
+ */
6
+
7
+ import { isToolFailure, type ToolFailure } from "@alexkroman1/aai";
1
8
  import { z } from "zod";
2
- import type { Address } from "./shared.ts";
9
+ import { resolveOrder } from "./resolve.ts";
10
+ import type { Address, RetailState } from "./shared.ts";
11
+ import { requireOwnUser } from "./store.ts";
3
12
 
4
13
  /** Spread into both address tools' schemas. Two hand-copied zod shapes is how
5
14
  * the order address and the profile address drift apart. */
@@ -27,3 +36,80 @@ export function formatAddress(address: Address): string {
27
36
  const lines = [address.address1, address.address2].filter(Boolean).join(", ");
28
37
  return `${lines}, ${address.city} ${address.state} ${address.zip}, ${address.country}`;
29
38
  }
39
+
40
+ // ─── One order's shipping address ────────────────────────────────────────────
41
+
42
+ export interface OrderAddressPlan {
43
+ readBack: string;
44
+ orderId: string;
45
+ address: Address;
46
+ }
47
+
48
+ export function planOrderAddress(
49
+ state: RetailState,
50
+ spokenOrderId: string,
51
+ address: Address,
52
+ ): OrderAddressPlan | ToolFailure {
53
+ const order = resolveOrder(state, spokenOrderId);
54
+ if (isToolFailure(order)) return order;
55
+
56
+ // Any pending variant is fine here — unlike cancel and modify-items, which
57
+ // require exactly 'pending'. Re-addressing a modified order is harmless.
58
+ if (!order.status.startsWith("pending")) {
59
+ return {
60
+ error: `Order ${order.order_id} is ${order.status}, and only a pending order's address can be changed.`,
61
+ };
62
+ }
63
+
64
+ return {
65
+ readBack: `ship order ${order.order_id} to ${formatAddress(address)} instead`,
66
+ orderId: order.order_id,
67
+ address: toAddress(address),
68
+ };
69
+ }
70
+
71
+ export function applyOrderAddress(state: RetailState, plan: OrderAddressPlan) {
72
+ const order = state.store.orders[plan.orderId];
73
+ if (order) order.address = plan.address;
74
+ return {
75
+ order_id: plan.orderId,
76
+ status: order?.status ?? "pending",
77
+ address: plan.address,
78
+ message: `Order ${plan.orderId} now ships to ${formatAddress(plan.address)}.`,
79
+ };
80
+ }
81
+
82
+ // ─── The customer's default address ──────────────────────────────────────────
83
+
84
+ export interface UserAddressPlan {
85
+ readBack: string;
86
+ userId: string;
87
+ address: Address;
88
+ }
89
+
90
+ export function planUserAddress(
91
+ state: RetailState,
92
+ userId: string,
93
+ address: Address,
94
+ ): UserAddressPlan | ToolFailure {
95
+ const user = requireOwnUser(state, userId);
96
+ if (isToolFailure(user)) return user;
97
+
98
+ return {
99
+ readBack:
100
+ `change your default address for future orders to ${formatAddress(address)} ` +
101
+ "(existing orders keep their own)",
102
+ userId: user.user_id,
103
+ address: toAddress(address),
104
+ };
105
+ }
106
+
107
+ export function applyUserAddress(state: RetailState, plan: UserAddressPlan) {
108
+ const user = state.store.users[plan.userId];
109
+ if (user) user.address = plan.address;
110
+ return {
111
+ user_id: plan.userId,
112
+ address: plan.address,
113
+ message: `Default address updated to ${formatAddress(plan.address)}. Existing orders keep their own shipping addresses.`,
114
+ };
115
+ }
@@ -0,0 +1,314 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ import { withSystemPrompt } from "@alexkroman1/aai/manifest";
4
+ import type { SessionEvent } from "@alexkroman1/aai/protocol";
5
+ import { withDiscoveredTools } from "@alexkroman1/aai/testing";
6
+ // An EVAL: does this desk actually behave? Run it with `aai eval`.
7
+ //
8
+ // `agent.test.ts` drives each tool directly and asserts about its result;
9
+ // `registry.test.ts` asserts every tool's gate one at a time. Neither can say
10
+ // whether the AGENT — a model, reading this system prompt, holding these
11
+ // seventeen tools — puts them in the right ORDER. That is what this file is
12
+ // for, and the four things it asserts are the four this template's whole shape
13
+ // exists to guarantee:
14
+ //
15
+ // 1. nothing about an order is reachable before the caller is identified,
16
+ // 2. a change is STAGED and the store is untouched,
17
+ // 3. an explicit yes applies it, exactly once,
18
+ // 4. after a handoff the call is over and every tool refuses.
19
+ //
20
+ // Each one is a MECHANISM (a dialog gate, a plan/apply split, a terminal
21
+ // state), so each assertion reads the mechanism's own output — the SDK's
22
+ // refusal sentence, the tool result, and the projection the browser is sent —
23
+ // rather than judging the sentence the model chose to say.
24
+ //
25
+ // What no eval here can see: anything below the audio boundary. Whether a
26
+ // caller reading an order number in bursts lands as one turn is a property of
27
+ // endpointing, and these fake speech stages remove it.
28
+ import { type EvalSession, type EvalTurn, lastStateIn } from "@alexkroman1/aai-runtime/eval";
29
+ import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
30
+ import { expect } from "vitest";
31
+ import { z } from "zod";
32
+ import authoredAgent from "./agent.ts";
33
+ import systemPrompt from "./system-prompt.md?raw";
34
+
35
+ /**
36
+ * The def a DEPLOYED agent runs: authored, plus what `tools/` declares, plus
37
+ * what `system-prompt.md` says.
38
+ *
39
+ * BOTH wrappers are load-bearing and neither is applied by `agent()` — they are
40
+ * applied by the BUILD (`aai build` enumerates `tools/` and resolves the prompt
41
+ * file), so an eval driving the raw default export would measure a
42
+ * seventeen-tool desk with no tools and the FRAMEWORK DEFAULT prompt. For this
43
+ * template that is the whole subject: the authenticate-first discipline, the
44
+ * three-step readback and the one-customer-per-call rule are all in that file.
45
+ *
46
+ * The glob is written here rather than reached for from a shared helper because
47
+ * this file SHIPS — see `registry.test.ts`.
48
+ */
49
+ const retailAgent = withSystemPrompt(
50
+ withDiscoveredTools(authoredAgent, import.meta.glob("./tools/*.ts", { eager: true })),
51
+ systemPrompt,
52
+ );
53
+
54
+ /** Harper Brown: exactly ONE pending order, which is what makes "cancel my
55
+ * pending order" unambiguous — `resolveOrder` refuses a reference that matches
56
+ * two, so a persona with two pending orders would put this eval's subject
57
+ * (the confirmation gate) behind a disambiguation turn. */
58
+ const CALLER_EMAIL = "harper.brown3965@example.com";
59
+ const PENDING_ORDER = "#W2273069";
60
+
61
+ /** The six tools that legitimately run before the caller is identified — the
62
+ * ones declaring `when: BEFORE_TRANSFER`. Anything else must refuse. */
63
+ const PUBLIC_TOOLS = new Set([
64
+ "find_user_id_by_email",
65
+ "find_user_id_by_name_zip",
66
+ "get_product_details",
67
+ "get_item_details",
68
+ "list_all_product_types",
69
+ "transfer_to_human_agents",
70
+ ]);
71
+
72
+ /**
73
+ * What the BROWSER is sent, as this eval reads it.
74
+ *
75
+ * Parsed rather than cast: `state.updated` carries `unknown`, and a schema that
76
+ * stops matching is a loud failure naming the field, where a cast would hand
77
+ * the assertions `undefined` and fail three lines later. It names only the
78
+ * fields asserted below, so `storeView` may grow without touching this.
79
+ */
80
+ const ProjectedStore = z.object({
81
+ customer: z.object({ userId: z.string() }).nullable(),
82
+ orders: z.array(z.object({ orderId: z.string(), status: z.string() })),
83
+ pending: z.object({ kind: z.string(), readBack: z.string() }).nullable(),
84
+ });
85
+
86
+ /**
87
+ * The latest projection the session pushed, or undefined if it pushed none.
88
+ *
89
+ * `lastStateIn` is the SDK's reader; the schema is what is worth passing to it,
90
+ * for the reason above — a frame that stopped matching fails naming the field.
91
+ */
92
+ const projection = (events: readonly SessionEvent[]) => lastStateIn(events, ProjectedStore);
93
+
94
+ /** The status the projection carries for `orderId` — the one fact "did anything
95
+ * change?" turns on. `undefined` covers both "no projection yet" and "not this
96
+ * customer's order", which are the same claim here: it was not touched. */
97
+ function statusOf(events: readonly SessionEvent[], orderId: string): string | undefined {
98
+ return projection(events)?.orders.find((o) => o.orderId === orderId)?.status;
99
+ }
100
+
101
+ /**
102
+ * The dialog gate's own refusal sentence, for the state it names.
103
+ *
104
+ * The character class absorbs the JSON escaping: a tool result reaches the
105
+ * event stream as a serialized string, so the state name arrives inside
106
+ * `\\"identifying\\"` rather than plain quotes.
107
+ */
108
+ const refusalAt = (state: string) =>
109
+ new RegExp(`Not available yet: this conversation is at [\\\\"]*${state}`);
110
+
111
+ /**
112
+ * Drive a whole call, one caller line at a time, and hand back every turn.
113
+ *
114
+ * The cases below assert about the turn a MECHANISM fired in rather than about
115
+ * turn number two, because how many turns a desk takes to get there is the
116
+ * model's business and it really does vary: measured live, this agent reads the
117
+ * order back out of `get_order_details` and asks before it stages, so the
118
+ * staging call has landed in turn two, three and four across runs. A case
119
+ * pinned to a turn index is a flake with a misleading name.
120
+ */
121
+ async function sayAll(session: EvalSession, lines: readonly string[]): Promise<EvalTurn[]> {
122
+ const turns: EvalTurn[] = [];
123
+ for (const line of lines) turns.push(await session.say(line));
124
+ return turns;
125
+ }
126
+
127
+ /** The turn a named tool was called in, if any. */
128
+ const turnCalling = (turns: readonly EvalTurn[], tool: string) =>
129
+ turns.find((t) => t.toolCalls.some((c) => c.name === tool));
130
+
131
+ /** One line the caller says to identify themselves, and the scripted tool call
132
+ * that answers it — the first turn of three of these four cases. */
133
+ const AUTH_TURN = [
134
+ { tool: "find_user_id_by_email", args: { email: CALLER_EMAIL } },
135
+ "Thanks — I have your account here. What can I do for you?",
136
+ ] as const;
137
+
138
+ /** The staged cancellation, as a scripted turn. */
139
+ const STAGE_TURN = [
140
+ {
141
+ tool: "cancel_pending_order",
142
+ args: { order_id: PENDING_ORDER, reason: "ordered by mistake" },
143
+ },
144
+ "So that would cancel your pending order and refund one thousand two hundred dollars " +
145
+ "and fifty-seven cents to your Visa. Is that right?",
146
+ ] as const;
147
+
148
+ describeEval(retailAgent, (test) => {
149
+ test(
150
+ "will not touch an order before the caller is identified",
151
+ async ({ session }) => {
152
+ const turn = await session.say(
153
+ "Hi — cancel my pending order please, number W two two seven three zero six nine. " +
154
+ "I don't need it any more.",
155
+ );
156
+
157
+ // The claim is about the GATE, so it is made of every gated call the
158
+ // model chose to make: each one has to have been refused, and the
159
+ // refusal has to say where the call actually is. A model that asks for
160
+ // the email instead makes no gated call at all, which satisfies this
161
+ // vacuously and is the same right answer — the two remaining assertions
162
+ // are what stop the case being vacuous overall.
163
+ for (const call of turn.toolCalls) {
164
+ if (PUBLIC_TOOLS.has(call.name)) continue;
165
+ expect(call.result).toMatch(refusalAt("identifying"));
166
+ }
167
+ // Nothing was authenticated, so the browser has been sent no customer —
168
+ // which is also the projection's security claim.
169
+ expect(projection(session.events())?.customer ?? null).toBeNull();
170
+ // And it asked for the one thing it needs.
171
+ expect(turn.text).toMatch(/email|account|name|zip/i);
172
+ },
173
+ {
174
+ stubReply: [
175
+ {
176
+ tool: "cancel_pending_order",
177
+ args: { order_id: PENDING_ORDER, reason: "no longer needed" },
178
+ },
179
+ "Before I can look anything up I'll need to find your account — what's the email on it?",
180
+ ],
181
+ },
182
+ );
183
+
184
+ test(
185
+ "stages a cancellation and changes nothing until the caller says yes",
186
+ async ({ session }) => {
187
+ // FIVE lines, because how many turns this desk takes to STAGE is its own
188
+ // business and measured live it varies by two: reading the order back out
189
+ // of `get_order_details` and asking "does that sound right?" is a whole
190
+ // turn, and whether it spends one is not something an eval should pin.
191
+ // Every assertion below is about the turn the staging landed in, so a
192
+ // later apply cannot affect any of them.
193
+ const turns = await sayAll(session, [
194
+ `My email is ${CALLER_EMAIL}.`,
195
+ "I'd like to cancel my pending order — I ordered it by mistake.",
196
+ "Yes, please go ahead and cancel it.",
197
+ "Yes — I'm sure. Cancel it.",
198
+ "Yes. Cancel it, please.",
199
+ ]);
200
+
201
+ const staging = turnCalling(turns, "cancel_pending_order");
202
+ const staged = staging?.toolCalls.find((c) => c.name === "cancel_pending_order");
203
+ expect(staged?.result).toMatch(/NOTHING HAS CHANGED YET/);
204
+ // The gate is a POSITION, and this is it moving: the tool reported the
205
+ // state it landed in, which is the only state `confirm_change` is legal
206
+ // in and is reachable only by staging.
207
+ expect(staged?.result).toMatch(/serving\.awaitingConfirmation/);
208
+ // A change cannot be described and applied in the same turn. This is the
209
+ // property the prose in the system prompt could never have.
210
+ expect(staging?.toolCalls.map((c) => c.name)).not.toContain("confirm_change");
211
+ // And after the turn that staged it, the store really is untouched — read
212
+ // off the projection the BROWSER was sent in that same turn.
213
+ expect(statusOf(staging?.events ?? [], PENDING_ORDER)).toBe("pending");
214
+ expect(projection(staging?.events ?? [])?.pending?.kind).toBe("cancel_pending_order");
215
+ // Step 2 of the policy: read it back and ask.
216
+ expect(staging?.text).toMatch(/\?/);
217
+ },
218
+ { stubReply: [...AUTH_TURN, ...STAGE_TURN, "Cancelling it now — one moment."] },
219
+ );
220
+
221
+ test(
222
+ "applies the staged change on an explicit yes, exactly once",
223
+ async ({ session }) => {
224
+ // Five lines, three of them a yes: which turn the desk applies the change
225
+ // in is its own business, and saying yes repeatedly is what makes
226
+ // "exactly once" below a claim about the MECHANISM rather than about the
227
+ // model's pacing.
228
+ await sayAll(session, [
229
+ `My email is ${CALLER_EMAIL}.`,
230
+ "Please cancel my pending order — I ordered it by mistake.",
231
+ "Yes, that's right, go ahead.",
232
+ "Yes — confirm it now, please.",
233
+ "Yes. Confirm it.",
234
+ ]);
235
+
236
+ const confirms = session.toolCalls().filter((c) => c.name === "confirm_change");
237
+ const applied = confirms.filter((c) => c.result?.includes('"status":"cancelled"'));
238
+ // ONE apply for the whole call, however many times the caller said yes.
239
+ // Measured live, a second yes really does produce a second
240
+ // `confirm_change` — and it is REFUSED, because applying cleared
241
+ // `pending` and sent `SETTLED`, and `awaitingConfirmation` is reachable
242
+ // only by staging something new. That is the gate doing the one job a
243
+ // prompt could not: a repeated yes cannot cancel an order twice.
244
+ expect(applied).toHaveLength(1);
245
+ for (const extra of confirms.filter((c) => c !== applied[0])) {
246
+ expect(extra.result).toMatch(/Not available yet/);
247
+ }
248
+ // And it came after the stage, never instead of it.
249
+ const names = session.toolCalls().map((c) => c.name);
250
+ expect(names.indexOf("cancel_pending_order")).toBeGreaterThanOrEqual(0);
251
+ expect(names.indexOf("confirm_change")).toBeGreaterThan(
252
+ names.indexOf("cancel_pending_order"),
253
+ );
254
+ // The store moved, and the staged change is gone with it.
255
+ expect(statusOf(session.events(), PENDING_ORDER)).toBe("cancelled");
256
+ expect(projection(session.events())?.pending).toBeNull();
257
+ },
258
+ {
259
+ stubReply: [
260
+ ...AUTH_TURN,
261
+ ...STAGE_TURN,
262
+ { tool: "confirm_change", args: {} },
263
+ "That's cancelled, and the refund is on its way to your Visa.",
264
+ ],
265
+ },
266
+ );
267
+
268
+ test(
269
+ "hands the call to a human and then refuses everything, including the order",
270
+ async ({ session }) => {
271
+ await session.say(`My email is ${CALLER_EMAIL}.`);
272
+ const handoff = await session.say(
273
+ "This isn't working for me — I want to speak to a real person.",
274
+ );
275
+
276
+ const transfer = handoff.toolCalls.find((c) => c.name === "transfer_to_human_agents");
277
+ // Named first, and with a message: a live model that answers the request
278
+ // with a question instead of the tool leaves `transfer` undefined, and
279
+ // `.toMatch()` on it reports only "expected a string, got undefined" —
280
+ // which says nothing about what the desk actually did.
281
+ expect(
282
+ transfer,
283
+ `tools called: [${handoff.toolCalls.map((c) => c.name).join(", ")}]; said: ${handoff.text}`,
284
+ ).toBeDefined();
285
+ // The terminal state is what makes "say nothing else after this" a
286
+ // property of the agent rather than a line in its prompt: `done` is the
287
+ // flow saying there is nowhere left to go.
288
+ expect(transfer?.result).toMatch(/"state":"transferred"/);
289
+ expect(transfer?.result).toMatch(/"done":true/);
290
+
291
+ const after = await session.say("Actually, before you go — just cancel my pending order.");
292
+ for (const call of after.toolCalls) {
293
+ expect(call.result).toMatch(refusalAt("transferred"));
294
+ }
295
+ // Which is the point: the order the caller asked about is untouched.
296
+ expect(statusOf(session.events(), PENDING_ORDER)).toBe("pending");
297
+ },
298
+ {
299
+ stubReply: [
300
+ ...AUTH_TURN,
301
+ {
302
+ tool: "transfer_to_human_agents",
303
+ args: { summary: "Caller asked for a human agent." },
304
+ },
305
+ "You are being transferred to a human agent. Please hold on.",
306
+ {
307
+ tool: "cancel_pending_order",
308
+ args: { order_id: PENDING_ORDER, reason: "no longer needed" },
309
+ },
310
+ "You are being transferred to a human agent. Please hold on.",
311
+ ],
312
+ },
313
+ );
314
+ });