@alexkroman1/aai-cli 7.0.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 (122) hide show
  1. package/README.md +1 -0
  2. package/dist/{_bundler-B4RqNF5Z.mjs → _bundler-CDuYl5Gb.mjs} +1 -1
  3. package/dist/{_dev-server-CiKFH9qw.mjs → _dev-server-Me06wgeF.mjs} +2 -2
  4. package/dist/{_init-CE4HKdgV.mjs → _init-BhEfQ6Yi.mjs} +1 -1
  5. package/dist/_resource-commands.d.ts +23 -0
  6. package/dist/{_slug-api-nRFaBEPJ.mjs → _slug-api-z-USxPtL.mjs} +14 -10
  7. package/dist/_slug-api.d.ts +5 -2
  8. package/dist/{_templates-FI3xPEMj.mjs → _templates-4WcKOjS5.mjs} +4 -5
  9. package/dist/_templates.d.ts +3 -4
  10. package/dist/{build-6jfaakgc.mjs → build-BBxf8vom.mjs} +2 -2
  11. package/dist/cli.mjs +338 -346
  12. package/dist/{deploy-DvYeD8ia.mjs → deploy-DRC8jWr8.mjs} +1 -1
  13. package/dist/{dev-B59JIfWO.mjs → dev-BvcctCn2.mjs} +1 -1
  14. package/dist/eval-DfQ8cm5p.mjs +56 -0
  15. package/dist/eval.d.ts +43 -0
  16. package/dist/{init-CpJVjEg0.mjs → init-CUo87gcI.mjs} +2 -2
  17. package/dist/{logs-9q-psa4q.mjs → logs-CQ2T46dr.mjs} +1 -1
  18. package/dist/scaffold/CLAUDE.md +409 -68
  19. package/dist/scaffold/package.json +5 -4
  20. package/dist/scaffold/server.mjs +10 -3
  21. package/dist/scaffold/tsconfig.json +0 -1
  22. package/dist/{secret-CVvSLIDV.mjs → secret-Ct34kdgU.mjs} +1 -1
  23. package/dist/{storage-BvUrnvM3.mjs → storage-i_79ZwAR.mjs} +1 -1
  24. package/dist/{studio--MUV0cid.mjs → studio-CzTzPqe1.mjs} +1 -1
  25. package/dist/templates/briefing-desk/agent.test.ts +350 -0
  26. package/dist/templates/briefing-desk/agent.ts +30 -0
  27. package/dist/templates/briefing-desk/shared.ts +200 -0
  28. package/dist/templates/briefing-desk/system-prompt.md +39 -0
  29. package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
  30. package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
  31. package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
  32. package/dist/templates/call-audit/agent.eval.test.ts +474 -0
  33. package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
  34. package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
  35. package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
  36. package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
  37. package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
  38. package/dist/templates/infocom-adventure/agent.test.ts +49 -18
  39. package/dist/templates/infocom-adventure/agent.ts +21 -0
  40. package/dist/templates/infocom-adventure/shared.ts +20 -9
  41. package/dist/templates/infocom-adventure/system-prompt.md +1 -1
  42. package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
  43. package/dist/templates/link-digest/agent.eval.test.ts +195 -0
  44. package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
  45. package/dist/templates/night-owl/agent.eval.test.ts +272 -0
  46. package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
  47. package/dist/templates/personal-finance/system-prompt.md +4 -0
  48. package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
  49. package/dist/templates/pipeline-simple/agent.ts +2 -2
  50. package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
  51. package/dist/templates/pizza-ordering/agent.test.ts +15 -0
  52. package/dist/templates/pizza-ordering/shared.ts +20 -1
  53. package/dist/templates/pizza-ordering/system-prompt.md +6 -0
  54. package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
  55. package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
  56. package/dist/templates/podcast-digest/agent.test.ts +30 -59
  57. package/dist/templates/podcast-digest/agent.ts +1 -1
  58. package/dist/templates/podcast-digest/workflows/digest.ts +52 -19
  59. package/dist/templates/podcast-digest/workflows/slack.ts +48 -169
  60. package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
  61. package/dist/templates/redline/agent.eval.test.ts +209 -0
  62. package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
  63. package/dist/templates/retail/address.ts +87 -1
  64. package/dist/templates/retail/agent.eval.test.ts +314 -0
  65. package/dist/templates/retail/agent.test.ts +171 -91
  66. package/dist/templates/retail/agent.ts +35 -1
  67. package/dist/templates/retail/cancel.ts +116 -0
  68. package/dist/templates/retail/client.tsx +39 -0
  69. package/dist/templates/retail/payment.ts +107 -0
  70. package/dist/templates/retail/pending.test.ts +196 -0
  71. package/dist/templates/retail/pending.ts +137 -0
  72. package/dist/templates/retail/registry.test.ts +119 -8
  73. package/dist/templates/retail/returns.ts +102 -0
  74. package/dist/templates/retail/shared.test.ts +42 -0
  75. package/dist/templates/retail/shared.ts +35 -0
  76. package/dist/templates/retail/store.test.ts +6 -4
  77. package/dist/templates/retail/store.ts +113 -57
  78. package/dist/templates/retail/swap.test.ts +4 -4
  79. package/dist/templates/retail/swap.ts +248 -15
  80. package/dist/templates/retail/system-prompt.md +62 -30
  81. package/dist/templates/retail/tools/cancel_change.ts +33 -0
  82. package/dist/templates/retail/tools/cancel_pending_order.ts +15 -59
  83. package/dist/templates/retail/tools/confirm_change.ts +42 -0
  84. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +21 -66
  85. package/dist/templates/retail/tools/modify_pending_order_address.ts +12 -24
  86. package/dist/templates/retail/tools/modify_pending_order_items.ts +22 -60
  87. package/dist/templates/retail/tools/modify_pending_order_payment.ts +15 -77
  88. package/dist/templates/retail/tools/modify_user_address.ts +12 -15
  89. package/dist/templates/retail/tools/return_delivered_order_items.ts +16 -71
  90. package/dist/templates/retail/tools/transfer_to_human_agents.ts +4 -2
  91. package/dist/templates/simple/agent.eval.test.ts +54 -0
  92. package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
  93. package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
  94. package/dist/templates/support-line/agent.eval.test.ts +273 -0
  95. package/dist/templates/support-line/system-prompt.md +2 -1
  96. package/dist/templates/support-line/tools/answer_question.ts +56 -7
  97. package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
  98. package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
  99. package/dist/templates/travel-concierge/agent.test.ts +110 -10
  100. package/dist/templates/travel-concierge/agent.ts +18 -9
  101. package/dist/templates/travel-concierge/shared.ts +110 -9
  102. package/dist/templates/travel-concierge/system-prompt.md +7 -1
  103. package/dist/templates/travel-concierge/tools/book_car_rental.ts +6 -3
  104. package/dist/templates/travel-concierge/tools/book_excursion.ts +6 -3
  105. package/dist/templates/travel-concierge/tools/book_hotel.ts +6 -3
  106. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +6 -3
  107. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
  108. package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
  109. package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
  110. package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
  111. package/dist/templates/travel-concierge/tools/update_ticket.ts +6 -3
  112. package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
  113. package/dist/templates/web-researcher/system-prompt.md +15 -4
  114. package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
  115. package/dist/test.d.ts +29 -5
  116. package/dist/{worker-bundler-COxnqstQ.mjs → worker-bundler-CCVEDjm1.mjs} +45 -25
  117. package/dist/worker-bundler.mjs +1 -1
  118. package/dist/{workflow-D2AQf2Pl.mjs → workflow-BflATMmU.mjs} +19 -11
  119. package/package.json +5 -5
  120. package/dist/eject-C9WJyyr2.mjs +0 -116
  121. package/dist/eject.d.ts +0 -60
  122. package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
@@ -10,12 +10,13 @@
10
10
  "start": "node server.mjs",
11
11
  "build": "aai build",
12
12
  "test": "aai test",
13
+ "eval": "aai eval",
13
14
  "publish:agent": "aai publish"
14
15
  },
15
16
  "dependencies": {
16
- "@alexkroman1/aai": "^7.0.0",
17
- "@alexkroman1/aai-runtime": "^7.0.0",
18
- "@alexkroman1/aai-ui": "^7.0.0",
17
+ "@alexkroman1/aai": "^8.0.0",
18
+ "@alexkroman1/aai-runtime": "^8.0.0",
19
+ "@alexkroman1/aai-ui": "^8.0.0",
19
20
  "@workflow/world-postgres": "4.3.3",
20
21
  "react": "^19.2.8",
21
22
  "react-dom": "^19.2.8",
@@ -25,7 +26,7 @@
25
26
  "zod": "^4.4.3"
26
27
  },
27
28
  "devDependencies": {
28
- "@alexkroman1/aai-cli": "^7.0.0",
29
+ "@alexkroman1/aai-cli": "^8.0.0",
29
30
  "@tailwindcss/vite": "^4.3.3",
30
31
  "@types/node": "^26.2.0",
31
32
  "@types/react": "^19.2.18",
@@ -56,7 +56,12 @@ if (!existsSync(workerPath)) {
56
56
  // Windows, where a bare POSIX-looking path is not a valid module specifier.
57
57
  const { default: agent } = await import(pathToFileURL(workerPath).href);
58
58
 
59
- /** Parse a dotenv-syntax file into a record; `{}` when it does not exist. */
59
+ /**
60
+ * Parse a dotenv-syntax file into a record; `{}` when it does not exist.
61
+ *
62
+ * @param {string} file - Path relative to the project root.
63
+ * @returns {Promise<Record<string, string | undefined>>}
64
+ */
60
65
  async function readEnvFile(file) {
61
66
  try {
62
67
  return parseEnv(await readFile(path.join(root, file), "utf-8"));
@@ -84,13 +89,15 @@ async function readEnvFile(file) {
84
89
  */
85
90
  async function resolveAgentEnv() {
86
91
  const declared = { ...(await readEnvFile(".env.example")), ...(await readEnvFile(".env")) };
92
+ /** @type {Record<string, string>} */
87
93
  const env = {};
88
94
  for (const [key, fileValue] of Object.entries(declared)) {
89
95
  const value = process.env[key] ?? fileValue;
90
96
  // An empty value is worse than a missing one: a provider would try to
91
97
  // authenticate with "" rather than report the credential as absent. The
92
- // example file is full of them by design (`BRAVE_API_KEY=`).
93
- if (value !== "") env[key] = value;
98
+ // example file is full of them by design (`BRAVE_API_KEY=`). A key the
99
+ // parser saw with no value at all is the same case.
100
+ if (value !== undefined && value !== "") env[key] = value;
94
101
  }
95
102
  return env;
96
103
  }
@@ -4,7 +4,6 @@
4
4
  "module": "ESNext",
5
5
  "moduleResolution": "bundler",
6
6
  "strict": true,
7
- "noImplicitAny": false,
8
7
  "useUnknownInCatchVariables": false,
9
8
  "verbatimModuleSyntax": true,
10
9
  "allowImportingTsExtensions": true,
@@ -2,7 +2,7 @@
2
2
  import { a as ok, n as fail } from "./_output-CKkmWs7i.mjs";
3
3
  import { n as log, s as unwrapCancel } from "./_ui-u7T4YooX.mjs";
4
4
  import { f as checkedResponse, p as isStringArray } from "./_studio-DR5yzZ1T.mjs";
5
- import { t as secretRequest } from "./_slug-api-nRFaBEPJ.mjs";
5
+ import { t as secretRequest } from "./_slug-api-z-USxPtL.mjs";
6
6
  import { isRecord } from "@alexkroman1/aai/utils";
7
7
  import * as p from "@clack/prompts";
8
8
  import { text } from "node:stream/consumers";
@@ -2,7 +2,7 @@
2
2
  import { a as ok, n as fail } from "./_output-CKkmWs7i.mjs";
3
3
  import { n as log } from "./_ui-u7T4YooX.mjs";
4
4
  import { f as checkedResponse } from "./_studio-DR5yzZ1T.mjs";
5
- import { n as slugRequest } from "./_slug-api-nRFaBEPJ.mjs";
5
+ import { n as slugRequest } from "./_slug-api-z-USxPtL.mjs";
6
6
  import { isRecord } from "@alexkroman1/aai/utils";
7
7
  import * as p from "@clack/prompts";
8
8
  //#region storage.ts
@@ -4,7 +4,7 @@ import { n as log, t as fmtUrl } from "./_ui-u7T4YooX.mjs";
4
4
  import { s as updateProjectConfig } from "./_config-CmJOFsAP.mjs";
5
5
  import { t as resolveServerEnv } from "./_server-common-6e0QI4mq.mjs";
6
6
  import { a as resolveDeployTarget } from "./_agent-BTwZJu4P.mjs";
7
- import { layerScaffold } from "./_templates-FI3xPEMj.mjs";
7
+ import { layerScaffold } from "./_templates-4WcKOjS5.mjs";
8
8
  import { a as publishStudioProject, c as studioProjectUrl, f as checkedResponse, i as projectNameFromDir, n as fetchStudioProject, o as pushStudioSource, r as listStudioProjects, s as studioProjectApiUrl, t as collectSourceFiles, u as apiRequest } from "./_studio-DR5yzZ1T.mjs";
9
9
  import path from "node:path";
10
10
  import { isRecord } from "@alexkroman1/aai/utils";
@@ -0,0 +1,350 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ import {
4
+ createToolContext,
5
+ type StubDelegateCall,
6
+ stubDelegate,
7
+ toolRunner,
8
+ withDiscoveredTools,
9
+ } from "@alexkroman1/aai/testing";
10
+ import { describe, expect, test } from "vitest";
11
+ import authoredAgent from "./agent.ts";
12
+ import type { AngleWork, Finding } from "./shared.ts";
13
+ import {
14
+ angleBrief,
15
+ briefingSlot,
16
+ countWork,
17
+ factChecker,
18
+ findByAngle,
19
+ MAX_ANGLES,
20
+ MAX_FINDINGS,
21
+ MAX_RESEARCH_STEPS,
22
+ recordFinding,
23
+ researcher,
24
+ } from "./shared.ts";
25
+
26
+ /** A finding whose cost is irrelevant to the case at hand. */
27
+ const NO_WORK: AngleWork = { searches: 0, reads: 0 };
28
+
29
+ /**
30
+ * The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
31
+ *
32
+ * The glob is written HERE rather than reached for from a shared helper because
33
+ * this file SHIPS — it is what a scaffolded project runs, so it may not import
34
+ * anything outside its own template.
35
+ */
36
+ const agentDef = withDiscoveredTools(
37
+ authoredAgent,
38
+ import.meta.glob("./tools/*.ts", { eager: true }),
39
+ );
40
+
41
+ const run = toolRunner(agentDef);
42
+
43
+ /**
44
+ * The desk's two subagents, faked.
45
+ *
46
+ * `stubDelegate` routes by SUBAGENT NAME, which is what tells a research run
47
+ * from a check — the two things this desk actually does. Nothing here runs a
48
+ * model or touches the network: a subagent is a model loop, and a spec that
49
+ * asserted on its steps would be asserting on a provider's choices. What is
50
+ * worth asserting is what the desk ASKS for and what it does with what comes
51
+ * back.
52
+ */
53
+ function desk(
54
+ options: {
55
+ research?: (call: StubDelegateCall) => string | { text: string; searches?: number };
56
+ check?: string;
57
+ } = {},
58
+ ) {
59
+ const research = options.research ?? ((call) => `Findings for ${call.task}.`);
60
+ return stubDelegate({
61
+ researcher: (call) => {
62
+ const reply = research(call);
63
+ if (typeof reply === "string") return { text: reply };
64
+ return {
65
+ text: reply.text,
66
+ toolCalls: Array.from({ length: reply.searches ?? 0 }, (_unused, index) => ({
67
+ name: "web_search",
68
+ input: { query: `q${index}` },
69
+ })),
70
+ };
71
+ },
72
+ "fact-checker": options.check ?? "Confirmed: two sources say so.",
73
+ });
74
+ }
75
+
76
+ describe("the desk itself", () => {
77
+ test("has no web tools of its own — everything goes through a subagent", () => {
78
+ expect(authoredAgent.builtinTools ?? []).toEqual([]);
79
+ // And the researcher does, which is the split the template exists to show.
80
+ expect(researcher.builtinTools).toContain("web_search");
81
+ expect(factChecker.builtinTools).toEqual(["web_search"]);
82
+ });
83
+
84
+ test("gives the checker a tighter budget than the researcher", () => {
85
+ expect(factChecker.maxSteps).toBeLessThan(MAX_RESEARCH_STEPS);
86
+ });
87
+
88
+ test("tells each subagent that its final message is all the desk sees", () => {
89
+ expect(researcher.instructions).toMatch(/FINAL message/);
90
+ });
91
+ });
92
+
93
+ describe("research_topic", () => {
94
+ test("fans every angle out as its own run, each with a self-contained task", async () => {
95
+ const model = desk();
96
+ const ctx = createToolContext({ delegate: model.delegate });
97
+
98
+ await run(
99
+ "research_topic",
100
+ { topic: "home batteries", angles: ["price trend", "install lead times"] },
101
+ ctx,
102
+ );
103
+
104
+ expect(model.calls).toHaveLength(2);
105
+ expect(model.calls.map((call) => call.subagent.name)).toEqual(["researcher", "researcher"]);
106
+ expect(model.calls.map((call) => call.task)).toEqual(["price trend", "install lead times"]);
107
+ // The subagent has not heard the call, so the topic rides in `context`.
108
+ expect(model.calls[0]?.options.context).toContain("home batteries");
109
+ });
110
+
111
+ test("starts the runs concurrently rather than one after another", async () => {
112
+ let inFlight = 0;
113
+ let peak = 0;
114
+ const model = stubDelegate({
115
+ researcher: () => {
116
+ inFlight += 1;
117
+ peak = Math.max(peak, inFlight);
118
+ return "found";
119
+ },
120
+ });
121
+ // The fake answers synchronously, so `inFlight` is only ever 1 unless the
122
+ // tool really did start every run before awaiting any — which is the claim.
123
+ const delegate = ((sub, options) =>
124
+ model.delegate(sub, options).finally(() => {
125
+ inFlight -= 1;
126
+ })) as typeof model.delegate;
127
+
128
+ await run(
129
+ "research_topic",
130
+ { topic: "t", angles: ["a", "b", "c"] },
131
+ createToolContext({ delegate }),
132
+ );
133
+
134
+ expect(peak).toBe(3);
135
+ });
136
+
137
+ test("records what each angle concluded, and only that", async () => {
138
+ const model = desk({
139
+ research: (call) => ({ text: `Answer to ${call.task}.`, searches: 3 }),
140
+ });
141
+ const ctx = createToolContext({ delegate: model.delegate });
142
+
143
+ const result = (await run("research_topic", { topic: "t", angles: ["a"] }, ctx)) as {
144
+ findings: Finding[];
145
+ };
146
+
147
+ expect(result.findings).toEqual([
148
+ { angle: "a", summary: "Answer to a.", work: { searches: 3, reads: 0 } },
149
+ ]);
150
+ const board = briefingSlot.get(ctx);
151
+ expect(board.topic).toBe("t");
152
+ expect(board.findings).toEqual(result.findings);
153
+ });
154
+
155
+ test("one failed angle does not sink the briefing", async () => {
156
+ const model = stubDelegate({
157
+ researcher: (call) => {
158
+ if (call.task === "b") throw new Error("provider is having a day");
159
+ return `Answer to ${call.task}.`;
160
+ },
161
+ });
162
+ const ctx = createToolContext({ delegate: model.delegate });
163
+
164
+ const result = (await run("research_topic", { topic: "t", angles: ["a", "b", "c"] }, ctx)) as {
165
+ findings: { angle: string }[];
166
+ failed: { angle: string; error: string }[];
167
+ message: string;
168
+ };
169
+
170
+ expect(result.findings.map((one) => one.angle)).toEqual(["a", "c"]);
171
+ expect(result.failed).toEqual([{ angle: "b", error: "provider is having a day" }]);
172
+ // And the desk is told to say so rather than quietly reporting two angles.
173
+ expect(result.message).toMatch(/could not get to/);
174
+ expect(briefingSlot.get(ctx).findings).toHaveLength(2);
175
+ });
176
+
177
+ test("fails as a tool when every angle fails, quoting the first reason", async () => {
178
+ const model = stubDelegate({
179
+ researcher: () => {
180
+ throw new Error("gateway said no");
181
+ },
182
+ });
183
+
184
+ const result = await run(
185
+ "research_topic",
186
+ { topic: "t", angles: ["a", "b"] },
187
+ createToolContext({ delegate: model.delegate }),
188
+ );
189
+
190
+ expect(result).toEqual({ error: expect.stringContaining("gateway said no") });
191
+ });
192
+
193
+ test("refuses a call whose angles are all blank, without spending a subagent", async () => {
194
+ const model = desk();
195
+
196
+ const result = await run(
197
+ "research_topic",
198
+ { topic: "t", angles: [" "] },
199
+ createToolContext({ delegate: model.delegate }),
200
+ );
201
+
202
+ expect(result).toEqual({ error: expect.stringContaining("No angles") });
203
+ expect(model.calls).toEqual([]);
204
+ });
205
+
206
+ test("accepts at most MAX_ANGLES angles", () => {
207
+ const schema = agentDef.tools.research_topic?.inputSchema;
208
+ const tooMany = { topic: "t", angles: Array.from({ length: MAX_ANGLES + 1 }, () => "a") };
209
+ expect(schema?.["~standard"].validate(tooMany)).toMatchObject({ issues: expect.anything() });
210
+ });
211
+ });
212
+
213
+ describe("verify_claim", () => {
214
+ test("asks the fact-checker, not the researcher", async () => {
215
+ const model = desk({ check: "Contradicted: the figure is 12%." });
216
+ const ctx = createToolContext({ delegate: model.delegate });
217
+
218
+ const result = (await run("verify_claim", { claim: "The figure is 40%." }, ctx)) as {
219
+ verdict: string;
220
+ checkedAgainst: string | null;
221
+ };
222
+
223
+ expect(model.calls.map((call) => call.subagent.name)).toEqual(["fact-checker"]);
224
+ expect(result.verdict).toBe("Contradicted: the figure is 12%.");
225
+ expect(result.checkedAgainst).toBeNull();
226
+ });
227
+
228
+ test("quotes the finding a claim came from, so the checker can see the source", async () => {
229
+ const model = desk();
230
+ const ctx = createToolContext({ delegate: model.delegate });
231
+ briefingSlot.update(ctx, (board) => {
232
+ recordFinding(board, {
233
+ angle: "install lead times",
234
+ summary: "Installers quote eight weeks.",
235
+ work: { searches: 2, reads: 1 },
236
+ });
237
+ });
238
+
239
+ const result = (await run(
240
+ "verify_claim",
241
+ { claim: "Installs take eight weeks.", about: "lead times" },
242
+ ctx,
243
+ )) as { checkedAgainst: string | null };
244
+
245
+ expect(model.calls[0]?.options.context).toContain("Installers quote eight weeks.");
246
+ expect(result.checkedAgainst).toBe("install lead times");
247
+ });
248
+
249
+ test("reports a failed check as a tool failure the model can recover from", async () => {
250
+ const model = stubDelegate({
251
+ "fact-checker": () => {
252
+ throw new Error("checker timed out");
253
+ },
254
+ });
255
+
256
+ const result = await run(
257
+ "verify_claim",
258
+ { claim: "Something." },
259
+ createToolContext({ delegate: model.delegate }),
260
+ );
261
+
262
+ expect(result).toEqual({ error: expect.stringContaining("checker timed out") });
263
+ });
264
+
265
+ test("refuses a blank claim without spending a subagent", async () => {
266
+ const model = desk();
267
+ const result = await run(
268
+ "verify_claim",
269
+ { claim: " " },
270
+ createToolContext({ delegate: model.delegate }),
271
+ );
272
+ expect(result).toEqual({ error: expect.stringContaining("Nothing to check") });
273
+ expect(model.calls).toEqual([]);
274
+ });
275
+ });
276
+
277
+ describe("briefing_so_far", () => {
278
+ test("says so when there is nothing yet, and spends no subagent", async () => {
279
+ const ctx = createToolContext();
280
+ const result = (await run("briefing_so_far", {}, ctx)) as { findings: unknown[] };
281
+ expect(result.findings).toEqual([]);
282
+ });
283
+
284
+ test("adds the lookups up across every angle", async () => {
285
+ const ctx = createToolContext();
286
+ briefingSlot.update(ctx, (board) => {
287
+ board.topic = "t";
288
+ recordFinding(board, { angle: "a", summary: "A.", work: { searches: 2, reads: 1 } });
289
+ recordFinding(board, { angle: "b", summary: "B.", work: { searches: 3, reads: 0 } });
290
+ });
291
+
292
+ const result = (await run("briefing_so_far", {}, ctx)) as {
293
+ totalSearches: number;
294
+ totalReads: number;
295
+ };
296
+ expect(result.totalSearches).toBe(5);
297
+ expect(result.totalReads).toBe(1);
298
+ });
299
+ });
300
+
301
+ describe("the brief a subagent is sent", () => {
302
+ test("carries the topic, because the subagent has not heard the call", () => {
303
+ expect(angleBrief("home batteries", "price trend")).toEqual({
304
+ task: "price trend",
305
+ context: expect.stringContaining("home batteries"),
306
+ });
307
+ });
308
+ });
309
+
310
+ describe("countWork", () => {
311
+ test("tells searches from page reads", () => {
312
+ expect(
313
+ countWork([
314
+ { name: "web_search", input: {} },
315
+ { name: "visit_webpage", input: {} },
316
+ { name: "web_search", input: {} },
317
+ ]),
318
+ ).toEqual({ searches: 2, reads: 1 });
319
+ });
320
+
321
+ test("counts a tool it does not recognise as neither", () => {
322
+ // A researcher that gains a third tool must not silently inflate "searches".
323
+ expect(countWork([{ name: "think", input: {} }])).toEqual({ searches: 0, reads: 0 });
324
+ });
325
+ });
326
+
327
+ describe("the board", () => {
328
+ test("holds MAX_FINDINGS, dropping the oldest", () => {
329
+ const ctx = createToolContext();
330
+ briefingSlot.update(ctx, (board) => {
331
+ for (let index = 0; index < MAX_FINDINGS + 3; index++) {
332
+ recordFinding(board, { angle: `angle ${index}`, summary: "s", work: NO_WORK });
333
+ }
334
+ });
335
+ const board = briefingSlot.get(ctx);
336
+ expect(board.findings).toHaveLength(MAX_FINDINGS);
337
+ expect(board.findings[0]?.angle).toBe("angle 3");
338
+ });
339
+
340
+ test("finds an angle from a loose mention, and nothing from a blank one", () => {
341
+ const ctx = createToolContext();
342
+ briefingSlot.update(ctx, (board) => {
343
+ recordFinding(board, { angle: "install lead times", summary: "s", work: NO_WORK });
344
+ });
345
+ const board = briefingSlot.get(ctx);
346
+ expect(findByAngle(board, "lead times")?.angle).toBe("install lead times");
347
+ expect(findByAngle(board, " ")).toBeUndefined();
348
+ expect(findByAngle(board, "battery chemistry")).toBeUndefined();
349
+ });
350
+ });
@@ -0,0 +1,30 @@
1
+ import { agent } from "@alexkroman1/aai";
2
+
3
+ /**
4
+ * A briefing desk you can phone: you name a subject, it puts several
5
+ * researchers on it at once and reads you back what they found.
6
+ *
7
+ * **It is the worked example for `ctx.delegate` — subagents.** The desk itself
8
+ * has NO web tools. Everything it knows comes back from a subagent run started
9
+ * inside a tool call: `researcher` (search + page reads, six steps) for each
10
+ * angle of a topic, `fact-checker` (search only, two steps, a cheaper model)
11
+ * for one claim at a time. `shared.ts` declares both and argues the split.
12
+ *
13
+ * **What that buys, on a phone call specifically.** A researcher may read tens
14
+ * of thousands of tokens of web pages; what crosses back into the conversation
15
+ * is its final paragraph. So the desk stays coherent over a long call — its
16
+ * context grows by a summary per angle rather than by everything four
17
+ * researchers read — and the four angles run at once, so the caller waits for
18
+ * the slowest rather than the sum. `tools/research_topic.ts` is where both of
19
+ * those actually happen.
20
+ *
21
+ * Compare `web-researcher`, which gives the search builtins to the agent
22
+ * itself: that is the right shape for one lookup, and the wrong one the moment
23
+ * a question has four sides.
24
+ */
25
+ export default agent({
26
+ name: "Briefing Desk",
27
+ greeting:
28
+ "Briefing desk. Tell me a subject and I'll put a few researchers on it — " +
29
+ "try something like, what's going on with home battery prices.",
30
+ });
@@ -0,0 +1,200 @@
1
+ /**
2
+ * The desk's two subagents, and the board of what they have found.
3
+ *
4
+ * **This template is the worked example for `ctx.delegate`.** Everything here
5
+ * exists to show the three things a subagent buys that `ctx.generate` cannot:
6
+ *
7
+ * 1. **A context window the caller never pays for.** `researcher` reads whole
8
+ * web pages. A briefing on four angles can spend a hundred thousand tokens
9
+ * doing it, and what comes back into the phone call's conversation is four
10
+ * paragraphs — because {@link DelegateResult.text} is the subagent's final
11
+ * message and nothing else crosses back.
12
+ * 2. **Parallelism.** Four angles are four independent runs, so
13
+ * `tools/research_topic.ts` fans them out with one `Promise.allSettled` and
14
+ * the caller waits for the slowest, not for the sum.
15
+ * 3. **Tools isolated by capability.** `researcher` can read the open web;
16
+ * `factChecker` can only search it, on a cheaper model with a third of the
17
+ * budget. Neither can reach the other's tools, and the DESK — the voice
18
+ * agent the caller is talking to — has no web tools at all.
19
+ */
20
+
21
+ import {
22
+ type DeepReadonly,
23
+ type DelegateFn,
24
+ type DelegateOptions,
25
+ type DelegateResult,
26
+ pushCapped,
27
+ type SubagentToolCall,
28
+ sessionSlot,
29
+ subagent,
30
+ } from "@alexkroman1/aai";
31
+ import { assemblyAILlm } from "@alexkroman1/aai/llm";
32
+
33
+ /**
34
+ * Steps one angle may take. A budget, not a limit to be raised when an answer
35
+ * disappoints: a subagent told to "keep looking until sure" is a subagent whose
36
+ * cost nobody can quote, and the caller is on the phone. Past it the researcher
37
+ * is asked for its answer with its tools withheld, so a capped run still comes
38
+ * back with prose rather than stopping mid-chain.
39
+ */
40
+ export const MAX_RESEARCH_STEPS = 6;
41
+
42
+ /**
43
+ * The researcher, and the one line that decides whether any of this works.
44
+ *
45
+ * "Finish with a summary" is not politeness. The parent gets the subagent's
46
+ * FINAL message, so a run that ends by saying "Done." has thrown away
47
+ * everything it read and no budget recovers it — this is the failure mode the
48
+ * `SubagentDef.instructions` contract warns about, stated once, here.
49
+ */
50
+ export const researcher = subagent({
51
+ name: "researcher",
52
+ instructions: [
53
+ "You are a research agent working one angle of a briefing.",
54
+ "",
55
+ "Search, then open the two or three most promising pages and read them.",
56
+ "Prefer primary sources and recent ones. If the sources disagree, say so",
57
+ "rather than picking a side.",
58
+ "",
59
+ "IMPORTANT: your FINAL message is the only thing the desk receives — it",
60
+ "does not see your searches, the pages you opened, or your reasoning. End",
61
+ "with a self-contained paragraph of what you found, naming the sources you",
62
+ "trusted. Three sentences is plenty; do not write a report.",
63
+ ].join("\n"),
64
+ // Read/browse. Independent of the desk's own builtins, which are none: the
65
+ // agent the caller talks to never touches the network.
66
+ builtinTools: ["web_search", "visit_webpage"],
67
+ maxSteps: MAX_RESEARCH_STEPS,
68
+ });
69
+
70
+ /**
71
+ * The fact-checker: a second ROLE, deliberately narrower than the first.
72
+ *
73
+ * Its own `llm` (cheaper and quicker — checking one sentence is not the job
74
+ * `researcher` does), its own budget, and search only. That split is the third
75
+ * reason to reach for a subagent: a capability a run does not need is one it
76
+ * cannot misuse.
77
+ */
78
+ export const factChecker = subagent({
79
+ name: "fact-checker",
80
+ instructions: [
81
+ "You check ONE claim against what you can find on the web.",
82
+ "",
83
+ "Search for it. Answer in one sentence, starting with one of",
84
+ "'Confirmed:', 'Contradicted:' or 'Unclear:', and name what you found.",
85
+ "'Unclear' is a real answer — say it rather than guessing.",
86
+ ].join("\n"),
87
+ llm: assemblyAILlm({ model: "gemini-2.5-flash-lite" }),
88
+ builtinTools: ["web_search"],
89
+ maxSteps: 2,
90
+ });
91
+
92
+ /** One angle, as the desk holds it. */
93
+ export interface Finding {
94
+ /** The angle the researcher was given. */
95
+ angle: string;
96
+ /** Its final message — the whole of what crossed back. */
97
+ summary: string;
98
+ /** Searches it ran and pages it opened, for narrating the wait. */
99
+ work: AngleWork;
100
+ }
101
+
102
+ /** What one angle COST, as the desk is willing to say it out loud. */
103
+ export interface AngleWork {
104
+ searches: number;
105
+ reads: number;
106
+ }
107
+
108
+ /**
109
+ * The brief one angle is sent with.
110
+ *
111
+ * Its own function because the `context` line is the whole of what a
112
+ * researcher learns about the call it belongs to — a subagent's context is
113
+ * ISOLATED, so anything the conversation knows and the angle does not say is
114
+ * lost unless it is written here. A spec asserts on this rather than on a
115
+ * template literal buried in a tool body.
116
+ */
117
+ export function angleBrief(topic: string, angle: string): DelegateOptions {
118
+ return { task: angle, context: `This angle belongs to a briefing on: ${topic}.` };
119
+ }
120
+
121
+ /**
122
+ * What a run did, read off the calls it made.
123
+ *
124
+ * {@link DelegateResult.toolCalls} carries the CALLS and not their results —
125
+ * the results are what stayed in the subagent's window — so this is the most
126
+ * the desk can honestly say about the wait. An unrecognised tool name counts
127
+ * as neither: a researcher that gains a third tool should not silently inflate
128
+ * "searches".
129
+ */
130
+ export function countWork(toolCalls: readonly SubagentToolCall[]): AngleWork {
131
+ let searches = 0;
132
+ let reads = 0;
133
+ for (const call of toolCalls) {
134
+ if (call.name === "web_search") searches += 1;
135
+ else if (call.name === "visit_webpage") reads += 1;
136
+ }
137
+ return { searches, reads };
138
+ }
139
+
140
+ /**
141
+ * Run one angle and reduce it to what the board holds.
142
+ *
143
+ * Takes the DELEGATE rather than the whole tool context — the same seam
144
+ * `plan-and-execute` puts on `ctx.generate`. The desk's own logic is then
145
+ * exercised against a fake without a context at all, and the tool that fans
146
+ * these out stays one line per angle.
147
+ */
148
+ export async function researchAngle(
149
+ delegate: DelegateFn,
150
+ topic: string,
151
+ angle: string,
152
+ ): Promise<Finding> {
153
+ const result: DelegateResult = await delegate(researcher, angleBrief(topic, angle));
154
+ return { angle, summary: result.text, work: countWork(result.toolCalls) };
155
+ }
156
+
157
+ /** Angles one `research_topic` call may fan out. Four researchers at once is
158
+ * already four model bills; past that a caller is waiting on a queue. */
159
+ export const MAX_ANGLES = 4;
160
+
161
+ /** Findings the board holds. Older ones fall off — they ride in every prompt
162
+ * the desk builds and in every recap it reads back. */
163
+ export const MAX_FINDINGS = 12;
164
+
165
+ export interface BriefingState {
166
+ /** What the caller asked about, as last stated. */
167
+ topic: string | null;
168
+ /** Every angle researched on this call, oldest first. */
169
+ findings: Finding[];
170
+ }
171
+
172
+ export function emptyBriefing(): BriefingState {
173
+ return { topic: null, findings: [] };
174
+ }
175
+
176
+ export const briefingSlot = sessionSlot("briefing", emptyBriefing);
177
+
178
+ /** Record a finding, holding {@link MAX_FINDINGS}. */
179
+ export function recordFinding(state: BriefingState, finding: Finding): void {
180
+ pushCapped(state.findings, finding, MAX_FINDINGS);
181
+ }
182
+
183
+ /**
184
+ * The board as a READ hands it out — deep-frozen, and typed to say so, which is
185
+ * what a slot's read returns.
186
+ */
187
+ export type FrozenBriefing = DeepReadonly<BriefingState>;
188
+
189
+ /** A finding named by what the caller would say: its angle, loosely matched. */
190
+ export function findByAngle(
191
+ state: FrozenBriefing,
192
+ angle: string,
193
+ ): DeepReadonly<Finding> | undefined {
194
+ const wanted = angle.trim().toLowerCase();
195
+ if (wanted === "") return undefined;
196
+ return state.findings.find(
197
+ (finding) =>
198
+ finding.angle.toLowerCase().includes(wanted) || wanted.includes(finding.angle.toLowerCase()),
199
+ );
200
+ }