@alexkroman1/aai-cli 7.0.0 → 8.0.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-Me06wgeF.mjs} +2 -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-BvcctCn2.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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { agent } from "@alexkroman1/aai";
|
|
2
|
-
import {
|
|
2
|
+
import { anthropicLlm } from "@alexkroman1/aai/llm";
|
|
3
3
|
|
|
4
4
|
export default agent({
|
|
5
5
|
name: "pipeline-simple",
|
|
@@ -8,5 +8,5 @@ export default agent({
|
|
|
8
8
|
// left unset (STT and TTS here) runs on the AssemblyAI default, billed to
|
|
9
9
|
// the one key a published agent is guaranteed to have, so swapping a stage
|
|
10
10
|
// never means restating the other two.
|
|
11
|
-
llm:
|
|
11
|
+
llm: anthropicLlm({ model: "claude-haiku-4-5" }),
|
|
12
12
|
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
import type { SessionEvent } from "@alexkroman1/aai/protocol";
|
|
4
|
+
import { withDiscoveredTools } from "@alexkroman1/aai/testing";
|
|
5
|
+
// An EVAL: does the order-taker actually take the order?
|
|
6
|
+
//
|
|
7
|
+
// `agent.test.ts` drives the six tools directly, which settles what each one
|
|
8
|
+
// does with the arguments it is given. What no test in it can settle is whether
|
|
9
|
+
// the MODEL reaches for the right one, with the arguments the caller actually
|
|
10
|
+
// said — and for a cart that lives in a `sessionSlot`, whether what turn 1
|
|
11
|
+
// wrote is still there on turn 2. That is what this file is for.
|
|
12
|
+
//
|
|
13
|
+
// Run it with `aai eval`. Without a provider key every case runs against a
|
|
14
|
+
// SCRIPTED model (its `stubReply`), which still boots this agent, still
|
|
15
|
+
// resolves `tools/`, and still executes the tool a script names — so a stub run
|
|
16
|
+
// proves the wiring and proves nothing about what the agent chose.
|
|
17
|
+
import { lastStateIn, toolResultIn } from "@alexkroman1/aai-runtime/eval";
|
|
18
|
+
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
19
|
+
import { expect } from "vitest";
|
|
20
|
+
import { z } from "zod";
|
|
21
|
+
import authoredAgent from "./agent.ts";
|
|
22
|
+
import { MENU } from "./shared.ts";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
|
|
26
|
+
*
|
|
27
|
+
* The glob is written HERE rather than reached for from a shared helper because
|
|
28
|
+
* this file SHIPS — a scaffolded project has no repo helper to import. Without
|
|
29
|
+
* it the eval would drive an agent with no tools at all, which is the one
|
|
30
|
+
* failure mode a green eval must not be able to hide.
|
|
31
|
+
*/
|
|
32
|
+
const agentDef = withDiscoveredTools(
|
|
33
|
+
authoredAgent,
|
|
34
|
+
import.meta.glob("./tools/*.ts", { eager: true }),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What the BROWSER is sent, as this eval reads it.
|
|
39
|
+
*
|
|
40
|
+
* Parsed rather than cast: `state.updated` carries `unknown`, so a projection
|
|
41
|
+
* that stopped matching FAILS naming the field, where the cast this replaced
|
|
42
|
+
* handed the assertions `undefined` and failed a line later on something else.
|
|
43
|
+
* It names only the fields asserted below, so `orderView` may grow without
|
|
44
|
+
* touching this.
|
|
45
|
+
*/
|
|
46
|
+
const ProjectedOrder = z.object({
|
|
47
|
+
pizzas: z.array(z.object({ id: z.number(), size: z.string(), toppings: z.array(z.string()) })),
|
|
48
|
+
orderPlaced: z.boolean(),
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The last cart `syncState` pushed to the browser, i.e. what the page shows.
|
|
53
|
+
*
|
|
54
|
+
* This agent declares ONE projection, so the frame IS `orderView`'s result —
|
|
55
|
+
* not a record keyed by the slot. That is the same value `useAgentState(
|
|
56
|
+
* orderProjection)` reads in `client.tsx`, which is why an eval can assert on
|
|
57
|
+
* it: it is the cart the customer is looking at. `lastStateIn` is the SDK's
|
|
58
|
+
* reader for exactly this.
|
|
59
|
+
*/
|
|
60
|
+
const lastPushedView = (events: readonly SessionEvent[]) => lastStateIn(events, ProjectedOrder);
|
|
61
|
+
|
|
62
|
+
/** Every cart the session pushed, in stream order. */
|
|
63
|
+
const pushedViews = (events: readonly SessionEvent[]) =>
|
|
64
|
+
events.flatMap((e) => (e.type === "state.updated" ? [ProjectedOrder.parse(e.state)] : []));
|
|
65
|
+
|
|
66
|
+
describeEval(agentDef, (test) => {
|
|
67
|
+
test(
|
|
68
|
+
"adds the pizza the caller described, at the price the menu quotes",
|
|
69
|
+
async ({ session }) => {
|
|
70
|
+
const turn = await session.say(
|
|
71
|
+
"Hi, I'd like a large pepperoni pizza with extra cheese please.",
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
// One tool, and the right one: quoting a price without adding the pizza,
|
|
75
|
+
// or adding it twice, are both real findings.
|
|
76
|
+
expect(turn.toolCalls.map((c) => c.name)).toEqual(["add_pizza"]);
|
|
77
|
+
const call = turn.toolCalls[0]!;
|
|
78
|
+
const args = call.args as { size: string; crust: string; toppings: string[] };
|
|
79
|
+
expect(args.size).toBe("large");
|
|
80
|
+
// The two toppings the caller named, however the model spelled them.
|
|
81
|
+
const toppings = args.toppings.map((t) => t.toLowerCase().replaceAll(" ", "_"));
|
|
82
|
+
expect(toppings).toContain("pepperoni");
|
|
83
|
+
expect(toppings).toContain("extra_cheese");
|
|
84
|
+
|
|
85
|
+
// The claim the template's own comment makes: the menu is generated from
|
|
86
|
+
// MENU, so the agent can never quote a price the ordering code doesn't
|
|
87
|
+
// charge. Computed from the menu here rather than from the tool's own
|
|
88
|
+
// output, which is what gives it teeth — a topping name the pricing table
|
|
89
|
+
// doesn't recognise silently falls back to $1.00.
|
|
90
|
+
const expected =
|
|
91
|
+
MENU.sizes[args.size as keyof typeof MENU.sizes] +
|
|
92
|
+
MENU.crusts[args.crust as keyof typeof MENU.crusts] +
|
|
93
|
+
MENU.toppings.pepperoni +
|
|
94
|
+
MENU.toppings.extra_cheese;
|
|
95
|
+
expect(
|
|
96
|
+
toolResultIn(turn.toolCalls, "add_pizza", z.object({ orderTotal: z.string() })).orderTotal,
|
|
97
|
+
).toBe(`$${expected.toFixed(2)}`);
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
stubReply: [
|
|
101
|
+
{
|
|
102
|
+
tool: "add_pizza",
|
|
103
|
+
args: {
|
|
104
|
+
size: "large",
|
|
105
|
+
crust: "regular",
|
|
106
|
+
toppings: ["pepperoni", "extra cheese"],
|
|
107
|
+
quantity: 1,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
"Added a large pepperoni with extra cheese.",
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
test(
|
|
116
|
+
"changes the pizza it added a turn ago, by the id that turn returned",
|
|
117
|
+
async ({ session }) => {
|
|
118
|
+
await session.say("Can I get a medium thin crust pizza with mushrooms?");
|
|
119
|
+
// The id only exists because turn 1 wrote it into the session's cart.
|
|
120
|
+
// Nothing in this utterance names it, so a slot that did not survive the
|
|
121
|
+
// turn leaves the model with nothing to address.
|
|
122
|
+
const turn = await session.say("Actually, make that a large.");
|
|
123
|
+
|
|
124
|
+
expect(turn.toolCalls.map((c) => c.name)).toEqual(["update_pizza"]);
|
|
125
|
+
const call = turn.toolCalls[0]!;
|
|
126
|
+
expect(call.args).toMatchObject({ pizza_id: 1, size: "large" });
|
|
127
|
+
|
|
128
|
+
// And the cart the page renders is the same one: still one pizza, resized
|
|
129
|
+
// rather than replaced, with the mushrooms from the first turn intact.
|
|
130
|
+
const view = lastPushedView(turn.events);
|
|
131
|
+
expect(view?.pizzas).toHaveLength(1);
|
|
132
|
+
expect(view?.pizzas[0]).toMatchObject({ id: 1, size: "large", toppings: ["mushrooms"] });
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
stubReply: [
|
|
136
|
+
{
|
|
137
|
+
tool: "add_pizza",
|
|
138
|
+
args: { size: "medium", crust: "thin", toppings: ["mushrooms"], quantity: 1 },
|
|
139
|
+
},
|
|
140
|
+
"A medium thin crust with mushrooms, coming up.",
|
|
141
|
+
{ tool: "update_pizza", args: { pizza_id: 1, size: "large" } },
|
|
142
|
+
"Made it a large.",
|
|
143
|
+
],
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
test(
|
|
148
|
+
"never places an empty order",
|
|
149
|
+
async ({ session }) => {
|
|
150
|
+
const turn = await session.say("That's everything, go ahead and place my order.");
|
|
151
|
+
|
|
152
|
+
// The model may check the cart first, or answer from the fact that
|
|
153
|
+
// nothing was ordered. What it may NOT do is get an order number: every
|
|
154
|
+
// place_order call on an empty cart has to come back refused, which is
|
|
155
|
+
// what stops the agent reading out a confirmation for nothing.
|
|
156
|
+
for (const call of turn.toolCalls.filter((c) => c.name === "place_order")) {
|
|
157
|
+
// `toolResultIn` over a ONE-CALL list: the name is this call's own, so
|
|
158
|
+
// the reader's "no such call" and "two calls" throws are unreachable and
|
|
159
|
+
// what is left is the parse plus the schema. The schema requires
|
|
160
|
+
// `error`, which is the claim — a call that SUCCEEDED fails here.
|
|
161
|
+
expect(toolResultIn([call], call.name, z.object({ error: z.string() })).error).toContain(
|
|
162
|
+
"Cannot place an empty order",
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
expect(turn.completed).toBe(true);
|
|
166
|
+
// Nothing the session pushed to the page may claim an order was placed.
|
|
167
|
+
expect(pushedViews(session.events()).filter((view) => view.orderPlaced)).toEqual([]);
|
|
168
|
+
},
|
|
169
|
+
{ stubReply: [{ tool: "place_order" }, "There's nothing in your order yet."] },
|
|
170
|
+
);
|
|
171
|
+
});
|
|
@@ -26,6 +26,7 @@ const agentDef = withDiscoveredTools(
|
|
|
26
26
|
|
|
27
27
|
import {
|
|
28
28
|
calculateTotal,
|
|
29
|
+
MENU,
|
|
29
30
|
orderProjection,
|
|
30
31
|
orderSlot,
|
|
31
32
|
orderView,
|
|
@@ -72,6 +73,20 @@ describe("pricing (shared.ts)", () => {
|
|
|
72
73
|
expect(pizzaPrice({ id: 1, ...margherita })).toBeCloseTo(11.99, 5);
|
|
73
74
|
});
|
|
74
75
|
|
|
76
|
+
test("a topping named the way the MENU PROSE spells it is charged menu price", () => {
|
|
77
|
+
// `menuText()` writes `extra_cheese` as "extra cheese", so that is what a
|
|
78
|
+
// model reading the prompt asks for. Before `toppingKey`, the table missed
|
|
79
|
+
// and charged the $1.00 unknown-topping default for a $1.50 topping —
|
|
80
|
+
// found live by `agent.eval.test.ts`, which priced the same pizza off MENU
|
|
81
|
+
// and got $17.99 against the tool's $17.49.
|
|
82
|
+
const keyed = pizzaPrice({ id: 1, ...margherita, toppings: ["extra_cheese"] });
|
|
83
|
+
expect(pizzaPrice({ id: 1, ...margherita, toppings: ["extra cheese"] })).toBeCloseTo(keyed, 5);
|
|
84
|
+
expect(pizzaPrice({ id: 1, ...margherita, toppings: ["Green Peppers"] })).toBeCloseTo(
|
|
85
|
+
11.99 + MENU.toppings.green_peppers,
|
|
86
|
+
5,
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
75
90
|
test("unknown topping falls back to the $1.00 default", () => {
|
|
76
91
|
const known = pizzaPrice({ id: 1, ...margherita, toppings: ["onions"] }); // $1.00 topping
|
|
77
92
|
const unknown = pizzaPrice({ id: 1, ...margherita, toppings: ["dragonfruit"] });
|
|
@@ -62,11 +62,30 @@ export function menuText(): string {
|
|
|
62
62
|
].join("\n");
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* A topping name as the price table keys it.
|
|
67
|
+
*
|
|
68
|
+
* `menuText()` renders `extra_cheese` as "extra cheese", so the menu the model
|
|
69
|
+
* reads and the table that charges for it are spelled differently — and the raw
|
|
70
|
+
* lookup below then missed and charged the $1.00 unknown-topping default for a
|
|
71
|
+
* $1.50 topping. `agent.eval.test.ts` found it against a live model: a large
|
|
72
|
+
* pepperoni with extra cheese was quoted at the menu's $17.99 and rung up at
|
|
73
|
+
* $17.49. Normalizing here keeps ONE spelling authoritative for pricing while
|
|
74
|
+
* the cart still stores what the caller actually said, which is what the
|
|
75
|
+
* sidebar and the read-back description show.
|
|
76
|
+
*/
|
|
77
|
+
export function toppingKey(name: string): string {
|
|
78
|
+
return name
|
|
79
|
+
.trim()
|
|
80
|
+
.toLowerCase()
|
|
81
|
+
.replaceAll(/[\s-]+/g, "_");
|
|
82
|
+
}
|
|
83
|
+
|
|
65
84
|
export function pizzaPrice(p: ReadonlyPizza): number {
|
|
66
85
|
const base = MENU.sizes[p.size];
|
|
67
86
|
const crust = MENU.crusts[p.crust];
|
|
68
87
|
const toppings = p.toppings.reduce(
|
|
69
|
-
(sum, t) => sum + (MENU.toppings[t as keyof typeof MENU.toppings] ?? 1.0),
|
|
88
|
+
(sum, t) => sum + (MENU.toppings[toppingKey(t) as keyof typeof MENU.toppings] ?? 1.0),
|
|
70
89
|
0,
|
|
71
90
|
);
|
|
72
91
|
return (base + crust + toppings) * p.quantity;
|
|
@@ -8,6 +8,12 @@ Behavior:
|
|
|
8
8
|
- When a customer wants a pizza, collect size, crust, and toppings, then use add_pizza to add it.
|
|
9
9
|
- If they just say something like "pepperoni pizza", assume medium, regular crust, and confirm before adding.
|
|
10
10
|
- Always confirm what you added after using add_pizza.
|
|
11
|
+
- NEVER say a pizza was added, changed, or removed unless the matching tool has
|
|
12
|
+
returned it. Saying "I've added that" with no tool call leaves the customer's
|
|
13
|
+
cart empty and their order unplaced.
|
|
14
|
+
- NEVER work out a price or a total yourself. Every price you say out loud comes
|
|
15
|
+
from a tool result — add_pizza, update_pizza, remove_pizza and view_order all
|
|
16
|
+
return the running total. If you have not called one, you do not have a total.
|
|
11
17
|
- Use view_order when the customer asks to review their order.
|
|
12
18
|
- Use update_pizza if they want to change an existing pizza.
|
|
13
19
|
- Use remove_pizza if they want to remove one.
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
// An EVAL: does the desk really plan, work one step, and replan? Run it with
|
|
4
|
+
// `aai eval`.
|
|
5
|
+
//
|
|
6
|
+
// `agent.test.ts` drives the three nodes and the four tools directly. What it
|
|
7
|
+
// cannot see is the LOOP as a caller drives it: whether the flow gate stops a
|
|
8
|
+
// step being worked before there is a plan, and whether one turn does exactly
|
|
9
|
+
// one step rather than running the plan to completion down a silent line. Both
|
|
10
|
+
// of those are read off the event stream here.
|
|
11
|
+
//
|
|
12
|
+
// **One thing a SCRIPTED run cannot reach: `ctx.generate`.** The stub model
|
|
13
|
+
// registers as an LLM provider, so `ctx.generate` resolves it — and the fake
|
|
14
|
+
// answers `doStream` only, so any tool that calls a model comes back
|
|
15
|
+
// `{"error":"The planner failed: fake LLM: doGenerate not implemented"}`. That
|
|
16
|
+
// is three of this template's four tools. So the two cases that run without a
|
|
17
|
+
// key are the ones about the GATE, which refuses before the body runs, and
|
|
18
|
+
// everything about the plan itself is `{ live: true }`.
|
|
19
|
+
//
|
|
20
|
+
// **`system-prompt.md` is applied HERE, not by `agent.ts`.** The build discovers
|
|
21
|
+
// the file, so an eval driving the raw default export would run this agent with
|
|
22
|
+
// the FRAMEWORK DEFAULT prompt — and the discipline that prompt imposes is the
|
|
23
|
+
// entire subject of this file. Measured against the default: the model answers
|
|
24
|
+
// product questions from its own knowledge and skips the tools the prompt exists
|
|
25
|
+
// to route it through, so a case run that way measures nothing it claims to.
|
|
26
|
+
|
|
27
|
+
import { withSystemPrompt } from "@alexkroman1/aai/manifest";
|
|
28
|
+
import { withDiscoveredTools } from "@alexkroman1/aai/testing";
|
|
29
|
+
import { type EvalSession, lastStateIn } from "@alexkroman1/aai-runtime/eval";
|
|
30
|
+
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
31
|
+
import { expect } from "vitest";
|
|
32
|
+
import { z } from "zod";
|
|
33
|
+
import authoredAgent from "./agent.ts";
|
|
34
|
+
import systemPrompt from "./system-prompt.md?raw";
|
|
35
|
+
|
|
36
|
+
/** The def a DEPLOYED agent runs — see `agent.test.ts` on why the glob is here. */
|
|
37
|
+
const agentDef = withSystemPrompt(
|
|
38
|
+
withDiscoveredTools(authoredAgent, import.meta.glob("./tools/*.ts", { eager: true })),
|
|
39
|
+
systemPrompt,
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What the BROWSER is sent, as this eval reads it.
|
|
44
|
+
*
|
|
45
|
+
* Parsed rather than cast: `state.updated` carries `unknown`, so a projection
|
|
46
|
+
* that stopped matching FAILS naming the field, where the cast this replaced
|
|
47
|
+
* handed the assertions `undefined` and failed a line later on something else.
|
|
48
|
+
* It names only the fields asserted below, so `planView` may grow without
|
|
49
|
+
* touching this.
|
|
50
|
+
*/
|
|
51
|
+
const ProjectedPlan = z.object({
|
|
52
|
+
objective: z.string().nullable(),
|
|
53
|
+
plan: z.array(z.string()),
|
|
54
|
+
done: z.array(z.object({ step: z.string(), result: z.string() })),
|
|
55
|
+
response: z.string().nullable(),
|
|
56
|
+
revisions: z.array(z.string()),
|
|
57
|
+
progress: z.number(),
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The latest `syncState` frame — what the browser would be rendering.
|
|
62
|
+
*
|
|
63
|
+
* `planProjection` rides out on `state.updated` after every tool call, so this
|
|
64
|
+
* is how an eval sees the plan at all: the session hands back events, and the
|
|
65
|
+
* projection is the one of them that carries state. `lastStateIn` is the SDK's
|
|
66
|
+
* reader for exactly this.
|
|
67
|
+
*/
|
|
68
|
+
const planState = (session: EvalSession) => lastStateIn(session.events(), ProjectedPlan);
|
|
69
|
+
|
|
70
|
+
const named = (calls: readonly { name: string }[]): string[] => calls.map((call) => call.name);
|
|
71
|
+
|
|
72
|
+
describeEval(agentDef, (test) => {
|
|
73
|
+
test(
|
|
74
|
+
"the stage the desk reports is the flow's, not a guess at the plan",
|
|
75
|
+
async ({ session }) => {
|
|
76
|
+
const turn = await session.say("Where do we stand with all this?");
|
|
77
|
+
|
|
78
|
+
// `plan_status` is the one tool that reads the machine's own position, and
|
|
79
|
+
// it is legal in every state. Whatever the model did with the answer, the
|
|
80
|
+
// desk must not have invented a plan to report.
|
|
81
|
+
const view = planState(session);
|
|
82
|
+
expect(view?.objective ?? null).toBeNull();
|
|
83
|
+
expect(view?.progress ?? 0).toBe(0);
|
|
84
|
+
|
|
85
|
+
for (const call of turn.toolCalls.filter((one) => one.name === "plan_status")) {
|
|
86
|
+
// `stage` comes off `planFlow.position`, and `reads` off `stageLabel` —
|
|
87
|
+
// deriving either from `!plan.objective` a second time is the drift this
|
|
88
|
+
// template removed, and it would show up right here.
|
|
89
|
+
expect(call.result).toMatch(/"stage":"idle"/);
|
|
90
|
+
expect(call.result).toMatch(/no plan yet/);
|
|
91
|
+
expect(call.result).toMatch(/start_plan/);
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
{ stubReply: [{ tool: "plan_status" }, "Nothing on the go yet — what are you trying to do?"] },
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
test(
|
|
98
|
+
"no step can be worked before a plan exists",
|
|
99
|
+
async ({ session, mode }) => {
|
|
100
|
+
const turn = await session.say("Skip the planning — just go and do the next step.");
|
|
101
|
+
|
|
102
|
+
const attempts = turn.toolCalls.filter((call) => call.name === "work_next_step");
|
|
103
|
+
// The script FORCES the call in stub mode, so the gate really runs; a live
|
|
104
|
+
// model that declines to call it has honoured the same rule a level
|
|
105
|
+
// earlier, which is why the count is asserted only where it is decided.
|
|
106
|
+
if (mode === "stub") expect(attempts).toHaveLength(1);
|
|
107
|
+
for (const attempt of attempts) {
|
|
108
|
+
// Refused BEFORE the body: `when: "working"` is what replaced the two
|
|
109
|
+
// hand-rolled guards, and its refusal names the position and quotes the
|
|
110
|
+
// state's instruction so the model can recover on its own turn.
|
|
111
|
+
expect(attempt.result).toMatch(/Not available yet/);
|
|
112
|
+
expect(attempt.result).toMatch(/idle/);
|
|
113
|
+
expect(attempt.result).toMatch(/start_plan/);
|
|
114
|
+
}
|
|
115
|
+
// And whatever else happened, nothing was ANSWERED: a desk that produced
|
|
116
|
+
// a response without working a step is the failure the flow exists to
|
|
117
|
+
// prevent.
|
|
118
|
+
expect(planState(session)?.response ?? null).toBeNull();
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
stubReply: [
|
|
122
|
+
{ tool: "work_next_step" },
|
|
123
|
+
"There's no plan yet — tell me what you're trying to get done.",
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
test(
|
|
129
|
+
"a step is worked once per call, and a worked step leaves the plan",
|
|
130
|
+
async ({ session }) => {
|
|
131
|
+
await session.say(
|
|
132
|
+
"I want to work out whether it is cheaper to take the train or fly from London to Edinburgh next month.",
|
|
133
|
+
);
|
|
134
|
+
const started = session.toolCalls().find((call) => call.name === "start_plan");
|
|
135
|
+
expect(started, `tools called: ${named(session.toolCalls()).join(", ")}`).toBeDefined();
|
|
136
|
+
const planned = planState(session);
|
|
137
|
+
// The tool's own result rides in the message: a planner that FAILED (a
|
|
138
|
+
// gateway error, a schema the provider would not honour) writes nothing,
|
|
139
|
+
// and "objective is null" on its own reads as a projection bug.
|
|
140
|
+
expect(planned?.objective, `start_plan answered: ${started?.result}`).toBeTruthy();
|
|
141
|
+
expect(planned?.plan.length ?? 0).toBeGreaterThan(0);
|
|
142
|
+
|
|
143
|
+
const worked = await session.say("Yes, go ahead and start on it.");
|
|
144
|
+
const calls = named(worked.toolCalls).filter((name) => name === "work_next_step").length;
|
|
145
|
+
expect(calls, `tools called: [${named(worked.toolCalls).join(", ")}]`).toBeGreaterThan(0);
|
|
146
|
+
|
|
147
|
+
const after = planState(session);
|
|
148
|
+
const done = after?.done ?? [];
|
|
149
|
+
expect(done.length).toBeGreaterThan(0);
|
|
150
|
+
// ONE STEP PER CALL: `work_next_step` is one execute-then-replan turn, not
|
|
151
|
+
// a loop. More steps recorded than calls made would mean the tool had run
|
|
152
|
+
// the plan to completion, leaving the caller on a silent line with no gap
|
|
153
|
+
// to change their mind in — which is the pause the whole design is for.
|
|
154
|
+
//
|
|
155
|
+
// Note this is the guarantee the template ENFORCES, and it is weaker than
|
|
156
|
+
// the one it asks for: "call this once per step, never in a loop" is in the
|
|
157
|
+
// system prompt AND the tool description, and a live model was measured
|
|
158
|
+
// calling it twice in one turn anyway. One session has one tool list and
|
|
159
|
+
// no per-turn hook, so the pause is asked for rather than enforced; what IS
|
|
160
|
+
// enforced is everything below.
|
|
161
|
+
expect(done.length).toBeLessThanOrEqual(calls);
|
|
162
|
+
for (const step of done) expect(step.result).toBeTruthy();
|
|
163
|
+
expect(after?.progress ?? 0).toBeGreaterThan(0);
|
|
164
|
+
// The step is CLAIMED off the head of the plan inside the synchronous
|
|
165
|
+
// update window, so no step can be done twice and a completed step is gone
|
|
166
|
+
// from what is left — that claim is what makes two concurrent calls safe.
|
|
167
|
+
const labels = done.map((step) => step.step);
|
|
168
|
+
expect(new Set(labels).size, `a step was worked twice: ${labels.join(" | ")}`).toBe(
|
|
169
|
+
labels.length,
|
|
170
|
+
);
|
|
171
|
+
for (const label of labels) expect(after?.plan ?? []).not.toContain(label);
|
|
172
|
+
},
|
|
173
|
+
// Live only: this step is a real model call and a real web search, which is
|
|
174
|
+
// the point — a scripted planner is a template that "plans" by imagining
|
|
175
|
+
// having looked something up.
|
|
176
|
+
{ live: true },
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
test(
|
|
180
|
+
"when the caller changes their mind it is the replanner that rewrites the plan",
|
|
181
|
+
async ({ session }) => {
|
|
182
|
+
await session.say(
|
|
183
|
+
"I need to sort out getting from London to Edinburgh next month — train or flight, whichever works.",
|
|
184
|
+
);
|
|
185
|
+
const before = planState(session);
|
|
186
|
+
expect(before?.plan.length ?? 0).toBeGreaterThan(0);
|
|
187
|
+
|
|
188
|
+
const turn = await session.say("Actually, forget the train entirely — I only want to fly.");
|
|
189
|
+
|
|
190
|
+
const revised = turn.toolCalls.find((call) => call.name === "revise_plan");
|
|
191
|
+
expect(
|
|
192
|
+
revised,
|
|
193
|
+
`tools called: ${named(turn.toolCalls).join(", ")} — the caller changed the objective, ` +
|
|
194
|
+
"so this is `revise_plan`, not a plan rewritten by hand",
|
|
195
|
+
).toBeDefined();
|
|
196
|
+
expect(revised?.args.instruction).toBeTruthy();
|
|
197
|
+
|
|
198
|
+
const after = planState(session);
|
|
199
|
+
// The trail is what the sidebar renders and what a caller cannot hold by
|
|
200
|
+
// ear: a revision the caller asked for is recorded as theirs.
|
|
201
|
+
expect(after?.revisions.some((entry) => entry.startsWith("Caller:"))).toBe(true);
|
|
202
|
+
// A revision reopens the plan rather than answering it, and completed
|
|
203
|
+
// steps are never redone — there are none here, so the whole plan is new.
|
|
204
|
+
expect(after?.plan).not.toEqual(before?.plan);
|
|
205
|
+
expect(after?.done).toHaveLength(0);
|
|
206
|
+
},
|
|
207
|
+
{ live: true },
|
|
208
|
+
);
|
|
209
|
+
});
|