@alexkroman1/aai-cli 6.11.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/{_agent-DpH2pBJd.mjs → _agent-BTwZJu4P.mjs} +2 -2
- package/dist/{_bundler-C2BDwU7r.mjs → _bundler-CDuYl5Gb.mjs} +2 -2
- package/dist/_dev-env.d.ts +22 -2
- package/dist/{_dev-server-LLLWnFBs.mjs → _dev-server-Me06wgeF.mjs} +38 -47
- package/dist/_dev-server.d.ts +0 -17
- package/dist/{_init-C57bWAQ8.mjs → _init-BhEfQ6Yi.mjs} +3 -2
- package/dist/{_preflight-BtfaYtbE.mjs → _preflight-Co1G9Jww.mjs} +1 -1
- package/dist/_preflight.d.ts +1 -1
- package/dist/_resource-commands.d.ts +23 -0
- package/dist/{_slug-api-BxrsJXVI.mjs → _slug-api-z-USxPtL.mjs} +16 -12
- package/dist/_slug-api.d.ts +5 -2
- package/dist/{_studio-U3jhKlA3.mjs → _studio-DR5yzZ1T.mjs} +2 -1
- package/dist/{_templates-Dxb_P2Wz.mjs → _templates-4WcKOjS5.mjs} +5 -6
- package/dist/_templates.d.ts +3 -4
- package/dist/_workflow-scan.d.ts +108 -0
- package/dist/{build-Dfza2pRI.mjs → build-BBxf8vom.mjs} +6 -3
- package/dist/cli.mjs +346 -349
- package/dist/{client-bundler-BEIqgOtd.mjs → client-bundler-CWnG42cU.mjs} +2 -1
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-vbpjGzqs.mjs → delete-Dl_LSfvI.mjs} +3 -3
- package/dist/{deploy-B8lubiRT.mjs → deploy-DRC8jWr8.mjs} +5 -5
- package/dist/{dev-Uq5ujP8a.mjs → dev-BvcctCn2.mjs} +1 -1
- package/dist/eval-DfQ8cm5p.mjs +56 -0
- package/dist/eval.d.ts +43 -0
- package/dist/{init-l6xfU4xX.mjs → init-CUo87gcI.mjs} +3 -3
- package/dist/{login-C71-qz8F.mjs → login-BBuM1sxH.mjs} +3 -4
- package/dist/{logs-BWVGSjLR.mjs → logs-CQ2T46dr.mjs} +2 -2
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +442 -80
- package/dist/scaffold/package.json +6 -3
- package/dist/scaffold/server.mjs +11 -4
- package/dist/scaffold/tsconfig.json +0 -1
- package/dist/{secret-BuMuFR4B.mjs → secret-Ct34kdgU.mjs} +2 -2
- package/dist/{storage-DzRZ-eCw.mjs → storage-i_79ZwAR.mjs} +2 -2
- package/dist/{studio-BCNUpDgP.mjs → studio-CzTzPqe1.mjs} +4 -4
- 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/call-audit/agent.test.ts +57 -204
- package/dist/templates/call-audit/agent.ts +32 -19
- package/dist/templates/call-audit/client.tsx +17 -54
- package/dist/templates/call-audit/workflows/audit.ts +11 -18
- package/dist/templates/call-audit/workflows/ingest.ts +106 -114
- package/dist/templates/call-audit/workflows/media.ts +2 -12
- package/dist/templates/call-audit/workflows/summarize.ts +47 -52
- package/dist/templates/call-audit/workflows/sync-api.ts +7 -7
- 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/dispatch-center/agent.test.ts +149 -25
- package/dist/templates/dispatch-center/client.tsx +239 -129
- package/dist/templates/dispatch-center/shared.ts +99 -1
- package/dist/templates/dispatch-center/system-prompt.md +3 -1
- package/dist/templates/dispatch-center/tools/incident_add_note.ts +16 -13
- package/dist/templates/dispatch-center/tools/incident_create.ts +19 -1
- package/dist/templates/dispatch-center/tools/incident_escalate.ts +68 -59
- package/dist/templates/dispatch-center/tools/incident_triage.ts +57 -43
- package/dist/templates/dispatch-center/tools/incident_update_status.ts +49 -40
- package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +8 -2
- package/dist/templates/dispatch-center/tools/resources_dispatch.ts +69 -52
- package/dist/templates/dispatch-center/tools/resources_update_status.ts +45 -41
- package/dist/templates/embedded-assets/agent.eval.test.ts +138 -0
- package/dist/templates/embedded-assets/agent.test.ts +17 -14
- package/dist/templates/health-assistant/agent.eval.test.ts +167 -0
- package/dist/templates/health-assistant/agent.test.ts +22 -4
- package/dist/templates/infocom-adventure/agent.eval.test.ts +158 -0
- package/dist/templates/infocom-adventure/agent.test.ts +53 -24
- package/dist/templates/infocom-adventure/agent.ts +21 -0
- package/dist/templates/infocom-adventure/client.tsx +246 -164
- 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/link-digest/agent.test.ts +24 -19
- package/dist/templates/link-digest/client.tsx +47 -61
- package/dist/templates/link-digest/workflows/digest.ts +19 -31
- 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/night-owl/agent.test.ts +70 -19
- package/dist/templates/night-owl/agent.ts +5 -0
- package/dist/templates/night-owl/client.tsx +56 -56
- package/dist/templates/night-owl/shared.ts +24 -0
- package/dist/templates/night-owl/tools/recommend.ts +18 -6
- 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 +42 -27
- package/dist/templates/pizza-ordering/client.tsx +9 -26
- 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/plan-and-execute/agent.test.ts +99 -58
- package/dist/templates/plan-and-execute/agent.ts +1 -1
- package/dist/templates/plan-and-execute/client.tsx +12 -15
- package/dist/templates/plan-and-execute/shared.ts +71 -2
- package/dist/templates/plan-and-execute/tools/plan_status.ts +18 -6
- package/dist/templates/plan-and-execute/tools/revise_plan.ts +18 -10
- package/dist/templates/plan-and-execute/tools/start_plan.ts +13 -2
- package/dist/templates/plan-and-execute/tools/work_next_step.ts +70 -22
- package/dist/templates/podcast-digest/agent.eval.test.ts +411 -0
- package/dist/templates/podcast-digest/agent.test.ts +717 -0
- package/dist/templates/podcast-digest/agent.ts +139 -0
- package/dist/templates/podcast-digest/client.tsx +154 -0
- package/dist/templates/podcast-digest/workflows/digest.ts +444 -0
- package/dist/templates/podcast-digest/workflows/feeds.ts +507 -0
- package/dist/templates/podcast-digest/workflows/slack.ts +88 -0
- package/dist/templates/recap-workflow/agent.eval.test.ts +366 -0
- package/dist/templates/recap-workflow/agent.test.ts +97 -94
- package/dist/templates/recap-workflow/tools/recap_progress.ts +9 -12
- package/dist/templates/recap-workflow/tools/recap_status.ts +8 -9
- package/dist/templates/recap-workflow/tools/request_recap.ts +2 -1
- package/dist/templates/recap-workflow/workflows/recap.ts +36 -40
- package/dist/templates/redline/agent.eval.test.ts +209 -0
- package/dist/templates/redline/agent.test.ts +17 -15
- package/dist/templates/redline/client.tsx +12 -12
- package/dist/templates/redline/workflows/redline.ts +19 -31
- package/dist/templates/research-workflow/agent.eval.test.ts +329 -0
- package/dist/templates/research-workflow/agent.test.ts +60 -59
- package/dist/templates/research-workflow/tools/research_progress.ts +9 -12
- package/dist/templates/research-workflow/tools/research_status.ts +9 -11
- package/dist/templates/research-workflow/workflows/research.ts +44 -61
- 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 +195 -112
- package/dist/templates/retail/agent.ts +35 -1
- package/dist/templates/retail/cancel.ts +116 -0
- package/dist/templates/retail/client.tsx +265 -117
- 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 +150 -7
- 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 +84 -15
- package/dist/templates/retail/store.ts +231 -48
- package/dist/templates/retail/swap.test.ts +4 -4
- package/dist/templates/retail/swap.ts +248 -15
- package/dist/templates/retail/system-prompt.md +69 -28
- package/dist/templates/retail/tools/cancel_change.ts +33 -0
- package/dist/templates/retail/tools/cancel_pending_order.ts +16 -60
- package/dist/templates/retail/tools/confirm_change.ts +42 -0
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +22 -67
- package/dist/templates/retail/tools/find_user_id_by_email.ts +8 -5
- package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +4 -5
- package/dist/templates/retail/tools/get_item_details.ts +3 -4
- package/dist/templates/retail/tools/get_order_details.ts +3 -4
- package/dist/templates/retail/tools/get_product_details.ts +3 -4
- package/dist/templates/retail/tools/get_user_details.ts +2 -2
- package/dist/templates/retail/tools/list_all_product_types.ts +5 -5
- package/dist/templates/retail/tools/modify_pending_order_address.ts +13 -25
- package/dist/templates/retail/tools/modify_pending_order_items.ts +23 -62
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +16 -78
- package/dist/templates/retail/tools/modify_user_address.ts +13 -16
- package/dist/templates/retail/tools/return_delivered_order_items.ts +17 -72
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +12 -6
- package/dist/templates/simple/agent.eval.test.ts +54 -0
- package/dist/templates/solo-rpg/agent.eval.test.ts +320 -0
- package/dist/templates/solo-rpg/agent.test.ts +227 -48
- package/dist/templates/solo-rpg/client.tsx +38 -37
- package/dist/templates/solo-rpg/shared.ts +145 -19
- package/dist/templates/solo-rpg/system-prompt.md +3 -2
- package/dist/templates/solo-rpg/tools/action_roll.ts +82 -63
- package/dist/templates/solo-rpg/tools/burn_momentum.ts +85 -50
- package/dist/templates/solo-rpg/tools/check_state.ts +24 -7
- package/dist/templates/solo-rpg/tools/load_game.ts +13 -1
- package/dist/templates/solo-rpg/tools/save_game.ts +16 -7
- package/dist/templates/solo-rpg/tools/setup_character.ts +22 -2
- package/dist/templates/solo-rpg/tools/update_state.ts +113 -100
- package/dist/templates/spoken-summary/agent.eval.test.ts +342 -0
- package/dist/templates/spoken-summary/agent.test.ts +68 -97
- package/dist/templates/spoken-summary/agent.ts +30 -17
- package/dist/templates/spoken-summary/client.tsx +10 -50
- package/dist/templates/spoken-summary/workflows/summarize.ts +17 -22
- package/dist/templates/spoken-summary/workflows/transcribe.ts +19 -26
- package/dist/templates/support-line/agent.eval.test.ts +273 -0
- package/dist/templates/support-line/agent.test.ts +11 -16
- package/dist/templates/support-line/agent.ts +1 -1
- package/dist/templates/support-line/client.tsx +9 -9
- package/dist/templates/support-line/nodes.ts +100 -0
- package/dist/templates/support-line/procedure.ts +407 -0
- package/dist/templates/support-line/system-prompt.md +2 -1
- package/dist/templates/support-line/tools/answer_question.ts +66 -4
- package/dist/templates/transcription-workflow/agent.eval.test.ts +234 -0
- package/dist/templates/transcription-workflow/agent.test.ts +94 -194
- package/dist/templates/transcription-workflow/agent.ts +1 -1
- package/dist/templates/transcription-workflow/client.tsx +17 -42
- package/dist/templates/transcription-workflow/workflows/batch.ts +19 -22
- package/dist/templates/transcription-workflow/workflows/normalize.ts +90 -186
- package/dist/templates/transcription-workflow/workflows/stitch.ts +0 -11
- package/dist/templates/transcription-workflow/workflows/stream.ts +4 -5
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +6 -6
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +32 -26
- package/dist/templates/transcription-workflow/workflows/wav.ts +0 -31
- package/dist/templates/travel-concierge/agent.eval.test.ts +323 -0
- package/dist/templates/travel-concierge/agent.test.ts +174 -43
- package/dist/templates/travel-concierge/agent.ts +18 -9
- package/dist/templates/travel-concierge/client.tsx +11 -23
- package/dist/templates/travel-concierge/routing.ts +34 -15
- package/dist/templates/travel-concierge/shared.ts +180 -12
- package/dist/templates/travel-concierge/system-prompt.md +7 -1
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +8 -5
- package/dist/templates/travel-concierge/tools/book_excursion.ts +8 -5
- package/dist/templates/travel-concierge/tools/book_hotel.ts +8 -5
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +8 -5
- 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 +8 -5
- 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-CGD4r8Kc.mjs → worker-bundler-CCVEDjm1.mjs} +241 -3
- package/dist/worker-bundler.mjs +1 -1
- package/dist/{workflow-CFpxOFfQ.mjs → workflow-BflATMmU.mjs} +47 -12
- package/dist/workflow-bundler.d.ts +6 -1
- package/dist/workflow.d.ts +1 -1
- package/package.json +5 -4
- package/dist/eject-31gjtaHF.mjs +0 -116
- package/dist/eject.d.ts +0 -60
- package/dist/templates/call-audit/workflows/temp-media.ts +0 -138
- package/dist/templates/infocom-adventure/tools/game_state_history.ts +0 -14
- package/dist/templates/support-line/graph.ts +0 -224
- package/dist/{_config-D_s09e7g.mjs → _config-CmJOFsAP.mjs} +1 -1
- /package/dist/templates/plan-and-execute/{graph.ts → procedure.ts} +0 -0
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
*
|
|
36
36
|
* ## Every stage REPORTS, and the report goes two places
|
|
37
37
|
*
|
|
38
|
-
* `report()` (`@alexkroman1/aai/
|
|
38
|
+
* `report()` (`@alexkroman1/aai/step`) writes to the run's own stream — which
|
|
39
39
|
* `research_progress` reads back down the phone and a page renders — and to the
|
|
40
40
|
* server log, with the attempt number appended past the first. A pass that is
|
|
41
41
|
* retrying and one that is working print the same sentences otherwise.
|
|
@@ -49,16 +49,10 @@
|
|
|
49
49
|
* researcher CONCLUDED, which is exactly what the step returns.
|
|
50
50
|
*/
|
|
51
51
|
|
|
52
|
-
import {
|
|
52
|
+
import { mapConcurrent, report } from "@alexkroman1/aai/step";
|
|
53
|
+
import { stepGenerateClassified, stepGenerateJsonClassified } from "@alexkroman1/aai/step-errors";
|
|
53
54
|
import { visitWebpage, webSearch } from "@alexkroman1/aai/tools";
|
|
54
|
-
import {
|
|
55
|
-
errorMessage,
|
|
56
|
-
isToolFailure,
|
|
57
|
-
mapInBatches,
|
|
58
|
-
report,
|
|
59
|
-
stepGenerate,
|
|
60
|
-
stepGenerateJson,
|
|
61
|
-
} from "@alexkroman1/aai/utils";
|
|
55
|
+
import { errorMessage, isToolFailure, plural } from "@alexkroman1/aai/utils";
|
|
62
56
|
import { sleep } from "workflow";
|
|
63
57
|
import { z } from "zod";
|
|
64
58
|
import {
|
|
@@ -108,9 +102,9 @@ export type Source = { title: string; url: string };
|
|
|
108
102
|
|
|
109
103
|
// ---- What each stage's model call has to come back as ------------------------
|
|
110
104
|
//
|
|
111
|
-
// `
|
|
112
|
-
// throw and therefore a retry — where the hand-rolled `askJson<T>()`
|
|
113
|
-
// replaces returned a value the compiler believed and nothing checked. They are
|
|
105
|
+
// `stepGenerateJsonClassified` validates against these, so a reply that missed
|
|
106
|
+
// is a plain throw and therefore a retry — where the hand-rolled `askJson<T>()`
|
|
107
|
+
// this replaces returned a value the compiler believed and nothing checked. They are
|
|
114
108
|
// deliberately LENIENT wherever the old hand-written coercion was: a model that
|
|
115
109
|
// put one number in an array of strings should cost that element, not the whole
|
|
116
110
|
// pass.
|
|
@@ -221,12 +215,14 @@ export async function researchFlow(input: { topic: string; requestedBy: string }
|
|
|
221
215
|
// failed angle fails the RUN: its finished siblings are already journaled, so
|
|
222
216
|
// the resume replays them for free and re-issues only what is missing, where
|
|
223
217
|
// catching here would file a report with a silent hole in it.
|
|
224
|
-
const first = await
|
|
218
|
+
const first = await mapConcurrent(angles, ANGLE_CONCURRENCY, (angle) =>
|
|
219
|
+
investigate(brief, angle),
|
|
220
|
+
);
|
|
225
221
|
|
|
226
222
|
// The supervisor's second look. Usually empty — a second wave costs the caller
|
|
227
223
|
// minutes, and the prompt says so.
|
|
228
224
|
const gaps = await findGaps(brief, first);
|
|
229
|
-
const second = await
|
|
225
|
+
const second = await mapConcurrent(gaps, ANGLE_CONCURRENCY, (angle) => investigate(brief, angle));
|
|
230
226
|
|
|
231
227
|
const notes = [...first, ...second];
|
|
232
228
|
const written = await writeReport(input.topic, brief, notes);
|
|
@@ -261,10 +257,9 @@ export async function writeBrief(topic: string): Promise<Brief> {
|
|
|
261
257
|
"use step";
|
|
262
258
|
|
|
263
259
|
await report(`Working out what "${topic}" is really asking.`);
|
|
264
|
-
const parsed = await
|
|
260
|
+
const parsed = await stepGenerateJsonClassified(
|
|
265
261
|
`Research request, as the caller said it: ${topic}`,
|
|
266
|
-
BRIEF_SYSTEM,
|
|
267
|
-
BriefReply,
|
|
262
|
+
{ system: BRIEF_SYSTEM, schema: BriefReply },
|
|
268
263
|
);
|
|
269
264
|
return { brief: parsed.brief || topic, criteria: parsed.criteria.slice(0, MAX_ANGLES) };
|
|
270
265
|
}
|
|
@@ -279,7 +274,10 @@ export async function writeBrief(topic: string): Promise<Brief> {
|
|
|
279
274
|
export async function planAngles(brief: Brief): Promise<string[]> {
|
|
280
275
|
"use step";
|
|
281
276
|
|
|
282
|
-
const parsed = await
|
|
277
|
+
const parsed = await stepGenerateJsonClassified(briefText(brief), {
|
|
278
|
+
system: PLAN_SYSTEM,
|
|
279
|
+
schema: AnglesReply,
|
|
280
|
+
});
|
|
283
281
|
const angles = parsed.angles.slice(0, MAX_ANGLES);
|
|
284
282
|
if (angles.length === 0) {
|
|
285
283
|
// Nothing to fan out over is a plan failure, not an empty result: the brief
|
|
@@ -287,7 +285,7 @@ export async function planAngles(brief: Brief): Promise<string[]> {
|
|
|
287
285
|
await report("No angles came back; researching the brief itself.");
|
|
288
286
|
return [brief.brief];
|
|
289
287
|
}
|
|
290
|
-
await report(`Researching ${angles.length}
|
|
288
|
+
await report(`Researching ${angles.length} ${plural(angles.length, "angle")}.`);
|
|
291
289
|
return angles;
|
|
292
290
|
}
|
|
293
291
|
|
|
@@ -341,16 +339,15 @@ export async function findGaps(brief: Brief, notes: readonly Note[]): Promise<st
|
|
|
341
339
|
"use step";
|
|
342
340
|
|
|
343
341
|
if (notes.length === 0) return [];
|
|
344
|
-
const parsed = await
|
|
342
|
+
const parsed = await stepGenerateJsonClassified(
|
|
345
343
|
`${briefText(brief)}\n\nWhat came back:\n${notes.map(noteText).join("\n\n")}`,
|
|
346
|
-
GAPS_SYSTEM,
|
|
347
|
-
AnglesReply,
|
|
344
|
+
{ system: GAPS_SYSTEM, schema: AnglesReply },
|
|
348
345
|
);
|
|
349
346
|
const gaps = parsed.angles.slice(0, MAX_ANGLES - 1);
|
|
350
347
|
await report(
|
|
351
348
|
gaps.length === 0
|
|
352
349
|
? "The brief is covered; writing it up."
|
|
353
|
-
: `Following up ${gaps.length}
|
|
350
|
+
: `Following up ${gaps.length} ${plural(gaps.length, "gap")}.`,
|
|
354
351
|
);
|
|
355
352
|
return gaps;
|
|
356
353
|
}
|
|
@@ -369,12 +366,14 @@ export async function writeReport(
|
|
|
369
366
|
): Promise<{ report: string; summary: string }> {
|
|
370
367
|
"use step";
|
|
371
368
|
|
|
372
|
-
await report(`Writing up ${notes.length}
|
|
373
|
-
const written = await
|
|
369
|
+
await report(`Writing up ${notes.length} ${plural(notes.length, "angle")}.`);
|
|
370
|
+
const written = await stepGenerateClassified(
|
|
374
371
|
`${briefText(brief)}\n\nFindings:\n${notes.map(noteText).join("\n\n")}`,
|
|
375
|
-
REPORT_SYSTEM,
|
|
372
|
+
{ system: REPORT_SYSTEM },
|
|
376
373
|
);
|
|
377
|
-
const summary = await
|
|
374
|
+
const summary = await stepGenerateClassified(`Topic: ${topic}\n\nReport:\n${written}`, {
|
|
375
|
+
system: BRIEF_SUMMARY_SYSTEM,
|
|
376
|
+
});
|
|
378
377
|
return { report: written, summary };
|
|
379
378
|
}
|
|
380
379
|
|
|
@@ -404,12 +403,11 @@ async function nextAction(
|
|
|
404
403
|
seen: readonly string[],
|
|
405
404
|
left: number,
|
|
406
405
|
): Promise<Action> {
|
|
407
|
-
return await
|
|
406
|
+
return await stepGenerateJsonClassified(
|
|
408
407
|
`${briefText(brief)}\n\nYour angle: ${angle}\n` +
|
|
409
408
|
`Actions left: ${left}\n\n` +
|
|
410
409
|
(seen.length === 0 ? "You have not looked at anything yet." : seen.join("\n\n")),
|
|
411
|
-
RESEARCH_SYSTEM,
|
|
412
|
-
ActionReply,
|
|
410
|
+
{ system: RESEARCH_SYSTEM, schema: ActionReply },
|
|
413
411
|
);
|
|
414
412
|
}
|
|
415
413
|
|
|
@@ -474,11 +472,10 @@ async function compress(angle: string, seen: readonly string[], sources: Source[
|
|
|
474
472
|
if (seen.length === 0) {
|
|
475
473
|
return { angle, findings: "Nothing was found on this angle.", sources: [] };
|
|
476
474
|
}
|
|
477
|
-
const parsed = await
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
);
|
|
475
|
+
const parsed = await stepGenerateJsonClassified(`Angle: ${angle}\n\n${seen.join("\n\n")}`, {
|
|
476
|
+
system: COMPRESS_SYSTEM,
|
|
477
|
+
schema: CompressReply,
|
|
478
|
+
});
|
|
482
479
|
return {
|
|
483
480
|
angle,
|
|
484
481
|
findings: parsed.findings ?? seen.join("\n\n"),
|
|
@@ -489,31 +486,17 @@ async function compress(angle: string, seen: readonly string[], sources: Source[
|
|
|
489
486
|
}
|
|
490
487
|
|
|
491
488
|
// ---- Model plumbing ---------------------------------------------------------
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
return await stepGenerate(prompt, { system }).catch(throwStepError);
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
/**
|
|
507
|
-
* The same call, for a stage whose reply is JSON of a known shape.
|
|
508
|
-
*
|
|
509
|
-
* `stepGenerateJson` owns the four things every such stage used to re-derive —
|
|
510
|
-
* unwrap the fence, parse, reject a non-object, check the shape — and throws
|
|
511
|
-
* PLAINLY when any of them misses, which is what makes a malformed reply a
|
|
512
|
-
* retry rather than a failure.
|
|
513
|
-
*/
|
|
514
|
-
async function askJson<S extends z.ZodType>(prompt: string, system: string, schema: S) {
|
|
515
|
-
return await stepGenerateJson(prompt, { system, schema }).catch(throwStepError);
|
|
516
|
-
}
|
|
489
|
+
//
|
|
490
|
+
// There is none left, and its absence is the point. This desk carried an `ask()`
|
|
491
|
+
// and an `askJson()` whose whole body was `.catch(throwStepError)`; the SDK's
|
|
492
|
+
// `stepGenerateClassified` and `stepGenerateJsonClassified`
|
|
493
|
+
// (`@alexkroman1/aai/step-errors`) ARE that call — the `/step` one with the
|
|
494
|
+
// gateway's verdict classified, so a terminal failure stays terminal and a rate
|
|
495
|
+
// limit becomes a `RetryableError` carrying the delay the gateway itself named.
|
|
496
|
+
// `stepGenerateJsonClassified` also owns the four things every JSON stage used
|
|
497
|
+
// to re-derive — unwrap the fence, parse, reject a non-object, check the shape —
|
|
498
|
+
// and throws PLAINLY when any of them misses, which is what makes a malformed
|
|
499
|
+
// reply a retry rather than a failure.
|
|
517
500
|
|
|
518
501
|
// ---- Pure helpers -----------------------------------------------------------
|
|
519
502
|
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Addresses: the shared schema fields, and the two address changes as
|
|
3
|
+
* plan/apply pairs (see `cancel.ts` for why every mutating action is split
|
|
4
|
+
* that way).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { isToolFailure, type ToolFailure } from "@alexkroman1/aai";
|
|
1
8
|
import { z } from "zod";
|
|
2
|
-
import
|
|
9
|
+
import { resolveOrder } from "./resolve.ts";
|
|
10
|
+
import type { Address, RetailState } from "./shared.ts";
|
|
11
|
+
import { requireOwnUser } from "./store.ts";
|
|
3
12
|
|
|
4
13
|
/** Spread into both address tools' schemas. Two hand-copied zod shapes is how
|
|
5
14
|
* the order address and the profile address drift apart. */
|
|
@@ -27,3 +36,80 @@ export function formatAddress(address: Address): string {
|
|
|
27
36
|
const lines = [address.address1, address.address2].filter(Boolean).join(", ");
|
|
28
37
|
return `${lines}, ${address.city} ${address.state} ${address.zip}, ${address.country}`;
|
|
29
38
|
}
|
|
39
|
+
|
|
40
|
+
// ─── One order's shipping address ────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export interface OrderAddressPlan {
|
|
43
|
+
readBack: string;
|
|
44
|
+
orderId: string;
|
|
45
|
+
address: Address;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function planOrderAddress(
|
|
49
|
+
state: RetailState,
|
|
50
|
+
spokenOrderId: string,
|
|
51
|
+
address: Address,
|
|
52
|
+
): OrderAddressPlan | ToolFailure {
|
|
53
|
+
const order = resolveOrder(state, spokenOrderId);
|
|
54
|
+
if (isToolFailure(order)) return order;
|
|
55
|
+
|
|
56
|
+
// Any pending variant is fine here — unlike cancel and modify-items, which
|
|
57
|
+
// require exactly 'pending'. Re-addressing a modified order is harmless.
|
|
58
|
+
if (!order.status.startsWith("pending")) {
|
|
59
|
+
return {
|
|
60
|
+
error: `Order ${order.order_id} is ${order.status}, and only a pending order's address can be changed.`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
readBack: `ship order ${order.order_id} to ${formatAddress(address)} instead`,
|
|
66
|
+
orderId: order.order_id,
|
|
67
|
+
address: toAddress(address),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function applyOrderAddress(state: RetailState, plan: OrderAddressPlan) {
|
|
72
|
+
const order = state.store.orders[plan.orderId];
|
|
73
|
+
if (order) order.address = plan.address;
|
|
74
|
+
return {
|
|
75
|
+
order_id: plan.orderId,
|
|
76
|
+
status: order?.status ?? "pending",
|
|
77
|
+
address: plan.address,
|
|
78
|
+
message: `Order ${plan.orderId} now ships to ${formatAddress(plan.address)}.`,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── The customer's default address ──────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
export interface UserAddressPlan {
|
|
85
|
+
readBack: string;
|
|
86
|
+
userId: string;
|
|
87
|
+
address: Address;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function planUserAddress(
|
|
91
|
+
state: RetailState,
|
|
92
|
+
userId: string,
|
|
93
|
+
address: Address,
|
|
94
|
+
): UserAddressPlan | ToolFailure {
|
|
95
|
+
const user = requireOwnUser(state, userId);
|
|
96
|
+
if (isToolFailure(user)) return user;
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
readBack:
|
|
100
|
+
`change your default address for future orders to ${formatAddress(address)} ` +
|
|
101
|
+
"(existing orders keep their own)",
|
|
102
|
+
userId: user.user_id,
|
|
103
|
+
address: toAddress(address),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function applyUserAddress(state: RetailState, plan: UserAddressPlan) {
|
|
108
|
+
const user = state.store.users[plan.userId];
|
|
109
|
+
if (user) user.address = plan.address;
|
|
110
|
+
return {
|
|
111
|
+
user_id: plan.userId,
|
|
112
|
+
address: plan.address,
|
|
113
|
+
message: `Default address updated to ${formatAddress(plan.address)}. Existing orders keep their own shipping addresses.`,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
import { withSystemPrompt } from "@alexkroman1/aai/manifest";
|
|
4
|
+
import type { SessionEvent } from "@alexkroman1/aai/protocol";
|
|
5
|
+
import { withDiscoveredTools } from "@alexkroman1/aai/testing";
|
|
6
|
+
// An EVAL: does this desk actually behave? Run it with `aai eval`.
|
|
7
|
+
//
|
|
8
|
+
// `agent.test.ts` drives each tool directly and asserts about its result;
|
|
9
|
+
// `registry.test.ts` asserts every tool's gate one at a time. Neither can say
|
|
10
|
+
// whether the AGENT — a model, reading this system prompt, holding these
|
|
11
|
+
// seventeen tools — puts them in the right ORDER. That is what this file is
|
|
12
|
+
// for, and the four things it asserts are the four this template's whole shape
|
|
13
|
+
// exists to guarantee:
|
|
14
|
+
//
|
|
15
|
+
// 1. nothing about an order is reachable before the caller is identified,
|
|
16
|
+
// 2. a change is STAGED and the store is untouched,
|
|
17
|
+
// 3. an explicit yes applies it, exactly once,
|
|
18
|
+
// 4. after a handoff the call is over and every tool refuses.
|
|
19
|
+
//
|
|
20
|
+
// Each one is a MECHANISM (a dialog gate, a plan/apply split, a terminal
|
|
21
|
+
// state), so each assertion reads the mechanism's own output — the SDK's
|
|
22
|
+
// refusal sentence, the tool result, and the projection the browser is sent —
|
|
23
|
+
// rather than judging the sentence the model chose to say.
|
|
24
|
+
//
|
|
25
|
+
// What no eval here can see: anything below the audio boundary. Whether a
|
|
26
|
+
// caller reading an order number in bursts lands as one turn is a property of
|
|
27
|
+
// endpointing, and these fake speech stages remove it.
|
|
28
|
+
import { type EvalSession, type EvalTurn, lastStateIn } from "@alexkroman1/aai-runtime/eval";
|
|
29
|
+
import { describeEval } from "@alexkroman1/aai-runtime/eval/vitest";
|
|
30
|
+
import { expect } from "vitest";
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
import authoredAgent from "./agent.ts";
|
|
33
|
+
import systemPrompt from "./system-prompt.md?raw";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The def a DEPLOYED agent runs: authored, plus what `tools/` declares, plus
|
|
37
|
+
* what `system-prompt.md` says.
|
|
38
|
+
*
|
|
39
|
+
* BOTH wrappers are load-bearing and neither is applied by `agent()` — they are
|
|
40
|
+
* applied by the BUILD (`aai build` enumerates `tools/` and resolves the prompt
|
|
41
|
+
* file), so an eval driving the raw default export would measure a
|
|
42
|
+
* seventeen-tool desk with no tools and the FRAMEWORK DEFAULT prompt. For this
|
|
43
|
+
* template that is the whole subject: the authenticate-first discipline, the
|
|
44
|
+
* three-step readback and the one-customer-per-call rule are all in that file.
|
|
45
|
+
*
|
|
46
|
+
* The glob is written here rather than reached for from a shared helper because
|
|
47
|
+
* this file SHIPS — see `registry.test.ts`.
|
|
48
|
+
*/
|
|
49
|
+
const retailAgent = withSystemPrompt(
|
|
50
|
+
withDiscoveredTools(authoredAgent, import.meta.glob("./tools/*.ts", { eager: true })),
|
|
51
|
+
systemPrompt,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
/** Harper Brown: exactly ONE pending order, which is what makes "cancel my
|
|
55
|
+
* pending order" unambiguous — `resolveOrder` refuses a reference that matches
|
|
56
|
+
* two, so a persona with two pending orders would put this eval's subject
|
|
57
|
+
* (the confirmation gate) behind a disambiguation turn. */
|
|
58
|
+
const CALLER_EMAIL = "harper.brown3965@example.com";
|
|
59
|
+
const PENDING_ORDER = "#W2273069";
|
|
60
|
+
|
|
61
|
+
/** The six tools that legitimately run before the caller is identified — the
|
|
62
|
+
* ones declaring `when: BEFORE_TRANSFER`. Anything else must refuse. */
|
|
63
|
+
const PUBLIC_TOOLS = new Set([
|
|
64
|
+
"find_user_id_by_email",
|
|
65
|
+
"find_user_id_by_name_zip",
|
|
66
|
+
"get_product_details",
|
|
67
|
+
"get_item_details",
|
|
68
|
+
"list_all_product_types",
|
|
69
|
+
"transfer_to_human_agents",
|
|
70
|
+
]);
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* What the BROWSER is sent, as this eval reads it.
|
|
74
|
+
*
|
|
75
|
+
* Parsed rather than cast: `state.updated` carries `unknown`, and a schema that
|
|
76
|
+
* stops matching is a loud failure naming the field, where a cast would hand
|
|
77
|
+
* the assertions `undefined` and fail three lines later. It names only the
|
|
78
|
+
* fields asserted below, so `storeView` may grow without touching this.
|
|
79
|
+
*/
|
|
80
|
+
const ProjectedStore = z.object({
|
|
81
|
+
customer: z.object({ userId: z.string() }).nullable(),
|
|
82
|
+
orders: z.array(z.object({ orderId: z.string(), status: z.string() })),
|
|
83
|
+
pending: z.object({ kind: z.string(), readBack: z.string() }).nullable(),
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The latest projection the session pushed, or undefined if it pushed none.
|
|
88
|
+
*
|
|
89
|
+
* `lastStateIn` is the SDK's reader; the schema is what is worth passing to it,
|
|
90
|
+
* for the reason above — a frame that stopped matching fails naming the field.
|
|
91
|
+
*/
|
|
92
|
+
const projection = (events: readonly SessionEvent[]) => lastStateIn(events, ProjectedStore);
|
|
93
|
+
|
|
94
|
+
/** The status the projection carries for `orderId` — the one fact "did anything
|
|
95
|
+
* change?" turns on. `undefined` covers both "no projection yet" and "not this
|
|
96
|
+
* customer's order", which are the same claim here: it was not touched. */
|
|
97
|
+
function statusOf(events: readonly SessionEvent[], orderId: string): string | undefined {
|
|
98
|
+
return projection(events)?.orders.find((o) => o.orderId === orderId)?.status;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The dialog gate's own refusal sentence, for the state it names.
|
|
103
|
+
*
|
|
104
|
+
* The character class absorbs the JSON escaping: a tool result reaches the
|
|
105
|
+
* event stream as a serialized string, so the state name arrives inside
|
|
106
|
+
* `\\"identifying\\"` rather than plain quotes.
|
|
107
|
+
*/
|
|
108
|
+
const refusalAt = (state: string) =>
|
|
109
|
+
new RegExp(`Not available yet: this conversation is at [\\\\"]*${state}`);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Drive a whole call, one caller line at a time, and hand back every turn.
|
|
113
|
+
*
|
|
114
|
+
* The cases below assert about the turn a MECHANISM fired in rather than about
|
|
115
|
+
* turn number two, because how many turns a desk takes to get there is the
|
|
116
|
+
* model's business and it really does vary: measured live, this agent reads the
|
|
117
|
+
* order back out of `get_order_details` and asks before it stages, so the
|
|
118
|
+
* staging call has landed in turn two, three and four across runs. A case
|
|
119
|
+
* pinned to a turn index is a flake with a misleading name.
|
|
120
|
+
*/
|
|
121
|
+
async function sayAll(session: EvalSession, lines: readonly string[]): Promise<EvalTurn[]> {
|
|
122
|
+
const turns: EvalTurn[] = [];
|
|
123
|
+
for (const line of lines) turns.push(await session.say(line));
|
|
124
|
+
return turns;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The turn a named tool was called in, if any. */
|
|
128
|
+
const turnCalling = (turns: readonly EvalTurn[], tool: string) =>
|
|
129
|
+
turns.find((t) => t.toolCalls.some((c) => c.name === tool));
|
|
130
|
+
|
|
131
|
+
/** One line the caller says to identify themselves, and the scripted tool call
|
|
132
|
+
* that answers it — the first turn of three of these four cases. */
|
|
133
|
+
const AUTH_TURN = [
|
|
134
|
+
{ tool: "find_user_id_by_email", args: { email: CALLER_EMAIL } },
|
|
135
|
+
"Thanks — I have your account here. What can I do for you?",
|
|
136
|
+
] as const;
|
|
137
|
+
|
|
138
|
+
/** The staged cancellation, as a scripted turn. */
|
|
139
|
+
const STAGE_TURN = [
|
|
140
|
+
{
|
|
141
|
+
tool: "cancel_pending_order",
|
|
142
|
+
args: { order_id: PENDING_ORDER, reason: "ordered by mistake" },
|
|
143
|
+
},
|
|
144
|
+
"So that would cancel your pending order and refund one thousand two hundred dollars " +
|
|
145
|
+
"and fifty-seven cents to your Visa. Is that right?",
|
|
146
|
+
] as const;
|
|
147
|
+
|
|
148
|
+
describeEval(retailAgent, (test) => {
|
|
149
|
+
test(
|
|
150
|
+
"will not touch an order before the caller is identified",
|
|
151
|
+
async ({ session }) => {
|
|
152
|
+
const turn = await session.say(
|
|
153
|
+
"Hi — cancel my pending order please, number W two two seven three zero six nine. " +
|
|
154
|
+
"I don't need it any more.",
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// The claim is about the GATE, so it is made of every gated call the
|
|
158
|
+
// model chose to make: each one has to have been refused, and the
|
|
159
|
+
// refusal has to say where the call actually is. A model that asks for
|
|
160
|
+
// the email instead makes no gated call at all, which satisfies this
|
|
161
|
+
// vacuously and is the same right answer — the two remaining assertions
|
|
162
|
+
// are what stop the case being vacuous overall.
|
|
163
|
+
for (const call of turn.toolCalls) {
|
|
164
|
+
if (PUBLIC_TOOLS.has(call.name)) continue;
|
|
165
|
+
expect(call.result).toMatch(refusalAt("identifying"));
|
|
166
|
+
}
|
|
167
|
+
// Nothing was authenticated, so the browser has been sent no customer —
|
|
168
|
+
// which is also the projection's security claim.
|
|
169
|
+
expect(projection(session.events())?.customer ?? null).toBeNull();
|
|
170
|
+
// And it asked for the one thing it needs.
|
|
171
|
+
expect(turn.text).toMatch(/email|account|name|zip/i);
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
stubReply: [
|
|
175
|
+
{
|
|
176
|
+
tool: "cancel_pending_order",
|
|
177
|
+
args: { order_id: PENDING_ORDER, reason: "no longer needed" },
|
|
178
|
+
},
|
|
179
|
+
"Before I can look anything up I'll need to find your account — what's the email on it?",
|
|
180
|
+
],
|
|
181
|
+
},
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
test(
|
|
185
|
+
"stages a cancellation and changes nothing until the caller says yes",
|
|
186
|
+
async ({ session }) => {
|
|
187
|
+
// FIVE lines, because how many turns this desk takes to STAGE is its own
|
|
188
|
+
// business and measured live it varies by two: reading the order back out
|
|
189
|
+
// of `get_order_details` and asking "does that sound right?" is a whole
|
|
190
|
+
// turn, and whether it spends one is not something an eval should pin.
|
|
191
|
+
// Every assertion below is about the turn the staging landed in, so a
|
|
192
|
+
// later apply cannot affect any of them.
|
|
193
|
+
const turns = await sayAll(session, [
|
|
194
|
+
`My email is ${CALLER_EMAIL}.`,
|
|
195
|
+
"I'd like to cancel my pending order — I ordered it by mistake.",
|
|
196
|
+
"Yes, please go ahead and cancel it.",
|
|
197
|
+
"Yes — I'm sure. Cancel it.",
|
|
198
|
+
"Yes. Cancel it, please.",
|
|
199
|
+
]);
|
|
200
|
+
|
|
201
|
+
const staging = turnCalling(turns, "cancel_pending_order");
|
|
202
|
+
const staged = staging?.toolCalls.find((c) => c.name === "cancel_pending_order");
|
|
203
|
+
expect(staged?.result).toMatch(/NOTHING HAS CHANGED YET/);
|
|
204
|
+
// The gate is a POSITION, and this is it moving: the tool reported the
|
|
205
|
+
// state it landed in, which is the only state `confirm_change` is legal
|
|
206
|
+
// in and is reachable only by staging.
|
|
207
|
+
expect(staged?.result).toMatch(/serving\.awaitingConfirmation/);
|
|
208
|
+
// A change cannot be described and applied in the same turn. This is the
|
|
209
|
+
// property the prose in the system prompt could never have.
|
|
210
|
+
expect(staging?.toolCalls.map((c) => c.name)).not.toContain("confirm_change");
|
|
211
|
+
// And after the turn that staged it, the store really is untouched — read
|
|
212
|
+
// off the projection the BROWSER was sent in that same turn.
|
|
213
|
+
expect(statusOf(staging?.events ?? [], PENDING_ORDER)).toBe("pending");
|
|
214
|
+
expect(projection(staging?.events ?? [])?.pending?.kind).toBe("cancel_pending_order");
|
|
215
|
+
// Step 2 of the policy: read it back and ask.
|
|
216
|
+
expect(staging?.text).toMatch(/\?/);
|
|
217
|
+
},
|
|
218
|
+
{ stubReply: [...AUTH_TURN, ...STAGE_TURN, "Cancelling it now — one moment."] },
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
test(
|
|
222
|
+
"applies the staged change on an explicit yes, exactly once",
|
|
223
|
+
async ({ session }) => {
|
|
224
|
+
// Five lines, three of them a yes: which turn the desk applies the change
|
|
225
|
+
// in is its own business, and saying yes repeatedly is what makes
|
|
226
|
+
// "exactly once" below a claim about the MECHANISM rather than about the
|
|
227
|
+
// model's pacing.
|
|
228
|
+
await sayAll(session, [
|
|
229
|
+
`My email is ${CALLER_EMAIL}.`,
|
|
230
|
+
"Please cancel my pending order — I ordered it by mistake.",
|
|
231
|
+
"Yes, that's right, go ahead.",
|
|
232
|
+
"Yes — confirm it now, please.",
|
|
233
|
+
"Yes. Confirm it.",
|
|
234
|
+
]);
|
|
235
|
+
|
|
236
|
+
const confirms = session.toolCalls().filter((c) => c.name === "confirm_change");
|
|
237
|
+
const applied = confirms.filter((c) => c.result?.includes('"status":"cancelled"'));
|
|
238
|
+
// ONE apply for the whole call, however many times the caller said yes.
|
|
239
|
+
// Measured live, a second yes really does produce a second
|
|
240
|
+
// `confirm_change` — and it is REFUSED, because applying cleared
|
|
241
|
+
// `pending` and sent `SETTLED`, and `awaitingConfirmation` is reachable
|
|
242
|
+
// only by staging something new. That is the gate doing the one job a
|
|
243
|
+
// prompt could not: a repeated yes cannot cancel an order twice.
|
|
244
|
+
expect(applied).toHaveLength(1);
|
|
245
|
+
for (const extra of confirms.filter((c) => c !== applied[0])) {
|
|
246
|
+
expect(extra.result).toMatch(/Not available yet/);
|
|
247
|
+
}
|
|
248
|
+
// And it came after the stage, never instead of it.
|
|
249
|
+
const names = session.toolCalls().map((c) => c.name);
|
|
250
|
+
expect(names.indexOf("cancel_pending_order")).toBeGreaterThanOrEqual(0);
|
|
251
|
+
expect(names.indexOf("confirm_change")).toBeGreaterThan(
|
|
252
|
+
names.indexOf("cancel_pending_order"),
|
|
253
|
+
);
|
|
254
|
+
// The store moved, and the staged change is gone with it.
|
|
255
|
+
expect(statusOf(session.events(), PENDING_ORDER)).toBe("cancelled");
|
|
256
|
+
expect(projection(session.events())?.pending).toBeNull();
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
stubReply: [
|
|
260
|
+
...AUTH_TURN,
|
|
261
|
+
...STAGE_TURN,
|
|
262
|
+
{ tool: "confirm_change", args: {} },
|
|
263
|
+
"That's cancelled, and the refund is on its way to your Visa.",
|
|
264
|
+
],
|
|
265
|
+
},
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
test(
|
|
269
|
+
"hands the call to a human and then refuses everything, including the order",
|
|
270
|
+
async ({ session }) => {
|
|
271
|
+
await session.say(`My email is ${CALLER_EMAIL}.`);
|
|
272
|
+
const handoff = await session.say(
|
|
273
|
+
"This isn't working for me — I want to speak to a real person.",
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
const transfer = handoff.toolCalls.find((c) => c.name === "transfer_to_human_agents");
|
|
277
|
+
// Named first, and with a message: a live model that answers the request
|
|
278
|
+
// with a question instead of the tool leaves `transfer` undefined, and
|
|
279
|
+
// `.toMatch()` on it reports only "expected a string, got undefined" —
|
|
280
|
+
// which says nothing about what the desk actually did.
|
|
281
|
+
expect(
|
|
282
|
+
transfer,
|
|
283
|
+
`tools called: [${handoff.toolCalls.map((c) => c.name).join(", ")}]; said: ${handoff.text}`,
|
|
284
|
+
).toBeDefined();
|
|
285
|
+
// The terminal state is what makes "say nothing else after this" a
|
|
286
|
+
// property of the agent rather than a line in its prompt: `done` is the
|
|
287
|
+
// flow saying there is nowhere left to go.
|
|
288
|
+
expect(transfer?.result).toMatch(/"state":"transferred"/);
|
|
289
|
+
expect(transfer?.result).toMatch(/"done":true/);
|
|
290
|
+
|
|
291
|
+
const after = await session.say("Actually, before you go — just cancel my pending order.");
|
|
292
|
+
for (const call of after.toolCalls) {
|
|
293
|
+
expect(call.result).toMatch(refusalAt("transferred"));
|
|
294
|
+
}
|
|
295
|
+
// Which is the point: the order the caller asked about is untouched.
|
|
296
|
+
expect(statusOf(session.events(), PENDING_ORDER)).toBe("pending");
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
stubReply: [
|
|
300
|
+
...AUTH_TURN,
|
|
301
|
+
{
|
|
302
|
+
tool: "transfer_to_human_agents",
|
|
303
|
+
args: { summary: "Caller asked for a human agent." },
|
|
304
|
+
},
|
|
305
|
+
"You are being transferred to a human agent. Please hold on.",
|
|
306
|
+
{
|
|
307
|
+
tool: "cancel_pending_order",
|
|
308
|
+
args: { order_id: PENDING_ORDER, reason: "no longer needed" },
|
|
309
|
+
},
|
|
310
|
+
"You are being transferred to a human agent. Please hold on.",
|
|
311
|
+
],
|
|
312
|
+
},
|
|
313
|
+
);
|
|
314
|
+
});
|