@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.
- package/README.md +1 -0
- package/dist/{_bundler-B4RqNF5Z.mjs → _bundler-CDuYl5Gb.mjs} +1 -1
- package/dist/{_dev-server-CiKFH9qw.mjs → _dev-server-Me06wgeF.mjs} +2 -2
- package/dist/{_init-CE4HKdgV.mjs → _init-BhEfQ6Yi.mjs} +1 -1
- package/dist/_resource-commands.d.ts +23 -0
- package/dist/{_slug-api-nRFaBEPJ.mjs → _slug-api-z-USxPtL.mjs} +14 -10
- package/dist/_slug-api.d.ts +5 -2
- package/dist/{_templates-FI3xPEMj.mjs → _templates-4WcKOjS5.mjs} +4 -5
- package/dist/_templates.d.ts +3 -4
- package/dist/{build-6jfaakgc.mjs → build-BBxf8vom.mjs} +2 -2
- package/dist/cli.mjs +338 -346
- package/dist/{deploy-DvYeD8ia.mjs → deploy-DRC8jWr8.mjs} +1 -1
- package/dist/{dev-B59JIfWO.mjs → dev-BvcctCn2.mjs} +1 -1
- package/dist/eval-DfQ8cm5p.mjs +56 -0
- package/dist/eval.d.ts +43 -0
- package/dist/{init-CpJVjEg0.mjs → init-CUo87gcI.mjs} +2 -2
- package/dist/{logs-9q-psa4q.mjs → logs-CQ2T46dr.mjs} +1 -1
- package/dist/scaffold/CLAUDE.md +409 -68
- package/dist/scaffold/package.json +5 -4
- package/dist/scaffold/server.mjs +10 -3
- package/dist/scaffold/tsconfig.json +0 -1
- package/dist/{secret-CVvSLIDV.mjs → secret-Ct34kdgU.mjs} +1 -1
- package/dist/{storage-BvUrnvM3.mjs → storage-i_79ZwAR.mjs} +1 -1
- package/dist/{studio--MUV0cid.mjs → studio-CzTzPqe1.mjs} +1 -1
- package/dist/templates/briefing-desk/agent.test.ts +350 -0
- package/dist/templates/briefing-desk/agent.ts +30 -0
- package/dist/templates/briefing-desk/shared.ts +200 -0
- package/dist/templates/briefing-desk/system-prompt.md +39 -0
- package/dist/templates/briefing-desk/tools/briefing_so_far.ts +35 -0
- package/dist/templates/briefing-desk/tools/research_topic.ts +88 -0
- package/dist/templates/briefing-desk/tools/verify_claim.ts +77 -0
- package/dist/templates/call-audit/agent.eval.test.ts +474 -0
- package/dist/templates/code-interpreter/agent.eval.test.ts +165 -0
- package/dist/templates/dispatch-center/agent.eval.test.ts +349 -0
- package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
- package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
- package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
- package/dist/templates/infocom-adventure/agent.test.ts +49 -18
- package/dist/templates/infocom-adventure/agent.ts +21 -0
- package/dist/templates/infocom-adventure/shared.ts +20 -9
- package/dist/templates/infocom-adventure/system-prompt.md +1 -1
- package/dist/templates/infocom-adventure/tools/game_state_move.ts +3 -2
- package/dist/templates/link-digest/agent.eval.test.ts +195 -0
- package/dist/templates/math-buddy/agent.eval.test.ts +146 -0
- package/dist/templates/night-owl/agent.eval.test.ts +272 -0
- package/dist/templates/personal-finance/agent.eval.test.ts +168 -0
- package/dist/templates/personal-finance/system-prompt.md +4 -0
- package/dist/templates/pipeline-simple/agent.eval.test.ts +78 -0
- package/dist/templates/pipeline-simple/agent.ts +2 -2
- package/dist/templates/pizza-ordering/agent.eval.test.ts +171 -0
- package/dist/templates/pizza-ordering/agent.test.ts +15 -0
- package/dist/templates/pizza-ordering/shared.ts +20 -1
- package/dist/templates/pizza-ordering/system-prompt.md +6 -0
- package/dist/templates/plan-and-execute/agent.eval.test.ts +209 -0
- package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
- package/dist/templates/podcast-digest/agent.test.ts +30 -59
- package/dist/templates/podcast-digest/agent.ts +1 -1
- package/dist/templates/podcast-digest/workflows/digest.ts +52 -19
- package/dist/templates/podcast-digest/workflows/slack.ts +48 -169
- package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
- package/dist/templates/redline/agent.eval.test.ts +209 -0
- package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
- package/dist/templates/retail/address.ts +87 -1
- package/dist/templates/retail/agent.eval.test.ts +314 -0
- package/dist/templates/retail/agent.test.ts +171 -91
- package/dist/templates/retail/agent.ts +35 -1
- package/dist/templates/retail/cancel.ts +116 -0
- package/dist/templates/retail/client.tsx +39 -0
- package/dist/templates/retail/payment.ts +107 -0
- package/dist/templates/retail/pending.test.ts +196 -0
- package/dist/templates/retail/pending.ts +137 -0
- package/dist/templates/retail/registry.test.ts +119 -8
- package/dist/templates/retail/returns.ts +102 -0
- package/dist/templates/retail/shared.test.ts +42 -0
- package/dist/templates/retail/shared.ts +35 -0
- package/dist/templates/retail/store.test.ts +6 -4
- package/dist/templates/retail/store.ts +113 -57
- package/dist/templates/retail/swap.test.ts +4 -4
- package/dist/templates/retail/swap.ts +248 -15
- package/dist/templates/retail/system-prompt.md +62 -30
- package/dist/templates/retail/tools/cancel_change.ts +33 -0
- package/dist/templates/retail/tools/cancel_pending_order.ts +15 -59
- package/dist/templates/retail/tools/confirm_change.ts +42 -0
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +21 -66
- package/dist/templates/retail/tools/modify_pending_order_address.ts +12 -24
- package/dist/templates/retail/tools/modify_pending_order_items.ts +22 -60
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +15 -77
- package/dist/templates/retail/tools/modify_user_address.ts +12 -15
- package/dist/templates/retail/tools/return_delivered_order_items.ts +16 -71
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +4 -2
- package/dist/templates/simple/agent.eval.test.ts +54 -0
- package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
- package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
- package/dist/templates/support-line/agent.eval.test.ts +273 -0
- package/dist/templates/support-line/system-prompt.md +2 -1
- package/dist/templates/support-line/tools/answer_question.ts +56 -7
- package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
- package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
- package/dist/templates/travel-concierge/agent.test.ts +110 -10
- package/dist/templates/travel-concierge/agent.ts +18 -9
- package/dist/templates/travel-concierge/shared.ts +110 -9
- package/dist/templates/travel-concierge/system-prompt.md +7 -1
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +6 -3
- package/dist/templates/travel-concierge/tools/book_excursion.ts +6 -3
- package/dist/templates/travel-concierge/tools/book_hotel.ts +6 -3
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +6 -3
- package/dist/templates/travel-concierge/tools/search_car_rentals.ts +9 -5
- package/dist/templates/travel-concierge/tools/search_excursions.ts +9 -5
- package/dist/templates/travel-concierge/tools/search_flights.ts +9 -6
- package/dist/templates/travel-concierge/tools/search_hotels.ts +9 -5
- package/dist/templates/travel-concierge/tools/update_ticket.ts +6 -3
- package/dist/templates/web-researcher/agent.eval.test.ts +121 -0
- package/dist/templates/web-researcher/system-prompt.md +15 -4
- package/dist/{test-9kPKJI-w.mjs → test-BvyT6sAc.mjs} +24 -10
- package/dist/test.d.ts +29 -5
- package/dist/{worker-bundler-COxnqstQ.mjs → worker-bundler-CCVEDjm1.mjs} +45 -25
- package/dist/worker-bundler.mjs +1 -1
- package/dist/{workflow-D2AQf2Pl.mjs → workflow-BflATMmU.mjs} +19 -11
- package/package.json +5 -5
- package/dist/eject-C9WJyyr2.mjs +0 -116
- package/dist/eject.d.ts +0 -60
- 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
|
+
});
|