@alexkroman1/aai-cli 7.0.0 → 8.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/{_bundler-B4RqNF5Z.mjs → _bundler-CDuYl5Gb.mjs} +1 -1
- package/dist/{_dev-server-CiKFH9qw.mjs → _dev-server-Dt3TCjhj.mjs} +6 -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-QuuOOUtv.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,342 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
// An EVAL for a WORKFLOW APP: does the run actually do the work? Run it with
|
|
3
|
+
// `aai eval`.
|
|
4
|
+
//
|
|
5
|
+
// `agent.test.ts` asserts about the declaration and drives the four legs one at
|
|
6
|
+
// a time. This drives the WHOLE BODY — `spokenSummaryFlow` from the top — and
|
|
7
|
+
// what it is here to check is the thing that is this template's whole reason to
|
|
8
|
+
// exist and that no per-step spec can see: the AUDIO ROUND TRIP. A recording
|
|
9
|
+
// goes in, and what comes out is a run whose OUTPUT NAMES A FILE that is really
|
|
10
|
+
// in the store, put there by the same step that spoke it.
|
|
11
|
+
//
|
|
12
|
+
// `describeWorkflowEval` picks the providers for you and says which it picked:
|
|
13
|
+
//
|
|
14
|
+
// * with `ASSEMBLYAI_API_KEY` — a LIVE run. The `{ live: true }` case below
|
|
15
|
+
// downloads a real four-minute news clip, really transcribes it and really
|
|
16
|
+
// summarizes it. That spends money and about a minute.
|
|
17
|
+
// * without one — a SCRIPTED run: the same body, the same four legs, with the
|
|
18
|
+
// provider answered in memory.
|
|
19
|
+
//
|
|
20
|
+
// Three of the four cases are SCRIPTED IN BOTH MODES, deliberately. Their claims
|
|
21
|
+
// are about WIRING — that the id in the output is the id of the file that was
|
|
22
|
+
// written, that the voice was handed the script and not the bullet list, that
|
|
23
|
+
// the poll loop asks for its wait — and a live provider can neither confirm nor
|
|
24
|
+
// deny any of them: it can only be asked and then have its answer accepted,
|
|
25
|
+
// which is not evidence. The live case is the one that answers "is the summary
|
|
26
|
+
// really of this recording".
|
|
27
|
+
//
|
|
28
|
+
// The UPLOAD STORE is faked in both modes, and that is not a mode question: it
|
|
29
|
+
// is this app's own storage (a database row and a blob), not a provider, and an
|
|
30
|
+
// eval has neither. `{ writable: true }` is what lets the store accept the
|
|
31
|
+
// write — opt-in precisely so a step that stored a file nobody meant it to
|
|
32
|
+
// still fails.
|
|
33
|
+
//
|
|
34
|
+
// TWO PROVIDER LEGS ARE NOT REACHABLE LIVE from an eval today, and both are
|
|
35
|
+
// named where they are worked around rather than left as a surprise:
|
|
36
|
+
//
|
|
37
|
+
// * the streaming UPLOAD leg needs a `stepFetch` — see `liveStepFetch` below.
|
|
38
|
+
// * `stepSpeak` needs a synthesizer, and the engine publishes none by default
|
|
39
|
+
// while `@alexkroman1/aai-runtime` exports no real one to pass. So the VOICE
|
|
40
|
+
// is faked in every case here, live included, and the live case's claim is
|
|
41
|
+
// about the transcript and the summary rather than about audible audio.
|
|
42
|
+
//
|
|
43
|
+
// WHAT NO EVAL HERE COVERS: durability. Imported through vitest with no bundler
|
|
44
|
+
// in the path, a `"use workflow"` body is an ordinary async function — no
|
|
45
|
+
// journal, no replay, and no per-step retry, so a rate-limited live run FAILS
|
|
46
|
+
// where a deployed one would have ridden it out, and the resume-replays-the-id
|
|
47
|
+
// property that makes speak-and-store ONE step is argued here rather than
|
|
48
|
+
// exercised. `run.slept` below is the other half of that admission written as
|
|
49
|
+
// an assertion. `aai-cli`'s `dev-workflow.scenario.test.ts` is the tier that
|
|
50
|
+
// really suspends and resumes a run.
|
|
51
|
+
import {
|
|
52
|
+
installStubSpeech,
|
|
53
|
+
installStubTranscribe,
|
|
54
|
+
installStubUploads,
|
|
55
|
+
} from "@alexkroman1/aai/testing/vitest";
|
|
56
|
+
import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
57
|
+
import { expect } from "vitest";
|
|
58
|
+
import agentDef, { spokenSummary } from "./agent.ts";
|
|
59
|
+
import { POLL_INTERVAL } from "./workflows/transcribe.ts";
|
|
60
|
+
|
|
61
|
+
/** The id every case uploads the recording under. */
|
|
62
|
+
const UPLOAD_ID = "upl_eval";
|
|
63
|
+
|
|
64
|
+
/** The public sample recording — four and a half minutes of real speech. */
|
|
65
|
+
const LIVE_RECORDING = "https://assembly.ai/wildfires.mp3";
|
|
66
|
+
|
|
67
|
+
/** What a scripted transcript says, so a summary of it is checkable. */
|
|
68
|
+
const TRANSCRIPT =
|
|
69
|
+
"Right, standup. The launch is on for Tuesday the fourth. Two bugs are left in " +
|
|
70
|
+
"checkout, both assigned to Priya, and neither is a blocker. Marketing wants the " +
|
|
71
|
+
"blog post by Monday. If the second bug slips we ship anyway and patch on Wednesday.";
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The reply the model is scripted to give.
|
|
75
|
+
*
|
|
76
|
+
* The `spoken` script deliberately shares NO wording with `points`, which is
|
|
77
|
+
* what makes "the voice was handed the script" an assertion rather than a
|
|
78
|
+
* coincidence — see the second case.
|
|
79
|
+
*/
|
|
80
|
+
const REPLY = {
|
|
81
|
+
headline: "Launch is on for Tuesday",
|
|
82
|
+
points: ["Ship Tuesday the fourth", "Two checkout bugs, neither blocking", "Blog post by Monday"],
|
|
83
|
+
spoken:
|
|
84
|
+
"Everything is lined up for the fourth. A couple of small things are still open " +
|
|
85
|
+
"in checkout and Priya has both of them; nothing there is holding the release. " +
|
|
86
|
+
"Marketing needs the write-up at the start of the week.",
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A `stepFetch` for the LIVE legs, and it exists to work around a gap rather
|
|
91
|
+
* than to add anything.
|
|
92
|
+
*
|
|
93
|
+
* The eval engine publishes no `stepFetch`, so a step's HTTP falls back to
|
|
94
|
+
* `globalThis.fetch` — which cannot send this app's UPLOAD leg at all.
|
|
95
|
+
* `stepTranscribeUpload` streams a stored recording window by window, and an
|
|
96
|
+
* iterable body requires `duplex: "half"`; the published fetch adds it (see
|
|
97
|
+
* `sdk/step-fetch.ts`, which says so) and the fallback does not, so a live run
|
|
98
|
+
* dies on `RequestInit: duplex option is required when sending a body` before
|
|
99
|
+
* the provider is ever reached. `EvalWorkflowsOptions.stepFetch` is the
|
|
100
|
+
* documented seam for a host to supply its own, and this is the smallest one
|
|
101
|
+
* that works. Every scripted case below REPLACES it, publishing being a
|
|
102
|
+
* replacement.
|
|
103
|
+
*/
|
|
104
|
+
const liveStepFetch = (url: string, init: Record<string, unknown> = {}): Promise<Response> =>
|
|
105
|
+
globalThis.fetch(url, { ...init, duplex: "half" } as RequestInit);
|
|
106
|
+
|
|
107
|
+
/** Publish this app's own store, writable because the last step writes to it. */
|
|
108
|
+
function publish(bytes: Uint8Array, name: string, type: string) {
|
|
109
|
+
return installStubUploads({ [UPLOAD_ID]: { bytes, name, type } }, { writable: true });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Answer every leg of the run in memory: the three transcription calls, and the
|
|
114
|
+
* model.
|
|
115
|
+
*
|
|
116
|
+
* ONE fake, because publishing a `stepFetch` REPLACES — a flow that transcribes
|
|
117
|
+
* AND calls a model cannot install two, which is exactly what `otherwise` is
|
|
118
|
+
* for. The transcription half is the SDK's own fake rather than this file's
|
|
119
|
+
* hand-typed wire: it routes off the SDK's endpoint constants, so a case cannot
|
|
120
|
+
* pass because the fake and the step agree on a typo.
|
|
121
|
+
*/
|
|
122
|
+
function scriptProvider(options: { text?: string; pendingPolls?: number } = {}) {
|
|
123
|
+
return installStubTranscribe({
|
|
124
|
+
text: options.text ?? TRANSCRIPT,
|
|
125
|
+
durationSec: 42,
|
|
126
|
+
// Passed straight through rather than conditionally spread: the option
|
|
127
|
+
// already admits `undefined`, and `guard-invariants` rule 2 counts the
|
|
128
|
+
// spread.
|
|
129
|
+
pendingPolls: options.pendingPolls,
|
|
130
|
+
otherwise: (request) =>
|
|
131
|
+
request.url.includes("llm-gateway")
|
|
132
|
+
? { body: { choices: [{ message: { content: JSON.stringify(REPLY) } }] } }
|
|
133
|
+
: undefined,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
describeWorkflowEval(
|
|
138
|
+
agentDef,
|
|
139
|
+
(test) => {
|
|
140
|
+
test("the run's output NAMES a file that one step spoke and stored", async ({ app }) => {
|
|
141
|
+
// Scripted in both modes: the claim is that the id in the output is the id
|
|
142
|
+
// of the file in the store, and no provider can be asked to make that true.
|
|
143
|
+
// This is the case that catches speak-and-store coming apart — a run that
|
|
144
|
+
// returned bytes, or an id nothing wrote, or two ids because the synthesis
|
|
145
|
+
// and the store became two steps.
|
|
146
|
+
const uploads = publish(new Uint8Array(64), "standup.wav", "audio/wav");
|
|
147
|
+
const provider = scriptProvider();
|
|
148
|
+
const speech = installStubSpeech({ pcmBytes: 96_000 });
|
|
149
|
+
|
|
150
|
+
const run = await app.run(spokenSummary, { recording: UPLOAD_ID });
|
|
151
|
+
|
|
152
|
+
// The error FIRST, so a failed run names its own reason instead of
|
|
153
|
+
// reporting "expected 'failed' to be 'completed'".
|
|
154
|
+
expect(run.error).toBeUndefined();
|
|
155
|
+
expect(run.status).toBe("completed");
|
|
156
|
+
const output = run.output;
|
|
157
|
+
if (output === undefined) expect.fail("a completed run must carry an output");
|
|
158
|
+
|
|
159
|
+
// EXACTLY one write. Two would mean the synthesis and the store had come
|
|
160
|
+
// apart into two steps, which is the mistake this template exists to argue
|
|
161
|
+
// against — a step is journaled by its return value, so an id replays and
|
|
162
|
+
// bytes do not.
|
|
163
|
+
expect(uploads.writes).toHaveLength(1);
|
|
164
|
+
const written = uploads.writes[0];
|
|
165
|
+
if (written === undefined) expect.fail("the speaking step must have stored a file");
|
|
166
|
+
// The output carries the ID of that write, and an id is a string — not the
|
|
167
|
+
// audio. A run's output is read back as JSON.
|
|
168
|
+
expect(output.audio).toBe(written.id);
|
|
169
|
+
expect(typeof output.audio).toBe("string");
|
|
170
|
+
|
|
171
|
+
// And the bytes are really there, and are really a WAV: named and typed for
|
|
172
|
+
// a browser, because the byte route serves what it was given and nothing
|
|
173
|
+
// plays a file handed to it as octet-stream.
|
|
174
|
+
expect(written.name).toBe("summary.wav");
|
|
175
|
+
expect(written.type).toBe("audio/wav");
|
|
176
|
+
expect(written.bytes.byteLength).toBe(44 + 96_000);
|
|
177
|
+
expect(String.fromCharCode(...written.bytes.subarray(0, 4))).toBe("RIFF");
|
|
178
|
+
expect(String.fromCharCode(...written.bytes.subarray(8, 12))).toBe("WAVE");
|
|
179
|
+
// 96,000 bytes at 24 kHz mono 16-bit is two seconds, which is what the
|
|
180
|
+
// page prints next to the player.
|
|
181
|
+
expect(output.audioDurationMs).toBe(2000);
|
|
182
|
+
|
|
183
|
+
// The rest of the round trip, so a run that stored audio for the wrong text
|
|
184
|
+
// is not mistaken for a working one.
|
|
185
|
+
expect(output.source).toBe("standup.wav");
|
|
186
|
+
expect(output.durationMs).toBe(42_000);
|
|
187
|
+
expect(output.transcript).toBe(TRANSCRIPT);
|
|
188
|
+
expect(output.points).toHaveLength(3);
|
|
189
|
+
expect(speech.calls).toHaveLength(1);
|
|
190
|
+
|
|
191
|
+
// Four legs, four narrated lines, in order — which is what a page watching
|
|
192
|
+
// the run renders.
|
|
193
|
+
expect(run.reported[0]).toMatch(/^Uploading standup\.wav /);
|
|
194
|
+
expect(run.reported).toContain("Summarizing the transcript.");
|
|
195
|
+
expect(run.reported.at(-1)).toMatch(/^Recorded a 2s summary in \w+'s voice\.$/);
|
|
196
|
+
// The job finished on its first poll, so no durable wait was asked for.
|
|
197
|
+
expect(run.slept).toEqual([]);
|
|
198
|
+
// One upload of the recording, and one submit — the split that exists so a
|
|
199
|
+
// fault in the submit does not re-upload the file.
|
|
200
|
+
expect(provider.calls.filter((call) => call.leg === "upload")).toHaveLength(1);
|
|
201
|
+
expect(provider.calls.filter((call) => call.leg === "submit")).toHaveLength(1);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("the voice reads the SCRIPT the model was asked for, not the points", async ({ app }) => {
|
|
205
|
+
// Scripted in both modes: what a live voice says is not evidence about
|
|
206
|
+
// which string it was handed. This is the case that catches the template's
|
|
207
|
+
// central prompt decision regressing — synthesize the bullet list and you
|
|
208
|
+
// get a voice reading "one. two. three." with no connective tissue.
|
|
209
|
+
publish(new Uint8Array(64), "standup.wav", "audio/wav");
|
|
210
|
+
const provider = scriptProvider();
|
|
211
|
+
const speech = installStubSpeech();
|
|
212
|
+
|
|
213
|
+
const run = await app.run(spokenSummary, { recording: UPLOAD_ID, voice: "michael" });
|
|
214
|
+
|
|
215
|
+
expect(run.error).toBeUndefined();
|
|
216
|
+
const spokenText = speech.calls[0]?.text;
|
|
217
|
+
expect(spokenText).toBe(REPLY.spoken);
|
|
218
|
+
// The script and the points share no wording, so this is the assertion:
|
|
219
|
+
// whatever was spoken, it was not the list.
|
|
220
|
+
for (const point of REPLY.points) expect(spokenText).not.toContain(point);
|
|
221
|
+
// The form's choice really reaches the synthesizer.
|
|
222
|
+
expect(speech.calls[0]?.voice).toBe("michael");
|
|
223
|
+
// Both shapes survive to the output — one to read, one that was heard.
|
|
224
|
+
expect(run.output?.points).toEqual(REPLY.points);
|
|
225
|
+
expect(run.output?.spoken).toBe(REPLY.spoken);
|
|
226
|
+
|
|
227
|
+
// And the model was ASKED for both, over the transcript it was given. A
|
|
228
|
+
// prompt that stopped asking for a script is how the field goes missing.
|
|
229
|
+
const prompt = String(provider.calls.find((call) => call.leg === "other")?.body ?? "");
|
|
230
|
+
expect(prompt).toContain("READ ALOUD");
|
|
231
|
+
expect(prompt).toContain("The launch is on for Tuesday the fourth");
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test("a recording with no speech stops before the model and the voice", async ({ app }) => {
|
|
235
|
+
// Scripted in both modes, and it costs nothing live either way: silence
|
|
236
|
+
// transcribes SUCCESSFULLY to nothing, so without a terminal failure here
|
|
237
|
+
// the run would go on to summarize no words and store half a second of
|
|
238
|
+
// audio — a green run with an empty product.
|
|
239
|
+
const uploads = publish(new Uint8Array(64), "silence.wav", "audio/wav");
|
|
240
|
+
const provider = scriptProvider({ text: " " });
|
|
241
|
+
const speech = installStubSpeech();
|
|
242
|
+
|
|
243
|
+
const run = await app.run(spokenSummary, { recording: UPLOAD_ID });
|
|
244
|
+
|
|
245
|
+
expect(run.status).toBe("failed");
|
|
246
|
+
expect(run.error).toMatch(/no speech in that recording/i);
|
|
247
|
+
expect(run.output).toBeUndefined();
|
|
248
|
+
// Nothing was summarized and nothing was spoken, which is the half that
|
|
249
|
+
// makes this more than an error-message assertion.
|
|
250
|
+
expect(provider.calls.filter((call) => call.leg === "other")).toEqual([]);
|
|
251
|
+
expect(speech.calls).toEqual([]);
|
|
252
|
+
expect(uploads.writes).toEqual([]);
|
|
253
|
+
expect(run.reported).not.toContain("Summarizing the transcript.");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("an unfinished job is waited out with a DURABLE sleep, not a busy loop", async ({
|
|
257
|
+
app,
|
|
258
|
+
}) => {
|
|
259
|
+
// Scripted in both modes: a live job cannot be asked to stay queued for
|
|
260
|
+
// exactly two polls, and this is the one place the poll loop's shape is
|
|
261
|
+
// visible — one submit, one poll per round, and a recorded wait between
|
|
262
|
+
// them. A loop that re-submitted, or one that spun with no wait, both
|
|
263
|
+
// produce a correct transcript and a wrong bill.
|
|
264
|
+
publish(new Uint8Array(64), "standup.wav", "audio/wav");
|
|
265
|
+
const provider = scriptProvider({ pendingPolls: 2 });
|
|
266
|
+
installStubSpeech();
|
|
267
|
+
|
|
268
|
+
const run = await app.run(spokenSummary, { recording: UPLOAD_ID });
|
|
269
|
+
|
|
270
|
+
expect(run.error).toBeUndefined();
|
|
271
|
+
expect(run.output?.transcript).toBe(TRANSCRIPT);
|
|
272
|
+
// Two waits for three polls: asked for and — this being an eval rather
|
|
273
|
+
// than a deployment — recorded rather than taken.
|
|
274
|
+
expect(run.slept).toEqual([{ duration: POLL_INTERVAL }, { duration: POLL_INTERVAL }]);
|
|
275
|
+
expect(provider.calls.filter((call) => call.leg === "poll")).toHaveLength(3);
|
|
276
|
+
// The expensive half happened ONCE, which is the whole reason the upload
|
|
277
|
+
// and the submit are separate steps.
|
|
278
|
+
expect(provider.calls.filter((call) => call.leg === "upload")).toHaveLength(1);
|
|
279
|
+
expect(provider.calls.filter((call) => call.leg === "submit")).toHaveLength(1);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test(
|
|
283
|
+
"really transcribes and summarizes a real recording",
|
|
284
|
+
async ({ app }) => {
|
|
285
|
+
// LIVE ONLY, and the one case here that measures rather than checks
|
|
286
|
+
// wiring. Three things have to be real for it to pass: the file has to
|
|
287
|
+
// stream to the provider, the transcript has to be of THAT recording, and
|
|
288
|
+
// the model has to write a script from that transcript.
|
|
289
|
+
//
|
|
290
|
+
// The VOICE is faked even here, and that is a harness limit rather than a
|
|
291
|
+
// choice — `stepSpeak` reads a published synthesizer, the eval engine
|
|
292
|
+
// publishes none, and no real one is exported to pass it. So what this
|
|
293
|
+
// case does NOT claim is that the audio is audible; the round trip's
|
|
294
|
+
// structure (one write, an id in the output, real WAV framing) is what
|
|
295
|
+
// the first case pins, in memory, where it is a fact rather than a hope.
|
|
296
|
+
const speech = installStubSpeech({ pcmBytes: 48_000 });
|
|
297
|
+
const response = await fetch(LIVE_RECORDING);
|
|
298
|
+
expect(response.ok).toBe(true);
|
|
299
|
+
const mp3 = new Uint8Array(await response.arrayBuffer());
|
|
300
|
+
const uploads = publish(mp3, "wildfires.mp3", "audio/mpeg");
|
|
301
|
+
|
|
302
|
+
const run = await app.run(spokenSummary, { recording: UPLOAD_ID });
|
|
303
|
+
|
|
304
|
+
expect(run.error).toBeUndefined();
|
|
305
|
+
expect(run.status).toBe("completed");
|
|
306
|
+
const output = run.output;
|
|
307
|
+
if (output === undefined) expect.fail("a completed run must carry an output");
|
|
308
|
+
|
|
309
|
+
// The transcript is of THIS recording — a news segment about smoke from
|
|
310
|
+
// Canadian wildfires reaching the US east coast.
|
|
311
|
+
expect(output.transcript).toMatch(/wildfire/i);
|
|
312
|
+
expect(output.transcript).toMatch(/canada/i);
|
|
313
|
+
expect(output.words).toBeGreaterThan(400);
|
|
314
|
+
// The FILENAME a reader sees is the one they uploaded, not the opaque id.
|
|
315
|
+
expect(output.source).toBe("wildfires.mp3");
|
|
316
|
+
expect(output.durationMs).toBeGreaterThan(250_000);
|
|
317
|
+
|
|
318
|
+
// The summary is of the transcript, not of summarizing in general.
|
|
319
|
+
const written = `${output.headline} ${output.points.join(" ")} ${output.spoken}`;
|
|
320
|
+
expect(written).toMatch(/smoke|wildfire|air/i);
|
|
321
|
+
expect(output.points.length).toBeGreaterThan(0);
|
|
322
|
+
expect(output.points.length).toBeLessThanOrEqual(4);
|
|
323
|
+
// A SCRIPT rather than a list: sentences, no bullet markers, and long
|
|
324
|
+
// enough that a voice reading it has something to say. This is the
|
|
325
|
+
// template's central prompt decision, measured against a real model.
|
|
326
|
+
expect(output.spoken).toMatch(/[.!?]/);
|
|
327
|
+
expect(output.spoken).not.toMatch(/^\s*[-*\u2022]/m);
|
|
328
|
+
expect(output.spoken.length).toBeGreaterThan(80);
|
|
329
|
+
// And it is the SCRIPT that was spoken, not the points a page renders.
|
|
330
|
+
expect(speech.calls[0]?.text).toBe(output.spoken);
|
|
331
|
+
|
|
332
|
+
// The store really holds what the output names.
|
|
333
|
+
expect(uploads.writes).toHaveLength(1);
|
|
334
|
+
const stored = uploads.read(output.audio);
|
|
335
|
+
if (stored === undefined) expect.fail("the run's `audio` id must name a stored file");
|
|
336
|
+
expect(String.fromCharCode(...stored.bytes.subarray(0, 4))).toBe("RIFF");
|
|
337
|
+
},
|
|
338
|
+
{ live: true },
|
|
339
|
+
);
|
|
340
|
+
},
|
|
341
|
+
{ stepFetch: liveStepFetch },
|
|
342
|
+
);
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
// An EVAL: does the line really refuse to say what it cannot ground? Run it
|
|
4
|
+
// with `aai eval`.
|
|
5
|
+
//
|
|
6
|
+
// `agent.test.ts` drives the retriever and each CRAG node directly. What it
|
|
7
|
+
// cannot see is the claim this template exists to make — that a MODEL holding
|
|
8
|
+
// this tool list cannot state a fee it did not just retrieve. So these cases
|
|
9
|
+
// drive a real session and read the verdicts off the tool results: which
|
|
10
|
+
// documents the grader passed, whether the answer was grounded, and whether an
|
|
11
|
+
// answer it could not ground was WITHHELD.
|
|
12
|
+
//
|
|
13
|
+
// **One thing a SCRIPTED run cannot reach: `ctx.generate` with a SCHEMA.** The
|
|
14
|
+
// stub model registers as an LLM provider, so `ctx.generate` resolves it, and it
|
|
15
|
+
// answers a plain text generation from the script. Every node in this template's
|
|
16
|
+
// loop passes a `schema` though (`binaryScore`), and that overload comes back
|
|
17
|
+
// `{"error":"The knowledge base lookup failed: No output generated."}` — so the
|
|
18
|
+
// whole corrective loop is unreachable scripted. That is the template's central
|
|
19
|
+
// tool, so the two cases that run without a key are written as invariants both
|
|
20
|
+
// modes satisfy (a failed lookup is REPORTED, never answered around), and the
|
|
21
|
+
// two that read a real verdict are `{ live: true }`.
|
|
22
|
+
//
|
|
23
|
+
// **`system-prompt.md` is applied HERE, not by `agent.ts`.** The build discovers
|
|
24
|
+
// the file, so an eval driving the raw default export would run this agent with
|
|
25
|
+
// the FRAMEWORK DEFAULT prompt — and the discipline that prompt imposes is the
|
|
26
|
+
// entire subject of this file. Measured against the default: the model answers
|
|
27
|
+
// product questions from its own knowledge and skips the tools the prompt exists
|
|
28
|
+
// to route it through, so a case run that way measures nothing it claims to.
|
|
29
|
+
|
|
30
|
+
import { withSystemPrompt } from "@alexkroman1/aai/manifest";
|
|
31
|
+
import { withDiscoveredTools } from "@alexkroman1/aai/testing";
|
|
32
|
+
import { type EvalSession, toolResultIn } from "@alexkroman1/aai-runtime/eval";
|
|
33
|
+
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
34
|
+
import { expect } from "vitest";
|
|
35
|
+
import { z } from "zod";
|
|
36
|
+
import authoredAgent from "./agent.ts";
|
|
37
|
+
import systemPrompt from "./system-prompt.md?raw";
|
|
38
|
+
|
|
39
|
+
/** The def a DEPLOYED agent runs — see `agent.test.ts` on why the glob is here. */
|
|
40
|
+
const agentDef = withSystemPrompt(
|
|
41
|
+
withDiscoveredTools(authoredAgent, import.meta.glob("./tools/*.ts", { eager: true })),
|
|
42
|
+
systemPrompt,
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* What the BROWSER is sent, as this eval reads it.
|
|
47
|
+
*
|
|
48
|
+
* Parsed rather than cast: `state.updated` carries `unknown`, so a projection
|
|
49
|
+
* that stopped matching FAILS naming the field, where the cast this replaced
|
|
50
|
+
* handed the assertions `undefined` and failed a line later on something else.
|
|
51
|
+
* It names only the fields asserted below, so `supportView` may grow without
|
|
52
|
+
* touching this.
|
|
53
|
+
*/
|
|
54
|
+
const ProjectedSupport = z.object({
|
|
55
|
+
ticket: z.string().nullable(),
|
|
56
|
+
trace: z
|
|
57
|
+
.object({
|
|
58
|
+
exhausted: z.boolean(),
|
|
59
|
+
docs: z.array(z.object({ relevant: z.boolean() })),
|
|
60
|
+
steps: z.array(z.object({ node: z.string() })),
|
|
61
|
+
})
|
|
62
|
+
.nullable(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* What `answer_question` answered, validated.
|
|
67
|
+
*
|
|
68
|
+
* All-optional, and that is the shape of the CONTRACT rather than laxness: the
|
|
69
|
+
* tool has three legal outcomes — a graded answer, a withheld one carrying
|
|
70
|
+
* guidance, and a reported failure — so a required field would fail two of the
|
|
71
|
+
* three. What the schema still buys is that a field the case reads is the type
|
|
72
|
+
* the case thinks it is.
|
|
73
|
+
*/
|
|
74
|
+
const Lookup = z.object({
|
|
75
|
+
answer: z.string().nullable().optional(),
|
|
76
|
+
sources: z.array(z.string()).optional(),
|
|
77
|
+
grounded: z.boolean().optional(),
|
|
78
|
+
answersTheQuestion: z.boolean().optional(),
|
|
79
|
+
rewrites: z.number().optional(),
|
|
80
|
+
guidance: z.string().optional(),
|
|
81
|
+
error: z.string().optional(),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/** Every `syncState` frame, in stream order. */
|
|
85
|
+
function frames(session: EvalSession) {
|
|
86
|
+
return session
|
|
87
|
+
.events()
|
|
88
|
+
.flatMap((event) =>
|
|
89
|
+
event.type === "state.updated" ? [ProjectedSupport.parse(event.state)] : [],
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* What `answer_question` answered, in call order.
|
|
95
|
+
*
|
|
96
|
+
* `toolResultIn` over a ONE-CALL list per call: the name is that call's own, so
|
|
97
|
+
* the reader's "no such call" and "two calls" throws are unreachable and what is
|
|
98
|
+
* left is the parse, the schema, and its "never completed" failure — which is
|
|
99
|
+
* the half the hand-rolled `JSON.parse(call.result ?? "null")` this replaced got
|
|
100
|
+
* wrong, reading `null` for a tool that never came back.
|
|
101
|
+
*/
|
|
102
|
+
function lookups(session: EvalSession) {
|
|
103
|
+
return session
|
|
104
|
+
.toolCalls()
|
|
105
|
+
.filter((call) => call.name === "answer_question")
|
|
106
|
+
.map((call) => toolResultIn([call], call.name, Lookup));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describeEval(agentDef, (test) => {
|
|
110
|
+
test(
|
|
111
|
+
"nothing about the product is said without going through the knowledge base",
|
|
112
|
+
async ({ session }) => {
|
|
113
|
+
const turn = await session.say("How much notice do I have to give if I want to cancel?");
|
|
114
|
+
|
|
115
|
+
// The prompt's one rule: `answer_question` is the only route to anything
|
|
116
|
+
// about Meridian Fibre. A model that answered a notice period from its own
|
|
117
|
+
// knowledge would sound identical down a phone.
|
|
118
|
+
const asked = turn.toolCalls.filter((call) => call.name === "answer_question");
|
|
119
|
+
// The reply rides in the message, because the failure that matters here is
|
|
120
|
+
// a turn that SPOKE without looking anything up — "let me check that for
|
|
121
|
+
// you" and then nothing, or worse, a notice period from memory.
|
|
122
|
+
expect(
|
|
123
|
+
asked.length,
|
|
124
|
+
`tools called: [${turn.toolCalls.map((c) => c.name).join(", ")}]; said: ${turn.text}`,
|
|
125
|
+
).toBe(1);
|
|
126
|
+
|
|
127
|
+
for (const payload of lookups(session)) {
|
|
128
|
+
// Three legal outcomes, and the invariant that spans them: an answer the
|
|
129
|
+
// caller is given is an answer the hallucination grader passed. `grounded:
|
|
130
|
+
// false` reaching the model is the one thing the whole apparatus exists
|
|
131
|
+
// to prevent — and a broken lookup is REPORTED rather than worked around.
|
|
132
|
+
if (payload.error !== undefined) continue;
|
|
133
|
+
if (payload.answer === null) continue;
|
|
134
|
+
expect(payload.grounded, `ungrounded answer handed to the model: ${payload.answer}`).toBe(
|
|
135
|
+
true,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
stubReply: [{ tool: "answer_question", args: { question: "notice to cancel" } }, "One sec."],
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
test(
|
|
145
|
+
"a logged ticket's callback number never leaves the server",
|
|
146
|
+
async ({ session }) => {
|
|
147
|
+
// Two turns, because that is the shape the prompt asks for: the lookup comes
|
|
148
|
+
// back empty, the line offers a ticket, and only then is there a number to
|
|
149
|
+
// take. Asked for in one breath, a live model reasonably answers the offer
|
|
150
|
+
// instead of acting on it.
|
|
151
|
+
await session.say("Can I add a pay-monthly mobile SIM to my broadband account?");
|
|
152
|
+
await session.say("Yes please, log that one — my callback number is 07700 900123.");
|
|
153
|
+
|
|
154
|
+
const logged = session.toolCalls().find((call) => call.name === "log_ticket");
|
|
155
|
+
expect(
|
|
156
|
+
logged,
|
|
157
|
+
`tools called: ${session
|
|
158
|
+
.toolCalls()
|
|
159
|
+
.map((c) => c.name)
|
|
160
|
+
.join(", ")}`,
|
|
161
|
+
).toBeDefined();
|
|
162
|
+
expect(logged?.result).toMatch(/TCK\d{4}/);
|
|
163
|
+
|
|
164
|
+
const latest = frames(session).at(-1);
|
|
165
|
+
// The projection carries the REFERENCE and nothing else — that is why
|
|
166
|
+
// `supportView` exists rather than `syncState` taking the state itself.
|
|
167
|
+
expect(latest?.ticket).toMatch(/^TCK\d{4}$/);
|
|
168
|
+
for (const frame of frames(session)) {
|
|
169
|
+
expect(JSON.stringify(frame)).not.toMatch(/900123/);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
stubReply: [
|
|
174
|
+
{ tool: "answer_question", args: { question: "add a mobile SIM to my account" } },
|
|
175
|
+
"I don't have that documented. Shall I log a ticket for a callback?",
|
|
176
|
+
{
|
|
177
|
+
tool: "log_ticket",
|
|
178
|
+
args: { question: "add a mobile SIM to my account", callback: "07700 900123" },
|
|
179
|
+
},
|
|
180
|
+
"That's logged — the reference is T C K four thousand and one.",
|
|
181
|
+
],
|
|
182
|
+
},
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
test(
|
|
186
|
+
"the answer comes from the document the caller asked about, not the one next to it",
|
|
187
|
+
async ({ session }) => {
|
|
188
|
+
// Short and single, deliberately: the claim here is which document the
|
|
189
|
+
// grader passed, so the question is phrased to measure that and not the
|
|
190
|
+
// loop's pacing.
|
|
191
|
+
//
|
|
192
|
+
// It used to say the corrective path could not fit the runtime's 30s
|
|
193
|
+
// per-tool deadline. Re-measured against the live gateway, that is no
|
|
194
|
+
// longer true by an order of magnitude — the two-attempt path runs in
|
|
195
|
+
// 2.8s of nine model calls — so the phrasing above is a choice about what
|
|
196
|
+
// this case measures rather than a way around a clock. The numbers and
|
|
197
|
+
// what they do and do not license are on `LOOKUP_BUDGET_MS` in
|
|
198
|
+
// `tools/answer_question.ts`.
|
|
199
|
+
const turn = await session.say("How much notice do I have to give to cancel my contract?");
|
|
200
|
+
|
|
201
|
+
const [payload] = lookups(session);
|
|
202
|
+
expect(
|
|
203
|
+
payload,
|
|
204
|
+
`tools called: [${turn.toolCalls.map((c) => c.name).join(", ")}]; said: ${turn.text}`,
|
|
205
|
+
).toBeDefined();
|
|
206
|
+
// The whole verdict in the message: `grounded: undefined` on its own does
|
|
207
|
+
// not say whether the lookup failed, or ran and refused.
|
|
208
|
+
const verdict = JSON.stringify(payload);
|
|
209
|
+
expect(payload?.grounded, verdict).toBe(true);
|
|
210
|
+
expect(payload?.answersTheQuestion, verdict).toBe(true);
|
|
211
|
+
|
|
212
|
+
const sources = payload?.sources ?? [];
|
|
213
|
+
expect(sources).toContain("Cancelling your contract");
|
|
214
|
+
// THE claim, and the reason the knowledge base is built the way it is:
|
|
215
|
+
// "cancelling your contract" and "cancelling an engineer visit" are two
|
|
216
|
+
// documents, two fees and one word apart. A retriever with no grader hands
|
|
217
|
+
// back both, and sixty pounds gets read down the phone as the answer to a
|
|
218
|
+
// contract question.
|
|
219
|
+
expect(sources).not.toContain("Cancelling or rebooking an engineer visit");
|
|
220
|
+
// Either spelling: the claim is that the notice period came out of D5, not
|
|
221
|
+
// how the answer prompt chose to write a numeral.
|
|
222
|
+
expect(String(payload?.answer)).toMatch(/thirty days|30 days/i);
|
|
223
|
+
expect(String(payload?.answer)).not.toMatch(/sixty pound|abortive/i);
|
|
224
|
+
expect(turn.text).not.toMatch(/sixty|abortive/i);
|
|
225
|
+
},
|
|
226
|
+
// Live only: the grader's verdict IS the measurement.
|
|
227
|
+
//
|
|
228
|
+
// THE CLOCK, and it is no longer the limit it was recorded as here.
|
|
229
|
+
// `answer_question` is up to eleven sequential model calls and the tool
|
|
230
|
+
// executor abandons any tool at `TOOL_EXECUTION_TIMEOUT_MS` (30s) — a
|
|
231
|
+
// deadline neither an agent author nor an eval can raise — so this comment
|
|
232
|
+
// used to say the two-attempt corrective path did not fit, which made this
|
|
233
|
+
// case a coin toss on the gateway rather than a reading of the grader.
|
|
234
|
+
// Re-measured: the whole loop is 2.0-3.1s, the corrective path 2.8s of nine
|
|
235
|
+
// calls, and eight consecutive live runs of this suite were green. The
|
|
236
|
+
// margin belongs to the service, not to us, so the tool keeps its own
|
|
237
|
+
// budget and this case keeps a question that settles on one attempt; if the
|
|
238
|
+
// gateway slows down again the failure says which, because a spent budget
|
|
239
|
+
// comes back as the graph's own exit sentence rather than as a timeout.
|
|
240
|
+
{ live: true },
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
test(
|
|
244
|
+
"a question nothing covers is refused, after the corrective edge has tried",
|
|
245
|
+
async ({ session }) => {
|
|
246
|
+
const turn = await session.say(
|
|
247
|
+
"Can I add a pay-monthly mobile SIM with unlimited data to my broadband account?",
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
const [payload] = lookups(session);
|
|
251
|
+
expect(
|
|
252
|
+
payload,
|
|
253
|
+
`tools called: [${turn.toolCalls.map((c) => c.name).join(", ")}]; said: ${turn.text}`,
|
|
254
|
+
).toBeDefined();
|
|
255
|
+
// Withheld, not softened: `answer: null` is the tool refusing to hand the
|
|
256
|
+
// model something to read out, and the guidance is the exit the grading
|
|
257
|
+
// apparatus needs — a support line that can only answer will answer wrong.
|
|
258
|
+
// A lookup that BROKE and one that ran and found nothing are different
|
|
259
|
+
// findings, and neither is readable from "answer is not null".
|
|
260
|
+
expect(payload?.answer, JSON.stringify(payload)).toBeNull();
|
|
261
|
+
expect(String(payload?.guidance)).toMatch(/log_ticket/);
|
|
262
|
+
// The corrective edge really ran: nothing relevant means the RETRIEVAL was
|
|
263
|
+
// wrong, so the query is rewritten and retried before the loop gives up.
|
|
264
|
+
expect(payload?.rewrites).toBeGreaterThanOrEqual(1);
|
|
265
|
+
|
|
266
|
+
const trace = frames(session).at(-1)?.trace;
|
|
267
|
+
expect(trace?.exhausted).toBe(true);
|
|
268
|
+
expect(trace?.docs.every((doc) => !doc.relevant)).toBe(true);
|
|
269
|
+
expect(trace?.steps.map((step) => step.node)).toContain("transform_query");
|
|
270
|
+
},
|
|
271
|
+
{ live: true },
|
|
272
|
+
);
|
|
273
|
+
});
|
|
@@ -7,7 +7,8 @@ The one rule that matters:
|
|
|
7
7
|
|
|
8
8
|
Running a call:
|
|
9
9
|
|
|
10
|
-
-
|
|
10
|
+
- **Every turn in which the caller asks anything about Meridian Fibre begins with a call to `answer_question`.** The call goes out first, in that turn, before you decide what to say. That is the whole procedure and there is no version of it where the lookup happens later.
|
|
11
|
+
- "Let me check that for you" is not a check. It is what you may say while the call you have already made is running, in the same turn — five words, no more. **A turn that ends on that sentence with no `answer_question` call in it has hung up on the caller**: they are holding a line that never comes back, which is worse than the silence the sentence exists to fill. If you have said it, the tool call is already out or the turn is not finished.
|
|
11
12
|
- Give the answer it returns in your own words, keeping every number exactly as it came back.
|
|
12
13
|
- When it returns no answer, say plainly that you do not have that documented. Do not guess, and do not soften it into a maybe. Offer to log a ticket.
|
|
13
14
|
- When it returns an answer with a caveat, give the answer and then offer the ticket.
|