@alexkroman1/aai-cli 13.1.0 → 13.3.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/bin.mjs +1 -1
- package/dist/{_agent-BTwZJu4P.mjs → _agent-Clbq3a4h.mjs} +2 -2
- package/dist/{_bundler-C31rlcYz.mjs → _bundler-DEkjO1d8.mjs} +3 -3
- package/dist/{_config-CmJOFsAP.mjs → _config-ZY2euhdh.mjs} +7 -4
- package/dist/_config.d.ts +25 -1
- package/dist/{_dev-server-DWvkNoIS.mjs → _dev-server-C13xuGB1.mjs} +13 -13
- package/dist/_e2e-test-utils.d.ts +15 -7
- package/dist/{_init-BURbdHNM.mjs → _init-Ci4QGjp8.mjs} +58 -10
- package/dist/{_output-CKkmWs7i.mjs → _output-DbJt9gsl.mjs} +34 -3
- package/dist/_output.d.ts +17 -1
- package/dist/{_preflight-Co1G9Jww.mjs → _preflight-B2laWPdU.mjs} +1 -1
- package/dist/{_server-common-6e0QI4mq.mjs → _server-common-BV4_CrP3.mjs} +3 -3
- package/dist/{_slug-api-CfGQq1Tt.mjs → _slug-api-DlKpj91X.mjs} +3 -3
- package/dist/{_studio-B1waXMuR.mjs → _studio-DTi3EdXQ.mjs} +3 -3
- package/dist/{_templates-0Zr2z6yA.mjs → _templates-V4l5sZJy.mjs} +2 -2
- package/dist/{_typecheck-gate-BXvUNnfs.mjs → _typecheck-gate-sV4N5F93.mjs} +3 -3
- package/dist/{_ui-u7T4YooX.mjs → _ui-DCt4qZrk.mjs} +1 -1
- package/dist/{_utils-B8QmtFhK.mjs → _utils-Dhd058D9.mjs} +1 -1
- package/dist/{_vite-env-D9Qj6KPX.mjs → _vite-env-kejF0p2L.mjs} +1 -1
- package/dist/{_workflow-determinism-f9J-5Zlj.mjs → _workflow-determinism-C9l8uC2R.mjs} +1 -1
- package/dist/{build-BLlLOrng.mjs → build-CWyFGe-W.mjs} +11 -8
- package/dist/cli.d.ts +2 -1
- package/dist/cli.mjs +120 -46
- package/dist/{client-bundler-j5c46x0G.mjs → client-bundler-CT-_26c5.mjs} +4 -4
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-X_CRgCf6.mjs → delete-RqlQZ6Y_.mjs} +6 -6
- package/dist/{deploy-BzZ-r4iM.mjs → deploy-B7f3CK7v.mjs} +13 -13
- package/dist/{dev-BDykFvcc.mjs → dev-DamXieW_.mjs} +5 -5
- package/dist/{eval-0qEOdm0l.mjs → eval-h3DTVfRw.mjs} +27 -11
- package/dist/eval.d.ts +2 -0
- package/dist/{init-BtJI306C.mjs → init-D-vFrV8h.mjs} +7 -7
- package/dist/{login-DeUETobb.mjs → login-CDiemNmC.mjs} +5 -5
- package/dist/{logs-hWnJ66Bl.mjs → logs-DGwPNCzB.mjs} +4 -4
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/.env.example +17 -1
- package/dist/scaffold/package.json +6 -5
- package/dist/secret-V1w7y9-j.mjs +162 -0
- package/dist/secret.d.ts +41 -8
- package/dist/{studio-BxAS_FQQ.mjs → studio-BpMCfUNx.mjs} +9 -9
- package/dist/templates/briefing-desk/agent.test.ts +7 -3
- package/dist/templates/call-audit/agent.eval.test.ts +14 -11
- package/dist/templates/call-audit/agent.test.ts +34 -7
- package/dist/templates/call-audit/client.tsx +14 -17
- 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/embedded-assets/agent.test.ts +7 -1
- package/dist/templates/health-assistant/agent.eval.test.ts +38 -15
- package/dist/templates/health-assistant/agent.test.ts +8 -4
- package/dist/templates/link-digest/agent.eval.test.ts +24 -15
- package/dist/templates/link-digest/agent.test.ts +7 -1
- package/dist/templates/link-digest/client.tsx +17 -21
- 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/night-owl/agent.test.ts +6 -1
- package/dist/templates/personal-finance/agent.eval.test.ts +27 -17
- package/dist/templates/pipeline-simple/agent.test.ts +31 -14
- 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/podcast-digest/agent.test.ts +13 -3
- package/dist/templates/podcast-digest/client.tsx +12 -15
- package/dist/templates/recap-workflow/agent.eval.test.ts +49 -20
- package/dist/templates/recap-workflow/agent.test.ts +22 -9
- package/dist/templates/redline/agent.eval.test.ts +32 -24
- package/dist/templates/redline/agent.test.ts +7 -1
- package/dist/templates/redline/client.tsx +12 -15
- package/dist/templates/research-workflow/agent.eval.test.ts +32 -22
- package/dist/templates/research-workflow/agent.test.ts +7 -1
- package/dist/templates/retail/agent.eval.test.ts +18 -34
- package/dist/templates/retail/registry.test.ts +99 -58
- package/dist/templates/simple/agent.test.ts +39 -6
- package/dist/templates/spoken-summary/agent.eval.test.ts +25 -16
- package/dist/templates/spoken-summary/agent.test.ts +21 -7
- package/dist/templates/spoken-summary/client.tsx +14 -18
- package/dist/templates/support-line/agent.eval.test.ts +23 -26
- package/dist/templates/transcription-workflow/agent.test.ts +26 -8
- package/dist/templates/transcription-workflow/client.tsx +19 -26
- 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/dist/test-guenJUxi.mjs +255 -0
- package/dist/test.d.ts +97 -31
- package/dist/typecheck.mjs +2 -2
- package/dist/worker-bundler.mjs +3 -3
- package/dist/{workflow-BBu5oEDw.mjs → workflow-BZyKLkhV.mjs} +5 -5
- package/package.json +10 -10
- package/dist/bin.d.mts +0 -2
- package/dist/secret-B-3T5tvr.mjs +0 -61
- package/dist/test-CiLab-AA.mjs +0 -182
- package/dist/tsdown.config.d.ts +0 -2
|
@@ -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(
|
|
@@ -106,7 +106,13 @@ function stubWorkflows(runs: WorkflowRunSnapshot[] = []): WorkflowClient {
|
|
|
106
106
|
|
|
107
107
|
describe("the agent declares its workflow", () => {
|
|
108
108
|
test("under the name ctx.workflows.start resolves it by", () => {
|
|
109
|
-
|
|
109
|
+
// `toContain` rather than an exact key list: a second workflow is an
|
|
110
|
+
// invited edit and must not redden a test the author did not write. The
|
|
111
|
+
// NAME is still pinned, deliberately — this key is a STRING to everything
|
|
112
|
+
// outside this file (the REST route, `ctx.workflows.get`, a schedule), so
|
|
113
|
+
// renaming it is a runtime 404 rather than a compile error, and nothing
|
|
114
|
+
// else says so.
|
|
115
|
+
expect(Object.keys(agentDef.workflows ?? {})).toContain("research");
|
|
110
116
|
expect(agentDef.workflows?.research).toBe(research);
|
|
111
117
|
});
|
|
112
118
|
|
|
@@ -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.
|
|
@@ -1,21 +1,6 @@
|
|
|
1
1
|
import { isToolFailure, type ToolContext } from "@alexkroman1/aai";
|
|
2
2
|
import { createToolContext, deployedAgent } from "@alexkroman1/aai/testing";
|
|
3
3
|
import { describe, expect, test } from "vitest";
|
|
4
|
-
import { callFlow, retailSlot } from "./store.ts";
|
|
5
|
-
|
|
6
|
-
/** Tools that legitimately run before the caller is identified — the six
|
|
7
|
-
* declaring `when: BEFORE_TRANSFER`. Everything else must refuse. Listed here
|
|
8
|
-
* so ADDING an unauthenticated tool is a deliberate edit to this file, not a
|
|
9
|
-
* silent gap. */
|
|
10
|
-
const PUBLIC_TOOLS = new Set([
|
|
11
|
-
"find_user_id_by_email",
|
|
12
|
-
"find_user_id_by_name_zip",
|
|
13
|
-
"get_product_details",
|
|
14
|
-
"get_item_details",
|
|
15
|
-
"list_all_product_types",
|
|
16
|
-
"transfer_to_human_agents",
|
|
17
|
-
]);
|
|
18
|
-
|
|
19
4
|
/**
|
|
20
5
|
* The def a DEPLOYED agent runs, lowered BY HAND — the one place in the
|
|
21
6
|
* templates that still does.
|
|
@@ -27,6 +12,7 @@ const PUBLIC_TOOLS = new Set([
|
|
|
27
12
|
* whose runner is not vitest, and so cannot register the plugin.
|
|
28
13
|
*/
|
|
29
14
|
import authoredAgent from "./agent.ts";
|
|
15
|
+
import { callFlow, gateFor, retailSlot } from "./store.ts";
|
|
30
16
|
import systemPrompt from "./system-prompt.md?raw";
|
|
31
17
|
|
|
32
18
|
const retailAgent = deployedAgent(authoredAgent, {
|
|
@@ -36,6 +22,39 @@ const retailAgent = deployedAgent(authoredAgent, {
|
|
|
36
22
|
|
|
37
23
|
const registry = Object.entries(retailAgent.tools);
|
|
38
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Whether `name`'s gate admits the pre-identification state — i.e. whether it
|
|
27
|
+
* is one of the tools that legitimately runs before the caller is identified.
|
|
28
|
+
*
|
|
29
|
+
* ASKED OF THE REGISTRY, not read off a list here. This used to be a hardcoded
|
|
30
|
+
* six-name set, which meant a tool you add is classified by a file you did not
|
|
31
|
+
* write: declare `when: BEFORE_TRANSFER` on a new catalogue read and the
|
|
32
|
+
* sweeps below would have demanded it refuse. `retailTool` records every gate
|
|
33
|
+
* in `TOOL_GATES`, so the honest question is what the tool itself declared.
|
|
34
|
+
*
|
|
35
|
+
* `undefined` — a tool built with plain `tool()` rather than `retailTool` — is
|
|
36
|
+
* neither: it has no gate at all, and the sweeps skip it. That is the same
|
|
37
|
+
* answer `gateFor` gives the `tool.called` hook, and "a tool this template did
|
|
38
|
+
* not declare is ignored" below is the assertion for it.
|
|
39
|
+
*/
|
|
40
|
+
const isPublic = (name: string) => gateFor(name)?.includes("identifying") ?? false;
|
|
41
|
+
|
|
42
|
+
/** Registry entries that went through `retailTool`, so the gate has an opinion. */
|
|
43
|
+
const gatedTools = registry.filter(([name]) => gateFor(name) !== undefined);
|
|
44
|
+
|
|
45
|
+
/** The tools this template SHIPS that run before identification. A pin on the
|
|
46
|
+
* shipped set — one of these quietly losing `BEFORE_TRANSFER` is a policy
|
|
47
|
+
* break — and nothing else reads it, so adding a public tool of your own costs
|
|
48
|
+
* no edit here. */
|
|
49
|
+
const SHIPPED_PUBLIC_TOOLS = [
|
|
50
|
+
"find_user_id_by_email",
|
|
51
|
+
"find_user_id_by_name_zip",
|
|
52
|
+
"get_product_details",
|
|
53
|
+
"get_item_details",
|
|
54
|
+
"list_all_product_types",
|
|
55
|
+
"transfer_to_human_agents",
|
|
56
|
+
];
|
|
57
|
+
|
|
39
58
|
// `createToolContext()` rather than a cast: it carries a real slot store (the
|
|
40
59
|
// same storability check and freeze the deployed one applies), and each call is a
|
|
41
60
|
// distinct session, which is what these per-tool cases assume.
|
|
@@ -81,9 +100,19 @@ async function bodyReachableCtx(name: string): Promise<ToolContext> {
|
|
|
81
100
|
return ctx;
|
|
82
101
|
}
|
|
83
102
|
|
|
84
|
-
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
103
|
+
/**
|
|
104
|
+
* Minimal args satisfying each tool's schema. Deliberately plausible-shaped but
|
|
105
|
+
* wrong — these calls are expected to fail; what is asserted is that they still
|
|
106
|
+
* moved the UI.
|
|
107
|
+
*
|
|
108
|
+
* **It is also the list of tools this file sweeps, and the list of tools this
|
|
109
|
+
* template ships.** It used to be a second copy of a seventeen-name array
|
|
110
|
+
* asserted with `toEqual`, so adding one tool of your own failed both — the
|
|
111
|
+
* count and the coverage — before it had run once. One source now: a tool with
|
|
112
|
+
* an entry here is driven through every sweep below, and every name here must
|
|
113
|
+
* still be discovered from `tools/`. Add your tool with its arguments and it
|
|
114
|
+
* joins the sweeps; leave it out and it is simply not covered by them.
|
|
115
|
+
*/
|
|
87
116
|
const SAMPLE_ARGS: Record<string, Record<string, unknown>> = {
|
|
88
117
|
cancel_change: {},
|
|
89
118
|
cancel_pending_order: { order_id: "#W0000000", reason: "no longer needed" },
|
|
@@ -137,56 +166,56 @@ const SAMPLE_ARGS: Record<string, Record<string, unknown>> = {
|
|
|
137
166
|
transfer_to_human_agents: { summary: "test" },
|
|
138
167
|
};
|
|
139
168
|
|
|
169
|
+
/** The tools with sample args, i.e. the ones every sweep below can drive. */
|
|
170
|
+
const sweepable = registry.filter(([name]) => name in SAMPLE_ARGS);
|
|
171
|
+
|
|
140
172
|
describe("tool registry", () => {
|
|
141
|
-
test("
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
"find_user_id_by_email",
|
|
154
|
-
"find_user_id_by_name_zip",
|
|
155
|
-
"get_item_details",
|
|
156
|
-
"get_order_details",
|
|
157
|
-
"get_product_details",
|
|
158
|
-
"get_user_details",
|
|
159
|
-
"list_all_product_types",
|
|
160
|
-
"modify_pending_order_address",
|
|
161
|
-
"modify_pending_order_items",
|
|
162
|
-
"modify_pending_order_payment",
|
|
163
|
-
"modify_user_address",
|
|
164
|
-
"return_delivered_order_items",
|
|
165
|
-
"transfer_to_human_agents",
|
|
166
|
-
].sort(),
|
|
173
|
+
test("discovers every tool this template ships", () => {
|
|
174
|
+
// `arrayContaining` rather than an exact sorted list. Fifteen of these are
|
|
175
|
+
// tau2's retail tool set, which this template used to hold verbatim;
|
|
176
|
+
// `confirm_change` and `cancel_change` are the two it does not have, since
|
|
177
|
+
// tau2's tools apply on their first call and here nothing does (see
|
|
178
|
+
// `pending.ts`). So the list is not a fidelity claim and never was a
|
|
179
|
+
// COUNT — what it is worth asserting is that discovery still finds each
|
|
180
|
+
// one, because a `tools/` glob that resolves nothing looks exactly like a
|
|
181
|
+
// desk with no tools. Adding a tool of your own passes; losing one of these
|
|
182
|
+
// fails, naming it.
|
|
183
|
+
expect(registry.map(([name]) => name)).toEqual(
|
|
184
|
+
expect.arrayContaining(Object.keys(SAMPLE_ARGS)),
|
|
167
185
|
);
|
|
168
186
|
});
|
|
169
187
|
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
test("every
|
|
173
|
-
for (const
|
|
174
|
-
expect.soft(
|
|
188
|
+
// Asserts softly: a batch of renames should list every name that no longer
|
|
189
|
+
// resolves, not just the first.
|
|
190
|
+
test("every name in SAMPLE_ARGS resolves to a real tool", () => {
|
|
191
|
+
for (const name of Object.keys(SAMPLE_ARGS)) {
|
|
192
|
+
expect.soft(retailAgent.tools[name], `SAMPLE_ARGS["${name}"] names no tool`).toBeDefined();
|
|
175
193
|
}
|
|
176
194
|
});
|
|
177
195
|
|
|
178
196
|
test("every tool declares a description the model can act on", () => {
|
|
179
197
|
for (const [name, def] of registry) {
|
|
198
|
+
// Any tool, yours included — the model picks a tool by its description,
|
|
199
|
+
// so an empty one is invisible to it.
|
|
180
200
|
expect.soft(def.description, name).toBeTruthy();
|
|
181
|
-
|
|
201
|
+
}
|
|
202
|
+
for (const name of Object.keys(SAMPLE_ARGS)) {
|
|
203
|
+
// The shipped ones additionally carry enough of the policy to be chosen
|
|
204
|
+
// correctly, which for this desk means more than a label.
|
|
205
|
+
expect.soft(retailAgent.tools[name]?.description.length, name).toBeGreaterThan(40);
|
|
182
206
|
}
|
|
183
207
|
});
|
|
184
208
|
});
|
|
185
209
|
|
|
186
210
|
describe("the UI-update invariant", () => {
|
|
187
|
-
//
|
|
211
|
+
// Over `sweepable` rather than the whole registry: these two REACH a tool's
|
|
212
|
+
// body, so they need arguments its schema accepts, and calling a tool of
|
|
213
|
+
// yours with `{}` would report a crash inside it as a UI regression. Give it
|
|
214
|
+
// a `SAMPLE_ARGS` entry and it is swept like the rest.
|
|
215
|
+
//
|
|
216
|
+
// This is the pair that fails if a future tool is built with tool() instead of
|
|
188
217
|
// retailTool(): it would work, and the sidebar would sit still through it.
|
|
189
|
-
test.each(
|
|
218
|
+
test.each(sweepable)("%s increments callSeq and logs activity", async (name, def) => {
|
|
190
219
|
// In whichever state lets the body run, so the flow gate is not what these
|
|
191
220
|
// calls are testing: the point is that a tool which reaches its BODY moves
|
|
192
221
|
// the sidebar. A refused call never reaches one — the hook below is what
|
|
@@ -200,7 +229,7 @@ describe("the UI-update invariant", () => {
|
|
|
200
229
|
expect(state.activity.at(-1)?.summary).toBeTruthy();
|
|
201
230
|
});
|
|
202
231
|
|
|
203
|
-
test.each(
|
|
232
|
+
test.each(sweepable)("%s logs its own registry key as its name", async (name, def) => {
|
|
204
233
|
// Catches a copy-paste where the retailTool `name` and the registry key
|
|
205
234
|
// disagree — the activity feed would then attribute calls to the wrong tool.
|
|
206
235
|
const ctx = await bodyReachableCtx(name);
|
|
@@ -229,7 +258,7 @@ describe("the blocked-call hook", () => {
|
|
|
229
258
|
ctx,
|
|
230
259
|
);
|
|
231
260
|
|
|
232
|
-
test.each(
|
|
261
|
+
test.each(gatedTools.filter(([name]) => !isPublic(name)))(
|
|
233
262
|
"%s records a blocked line when the model tries it too early",
|
|
234
263
|
(name) => {
|
|
235
264
|
const ctx = makeCtx();
|
|
@@ -245,7 +274,7 @@ describe("the blocked-call hook", () => {
|
|
|
245
274
|
},
|
|
246
275
|
);
|
|
247
276
|
|
|
248
|
-
test.each(
|
|
277
|
+
test.each(gatedTools.filter(([name]) => isPublic(name)))(
|
|
249
278
|
"%s is left to the wrapper, because it is going to run",
|
|
250
279
|
(name) => {
|
|
251
280
|
const ctx = makeCtx();
|
|
@@ -280,7 +309,19 @@ describe("the blocked-call hook", () => {
|
|
|
280
309
|
});
|
|
281
310
|
|
|
282
311
|
describe("the authentication gate", () => {
|
|
283
|
-
test
|
|
312
|
+
test("the tools that run before identification are still the shipped six", () => {
|
|
313
|
+
// The one place `SHIPPED_PUBLIC_TOOLS` is read, and the only assertion in
|
|
314
|
+
// this file about WHICH tools are public. The sweeps below classify by
|
|
315
|
+
// `isPublic`, i.e. by what each tool declared, so a public tool of your own
|
|
316
|
+
// needs no edit here — but one of these six quietly losing
|
|
317
|
+
// `when: BEFORE_TRANSFER` would silently make the desk unreachable before a
|
|
318
|
+
// caller is identified, and nothing else would say so.
|
|
319
|
+
expect(registry.map(([name]) => name).filter(isPublic)).toEqual(
|
|
320
|
+
expect.arrayContaining(SHIPPED_PUBLIC_TOOLS),
|
|
321
|
+
);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
test.each(gatedTools.filter(([name]) => !isPublic(name)))(
|
|
284
325
|
"%s refuses before the caller is identified",
|
|
285
326
|
async (name, def) => {
|
|
286
327
|
const result = await def.execute(SAMPLE_ARGS[name] ?? {}, makeCtx());
|
|
@@ -297,7 +338,7 @@ describe("the authentication gate", () => {
|
|
|
297
338
|
},
|
|
298
339
|
);
|
|
299
340
|
|
|
300
|
-
test.each(
|
|
341
|
+
test.each(sweepable.filter(([name]) => isPublic(name)))(
|
|
301
342
|
"%s does not require authentication",
|
|
302
343
|
async (name, def) => {
|
|
303
344
|
const result = await def.execute(SAMPLE_ARGS[name] ?? {}, makeCtx());
|
|
@@ -331,7 +372,7 @@ describe("agent config", () => {
|
|
|
331
372
|
});
|
|
332
373
|
|
|
333
374
|
describe("the transfer is terminal", () => {
|
|
334
|
-
test.each(
|
|
375
|
+
test.each(gatedTools)("%s refuses once the call is with a human", async (name, def) => {
|
|
335
376
|
const ctx = servingCtx();
|
|
336
377
|
callFlow.send(ctx, { type: "TRANSFERRED" });
|
|
337
378
|
|
|
@@ -2,17 +2,50 @@ import { toAgentConfig } from "@alexkroman1/aai/manifest";
|
|
|
2
2
|
import { describe, expect, test } from "vitest";
|
|
3
3
|
import agentDef from "./agent.ts";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* What a starter's spec may assert.
|
|
7
|
+
*
|
|
8
|
+
* Renaming the agent, giving it a voice, swapping a stage or switching the
|
|
9
|
+
* whole thing to speech-to-speech are the first edits this template invites —
|
|
10
|
+
* and `aai build` runs these tests before it bundles, so an assertion that
|
|
11
|
+
* pins the template's own identity turns the first customization into a build
|
|
12
|
+
* failure in a file the author never wrote. Every test here therefore asserts
|
|
13
|
+
* a property that survives those edits, on the RESOLVED config rather than on
|
|
14
|
+
* the def's empty fields.
|
|
15
|
+
*/
|
|
5
16
|
describe("simple template", () => {
|
|
6
17
|
test("config passes manifest validation", () => {
|
|
7
18
|
// Same conversion `aai build`/`aai deploy` run.
|
|
8
19
|
expect(() => toAgentConfig(agentDef)).not.toThrow();
|
|
9
20
|
});
|
|
10
21
|
|
|
11
|
-
test("exports an agent
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
expect(agentDef.
|
|
16
|
-
expect(agentDef.
|
|
22
|
+
test("exports an agent the platform can name", () => {
|
|
23
|
+
// Not the literal: what has to hold is that there IS a name, and that the
|
|
24
|
+
// conversion carries it through — `AgentName` refuses a blank one, and the
|
|
25
|
+
// studio lists a deployed agent by exactly this string.
|
|
26
|
+
expect(agentDef.name).toBeTruthy();
|
|
27
|
+
expect(toAgentConfig(agentDef).name).toBe(agentDef.name);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("every stage its mode needs is filled, declared or defaulted", () => {
|
|
31
|
+
// The template's point: with no provider fields declared, the default
|
|
32
|
+
// all-AssemblyAI cascaded pipeline is injected at parse time (see
|
|
33
|
+
// `defaultProviders`) — so an agent that declares nothing still runs.
|
|
34
|
+
// Asserted per MODE so it stays true after a swap: declare `stt`/`llm`/`tts`
|
|
35
|
+
// and the rest still default; declare `s2s` and there is no cascade to fill,
|
|
36
|
+
// which is the one thing that must never happen by fallthrough.
|
|
37
|
+
const config = toAgentConfig(agentDef);
|
|
38
|
+
if (config.mode === "s2s") {
|
|
39
|
+
expect(config.s2s?.kind).toBeTruthy();
|
|
40
|
+
expect(config.stt).toBeUndefined();
|
|
41
|
+
expect(config.tts).toBeUndefined();
|
|
42
|
+
} else if (config.mode === "text") {
|
|
43
|
+
expect(config.llm?.kind).toBeTruthy();
|
|
44
|
+
} else {
|
|
45
|
+
expect(config.mode).toBe("pipeline");
|
|
46
|
+
expect(config.stt?.kind).toBeTruthy();
|
|
47
|
+
expect(config.llm?.kind).toBeTruthy();
|
|
48
|
+
expect(config.tts?.kind).toBeTruthy();
|
|
49
|
+
}
|
|
17
50
|
});
|
|
18
51
|
});
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
// exercised. `run.slept` below is the other half of that admission written as
|
|
49
49
|
// an assertion. `aai-cli`'s `dev-workflow.scenario.test.ts` is the tier that
|
|
50
50
|
// really suspends and resumes a run.
|
|
51
|
+
import { stubGatewayRoute } from "@alexkroman1/aai/testing";
|
|
51
52
|
import {
|
|
52
53
|
installStubSpeech,
|
|
53
54
|
installStubTranscribe,
|
|
@@ -115,23 +116,30 @@ function publish(bytes: Uint8Array, name: string, type: string) {
|
|
|
115
116
|
*
|
|
116
117
|
* ONE fake, because publishing a `stepFetch` REPLACES — a flow that transcribes
|
|
117
118
|
* AND calls a model cannot install two, which is exactly what `otherwise` is
|
|
118
|
-
* for.
|
|
119
|
-
*
|
|
120
|
-
* pass because the fake and the step agree
|
|
119
|
+
* for. BOTH halves are the SDK's own fakes rather than this file's hand-typed
|
|
120
|
+
* wire, and it is the same argument twice: each routes off the SDK's own
|
|
121
|
+
* endpoint constant, so a case cannot pass because the fake and the step agree
|
|
122
|
+
* on a typo. The predicate here used to be `url.includes("llm-gateway")` — a
|
|
123
|
+
* HOST, which the default gateway happens to carry and a `gatewayUrl` pointed
|
|
124
|
+
* anywhere else does not, so the fake would have gone on answering the
|
|
125
|
+
* transcription 404 to a model call it no longer recognised.
|
|
126
|
+
*
|
|
127
|
+
* The reader also hands back DECODED calls, which is why the case below asks
|
|
128
|
+
* `model.calls[0].prompt` what the model was SHOWN: off a raw request body that
|
|
129
|
+
* is the whole serialized request, `model` and `reasoning_effort` included.
|
|
121
130
|
*/
|
|
122
131
|
function scriptProvider(options: { text?: string; pendingPolls?: number } = {}) {
|
|
123
|
-
|
|
132
|
+
const model = stubGatewayRoute(JSON.stringify(REPLY));
|
|
133
|
+
const provider = installStubTranscribe({
|
|
124
134
|
text: options.text ?? TRANSCRIPT,
|
|
125
135
|
durationSec: 42,
|
|
126
136
|
// Passed straight through rather than conditionally spread: the option
|
|
127
137
|
// already admits `undefined`, and `guard-invariants` rule 2 counts the
|
|
128
138
|
// spread.
|
|
129
139
|
pendingPolls: options.pendingPolls,
|
|
130
|
-
otherwise: (request) =>
|
|
131
|
-
request.url.includes("llm-gateway")
|
|
132
|
-
? { body: { choices: [{ message: { content: JSON.stringify(REPLY) } }] } }
|
|
133
|
-
: undefined,
|
|
140
|
+
otherwise: (request) => model.route(request),
|
|
134
141
|
});
|
|
142
|
+
return { provider, model };
|
|
135
143
|
}
|
|
136
144
|
|
|
137
145
|
describeWorkflowEval(
|
|
@@ -144,7 +152,7 @@ describeWorkflowEval(
|
|
|
144
152
|
// returned bytes, or an id nothing wrote, or two ids because the synthesis
|
|
145
153
|
// and the store became two steps.
|
|
146
154
|
const uploads = publish(new Uint8Array(64), "standup.wav", "audio/wav");
|
|
147
|
-
const provider = scriptProvider();
|
|
155
|
+
const { provider } = scriptProvider();
|
|
148
156
|
const speech = installStubSpeech({ pcmBytes: 96_000 });
|
|
149
157
|
|
|
150
158
|
const run = await app.run(spokenSummary, { recording: UPLOAD_ID });
|
|
@@ -207,7 +215,7 @@ describeWorkflowEval(
|
|
|
207
215
|
// central prompt decision regressing — synthesize the bullet list and you
|
|
208
216
|
// get a voice reading "one. two. three." with no connective tissue.
|
|
209
217
|
publish(new Uint8Array(64), "standup.wav", "audio/wav");
|
|
210
|
-
const
|
|
218
|
+
const { model } = scriptProvider();
|
|
211
219
|
const speech = installStubSpeech();
|
|
212
220
|
|
|
213
221
|
const run = await app.run(spokenSummary, { recording: UPLOAD_ID, voice: "michael" });
|
|
@@ -226,9 +234,10 @@ describeWorkflowEval(
|
|
|
226
234
|
|
|
227
235
|
// And the model was ASKED for both, over the transcript it was given. A
|
|
228
236
|
// prompt that stopped asking for a script is how the field goes missing.
|
|
229
|
-
const
|
|
230
|
-
|
|
231
|
-
expect(prompt).toContain("
|
|
237
|
+
const asked = model.calls[0];
|
|
238
|
+
if (asked === undefined) expect.fail("the run must have asked the model for a summary");
|
|
239
|
+
expect(asked.prompt).toContain("READ ALOUD");
|
|
240
|
+
expect(asked.prompt).toContain("The launch is on for Tuesday the fourth");
|
|
232
241
|
});
|
|
233
242
|
|
|
234
243
|
test("a recording with no speech stops before the model and the voice", async ({ app }) => {
|
|
@@ -237,7 +246,7 @@ describeWorkflowEval(
|
|
|
237
246
|
// the run would go on to summarize no words and store half a second of
|
|
238
247
|
// audio — a green run with an empty product.
|
|
239
248
|
const uploads = publish(new Uint8Array(64), "silence.wav", "audio/wav");
|
|
240
|
-
const
|
|
249
|
+
const { model } = scriptProvider({ text: " " });
|
|
241
250
|
const speech = installStubSpeech();
|
|
242
251
|
|
|
243
252
|
const run = await app.run(spokenSummary, { recording: UPLOAD_ID });
|
|
@@ -247,7 +256,7 @@ describeWorkflowEval(
|
|
|
247
256
|
expect(run.output).toBeUndefined();
|
|
248
257
|
// Nothing was summarized and nothing was spoken, which is the half that
|
|
249
258
|
// makes this more than an error-message assertion.
|
|
250
|
-
expect(
|
|
259
|
+
expect(model.calls).toEqual([]);
|
|
251
260
|
expect(speech.calls).toEqual([]);
|
|
252
261
|
expect(uploads.writes).toEqual([]);
|
|
253
262
|
expect(run.reported).not.toContain("Summarizing the transcript.");
|
|
@@ -262,7 +271,7 @@ describeWorkflowEval(
|
|
|
262
271
|
// them. A loop that re-submitted, or one that spun with no wait, both
|
|
263
272
|
// produce a correct transcript and a wrong bill.
|
|
264
273
|
publish(new Uint8Array(64), "standup.wav", "audio/wav");
|
|
265
|
-
const provider = scriptProvider({ pendingPolls: 2 });
|
|
274
|
+
const { provider } = scriptProvider({ pendingPolls: 2 });
|
|
266
275
|
installStubSpeech();
|
|
267
276
|
|
|
268
277
|
const run = await app.run(spokenSummary, { recording: UPLOAD_ID });
|