@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
|
@@ -16,33 +16,43 @@
|
|
|
16
16
|
// live rates API.
|
|
17
17
|
|
|
18
18
|
import agentDef from "virtual:aai/agent";
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
createVmRunCode,
|
|
21
|
+
type EvalTurn,
|
|
22
|
+
toolArgsIn,
|
|
23
|
+
toolResultsIn,
|
|
24
|
+
} from "@alexkroman1/aai-runtime/eval";
|
|
20
25
|
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
21
26
|
import { expect } from "vitest";
|
|
27
|
+
import { z } from "zod";
|
|
22
28
|
|
|
23
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The arguments Penny's two builtins carry, as the wire has them.
|
|
31
|
+
*
|
|
32
|
+
* Schemas rather than `String(args.code ?? "")`, which is what `toolArgsIn`
|
|
33
|
+
* takes one for: `args` is `Record<string, unknown>` — the model wrote it and
|
|
34
|
+
* nothing validated it — so an argument Penny renamed, or never sent, used to
|
|
35
|
+
* read as `""`, and the claims below about the code she submitted and the URL
|
|
36
|
+
* she asked for would have been claims about an empty string. An argument that
|
|
37
|
+
* stops arriving FAILS here, naming the field.
|
|
38
|
+
*/
|
|
39
|
+
const RunCodeArgs = z.object({ code: z.string() });
|
|
40
|
+
const FetchJsonArgs = z.object({ url: z.string() });
|
|
24
41
|
|
|
25
42
|
/** The code every `run_code` call in this turn carried, joined. */
|
|
26
|
-
const codeIn = (turn:
|
|
27
|
-
turn.toolCalls
|
|
28
|
-
.
|
|
29
|
-
.map((c) => String(c.args.code ?? ""))
|
|
43
|
+
const codeIn = (turn: EvalTurn) =>
|
|
44
|
+
toolArgsIn(turn.toolCalls, "run_code", RunCodeArgs)
|
|
45
|
+
.map((args) => args.code)
|
|
30
46
|
.join("\n");
|
|
31
47
|
|
|
32
48
|
/** Every URL this turn's `fetch_json` calls asked for. */
|
|
33
|
-
const fetchedUrls = (turn:
|
|
34
|
-
turn.toolCalls
|
|
49
|
+
const fetchedUrls = (turn: EvalTurn) =>
|
|
50
|
+
toolArgsIn(turn.toolCalls, "fetch_json", FetchJsonArgs).map((args) => args.url);
|
|
35
51
|
|
|
36
52
|
/**
|
|
37
|
-
* A `run_code` executor, so
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* boundary, and off-platform there is none — so a case could assert the CALL and
|
|
41
|
-
* the code it carried, and never what the code came back with.
|
|
42
|
-
* `createVmRunCode()` is a `node:vm` context with a capturing `console.log`,
|
|
43
|
-
* which is enough here: what runs is arithmetic, not a program. It is NOT a
|
|
44
|
-
* sandbox and does not pretend to be one; a deployed agent still gets the
|
|
45
|
-
* refusal.
|
|
53
|
+
* A `run_code` executor, so the arithmetic cases can assert the ANSWER and not
|
|
54
|
+
* merely the call — `createVmRunCode`'s own doc carries why the builtin refuses
|
|
55
|
+
* without one. `fetch_json` needs nothing of the sort: it makes a real request.
|
|
46
56
|
*/
|
|
47
57
|
const runCode = createVmRunCode();
|
|
48
58
|
|
|
@@ -13,7 +13,7 @@ import type { SessionEvent } from "@alexkroman1/aai/protocol";
|
|
|
13
13
|
// SCRIPTED model (its `stubReply`), which still boots this agent, still
|
|
14
14
|
// resolves `tools/`, and still executes the tool a script names — so a stub run
|
|
15
15
|
// proves the wiring and proves nothing about what the agent chose.
|
|
16
|
-
import { lastStateIn, toolResultIn } from "@alexkroman1/aai-runtime/eval";
|
|
16
|
+
import { lastStateIn, statesIn, toolNames, toolResultIn } from "@alexkroman1/aai-runtime/eval";
|
|
17
17
|
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
18
18
|
import { expect } from "vitest";
|
|
19
19
|
import { z } from "zod";
|
|
@@ -44,9 +44,14 @@ const ProjectedOrder = z.object({
|
|
|
44
44
|
*/
|
|
45
45
|
const lastPushedView = (events: readonly SessionEvent[]) => lastStateIn(events, ProjectedOrder);
|
|
46
46
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Every cart the session pushed, in stream order — `statesIn` is `lastStateIn`'s
|
|
49
|
+
* plural half, and takes the schema for the same reason.
|
|
50
|
+
*
|
|
51
|
+
* The SEQUENCE is the stronger claim: not "the cart is not placed now" but "no
|
|
52
|
+
* frame the customer ever saw showed it placed".
|
|
53
|
+
*/
|
|
54
|
+
const pushedViews = (events: readonly SessionEvent[]) => statesIn(events, ProjectedOrder);
|
|
50
55
|
|
|
51
56
|
describeEval(agentDef, (test) => {
|
|
52
57
|
test(
|
|
@@ -58,7 +63,7 @@ describeEval(agentDef, (test) => {
|
|
|
58
63
|
|
|
59
64
|
// One tool, and the right one: quoting a price without adding the pizza,
|
|
60
65
|
// or adding it twice, are both real findings.
|
|
61
|
-
expect(turn.toolCalls
|
|
66
|
+
expect(toolNames(turn.toolCalls)).toEqual(["add_pizza"]);
|
|
62
67
|
const call = turn.toolCalls[0]!;
|
|
63
68
|
const args = call.args as { size: string; crust: string; toppings: string[] };
|
|
64
69
|
expect(args.size).toBe("large");
|
|
@@ -106,7 +111,7 @@ describeEval(agentDef, (test) => {
|
|
|
106
111
|
// turn leaves the model with nothing to address.
|
|
107
112
|
const turn = await session.say("Actually, make that a large.");
|
|
108
113
|
|
|
109
|
-
expect(turn.toolCalls
|
|
114
|
+
expect(toolNames(turn.toolCalls)).toEqual(["update_pizza"]);
|
|
110
115
|
const call = turn.toolCalls[0]!;
|
|
111
116
|
expect(call.args).toMatchObject({ pizza_id: 1, size: "large" });
|
|
112
117
|
|
|
@@ -24,7 +24,13 @@
|
|
|
24
24
|
|
|
25
25
|
/** The def a DEPLOYED agent runs — see `agent.test.ts` on why the glob is here. */
|
|
26
26
|
import agentDef from "virtual:aai/agent";
|
|
27
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
describeToolCalls,
|
|
29
|
+
describeTurn,
|
|
30
|
+
type EvalSession,
|
|
31
|
+
lastStateIn,
|
|
32
|
+
toolNames,
|
|
33
|
+
} from "@alexkroman1/aai-runtime/eval";
|
|
28
34
|
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
29
35
|
import { expect } from "vitest";
|
|
30
36
|
import { z } from "zod";
|
|
@@ -57,8 +63,6 @@ const ProjectedPlan = z.object({
|
|
|
57
63
|
*/
|
|
58
64
|
const planState = (session: EvalSession) => lastStateIn(session.events(), ProjectedPlan);
|
|
59
65
|
|
|
60
|
-
const named = (calls: readonly { name: string }[]): string[] => calls.map((call) => call.name);
|
|
61
|
-
|
|
62
66
|
describeEval(agentDef, (test) => {
|
|
63
67
|
test(
|
|
64
68
|
"the stage the desk reports is the flow's, not a guess at the plan",
|
|
@@ -122,7 +126,10 @@ describeEval(agentDef, (test) => {
|
|
|
122
126
|
"I want to work out whether it is cheaper to take the train or fly from London to Edinburgh next month.",
|
|
123
127
|
);
|
|
124
128
|
const started = session.toolCalls().find((call) => call.name === "start_plan");
|
|
125
|
-
|
|
129
|
+
// The whole SESSION's calls, not one turn's: the plan may be started on
|
|
130
|
+
// either utterance, and "expected undefined to be defined" says nothing
|
|
131
|
+
// about a desk that talked instead. `describeToolCalls` is that sentence.
|
|
132
|
+
expect(started, describeToolCalls(session.toolCalls())).toBeDefined();
|
|
126
133
|
const planned = planState(session);
|
|
127
134
|
// The tool's own result rides in the message: a planner that FAILED (a
|
|
128
135
|
// gateway error, a schema the provider would not honour) writes nothing,
|
|
@@ -131,8 +138,8 @@ describeEval(agentDef, (test) => {
|
|
|
131
138
|
expect(planned?.plan.length ?? 0).toBeGreaterThan(0);
|
|
132
139
|
|
|
133
140
|
const worked = await session.say("Yes, go ahead and start on it.");
|
|
134
|
-
const calls =
|
|
135
|
-
expect(calls,
|
|
141
|
+
const calls = toolNames(worked.toolCalls).filter((name) => name === "work_next_step").length;
|
|
142
|
+
expect(calls, describeTurn(worked)).toBeGreaterThan(0);
|
|
136
143
|
|
|
137
144
|
const after = planState(session);
|
|
138
145
|
const done = after?.done ?? [];
|
|
@@ -180,7 +187,7 @@ describeEval(agentDef, (test) => {
|
|
|
180
187
|
const revised = turn.toolCalls.find((call) => call.name === "revise_plan");
|
|
181
188
|
expect(
|
|
182
189
|
revised,
|
|
183
|
-
|
|
190
|
+
`${describeTurn(turn)} — the caller changed the objective, ` +
|
|
184
191
|
"so this is `revise_plan`, not a plan rewritten by hand",
|
|
185
192
|
).toBeDefined();
|
|
186
193
|
expect(revised?.args.instruction).toBeTruthy();
|
|
@@ -62,8 +62,15 @@
|
|
|
62
62
|
* this file SHIPS — see `agent.test.ts`.
|
|
63
63
|
*/
|
|
64
64
|
import agentDef from "virtual:aai/agent";
|
|
65
|
+
import { stubGatewayRoute } from "@alexkroman1/aai/testing";
|
|
65
66
|
import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
|
|
66
|
-
import
|
|
67
|
+
import {
|
|
68
|
+
describeToolCalls,
|
|
69
|
+
type EvalToolCall,
|
|
70
|
+
type EvalWorkflows,
|
|
71
|
+
toolResultIn,
|
|
72
|
+
toolResultsIn,
|
|
73
|
+
} from "@alexkroman1/aai-runtime/eval";
|
|
67
74
|
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
68
75
|
import { expect } from "vitest";
|
|
69
76
|
import { z } from "zod";
|
|
@@ -116,17 +123,24 @@ type ScriptedProvider = {
|
|
|
116
123
|
* rather than answering an empty 200: a step calling something nobody expected
|
|
117
124
|
* is a finding, where an empty body reads as a provider that said nothing.
|
|
118
125
|
*
|
|
126
|
+
* The model leg is `stubGatewayRoute`'s, first, because it is the one leg whose
|
|
127
|
+
* shape this file cannot check: the completion envelope is a WIRE shape, so a
|
|
128
|
+
* field typed one off does not fail — `stepGenerate` reads no content, reports
|
|
129
|
+
* an empty completion, and the case blames the recap. The reader routes off the
|
|
130
|
+
* SDK's own completions PATH and answers `undefined` for everything else, which
|
|
131
|
+
* is what lets it sit in front of the three batch legs below it.
|
|
132
|
+
*
|
|
119
133
|
* `hold` keeps the FIRST poll pending, and it is the only way to observe a run
|
|
120
134
|
* that is still going: a durable `sleep` is skipped here, so an unheld run
|
|
121
135
|
* burns its whole poll loop in milliseconds.
|
|
122
136
|
*/
|
|
123
137
|
function stubProvider(options: { hold?: boolean; ending?: Ending } = {}): ScriptedProvider {
|
|
124
138
|
const gate = Promise.withResolvers<void>();
|
|
139
|
+
const model = stubGatewayRoute(RECAP_JSON);
|
|
125
140
|
let polls = 0;
|
|
126
141
|
const stub = installStubStepFetch(async (request) => {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
142
|
+
const recapped = model.route(request);
|
|
143
|
+
if (recapped) return recapped;
|
|
130
144
|
if (request.method === "POST") return { body: { id: TRANSCRIPT_ID, status: "queued" } };
|
|
131
145
|
// The compensation. A real DELETE removes the transcript from the account,
|
|
132
146
|
// which is what makes "a failed run leaves nothing behind" a claim rather
|
|
@@ -158,21 +172,32 @@ const Cancelled = z.object({ cancelled: z.boolean(), note: z.string() });
|
|
|
158
172
|
*
|
|
159
173
|
* Parsed rather than regexed: a tool result reaches the event stream as a
|
|
160
174
|
* serialized string, and a shape that stopped matching should fail HERE naming
|
|
161
|
-
* the field instead of handing the next assertion `undefined`.
|
|
175
|
+
* the field instead of handing the next assertion `undefined`. That is
|
|
176
|
+
* `toolResultsIn`'s job, and the half it does better than the filter-and-map
|
|
177
|
+
* this was: a call with no result THROWS naming its position, where
|
|
178
|
+
* `one.result !== undefined` dropped it — so a tool that was called and never
|
|
179
|
+
* returned left a shorter list and a case that read the calls it did get.
|
|
162
180
|
*/
|
|
163
|
-
function recapStarts(calls: readonly EvalToolCall[]): z.infer<typeof RecapStart>[] {
|
|
164
|
-
return calls
|
|
165
|
-
.filter((one) => one.name === "request_recap" && one.result !== undefined)
|
|
166
|
-
.map((one) => RecapStart.parse(JSON.parse(String(one.result))));
|
|
181
|
+
function recapStarts(calls: readonly EvalToolCall[]): readonly z.infer<typeof RecapStart>[] {
|
|
182
|
+
return toolResultsIn(calls, "request_recap", RecapStart);
|
|
167
183
|
}
|
|
168
184
|
|
|
169
|
-
/**
|
|
185
|
+
/**
|
|
186
|
+
* The run id the FIRST `request_recap` of this turn reported.
|
|
187
|
+
*
|
|
188
|
+
* `recapStarts` rather than `toolResultIn`: a turn is allowed more than one call
|
|
189
|
+
* here — the case below says so in as many words, a desk that asks twice being
|
|
190
|
+
* the model's business — and the singular reader refuses a second one on
|
|
191
|
+
* purpose. What is asserted about the extras is that each was REFUSED with the
|
|
192
|
+
* run this found.
|
|
193
|
+
*/
|
|
170
194
|
function startedRunId(calls: readonly EvalToolCall[]): string {
|
|
171
195
|
const [first] = recapStarts(calls);
|
|
172
196
|
if (first === undefined) {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
197
|
+
// `describeToolCalls` is the harness's own sentence for this — including
|
|
198
|
+
// "called no tools", which is the answer a desk that asked a question
|
|
199
|
+
// instead of acting gives and the one an empty list reads as truncation.
|
|
200
|
+
throw new Error(`no request_recap in this turn: ${describeToolCalls(calls)}`);
|
|
176
201
|
}
|
|
177
202
|
return first.runId;
|
|
178
203
|
}
|
|
@@ -207,14 +232,19 @@ const START_TURN = [
|
|
|
207
232
|
* Not tidiness: the scripted provider is unpublished when the test that
|
|
208
233
|
* installed it finishes, so a body still mid-flight would make its next request
|
|
209
234
|
* against whatever the next case publishes — or against the real provider, with
|
|
210
|
-
* a real key.
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
235
|
+
* a real key. `close()` says so on stderr (`EvalRunAbandoned`) when a case
|
|
236
|
+
* forgets, which is a report rather than a fix — the wait is `settleAll`'s and
|
|
237
|
+
* the RELEASE is this template's, because what holds the run in flight is this
|
|
238
|
+
* file's own gate and nothing in the harness can open one.
|
|
239
|
+
*
|
|
240
|
+
* A run drained here COMPLETES rather than failing, and the last thing it does
|
|
241
|
+
* on the way is delete its own transcript: the retention gate's window closes
|
|
242
|
+
* with nobody having answered, which is the safe default. See the header, and
|
|
243
|
+
* the case that pins it.
|
|
214
244
|
*/
|
|
215
245
|
async function drain(workflows: EvalWorkflows | undefined, provider: ScriptedProvider) {
|
|
216
246
|
provider.release();
|
|
217
|
-
|
|
247
|
+
await workflows?.settleAll();
|
|
218
248
|
}
|
|
219
249
|
|
|
220
250
|
describeEval(
|
|
@@ -328,8 +358,7 @@ describeEval(
|
|
|
328
358
|
const runId = startedRunId(started.toolCalls);
|
|
329
359
|
const turn = await session.say("Forget it — cancel that, please.");
|
|
330
360
|
|
|
331
|
-
const
|
|
332
|
-
const answer = Cancelled.parse(JSON.parse(String(cancel?.result)));
|
|
361
|
+
const answer = toolResultIn(turn.toolCalls, "cancel_recap", Cancelled);
|
|
333
362
|
expect(answer.cancelled).toBe(true);
|
|
334
363
|
// The sentence is a documented promise of this template, not a
|
|
335
364
|
// decoration: cancellation is NOT cooperative here, so the transcript
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
// journal, no replay, and no per-step retry, so a rate-limited live run FAILS
|
|
30
30
|
// where a deployed one would have ridden it out. The tier that really resumes a
|
|
31
31
|
// run is `aai-cli`'s `dev-workflow.scenario.test.ts`.
|
|
32
|
+
import { stubGatewayRoute } from "@alexkroman1/aai/testing";
|
|
32
33
|
import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
|
|
33
34
|
import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
34
35
|
import { expect } from "vitest";
|
|
@@ -52,32 +53,36 @@ const critique = (verdict: "ship" | "revise", score = 8): string =>
|
|
|
52
53
|
notes: verdict === "ship" ? [] : ["Say what happens to the handover", "Name the start date"],
|
|
53
54
|
});
|
|
54
55
|
|
|
55
|
-
/** One gateway reply, in the envelope `stepGenerate` reads. */
|
|
56
|
-
const reply = (content: string) => ({ body: { choices: [{ message: { content } }] } });
|
|
57
|
-
|
|
58
56
|
/**
|
|
59
57
|
* Answer the gateway with `contents`, in order, and record what each stage asked.
|
|
60
58
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
59
|
+
* `stubGatewayRoute` owns both halves this file used to hand-write. The
|
|
60
|
+
* ENVELOPE, because it is a WIRE shape and a field typed one off does not fail —
|
|
61
|
+
* `stepGenerate` reads no content, reports an empty completion, and the case
|
|
62
|
+
* blames the loop. And the CURSOR: the last reply repeats, because a loop cannot
|
|
63
|
+
* know how many calls it will make, so a script that said one thing forever
|
|
64
|
+
* could only ever drive it into its budget and one that ran out mid-loop would
|
|
65
|
+
* fail on the script rather than on the code. Both of those are now the SDK's
|
|
66
|
+
* one copy rather than this file's second.
|
|
67
|
+
*
|
|
68
|
+
* `installStubStepFetch` rather than `installStubGateway`: `stepGenerate` goes
|
|
69
|
+
* through the published `stepFetch` slot, and a published slot BEATS a stubbed
|
|
70
|
+
* global, so stubbing the global here would test a path production does not
|
|
71
|
+
* take. Anything that is not a completion request THROWS — every step in this
|
|
72
|
+
* body is a model call, so a request the route does not recognise is a finding,
|
|
73
|
+
* and answering it with a reply anyway is how a stage that started dialling
|
|
74
|
+
* something else would pass.
|
|
67
75
|
*/
|
|
68
76
|
function scriptGateway(contents: readonly string[]) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
77
|
+
const model = stubGatewayRoute(contents);
|
|
78
|
+
installStubStepFetch((request) => {
|
|
79
|
+
const answered = model.route(request);
|
|
80
|
+
if (answered === undefined) {
|
|
81
|
+
throw new Error(`unexpected step request in an eval: ${request.method} ${request.url}`);
|
|
82
|
+
}
|
|
83
|
+
return answered;
|
|
74
84
|
});
|
|
75
|
-
return
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/** Every prompt the gateway was sent, in call order. */
|
|
79
|
-
function promptsOf(fetched: ReturnType<typeof scriptGateway>): string[] {
|
|
80
|
-
return fetched.calls.map((call) => String(call.body ?? ""));
|
|
85
|
+
return model;
|
|
81
86
|
}
|
|
82
87
|
|
|
83
88
|
describeWorkflowEval(agentDef, (test) => {
|
|
@@ -140,7 +145,7 @@ describeWorkflowEval(agentDef, (test) => {
|
|
|
140
145
|
// running, and asking it and then accepting whatever it says is not evidence
|
|
141
146
|
// about the budget. What this pins is the loop's arithmetic — the half a
|
|
142
147
|
// live case cannot reach.
|
|
143
|
-
const
|
|
148
|
+
const model = scriptGateway([
|
|
144
149
|
DRAFT,
|
|
145
150
|
critique("revise", 4),
|
|
146
151
|
`${DRAFT} It starts on the first Monday of the month.`,
|
|
@@ -170,12 +175,15 @@ describeWorkflowEval(agentDef, (test) => {
|
|
|
170
175
|
).toBe(true);
|
|
171
176
|
// One draft plus a critique-and-revise pair per round. A loop that critiqued
|
|
172
177
|
// twice, or revised the round it shipped, changes this number.
|
|
173
|
-
expect(
|
|
178
|
+
expect(model.calls).toHaveLength(1 + 2 * MAX_ROUNDS);
|
|
174
179
|
|
|
175
180
|
// `briefBlock` is what keeps the three stages from drifting apart, and this is
|
|
176
181
|
// the assertion behind that claim: the writer, the critic AND the reviser were
|
|
177
|
-
// all shown the same brief and the same must-cover point.
|
|
178
|
-
|
|
182
|
+
// all shown the same brief and the same must-cover point. Read off the
|
|
183
|
+
// recorded `prompt` — the USER message — rather than off the raw request
|
|
184
|
+
// body, which is the whole serialized request and would let a `model` id or a
|
|
185
|
+
// `temperature` satisfy one of these `toContain`s.
|
|
186
|
+
const prompts = model.calls.map((call) => call.prompt);
|
|
179
187
|
expect(prompts).toHaveLength(1 + 2 * MAX_ROUNDS);
|
|
180
188
|
for (const prompt of prompts) {
|
|
181
189
|
expect(prompt).toContain("quokka");
|
|
@@ -29,8 +29,9 @@
|
|
|
29
29
|
// endpointing, barge-in, whether two sentences merged into one turn.
|
|
30
30
|
|
|
31
31
|
import agentDef from "virtual:aai/agent";
|
|
32
|
+
import { stubGatewayRoute } from "@alexkroman1/aai/testing";
|
|
32
33
|
import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
|
|
33
|
-
import type
|
|
34
|
+
import { type EvalToolCall, type EvalWorkflows, toolResultIn } from "@alexkroman1/aai-runtime/eval";
|
|
34
35
|
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
35
36
|
import { expect } from "vitest";
|
|
36
37
|
import { z } from "zod";
|
|
@@ -112,7 +113,17 @@ type ScriptedSteps = {
|
|
|
112
113
|
* on the live path, so a live case still measures the agent. Anything that is
|
|
113
114
|
* not the gateway THROWS rather than answering 200: an unexpected request from
|
|
114
115
|
* a step is a finding, and a silent empty body would be read as a model that
|
|
115
|
-
* said nothing.
|
|
116
|
+
* said nothing. `stubGatewayRoute` is what decides which is which, and it
|
|
117
|
+
* decides on the SDK's own completions PATH — so the script cannot come unstuck
|
|
118
|
+
* from the step by the two agreeing on a typo, and the envelope it answers with
|
|
119
|
+
* is the SDK's rather than this file's. That last part is the one worth having:
|
|
120
|
+
* the envelope is a WIRE shape, so a field typed one off does not fail —
|
|
121
|
+
* `stepGenerate` reads no content, reports an empty completion, and the case
|
|
122
|
+
* blames the run.
|
|
123
|
+
*
|
|
124
|
+
* The CURSOR is the reader's too: the last reply repeats, which is what a stage
|
|
125
|
+
* that legitimately calls the model twice needs and what stops a script running
|
|
126
|
+
* out mid-run and failing on itself.
|
|
116
127
|
*
|
|
117
128
|
* `hold` keeps the FIRST answer pending, which is the only way to observe a run
|
|
118
129
|
* that is still going: a durable `sleep` is skipped here, so an unheld run
|
|
@@ -120,15 +131,17 @@ type ScriptedSteps = {
|
|
|
120
131
|
*/
|
|
121
132
|
function scriptSteps(options: { hold?: boolean } = {}): ScriptedSteps {
|
|
122
133
|
const gate = Promise.withResolvers<void>();
|
|
123
|
-
|
|
134
|
+
const model = stubGatewayRoute(MODEL_SCRIPT);
|
|
124
135
|
const stub = installStubStepFetch(async (request) => {
|
|
125
|
-
|
|
136
|
+
const answered = model.route(request);
|
|
137
|
+
if (answered === undefined) {
|
|
126
138
|
throw new Error(`unexpected step request in an eval: ${request.method} ${request.url}`);
|
|
127
139
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
140
|
+
// `model.calls` has already recorded this one, so a length of 1 IS the first
|
|
141
|
+
// answer — and holding after the route rather than before it keeps the reply
|
|
142
|
+
// this returns the one the script owed that call.
|
|
143
|
+
if (options.hold === true && model.calls.length === 1) await gate.promise;
|
|
144
|
+
return answered;
|
|
132
145
|
});
|
|
133
146
|
return { calls: stub.calls, release: () => gate.resolve() };
|
|
134
147
|
}
|
|
@@ -141,20 +154,16 @@ const Started = z.object({
|
|
|
141
154
|
});
|
|
142
155
|
|
|
143
156
|
/**
|
|
144
|
-
* The run id
|
|
157
|
+
* The run id the `request_research` call reported.
|
|
145
158
|
*
|
|
146
|
-
*
|
|
147
|
-
* serialized string, and a shape that stopped matching should
|
|
148
|
-
* the field instead of handing the next assertion `undefined`.
|
|
159
|
+
* `toolResultIn` rather than a `find` and a parse: a tool result reaches the
|
|
160
|
+
* event stream as a serialized string, and a shape that stopped matching should
|
|
161
|
+
* fail HERE naming the field instead of handing the next assertion `undefined`.
|
|
162
|
+
* It throws for the two other ways this can go wrong as well, each naming what
|
|
163
|
+
* was really called — no such call, and a call that never returned.
|
|
149
164
|
*/
|
|
150
165
|
function startedRunId(calls: readonly EvalToolCall[]): string {
|
|
151
|
-
|
|
152
|
-
if (call?.result === undefined) {
|
|
153
|
-
throw new Error(
|
|
154
|
-
`the desk called no request_research: ${calls.map((one) => one.name).join(", ") || "(no tools)"}`,
|
|
155
|
-
);
|
|
156
|
-
}
|
|
157
|
-
return Started.parse(JSON.parse(call.result)).runId;
|
|
166
|
+
return toolResultIn(calls, "request_research", Started).runId;
|
|
158
167
|
}
|
|
159
168
|
|
|
160
169
|
/** Every tool call in this turn that READS a run, whichever the model picked. */
|
|
@@ -177,12 +186,13 @@ const START_TURN = [
|
|
|
177
186
|
* Not tidiness: the scripted `stepFetch` is unpublished when the test that
|
|
178
187
|
* installed it finishes, so a body still mid-flight would make its next model
|
|
179
188
|
* call against whatever the next case publishes — or against the real gateway.
|
|
189
|
+
* `close()` reports that on stderr (`EvalRunAbandoned`) rather than fixing it:
|
|
190
|
+
* the wait is `settleAll`'s, and the RELEASE stays here, because what holds the
|
|
191
|
+
* run in flight is this file's own gate and nothing in the harness can open one.
|
|
180
192
|
*/
|
|
181
193
|
async function drain(workflows: EvalWorkflows | undefined, steps: ScriptedSteps): Promise<void> {
|
|
182
194
|
steps.release();
|
|
183
|
-
|
|
184
|
-
await workflows?.settle(run.runId, research);
|
|
185
|
-
}
|
|
195
|
+
await workflows?.settleAll();
|
|
186
196
|
}
|
|
187
197
|
|
|
188
198
|
describeEval(
|
|
@@ -31,7 +31,7 @@ import type { SessionEvent } from "@alexkroman1/aai/protocol";
|
|
|
31
31
|
// What no eval here can see: anything below the audio boundary. Whether a
|
|
32
32
|
// caller reading an order number in bursts lands as one turn is a property of
|
|
33
33
|
// endpointing, and these fake speech stages remove it.
|
|
34
|
-
import {
|
|
34
|
+
import { describeTurn, lastStateIn, toolNames, turnCalling } from "@alexkroman1/aai-runtime/eval";
|
|
35
35
|
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
36
36
|
import { expect } from "vitest";
|
|
37
37
|
import { z } from "zod";
|
|
@@ -93,26 +93,6 @@ function statusOf(events: readonly SessionEvent[], orderId: string): string | un
|
|
|
93
93
|
const refusalAt = (state: string) =>
|
|
94
94
|
new RegExp(`Not available yet: this conversation is at [\\\\"]*${state}`);
|
|
95
95
|
|
|
96
|
-
/**
|
|
97
|
-
* Drive a whole call, one caller line at a time, and hand back every turn.
|
|
98
|
-
*
|
|
99
|
-
* The cases below assert about the turn a MECHANISM fired in rather than about
|
|
100
|
-
* turn number two, because how many turns a desk takes to get there is the
|
|
101
|
-
* model's business and it really does vary: measured live, this agent reads the
|
|
102
|
-
* order back out of `get_order_details` and asks before it stages, so the
|
|
103
|
-
* staging call has landed in turn two, three and four across runs. A case
|
|
104
|
-
* pinned to a turn index is a flake with a misleading name.
|
|
105
|
-
*/
|
|
106
|
-
async function sayAll(session: EvalSession, lines: readonly string[]): Promise<EvalTurn[]> {
|
|
107
|
-
const turns: EvalTurn[] = [];
|
|
108
|
-
for (const line of lines) turns.push(await session.say(line));
|
|
109
|
-
return turns;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** The turn a named tool was called in, if any. */
|
|
113
|
-
const turnCalling = (turns: readonly EvalTurn[], tool: string) =>
|
|
114
|
-
turns.find((t) => t.toolCalls.some((c) => c.name === tool));
|
|
115
|
-
|
|
116
96
|
/** One line the caller says to identify themselves, and the scripted tool call
|
|
117
97
|
* that answers it — the first turn of three of these four cases. */
|
|
118
98
|
const AUTH_TURN = [
|
|
@@ -175,7 +155,7 @@ describeEval(retailAgent, (test) => {
|
|
|
175
155
|
// turn, and whether it spends one is not something an eval should pin.
|
|
176
156
|
// Every assertion below is about the turn the staging landed in, so a
|
|
177
157
|
// later apply cannot affect any of them.
|
|
178
|
-
const turns = await sayAll(
|
|
158
|
+
const turns = await session.sayAll([
|
|
179
159
|
`My email is ${CALLER_EMAIL}.`,
|
|
180
160
|
"I'd like to cancel my pending order — I ordered it by mistake.",
|
|
181
161
|
"Yes, please go ahead and cancel it.",
|
|
@@ -183,8 +163,13 @@ describeEval(retailAgent, (test) => {
|
|
|
183
163
|
"Yes. Cancel it, please.",
|
|
184
164
|
]);
|
|
185
165
|
|
|
166
|
+
// The turn the staging landed in, whichever it was — measured live it has
|
|
167
|
+
// been turn two, three and four. `turnCalling` throws when no turn staged
|
|
168
|
+
// at all, naming every turn's tool list: a desk that talked through all
|
|
169
|
+
// five without staging is the finding, and "expected undefined to be
|
|
170
|
+
// defined" is not a report of it.
|
|
186
171
|
const staging = turnCalling(turns, "cancel_pending_order");
|
|
187
|
-
const staged = staging
|
|
172
|
+
const staged = staging.toolCalls.find((c) => c.name === "cancel_pending_order");
|
|
188
173
|
expect(staged?.result).toMatch(/NOTHING HAS CHANGED YET/);
|
|
189
174
|
// The gate is a POSITION, and this is it moving: the tool reported the
|
|
190
175
|
// state it landed in, which is the only state `confirm_change` is legal
|
|
@@ -192,13 +177,13 @@ describeEval(retailAgent, (test) => {
|
|
|
192
177
|
expect(staged?.result).toMatch(/serving\.awaitingConfirmation/);
|
|
193
178
|
// A change cannot be described and applied in the same turn. This is the
|
|
194
179
|
// property the prose in the system prompt could never have.
|
|
195
|
-
expect(staging
|
|
180
|
+
expect(toolNames(staging.toolCalls)).not.toContain("confirm_change");
|
|
196
181
|
// And after the turn that staged it, the store really is untouched — read
|
|
197
182
|
// off the projection the BROWSER was sent in that same turn.
|
|
198
|
-
expect(statusOf(staging
|
|
199
|
-
expect(projection(staging
|
|
183
|
+
expect(statusOf(staging.events, PENDING_ORDER)).toBe("pending");
|
|
184
|
+
expect(projection(staging.events)?.pending?.kind).toBe("cancel_pending_order");
|
|
200
185
|
// Step 2 of the policy: read it back and ask.
|
|
201
|
-
expect(staging
|
|
186
|
+
expect(staging.text).toMatch(/\?/);
|
|
202
187
|
},
|
|
203
188
|
{ stubReply: [...AUTH_TURN, ...STAGE_TURN, "Cancelling it now — one moment."] },
|
|
204
189
|
);
|
|
@@ -210,7 +195,7 @@ describeEval(retailAgent, (test) => {
|
|
|
210
195
|
// in is its own business, and saying yes repeatedly is what makes
|
|
211
196
|
// "exactly once" below a claim about the MECHANISM rather than about the
|
|
212
197
|
// model's pacing.
|
|
213
|
-
await sayAll(
|
|
198
|
+
await session.sayAll([
|
|
214
199
|
`My email is ${CALLER_EMAIL}.`,
|
|
215
200
|
"Please cancel my pending order — I ordered it by mistake.",
|
|
216
201
|
"Yes, that's right, go ahead.",
|
|
@@ -231,7 +216,7 @@ describeEval(retailAgent, (test) => {
|
|
|
231
216
|
expect(extra.result).toMatch(/Not available yet/);
|
|
232
217
|
}
|
|
233
218
|
// And it came after the stage, never instead of it.
|
|
234
|
-
const names = session.toolCalls()
|
|
219
|
+
const names = toolNames(session.toolCalls());
|
|
235
220
|
expect(names.indexOf("cancel_pending_order")).toBeGreaterThanOrEqual(0);
|
|
236
221
|
expect(names.indexOf("confirm_change")).toBeGreaterThan(
|
|
237
222
|
names.indexOf("cancel_pending_order"),
|
|
@@ -262,11 +247,10 @@ describeEval(retailAgent, (test) => {
|
|
|
262
247
|
// Named first, and with a message: a live model that answers the request
|
|
263
248
|
// with a question instead of the tool leaves `transfer` undefined, and
|
|
264
249
|
// `.toMatch()` on it reports only "expected a string, got undefined" —
|
|
265
|
-
// which says nothing about what the desk actually did.
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
).toBeDefined();
|
|
250
|
+
// which says nothing about what the desk actually did. `describeTurn` is
|
|
251
|
+
// that sentence, done by the harness: the tools it called and what it
|
|
252
|
+
// said, plus whether the reply was cancelled.
|
|
253
|
+
expect(transfer, describeTurn(handoff)).toBeDefined();
|
|
270
254
|
// The terminal state is what makes "say nothing else after this" a
|
|
271
255
|
// property of the agent rather than a line in its prompt: `done` is the
|
|
272
256
|
// flow saying there is nowhere left to go.
|