@alexkroman1/aai-cli 7.0.0 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +1 -0
  2. package/dist/{_bundler-B4RqNF5Z.mjs → _bundler-CDuYl5Gb.mjs} +1 -1
  3. package/dist/{_dev-server-CiKFH9qw.mjs → _dev-server-Dt3TCjhj.mjs} +6 -2
  4. package/dist/{_init-CE4HKdgV.mjs → _init-BhEfQ6Yi.mjs} +1 -1
  5. package/dist/_resource-commands.d.ts +23 -0
  6. package/dist/{_slug-api-nRFaBEPJ.mjs → _slug-api-z-USxPtL.mjs} +14 -10
  7. package/dist/_slug-api.d.ts +5 -2
  8. package/dist/{_templates-FI3xPEMj.mjs → _templates-4WcKOjS5.mjs} +4 -5
  9. package/dist/_templates.d.ts +3 -4
  10. package/dist/{build-6jfaakgc.mjs → build-BBxf8vom.mjs} +2 -2
  11. package/dist/cli.mjs +338 -346
  12. package/dist/{deploy-DvYeD8ia.mjs → deploy-DRC8jWr8.mjs} +1 -1
  13. package/dist/{dev-B59JIfWO.mjs → dev-QuuOOUtv.mjs} +1 -1
  14. package/dist/eval-DfQ8cm5p.mjs +56 -0
  15. package/dist/eval.d.ts +43 -0
  16. package/dist/{init-CpJVjEg0.mjs → init-CUo87gcI.mjs} +2 -2
  17. package/dist/{logs-9q-psa4q.mjs → logs-CQ2T46dr.mjs} +1 -1
  18. package/dist/scaffold/CLAUDE.md +409 -68
  19. package/dist/scaffold/package.json +5 -4
  20. package/dist/scaffold/server.mjs +10 -3
  21. package/dist/scaffold/tsconfig.json +0 -1
  22. package/dist/{secret-CVvSLIDV.mjs → secret-Ct34kdgU.mjs} +1 -1
  23. package/dist/{storage-BvUrnvM3.mjs → storage-i_79ZwAR.mjs} +1 -1
  24. package/dist/{studio--MUV0cid.mjs → studio-CzTzPqe1.mjs} +1 -1
  25. package/dist/templates/briefing-desk/agent.test.ts +350 -0
  26. package/dist/templates/briefing-desk/agent.ts +30 -0
  27. package/dist/templates/briefing-desk/shared.ts +200 -0
  28. package/dist/templates/briefing-desk/system-prompt.md +39 -0
  29. package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
  30. package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
  31. package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
  32. package/dist/templates/call-audit/agent.eval.test.ts +474 -0
  33. package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
  34. package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
  35. package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
  36. package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
  37. package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
  38. package/dist/templates/infocom-adventure/agent.test.ts +49 -18
  39. package/dist/templates/infocom-adventure/agent.ts +21 -0
  40. package/dist/templates/infocom-adventure/shared.ts +20 -9
  41. package/dist/templates/infocom-adventure/system-prompt.md +1 -1
  42. package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
  43. package/dist/templates/link-digest/agent.eval.test.ts +195 -0
  44. package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
  45. package/dist/templates/night-owl/agent.eval.test.ts +272 -0
  46. package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
  47. package/dist/templates/personal-finance/system-prompt.md +4 -0
  48. package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
  49. package/dist/templates/pipeline-simple/agent.ts +2 -2
  50. package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
  51. package/dist/templates/pizza-ordering/agent.test.ts +15 -0
  52. package/dist/templates/pizza-ordering/shared.ts +20 -1
  53. package/dist/templates/pizza-ordering/system-prompt.md +6 -0
  54. package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
  55. package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
  56. package/dist/templates/podcast-digest/agent.test.ts +30 -59
  57. package/dist/templates/podcast-digest/agent.ts +1 -1
  58. package/dist/templates/podcast-digest/workflows/digest.ts +52 -19
  59. package/dist/templates/podcast-digest/workflows/slack.ts +48 -169
  60. package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
  61. package/dist/templates/redline/agent.eval.test.ts +209 -0
  62. package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
  63. package/dist/templates/retail/address.ts +87 -1
  64. package/dist/templates/retail/agent.eval.test.ts +314 -0
  65. package/dist/templates/retail/agent.test.ts +171 -91
  66. package/dist/templates/retail/agent.ts +35 -1
  67. package/dist/templates/retail/cancel.ts +116 -0
  68. package/dist/templates/retail/client.tsx +39 -0
  69. package/dist/templates/retail/payment.ts +107 -0
  70. package/dist/templates/retail/pending.test.ts +196 -0
  71. package/dist/templates/retail/pending.ts +137 -0
  72. package/dist/templates/retail/registry.test.ts +119 -8
  73. package/dist/templates/retail/returns.ts +102 -0
  74. package/dist/templates/retail/shared.test.ts +42 -0
  75. package/dist/templates/retail/shared.ts +35 -0
  76. package/dist/templates/retail/store.test.ts +6 -4
  77. package/dist/templates/retail/store.ts +113 -57
  78. package/dist/templates/retail/swap.test.ts +4 -4
  79. package/dist/templates/retail/swap.ts +248 -15
  80. package/dist/templates/retail/system-prompt.md +62 -30
  81. package/dist/templates/retail/tools/cancel_change.ts +33 -0
  82. package/dist/templates/retail/tools/cancel_pending_order.ts +15 -59
  83. package/dist/templates/retail/tools/confirm_change.ts +42 -0
  84. package/dist/templates/retail/tools/exchange_delivered_order_items.ts +21 -66
  85. package/dist/templates/retail/tools/modify_pending_order_address.ts +12 -24
  86. package/dist/templates/retail/tools/modify_pending_order_items.ts +22 -60
  87. package/dist/templates/retail/tools/modify_pending_order_payment.ts +15 -77
  88. package/dist/templates/retail/tools/modify_user_address.ts +12 -15
  89. package/dist/templates/retail/tools/return_delivered_order_items.ts +16 -71
  90. package/dist/templates/retail/tools/transfer_to_human_agents.ts +4 -2
  91. package/dist/templates/simple/agent.eval.test.ts +54 -0
  92. package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
  93. package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
  94. package/dist/templates/support-line/agent.eval.test.ts +273 -0
  95. package/dist/templates/support-line/system-prompt.md +2 -1
  96. package/dist/templates/support-line/tools/answer_question.ts +56 -7
  97. package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
  98. package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
  99. package/dist/templates/travel-concierge/agent.test.ts +110 -10
  100. package/dist/templates/travel-concierge/agent.ts +18 -9
  101. package/dist/templates/travel-concierge/shared.ts +110 -9
  102. package/dist/templates/travel-concierge/system-prompt.md +7 -1
  103. package/dist/templates/travel-concierge/tools/book_car_rental.ts +6 -3
  104. package/dist/templates/travel-concierge/tools/book_excursion.ts +6 -3
  105. package/dist/templates/travel-concierge/tools/book_hotel.ts +6 -3
  106. package/dist/templates/travel-concierge/tools/cancel_ticket.ts +6 -3
  107. package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
  108. package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
  109. package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
  110. package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
  111. package/dist/templates/travel-concierge/tools/update_ticket.ts +6 -3
  112. package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
  113. package/dist/templates/web-researcher/system-prompt.md +15 -4
  114. package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
  115. package/dist/test.d.ts +29 -5
  116. package/dist/{worker-bundler-COxnqstQ.mjs → worker-bundler-CCVEDjm1.mjs} +45 -25
  117. package/dist/worker-bundler.mjs +1 -1
  118. package/dist/{workflow-D2AQf2Pl.mjs → workflow-BflATMmU.mjs} +19 -11
  119. package/package.json +5 -5
  120. package/dist/eject-C9WJyyr2.mjs +0 -116
  121. package/dist/eject.d.ts +0 -60
  122. package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
@@ -0,0 +1,146 @@
1
+ // An EVAL: does Math Buddy delegate every calculation to code? Run it with
2
+ // `aai eval`.
3
+ //
4
+ // This tutor's whole design is "the model does the talking, run_code does the
5
+ // arithmetic" — which is also why it runs on Flash-Lite. So the claim worth
6
+ // pinning is not that the answer is right, it is that the answer came from
7
+ // CODE, and that the code is the recipe the prompt gave.
8
+ //
9
+ // Two things this file has to work around, both explained at length in
10
+ // `../code-interpreter/agent.eval.test.ts`: `system-prompt.md` is discovered by
11
+ // the build rather than imported, so an eval has to apply it or it measures a
12
+ // different agent; and `run_code` refuses unless the EVAL supplies an executor,
13
+ // which this suite does — so the cases below assert the answer the code came
14
+ // back with as well as the code the tutor wrote.
15
+ import { runInNewContext } from "node:vm";
16
+ import { withSystemPrompt } from "@alexkroman1/aai/manifest";
17
+ import { errorMessage } from "@alexkroman1/aai/utils";
18
+ import type { RunCodeExecutor } from "@alexkroman1/aai-runtime/eval";
19
+ import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
20
+ import { expect } from "vitest";
21
+ import authored from "./agent.ts";
22
+ import systemPrompt from "./system-prompt.md?raw";
23
+
24
+ const agentDef = withSystemPrompt(authored, systemPrompt);
25
+
26
+ /** The code every `run_code` call in this turn carried, joined. */
27
+ const codeIn = (turn: { toolCalls: readonly { name: string; args: Record<string, unknown> }[] }) =>
28
+ turn.toolCalls
29
+ .filter((c) => c.name === "run_code")
30
+ .map((c) => String(c.args.code ?? ""))
31
+ .join("\n");
32
+
33
+ /**
34
+ * A `run_code` executor, so these cases can assert the ANSWER.
35
+ *
36
+ * The builtin refuses without one — the Modal container is the security
37
+ * boundary, and off-platform there is none — so a case could assert the CALL and
38
+ * the code it carried, and never what the code came back with. A `node:vm`
39
+ * context with a capturing `console.log` is what a developer would reach for on
40
+ * their own machine, and it is enough here: what runs is arithmetic, not a
41
+ * program. It is NOT a sandbox and does not pretend to be one; a deployed agent
42
+ * still gets the refusal.
43
+ *
44
+ * A template eval imports from `@alexkroman1/aai-runtime/eval` and
45
+ * `/eval/vitest` and nowhere else — the ROOT barrel drags the host runtime's
46
+ * node-reaching module graph into this project's TypeScript program, which is
47
+ * three errors in runtime files no eval ever calls. `RunCodeExecutor` is
48
+ * re-exported from `/eval` for exactly that reason.
49
+ */
50
+ const runCode: RunCodeExecutor = async (code) => {
51
+ const lines: string[] = [];
52
+ const log = (...args: unknown[]): void => {
53
+ lines.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
54
+ };
55
+ try {
56
+ runInNewContext(code, { console: { log } }, { timeout: 1000 });
57
+ } catch (err) {
58
+ return { error: errorMessage(err) };
59
+ }
60
+ return lines.join("\n");
61
+ };
62
+
63
+ /** What every `run_code` call in this turn PRINTED, joined. */
64
+ const outputIn = (turn: { toolCalls: readonly { name: string; result?: string }[] }) =>
65
+ turn.toolCalls
66
+ .filter((c) => c.name === "run_code")
67
+ .map((c) => c.result ?? "")
68
+ .join("\n");
69
+
70
+ describeEval(
71
+ agentDef,
72
+ (test) => {
73
+ test(
74
+ "converts units in code, with a real conversion factor",
75
+ async ({ session }) => {
76
+ const turn = await session.say("Convert 5 miles to kilometres.");
77
+
78
+ // The prompt hands the tutor the factors; the finding it guards against
79
+ // is a tutor that recites a remembered figure instead. A factor in the
80
+ // code is the evidence that the conversion was computed, not recalled.
81
+ expect(turn.toolCalls.map((c) => c.name)).toContain("run_code");
82
+ const code = codeIn(turn);
83
+ expect(code).toContain("5");
84
+ expect(code).toMatch(/1\.60|1\.61|0\.621|8\.04/);
85
+ // And the factor was applied rather than merely mentioned: five miles is
86
+ // 8.0467 km, so whatever rounding the tutor chose the answer starts 8.0.
87
+ const output = outputIn(turn);
88
+ expect(output, `run_code printed: ${output}`).toMatch(/8\.0/);
89
+ },
90
+ { live: true },
91
+ );
92
+
93
+ test(
94
+ "rolls dice with a random draw rather than inventing numbers",
95
+ async ({ session }) => {
96
+ const turn = await session.say("Roll 3 twenty-sided dice for me.");
97
+
98
+ // A model asked for dice will happily make three numbers up, and the
99
+ // reply is indistinguishable from a real roll. `Math.random` in the code
100
+ // is the only thing that tells them apart.
101
+ expect(turn.toolCalls.map((c) => c.name)).toContain("run_code");
102
+ const code = codeIn(turn);
103
+ expect(code).toMatch(/Math\.random/);
104
+ expect(code).toContain("20");
105
+ expect(code).toContain("3");
106
+
107
+ // And the draw really happened: three integers, every one of them a legal
108
+ // face of a twenty-sided die. `Math.random` in the code says the tutor
109
+ // asked for a roll; this says it GOT one — a `run_code` that refused
110
+ // prints a sentence with no dice in it at all.
111
+ const output = outputIn(turn);
112
+ const rolled = [...output.matchAll(/\d+/g)].map((m) => Number(m[0]));
113
+ expect(rolled.length, `run_code printed: ${output}`).toBeGreaterThanOrEqual(3);
114
+ for (const face of rolled) {
115
+ expect(face, `run_code printed: ${output}`).toBeGreaterThanOrEqual(1);
116
+ expect(face, `run_code printed: ${output}`).toBeLessThanOrEqual(20);
117
+ }
118
+ },
119
+ { live: true },
120
+ );
121
+
122
+ test(
123
+ "the run_code builtin is wired to the agent's tool executor",
124
+ async ({ session }) => {
125
+ const turn = await session.say("What is 127 times 849?");
126
+
127
+ // A tool the agent does not declare produces a `tool.called` with no
128
+ // result, so the paired result is what says `builtinTools: ["run_code"]`
129
+ // still resolves to something executable. The ANSWER rather than
130
+ // `toBeDefined()`, which the refusal string satisfied too.
131
+ const [call] = turn.toolCalls;
132
+ expect(call?.name).toBe("run_code");
133
+ expect(call?.result).toBe("107823");
134
+ expect(turn.completed).toBe(true);
135
+ },
136
+ {
137
+ stubReply: [
138
+ { tool: "run_code", args: { code: "console.log(127 * 849)" } },
139
+ "That's 107,823.",
140
+ ],
141
+ },
142
+ );
143
+ },
144
+ // `runCode` is what makes these cases about the ANSWER and not just the call.
145
+ { runCode },
146
+ );
@@ -0,0 +1,272 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ // An EVAL: does the companion actually pick from its own shelf?
4
+ //
5
+ // `agent.test.ts` calls `recommend` directly, which settles what the tool does
6
+ // with a category and a mood it is handed. What it cannot settle is whether the
7
+ // MODEL turns "something cozy to watch" into `{ category: "movie", mood:
8
+ // "cozy" }` rather than reciting three films it likes — and whether the night's
9
+ // log, which lives in a `sessionSlot`, is still there two turns later.
10
+ //
11
+ // Run it with `aai eval`. Without a provider key every case runs against a
12
+ // SCRIPTED model (its `stubReply`): the real session, the real slot, the real
13
+ // tool, a fake reply. That proves the wiring and nothing about the choice.
14
+
15
+ import { runInNewContext } from "node:vm";
16
+ import { withSystemPrompt } from "@alexkroman1/aai/manifest";
17
+ import type { SessionEvent } from "@alexkroman1/aai/protocol";
18
+ import { withDiscoveredTools } from "@alexkroman1/aai/testing";
19
+ import { errorMessage } from "@alexkroman1/aai/utils";
20
+ import {
21
+ customEventsIn,
22
+ lastStateIn,
23
+ type RunCodeExecutor,
24
+ toolResultIn,
25
+ } from "@alexkroman1/aai-runtime/eval";
26
+ import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
27
+ import { expect } from "vitest";
28
+ import { z } from "zod";
29
+ import authoredAgent from "./agent.ts";
30
+ import { CATEGORIES, MOODS } from "./shared.ts";
31
+ import systemPrompt from "./system-prompt.md?raw";
32
+
33
+ /**
34
+ * The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
35
+ *
36
+ * The glob is written HERE rather than reached for from a shared helper because
37
+ * this file SHIPS — a scaffolded project has no repo helper to import. Without
38
+ * it the eval would drive an agent with no `recommend` at all, which is the one
39
+ * failure a green eval must never be able to hide.
40
+ *
41
+ * And plus its PROMPT. `agent.ts` does not declare one — `system-prompt.md` is
42
+ * resolved by the BUILD (`aai build`/`aai deploy`), so the raw default export
43
+ * carries the FRAMEWORK DEFAULT prompt. An eval that drives it measures a
44
+ * different agent than the one that deploys, and every tool-choice claim below
45
+ * then passes or fails for the wrong reason.
46
+ */
47
+ const agentDef = withSystemPrompt(
48
+ withDiscoveredTools(authoredAgent, import.meta.glob("./tools/*.ts", { eager: true })),
49
+ systemPrompt,
50
+ );
51
+
52
+ /**
53
+ * One `recommend` answer, and the whole projection, as the wire carries them.
54
+ *
55
+ * Schemas rather than casts, which is what `toolResultIn` and `lastStateIn`
56
+ * take one for: a shelf or a projection that stopped matching FAILS here naming
57
+ * the field, where a cast hands the assertions `undefined` and fails a line
58
+ * later on something unrelated.
59
+ */
60
+ const RecSchema = z.object({
61
+ category: z.enum(CATEGORIES),
62
+ mood: z.enum(MOODS),
63
+ picks: z.array(z.string()),
64
+ });
65
+ const ProjectedNight = z.object({ recs: z.array(RecSchema) });
66
+
67
+ /**
68
+ * The night's log as the PAGE has it: the last `syncState` frame pushed.
69
+ *
70
+ * This agent declares one projection, so the frame IS `nightProjection`'s
71
+ * result — the same `{ recs }` value `useAgentState(nightProjection)` reads in
72
+ * `client.tsx`. Asserting on it is asserting on what the sidebar shows.
73
+ */
74
+ const pushedRecs = (events: readonly SessionEvent[]) =>
75
+ lastStateIn(events, ProjectedNight)?.recs ?? [];
76
+
77
+ /** The `wind_down` nudges in `events` — `customEventsIn` filters by name. */
78
+ const nudges = (events: readonly SessionEvent[]) => customEventsIn(events, "wind_down");
79
+
80
+ /**
81
+ * A `run_code` executor, so the sleep-cycle case can assert the ANSWER.
82
+ *
83
+ * The builtin refuses without one — the Modal container is the security
84
+ * boundary, and off-platform there is none — which left this template's
85
+ * headline feature assertable as a CALL and never as a number. A `node:vm`
86
+ * context with a capturing `console.log` is what a developer would reach for on
87
+ * their own machine, and it is enough: the code under test is arithmetic the
88
+ * model wrote, not a program.
89
+ *
90
+ * A template eval imports from `@alexkroman1/aai-runtime/eval` and
91
+ * `/eval/vitest` and nowhere else — the ROOT barrel drags the host runtime's
92
+ * node-reaching module graph into this project's TypeScript program, which is
93
+ * three errors in runtime files no eval ever calls. `RunCodeExecutor` is
94
+ * re-exported from `/eval` for exactly that reason.
95
+ */
96
+ const runCode: RunCodeExecutor = async (code) => {
97
+ const lines: string[] = [];
98
+ const log = (...args: unknown[]): void => {
99
+ lines.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
100
+ };
101
+ try {
102
+ runInNewContext(code, { console: { log } }, { timeout: 1000 });
103
+ } catch (err) {
104
+ return { error: errorMessage(err) };
105
+ }
106
+ return lines.join("\n");
107
+ };
108
+
109
+ /** Two-digit, for the clock arithmetic below. */
110
+ const pad = (n: number): string => String(n).padStart(2, "0");
111
+
112
+ /**
113
+ * Every bedtime `system-prompt.md`'s recipe admits for a given wake-up hour,
114
+ * in each of the spellings a tutor might print it in.
115
+ *
116
+ * Derived from the recipe rather than typed out — 90 minutes a cycle plus 15 to
117
+ * fall asleep, wrapped into the previous day — so this and the prompt cannot
118
+ * disagree about the arithmetic the case is checking.
119
+ */
120
+ const bedtimesFor = (wakeHour: number): string[] =>
121
+ [3, 4, 5, 6].flatMap((cycles) => {
122
+ const at = (wakeHour * 60 - (cycles * 90 + 15) + 1440) % 1440;
123
+ const hour = Math.floor(at / 60);
124
+ const minute = pad(at % 60);
125
+ // Three spellings per time, because the prompt asks for HH:MM and a tutor
126
+ // reasonably prints any of them: padded, unpadded (measured — a live run
127
+ // printed "0:45" for four cycles), and the 12-hour clock.
128
+ return [
129
+ `${pad(hour)}:${minute}`,
130
+ `${hour}:${minute}`,
131
+ `${hour % 12 === 0 ? 12 : hour % 12}:${minute}`,
132
+ ];
133
+ });
134
+
135
+ describeEval(
136
+ agentDef,
137
+ (test) => {
138
+ test(
139
+ "turns a mood into the category and mood the tool takes",
140
+ async ({ session }) => {
141
+ const turn = await session.say("I want something cozy to watch tonight.");
142
+
143
+ // "to watch" is the category and "cozy" is the mood; the shelf is the
144
+ // tool's, so answering from the model's own taste is the regression.
145
+ expect(turn.toolCalls.map((c) => c.name)).toEqual(["recommend"]);
146
+ const call = turn.toolCalls[0]!;
147
+ expect(call.args).toEqual({ category: "movie", mood: "cozy" });
148
+
149
+ // And what it read out came back from the shelf: the tool answers with
150
+ // the picks for exactly the pair it was asked for.
151
+ const rec = toolResultIn(turn.toolCalls, "recommend", RecSchema);
152
+ expect(rec).toMatchObject({ category: "movie", mood: "cozy" });
153
+ expect(rec.picks.length).toBeGreaterThan(0);
154
+ // It read out what the shelf handed back rather than a title of its own.
155
+ expect(rec.picks.some((pick) => turn.text.includes(pick))).toBe(true);
156
+ },
157
+ {
158
+ stubReply: [
159
+ { tool: "recommend", args: { category: "movie", mood: "cozy" } },
160
+ "Paddington 2 is the coziest thing I own.",
161
+ ],
162
+ },
163
+ );
164
+
165
+ test(
166
+ "the night's log keeps what an earlier turn picked, newest first",
167
+ async ({ session }) => {
168
+ await session.say("I want something cozy to watch tonight.");
169
+ const turn = await session.say("Now give me something spooky to read.");
170
+
171
+ expect(turn.toolCalls.map((c) => c.name)).toEqual(["recommend"]);
172
+ expect(turn.toolCalls[0]!.args).toEqual({ category: "book", mood: "spooky" });
173
+
174
+ // The slot survived the turn boundary: the frame the page renders after
175
+ // the second answer still carries the first, and the newest is first —
176
+ // which is the order the sidebar lists them in.
177
+ const recs = pushedRecs(session.events());
178
+ expect(recs.map((r) => `${r.category}/${r.mood}`)).toEqual(["book/spooky", "movie/cozy"]);
179
+ },
180
+ {
181
+ stubReply: [
182
+ { tool: "recommend", args: { category: "movie", mood: "cozy" } },
183
+ "Paddington 2 it is.",
184
+ { tool: "recommend", args: { category: "book", mood: "spooky" } },
185
+ "Mexican Gothic, then.",
186
+ ],
187
+ },
188
+ );
189
+
190
+ test(
191
+ "the wind-down nudge arrives once, on the third pick",
192
+ async ({ session }) => {
193
+ const first = await session.say("I want something cozy to watch tonight.");
194
+ expect(nudges(first.events)).toEqual([]);
195
+ const second = await session.say("Now give me something spooky to read.");
196
+ expect(nudges(second.events)).toEqual([]);
197
+
198
+ // Counted in the SLOT, so the third pick is only the third if the two
199
+ // before it were still there — and it is a `ctx.send`, not a field on the
200
+ // projection, so it must arrive exactly once and never be replayed.
201
+ const third = await session.say("And some chill music too.");
202
+ expect(nudges(third.events)).toHaveLength(1);
203
+
204
+ const fourth = await session.say("One funny book as well, please.");
205
+ expect(nudges(fourth.events)).toEqual([]);
206
+ expect(nudges(session.events())).toHaveLength(1);
207
+ expect(pushedRecs(session.events())).toHaveLength(4);
208
+ },
209
+ {
210
+ stubReply: [
211
+ { tool: "recommend", args: { category: "movie", mood: "cozy" } },
212
+ "Paddington 2 it is.",
213
+ { tool: "recommend", args: { category: "book", mood: "spooky" } },
214
+ "Mexican Gothic, then.",
215
+ { tool: "recommend", args: { category: "music", mood: "chill" } },
216
+ "Tycho, Dive.",
217
+ { tool: "recommend", args: { category: "book", mood: "funny" } },
218
+ "Good Omens.",
219
+ ],
220
+ },
221
+ );
222
+ test(
223
+ "works the bedtime out in CODE, and the number is right",
224
+ async ({ session }) => {
225
+ const turn = await session.say(
226
+ "I need to be up at 7 in the morning. When should I fall asleep?",
227
+ );
228
+
229
+ const ran = turn.toolCalls.filter((c) => c.name === "run_code");
230
+ expect(
231
+ ran,
232
+ `tools called: [${turn.toolCalls.map((c) => c.name).join(", ")}]; said: ${turn.text}`,
233
+ ).not.toEqual([]);
234
+ // The recipe is the prompt's, and it is two constants: a 90-minute cycle
235
+ // plus the 15 minutes it takes to fall asleep. Arithmetic done in the
236
+ // model's head has neither of them anywhere in the code.
237
+ const code = ran.map((c) => String(c.args.code ?? "")).join("\n");
238
+ expect(code).toContain("90");
239
+ expect(code).toContain("15");
240
+
241
+ const output = ran.map((c) => c.result ?? "").join("\n");
242
+ // The builtin really EXECUTED. With no `runCode` executor this string is
243
+ // "run_code is only available in the sandboxed runtime", which every
244
+ // assertion about a CALL sails past — so this template's headline
245
+ // feature could be checked as a call and never as an answer.
246
+ expect(output).not.toMatch(/only available in the sandboxed runtime/);
247
+ // And the answer is a whole number of cycles back from 07:00 with the
248
+ // quarter hour added. A tutor that dropped the 15 lands on :00 and a
249
+ // tutor that guessed lands anywhere; both fail here.
250
+ expect(output, `run_code printed: ${output}`).toMatch(new RegExp(bedtimesFor(7).join("|")));
251
+ },
252
+ {
253
+ stubReply: [
254
+ {
255
+ tool: "run_code",
256
+ args: {
257
+ code: [
258
+ "const wake = 7 * 60;",
259
+ "const at = (wake - (6 * 90 + 15) + 1440) % 1440;",
260
+ "const two = (n) => String(n).padStart(2, '0');",
261
+ "console.log(two(Math.floor(at / 60)) + ':' + two(at % 60));",
262
+ ].join("\n"),
263
+ },
264
+ },
265
+ "Aim for nine forty-five tonight — that's six full cycles before seven.",
266
+ ],
267
+ },
268
+ );
269
+ },
270
+ // `runCode` is what makes the case above about an ANSWER rather than a call.
271
+ { runCode },
272
+ );
@@ -0,0 +1,168 @@
1
+ // An EVAL: does Penny fetch the rate, compute the split, and refuse to hand out
2
+ // investment advice? Run it with `aai eval`.
3
+ //
4
+ // Penny has two builtins and a rule about what she may not say, so the three
5
+ // live cases below are one per promise the prompt makes: fetch_json for
6
+ // anything that moves (rates, crypto), run_code for anything arithmetic, and a
7
+ // not-financial-advice caveat whenever the subject is an investment.
8
+ //
9
+ // The two harness facts, argued at length in
10
+ // `../code-interpreter/agent.eval.test.ts`: `system-prompt.md` is discovered by
11
+ // the build rather than imported, so an eval has to apply it or it measures an
12
+ // agent with no house rules at all; and `run_code` refuses unless the EVAL
13
+ // supplies an executor, which this suite does — so the arithmetic cases assert
14
+ // the answer as well as the code that was submitted. `fetch_json` is
15
+ // unaffected — it makes a real request, so the currency case really does reach a
16
+ // live rates API.
17
+ import { runInNewContext } from "node:vm";
18
+ import { withSystemPrompt } from "@alexkroman1/aai/manifest";
19
+ import { errorMessage } from "@alexkroman1/aai/utils";
20
+ import type { RunCodeExecutor } from "@alexkroman1/aai-runtime/eval";
21
+ import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
22
+ import { expect } from "vitest";
23
+ import authored from "./agent.ts";
24
+ import systemPrompt from "./system-prompt.md?raw";
25
+
26
+ const agentDef = withSystemPrompt(authored, systemPrompt);
27
+
28
+ type Turn = { toolCalls: readonly { name: string; args: Record<string, unknown> }[] };
29
+
30
+ /** The code every `run_code` call in this turn carried, joined. */
31
+ const codeIn = (turn: Turn) =>
32
+ turn.toolCalls
33
+ .filter((c) => c.name === "run_code")
34
+ .map((c) => String(c.args.code ?? ""))
35
+ .join("\n");
36
+
37
+ /** Every URL this turn's `fetch_json` calls asked for. */
38
+ const fetchedUrls = (turn: Turn) =>
39
+ turn.toolCalls.filter((c) => c.name === "fetch_json").map((c) => String(c.args.url ?? ""));
40
+
41
+ /**
42
+ * A `run_code` executor, so these cases can assert the ANSWER.
43
+ *
44
+ * The builtin refuses without one — the Modal container is the security
45
+ * boundary, and off-platform there is none — so a case could assert the CALL and
46
+ * the code it carried, and never what the code came back with. A `node:vm`
47
+ * context with a capturing `console.log` is what a developer would reach for on
48
+ * their own machine, and it is enough here: what runs is arithmetic, not a
49
+ * program. It is NOT a sandbox and does not pretend to be one; a deployed agent
50
+ * still gets the refusal.
51
+ *
52
+ * A template eval imports from `@alexkroman1/aai-runtime/eval` and
53
+ * `/eval/vitest` and nowhere else — the ROOT barrel drags the host runtime's
54
+ * node-reaching module graph into this project's TypeScript program, which is
55
+ * three errors in runtime files no eval ever calls. `RunCodeExecutor` is
56
+ * re-exported from `/eval` for exactly that reason.
57
+ */
58
+ const runCode: RunCodeExecutor = async (code) => {
59
+ const lines: string[] = [];
60
+ const log = (...args: unknown[]): void => {
61
+ lines.push(args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "));
62
+ };
63
+ try {
64
+ runInNewContext(code, { console: { log } }, { timeout: 1000 });
65
+ } catch (err) {
66
+ return { error: errorMessage(err) };
67
+ }
68
+ return lines.join("\n");
69
+ };
70
+
71
+ /** What every `run_code` call in this turn PRINTED, joined. */
72
+ const outputIn = (turn: { toolCalls: readonly { name: string; result?: string }[] }) =>
73
+ turn.toolCalls
74
+ .filter((c) => c.name === "run_code")
75
+ .map((c) => c.result ?? "")
76
+ .join("\n");
77
+
78
+ describeEval(
79
+ agentDef,
80
+ (test) => {
81
+ test(
82
+ "splits a bill in code, tip included",
83
+ async ({ session }) => {
84
+ const turn = await session.say(
85
+ "Help me split a 120 dollar bill four ways with a 20 percent tip.",
86
+ );
87
+
88
+ // Three numbers, two operations and a rounding rule: the exact shape of
89
+ // question a model answers plausibly and wrongly. All three inputs have
90
+ // to reach the code, or something was worked out in the model's head.
91
+ expect(turn.toolCalls.map((c) => c.name)).toContain("run_code");
92
+ const code = codeIn(turn);
93
+ expect(code).toContain("120");
94
+ expect(code).toMatch(/\b4\b/);
95
+ expect(code).toMatch(/20|0\.2/);
96
+
97
+ // And the sum came out right: $120 plus 20% is $144, four ways is $36.
98
+ // Without an executor `run_code` answers with a refusal, so every claim
99
+ // above is satisfied by an agent that then divides in its head — which is
100
+ // the failure this template's whole run_code rule exists to prevent.
101
+ const output = outputIn(turn);
102
+ expect(output, `run_code printed: ${output}`).toMatch(/\b36(\.0+)?\b/);
103
+ },
104
+ { live: true },
105
+ );
106
+
107
+ test(
108
+ "looks a currency rate up instead of quoting one from memory",
109
+ async ({ session }) => {
110
+ const turn = await session.say("What's 100 US dollars in euros right now?");
111
+
112
+ // "Right now" is the point. A rate the model remembers is months stale
113
+ // and has no source, and the prompt names the endpoint to use — so the
114
+ // regression this catches is Penny answering confidently with no request
115
+ // at all.
116
+ const urls = fetchedUrls(turn);
117
+ expect(urls.length).toBeGreaterThan(0);
118
+ expect(urls.join(" ")).toMatch(/^https:\/\//);
119
+ expect(urls.join(" ")).toMatch(/er-api|exchangerate|currency|rates/i);
120
+ },
121
+ { live: true },
122
+ );
123
+
124
+ test(
125
+ "will not tell you to put your savings into crypto",
126
+ async ({ session }) => {
127
+ const turn = await session.say("Should I put all my savings into bitcoin?");
128
+
129
+ // The one thing this agent must never do straight. The prompt promises a
130
+ // fluctuation / not-financial-advice caveat whenever the subject is an
131
+ // investment, and a prompt edit that drops it leaves an agent cheerfully
132
+ // recommending an all-in bet on a voice call.
133
+ //
134
+ // `risk` is in the alternation because the caveat is a CLAIM and this is
135
+ // a live model's wording: a passing run answered "it's high risk and
136
+ // prices change fast", which is the promise kept in words none of the
137
+ // other four alternatives match. A reply that recommends the bet carries
138
+ // none of the five.
139
+ expect(turn.text).toMatch(/not (financial|investment) advice|fluctuat|volatil|swing|risk/i);
140
+ expect(turn.completed).toBe(true);
141
+ },
142
+ { live: true },
143
+ );
144
+
145
+ test(
146
+ "the run_code builtin is wired to the agent's tool executor",
147
+ async ({ session }) => {
148
+ const turn = await session.say("What's 20 percent of 120 dollars?");
149
+
150
+ // A tool the agent does not declare produces a `tool.called` with no
151
+ // result, so the paired result is what says `builtinTools` still resolves
152
+ // to something executable. Scripted deliberately on run_code rather than
153
+ // fetch_json: a scripted tool call really runs, and a wiring check should
154
+ // not depend on somebody else's API being up. The ANSWER rather than
155
+ // `toBeDefined()`, which the refusal string satisfied too.
156
+ const [call] = turn.toolCalls;
157
+ expect(call?.name).toBe("run_code");
158
+ expect(call?.result).toBe("24");
159
+ expect(turn.completed).toBe(true);
160
+ },
161
+ {
162
+ stubReply: [{ tool: "run_code", args: { code: "console.log(120 * 0.2)" } }, "That's $24."],
163
+ },
164
+ );
165
+ },
166
+ // `runCode` is what makes these cases about the ANSWER and not just the call.
167
+ { runCode },
168
+ );
@@ -1,6 +1,10 @@
1
1
  You are Penny, a friendly personal finance assistant. You help people with currency conversions, cryptocurrency prices, loan calculations, savings projections, and splitting bills.
2
2
 
3
3
  Rules:
4
+ - Never do arithmetic in your head. EVERY figure you speak — a tip, a split, a
5
+ payment, a projection, a converted amount — comes out of run_code, including
6
+ ones you could do instantly. A number you worked out yourself is a number you
7
+ invented, and the caller spends money on it.
4
8
  - Always show your math clearly when explaining calculations
5
9
  - When discussing investments or crypto, remind users that prices fluctuate and this is not financial advice
6
10
  - Be encouraging about savings goals
@@ -0,0 +1,78 @@
1
+ // An EVAL: does the swapped-in stage actually answer? Run it with `aai eval`.
2
+ //
3
+ // `agent.test.ts` asserts the DESCRIPTOR — `llm.kind === "anthropic"`, the two
4
+ // unset stages filling to AssemblyAI in the deployable config. It runs no agent,
5
+ // so it cannot tell a working provider from a model id that was retired last
6
+ // month: `toAgentConfig` is happy either way. This file is the other half. A
7
+ // live run here opens a real Anthropic connection with the real model string,
8
+ // which is the one claim this template makes and the one that rots on its own.
9
+ //
10
+ // **A live run needs ANTHROPIC_API_KEY, not the AssemblyAI key.** That is the
11
+ // template's own lesson arriving in the test suite: swap a stage and you bring
12
+ // that stage's credential. Without it `describeEval` announces SCRIPTED and the
13
+ // live-only case below is skipped — which is a wiring check, not a measurement,
14
+ // and the banner says so on every run.
15
+ import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
16
+ import { expect } from "vitest";
17
+ import agentDef from "./agent.ts";
18
+
19
+ /** Roughly how many words a reply is, for the spoken-length claim. */
20
+ const wordCount = (text: string): number => text.trim().split(/\s+/).filter(Boolean).length;
21
+
22
+ describeEval(agentDef, (test) => {
23
+ test(
24
+ "answers on the declared model, without reaching for a tool",
25
+ async ({ session }) => {
26
+ // `say()` hands back THAT turn, so this is a claim about the reply to
27
+ // this question rather than about everything said so far — which already
28
+ // includes the greeting.
29
+ const turn = await session.say("What is the capital of France?");
30
+
31
+ expect(turn.completed).toBe(true);
32
+ expect(turn.text).toMatch(/paris/i);
33
+ // This agent declares no tools and no builtins, so a tool call here
34
+ // would mean something got added by accident.
35
+ expect(turn.toolCalls).toEqual([]);
36
+ expect(turn.events.some((e) => e.type === "error.reported")).toBe(false);
37
+ },
38
+ { stubReply: "Paris is the capital of France." },
39
+ );
40
+
41
+ test(
42
+ "keeps the thread across two turns",
43
+ async ({ session }) => {
44
+ await session.say("My name is Sam and I work in Berlin.");
45
+ const turn = await session.say("Which city did I say I work in?");
46
+
47
+ expect(turn.text).toMatch(/berlin/i);
48
+ expect(turn.events.some((e) => e.type === "error.reported")).toBe(false);
49
+ },
50
+ // One scripted reply per turn: the second is the one under test, and a
51
+ // script that answered only the first would fail the case it is meant to
52
+ // let run. Scripted, the claim is that the session drives two turns; live,
53
+ // it is that the model still has the first one.
54
+ { stubReply: ["Good to meet you, Sam.", "You said Berlin."] },
55
+ );
56
+
57
+ test(
58
+ "keeps a reply speakable, even when the question invites an essay",
59
+ async ({ session }) => {
60
+ const turn = await session.say(
61
+ "Tell me everything you know about the history of the Roman Empire.",
62
+ );
63
+
64
+ // This template ships no prompt of its own, so what holds the reply down
65
+ // is `DEFAULT_SYSTEM_PROMPT`'s SPEAKING section — two sentences, about
66
+ // thirty spoken words, no markdown. That is a measured rule (interruption
67
+ // rate climbs from 17% under ten words to 59% past thirty-five), and a
68
+ // stage swap that quietly loses it produces an agent nobody can hold a
69
+ // call with. The ceiling is generous against the rule's own thirty so the
70
+ // case fails on an essay rather than on a long sentence.
71
+ expect(wordCount(turn.text)).toBeLessThanOrEqual(80);
72
+ expect(turn.text).not.toMatch(/[*#`]|^\s*[-•]\s/m);
73
+ },
74
+ // Live only: a scripted reply's length is this file's own choice, so
75
+ // asserting it in stub mode would measure nothing.
76
+ { live: true },
77
+ );
78
+ });