@alexkroman1/aai-cli 5.14.0 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{_agent-C8Jk-ZcP.mjs → _agent-vGW5SBnX.mjs} +23 -5
- package/dist/_agent.d.ts +18 -0
- package/dist/{_api-client-BX8Qv51n.mjs → _api-client-LgLksMhN.mjs} +45 -8
- package/dist/_api-client.d.ts +39 -0
- package/dist/{_bundler-BuIyDdvs.mjs → _bundler-BapCUUJN.mjs} +42 -14
- package/dist/_bundler.d.ts +43 -5
- package/dist/_cli-common.d.ts +67 -3
- package/dist/{_config-CzwAWOWr.mjs → _config-D_s09e7g.mjs} +36 -18
- package/dist/_deploy.d.ts +2 -5
- package/dist/_dev-restart.d.ts +6 -1
- package/dist/{_dev-server-Ch7K8e8i.mjs → _dev-server-DQ1t9sbw.mjs} +94 -38
- package/dist/_dev-server.d.ts +30 -2
- package/dist/_e2e-test-utils.d.ts +80 -0
- package/dist/_fault-mode.d.ts +175 -0
- package/dist/{_init-CbkrE2wq.mjs → _init-CpISqoR8.mjs} +3 -3
- package/dist/{_output-CC300DzW.mjs → _output-CKkmWs7i.mjs} +8 -1
- package/dist/_output.d.ts +9 -2
- package/dist/{_server-common-61fL__39.mjs → _server-common-6e0QI4mq.mjs} +1 -1
- package/dist/{_slug-api-D30zPmsn.mjs → _slug-api-D2heILuC.mjs} +2 -2
- package/dist/{_studio-D2ciIhbi.mjs → _studio-zAJycZ8j.mjs} +3 -3
- package/dist/{_templates-B1kt3BGt.mjs → _templates-iDGjGiPf.mjs} +4 -6
- package/dist/{_typecheck-gate-4v9UBZHh.mjs → _typecheck-gate-BXvUNnfs.mjs} +1 -1
- package/dist/{_utils-0DHbcqUb.mjs → _utils-B8QmtFhK.mjs} +21 -7
- package/dist/_utils.d.ts +18 -2
- package/dist/{_vite-env-Dg_QlVv0.mjs → _vite-env-BNveawd1.mjs} +31 -1
- package/dist/_vite-env.d.ts +30 -0
- package/dist/build-BO1Ni6oJ.mjs +62 -0
- package/dist/build.d.ts +19 -0
- package/dist/cli-workflow.d.ts +10 -0
- package/dist/cli.d.ts +1 -2
- package/dist/cli.mjs +406 -224
- package/dist/{client-bundler-C9qXL8A2.mjs → client-bundler-B8Q9F8gJ.mjs} +3 -22
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-DCRX3d96.mjs → delete-4I0uNR4a.mjs} +6 -7
- package/dist/delete.d.ts +2 -8
- package/dist/{deploy-DjsEV-40.mjs → deploy-DJ3HRcia.mjs} +13 -14
- package/dist/{dev-CvPH5dbF.mjs → dev-C6SWaAZP.mjs} +3 -3
- package/dist/{eject-LgBRfnbH.mjs → eject-Zrzk5KV3.mjs} +41 -15
- package/dist/eject.d.ts +30 -3
- package/dist/{init-BppxMR8r.mjs → init-BTRGiPQr.mjs} +61 -26
- package/dist/init.d.ts +11 -0
- package/dist/{login-BQQaxJhL.mjs → login-k0Z3VQu9.mjs} +5 -5
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/CLAUDE.md +498 -91
- package/dist/scaffold/package.json +11 -8
- package/dist/scaffold/pnpm-workspace.yaml +8 -0
- package/dist/scaffold/server.mjs +53 -57
- package/dist/{secret-Ds9dvqAj.mjs → secret-DzSQx1Ds.mjs} +6 -3
- package/dist/{storage-DbrQSHFk.mjs → storage-C-Do0mIA.mjs} +18 -6
- package/dist/{studio-GSFd4s5O.mjs → studio-BGVmCA2G.mjs} +10 -10
- package/dist/templates/code-interpreter/agent.ts +0 -2
- package/dist/templates/dispatch-center/agent.test.ts +17 -19
- package/dist/templates/dispatch-center/agent.ts +2 -29
- package/dist/templates/dispatch-center/client.tsx +33 -16
- package/dist/templates/dispatch-center/shared.ts +53 -19
- package/dist/templates/dispatch-center/tools/incident_add_note.ts +12 -14
- package/dist/templates/dispatch-center/tools/incident_create.ts +41 -44
- package/dist/templates/dispatch-center/tools/incident_escalate.ts +59 -61
- package/dist/templates/dispatch-center/tools/incident_get.ts +3 -4
- package/dist/templates/dispatch-center/tools/incident_triage.ts +43 -45
- package/dist/templates/dispatch-center/tools/incident_update_status.ts +39 -41
- package/dist/templates/dispatch-center/tools/ops_dashboard.ts +2 -5
- package/dist/templates/dispatch-center/tools/ops_protocols.ts +1 -1
- package/dist/templates/dispatch-center/tools/ops_run_scenario.ts +20 -23
- package/dist/templates/dispatch-center/tools/resources_dispatch.ts +52 -54
- package/dist/templates/dispatch-center/tools/resources_get_available.ts +2 -4
- package/dist/templates/dispatch-center/tools/resources_update_status.ts +39 -42
- package/dist/templates/embedded-assets/agent.test.ts +83 -0
- package/dist/templates/embedded-assets/agent.ts +1 -48
- package/dist/templates/embedded-assets/shared.ts +17 -0
- package/dist/templates/embedded-assets/system-prompt.md +13 -0
- package/dist/templates/embedded-assets/tools/list_topics.ts +9 -0
- package/dist/templates/embedded-assets/tools/search_knowledge.ts +26 -0
- package/dist/templates/health-assistant/agent.test.ts +169 -0
- package/dist/templates/health-assistant/agent.ts +1 -166
- package/dist/templates/health-assistant/fda.ts +100 -0
- package/dist/templates/health-assistant/tools/check_drug_interaction.ts +59 -0
- package/dist/templates/health-assistant/tools/medication_lookup.ts +31 -0
- package/dist/templates/infocom-adventure/agent.test.ts +205 -0
- package/dist/templates/infocom-adventure/agent.ts +3 -106
- package/dist/templates/infocom-adventure/client.tsx +28 -12
- package/dist/templates/infocom-adventure/shared.ts +28 -11
- package/dist/templates/infocom-adventure/tools/game_state_drop.ts +13 -0
- package/dist/templates/infocom-adventure/tools/game_state_flag.ts +13 -0
- package/dist/templates/infocom-adventure/tools/game_state_get.ts +16 -0
- package/dist/templates/infocom-adventure/tools/game_state_history.ts +14 -0
- package/dist/templates/infocom-adventure/tools/game_state_move.ts +14 -0
- package/dist/templates/infocom-adventure/tools/game_state_restart.ts +13 -0
- package/dist/templates/infocom-adventure/tools/game_state_score.ts +13 -0
- package/dist/templates/infocom-adventure/tools/game_state_take.ts +13 -0
- package/dist/templates/link-digest/agent.test.ts +223 -0
- package/dist/templates/link-digest/agent.ts +79 -0
- package/dist/templates/link-digest/client.tsx +146 -0
- package/dist/templates/link-digest/workflows/digest.ts +248 -0
- package/dist/templates/math-buddy/agent.ts +0 -2
- package/dist/templates/night-owl/agent.test.ts +80 -0
- package/dist/templates/night-owl/agent.ts +1 -68
- package/dist/templates/night-owl/system-prompt.md +10 -0
- package/dist/templates/night-owl/tools/recommend.ts +64 -0
- package/dist/templates/personal-finance/agent.ts +0 -2
- package/dist/templates/pizza-ordering/agent.test.ts +52 -47
- package/dist/templates/pizza-ordering/agent.ts +9 -158
- package/dist/templates/pizza-ordering/shared.ts +35 -12
- package/dist/templates/pizza-ordering/tools/add_pizza.ts +31 -0
- package/dist/templates/pizza-ordering/tools/place_order.ts +22 -0
- package/dist/templates/pizza-ordering/tools/remove_pizza.ts +22 -0
- package/dist/templates/pizza-ordering/tools/set_customer_name.ts +13 -0
- package/dist/templates/pizza-ordering/tools/update_pizza.ts +28 -0
- package/dist/templates/pizza-ordering/tools/view_order.ts +21 -0
- package/dist/templates/plan-and-execute/agent.test.ts +411 -0
- package/dist/templates/plan-and-execute/agent.ts +23 -0
- package/dist/templates/plan-and-execute/client.tsx +115 -0
- package/dist/templates/plan-and-execute/graph.ts +178 -0
- package/dist/templates/plan-and-execute/prompts.ts +110 -0
- package/dist/templates/plan-and-execute/shared.ts +161 -0
- package/dist/templates/plan-and-execute/system-prompt.md +18 -0
- package/dist/templates/plan-and-execute/tools/plan_status.ts +20 -0
- package/dist/templates/plan-and-execute/tools/revise_plan.ts +63 -0
- package/dist/templates/plan-and-execute/tools/start_plan.ts +51 -0
- package/dist/templates/plan-and-execute/tools/work_next_step.ts +101 -0
- package/dist/templates/recap-workflow/agent.test.ts +762 -0
- package/dist/templates/recap-workflow/agent.ts +95 -0
- package/dist/templates/recap-workflow/shared.ts +43 -0
- package/dist/templates/recap-workflow/tools/cancel_recap.ts +23 -0
- package/dist/templates/recap-workflow/tools/keep_transcript.ts +31 -0
- package/dist/templates/recap-workflow/tools/recap_progress.ts +27 -0
- package/dist/templates/recap-workflow/tools/recap_status.ts +46 -0
- package/dist/templates/recap-workflow/tools/request_recap.ts +43 -0
- package/dist/templates/recap-workflow/workflows/recap.ts +544 -0
- package/dist/templates/recap-workflow/workflows/tokens.ts +26 -0
- package/dist/templates/redline/agent.test.ts +222 -0
- package/dist/templates/redline/agent.ts +89 -0
- package/dist/templates/redline/client.tsx +184 -0
- package/dist/templates/redline/workflows/prompts.ts +68 -0
- package/dist/templates/redline/workflows/redline.ts +258 -0
- package/dist/templates/research-workflow/agent.test.ts +525 -0
- package/dist/templates/research-workflow/agent.ts +94 -0
- package/dist/templates/research-workflow/shared.ts +35 -0
- package/dist/templates/research-workflow/tools/file_it_now.ts +22 -0
- package/dist/templates/research-workflow/tools/request_research.ts +33 -0
- package/dist/templates/research-workflow/tools/research_progress.ts +26 -0
- package/dist/templates/research-workflow/tools/research_status.ts +40 -0
- package/dist/templates/research-workflow/workflows/prompts.ts +143 -0
- package/dist/templates/research-workflow/workflows/research.ts +558 -0
- package/dist/templates/retail/agent.test.ts +236 -172
- package/dist/templates/retail/agent.ts +5 -42
- package/dist/templates/retail/client.tsx +33 -11
- package/dist/templates/retail/registry.test.ts +31 -16
- package/dist/templates/retail/resolve.ts +28 -64
- package/dist/templates/retail/shared.test.ts +4 -3
- package/dist/templates/retail/shared.ts +24 -7
- package/dist/templates/retail/store.test.ts +12 -10
- package/dist/templates/retail/store.ts +69 -30
- package/dist/templates/retail/tools/cancel_pending_order.ts +4 -9
- package/dist/templates/retail/tools/exchange_delivered_order_items.ts +22 -11
- package/dist/templates/retail/tools/find_user_id_by_email.ts +5 -9
- package/dist/templates/retail/tools/find_user_id_by_name_zip.ts +5 -7
- package/dist/templates/retail/tools/get_item_details.ts +5 -7
- package/dist/templates/retail/tools/get_order_details.ts +5 -7
- package/dist/templates/retail/tools/get_product_details.ts +4 -7
- package/dist/templates/retail/tools/get_user_details.ts +4 -7
- package/dist/templates/retail/tools/list_all_product_types.ts +5 -7
- package/dist/templates/retail/tools/modify_pending_order_address.ts +4 -9
- package/dist/templates/retail/tools/modify_pending_order_items.ts +4 -16
- package/dist/templates/retail/tools/modify_pending_order_payment.ts +3 -9
- package/dist/templates/retail/tools/modify_user_address.ts +4 -9
- package/dist/templates/retail/tools/return_delivered_order_items.ts +3 -9
- package/dist/templates/retail/tools/transfer_to_human_agents.ts +1 -1
- package/dist/templates/solo-rpg/agent.test.ts +152 -29
- package/dist/templates/solo-rpg/agent.ts +3 -21
- package/dist/templates/solo-rpg/client.tsx +19 -6
- package/dist/templates/solo-rpg/shared.ts +19 -11
- package/dist/templates/solo-rpg/tools/action_roll.ts +2 -4
- package/dist/templates/solo-rpg/tools/burn_momentum.ts +2 -4
- package/dist/templates/solo-rpg/tools/check_state.ts +6 -6
- package/dist/templates/solo-rpg/tools/load_game.ts +1 -1
- package/dist/templates/solo-rpg/tools/oracle.ts +18 -11
- package/dist/templates/solo-rpg/tools/save_game.ts +10 -7
- package/dist/templates/solo-rpg/tools/setup_character.ts +1 -1
- package/dist/templates/solo-rpg/tools/update_state.ts +3 -4
- package/dist/templates/support-line/agent.test.ts +343 -0
- package/dist/templates/support-line/agent.ts +27 -0
- package/dist/templates/support-line/client.tsx +122 -0
- package/dist/templates/support-line/graph.ts +224 -0
- package/dist/templates/support-line/knowledge.json +65 -0
- package/dist/templates/support-line/prompts.ts +95 -0
- package/dist/templates/support-line/shared.ts +240 -0
- package/dist/templates/support-line/system-prompt.md +17 -0
- package/dist/templates/support-line/tools/answer_question.ts +72 -0
- package/dist/templates/support-line/tools/list_topics.ts +22 -0
- package/dist/templates/support-line/tools/log_ticket.ts +30 -0
- package/dist/templates/transcription-workflow/agent.test.ts +563 -0
- package/dist/templates/transcription-workflow/agent.ts +92 -0
- package/dist/templates/transcription-workflow/client.tsx +234 -0
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +416 -0
- package/dist/templates/transcription-workflow/workflows/wav.ts +314 -0
- package/dist/templates/travel-concierge/agent.test.ts +338 -0
- package/dist/templates/travel-concierge/agent.ts +29 -0
- package/dist/templates/travel-concierge/client.tsx +161 -0
- package/dist/templates/travel-concierge/routing.ts +138 -0
- package/dist/templates/travel-concierge/shared.ts +547 -0
- package/dist/templates/travel-concierge/system-prompt.md +24 -0
- package/dist/templates/travel-concierge/tools/book_car_rental.ts +20 -0
- package/dist/templates/travel-concierge/tools/book_excursion.ts +18 -0
- package/dist/templates/travel-concierge/tools/book_hotel.ts +20 -0
- package/dist/templates/travel-concierge/tools/cancel_action.ts +7 -0
- package/dist/templates/travel-concierge/tools/cancel_ticket.ts +16 -0
- package/dist/templates/travel-concierge/tools/complete_or_escalate.ts +7 -0
- package/dist/templates/travel-concierge/tools/confirm_action.ts +7 -0
- package/dist/templates/travel-concierge/tools/lookup_booking.ts +35 -0
- package/dist/templates/travel-concierge/tools/search_car_rentals.ts +33 -0
- package/dist/templates/travel-concierge/tools/search_excursions.ts +37 -0
- package/dist/templates/travel-concierge/tools/search_flights.ts +43 -0
- package/dist/templates/travel-concierge/tools/search_hotels.ts +36 -0
- package/dist/templates/travel-concierge/tools/to_car_rental_assistant.ts +11 -0
- package/dist/templates/travel-concierge/tools/to_excursion_assistant.ts +11 -0
- package/dist/templates/travel-concierge/tools/to_flight_assistant.ts +11 -0
- package/dist/templates/travel-concierge/tools/to_hotel_assistant.ts +11 -0
- package/dist/templates/travel-concierge/tools/update_ticket.ts +22 -0
- package/dist/templates/web-researcher/agent.ts +0 -2
- package/dist/templates/web-researcher/system-prompt.md +10 -0
- package/dist/{test-2LWBcXvY.mjs → test-B6ZBxSpk.mjs} +2 -2
- package/dist/typecheck.mjs +17 -15
- package/dist/worker-bundler-CIdDhZ0c.mjs +398 -0
- package/dist/worker-bundler.d.ts +14 -0
- package/dist/worker-bundler.mjs +1 -108
- package/dist/workflow-B-O97AqI.mjs +125 -0
- package/dist/workflow-bundler.d.ts +133 -0
- package/dist/workflow.d.ts +67 -0
- package/package.json +13 -9
- package/dist/build-DQJ1ZSm9.mjs +0 -39
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* The pure half of the transcription desk: reading a WAV header, deciding where
|
|
4
|
+
* to cut, and writing a header back over each cut.
|
|
5
|
+
*
|
|
6
|
+
* No directive in this file, and that is legal — the Workflow DevKit's builder
|
|
7
|
+
* scans `workflows/` and transforms only what carries a `"use workflow"` /
|
|
8
|
+
* `"use step"` body, so an ordinary module can sit beside them. It is a separate
|
|
9
|
+
* file because everything here is a pure function of a journaled value, which is
|
|
10
|
+
* what makes it testable without a run at all: `transcribe.ts`'s spec drives
|
|
11
|
+
* these directly.
|
|
12
|
+
*
|
|
13
|
+
* ## Why the chunking is here rather than at the provider
|
|
14
|
+
*
|
|
15
|
+
* AssemblyAI's SYNC endpoint answers in the request — no job id, no polling, no
|
|
16
|
+
* callback — and pays for that with a hard **120-second, 40 MB** limit per
|
|
17
|
+
* request. A two-hour recording is therefore not one call, it is sixty; and the
|
|
18
|
+
* splitting has to happen somewhere that can address the audio by BYTE RANGE,
|
|
19
|
+
* because re-downloading a 700 MB recording once per chunk is the obvious
|
|
20
|
+
* implementation and the wrong one.
|
|
21
|
+
*
|
|
22
|
+
* A linear-PCM WAV is the format that makes that possible without a decoder:
|
|
23
|
+
* every sample is the same size, so a byte offset IS a timestamp
|
|
24
|
+
* (`offset / (sampleRate * blockAlign)`) and a cut at any frame boundary is a
|
|
25
|
+
* clean cut. That is the whole reason this template asks for WAV and refuses
|
|
26
|
+
* anything else by name rather than trying to be clever — an MP3 or an M4A frame
|
|
27
|
+
* boundary cannot be found by arithmetic, and finding it means shipping a
|
|
28
|
+
* decoder into a step.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/** What the sync endpoint will accept in one request. */
|
|
32
|
+
export const MAX_SEGMENT_SECONDS = 120;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Seconds of audio per request, with headroom under the cap.
|
|
36
|
+
*
|
|
37
|
+
* Not `MAX_SEGMENT_SECONDS`: the overlap below is added on top of this, and a
|
|
38
|
+
* segment that lands one frame over the limit is a 413 rather than a shorter
|
|
39
|
+
* transcript.
|
|
40
|
+
*/
|
|
41
|
+
export const SEGMENT_SECONDS = 90;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Seconds each segment repeats from the one before it.
|
|
45
|
+
*
|
|
46
|
+
* A cut lands wherever the arithmetic puts it, which is usually mid-word: the
|
|
47
|
+
* decoder on either side then hears half a word and reports something plausible
|
|
48
|
+
* and wrong. Overlapping the cut means both sides hear the whole word, and
|
|
49
|
+
* `stitchTranscript` drops the duplicate. The cost is 2 seconds of audio
|
|
50
|
+
* transcribed twice per segment — ~2% at `SEGMENT_SECONDS`.
|
|
51
|
+
*/
|
|
52
|
+
export const SEGMENT_OVERLAP_SECONDS = 2;
|
|
53
|
+
|
|
54
|
+
/** The sync endpoint's payload cap, which a very high sample rate can reach first. */
|
|
55
|
+
export const MAX_SEGMENT_BYTES = 40 * 1024 * 1024;
|
|
56
|
+
|
|
57
|
+
/** The sync endpoint refuses audio shorter than this. */
|
|
58
|
+
export const MIN_SEGMENT_MS = 80;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The largest `bytesPerSecond` this desk will cut, and the reason it is checked
|
|
62
|
+
* at PARSE time rather than at cut time.
|
|
63
|
+
*
|
|
64
|
+
* `planSegments` divides by a format's bytes-per-second and then subtracts the
|
|
65
|
+
* overlap from the byte cap, so two declared formats make its loop never
|
|
66
|
+
* advance — and this is the one workflow app that takes an arbitrary uploaded
|
|
67
|
+
* file over a public form, so both are reachable by anyone with the URL:
|
|
68
|
+
*
|
|
69
|
+
* - a **zero** sample rate (or zero channels/bits, which
|
|
70
|
+
* {@link blockAlign} already catches) makes `stride` zero, so `start +=
|
|
71
|
+
* stride` is a no-op and the loop pushes a `Segment` per iteration until the
|
|
72
|
+
* process dies of memory;
|
|
73
|
+
* - a **huge** one — `sampleRate` is a `uint32` and `channels`/`bitsPerSample`
|
|
74
|
+
* are `uint16`, so a header can declare gigabytes a second — makes the
|
|
75
|
+
* overlap alone exceed {@link MAX_SEGMENT_BYTES}, leaving `stride` NEGATIVE
|
|
76
|
+
* and the loop walking backwards forever.
|
|
77
|
+
*
|
|
78
|
+
* Both are pure CPU with no `await` in them, so no `AbortSignal.timeout` and no
|
|
79
|
+
* step retry budget can interrupt one: the guard has to come before the loop.
|
|
80
|
+
* Requiring a whole second of audio to fit alongside the overlap is the bound
|
|
81
|
+
* that keeps the stride positive with room to spare; 48 kHz 24-bit stereo is
|
|
82
|
+
* 288 kB/s, four orders of magnitude under it.
|
|
83
|
+
*/
|
|
84
|
+
export const MAX_BYTES_PER_SECOND = MAX_SEGMENT_BYTES / (SEGMENT_OVERLAP_SECONDS + 1);
|
|
85
|
+
|
|
86
|
+
/** A parsed linear-PCM WAV header, plus where its samples live. */
|
|
87
|
+
export type WavFormat = {
|
|
88
|
+
sampleRate: number;
|
|
89
|
+
channels: number;
|
|
90
|
+
bitsPerSample: number;
|
|
91
|
+
/** Byte offset of the first sample. */
|
|
92
|
+
dataStart: number;
|
|
93
|
+
/** Byte offset one past the last sample. */
|
|
94
|
+
dataEnd: number;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** One request's worth of audio, addressed as a byte range of the source. */
|
|
98
|
+
export type Segment = {
|
|
99
|
+
/** Position in the recording — the fan-out's order, and the merge's. */
|
|
100
|
+
index: number;
|
|
101
|
+
/** First byte, frame-aligned and inclusive. */
|
|
102
|
+
start: number;
|
|
103
|
+
/** One past the last byte, frame-aligned. */
|
|
104
|
+
end: number;
|
|
105
|
+
/** Where this segment starts in the recording, for the progress log. */
|
|
106
|
+
startMs: number;
|
|
107
|
+
/** Where it ends. Overlaps the next segment's `startMs` — see the module doc. */
|
|
108
|
+
endMs: number;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
/** Raised for a recording this template cannot cut. Always terminal. */
|
|
112
|
+
export class UnsupportedRecordingError extends Error {
|
|
113
|
+
constructor(message: string) {
|
|
114
|
+
super(message);
|
|
115
|
+
this.name = "UnsupportedRecordingError";
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Bytes one sample frame occupies — all channels of one instant. */
|
|
120
|
+
export function blockAlign(format: Pick<WavFormat, "channels" | "bitsPerSample">): number {
|
|
121
|
+
return (format.channels * format.bitsPerSample) / 8;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Bytes of audio per second of wall clock. */
|
|
125
|
+
export function bytesPerSecond(
|
|
126
|
+
format: Pick<WavFormat, "channels" | "bitsPerSample" | "sampleRate">,
|
|
127
|
+
): number {
|
|
128
|
+
return blockAlign(format) * format.sampleRate;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Where a byte offset falls in the recording. */
|
|
132
|
+
export function offsetToMs(format: WavFormat, offset: number): number {
|
|
133
|
+
return Math.round(((offset - format.dataStart) / bytesPerSecond(format)) * 1000);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Four bytes read as ASCII — a RIFF chunk id. */
|
|
137
|
+
function chunkId(bytes: Uint8Array, at: number): string {
|
|
138
|
+
return String.fromCharCode(...bytes.subarray(at, at + 4));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Read a WAV header out of the first bytes of a recording.
|
|
143
|
+
*
|
|
144
|
+
* `totalBytes` is the size of the whole file, which the header cannot be trusted
|
|
145
|
+
* for: a WAV written by a streaming encoder declares `0` or `0xFFFFFFFF` for its
|
|
146
|
+
* data length because the length was not known when the header went out, and a
|
|
147
|
+
* truncated download declares more than it holds. So the sample range is the
|
|
148
|
+
* intersection of what the header claims and what the file actually has.
|
|
149
|
+
*
|
|
150
|
+
* @param head - The start of the file. Must reach past the `data` chunk's
|
|
151
|
+
* header — {@link parseWav} walks the chunk list, and a file with a large
|
|
152
|
+
* `LIST` or `bext` chunk in front of its samples pushes that further than the
|
|
153
|
+
* canonical 44 bytes.
|
|
154
|
+
* @param totalBytes - The size of the whole file, from `Content-Range` or
|
|
155
|
+
* `Content-Length`.
|
|
156
|
+
*/
|
|
157
|
+
export function parseWav(head: Uint8Array, totalBytes: number): WavFormat {
|
|
158
|
+
if (head.length < 12 || chunkId(head, 0) !== "RIFF" || chunkId(head, 8) !== "WAVE") {
|
|
159
|
+
throw new UnsupportedRecordingError(
|
|
160
|
+
"That is not a WAV file. This desk cuts linear-PCM WAV by byte offset, so a compressed " +
|
|
161
|
+
"recording has to be converted first (`ffmpeg -i in.m4a -c:a pcm_s16le out.wav`).",
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
const view = new DataView(head.buffer, head.byteOffset, head.byteLength);
|
|
165
|
+
let fmt: Pick<WavFormat, "sampleRate" | "channels" | "bitsPerSample"> | undefined;
|
|
166
|
+
|
|
167
|
+
// The chunk list. Each entry is a 4-byte id, a 4-byte little-endian length,
|
|
168
|
+
// and a payload padded to an even length — the padding byte is not counted by
|
|
169
|
+
// the length, which is the off-by-one this loop exists to get right once.
|
|
170
|
+
for (let at = 12; at + 8 <= head.length; ) {
|
|
171
|
+
const id = chunkId(head, at);
|
|
172
|
+
const size = view.getUint32(at + 4, true);
|
|
173
|
+
const payload = at + 8;
|
|
174
|
+
|
|
175
|
+
if (id === "fmt " && payload + 16 <= head.length) {
|
|
176
|
+
const encoding = view.getUint16(payload, true);
|
|
177
|
+
// 1 is WAVE_FORMAT_PCM. 0xFFFE is WAVE_FORMAT_EXTENSIBLE, whose real
|
|
178
|
+
// encoding lives in a GUID further in; refused rather than guessed,
|
|
179
|
+
// because guessing wrong produces noise that transcribes as words.
|
|
180
|
+
if (encoding !== 1) {
|
|
181
|
+
throw new UnsupportedRecordingError(
|
|
182
|
+
`That WAV holds encoding ${encoding}, not linear PCM — re-encode it with \`-c:a pcm_s16le\`.`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
fmt = {
|
|
186
|
+
channels: view.getUint16(payload + 2, true),
|
|
187
|
+
sampleRate: view.getUint32(payload + 4, true),
|
|
188
|
+
bitsPerSample: view.getUint16(payload + 14, true),
|
|
189
|
+
};
|
|
190
|
+
} else if (id === "data") {
|
|
191
|
+
if (!fmt)
|
|
192
|
+
throw new UnsupportedRecordingError("That WAV has no `fmt ` chunk before its data.");
|
|
193
|
+
if (blockAlign(fmt) <= 0) {
|
|
194
|
+
throw new UnsupportedRecordingError(
|
|
195
|
+
`That WAV declares ${fmt.channels} channels at ${fmt.bitsPerSample} bits — nothing to cut.`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
// The rate is validated HERE, beside the encoding and the block align,
|
|
199
|
+
// because everything downstream divides by it — see
|
|
200
|
+
// {@link MAX_BYTES_PER_SECOND} for the two loops a bad one hangs.
|
|
201
|
+
if (fmt.sampleRate <= 0) {
|
|
202
|
+
throw new UnsupportedRecordingError(
|
|
203
|
+
"That WAV declares a sample rate of 0, so nothing in it can be given a timestamp.",
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
const perSecond = bytesPerSecond(fmt);
|
|
207
|
+
if (perSecond > MAX_BYTES_PER_SECOND) {
|
|
208
|
+
throw new UnsupportedRecordingError(
|
|
209
|
+
`That WAV declares ${fmt.sampleRate} Hz across ${fmt.channels} channels at ${fmt.bitsPerSample} bits — ${perSecond} bytes a second, past the ${MAX_BYTES_PER_SECOND} this desk can cut into ${MAX_SEGMENT_BYTES}-byte requests.`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
// See the doc above: `0` and `0xFFFFFFFF` both mean "unknown", and any
|
|
213
|
+
// declared length is capped by what was actually served.
|
|
214
|
+
const declared = size === 0 || size === 0xff_ff_ff_ff ? Number.POSITIVE_INFINITY : size;
|
|
215
|
+
return { ...fmt, dataStart: payload, dataEnd: Math.min(payload + declared, totalBytes) };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
at = payload + size + (size % 2);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
throw new UnsupportedRecordingError(
|
|
222
|
+
`No \`data\` chunk in the first ${head.length} bytes of that WAV — its header is longer than this desk probes.`,
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Round an offset down to a frame boundary, so a cut never lands mid-sample. */
|
|
227
|
+
function alignDown(offset: number, frame: number): number {
|
|
228
|
+
return Math.floor(offset / frame) * frame;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Cut a recording into segments the sync endpoint will accept.
|
|
233
|
+
*
|
|
234
|
+
* The fan-out's width comes from this, and it is a pure function of a journaled
|
|
235
|
+
* value (the format, which a step returned) — the ordinary determinism rule: a
|
|
236
|
+
* replay has to re-derive the same list, in the same order, or the DevKit hands
|
|
237
|
+
* the Nth journal entry to a different call.
|
|
238
|
+
*/
|
|
239
|
+
export function planSegments(format: WavFormat): Segment[] {
|
|
240
|
+
const frame = blockAlign(format);
|
|
241
|
+
const perSecond = bytesPerSecond(format);
|
|
242
|
+
const audioBytes = format.dataEnd - format.dataStart;
|
|
243
|
+
if (audioBytes < (perSecond * MIN_SEGMENT_MS) / 1000) {
|
|
244
|
+
throw new UnsupportedRecordingError(
|
|
245
|
+
`That recording is ${Math.round((audioBytes / perSecond) * 1000)}ms long — the sync endpoint needs at least ${MIN_SEGMENT_MS}ms.`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// The overlap is decided FIRST, because both of the stride's caps have to
|
|
250
|
+
// leave room for it: a segment is `stride + overlap` long, so subtracting it
|
|
251
|
+
// here is what keeps that sum inside the endpoint's two limits. Getting this
|
|
252
|
+
// backwards is a 413 on exactly the inputs that reach a cap.
|
|
253
|
+
const overlap = alignDown(
|
|
254
|
+
Math.min(
|
|
255
|
+
SEGMENT_OVERLAP_SECONDS * perSecond,
|
|
256
|
+
(MAX_SEGMENT_SECONDS - SEGMENT_SECONDS) * perSecond,
|
|
257
|
+
),
|
|
258
|
+
frame,
|
|
259
|
+
);
|
|
260
|
+
// Whichever cap binds first. A 96 kHz stereo 24-bit recording reaches 40 MB
|
|
261
|
+
// at ~73 seconds, so on that input the byte cap is the one that decides;
|
|
262
|
+
// ordinary speech audio is nowhere near it and the duration cap decides.
|
|
263
|
+
const stride = alignDown(
|
|
264
|
+
Math.min(SEGMENT_SECONDS * perSecond, MAX_SEGMENT_BYTES - overlap),
|
|
265
|
+
frame,
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
const segments: Segment[] = [];
|
|
269
|
+
for (let start = format.dataStart; start < format.dataEnd; start += stride) {
|
|
270
|
+
const end = Math.min(start + stride + overlap, format.dataEnd);
|
|
271
|
+
segments.push({
|
|
272
|
+
index: segments.length,
|
|
273
|
+
start,
|
|
274
|
+
end,
|
|
275
|
+
startMs: offsetToMs(format, start),
|
|
276
|
+
endMs: offsetToMs(format, end),
|
|
277
|
+
});
|
|
278
|
+
// The overlap means the last segment reaches the end one stride early, and
|
|
279
|
+
// without this the loop would emit a final empty one.
|
|
280
|
+
if (end >= format.dataEnd) break;
|
|
281
|
+
}
|
|
282
|
+
return segments;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Put a canonical 44-byte WAV header in front of a slice of samples.
|
|
287
|
+
*
|
|
288
|
+
* A byte range of a WAV is raw PCM with no header, and the sync endpoint reads
|
|
289
|
+
* the rate and channel count out of one. Sending `audio/pcm` with a `config`
|
|
290
|
+
* part instead would work equally well; a header keeps the two parts of the
|
|
291
|
+
* request from being able to disagree.
|
|
292
|
+
*/
|
|
293
|
+
export function wavWithHeader(format: WavFormat, samples: Uint8Array): Uint8Array<ArrayBuffer> {
|
|
294
|
+
const out = new Uint8Array(44 + samples.length);
|
|
295
|
+
const view = new DataView(out.buffer);
|
|
296
|
+
const write = (at: number, text: string) => {
|
|
297
|
+
for (let i = 0; i < text.length; i++) view.setUint8(at + i, text.charCodeAt(i));
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
write(0, "RIFF");
|
|
301
|
+
view.setUint32(4, 36 + samples.length, true);
|
|
302
|
+
write(8, "WAVEfmt ");
|
|
303
|
+
view.setUint32(16, 16, true); // `fmt ` payload length
|
|
304
|
+
view.setUint16(20, 1, true); // linear PCM
|
|
305
|
+
view.setUint16(22, format.channels, true);
|
|
306
|
+
view.setUint32(24, format.sampleRate, true);
|
|
307
|
+
view.setUint32(28, bytesPerSecond(format), true);
|
|
308
|
+
view.setUint16(32, blockAlign(format), true);
|
|
309
|
+
view.setUint16(34, format.bitsPerSample, true);
|
|
310
|
+
write(36, "data");
|
|
311
|
+
view.setUint32(40, samples.length, true);
|
|
312
|
+
out.set(samples, 44);
|
|
313
|
+
return out;
|
|
314
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
import type { ToolContext } from "@alexkroman1/aai";
|
|
4
|
+
import { createToolContext, runTool, withDiscoveredTools } from "@alexkroman1/aai/testing";
|
|
5
|
+
import { describe, expect, test } from "vitest";
|
|
6
|
+
import authoredAgent from "./agent.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
|
|
10
|
+
*
|
|
11
|
+
* The glob is written HERE rather than reached for from a shared helper because
|
|
12
|
+
* this file SHIPS: it is what a scaffolded project runs, so it may not import
|
|
13
|
+
* anything outside its own template, and `import.meta.glob` is expanded against
|
|
14
|
+
* the file containing it either way. This is the pattern a user writes.
|
|
15
|
+
*/
|
|
16
|
+
const agentDef = withDiscoveredTools(
|
|
17
|
+
authoredAgent,
|
|
18
|
+
import.meta.glob("./tools/*.ts", { eager: true }),
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
import { activeAssistant, FLIGHTS, tripSlot, tripView } from "./shared.ts";
|
|
22
|
+
|
|
23
|
+
// ─── Harness ─────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
/** Each context owns its OWN slot store, which is what the isolation test
|
|
26
|
+
* below rests on. */
|
|
27
|
+
const makeCtx = (): ToolContext => createToolContext();
|
|
28
|
+
|
|
29
|
+
/** A tool by the name the model calls it by, bound to this agent. The lookup
|
|
30
|
+
* and its "no such tool" message are `runTool`'s (`@alexkroman1/aai/testing`);
|
|
31
|
+
* what is local is only which agent they run against. */
|
|
32
|
+
const run = (name: string, args: Record<string, unknown>, ctx: ToolContext) =>
|
|
33
|
+
runTool(agentDef, name, args, ctx);
|
|
34
|
+
|
|
35
|
+
/** The state a tool just mutated, read back the way `syncState` reads it. */
|
|
36
|
+
function stateOf(ctx: ToolContext) {
|
|
37
|
+
return tripSlot.get(ctx);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── 1. The dialog stack ─────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
describe("dialog stack (routing.ts)", () => {
|
|
43
|
+
test("delegating pushes a desk and hands back its brief", async () => {
|
|
44
|
+
const ctx = makeCtx();
|
|
45
|
+
expect(activeAssistant(stateOf(ctx))).toBe("primary");
|
|
46
|
+
|
|
47
|
+
const handoff = (await run(
|
|
48
|
+
"to_hotel_assistant",
|
|
49
|
+
{ request: "somewhere near the water" },
|
|
50
|
+
ctx,
|
|
51
|
+
)) as {
|
|
52
|
+
desk: string;
|
|
53
|
+
instructions: string;
|
|
54
|
+
};
|
|
55
|
+
expect(handoff.desk).toBe("hotel desk");
|
|
56
|
+
// The brief IS the tool result — that is the whole port of their
|
|
57
|
+
// per-assistant prompt onto a session whose prompt is fixed at connect.
|
|
58
|
+
expect(handoff.instructions).toContain("hotel desk");
|
|
59
|
+
expect(activeAssistant(stateOf(ctx))).toBe("hotel");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("re-delegating to the desk already holding the call does not grow the stack", async () => {
|
|
63
|
+
const ctx = makeCtx();
|
|
64
|
+
await run("to_flight_assistant", { request: "move my flight" }, ctx);
|
|
65
|
+
await run("to_flight_assistant", { request: "actually a later one" }, ctx);
|
|
66
|
+
expect(stateOf(ctx).dialogState).toEqual(["primary", "flight"]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("complete_or_escalate pops, and never past the concierge", async () => {
|
|
70
|
+
const ctx = makeCtx();
|
|
71
|
+
await run("to_excursion_assistant", { request: "something to do" }, ctx);
|
|
72
|
+
|
|
73
|
+
const back = (await run("complete_or_escalate", { reason: "booked it" }, ctx)) as {
|
|
74
|
+
returnedFrom: string;
|
|
75
|
+
nowHandling: string;
|
|
76
|
+
};
|
|
77
|
+
expect(back.returnedFrom).toBe("excursions desk");
|
|
78
|
+
expect(back.nowHandling).toBe("concierge");
|
|
79
|
+
expect(activeAssistant(stateOf(ctx))).toBe("primary");
|
|
80
|
+
|
|
81
|
+
// Popping again at the bottom is a no-op rather than an empty stack: the
|
|
82
|
+
// slot's `after` hook is what guarantees there is always an assistant.
|
|
83
|
+
await run("complete_or_escalate", { reason: "nothing else" }, ctx);
|
|
84
|
+
expect(stateOf(ctx).dialogState).toEqual(["primary"]);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ─── 2. The confirmation gate ────────────────────────────────────────────────
|
|
89
|
+
//
|
|
90
|
+
// Their graph halts before a sensitive tool (`interrupt_before`) and resumes on
|
|
91
|
+
// approval. Here every sensitive tool stages and applies nothing, so the
|
|
92
|
+
// approval has somewhere to arrive. These are the tests that matter: a
|
|
93
|
+
// regression makes the agent able to rebook a flight BY MISTAKE.
|
|
94
|
+
|
|
95
|
+
describe("sensitive tools stage rather than act", () => {
|
|
96
|
+
test.each([
|
|
97
|
+
["update_ticket", { flightId: "LX52" }],
|
|
98
|
+
["cancel_ticket", {}],
|
|
99
|
+
["book_hotel", { hotelId: "H1", nights: 2 }],
|
|
100
|
+
["book_car_rental", { carId: "C2", days: 3 }],
|
|
101
|
+
["book_excursion", { excursionId: "E2" }],
|
|
102
|
+
])("%s changes nothing on its own", async (name, args) => {
|
|
103
|
+
const ctx = makeCtx();
|
|
104
|
+
const before = structuredClone(stateOf(ctx));
|
|
105
|
+
|
|
106
|
+
const staged = (await run(name, args, ctx)) as { awaitingConfirmation: boolean };
|
|
107
|
+
expect(staged.awaitingConfirmation).toBe(true);
|
|
108
|
+
|
|
109
|
+
const after = stateOf(ctx);
|
|
110
|
+
expect(after.ticket).toEqual(before.ticket);
|
|
111
|
+
expect(after.bookings).toEqual([]);
|
|
112
|
+
expect(after.pending).not.toBeNull();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("confirm_action applies the staged change, and only then", async () => {
|
|
116
|
+
const ctx = makeCtx();
|
|
117
|
+
await run("book_hotel", { hotelId: "H1", nights: 3 }, ctx);
|
|
118
|
+
|
|
119
|
+
const applied = (await run("confirm_action", {}, ctx)) as {
|
|
120
|
+
applied: string;
|
|
121
|
+
reference: string;
|
|
122
|
+
};
|
|
123
|
+
expect(applied.applied).toContain("Harborview Suites");
|
|
124
|
+
// 3 nights at $265.
|
|
125
|
+
expect(applied.applied).toContain("$795");
|
|
126
|
+
|
|
127
|
+
const state = stateOf(ctx);
|
|
128
|
+
expect(state.pending).toBeNull();
|
|
129
|
+
expect(state.bookings).toHaveLength(1);
|
|
130
|
+
expect(state.bookings[0]?.reference).toBe(applied.reference);
|
|
131
|
+
|
|
132
|
+
// The gate re-arms: a second confirm has nothing to apply.
|
|
133
|
+
expect(await run("confirm_action", {}, ctx)).toEqual({
|
|
134
|
+
error: "Nothing is waiting for confirmation. Use the booking tool first.",
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("cancel_action drops the staged change and leaves the booking alone", async () => {
|
|
139
|
+
const ctx = makeCtx();
|
|
140
|
+
await run("update_ticket", { flightId: "LX54" }, ctx);
|
|
141
|
+
const dropped = (await run("cancel_action", {}, ctx)) as { discarded: string };
|
|
142
|
+
expect(dropped.discarded).toContain("LX54");
|
|
143
|
+
|
|
144
|
+
const state = stateOf(ctx);
|
|
145
|
+
expect(state.pending).toBeNull();
|
|
146
|
+
expect(state.ticket?.flightId).toBe("LX40");
|
|
147
|
+
expect(await run("cancel_action", {}, ctx)).toEqual({
|
|
148
|
+
message: "Nothing was waiting for confirmation.",
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("a staged action naming something that does not exist is refused at staging time", async () => {
|
|
153
|
+
// Refused where the model can still recover — before the caller is asked to
|
|
154
|
+
// confirm a flight the airline does not fly.
|
|
155
|
+
const ctx = makeCtx();
|
|
156
|
+
expect(await run("update_ticket", { flightId: "ZZ99" }, ctx)).toEqual({
|
|
157
|
+
error: "No flight ZZ99 in the schedule.",
|
|
158
|
+
});
|
|
159
|
+
expect(stateOf(ctx).pending).toBeNull();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("cancelling the ticket makes a later ticket change impossible", async () => {
|
|
163
|
+
const ctx = makeCtx();
|
|
164
|
+
await run("cancel_ticket", {}, ctx);
|
|
165
|
+
await run("confirm_action", {}, ctx);
|
|
166
|
+
expect(stateOf(ctx).ticket).toBeNull();
|
|
167
|
+
|
|
168
|
+
expect(await run("update_ticket", { flightId: "LX52" }, ctx)).toEqual({
|
|
169
|
+
error: "This caller has no ticket to move — it was cancelled on this call.",
|
|
170
|
+
});
|
|
171
|
+
expect(await run("cancel_ticket", {}, ctx)).toEqual({ error: "There is no ticket to cancel." });
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("a second staging is REFUSED rather than overwriting the first", async () => {
|
|
175
|
+
// The LLM loop runs a step's tool calls concurrently, so two sensitive
|
|
176
|
+
// tools in one step is ordinary: the model hears "move my flight and book
|
|
177
|
+
// the hotel" and emits both. Assigning `pending` unconditionally made the
|
|
178
|
+
// second win — both answered `awaitingConfirmation`, the caller said yes
|
|
179
|
+
// once, and one of the two changes was silently dropped forever.
|
|
180
|
+
const ctx = makeCtx();
|
|
181
|
+
const first = (await run("update_ticket", { flightId: "LX52" }, ctx)) as {
|
|
182
|
+
awaitingConfirmation: boolean;
|
|
183
|
+
};
|
|
184
|
+
expect(first.awaitingConfirmation).toBe(true);
|
|
185
|
+
|
|
186
|
+
const second = (await run("book_hotel", { hotelId: "H1", nights: 2 }, ctx)) as {
|
|
187
|
+
error: string;
|
|
188
|
+
};
|
|
189
|
+
// The refusal NAMES what is already waiting, which is what lets the model
|
|
190
|
+
// settle that one and come back rather than guess.
|
|
191
|
+
expect(second.error).toContain("LX52");
|
|
192
|
+
expect(second.error).toMatch(/confirm_action or cancel_action/);
|
|
193
|
+
|
|
194
|
+
// The first staging is untouched, and it is what a yes applies.
|
|
195
|
+
expect(stateOf(ctx).pending).toEqual({ kind: "update_ticket", flightId: "LX52" });
|
|
196
|
+
const applied = (await run("confirm_action", {}, ctx)) as { applied: string };
|
|
197
|
+
expect(applied.applied).toContain("LX52");
|
|
198
|
+
expect(stateOf(ctx).bookings).toEqual([]);
|
|
199
|
+
|
|
200
|
+
// Once the queue is clear, the hotel can be staged after all.
|
|
201
|
+
const retried = (await run("book_hotel", { hotelId: "H1", nights: 2 }, ctx)) as {
|
|
202
|
+
awaitingConfirmation: boolean;
|
|
203
|
+
};
|
|
204
|
+
expect(retried.awaitingConfirmation).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("cancel_action clears the block, so a declined change does not wedge the desk", async () => {
|
|
208
|
+
const ctx = makeCtx();
|
|
209
|
+
await run("book_car_rental", { carId: "C2", days: 3 }, ctx);
|
|
210
|
+
await run("cancel_action", {}, ctx);
|
|
211
|
+
const staged = (await run("book_excursion", { excursionId: "E2" }, ctx)) as {
|
|
212
|
+
awaitingConfirmation: boolean;
|
|
213
|
+
};
|
|
214
|
+
expect(staged.awaitingConfirmation).toBe(true);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("two independent contexts never share a stack, a ticket or an itinerary", async () => {
|
|
218
|
+
// What this really checks: the state lives in the SLOT and not in a
|
|
219
|
+
// module-level variable. `createToolContext()` hands each call its own
|
|
220
|
+
// detached slot store, so the isolation is per CONTEXT — two distinct
|
|
221
|
+
// session ids would prove nothing extra, and `sessionSlot` could stop
|
|
222
|
+
// keying by session with this still passing.
|
|
223
|
+
const first = makeCtx();
|
|
224
|
+
const second = makeCtx();
|
|
225
|
+
|
|
226
|
+
await run("to_hotel_assistant", { request: "a room" }, first);
|
|
227
|
+
await run("book_hotel", { hotelId: "H3", nights: 1 }, first);
|
|
228
|
+
await run("confirm_action", {}, first);
|
|
229
|
+
|
|
230
|
+
expect(activeAssistant(stateOf(second))).toBe("primary");
|
|
231
|
+
expect(stateOf(second).bookings).toEqual([]);
|
|
232
|
+
expect(await run("confirm_action", {}, second)).toEqual({
|
|
233
|
+
error: "Nothing is waiting for confirmation. Use the booking tool first.",
|
|
234
|
+
});
|
|
235
|
+
expect(stateOf(first).bookings).toHaveLength(1);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// ─── 3. Search ───────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
describe("search tools", () => {
|
|
242
|
+
test("an unmatched route widens to the whole schedule rather than answering nothing", async () => {
|
|
243
|
+
const ctx = makeCtx();
|
|
244
|
+
const hit = (await run("search_flights", { route: "Zurich to Boston" }, ctx)) as {
|
|
245
|
+
widened: boolean;
|
|
246
|
+
flights: unknown[];
|
|
247
|
+
};
|
|
248
|
+
expect(hit.widened).toBe(false);
|
|
249
|
+
expect(hit.flights).toHaveLength(3);
|
|
250
|
+
|
|
251
|
+
const miss = (await run("search_flights", { route: "Osaka" }, ctx)) as {
|
|
252
|
+
widened: boolean;
|
|
253
|
+
flights: unknown[];
|
|
254
|
+
};
|
|
255
|
+
expect(miss.widened).toBe(true);
|
|
256
|
+
expect(miss.flights).toHaveLength(FLIGHTS.length);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("hotels come back cheapest first, and a ceiling filters them", async () => {
|
|
260
|
+
const ctx = makeCtx();
|
|
261
|
+
const all = (await run("search_hotels", { city: "Boston" }, ctx)) as {
|
|
262
|
+
hotels: { perNight: string }[];
|
|
263
|
+
};
|
|
264
|
+
expect(all.hotels.map((h) => h.perNight)).toEqual(["$180", "$265", "$340"]);
|
|
265
|
+
|
|
266
|
+
const cheap = (await run("search_hotels", { city: "Boston", maxPerNight: 200 }, ctx)) as {
|
|
267
|
+
hotels: { name: string }[];
|
|
268
|
+
};
|
|
269
|
+
expect(cheap.hotels).toHaveLength(1);
|
|
270
|
+
expect(cheap.hotels[0]?.name).toBe("Cambridge Rooms");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("an excursion keyword that matches nothing falls back to the city", async () => {
|
|
274
|
+
const ctx = makeCtx();
|
|
275
|
+
const result = (await run("search_excursions", { city: "Boston", keyword: "skiing" }, ctx)) as {
|
|
276
|
+
widened: boolean;
|
|
277
|
+
excursions: unknown[];
|
|
278
|
+
};
|
|
279
|
+
expect(result.widened).toBe(true);
|
|
280
|
+
expect(result.excursions).toHaveLength(3);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("lookup_booking reports the ticket the caller is actually holding", async () => {
|
|
284
|
+
const ctx = makeCtx();
|
|
285
|
+
await run("update_ticket", { flightId: "LX52" }, ctx);
|
|
286
|
+
await run("confirm_action", {}, ctx);
|
|
287
|
+
const booking = (await run("lookup_booking", {}, ctx)) as {
|
|
288
|
+
passenger: string;
|
|
289
|
+
ticket: { flight: string; departs: string } | null;
|
|
290
|
+
};
|
|
291
|
+
expect(booking.passenger).toBe("Nadia Rossi");
|
|
292
|
+
expect(booking.ticket?.flight).toBe("LX52");
|
|
293
|
+
expect(booking.ticket?.departs).toBe("Wed 09:20");
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
// ─── 4. The projection contract with client.tsx ─────────────────────────────
|
|
298
|
+
|
|
299
|
+
describe("tripView projection", () => {
|
|
300
|
+
test("an untouched call projects the seeded booking at the concierge desk", () => {
|
|
301
|
+
// Exactly the value client.tsx hoists as its fallback.
|
|
302
|
+
const view = tripSlot.projection(tripView)(undefined);
|
|
303
|
+
expect(view).toMatchObject({
|
|
304
|
+
assistant: "primary",
|
|
305
|
+
assistantTitle: "concierge",
|
|
306
|
+
bookings: [],
|
|
307
|
+
total: 0,
|
|
308
|
+
pending: null,
|
|
309
|
+
});
|
|
310
|
+
expect(view.ticket?.flightId).toBe("LX40");
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("renders the staged action as the prose the concierge just spoke", async () => {
|
|
314
|
+
const ctx = makeCtx();
|
|
315
|
+
await run("to_car_rental_assistant", { request: "a car for the week" }, ctx);
|
|
316
|
+
await run("book_car_rental", { carId: "C3", days: 4 }, ctx);
|
|
317
|
+
|
|
318
|
+
const view = tripView(stateOf(ctx));
|
|
319
|
+
expect(view.assistant).toBe("car_rental");
|
|
320
|
+
expect(view.assistantTitle).toBe("car rental desk");
|
|
321
|
+
// $95/day × 4 — the sidebar and the spoken read-back derive from one string.
|
|
322
|
+
expect(view.pending).toContain("$380");
|
|
323
|
+
expect(view.log.at(-1)).toContain("Awaiting confirmation");
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
test("totals every confirmed booking", async () => {
|
|
327
|
+
const ctx = makeCtx();
|
|
328
|
+
await run("book_hotel", { hotelId: "H3", nights: 2 }, ctx); // 2 × 180
|
|
329
|
+
await run("confirm_action", {}, ctx);
|
|
330
|
+
await run("book_excursion", { excursionId: "E1" }, ctx); // 35
|
|
331
|
+
await run("confirm_action", {}, ctx);
|
|
332
|
+
|
|
333
|
+
const view = tripView(stateOf(ctx));
|
|
334
|
+
expect(view.total).toBe(395);
|
|
335
|
+
expect(view.bookings.map((b) => b.kind)).toEqual(["hotel", "excursion"]);
|
|
336
|
+
expect(view.pending).toBeNull();
|
|
337
|
+
});
|
|
338
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { agent } from "@alexkroman1/aai";
|
|
2
|
+
import { tripSlot, tripView } from "./shared.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A phone travel concierge, adapted from LangGraph's customer-support tutorial.
|
|
6
|
+
* `shared.ts` carries the attribution and what changed on the way to voice; the
|
|
7
|
+
* two mechanisms worth reading for are the dialog stack (`routing.ts`) and the
|
|
8
|
+
* confirmation gate (`stageAction` / `confirm_action`).
|
|
9
|
+
*
|
|
10
|
+
* **Every tool in `tools/` belongs to this one agent — the delegation is in the
|
|
11
|
+
* PROMPT, not in the tool list.** Their graph gives each specialist node its
|
|
12
|
+
* own bound tool set, so a specialist physically cannot call another desk's
|
|
13
|
+
* tools. A voice session has one model with one tool list for its whole life,
|
|
14
|
+
* so the specialist's brief arrives as a tool result and the narrowing is
|
|
15
|
+
* something the model is asked to honour rather than something the runtime
|
|
16
|
+
* enforces. That is the honest limit of the port, and it is why
|
|
17
|
+
* `complete_or_escalate` is described the way it is: the stack is what the
|
|
18
|
+
* caller and the sidebar can both see, even when the model reaches past it.
|
|
19
|
+
*/
|
|
20
|
+
export default agent({
|
|
21
|
+
name: "Swiss Air Concierge",
|
|
22
|
+
// The stack, the ticket and the itinerary exist before the first tool call,
|
|
23
|
+
// so a resumed connection has something to project.
|
|
24
|
+
// One projection replaces a `ctx.send` in each of eleven tools — and is the
|
|
25
|
+
// single place that decides the caller's record leaves the server trimmed.
|
|
26
|
+
syncState: tripSlot.projection(tripView),
|
|
27
|
+
greeting:
|
|
28
|
+
"Swiss Air Travel, this is the concierge desk. I can see your booking — what can I do for you today?",
|
|
29
|
+
});
|