@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
|
@@ -34,7 +34,12 @@ import type { SessionEvent } from "@alexkroman1/aai/protocol";
|
|
|
34
34
|
// What no eval here can see: anything below the audio boundary. Whether a
|
|
35
35
|
// dispatcher reading a callsign in bursts lands as one turn is a property of
|
|
36
36
|
// endpointing, and these fake speech stages remove it.
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
type EvalSession,
|
|
39
|
+
lastStateIn,
|
|
40
|
+
toolNames,
|
|
41
|
+
turnCalling,
|
|
42
|
+
} from "@alexkroman1/aai-runtime/eval";
|
|
38
43
|
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
39
44
|
import { expect } from "vitest";
|
|
40
45
|
import { z } from "zod";
|
|
@@ -100,20 +105,6 @@ const boardEntry = (events: readonly SessionEvent[], id: string) =>
|
|
|
100
105
|
const refusalAt = (state: string) =>
|
|
101
106
|
new RegExp(`Not available yet: this conversation is at [\\\\"]*${state}`);
|
|
102
107
|
|
|
103
|
-
/**
|
|
104
|
-
* Drive a whole shift, one dispatcher line at a time, and hand back every turn.
|
|
105
|
-
*
|
|
106
|
-
* The cases below assert about the turn a MECHANISM fired in rather than about
|
|
107
|
-
* turn number two: how many turns a desk takes to get there is the model's
|
|
108
|
-
* business and it varies, and a case pinned to a turn index is a flake with a
|
|
109
|
-
* misleading name.
|
|
110
|
-
*/
|
|
111
|
-
async function sayAll(session: EvalSession, lines: readonly string[]): Promise<EvalTurn[]> {
|
|
112
|
-
const turns: EvalTurn[] = [];
|
|
113
|
-
for (const line of lines) turns.push(await session.say(line));
|
|
114
|
-
return turns;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
108
|
/** Every call to `tool` across the whole shift. */
|
|
118
109
|
const callsTo = (session: EvalSession, tool: string) =>
|
|
119
110
|
session.toolCalls().filter((c) => c.name === tool);
|
|
@@ -200,12 +191,15 @@ describeEval(dispatchAgent, (test) => {
|
|
|
200
191
|
test(
|
|
201
192
|
"rolls units on a logged incident and follows them to monitoring",
|
|
202
193
|
async ({ session }) => {
|
|
203
|
-
const turns = await sayAll(
|
|
194
|
+
const turns = await session.sayAll([THE_CALL, "Dispatch the recommended units now."]);
|
|
204
195
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
196
|
+
// The turn the dispatch fired in, whichever one that turned out to be:
|
|
197
|
+
// how many turns a desk spends getting there is the model's business, so
|
|
198
|
+
// a case pinned to turn two is a flake with a misleading name. A shift
|
|
199
|
+
// that never dispatched at all fails HERE, with every turn's tool list in
|
|
200
|
+
// the message, rather than as an `undefined` three assertions later.
|
|
201
|
+
const dispatching = turnCalling(turns, "resources_dispatch");
|
|
202
|
+
const rolled = dispatching.toolCalls.find((c) => c.name === "resources_dispatch");
|
|
209
203
|
// Units really assigned — `dispatched` is empty when every requested
|
|
210
204
|
// callsign was busy, which is the case the fourth test owns.
|
|
211
205
|
expect(rolled?.result).toMatch(/"dispatched":\[\{/);
|
|
@@ -215,7 +209,7 @@ describeEval(dispatchAgent, (test) => {
|
|
|
215
209
|
// The board agrees: the incident is dispatched, not merely triaged.
|
|
216
210
|
expect(boardEntry(session.events(), FIRST_INCIDENT)?.status).toBe("dispatched");
|
|
217
211
|
// And the order is the one the desk's flow requires: log, then dispatch.
|
|
218
|
-
const names = session.toolCalls()
|
|
212
|
+
const names = toolNames(session.toolCalls());
|
|
219
213
|
expect(names.indexOf("resources_dispatch")).toBeGreaterThan(names.indexOf("incident_create"));
|
|
220
214
|
},
|
|
221
215
|
{
|
|
@@ -230,7 +224,7 @@ describeEval(dispatchAgent, (test) => {
|
|
|
230
224
|
test(
|
|
231
225
|
"closing an incident releases the units that were on it",
|
|
232
226
|
async ({ session }) => {
|
|
233
|
-
await sayAll(
|
|
227
|
+
await session.sayAll([
|
|
234
228
|
THE_CALL,
|
|
235
229
|
"Dispatch the recommended units, emergency priority.",
|
|
236
230
|
"Units report the patient is transported and they're clear. Close it out.",
|
|
@@ -252,7 +246,7 @@ describeEval(dispatchAgent, (test) => {
|
|
|
252
246
|
// The board agrees, which is the half a browser would show.
|
|
253
247
|
expect(boardEntry(session.events(), FIRST_INCIDENT)?.status).toBe("resolved");
|
|
254
248
|
// And the shift ran in the order the flow requires.
|
|
255
|
-
const names = session.toolCalls()
|
|
249
|
+
const names = toolNames(session.toolCalls());
|
|
256
250
|
expect(names.indexOf("incident_create")).toBeGreaterThanOrEqual(0);
|
|
257
251
|
expect(names.indexOf("resources_dispatch")).toBeGreaterThan(names.indexOf("incident_create"));
|
|
258
252
|
expect(names.lastIndexOf("incident_update_status")).toBeGreaterThan(
|
|
@@ -279,7 +273,7 @@ describeEval(dispatchAgent, (test) => {
|
|
|
279
273
|
test(
|
|
280
274
|
"a unit already rolling is not sent to a second call",
|
|
281
275
|
async ({ session }) => {
|
|
282
|
-
await sayAll(
|
|
276
|
+
await session.sayAll([
|
|
283
277
|
THE_CALL,
|
|
284
278
|
"Send Medic-1 to Oak Street.",
|
|
285
279
|
"New call: 12 Pine Lane, chest pains. Log it.",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* tool-choice claim below then passes or fails for the wrong reason.
|
|
26
26
|
*/
|
|
27
27
|
import agentDef from "virtual:aai/agent";
|
|
28
|
-
import { toolResultIn } from "@alexkroman1/aai-runtime/eval";
|
|
28
|
+
import { toolNames, toolResultIn } from "@alexkroman1/aai-runtime/eval";
|
|
29
29
|
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
30
30
|
import { expect } from "vitest";
|
|
31
31
|
import { z } from "zod";
|
|
@@ -57,7 +57,7 @@ describeEval(agentDef, (test) => {
|
|
|
57
57
|
// about voice frameworks in general.
|
|
58
58
|
const turn = await session.say("Can your agents make HTTP requests?");
|
|
59
59
|
|
|
60
|
-
expect(turn.toolCalls
|
|
60
|
+
expect(toolNames(turn.toolCalls)).toEqual(["search_knowledge"]);
|
|
61
61
|
const call = turn.toolCalls[0]!;
|
|
62
62
|
expect(typeof (call.args as { query?: unknown }).query).toBe("string");
|
|
63
63
|
// The right entry, out of four: the one this question is really about.
|
|
@@ -81,7 +81,7 @@ describeEval(agentDef, (test) => {
|
|
|
81
81
|
async ({ session }) => {
|
|
82
82
|
const turn = await session.say("What topics can you help me with?");
|
|
83
83
|
|
|
84
|
-
expect(turn.toolCalls
|
|
84
|
+
expect(toolNames(turn.toolCalls)).toEqual(["list_topics"]);
|
|
85
85
|
// Every question in `knowledge.json` and nothing else — the check that
|
|
86
86
|
// catches an index built from a stale copy of the asset.
|
|
87
87
|
expect(toolResultIn(turn.toolCalls, "list_topics", z.array(z.string()))).toEqual(
|
|
@@ -30,7 +30,13 @@ describe("embedded-assets template", () => {
|
|
|
30
30
|
// `agent()` takes no `tools` field: a file in `tools/` IS the tool, and
|
|
31
31
|
// nothing imports it. Discovery is what puts it in front of the model, so a
|
|
32
32
|
// template whose tools are never resolved ships a model with no tools.
|
|
33
|
-
|
|
33
|
+
// `arrayContaining` rather than an exact list: adding a file to `tools/` is
|
|
34
|
+
// the edit this template most invites, and an exact list would redden on
|
|
35
|
+
// it. Losing one of these two is still a failure — that is the half worth
|
|
36
|
+
// asserting.
|
|
37
|
+
expect(Object.keys(agentDef.tools ?? {})).toEqual(
|
|
38
|
+
expect.arrayContaining(["list_topics", "search_knowledge"]),
|
|
39
|
+
);
|
|
34
40
|
});
|
|
35
41
|
});
|
|
36
42
|
|
|
@@ -34,14 +34,36 @@
|
|
|
34
34
|
* tool-choice claim below then passes or fails for the wrong reason.
|
|
35
35
|
*/
|
|
36
36
|
import agentDef from "virtual:aai/agent";
|
|
37
|
-
import {
|
|
37
|
+
import {
|
|
38
|
+
describeTurn,
|
|
39
|
+
type EvalToolCall,
|
|
40
|
+
toolArgsIn,
|
|
41
|
+
toolResultIn,
|
|
42
|
+
} from "@alexkroman1/aai-runtime/eval";
|
|
38
43
|
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
39
44
|
import { expect } from "vitest";
|
|
40
45
|
import { z } from "zod";
|
|
41
46
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Every drug this scope's interaction checks were actually asked about,
|
|
49
|
+
* lowercased.
|
|
50
|
+
*
|
|
51
|
+
* Read through `toolArgsIn` WITH a schema, which is what that reader takes one
|
|
52
|
+
* for: `args` is `Record<string, unknown>` on the wire — the model wrote it and
|
|
53
|
+
* nothing validated it — so the `args.drugs as string[] ?? []` this replaced
|
|
54
|
+
* turned a `drugs` the desk renamed, or never sent, into an empty list, and the
|
|
55
|
+
* two claims below would have been claims about nothing. A `drugs` that stops
|
|
56
|
+
* arriving FAILS here, naming the field.
|
|
57
|
+
*
|
|
58
|
+
* ZERO checks answers `[]` rather than throwing, which is what keeps the
|
|
59
|
+
* dangerous case assertable: a desk that answered an interaction question
|
|
60
|
+
* without asking a single label reaches the assertions with nothing in hand.
|
|
61
|
+
*/
|
|
62
|
+
const InteractionArgs = z.object({ drugs: z.array(z.string()) });
|
|
63
|
+
const drugsAsked = (calls: readonly EvalToolCall[]): string[] =>
|
|
64
|
+
toolArgsIn(calls, "check_drug_interaction", InteractionArgs).flatMap((args) =>
|
|
65
|
+
args.drugs.map((drug) => drug.trim().toLowerCase()),
|
|
66
|
+
);
|
|
45
67
|
|
|
46
68
|
/**
|
|
47
69
|
* A refusal from `check_drug_interaction`, as the model saw it.
|
|
@@ -60,12 +82,15 @@ describeEval(agentDef, (test) => {
|
|
|
60
82
|
const turn = await session.say("Can I take ibuprofen and warfarin together?");
|
|
61
83
|
|
|
62
84
|
// It may look each drug up as well — that is fine and often useful. What
|
|
63
|
-
// it may not do is answer this question without asking the labels
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
85
|
+
// it may not do is answer this question without asking the labels — and a
|
|
86
|
+
// desk that asked nothing arrives here with an empty list, so these two
|
|
87
|
+
// lines carry the never-checked finding as well as the wrong-drugs one.
|
|
88
|
+
// `describeTurn` is what tells them apart in the failure: "expected [] to
|
|
89
|
+
// contain 'ibuprofen'" does not say whether the desk called something
|
|
90
|
+
// else, called nothing, or was cut off mid-reply.
|
|
91
|
+
const asked = drugsAsked(turn.toolCalls);
|
|
92
|
+
expect(asked, describeTurn(turn)).toContain("ibuprofen");
|
|
93
|
+
expect(asked, describeTurn(turn)).toContain("warfarin");
|
|
69
94
|
|
|
70
95
|
// The rule at the top of its prompt: it is not a doctor. An interaction
|
|
71
96
|
// answer that does not end at a professional is the failure that makes
|
|
@@ -90,11 +115,9 @@ describeEval(agentDef, (test) => {
|
|
|
90
115
|
// drug alone and report nothing.
|
|
91
116
|
const turn = await session.say("Is it okay if I add ibuprofen for a headache?");
|
|
92
117
|
|
|
93
|
-
const
|
|
94
|
-
expect(
|
|
95
|
-
|
|
96
|
-
expect(asked).toContain("warfarin");
|
|
97
|
-
expect(asked).toContain("ibuprofen");
|
|
118
|
+
const asked = drugsAsked(turn.toolCalls);
|
|
119
|
+
expect(asked, describeTurn(turn)).toContain("warfarin");
|
|
120
|
+
expect(asked, describeTurn(turn)).toContain("ibuprofen");
|
|
98
121
|
},
|
|
99
122
|
{
|
|
100
123
|
stubReply: [
|
|
@@ -57,10 +57,14 @@ describe("health-assistant template", () => {
|
|
|
57
57
|
test("both tools are discovered from tools/", () => {
|
|
58
58
|
// `agent()` takes no `tools` field: a file in `tools/` IS the tool. A
|
|
59
59
|
// template whose tools are never resolved ships a model with none.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
60
|
+
// `arrayContaining` rather than an exact list: a tool you add is the edit
|
|
61
|
+
// this template invites, and it must not redden a test you did not write.
|
|
62
|
+
// Losing one of these two still fails, which is the regression worth
|
|
63
|
+
// catching — discovery silently finding nothing looks exactly like a
|
|
64
|
+
// template with no tools.
|
|
65
|
+
expect(Object.keys(agentDef.tools ?? {})).toEqual(
|
|
66
|
+
expect.arrayContaining(["check_drug_interaction", "medication_lookup"]),
|
|
67
|
+
);
|
|
64
68
|
});
|
|
65
69
|
});
|
|
66
70
|
|
|
@@ -26,15 +26,13 @@
|
|
|
26
26
|
|
|
27
27
|
import { createServer, type Server } from "node:http";
|
|
28
28
|
import type { AddressInfo } from "node:net";
|
|
29
|
+
import { stubGatewayRoute } from "@alexkroman1/aai/testing";
|
|
29
30
|
import { installStubStepFetch } from "@alexkroman1/aai/testing/vitest";
|
|
30
31
|
import { describeWorkflowEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
31
32
|
import { expect, onTestFinished } from "vitest";
|
|
32
33
|
import agentDef, { digest } from "./agent.ts";
|
|
33
34
|
import { SETTLE_MS } from "./workflows/digest.ts";
|
|
34
35
|
|
|
35
|
-
/** The gateway leg, so one handler can route the page and the model apart. */
|
|
36
|
-
const isModelCall = (url: string): boolean => url.includes("/chat/completions");
|
|
37
|
-
|
|
38
36
|
/**
|
|
39
37
|
* A page with an ANSWER in it, so "did it summarize what it fetched" is a
|
|
40
38
|
* question with a right answer rather than a vibe.
|
|
@@ -107,15 +105,25 @@ async function servePage(html: string): Promise<string> {
|
|
|
107
105
|
*
|
|
108
106
|
* ONE handler, because publishing a `stepFetch` REPLACES — a flow that fetches a
|
|
109
107
|
* page and calls a model cannot install two fakes, so it routes by URL. The
|
|
110
|
-
*
|
|
111
|
-
*
|
|
108
|
+
* routing is `stubGatewayRoute`'s rather than this file's: it answers the
|
|
109
|
+
* completion request and `undefined` for everything else, which is what makes
|
|
110
|
+
* the page the `??` arm — and it routes off the SDK's own completions PATH, so a
|
|
111
|
+
* case cannot pass because the fake and the step agree on a typo. That matters
|
|
112
|
+
* more here than it looks: the envelope is a WIRE shape, so a field typed one
|
|
113
|
+
* off does not fail — `stepGenerate` reads no content and reports an empty
|
|
114
|
+
* completion, and the case blames the digest.
|
|
115
|
+
*
|
|
116
|
+
* The recorded calls are what makes the prompt assertable, which is the only way
|
|
117
|
+
* to check what the model was SHOWN rather than what it said — and they come
|
|
118
|
+
* back DECODED, so the last case reads `prompt` rather than the raw request
|
|
119
|
+
* body, which is the whole serialized request.
|
|
112
120
|
*/
|
|
113
121
|
function scriptBothLegs(html: string, reply = SCRIPTED_DIGEST) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
: { body: html, headers: { "Content-Type": "text/html" } },
|
|
122
|
+
const model = stubGatewayRoute(reply);
|
|
123
|
+
installStubStepFetch(
|
|
124
|
+
(request) => model.route(request) ?? { body: html, headers: { "Content-Type": "text/html" } },
|
|
118
125
|
);
|
|
126
|
+
return model;
|
|
119
127
|
}
|
|
120
128
|
|
|
121
129
|
describeWorkflowEval(agentDef, (test) => {
|
|
@@ -180,18 +188,19 @@ describeWorkflowEval(agentDef, (test) => {
|
|
|
180
188
|
"</body>",
|
|
181
189
|
`<p>${"padding sentence about otters. ".repeat(2000)}</p></body>`,
|
|
182
190
|
);
|
|
183
|
-
const
|
|
191
|
+
const model = scriptBothLegs(oversized);
|
|
184
192
|
|
|
185
193
|
const run = await app.run(digest, { url: "https://example.test/otters" });
|
|
186
194
|
expect(run.status).toBe("completed");
|
|
187
195
|
|
|
188
|
-
const
|
|
189
|
-
|
|
196
|
+
const asked = model.calls[0];
|
|
197
|
+
if (asked === undefined) expect.fail("the run must have shown the article to the model");
|
|
198
|
+
expect(asked.prompt).toContain("Sea otters are one of the few mammals that use tools");
|
|
190
199
|
// Neither the script's instruction nor the stylesheet reached the prompt.
|
|
191
|
-
expect(prompt).not.toContain(SMUGGLED);
|
|
192
|
-
expect(prompt).not.toContain("rebeccapurple");
|
|
200
|
+
expect(asked.prompt).not.toContain(SMUGGLED);
|
|
201
|
+
expect(asked.prompt).not.toContain("rebeccapurple");
|
|
193
202
|
// And the text was CAPPED on the way across the queue. 24k characters plus
|
|
194
203
|
// the prompt's own framing, well under the ~60k this page would otherwise be.
|
|
195
|
-
expect(prompt.length).toBeLessThan(30_000);
|
|
204
|
+
expect(asked.prompt.length).toBeLessThan(30_000);
|
|
196
205
|
});
|
|
197
206
|
});
|
|
@@ -51,7 +51,13 @@ describe("the agent declares itself a workflow app", () => {
|
|
|
51
51
|
test("under the name the page starts a run by", () => {
|
|
52
52
|
// `api.start("digest", …)` in client.tsx names this key. Nothing else
|
|
53
53
|
// records it, so a rename here is a 400 there rather than a compile error.
|
|
54
|
-
|
|
54
|
+
// `toContain` rather than an exact key list: adding a second workflow is an
|
|
55
|
+
// invited edit and must not redden a test the author did not write. The
|
|
56
|
+
// NAME is still pinned, deliberately — the page starts a run by this
|
|
57
|
+
// string, so renaming the key is a runtime 400 rather than a compile
|
|
58
|
+
// error, and this pin is the only thing that says so. Rename it here and
|
|
59
|
+
// in `client.tsx` together.
|
|
60
|
+
expect(Object.keys(agentDef.workflows ?? {})).toContain("digest");
|
|
55
61
|
expect(agentDef.workflows?.digest).toBe(digest);
|
|
56
62
|
});
|
|
57
63
|
});
|
|
@@ -19,16 +19,18 @@
|
|
|
19
19
|
* page used to promise "the run continues without it" and then had no way back
|
|
20
20
|
* to the run it was promising about.
|
|
21
21
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* A correlation KEY is the handle that survives, and this page writes none of
|
|
23
|
+
* it: `useWorkflowSubmit` mints an opaque per-page key into `sessionStorage`,
|
|
24
|
+
* records every run under it, and asks `find("digest", key)` as it mounts —
|
|
25
|
+
* so a reload lands back on the same headline, the same progress log and the
|
|
26
|
+
* same buttons. Six templates used to write those two options each, which is
|
|
27
|
+
* what made it the default.
|
|
24
28
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* and follow whatever run comes back, so a reload lands back on the same
|
|
31
|
-
* headline, the same progress log and the same buttons.
|
|
29
|
+
* What the page can still say is which key: `useRunKey({ storage: "local" })`
|
|
30
|
+
* for a run meant to outlive the tab (`podcast-digest`), an ACCOUNT's own id
|
|
31
|
+
* for an app with logins, `recover: false` for a form that must always open
|
|
32
|
+
* empty. `use-run-key.ts` argues what a key may not be — derived from the URL
|
|
33
|
+
* being digested, or carried in a `?key=` parameter.
|
|
32
34
|
*
|
|
33
35
|
* Deployed, this needs the correlation-key index, which is a `DATABASE_URL`
|
|
34
36
|
* away — `agent.ts` says what happens without one (the runs are still durable;
|
|
@@ -55,14 +57,14 @@
|
|
|
55
57
|
* Progress also REPLAYS — chunks are retained with the run — so a reload mid-run
|
|
56
58
|
* catches up rather than starting from whatever arrives next. That only pays off
|
|
57
59
|
* because the reload can name its run again: `<WorkflowProgress runId>` is handed
|
|
58
|
-
* `run?.runId`, so before
|
|
60
|
+
* `run?.runId`, so before the recovery a refresh replayed a log for nobody.
|
|
59
61
|
* `lines={1}` is
|
|
60
62
|
* what narrows it to the newest line, because on a page this small that is the
|
|
61
63
|
* whole of what a status wants; `transcription-workflow` renders the full log,
|
|
62
64
|
* where a fan-out makes the history worth seeing.
|
|
63
65
|
*/
|
|
64
66
|
|
|
65
|
-
import { page,
|
|
67
|
+
import { page, useWorkflowSubmit, WorkflowProgress } from "@alexkroman1/aai-ui";
|
|
66
68
|
import "@alexkroman1/aai-ui/styles.css";
|
|
67
69
|
// ERASED at build time, so naming the agent's own type costs the browser bundle
|
|
68
70
|
// nothing — and it is what stops this file restating a shape `workflows/
|
|
@@ -90,9 +92,6 @@ function pendingNote(startedHere: boolean, found: boolean): string {
|
|
|
90
92
|
|
|
91
93
|
export function App() {
|
|
92
94
|
const [url, setUrl] = useState("");
|
|
93
|
-
// This tab's handle on its own runs — minted once and remembered, which is
|
|
94
|
-
// what a later load produces to find the run again.
|
|
95
|
-
const key = useRunKey();
|
|
96
95
|
// Did THIS load start the run? A reload cannot have, and that is the only way
|
|
97
96
|
// the page can tell "working on what you just submitted" from "picking up
|
|
98
97
|
// where you left off" — the hook reports the run, not who asked for it.
|
|
@@ -102,13 +101,10 @@ export function App() {
|
|
|
102
101
|
// rejected input, which is better copy than anything this page could write, and
|
|
103
102
|
// `wake` is bound to whatever run the hook is following — the whole reason this
|
|
104
103
|
// page no longer holds a `createWorkflowApi()` of its own.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
key,
|
|
110
|
-
recover: true,
|
|
111
|
-
});
|
|
104
|
+
// No `key` and no `recover`: this tab's handle on its own runs is minted and
|
|
105
|
+
// remembered by the hook, and read back as it mounts. See the module doc for
|
|
106
|
+
// what a page says when it wants a different one.
|
|
107
|
+
const { submit, run, pending, error, wake } = useWorkflowSubmit<typeof digest>("digest");
|
|
112
108
|
|
|
113
109
|
// `submit()` resolves as soon as the run exists — deliberately not when it
|
|
114
110
|
// finishes. That is the whole mechanism: the digest sleeps for a while, and
|
|
@@ -14,27 +14,38 @@
|
|
|
14
14
|
// back with as well as the code the tutor wrote.
|
|
15
15
|
|
|
16
16
|
import agentDef from "virtual:aai/agent";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
createVmRunCode,
|
|
19
|
+
type EvalTurn,
|
|
20
|
+
toolArgsIn,
|
|
21
|
+
toolNames,
|
|
22
|
+
toolResultsIn,
|
|
23
|
+
} from "@alexkroman1/aai-runtime/eval";
|
|
18
24
|
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
19
25
|
import { expect } from "vitest";
|
|
26
|
+
import { z } from "zod";
|
|
20
27
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
/**
|
|
29
|
+
* The code every `run_code` call in this turn carried, joined.
|
|
30
|
+
*
|
|
31
|
+
* Read through `toolArgsIn` WITH a schema, which is what that reader takes one
|
|
32
|
+
* for: `args` is `Record<string, unknown>` on the wire — the tutor wrote it and
|
|
33
|
+
* nothing validated it — so the `String(c.args.code ?? "")` this replaced turned
|
|
34
|
+
* an argument the model renamed, or never sent, into `""`. The claims below are
|
|
35
|
+
* about the recipe the tutor wrote, and against `""` every one of them would
|
|
36
|
+
* have been a claim about nothing. A `code` that stops arriving FAILS here,
|
|
37
|
+
* naming the field.
|
|
38
|
+
*/
|
|
39
|
+
const RunCodeArgs = z.object({ code: z.string() });
|
|
40
|
+
const codeIn = (turn: EvalTurn) =>
|
|
41
|
+
toolArgsIn(turn.toolCalls, "run_code", RunCodeArgs)
|
|
42
|
+
.map((args) => args.code)
|
|
26
43
|
.join("\n");
|
|
27
44
|
|
|
28
45
|
/**
|
|
29
|
-
* A `run_code` executor, so these cases can assert the
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* boundary, and off-platform there is none — so a case could assert the CALL and
|
|
33
|
-
* the code it carried, and never what the code came back with.
|
|
34
|
-
* `createVmRunCode()` is a `node:vm` context with a capturing `console.log`,
|
|
35
|
-
* which is enough here: what runs is arithmetic, not a program. It is NOT a
|
|
36
|
-
* sandbox and does not pretend to be one; a deployed agent still gets the
|
|
37
|
-
* refusal.
|
|
46
|
+
* A `run_code` executor, so these cases can assert the answer the tutor's code
|
|
47
|
+
* came back with and not merely the code — `createVmRunCode`'s own doc carries
|
|
48
|
+
* why the builtin refuses without one.
|
|
38
49
|
*/
|
|
39
50
|
const runCode = createVmRunCode();
|
|
40
51
|
|
|
@@ -49,7 +60,7 @@ describeEval(
|
|
|
49
60
|
// The prompt hands the tutor the factors; the finding it guards against
|
|
50
61
|
// is a tutor that recites a remembered figure instead. A factor in the
|
|
51
62
|
// code is the evidence that the conversion was computed, not recalled.
|
|
52
|
-
expect(turn.toolCalls
|
|
63
|
+
expect(toolNames(turn.toolCalls)).toContain("run_code");
|
|
53
64
|
const code = codeIn(turn);
|
|
54
65
|
expect(code).toContain("5");
|
|
55
66
|
expect(code).toMatch(/1\.60|1\.61|0\.621|8\.04/);
|
|
@@ -69,7 +80,7 @@ describeEval(
|
|
|
69
80
|
// A model asked for dice will happily make three numbers up, and the
|
|
70
81
|
// reply is indistinguishable from a real roll. `Math.random` in the code
|
|
71
82
|
// is the only thing that tells them apart.
|
|
72
|
-
expect(turn.toolCalls
|
|
83
|
+
expect(toolNames(turn.toolCalls)).toContain("run_code");
|
|
73
84
|
const code = codeIn(turn);
|
|
74
85
|
expect(code).toMatch(/Math\.random/);
|
|
75
86
|
expect(code).toContain("20");
|
|
@@ -29,7 +29,10 @@ import type { SessionEvent } from "@alexkroman1/aai/protocol";
|
|
|
29
29
|
import {
|
|
30
30
|
createVmRunCode,
|
|
31
31
|
customEventsIn,
|
|
32
|
+
describeTurn,
|
|
32
33
|
lastStateIn,
|
|
34
|
+
toolArgsIn,
|
|
35
|
+
toolNames,
|
|
33
36
|
toolResultIn,
|
|
34
37
|
} from "@alexkroman1/aai-runtime/eval";
|
|
35
38
|
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
@@ -66,17 +69,23 @@ const pushedRecs = (events: readonly SessionEvent[]) =>
|
|
|
66
69
|
const nudges = (events: readonly SessionEvent[]) => customEventsIn(events, "wind_down");
|
|
67
70
|
|
|
68
71
|
/**
|
|
69
|
-
* A `run_code` executor, so the sleep-cycle case can assert the
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
* boundary, and off-platform there is none — which left this template's
|
|
73
|
-
* headline feature assertable as a CALL and never as a number. A `node:vm`
|
|
74
|
-
* context with a capturing `console.log` is what a developer would reach for on
|
|
75
|
-
* their own machine, and `createVmRunCode()` is exactly that: the code under
|
|
76
|
-
* test is arithmetic the model wrote, not a program.
|
|
72
|
+
* A `run_code` executor, so the sleep-cycle case can assert the bedtime NUMBER
|
|
73
|
+
* and not merely the call — this template's headline feature is the arithmetic.
|
|
74
|
+
* `createVmRunCode`'s own doc carries why the builtin refuses without one.
|
|
77
75
|
*/
|
|
78
76
|
const runCode = createVmRunCode();
|
|
79
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The `code` argument a `run_code` call carries.
|
|
80
|
+
*
|
|
81
|
+
* The schema is what `toolArgsIn` takes one for: `args` is
|
|
82
|
+
* `Record<string, unknown>` on the wire — the model wrote it and nothing
|
|
83
|
+
* validated it — so the `String(c.args.code ?? "")` this replaced turned an
|
|
84
|
+
* argument the companion renamed, or never sent, into `""`, and the two
|
|
85
|
+
* constants asserted below would have been looked for in nothing at all.
|
|
86
|
+
*/
|
|
87
|
+
const RunCodeArgs = z.object({ code: z.string() });
|
|
88
|
+
|
|
80
89
|
/** Two-digit, for the clock arithmetic below. */
|
|
81
90
|
const pad = (n: number): string => String(n).padStart(2, "0");
|
|
82
91
|
|
|
@@ -113,7 +122,7 @@ describeEval(
|
|
|
113
122
|
|
|
114
123
|
// "to watch" is the category and "cozy" is the mood; the shelf is the
|
|
115
124
|
// tool's, so answering from the model's own taste is the regression.
|
|
116
|
-
expect(turn.toolCalls
|
|
125
|
+
expect(toolNames(turn.toolCalls)).toEqual(["recommend"]);
|
|
117
126
|
const call = turn.toolCalls[0]!;
|
|
118
127
|
expect(call.args).toEqual({ category: "movie", mood: "cozy" });
|
|
119
128
|
|
|
@@ -139,7 +148,7 @@ describeEval(
|
|
|
139
148
|
await session.say("I want something cozy to watch tonight.");
|
|
140
149
|
const turn = await session.say("Now give me something spooky to read.");
|
|
141
150
|
|
|
142
|
-
expect(turn.toolCalls
|
|
151
|
+
expect(toolNames(turn.toolCalls)).toEqual(["recommend"]);
|
|
143
152
|
expect(turn.toolCalls[0]!.args).toEqual({ category: "book", mood: "spooky" });
|
|
144
153
|
|
|
145
154
|
// The slot survived the turn boundary: the frame the page renders after
|
|
@@ -197,15 +206,21 @@ describeEval(
|
|
|
197
206
|
"I need to be up at 7 in the morning. When should I fall asleep?",
|
|
198
207
|
);
|
|
199
208
|
|
|
209
|
+
// The CALLS, not their arguments: what this asserts is that the companion
|
|
210
|
+
// reached for code at all, and the results are read off the same list
|
|
211
|
+
// below. `toolArgsIn` answers the other half, the code it submitted.
|
|
200
212
|
const ran = turn.toolCalls.filter((c) => c.name === "run_code");
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
213
|
+
// `describeTurn` is the message: "expected [] not to equal []" says
|
|
214
|
+
// nothing about a companion that talked its way through the sum
|
|
215
|
+
// instead, and it names a cancelled reply, which is the usual reason a
|
|
216
|
+
// turn reached for nothing at all.
|
|
217
|
+
expect(ran, describeTurn(turn)).not.toEqual([]);
|
|
205
218
|
// The recipe is the prompt's, and it is two constants: a 90-minute cycle
|
|
206
219
|
// plus the 15 minutes it takes to fall asleep. Arithmetic done in the
|
|
207
220
|
// model's head has neither of them anywhere in the code.
|
|
208
|
-
const code =
|
|
221
|
+
const code = toolArgsIn(turn.toolCalls, "run_code", RunCodeArgs)
|
|
222
|
+
.map((args) => args.code)
|
|
223
|
+
.join("\n");
|
|
209
224
|
expect(code).toContain("90");
|
|
210
225
|
expect(code).toContain("15");
|
|
211
226
|
|
|
@@ -32,7 +32,12 @@ describe("night-owl template", () => {
|
|
|
32
32
|
});
|
|
33
33
|
|
|
34
34
|
test("recommend is discovered from tools/", () => {
|
|
35
|
-
|
|
35
|
+
// `toContain` rather than an exact list: a file in `tools/` IS a tool, so
|
|
36
|
+
// adding one is the edit this template most invites, and an exact list
|
|
37
|
+
// would make that edit fail a test the author never wrote. What has to hold
|
|
38
|
+
// is that discovery ran at all — a template whose `tools/` is never
|
|
39
|
+
// resolved ships a model with no tools.
|
|
40
|
+
expect(Object.keys(agentDef.tools ?? {})).toContain("recommend");
|
|
36
41
|
});
|
|
37
42
|
|
|
38
43
|
test("the projection an untouched session pushes is an empty log", () => {
|
|
@@ -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
|
|