@alexkroman1/aai-cli 5.14.0 → 6.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 (231) hide show
  1. package/dist/{_agent-C8Jk-ZcP.mjs → _agent-vGW5SBnX.mjs} +23 -5
  2. package/dist/_agent.d.ts +18 -0
  3. package/dist/{_api-client-BX8Qv51n.mjs → _api-client-LgLksMhN.mjs} +45 -8
  4. package/dist/_api-client.d.ts +39 -0
  5. package/dist/{_bundler-BuIyDdvs.mjs → _bundler-BapCUUJN.mjs} +42 -14
  6. package/dist/_bundler.d.ts +43 -5
  7. package/dist/_cli-common.d.ts +67 -3
  8. package/dist/{_config-CzwAWOWr.mjs → _config-D_s09e7g.mjs} +36 -18
  9. package/dist/_deploy.d.ts +2 -5
  10. package/dist/_dev-restart.d.ts +6 -1
  11. package/dist/{_dev-server-Ch7K8e8i.mjs → _dev-server-DQ1t9sbw.mjs} +94 -38
  12. package/dist/_dev-server.d.ts +30 -2
  13. package/dist/_e2e-test-utils.d.ts +80 -0
  14. package/dist/_fault-mode.d.ts +175 -0
  15. package/dist/{_init-CbkrE2wq.mjs → _init-CpISqoR8.mjs} +3 -3
  16. package/dist/{_output-CC300DzW.mjs → _output-CKkmWs7i.mjs} +8 -1
  17. package/dist/_output.d.ts +9 -2
  18. package/dist/{_server-common-61fL__39.mjs → _server-common-6e0QI4mq.mjs} +1 -1
  19. package/dist/{_slug-api-D30zPmsn.mjs → _slug-api-D2heILuC.mjs} +2 -2
  20. package/dist/{_studio-D2ciIhbi.mjs → _studio-zAJycZ8j.mjs} +3 -3
  21. package/dist/{_templates-B1kt3BGt.mjs → _templates-iDGjGiPf.mjs} +4 -6
  22. package/dist/{_typecheck-gate-4v9UBZHh.mjs → _typecheck-gate-BXvUNnfs.mjs} +1 -1
  23. package/dist/{_utils-0DHbcqUb.mjs → _utils-B8QmtFhK.mjs} +21 -7
  24. package/dist/_utils.d.ts +18 -2
  25. package/dist/{_vite-env-Dg_QlVv0.mjs → _vite-env-BNveawd1.mjs} +31 -1
  26. package/dist/_vite-env.d.ts +30 -0
  27. package/dist/build-BO1Ni6oJ.mjs +62 -0
  28. package/dist/build.d.ts +19 -0
  29. package/dist/cli-workflow.d.ts +10 -0
  30. package/dist/cli.d.ts +1 -2
  31. package/dist/cli.mjs +406 -224
  32. package/dist/{client-bundler-C9qXL8A2.mjs → client-bundler-B8Q9F8gJ.mjs} +3 -22
  33. package/dist/client-bundler.mjs +1 -1
  34. package/dist/{delete-DCRX3d96.mjs → delete-4I0uNR4a.mjs} +6 -7
  35. package/dist/delete.d.ts +2 -8
  36. package/dist/{deploy-DjsEV-40.mjs → deploy-DJ3HRcia.mjs} +13 -14
  37. package/dist/{dev-CvPH5dbF.mjs → dev-C6SWaAZP.mjs} +3 -3
  38. package/dist/{eject-LgBRfnbH.mjs → eject-Zrzk5KV3.mjs} +41 -15
  39. package/dist/eject.d.ts +30 -3
  40. package/dist/{init-BppxMR8r.mjs → init-BTRGiPQr.mjs} +61 -26
  41. package/dist/init.d.ts +11 -0
  42. package/dist/{login-BQQaxJhL.mjs → login-k0Z3VQu9.mjs} +5 -5
  43. package/dist/project-config.mjs +1 -1
  44. package/dist/scaffold/CLAUDE.md +498 -91
  45. package/dist/scaffold/package.json +11 -8
  46. package/dist/scaffold/pnpm-workspace.yaml +8 -0
  47. package/dist/scaffold/server.mjs +53 -57
  48. package/dist/{secret-Ds9dvqAj.mjs → secret-DzSQx1Ds.mjs} +6 -3
  49. package/dist/{storage-DbrQSHFk.mjs → storage-C-Do0mIA.mjs} +18 -6
  50. package/dist/{studio-GSFd4s5O.mjs → studio-BGVmCA2G.mjs} +10 -10
  51. package/dist/templates/code-interpreter/agent.ts +0 -2
  52. package/dist/templates/dispatch-center/agent.test.ts +17 -19
  53. package/dist/templates/dispatch-center/agent.ts +2 -29
  54. package/dist/templates/dispatch-center/client.tsx +33 -16
  55. package/dist/templates/dispatch-center/shared.ts +53 -19
  56. package/dist/templates/dispatch-center/tools/incident_add_note.ts +12 -14
  57. package/dist/templates/dispatch-center/tools/incident_create.ts +41 -44
  58. package/dist/templates/dispatch-center/tools/incident_escalate.ts +59 -61
  59. package/dist/templates/dispatch-center/tools/incident_get.ts +3 -4
  60. package/dist/templates/dispatch-center/tools/incident_triage.ts +43 -45
  61. package/dist/templates/dispatch-center/tools/incident_update_status.ts +39 -41
  62. package/dist/templates/dispatch-center/tools/ops_dashboard.ts +2 -5
  63. package/dist/templates/dispatch-center/tools/ops_protocols.ts +1 -1
  64. package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +20 -23
  65. package/dist/templates/dispatch-center/tools/resources_dispatch.ts +52 -54
  66. package/dist/templates/dispatch-center/tools/resources_get_available.ts +2 -4
  67. package/dist/templates/dispatch-center/tools/resources_update_status.ts +39 -42
  68. package/dist/templates/embedded-assets/agent.test.ts +83 -0
  69. package/dist/templates/embedded-assets/agent.ts +1 -48
  70. package/dist/templates/embedded-assets/shared.ts +17 -0
  71. package/dist/templates/embedded-assets/system-prompt.md +13 -0
  72. package/dist/templates/embedded-assets/tools/list_topics.ts +9 -0
  73. package/dist/templates/embedded-assets/tools/search_knowledge.ts +26 -0
  74. package/dist/templates/health-assistant/agent.test.ts +169 -0
  75. package/dist/templates/health-assistant/agent.ts +1 -166
  76. package/dist/templates/health-assistant/fda.ts +100 -0
  77. package/dist/templates/health-assistant/tools/check_drug_interaction.ts +59 -0
  78. package/dist/templates/health-assistant/tools/medication_lookup.ts +31 -0
  79. package/dist/templates/infocom-adventure/agent.test.ts +205 -0
  80. package/dist/templates/infocom-adventure/agent.ts +3 -106
  81. package/dist/templates/infocom-adventure/client.tsx +28 -12
  82. package/dist/templates/infocom-adventure/shared.ts +28 -11
  83. package/dist/templates/infocom-adventure/tools/game_state_drop.ts +13 -0
  84. package/dist/templates/infocom-adventure/tools/game_state_flag.ts +13 -0
  85. package/dist/templates/infocom-adventure/tools/game_state_get.ts +16 -0
  86. package/dist/templates/infocom-adventure/tools/game_state_history.ts +14 -0
  87. package/dist/templates/infocom-adventure/tools/game_state_move.ts +14 -0
  88. package/dist/templates/infocom-adventure/tools/game_state_restart.ts +13 -0
  89. package/dist/templates/infocom-adventure/tools/game_state_score.ts +13 -0
  90. package/dist/templates/infocom-adventure/tools/game_state_take.ts +13 -0
  91. package/dist/templates/link-digest/agent.test.ts +223 -0
  92. package/dist/templates/link-digest/agent.ts +79 -0
  93. package/dist/templates/link-digest/client.tsx +146 -0
  94. package/dist/templates/link-digest/workflows/digest.ts +248 -0
  95. package/dist/templates/math-buddy/agent.ts +0 -2
  96. package/dist/templates/night-owl/agent.test.ts +80 -0
  97. package/dist/templates/night-owl/agent.ts +1 -68
  98. package/dist/templates/night-owl/system-prompt.md +10 -0
  99. package/dist/templates/night-owl/tools/recommend.ts +64 -0
  100. package/dist/templates/personal-finance/agent.ts +0 -2
  101. package/dist/templates/pizza-ordering/agent.test.ts +52 -47
  102. package/dist/templates/pizza-ordering/agent.ts +9 -158
  103. package/dist/templates/pizza-ordering/shared.ts +35 -12
  104. package/dist/templates/pizza-ordering/tools/add_pizza.ts +31 -0
  105. package/dist/templates/pizza-ordering/tools/place_order.ts +22 -0
  106. package/dist/templates/pizza-ordering/tools/remove_pizza.ts +22 -0
  107. package/dist/templates/pizza-ordering/tools/set_customer_name.ts +13 -0
  108. package/dist/templates/pizza-ordering/tools/update_pizza.ts +28 -0
  109. package/dist/templates/pizza-ordering/tools/view_order.ts +21 -0
  110. package/dist/templates/plan-and-execute/agent.test.ts +411 -0
  111. package/dist/templates/plan-and-execute/agent.ts +23 -0
  112. package/dist/templates/plan-and-execute/client.tsx +115 -0
  113. package/dist/templates/plan-and-execute/graph.ts +178 -0
  114. package/dist/templates/plan-and-execute/prompts.ts +110 -0
  115. package/dist/templates/plan-and-execute/shared.ts +161 -0
  116. package/dist/templates/plan-and-execute/system-prompt.md +18 -0
  117. package/dist/templates/plan-and-execute/tools/plan_status.ts +20 -0
  118. package/dist/templates/plan-and-execute/tools/revise_plan.ts +63 -0
  119. package/dist/templates/plan-and-execute/tools/start_plan.ts +51 -0
  120. package/dist/templates/plan-and-execute/tools/work_next_step.ts +101 -0
  121. package/dist/templates/recap-workflow/agent.test.ts +762 -0
  122. package/dist/templates/recap-workflow/agent.ts +95 -0
  123. package/dist/templates/recap-workflow/shared.ts +43 -0
  124. package/dist/templates/recap-workflow/tools/cancel_recap.ts +23 -0
  125. package/dist/templates/recap-workflow/tools/keep_transcript.ts +31 -0
  126. package/dist/templates/recap-workflow/tools/recap_progress.ts +27 -0
  127. package/dist/templates/recap-workflow/tools/recap_status.ts +46 -0
  128. package/dist/templates/recap-workflow/tools/request_recap.ts +43 -0
  129. package/dist/templates/recap-workflow/workflows/recap.ts +544 -0
  130. package/dist/templates/recap-workflow/workflows/tokens.ts +26 -0
  131. package/dist/templates/redline/agent.test.ts +222 -0
  132. package/dist/templates/redline/agent.ts +89 -0
  133. package/dist/templates/redline/client.tsx +184 -0
  134. package/dist/templates/redline/workflows/prompts.ts +68 -0
  135. package/dist/templates/redline/workflows/redline.ts +258 -0
  136. package/dist/templates/research-workflow/agent.test.ts +525 -0
  137. package/dist/templates/research-workflow/agent.ts +94 -0
  138. package/dist/templates/research-workflow/shared.ts +35 -0
  139. package/dist/templates/research-workflow/tools/file_it_now.ts +22 -0
  140. package/dist/templates/research-workflow/tools/request_research.ts +33 -0
  141. package/dist/templates/research-workflow/tools/research_progress.ts +26 -0
  142. package/dist/templates/research-workflow/tools/research_status.ts +40 -0
  143. package/dist/templates/research-workflow/workflows/prompts.ts +143 -0
  144. package/dist/templates/research-workflow/workflows/research.ts +558 -0
  145. package/dist/templates/retail/agent.test.ts +236 -172
  146. package/dist/templates/retail/agent.ts +5 -42
  147. package/dist/templates/retail/client.tsx +33 -11
  148. package/dist/templates/retail/registry.test.ts +31 -16
  149. package/dist/templates/retail/resolve.ts +28 -64
  150. package/dist/templates/retail/shared.test.ts +4 -3
  151. package/dist/templates/retail/shared.ts +24 -7
  152. package/dist/templates/retail/store.test.ts +12 -10
  153. package/dist/templates/retail/store.ts +69 -30
  154. package/dist/templates/retail/tools/cancel_pending_order.ts +4 -9
  155. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +22 -11
  156. package/dist/templates/retail/tools/find_user_id_by_email.ts +5 -9
  157. package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +5 -7
  158. package/dist/templates/retail/tools/get_item_details.ts +5 -7
  159. package/dist/templates/retail/tools/get_order_details.ts +5 -7
  160. package/dist/templates/retail/tools/get_product_details.ts +4 -7
  161. package/dist/templates/retail/tools/get_user_details.ts +4 -7
  162. package/dist/templates/retail/tools/list_all_product_types.ts +5 -7
  163. package/dist/templates/retail/tools/modify_pending_order_address.ts +4 -9
  164. package/dist/templates/retail/tools/modify_pending_order_items.ts +4 -16
  165. package/dist/templates/retail/tools/modify_pending_order_payment.ts +3 -9
  166. package/dist/templates/retail/tools/modify_user_address.ts +4 -9
  167. package/dist/templates/retail/tools/return_delivered_order_items.ts +3 -9
  168. package/dist/templates/retail/tools/transfer_to_human_agents.ts +1 -1
  169. package/dist/templates/solo-rpg/agent.test.ts +152 -29
  170. package/dist/templates/solo-rpg/agent.ts +3 -21
  171. package/dist/templates/solo-rpg/client.tsx +19 -6
  172. package/dist/templates/solo-rpg/shared.ts +19 -11
  173. package/dist/templates/solo-rpg/tools/action_roll.ts +2 -4
  174. package/dist/templates/solo-rpg/tools/burn_momentum.ts +2 -4
  175. package/dist/templates/solo-rpg/tools/check_state.ts +6 -6
  176. package/dist/templates/solo-rpg/tools/load_game.ts +1 -1
  177. package/dist/templates/solo-rpg/tools/oracle.ts +18 -11
  178. package/dist/templates/solo-rpg/tools/save_game.ts +10 -7
  179. package/dist/templates/solo-rpg/tools/setup_character.ts +1 -1
  180. package/dist/templates/solo-rpg/tools/update_state.ts +3 -4
  181. package/dist/templates/support-line/agent.test.ts +343 -0
  182. package/dist/templates/support-line/agent.ts +27 -0
  183. package/dist/templates/support-line/client.tsx +122 -0
  184. package/dist/templates/support-line/graph.ts +224 -0
  185. package/dist/templates/support-line/knowledge.json +65 -0
  186. package/dist/templates/support-line/prompts.ts +95 -0
  187. package/dist/templates/support-line/shared.ts +240 -0
  188. package/dist/templates/support-line/system-prompt.md +17 -0
  189. package/dist/templates/support-line/tools/answer_question.ts +72 -0
  190. package/dist/templates/support-line/tools/list_topics.ts +22 -0
  191. package/dist/templates/support-line/tools/log_ticket.ts +30 -0
  192. package/dist/templates/transcription-workflow/agent.test.ts +563 -0
  193. package/dist/templates/transcription-workflow/agent.ts +92 -0
  194. package/dist/templates/transcription-workflow/client.tsx +234 -0
  195. package/dist/templates/transcription-workflow/workflows/transcribe.ts +416 -0
  196. package/dist/templates/transcription-workflow/workflows/wav.ts +314 -0
  197. package/dist/templates/travel-concierge/agent.test.ts +338 -0
  198. package/dist/templates/travel-concierge/agent.ts +29 -0
  199. package/dist/templates/travel-concierge/client.tsx +161 -0
  200. package/dist/templates/travel-concierge/routing.ts +138 -0
  201. package/dist/templates/travel-concierge/shared.ts +547 -0
  202. package/dist/templates/travel-concierge/system-prompt.md +24 -0
  203. package/dist/templates/travel-concierge/tools/book_car_rental.ts +20 -0
  204. package/dist/templates/travel-concierge/tools/book_excursion.ts +18 -0
  205. package/dist/templates/travel-concierge/tools/book_hotel.ts +20 -0
  206. package/dist/templates/travel-concierge/tools/cancel_action.ts +7 -0
  207. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +16 -0
  208. package/dist/templates/travel-concierge/tools/complete_or_escalate.ts +7 -0
  209. package/dist/templates/travel-concierge/tools/confirm_action.ts +7 -0
  210. package/dist/templates/travel-concierge/tools/lookup_booking.ts +35 -0
  211. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +33 -0
  212. package/dist/templates/travel-concierge/tools/search_excursions.ts +37 -0
  213. package/dist/templates/travel-concierge/tools/search_flights.ts +43 -0
  214. package/dist/templates/travel-concierge/tools/search_hotels.ts +36 -0
  215. package/dist/templates/travel-concierge/tools/to_car_rental_assistant.ts +11 -0
  216. package/dist/templates/travel-concierge/tools/to_excursion_assistant.ts +11 -0
  217. package/dist/templates/travel-concierge/tools/to_flight_assistant.ts +11 -0
  218. package/dist/templates/travel-concierge/tools/to_hotel_assistant.ts +11 -0
  219. package/dist/templates/travel-concierge/tools/update_ticket.ts +22 -0
  220. package/dist/templates/web-researcher/agent.ts +0 -2
  221. package/dist/templates/web-researcher/system-prompt.md +10 -0
  222. package/dist/{test-2LWBcXvY.mjs → test-B6ZBxSpk.mjs} +2 -2
  223. package/dist/typecheck.mjs +17 -15
  224. package/dist/worker-bundler-CIdDhZ0c.mjs +398 -0
  225. package/dist/worker-bundler.d.ts +14 -0
  226. package/dist/worker-bundler.mjs +1 -108
  227. package/dist/workflow-B-O97AqI.mjs +125 -0
  228. package/dist/workflow-bundler.d.ts +133 -0
  229. package/dist/workflow.d.ts +67 -0
  230. package/package.json +13 -9
  231. package/dist/build-DQJ1ZSm9.mjs +0 -39
@@ -0,0 +1,558 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * The durable half of the research desk: a real deep-research pass.
4
+ *
5
+ * Read `transcription-workflow/workflows/transcribe.ts` for the rules every
6
+ * directive body obeys — replayed from the top, so no live handles and no
7
+ * undurable decisions; step arguments and results are serialized, so pass an id
8
+ * and not a payload. What this template adds is the OTHER kind of long work:
9
+ * transcription is a fan-out over a known list, and research is a fan-out whose
10
+ * width, depth and second wave are all decided by the model as it goes.
11
+ *
12
+ * ```text
13
+ * writeBrief 1 step → the request as something a researcher is held to
14
+ * planAngles 1 step → the angles worth pursuing (the fan-out's width)
15
+ * investigate N steps → one researcher each: search, read, compress
16
+ * findGaps 1 step → the supervisor's second look
17
+ * investigate M steps → the second wave, when there is one
18
+ * writeReport 1 step → the report, then the sentence for the phone
19
+ * sleep + file 1 step → the review wait, then filing
20
+ * ```
21
+ *
22
+ * ## A step can do what a TOOL can do, and that is what makes this real
23
+ *
24
+ * `investigate` calls `webSearch` and `visitWebpage` from
25
+ * `@alexkroman1/aai/tools` — the SAME implementations behind the model-facing
26
+ * builtins, with the same URL screening, redirect re-validation and size caps.
27
+ * A step is not a lesser environment than a tool body: it is bundled with
28
+ * everything it imports, so anything a tool can reach it can reach. Before this,
29
+ * this template's "research" was three model calls asking a model what it
30
+ * already believed, which is the thing deep research exists not to be.
31
+ *
32
+ * The stage shape and its stop rules come from LangChain's
33
+ * `open_deep_research`; `prompts.ts` carries the attribution and what was
34
+ * adapted.
35
+ *
36
+ * ## Every stage REPORTS, and the report goes two places
37
+ *
38
+ * `report()` (`@alexkroman1/aai/utils`) writes to the run's own stream — which
39
+ * `research_progress` reads back down the phone and a page renders — and to the
40
+ * server log, with the attempt number appended past the first. A pass that is
41
+ * retrying and one that is working print the same sentences otherwise.
42
+ *
43
+ * ## Where the model calls are, and why the loop is INSIDE one step
44
+ *
45
+ * A researcher's search loop is journaled as ONE step result rather than one per
46
+ * iteration, which is deliberate: the loop is a negotiation with a model and a
47
+ * search engine, and replaying it turn by turn would pin a run to decisions that
48
+ * were only ever provisional. What has to survive a resume is what the
49
+ * researcher CONCLUDED, which is exactly what the step returns.
50
+ */
51
+
52
+ import { throwStepError } from "@alexkroman1/aai/step-errors";
53
+ 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";
62
+ import { sleep } from "workflow";
63
+ import { z } from "zod";
64
+ import {
65
+ BRIEF_SUMMARY_SYSTEM,
66
+ BRIEF_SYSTEM,
67
+ COMPRESS_SYSTEM,
68
+ GAPS_SYSTEM,
69
+ PLAN_SYSTEM,
70
+ REPORT_SYSTEM,
71
+ RESEARCH_SYSTEM,
72
+ } from "./prompts.ts";
73
+
74
+ /** Angles investigated at once. The far side of every one is a rate limit. */
75
+ const ANGLE_CONCURRENCY = 2;
76
+
77
+ /**
78
+ * How long the desk sits on a finished report before filing it.
79
+ *
80
+ * Short enough to watch in `aai dev`. Nothing about this file changes if it is
81
+ * `"6 hours"` — which is the interesting version, and the one a real desk would
82
+ * use; what makes either affordable is that the run is SUSPENDED rather than
83
+ * blocked, so the sandbox is free to exit and the run resumes when it comes due.
84
+ */
85
+ const REVIEW_DELAY = "30 seconds";
86
+
87
+ /** Most angles a wave may carry, whatever the supervisor asks for. */
88
+ const MAX_ANGLES = 4;
89
+
90
+ /**
91
+ * Actions one researcher may take before it must stop.
92
+ *
93
+ * The budget is the mechanism, not the prompt: a model told to stop when it
94
+ * has enough will sometimes not, and a run whose cost is decided by a model is
95
+ * a run nobody can price. Six covers "search, read, search, read" with room to
96
+ * follow one lead.
97
+ */
98
+ const RESEARCH_BUDGET = 6;
99
+
100
+ /** Characters of a page kept for the compression stage. */
101
+ const MAX_PAGE_CHARS = 6000;
102
+
103
+ /** Results asked for per search. Beyond this they stop being about the query. */
104
+ const SEARCH_RESULTS = 5;
105
+
106
+ /** One source a researcher actually used. */
107
+ export type Source = { title: string; url: string };
108
+
109
+ // ---- What each stage's model call has to come back as ------------------------
110
+ //
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
114
+ // deliberately LENIENT wherever the old hand-written coercion was: a model that
115
+ // put one number in an array of strings should cost that element, not the whole
116
+ // pass.
117
+
118
+ /**
119
+ * A model's array of strings, with everything else dropped.
120
+ *
121
+ * `.catch([])` covers the field being absent or not an array at all, which is
122
+ * the same "take what is usable" rule applied one level up.
123
+ */
124
+ const StringList = z
125
+ .array(z.unknown())
126
+ .transform((values) =>
127
+ values.filter((value): value is string => typeof value === "string" && value.trim().length > 0),
128
+ )
129
+ .catch([]);
130
+
131
+ /** One cited source, as the compression stage is asked to report it. */
132
+ const CitedSource = z.object({ title: z.string(), url: z.string() });
133
+
134
+ /** The cited sources, with any malformed entry dropped rather than fatal. */
135
+ const CitedSources = z.array(z.unknown()).transform((items) =>
136
+ items.flatMap((item) => {
137
+ const parsed = CitedSource.safeParse(item);
138
+ return parsed.success ? [parsed.data] : [];
139
+ }),
140
+ );
141
+
142
+ /** What `writeBrief` asks for. */
143
+ const BriefReply = z.object({ brief: z.string().trim().optional(), criteria: StringList });
144
+
145
+ /** What `planAngles` and `findGaps` ask for. */
146
+ const AnglesReply = z.object({ angles: StringList });
147
+
148
+ /**
149
+ * What one turn of the researcher's loop asks for.
150
+ *
151
+ * `.catch("stop")` is the old `parsed.action === "search" || …` guard: an action
152
+ * the model did not name is a stop, not a fatal reply, because the budget is
153
+ * better spent than burned on turns that cannot do anything.
154
+ */
155
+ const ActionReply = z.object({
156
+ action: z.enum(["search", "read", "stop"]).catch("stop"),
157
+ query: z.string().optional(),
158
+ url: z.string().optional(),
159
+ why: z.string().optional(),
160
+ });
161
+
162
+ /**
163
+ * What `compress` asks for.
164
+ *
165
+ * `sources` is `.catch(undefined)` rather than merely optional, and the
166
+ * distinction is the one this whole stage turns on: a model that returned
167
+ * something unusable there should fall back to the sources the researcher was
168
+ * ACTUALLY shown, not throw the compressed findings away and research the angle
169
+ * again.
170
+ */
171
+ const CompressReply = z.object({
172
+ findings: z.string().optional(),
173
+ sources: CitedSources.optional().catch(undefined),
174
+ });
175
+
176
+ /** What one researcher concluded about one angle. */
177
+ export type Note = {
178
+ angle: string;
179
+ /** The compressed findings — kept long on purpose; a later step summarizes. */
180
+ findings: string;
181
+ sources: Source[];
182
+ };
183
+
184
+ /** The research brief, as `writeBrief` settles it. */
185
+ export type Brief = {
186
+ brief: string;
187
+ /** What a complete answer has to contain — what `findGaps` measures against. */
188
+ criteria: string[];
189
+ };
190
+
191
+ /** What one research pass produces. */
192
+ export type Findings = {
193
+ topic: string;
194
+ /** Two sentences, for an agent to read down a phone. */
195
+ summary: string;
196
+ /** The written report — markdown, cited. What a page renders. */
197
+ report: string;
198
+ /** How many distinct sources were used, which is what the voice agent quotes. */
199
+ sources: number;
200
+ angles: string[];
201
+ };
202
+
203
+ /**
204
+ * Research `topic` properly and return something worth reading back.
205
+ *
206
+ * Five to twelve model calls and as many searches, which is the point: it takes
207
+ * long enough that a caller cannot wait for it, and that is what a durable run
208
+ * is for. `agent.ts` starts this with `notify`, so the agent says so when it
209
+ * lands rather than waiting to be asked.
210
+ *
211
+ * The `sleep` on top is the review wait — the one suspension in the template,
212
+ * and what `file_it_now` skips with `wakeUp`.
213
+ */
214
+ export async function researchFlow(input: { topic: string; requestedBy: string }) {
215
+ "use workflow";
216
+
217
+ const brief = await writeBrief(input.topic);
218
+ const angles = await planAngles(brief);
219
+
220
+ // One step per angle, bounded, in an order a replay reproduces exactly. A
221
+ // failed angle fails the RUN: its finished siblings are already journaled, so
222
+ // the resume replays them for free and re-issues only what is missing, where
223
+ // catching here would file a report with a silent hole in it.
224
+ const first = await mapInBatches(angles, ANGLE_CONCURRENCY, (angle) => investigate(brief, angle));
225
+
226
+ // The supervisor's second look. Usually empty — a second wave costs the caller
227
+ // minutes, and the prompt says so.
228
+ const gaps = await findGaps(brief, first);
229
+ const second = await mapInBatches(gaps, ANGLE_CONCURRENCY, (angle) => investigate(brief, angle));
230
+
231
+ const notes = [...first, ...second];
232
+ const written = await writeReport(input.topic, brief, notes);
233
+
234
+ // Suspended, not blocked. On resume the body re-runs from the top and every
235
+ // step above returns its journaled result rather than researching again —
236
+ // which is also what `file_it_now` ends early, through `wakeUp`.
237
+ await sleep(REVIEW_DELAY);
238
+
239
+ // Whatever this returns is what `ctx.workflows.get(runId)` reports as `output`
240
+ // on a completed run — so it is what the agent reads back, and what the
241
+ // announcement is built from.
242
+ return {
243
+ topic: input.topic,
244
+ summary: written.summary,
245
+ report: written.report,
246
+ sources: countSources(notes),
247
+ angles: notes.map((note) => note.angle),
248
+ filedAt: await file(input.requestedBy, input.topic),
249
+ } satisfies Findings & { filedAt: string };
250
+ }
251
+
252
+ /**
253
+ * Turn the phone request into a brief.
254
+ *
255
+ * A step rather than body code for the ordinary reason — it does I/O — and a
256
+ * stage at all because everything downstream measures against it: a request
257
+ * that arrived as four words is otherwise re-interpreted, differently, by every
258
+ * later model call.
259
+ */
260
+ export async function writeBrief(topic: string): Promise<Brief> {
261
+ "use step";
262
+
263
+ await report(`Working out what "${topic}" is really asking.`);
264
+ const parsed = await askJson(
265
+ `Research request, as the caller said it: ${topic}`,
266
+ BRIEF_SYSTEM,
267
+ BriefReply,
268
+ );
269
+ return { brief: parsed.brief || topic, criteria: parsed.criteria.slice(0, MAX_ANGLES) };
270
+ }
271
+
272
+ /**
273
+ * Break the brief into the angles worth pursuing.
274
+ *
275
+ * The fan-out's WIDTH comes from this step's journaled result, which is the
276
+ * ordinary determinism rule: a replay re-derives the same list rather than
277
+ * asking the model again and getting a different one.
278
+ */
279
+ export async function planAngles(brief: Brief): Promise<string[]> {
280
+ "use step";
281
+
282
+ const parsed = await askJson(briefText(brief), PLAN_SYSTEM, AnglesReply);
283
+ const angles = parsed.angles.slice(0, MAX_ANGLES);
284
+ if (angles.length === 0) {
285
+ // Nothing to fan out over is a plan failure, not an empty result: the brief
286
+ // itself is the one angle that is always available.
287
+ await report("No angles came back; researching the brief itself.");
288
+ return [brief.brief];
289
+ }
290
+ await report(`Researching ${angles.length} angle${angles.length === 1 ? "" : "s"}.`);
291
+ return angles;
292
+ }
293
+
294
+ /**
295
+ * Investigate one angle: search, read, stop, compress.
296
+ *
297
+ * The loop is the researcher — the model chooses each action and the budget is
298
+ * what ends it. Everything it saw is kept as raw material for the compression
299
+ * at the end, which is where it becomes small enough to journal.
300
+ */
301
+ export async function investigate(brief: Brief, angle: string): Promise<Note> {
302
+ "use step";
303
+
304
+ await report(`Looking into: ${angle}`);
305
+ const seen: string[] = [];
306
+ const sources: Source[] = [];
307
+
308
+ for (let spent = 0; spent < RESEARCH_BUDGET; spent++) {
309
+ const action = await nextAction(brief, angle, seen, RESEARCH_BUDGET - spent);
310
+ if (action.action === "stop") break;
311
+ if (action.action === "search" && action.query) {
312
+ const found = await search(action.query);
313
+ seen.push(`SEARCH ${action.query}\n${found.summary}`);
314
+ sources.push(...found.sources);
315
+ continue;
316
+ }
317
+ if (action.action === "read" && action.url) {
318
+ await report(`Reading ${hostname(action.url)}`);
319
+ seen.push(`PAGE ${action.url}\n${await readPage(action.url)}`);
320
+ continue;
321
+ }
322
+ // An action the model did not fill in: stop rather than spend the budget on
323
+ // turns that cannot do anything.
324
+ break;
325
+ }
326
+
327
+ return await compress(angle, seen, sources);
328
+ }
329
+
330
+ /** Retries beyond the default: the far side is a search engine and a model. */
331
+ investigate.maxRetries = 4;
332
+
333
+ /**
334
+ * The supervisor's second look.
335
+ *
336
+ * Bounded to one extra wave by construction — this is called once — because the
337
+ * failure mode of an open-ended supervisor is a run that never converges, and a
338
+ * caller who is told "still working" for twenty minutes.
339
+ */
340
+ export async function findGaps(brief: Brief, notes: readonly Note[]): Promise<string[]> {
341
+ "use step";
342
+
343
+ if (notes.length === 0) return [];
344
+ const parsed = await askJson(
345
+ `${briefText(brief)}\n\nWhat came back:\n${notes.map(noteText).join("\n\n")}`,
346
+ GAPS_SYSTEM,
347
+ AnglesReply,
348
+ );
349
+ const gaps = parsed.angles.slice(0, MAX_ANGLES - 1);
350
+ await report(
351
+ gaps.length === 0
352
+ ? "The brief is covered; writing it up."
353
+ : `Following up ${gaps.length} gap${gaps.length === 1 ? "" : "s"}.`,
354
+ );
355
+ return gaps;
356
+ }
357
+
358
+ /**
359
+ * Write the report, then the sentence a phone can carry.
360
+ *
361
+ * Two model calls in one step because they are one decision: the summary is a
362
+ * reduction OF the report, and journaling them separately would let a resume
363
+ * pair a new summary with an old report.
364
+ */
365
+ export async function writeReport(
366
+ topic: string,
367
+ brief: Brief,
368
+ notes: readonly Note[],
369
+ ): Promise<{ report: string; summary: string }> {
370
+ "use step";
371
+
372
+ await report(`Writing up ${notes.length} angle${notes.length === 1 ? "" : "s"}.`);
373
+ const written = await ask(
374
+ `${briefText(brief)}\n\nFindings:\n${notes.map(noteText).join("\n\n")}`,
375
+ REPORT_SYSTEM,
376
+ );
377
+ const summary = await ask(`Topic: ${topic}\n\nReport:\n${written}`, BRIEF_SUMMARY_SYSTEM);
378
+ return { report: written, summary };
379
+ }
380
+
381
+ /**
382
+ * File the finished research.
383
+ *
384
+ * `ctx.db` is the one half of a tool context a step still does not get, so this
385
+ * writes nothing and says so rather than naming a call it cannot make. The
386
+ * parameters carry `_` for the same reason.
387
+ */
388
+ export async function file(_requestedBy: string, _topic: string): Promise<string> {
389
+ "use step";
390
+
391
+ await report("Filing the findings.");
392
+ return "filed";
393
+ }
394
+
395
+ // ---- The researcher's own calls ---------------------------------------------
396
+
397
+ /** What the model wants to do next. */
398
+ type Action = z.infer<typeof ActionReply>;
399
+
400
+ /** Ask the model for one action, given everything the researcher has seen. */
401
+ async function nextAction(
402
+ brief: Brief,
403
+ angle: string,
404
+ seen: readonly string[],
405
+ left: number,
406
+ ): Promise<Action> {
407
+ return await askJson(
408
+ `${briefText(brief)}\n\nYour angle: ${angle}\n` +
409
+ `Actions left: ${left}\n\n` +
410
+ (seen.length === 0 ? "You have not looked at anything yet." : seen.join("\n\n")),
411
+ RESEARCH_SYSTEM,
412
+ ActionReply,
413
+ );
414
+ }
415
+
416
+ /**
417
+ * One search, through the SAME implementation the `web_search` builtin uses.
418
+ *
419
+ * A failed search is not a failed angle, and the failure goes back into `seen`
420
+ * rather than only into the log: the researcher's next turn is chosen from what
421
+ * it has been shown, so a search that quietly returned nothing reads as "no such
422
+ * pages exist" and gets run again, differently worded, until the budget is gone.
423
+ */
424
+ async function search(query: string): Promise<{ summary: string; sources: Source[] }> {
425
+ await report(`Searching: ${query}`);
426
+ try {
427
+ const results = await webSearch<{ results?: { title?: string; url?: string }[] }>({
428
+ query,
429
+ max_results: SEARCH_RESULTS,
430
+ });
431
+ // The `catch` below was written for exactly this and could not reach it:
432
+ // `webSearch` ANSWERS with `{ error }` rather than throwing, so a refused
433
+ // search arrived here as an empty result list and was reported to the
434
+ // researcher as "No results." — the thing this function's doc says not to do.
435
+ if (isToolFailure(results)) throw new Error(results.error);
436
+ const sources = (results.results ?? [])
437
+ .filter((one): one is { title: string; url: string } =>
438
+ Boolean(typeof one.url === "string" && one.url),
439
+ )
440
+ .map((one) => ({ title: one.title || one.url, url: one.url }));
441
+ return {
442
+ summary: sources.length === 0 ? "No results." : sources.map(describeResult).join("\n"),
443
+ sources,
444
+ };
445
+ } catch (err: unknown) {
446
+ const summary = `That search failed: ${errorMessage(err)}`;
447
+ await report(summary);
448
+ return { summary, sources: [] };
449
+ }
450
+ }
451
+
452
+ /** One page, capped — the compression stage reads this, not a browser. */
453
+ async function readPage(url: string): Promise<string> {
454
+ try {
455
+ const page = await visitWebpage<{ content?: string; text?: string }>(url);
456
+ // Same rule as the search above: an unreadable page ANSWERS with `{ error }`,
457
+ // and `?? ""` would put an empty note in front of the compression stage —
458
+ // which reads as "this page said nothing" rather than "we never read it".
459
+ if (isToolFailure(page)) throw new Error(page.error);
460
+ return String(page.content ?? page.text ?? "").slice(0, MAX_PAGE_CHARS);
461
+ } catch (err: unknown) {
462
+ return `Could not read this page: ${errorMessage(err)}`;
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Compress what one researcher saw into a journaled note.
468
+ *
469
+ * The stage that keeps a step's result small enough to carry, and the one whose
470
+ * prompt says to REPEAT rather than summarize: a summary of a summary is how a
471
+ * long research pass ends in a confident, sourceless paragraph.
472
+ */
473
+ async function compress(angle: string, seen: readonly string[], sources: Source[]): Promise<Note> {
474
+ if (seen.length === 0) {
475
+ return { angle, findings: "Nothing was found on this angle.", sources: [] };
476
+ }
477
+ const parsed = await askJson(
478
+ `Angle: ${angle}\n\n${seen.join("\n\n")}`,
479
+ COMPRESS_SYSTEM,
480
+ CompressReply,
481
+ );
482
+ return {
483
+ angle,
484
+ findings: parsed.findings ?? seen.join("\n\n"),
485
+ // A model that cited nothing at all falls back to what the researcher was
486
+ // actually shown, which is the honest answer and not an empty one.
487
+ sources: parsed.sources ?? dedupe(sources).slice(0, SEARCH_RESULTS),
488
+ };
489
+ }
490
+
491
+ // ---- 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
+ }
517
+
518
+ // ---- Pure helpers -----------------------------------------------------------
519
+
520
+ /** The brief as the models are shown it. */
521
+ function briefText(brief: Brief): string {
522
+ const criteria = brief.criteria.map((one) => `- ${one}`).join("\n");
523
+ return criteria
524
+ ? `Brief: ${brief.brief}\n\nA complete answer covers:\n${criteria}`
525
+ : `Brief: ${brief.brief}`;
526
+ }
527
+
528
+ /** One note, as a later stage reads it. */
529
+ function noteText(note: Note): string {
530
+ const cited = note.sources.map((one, at) => `[${at + 1}] ${one.title} — ${one.url}`).join("\n");
531
+ return `## ${note.angle}\n${note.findings}\n${cited}`;
532
+ }
533
+
534
+ /** One search result, as the researcher sees it. */
535
+ function describeResult(source: Source): string {
536
+ return `- ${source.title} — ${source.url}`;
537
+ }
538
+
539
+ /** Distinct sources by URL, first occurrence winning. */
540
+ export function dedupe(sources: readonly Source[]): Source[] {
541
+ const byUrl = new Map<string, Source>();
542
+ for (const one of sources) if (!byUrl.has(one.url)) byUrl.set(one.url, one);
543
+ return [...byUrl.values()];
544
+ }
545
+
546
+ /** How many distinct sources the whole pass rests on — what the agent quotes. */
547
+ export function countSources(notes: readonly Note[]): number {
548
+ return dedupe(notes.flatMap((note) => note.sources)).length;
549
+ }
550
+
551
+ /** A URL's host, for a progress line a listener can follow. */
552
+ function hostname(url: string): string {
553
+ try {
554
+ return new URL(url).hostname;
555
+ } catch {
556
+ return url;
557
+ }
558
+ }