@alexkroman1/aai-cli 13.0.0 → 13.2.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/dist/scaffold/package.json +4 -4
- package/dist/templates/call-audit/agent.eval.test.ts +14 -11
- package/dist/templates/call-audit/agent.test.ts +20 -4
- package/dist/templates/call-audit/workflows/ingest.ts +10 -1
- package/dist/templates/code-interpreter/agent.eval.test.ts +27 -17
- package/dist/templates/dispatch-center/agent.eval.test.ts +18 -24
- package/dist/templates/embedded-assets/agent.eval.test.ts +3 -3
- package/dist/templates/health-assistant/agent.eval.test.ts +38 -15
- package/dist/templates/link-digest/agent.eval.test.ts +24 -15
- package/dist/templates/math-buddy/agent.eval.test.ts +28 -17
- package/dist/templates/night-owl/agent.eval.test.ts +30 -15
- package/dist/templates/personal-finance/agent.eval.test.ts +27 -17
- package/dist/templates/pizza-ordering/agent.eval.test.ts +11 -6
- package/dist/templates/plan-and-execute/agent.eval.test.ts +14 -7
- package/dist/templates/recap-workflow/agent.eval.test.ts +49 -20
- package/dist/templates/redline/agent.eval.test.ts +32 -24
- package/dist/templates/research-workflow/agent.eval.test.ts +32 -22
- package/dist/templates/retail/agent.eval.test.ts +18 -34
- package/dist/templates/spoken-summary/agent.eval.test.ts +25 -16
- package/dist/templates/spoken-summary/agent.test.ts +9 -4
- package/dist/templates/support-line/agent.eval.test.ts +23 -26
- package/dist/templates/transcription-workflow/agent.test.ts +10 -0
- package/dist/templates/transcription-workflow/workflows/normalize.ts +10 -1
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +5 -2
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +11 -4
- package/dist/templates/travel-concierge/agent.eval.test.ts +37 -56
- package/package.json +4 -4
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
"publish:agent": "aai publish"
|
|
15
15
|
},
|
|
16
16
|
"dependencies": {
|
|
17
|
-
"@alexkroman1/aai": "^13.
|
|
18
|
-
"@alexkroman1/aai-runtime": "^13.
|
|
19
|
-
"@alexkroman1/aai-ui": "^13.
|
|
17
|
+
"@alexkroman1/aai": "^13.2.0",
|
|
18
|
+
"@alexkroman1/aai-runtime": "^13.2.0",
|
|
19
|
+
"@alexkroman1/aai-ui": "^13.2.0",
|
|
20
20
|
"react": "^19.2.8",
|
|
21
21
|
"react-dom": "^19.2.8",
|
|
22
22
|
"tailwindcss": "^4.0.0",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"zod": "^4.5.4"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
|
-
"@alexkroman1/aai-cli": "^13.
|
|
27
|
+
"@alexkroman1/aai-cli": "^13.2.0",
|
|
28
28
|
"@tailwindcss/vite": "^4.3.3",
|
|
29
29
|
"@types/node": "^26.4.1",
|
|
30
30
|
"@types/react": "^19.2.18",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
// the tier that really resumes a run.
|
|
53
53
|
import { spawnSync } from "node:child_process";
|
|
54
54
|
import { encodeWav } from "@alexkroman1/aai/step";
|
|
55
|
+
import { stubGatewayRoute } from "@alexkroman1/aai/testing";
|
|
55
56
|
import { installStubTranscribe, installStubUploads } from "@alexkroman1/aai/testing/vitest";
|
|
56
57
|
import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
57
58
|
import { describe, expect, test } from "vitest";
|
|
@@ -211,19 +212,21 @@ function publish(bytes: Uint8Array, name: string, type: string) {
|
|
|
211
212
|
* Answer the sync transcription endpoint and the model in memory.
|
|
212
213
|
*
|
|
213
214
|
* ONE fake, because publishing a `stepFetch` REPLACES — a flow that transcribes
|
|
214
|
-
* AND calls a model cannot install two, which is what `otherwise` is for.
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* because the fake and the step agree on a typo.
|
|
215
|
+
* AND calls a model cannot install two, which is what `otherwise` is for. BOTH
|
|
216
|
+
* halves are the SDK's own fakes rather than this file's hand-typed wire, and
|
|
217
|
+
* for the same reason: each routes off the SDK's own endpoint constant, so a
|
|
218
|
+
* case cannot pass because the fake and the step agree on a typo. The gateway
|
|
219
|
+
* envelope is the half where that matters most — it is a WIRE shape, so getting
|
|
220
|
+
* a field wrong does not fail: `stepGenerate` reads no content, reports an empty
|
|
221
|
+
* completion, and the case blames the desk.
|
|
222
|
+
*
|
|
223
|
+
* `route` answers `undefined` for anything that is not a completion request, so
|
|
224
|
+
* it drops straight into `otherwise` and the transcription legs still reach the
|
|
225
|
+
* fake below it.
|
|
218
226
|
*/
|
|
219
227
|
function scriptProvider(text: readonly string[]) {
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
otherwise: (request) =>
|
|
223
|
-
request.url.includes("/chat/completions")
|
|
224
|
-
? { body: { choices: [{ message: { content: JSON.stringify(REPLY) } }] } }
|
|
225
|
-
: undefined,
|
|
226
|
-
});
|
|
228
|
+
const model = stubGatewayRoute(JSON.stringify(REPLY));
|
|
229
|
+
return installStubTranscribe({ text, otherwise: (request) => model.route(request) });
|
|
227
230
|
}
|
|
228
231
|
|
|
229
232
|
describeWorkflowEvalWithFfmpeg(
|
|
@@ -928,9 +928,21 @@ describe("the body's step policy", () => {
|
|
|
928
928
|
*
|
|
929
929
|
* The block above drives the body through `createWorkflowCtx` with
|
|
930
930
|
* `runSteps: false` and a journaled result per step, which is what makes it
|
|
931
|
-
* affordable: `ingestRecording`
|
|
932
|
-
*
|
|
933
|
-
* `FatalError` that produces, unconditionally.
|
|
931
|
+
* affordable: `ingestRecording` shells out to the ffmpeg toolchain, which this
|
|
932
|
+
* tier cannot usefully feed — the `ingestRecording` and `narrate` specs above
|
|
933
|
+
* assert the `FatalError` that produces, unconditionally.
|
|
934
|
+
*
|
|
935
|
+
* **The run fails here whether or not ffmpeg is installed, and the two failures
|
|
936
|
+
* do not say the same thing** — which is what the assertion below has to be
|
|
937
|
+
* written against. With no binary on `PATH` the step reports the missing
|
|
938
|
+
* toolchain as an instruction (the template's own doc promises that rather than
|
|
939
|
+
* `spawn ffmpeg ENOENT`); WITH one, the step gets a step further and `ffprobe`
|
|
940
|
+
* rejects the 2 KB stub above with `ffprobe exited with code 1`. This docblock
|
|
941
|
+
* used to state "this repo's test environment has none" as a premise, and the
|
|
942
|
+
* spec below matched `/ffmpeg/i` on the strength of it — so the test passed only
|
|
943
|
+
* on a machine where ffmpeg was ABSENT, and a developer with Homebrew's ffmpeg
|
|
944
|
+
* saw a red suite on a clean checkout of `main`. CI has no ffmpeg, so nothing
|
|
945
|
+
* caught it.
|
|
934
946
|
*
|
|
935
947
|
* So a whole run of this desk is not reachable here and this file does not
|
|
936
948
|
* pretend otherwise; `aai-cli`'s `dev-workflow.scenario.test.ts` is the tier
|
|
@@ -953,7 +965,11 @@ describe("the run is DURABLE, as far as ffmpeg allows", () => {
|
|
|
953
965
|
const run = await runWorkflow(audit, { recording: UPLOAD_ID }, { name: "audit" });
|
|
954
966
|
|
|
955
967
|
expect(run.status).toBe("failed");
|
|
956
|
-
|
|
968
|
+
// Either member of the toolchain, for the reason this block's doc gives: the
|
|
969
|
+
// step names `ffmpeg` when the binary is missing and `ffprobe` when it is
|
|
970
|
+
// present, and this spec is about neither. What it is about is the two
|
|
971
|
+
// assertions below — the ENGINE honouring `FatalError`.
|
|
972
|
+
expect(run.error).toMatch(/ff(mpeg|probe)/i);
|
|
957
973
|
const ingest = run.steps.find((step) => step.name === "ingestRecording");
|
|
958
974
|
// The whole point of `FatalError`: `maxAttempts: 6` is the budget this call
|
|
959
975
|
// site asks for, and a failure that cannot change must not spend it. A step
|
|
@@ -143,7 +143,16 @@ export async function ingestRecording(uploadId: string): Promise<Ingested> {
|
|
|
143
143
|
const normalized = join(dir, "audio.pcm");
|
|
144
144
|
const silenceLog = join(dir, "silence.txt");
|
|
145
145
|
|
|
146
|
-
|
|
146
|
+
// NO `size`, though `stored.size` is right there — and that is the whole
|
|
147
|
+
// difference between this copy being one window at a time and being
|
|
148
|
+
// `STEP_FILE_READ_CONCURRENCY` of them. Passing `size` means "I am judging
|
|
149
|
+
// completeness myself", which is what a body polling a still-arriving
|
|
150
|
+
// upload needs and is the opposite of what happened above: this step has
|
|
151
|
+
// already called `requireCompleteUpload`, so the file IS whole and the
|
|
152
|
+
// windows may land in any order. Omitting it lets `readUploadToFile`
|
|
153
|
+
// establish that for itself and fan out. The cost is one metadata round
|
|
154
|
+
// trip, against the dozens of window reads it overlaps.
|
|
155
|
+
await readUploadToFile(uploadId, source);
|
|
147
156
|
|
|
148
157
|
// What it WAS, for the progress log and the page. Worth one ffprobe: "41
|
|
149
158
|
// minutes of aac" explains the shape of the run, where "the recording" leaves
|
|
@@ -24,27 +24,37 @@
|
|
|
24
24
|
// Coda reached for code, and what the code came back with.
|
|
25
25
|
|
|
26
26
|
import agentDef from "virtual:aai/agent";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
createVmRunCode,
|
|
29
|
+
type EvalTurn,
|
|
30
|
+
toolArgsIn,
|
|
31
|
+
toolNames,
|
|
32
|
+
toolResultsIn,
|
|
33
|
+
} from "@alexkroman1/aai-runtime/eval";
|
|
28
34
|
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
29
35
|
import { expect } from "vitest";
|
|
36
|
+
import { z } from "zod";
|
|
30
37
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
38
|
+
/**
|
|
39
|
+
* The code every `run_code` call in this turn carried, joined.
|
|
40
|
+
*
|
|
41
|
+
* Read through `toolArgsIn` WITH a schema, which is what that reader takes one
|
|
42
|
+
* for: `args` is `Record<string, unknown>` on the wire — the model wrote it and
|
|
43
|
+
* nothing validated it — so the `String(c.args.code ?? "")` this replaced turned
|
|
44
|
+
* an argument Coda renamed, or never sent, into `""`, and every claim below about
|
|
45
|
+
* the code she wrote would have been a claim about an empty string. A `code`
|
|
46
|
+
* that stops arriving FAILS here, naming the field.
|
|
47
|
+
*/
|
|
48
|
+
const RunCodeArgs = z.object({ code: z.string() });
|
|
49
|
+
const codeIn = (turn: EvalTurn) =>
|
|
50
|
+
toolArgsIn(turn.toolCalls, "run_code", RunCodeArgs)
|
|
51
|
+
.map((args) => args.code)
|
|
36
52
|
.join("\n");
|
|
37
53
|
|
|
38
54
|
/**
|
|
39
|
-
* A `run_code` executor, so these cases can assert the ANSWER
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* boundary, and off-platform there is none — so a case could assert the CALL and
|
|
43
|
-
* the code it carried, and never what the code came back with.
|
|
44
|
-
* `createVmRunCode()` is a `node:vm` context with a capturing `console.log`,
|
|
45
|
-
* which is enough here: what runs is arithmetic, not a program. It is NOT a
|
|
46
|
-
* sandbox and does not pretend to be one; a deployed agent still gets the
|
|
47
|
-
* refusal.
|
|
55
|
+
* A `run_code` executor, so these cases can assert the ANSWER and not merely the
|
|
56
|
+
* call — `createVmRunCode`'s own doc carries why the builtin refuses without one
|
|
57
|
+
* and why a `node:vm` context is the right thing to hand it here.
|
|
48
58
|
*/
|
|
49
59
|
const runCode = createVmRunCode();
|
|
50
60
|
|
|
@@ -59,7 +69,7 @@ describeEval(
|
|
|
59
69
|
// The template's CRITICAL RULE, and the whole reason it declares
|
|
60
70
|
// run_code: a model that answers this one directly has regressed, and it
|
|
61
71
|
// is the easiest question in the file to answer wrongly with confidence.
|
|
62
|
-
expect(turn.toolCalls
|
|
72
|
+
expect(toolNames(turn.toolCalls)).toContain("run_code");
|
|
63
73
|
const code = codeIn(turn);
|
|
64
74
|
expect(code).toContain("127");
|
|
65
75
|
expect(code).toContain("849");
|
|
@@ -90,7 +100,7 @@ describeEval(
|
|
|
90
100
|
// The prompt lists this exact question under "you MUST use code for".
|
|
91
101
|
// It is the case a narrower reading of the rule ("code is for maths")
|
|
92
102
|
// silently drops.
|
|
93
|
-
expect(turn.toolCalls
|
|
103
|
+
expect(toolNames(turn.toolCalls)).toContain("run_code");
|
|
94
104
|
expect(codeIn(turn)).toMatch(/Date|2000/);
|
|
95
105
|
// The code RAN rather than being refused — but the ANSWER is
|
|
96
106
|
// deliberately not asserted here, and the reason is worth knowing before
|
|
@@ -34,7 +34,12 @@ import type { SessionEvent } from "@alexkroman1/aai/protocol";
|
|
|
34
34
|
// What no eval here can see: anything below the audio boundary. Whether a
|
|
35
35
|
// dispatcher reading a callsign in bursts lands as one turn is a property of
|
|
36
36
|
// endpointing, and these fake speech stages remove it.
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
type EvalSession,
|
|
39
|
+
lastStateIn,
|
|
40
|
+
toolNames,
|
|
41
|
+
turnCalling,
|
|
42
|
+
} from "@alexkroman1/aai-runtime/eval";
|
|
38
43
|
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
39
44
|
import { expect } from "vitest";
|
|
40
45
|
import { z } from "zod";
|
|
@@ -100,20 +105,6 @@ const boardEntry = (events: readonly SessionEvent[], id: string) =>
|
|
|
100
105
|
const refusalAt = (state: string) =>
|
|
101
106
|
new RegExp(`Not available yet: this conversation is at [\\\\"]*${state}`);
|
|
102
107
|
|
|
103
|
-
/**
|
|
104
|
-
* Drive a whole shift, one dispatcher line at a time, and hand back every turn.
|
|
105
|
-
*
|
|
106
|
-
* The cases below assert about the turn a MECHANISM fired in rather than about
|
|
107
|
-
* turn number two: how many turns a desk takes to get there is the model's
|
|
108
|
-
* business and it varies, and a case pinned to a turn index is a flake with a
|
|
109
|
-
* misleading name.
|
|
110
|
-
*/
|
|
111
|
-
async function sayAll(session: EvalSession, lines: readonly string[]): Promise<EvalTurn[]> {
|
|
112
|
-
const turns: EvalTurn[] = [];
|
|
113
|
-
for (const line of lines) turns.push(await session.say(line));
|
|
114
|
-
return turns;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
108
|
/** Every call to `tool` across the whole shift. */
|
|
118
109
|
const callsTo = (session: EvalSession, tool: string) =>
|
|
119
110
|
session.toolCalls().filter((c) => c.name === tool);
|
|
@@ -200,12 +191,15 @@ describeEval(dispatchAgent, (test) => {
|
|
|
200
191
|
test(
|
|
201
192
|
"rolls units on a logged incident and follows them to monitoring",
|
|
202
193
|
async ({ session }) => {
|
|
203
|
-
const turns = await sayAll(
|
|
194
|
+
const turns = await session.sayAll([THE_CALL, "Dispatch the recommended units now."]);
|
|
204
195
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
196
|
+
// The turn the dispatch fired in, whichever one that turned out to be:
|
|
197
|
+
// how many turns a desk spends getting there is the model's business, so
|
|
198
|
+
// a case pinned to turn two is a flake with a misleading name. A shift
|
|
199
|
+
// that never dispatched at all fails HERE, with every turn's tool list in
|
|
200
|
+
// the message, rather than as an `undefined` three assertions later.
|
|
201
|
+
const dispatching = turnCalling(turns, "resources_dispatch");
|
|
202
|
+
const rolled = dispatching.toolCalls.find((c) => c.name === "resources_dispatch");
|
|
209
203
|
// Units really assigned — `dispatched` is empty when every requested
|
|
210
204
|
// callsign was busy, which is the case the fourth test owns.
|
|
211
205
|
expect(rolled?.result).toMatch(/"dispatched":\[\{/);
|
|
@@ -215,7 +209,7 @@ describeEval(dispatchAgent, (test) => {
|
|
|
215
209
|
// The board agrees: the incident is dispatched, not merely triaged.
|
|
216
210
|
expect(boardEntry(session.events(), FIRST_INCIDENT)?.status).toBe("dispatched");
|
|
217
211
|
// And the order is the one the desk's flow requires: log, then dispatch.
|
|
218
|
-
const names = session.toolCalls()
|
|
212
|
+
const names = toolNames(session.toolCalls());
|
|
219
213
|
expect(names.indexOf("resources_dispatch")).toBeGreaterThan(names.indexOf("incident_create"));
|
|
220
214
|
},
|
|
221
215
|
{
|
|
@@ -230,7 +224,7 @@ describeEval(dispatchAgent, (test) => {
|
|
|
230
224
|
test(
|
|
231
225
|
"closing an incident releases the units that were on it",
|
|
232
226
|
async ({ session }) => {
|
|
233
|
-
await sayAll(
|
|
227
|
+
await session.sayAll([
|
|
234
228
|
THE_CALL,
|
|
235
229
|
"Dispatch the recommended units, emergency priority.",
|
|
236
230
|
"Units report the patient is transported and they're clear. Close it out.",
|
|
@@ -252,7 +246,7 @@ describeEval(dispatchAgent, (test) => {
|
|
|
252
246
|
// The board agrees, which is the half a browser would show.
|
|
253
247
|
expect(boardEntry(session.events(), FIRST_INCIDENT)?.status).toBe("resolved");
|
|
254
248
|
// And the shift ran in the order the flow requires.
|
|
255
|
-
const names = session.toolCalls()
|
|
249
|
+
const names = toolNames(session.toolCalls());
|
|
256
250
|
expect(names.indexOf("incident_create")).toBeGreaterThanOrEqual(0);
|
|
257
251
|
expect(names.indexOf("resources_dispatch")).toBeGreaterThan(names.indexOf("incident_create"));
|
|
258
252
|
expect(names.lastIndexOf("incident_update_status")).toBeGreaterThan(
|
|
@@ -279,7 +273,7 @@ describeEval(dispatchAgent, (test) => {
|
|
|
279
273
|
test(
|
|
280
274
|
"a unit already rolling is not sent to a second call",
|
|
281
275
|
async ({ session }) => {
|
|
282
|
-
await sayAll(
|
|
276
|
+
await session.sayAll([
|
|
283
277
|
THE_CALL,
|
|
284
278
|
"Send Medic-1 to Oak Street.",
|
|
285
279
|
"New call: 12 Pine Lane, chest pains. Log it.",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* tool-choice claim below then passes or fails for the wrong reason.
|
|
26
26
|
*/
|
|
27
27
|
import agentDef from "virtual:aai/agent";
|
|
28
|
-
import { toolResultIn } from "@alexkroman1/aai-runtime/eval";
|
|
28
|
+
import { toolNames, toolResultIn } from "@alexkroman1/aai-runtime/eval";
|
|
29
29
|
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
30
30
|
import { expect } from "vitest";
|
|
31
31
|
import { z } from "zod";
|
|
@@ -57,7 +57,7 @@ describeEval(agentDef, (test) => {
|
|
|
57
57
|
// about voice frameworks in general.
|
|
58
58
|
const turn = await session.say("Can your agents make HTTP requests?");
|
|
59
59
|
|
|
60
|
-
expect(turn.toolCalls
|
|
60
|
+
expect(toolNames(turn.toolCalls)).toEqual(["search_knowledge"]);
|
|
61
61
|
const call = turn.toolCalls[0]!;
|
|
62
62
|
expect(typeof (call.args as { query?: unknown }).query).toBe("string");
|
|
63
63
|
// The right entry, out of four: the one this question is really about.
|
|
@@ -81,7 +81,7 @@ describeEval(agentDef, (test) => {
|
|
|
81
81
|
async ({ session }) => {
|
|
82
82
|
const turn = await session.say("What topics can you help me with?");
|
|
83
83
|
|
|
84
|
-
expect(turn.toolCalls
|
|
84
|
+
expect(toolNames(turn.toolCalls)).toEqual(["list_topics"]);
|
|
85
85
|
// Every question in `knowledge.json` and nothing else — the check that
|
|
86
86
|
// catches an index built from a stale copy of the asset.
|
|
87
87
|
expect(toolResultIn(turn.toolCalls, "list_topics", z.array(z.string()))).toEqual(
|
|
@@ -34,14 +34,36 @@
|
|
|
34
34
|
* tool-choice claim below then passes or fails for the wrong reason.
|
|
35
35
|
*/
|
|
36
36
|
import agentDef from "virtual:aai/agent";
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
describeTurn,
|
|
39
|
+
type EvalToolCall,
|
|
40
|
+
toolArgsIn,
|
|
41
|
+
toolResultIn,
|
|
42
|
+
} from "@alexkroman1/aai-runtime/eval";
|
|
38
43
|
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
39
44
|
import { expect } from "vitest";
|
|
40
45
|
import { z } from "zod";
|
|
41
46
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Every drug this scope's interaction checks were actually asked about,
|
|
49
|
+
* lowercased.
|
|
50
|
+
*
|
|
51
|
+
* Read through `toolArgsIn` WITH a schema, which is what that reader takes one
|
|
52
|
+
* for: `args` is `Record<string, unknown>` on the wire — the model wrote it and
|
|
53
|
+
* nothing validated it — so the `args.drugs as string[] ?? []` this replaced
|
|
54
|
+
* turned a `drugs` the desk renamed, or never sent, into an empty list, and the
|
|
55
|
+
* two claims below would have been claims about nothing. A `drugs` that stops
|
|
56
|
+
* arriving FAILS here, naming the field.
|
|
57
|
+
*
|
|
58
|
+
* ZERO checks answers `[]` rather than throwing, which is what keeps the
|
|
59
|
+
* dangerous case assertable: a desk that answered an interaction question
|
|
60
|
+
* without asking a single label reaches the assertions with nothing in hand.
|
|
61
|
+
*/
|
|
62
|
+
const InteractionArgs = z.object({ drugs: z.array(z.string()) });
|
|
63
|
+
const drugsAsked = (calls: readonly EvalToolCall[]): string[] =>
|
|
64
|
+
toolArgsIn(calls, "check_drug_interaction", InteractionArgs).flatMap((args) =>
|
|
65
|
+
args.drugs.map((drug) => drug.trim().toLowerCase()),
|
|
66
|
+
);
|
|
45
67
|
|
|
46
68
|
/**
|
|
47
69
|
* A refusal from `check_drug_interaction`, as the model saw it.
|
|
@@ -60,12 +82,15 @@ describeEval(agentDef, (test) => {
|
|
|
60
82
|
const turn = await session.say("Can I take ibuprofen and warfarin together?");
|
|
61
83
|
|
|
62
84
|
// It may look each drug up as well — that is fine and often useful. What
|
|
63
|
-
// it may not do is answer this question without asking the labels
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
85
|
+
// it may not do is answer this question without asking the labels — and a
|
|
86
|
+
// desk that asked nothing arrives here with an empty list, so these two
|
|
87
|
+
// lines carry the never-checked finding as well as the wrong-drugs one.
|
|
88
|
+
// `describeTurn` is what tells them apart in the failure: "expected [] to
|
|
89
|
+
// contain 'ibuprofen'" does not say whether the desk called something
|
|
90
|
+
// else, called nothing, or was cut off mid-reply.
|
|
91
|
+
const asked = drugsAsked(turn.toolCalls);
|
|
92
|
+
expect(asked, describeTurn(turn)).toContain("ibuprofen");
|
|
93
|
+
expect(asked, describeTurn(turn)).toContain("warfarin");
|
|
69
94
|
|
|
70
95
|
// The rule at the top of its prompt: it is not a doctor. An interaction
|
|
71
96
|
// answer that does not end at a professional is the failure that makes
|
|
@@ -90,11 +115,9 @@ describeEval(agentDef, (test) => {
|
|
|
90
115
|
// drug alone and report nothing.
|
|
91
116
|
const turn = await session.say("Is it okay if I add ibuprofen for a headache?");
|
|
92
117
|
|
|
93
|
-
const
|
|
94
|
-
expect(
|
|
95
|
-
|
|
96
|
-
expect(asked).toContain("warfarin");
|
|
97
|
-
expect(asked).toContain("ibuprofen");
|
|
118
|
+
const asked = drugsAsked(turn.toolCalls);
|
|
119
|
+
expect(asked, describeTurn(turn)).toContain("warfarin");
|
|
120
|
+
expect(asked, describeTurn(turn)).toContain("ibuprofen");
|
|
98
121
|
},
|
|
99
122
|
{
|
|
100
123
|
stubReply: [
|
|
@@ -26,15 +26,13 @@
|
|
|
26
26
|
|
|
27
27
|
import { createServer, type Server } from "node:http";
|
|
28
28
|
import type { AddressInfo } from "node:net";
|
|
29
|
+
import { stubGatewayRoute } from "@alexkroman1/aai/testing";
|
|
29
30
|
import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
|
|
30
31
|
import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
31
32
|
import { expect, onTestFinished } from "vitest";
|
|
32
33
|
import agentDef, { digest } from "./agent.ts";
|
|
33
34
|
import { SETTLE_MS } from "./workflows/digest.ts";
|
|
34
35
|
|
|
35
|
-
/** The gateway leg, so one handler can route the page and the model apart. */
|
|
36
|
-
const isModelCall = (url: string): boolean => url.includes("/chat/completions");
|
|
37
|
-
|
|
38
36
|
/**
|
|
39
37
|
* A page with an ANSWER in it, so "did it summarize what it fetched" is a
|
|
40
38
|
* question with a right answer rather than a vibe.
|
|
@@ -107,15 +105,25 @@ async function servePage(html: string): Promise<string> {
|
|
|
107
105
|
*
|
|
108
106
|
* ONE handler, because publishing a `stepFetch` REPLACES — a flow that fetches a
|
|
109
107
|
* page and calls a model cannot install two fakes, so it routes by URL. The
|
|
110
|
-
*
|
|
111
|
-
*
|
|
108
|
+
* routing is `stubGatewayRoute`'s rather than this file's: it answers the
|
|
109
|
+
* completion request and `undefined` for everything else, which is what makes
|
|
110
|
+
* the page the `??` arm — and it routes off the SDK's own completions PATH, so a
|
|
111
|
+
* case cannot pass because the fake and the step agree on a typo. That matters
|
|
112
|
+
* more here than it looks: the envelope is a WIRE shape, so a field typed one
|
|
113
|
+
* off does not fail — `stepGenerate` reads no content and reports an empty
|
|
114
|
+
* completion, and the case blames the digest.
|
|
115
|
+
*
|
|
116
|
+
* The recorded calls are what makes the prompt assertable, which is the only way
|
|
117
|
+
* to check what the model was SHOWN rather than what it said — and they come
|
|
118
|
+
* back DECODED, so the last case reads `prompt` rather than the raw request
|
|
119
|
+
* body, which is the whole serialized request.
|
|
112
120
|
*/
|
|
113
121
|
function scriptBothLegs(html: string, reply = SCRIPTED_DIGEST) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
: { body: html, headers: { "Content-Type": "text/html" } },
|
|
122
|
+
const model = stubGatewayRoute(reply);
|
|
123
|
+
installStubStepFetch(
|
|
124
|
+
(request) => model.route(request) ?? { body: html, headers: { "Content-Type": "text/html" } },
|
|
118
125
|
);
|
|
126
|
+
return model;
|
|
119
127
|
}
|
|
120
128
|
|
|
121
129
|
describeWorkflowEval(agentDef, (test) => {
|
|
@@ -180,18 +188,19 @@ describeWorkflowEval(agentDef, (test) => {
|
|
|
180
188
|
"</body>",
|
|
181
189
|
`<p>${"padding sentence about otters. ".repeat(2000)}</p></body>`,
|
|
182
190
|
);
|
|
183
|
-
const
|
|
191
|
+
const model = scriptBothLegs(oversized);
|
|
184
192
|
|
|
185
193
|
const run = await app.run(digest, { url: "https://example.test/otters" });
|
|
186
194
|
expect(run.status).toBe("completed");
|
|
187
195
|
|
|
188
|
-
const
|
|
189
|
-
|
|
196
|
+
const asked = model.calls[0];
|
|
197
|
+
if (asked === undefined) expect.fail("the run must have shown the article to the model");
|
|
198
|
+
expect(asked.prompt).toContain("Sea otters are one of the few mammals that use tools");
|
|
190
199
|
// Neither the script's instruction nor the stylesheet reached the prompt.
|
|
191
|
-
expect(prompt).not.toContain(SMUGGLED);
|
|
192
|
-
expect(prompt).not.toContain("rebeccapurple");
|
|
200
|
+
expect(asked.prompt).not.toContain(SMUGGLED);
|
|
201
|
+
expect(asked.prompt).not.toContain("rebeccapurple");
|
|
193
202
|
// And the text was CAPPED on the way across the queue. 24k characters plus
|
|
194
203
|
// the prompt's own framing, well under the ~60k this page would otherwise be.
|
|
195
|
-
expect(prompt.length).toBeLessThan(30_000);
|
|
204
|
+
expect(asked.prompt.length).toBeLessThan(30_000);
|
|
196
205
|
});
|
|
197
206
|
});
|
|
@@ -14,27 +14,38 @@
|
|
|
14
14
|
// back with as well as the code the tutor wrote.
|
|
15
15
|
|
|
16
16
|
import agentDef from "virtual:aai/agent";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
createVmRunCode,
|
|
19
|
+
type EvalTurn,
|
|
20
|
+
toolArgsIn,
|
|
21
|
+
toolNames,
|
|
22
|
+
toolResultsIn,
|
|
23
|
+
} from "@alexkroman1/aai-runtime/eval";
|
|
18
24
|
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
19
25
|
import { expect } from "vitest";
|
|
26
|
+
import { z } from "zod";
|
|
20
27
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
/**
|
|
29
|
+
* The code every `run_code` call in this turn carried, joined.
|
|
30
|
+
*
|
|
31
|
+
* Read through `toolArgsIn` WITH a schema, which is what that reader takes one
|
|
32
|
+
* for: `args` is `Record<string, unknown>` on the wire — the tutor wrote it and
|
|
33
|
+
* nothing validated it — so the `String(c.args.code ?? "")` this replaced turned
|
|
34
|
+
* an argument the model renamed, or never sent, into `""`. The claims below are
|
|
35
|
+
* about the recipe the tutor wrote, and against `""` every one of them would
|
|
36
|
+
* have been a claim about nothing. A `code` that stops arriving FAILS here,
|
|
37
|
+
* naming the field.
|
|
38
|
+
*/
|
|
39
|
+
const RunCodeArgs = z.object({ code: z.string() });
|
|
40
|
+
const codeIn = (turn: EvalTurn) =>
|
|
41
|
+
toolArgsIn(turn.toolCalls, "run_code", RunCodeArgs)
|
|
42
|
+
.map((args) => args.code)
|
|
26
43
|
.join("\n");
|
|
27
44
|
|
|
28
45
|
/**
|
|
29
|
-
* A `run_code` executor, so these cases can assert the
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* boundary, and off-platform there is none — so a case could assert the CALL and
|
|
33
|
-
* the code it carried, and never what the code came back with.
|
|
34
|
-
* `createVmRunCode()` is a `node:vm` context with a capturing `console.log`,
|
|
35
|
-
* which is enough here: what runs is arithmetic, not a program. It is NOT a
|
|
36
|
-
* sandbox and does not pretend to be one; a deployed agent still gets the
|
|
37
|
-
* refusal.
|
|
46
|
+
* A `run_code` executor, so these cases can assert the answer the tutor's code
|
|
47
|
+
* came back with and not merely the code — `createVmRunCode`'s own doc carries
|
|
48
|
+
* why the builtin refuses without one.
|
|
38
49
|
*/
|
|
39
50
|
const runCode = createVmRunCode();
|
|
40
51
|
|
|
@@ -49,7 +60,7 @@ describeEval(
|
|
|
49
60
|
// The prompt hands the tutor the factors; the finding it guards against
|
|
50
61
|
// is a tutor that recites a remembered figure instead. A factor in the
|
|
51
62
|
// code is the evidence that the conversion was computed, not recalled.
|
|
52
|
-
expect(turn.toolCalls
|
|
63
|
+
expect(toolNames(turn.toolCalls)).toContain("run_code");
|
|
53
64
|
const code = codeIn(turn);
|
|
54
65
|
expect(code).toContain("5");
|
|
55
66
|
expect(code).toMatch(/1\.60|1\.61|0\.621|8\.04/);
|
|
@@ -69,7 +80,7 @@ describeEval(
|
|
|
69
80
|
// A model asked for dice will happily make three numbers up, and the
|
|
70
81
|
// reply is indistinguishable from a real roll. `Math.random` in the code
|
|
71
82
|
// is the only thing that tells them apart.
|
|
72
|
-
expect(turn.toolCalls
|
|
83
|
+
expect(toolNames(turn.toolCalls)).toContain("run_code");
|
|
73
84
|
const code = codeIn(turn);
|
|
74
85
|
expect(code).toMatch(/Math\.random/);
|
|
75
86
|
expect(code).toContain("20");
|
|
@@ -29,7 +29,10 @@ import type { SessionEvent } from "@alexkroman1/aai/protocol";
|
|
|
29
29
|
import {
|
|
30
30
|
createVmRunCode,
|
|
31
31
|
customEventsIn,
|
|
32
|
+
describeTurn,
|
|
32
33
|
lastStateIn,
|
|
34
|
+
toolArgsIn,
|
|
35
|
+
toolNames,
|
|
33
36
|
toolResultIn,
|
|
34
37
|
} from "@alexkroman1/aai-runtime/eval";
|
|
35
38
|
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
@@ -66,17 +69,23 @@ const pushedRecs = (events: readonly SessionEvent[]) =>
|
|
|
66
69
|
const nudges = (events: readonly SessionEvent[]) => customEventsIn(events, "wind_down");
|
|
67
70
|
|
|
68
71
|
/**
|
|
69
|
-
* A `run_code` executor, so the sleep-cycle case can assert the
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* boundary, and off-platform there is none — which left this template's
|
|
73
|
-
* headline feature assertable as a CALL and never as a number. A `node:vm`
|
|
74
|
-
* context with a capturing `console.log` is what a developer would reach for on
|
|
75
|
-
* their own machine, and `createVmRunCode()` is exactly that: the code under
|
|
76
|
-
* test is arithmetic the model wrote, not a program.
|
|
72
|
+
* A `run_code` executor, so the sleep-cycle case can assert the bedtime NUMBER
|
|
73
|
+
* and not merely the call — this template's headline feature is the arithmetic.
|
|
74
|
+
* `createVmRunCode`'s own doc carries why the builtin refuses without one.
|
|
77
75
|
*/
|
|
78
76
|
const runCode = createVmRunCode();
|
|
79
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The `code` argument a `run_code` call carries.
|
|
80
|
+
*
|
|
81
|
+
* The schema is what `toolArgsIn` takes one for: `args` is
|
|
82
|
+
* `Record<string, unknown>` on the wire — the model wrote it and nothing
|
|
83
|
+
* validated it — so the `String(c.args.code ?? "")` this replaced turned an
|
|
84
|
+
* argument the companion renamed, or never sent, into `""`, and the two
|
|
85
|
+
* constants asserted below would have been looked for in nothing at all.
|
|
86
|
+
*/
|
|
87
|
+
const RunCodeArgs = z.object({ code: z.string() });
|
|
88
|
+
|
|
80
89
|
/** Two-digit, for the clock arithmetic below. */
|
|
81
90
|
const pad = (n: number): string => String(n).padStart(2, "0");
|
|
82
91
|
|
|
@@ -113,7 +122,7 @@ describeEval(
|
|
|
113
122
|
|
|
114
123
|
// "to watch" is the category and "cozy" is the mood; the shelf is the
|
|
115
124
|
// tool's, so answering from the model's own taste is the regression.
|
|
116
|
-
expect(turn.toolCalls
|
|
125
|
+
expect(toolNames(turn.toolCalls)).toEqual(["recommend"]);
|
|
117
126
|
const call = turn.toolCalls[0]!;
|
|
118
127
|
expect(call.args).toEqual({ category: "movie", mood: "cozy" });
|
|
119
128
|
|
|
@@ -139,7 +148,7 @@ describeEval(
|
|
|
139
148
|
await session.say("I want something cozy to watch tonight.");
|
|
140
149
|
const turn = await session.say("Now give me something spooky to read.");
|
|
141
150
|
|
|
142
|
-
expect(turn.toolCalls
|
|
151
|
+
expect(toolNames(turn.toolCalls)).toEqual(["recommend"]);
|
|
143
152
|
expect(turn.toolCalls[0]!.args).toEqual({ category: "book", mood: "spooky" });
|
|
144
153
|
|
|
145
154
|
// The slot survived the turn boundary: the frame the page renders after
|
|
@@ -197,15 +206,21 @@ describeEval(
|
|
|
197
206
|
"I need to be up at 7 in the morning. When should I fall asleep?",
|
|
198
207
|
);
|
|
199
208
|
|
|
209
|
+
// The CALLS, not their arguments: what this asserts is that the companion
|
|
210
|
+
// reached for code at all, and the results are read off the same list
|
|
211
|
+
// below. `toolArgsIn` answers the other half, the code it submitted.
|
|
200
212
|
const ran = turn.toolCalls.filter((c) => c.name === "run_code");
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
213
|
+
// `describeTurn` is the message: "expected [] not to equal []" says
|
|
214
|
+
// nothing about a companion that talked its way through the sum
|
|
215
|
+
// instead, and it names a cancelled reply, which is the usual reason a
|
|
216
|
+
// turn reached for nothing at all.
|
|
217
|
+
expect(ran, describeTurn(turn)).not.toEqual([]);
|
|
205
218
|
// The recipe is the prompt's, and it is two constants: a 90-minute cycle
|
|
206
219
|
// plus the 15 minutes it takes to fall asleep. Arithmetic done in the
|
|
207
220
|
// model's head has neither of them anywhere in the code.
|
|
208
|
-
const code =
|
|
221
|
+
const code = toolArgsIn(turn.toolCalls, "run_code", RunCodeArgs)
|
|
222
|
+
.map((args) => args.code)
|
|
223
|
+
.join("\n");
|
|
209
224
|
expect(code).toContain("90");
|
|
210
225
|
expect(code).toContain("15");
|
|
211
226
|
|