@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,525 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/// <reference types="vite/client" />
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Specs for the research desk's four tools.
|
|
6
|
+
*
|
|
7
|
+
* All are exercised against a STUBBED `ctx.workflows`, which is the only honest
|
|
8
|
+
* way to unit-test them: the real client needs a Workflow DevKit world, and the
|
|
9
|
+
* bodies in `workflows/` are only durable once the build has transformed them.
|
|
10
|
+
* What these assert is the agent's half of the contract — that the handoff tool
|
|
11
|
+
* passes the correlation key, that the status tool narrows a snapshot correctly
|
|
12
|
+
* before reading it aloud, and that the two tools reaching PAST a status (the
|
|
13
|
+
* progress stream, the early wake) ask for what a voice reply can use.
|
|
14
|
+
*
|
|
15
|
+
* The STEPS are exercised separately, and directly: imported through vitest with
|
|
16
|
+
* no bundler in the path, a `"use step"` function is an ordinary async function,
|
|
17
|
+
* so its prompt handling, its parsing and its `FatalError` guards are all
|
|
18
|
+
* testable — while durability, suspension and replay are not. The body itself is
|
|
19
|
+
* not driven here for that reason; `aai-cli`'s `dev-workflow.scenario.test.ts`
|
|
20
|
+
* builds a project and runs one.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { ToolContext, WorkflowClient, WorkflowRunSnapshot } from "@alexkroman1/aai";
|
|
24
|
+
import {
|
|
25
|
+
createProgressStream,
|
|
26
|
+
createRunSnapshot,
|
|
27
|
+
createStubWorkflows,
|
|
28
|
+
createToolContext,
|
|
29
|
+
runTool,
|
|
30
|
+
type StubGatewayCall,
|
|
31
|
+
withDiscoveredTools,
|
|
32
|
+
} from "@alexkroman1/aai/testing";
|
|
33
|
+
import { installStubGateway as stubGateway } from "@alexkroman1/aai/testing/vitest";
|
|
34
|
+
import { visitWebpage, webSearch } from "@alexkroman1/aai/tools";
|
|
35
|
+
import { beforeEach, describe, expect, test, vi } from "vitest";
|
|
36
|
+
import { FatalError, RetryableError } from "workflow";
|
|
37
|
+
import authoredAgent from "./agent.ts";
|
|
38
|
+
import { research } from "./shared.ts";
|
|
39
|
+
import {
|
|
40
|
+
countSources,
|
|
41
|
+
dedupe,
|
|
42
|
+
findGaps,
|
|
43
|
+
investigate,
|
|
44
|
+
planAngles,
|
|
45
|
+
writeBrief,
|
|
46
|
+
writeReport,
|
|
47
|
+
} from "./workflows/research.ts";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The web, faked at the SDK's own seam.
|
|
51
|
+
*
|
|
52
|
+
* `webSearch` and `visitWebpage` screen a URL and then really fetch it, through
|
|
53
|
+
* an undici dispatcher a `globalThis.fetch` stub cannot reach — so mocking the
|
|
54
|
+
* module is the only honest way to keep this suite offline. What is asserted is
|
|
55
|
+
* that the researcher CALLS them with what the model asked for; the builtins'
|
|
56
|
+
* own behaviour is `aai`'s to test, and it does.
|
|
57
|
+
*/
|
|
58
|
+
vi.mock("@alexkroman1/aai/tools", () => ({
|
|
59
|
+
webSearch: vi.fn(async () => ({
|
|
60
|
+
results: [{ title: "Otters", url: "https://otters.example/tools" }],
|
|
61
|
+
})),
|
|
62
|
+
visitWebpage: vi.fn(async () => ({ content: "The page body." })),
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
|
|
67
|
+
*
|
|
68
|
+
* The glob is written HERE rather than reached for from a shared helper because
|
|
69
|
+
* this file SHIPS: it is what a scaffolded project runs, so it may not import
|
|
70
|
+
* anything outside its own template, and `import.meta.glob` is expanded against
|
|
71
|
+
* the file containing it either way. This is the pattern a user writes.
|
|
72
|
+
*/
|
|
73
|
+
const agentDef = withDiscoveredTools(
|
|
74
|
+
authoredAgent,
|
|
75
|
+
import.meta.glob("./tools/*.ts", { eager: true }),
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
/** Every tool here is driven through the agent's own table, by the name the model calls. */
|
|
79
|
+
const run = (name: string, args: Record<string, unknown>, ctx: ToolContext): Promise<unknown> =>
|
|
80
|
+
runTool(agentDef, name, args, ctx);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A `ctx.workflows` that records `start` and answers `find` from a fixture.
|
|
84
|
+
*
|
|
85
|
+
* Returned WITHOUT a cast, which is the property worth keeping: a cast would
|
|
86
|
+
* also stop reporting the day `WorkflowClient` grows a method, and this stub is
|
|
87
|
+
* how the template's tools reach the client at all. `createStubWorkflows` is
|
|
88
|
+
* what keeps that affordable — it fills in the methods this desk does not
|
|
89
|
+
* drive, so the day the client does grow one, only the tests that use it change.
|
|
90
|
+
*/
|
|
91
|
+
function stubWorkflows(runs: WorkflowRunSnapshot[] = []): WorkflowClient {
|
|
92
|
+
return createStubWorkflows({
|
|
93
|
+
start: vi.fn(async () => "wrun_stub"),
|
|
94
|
+
get: vi.fn(async () => runs[0]),
|
|
95
|
+
find: vi.fn(async () => runs),
|
|
96
|
+
recent: vi.fn(async () => runs),
|
|
97
|
+
cancel: vi.fn(async () => true),
|
|
98
|
+
wakeUp: vi.fn(async () => 0),
|
|
99
|
+
// A tail of 0 means "one line written", which is the case the tools read.
|
|
100
|
+
// The `-1` case is overridden per test, because it is the one that decides
|
|
101
|
+
// whether the stream is opened at all.
|
|
102
|
+
streamTail: vi.fn(async () => 0),
|
|
103
|
+
stream: vi.fn(async () => createProgressStream([])),
|
|
104
|
+
// Name only: `WorkflowDef.description` is optional, so passing it through
|
|
105
|
+
// would mean handing `description: undefined` to a field that does not
|
|
106
|
+
// accept it. Nothing here reads the description anyway.
|
|
107
|
+
listing: () => [{ name: "research" }],
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
describe("the agent declares its workflow", () => {
|
|
112
|
+
test("under the name ctx.workflows.start resolves it by", () => {
|
|
113
|
+
expect(Object.keys(agentDef.workflows ?? {})).toEqual(["research"]);
|
|
114
|
+
expect(agentDef.workflows?.research).toBe(research);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("with an input schema, so a bad topic fails at the call site", async () => {
|
|
118
|
+
const result = await research.input?.["~standard"].validate({
|
|
119
|
+
topic: "otters",
|
|
120
|
+
requestedBy: "s",
|
|
121
|
+
});
|
|
122
|
+
expect(result?.issues).toBeUndefined();
|
|
123
|
+
const bad = await research.input?.["~standard"].validate({ topic: "no", requestedBy: "s" });
|
|
124
|
+
expect(bad?.issues).toBeDefined();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The options `request_research` starts its run with.
|
|
130
|
+
*
|
|
131
|
+
* AWAITED rather than `void`-ed. Reading a mock's call list off a floating
|
|
132
|
+
* promise worked only because the tool body happens to reach `start` before its
|
|
133
|
+
* first `await`; anything async landing ahead of that would have made this
|
|
134
|
+
* return `undefined` and the reader throw a `TypeError` instead of failing on
|
|
135
|
+
* the option it is about — and the dropped promise is an unhandled rejection
|
|
136
|
+
* either way.
|
|
137
|
+
*
|
|
138
|
+
* The return type is inferred from the mock, so `notify` arrives typed and the
|
|
139
|
+
* caller needs no cast.
|
|
140
|
+
*/
|
|
141
|
+
async function workflowsStartOptions() {
|
|
142
|
+
const workflows = stubWorkflows();
|
|
143
|
+
const ctx = createToolContext({ workflows });
|
|
144
|
+
await run("request_research", { topic: "otters" }, ctx);
|
|
145
|
+
return vi.mocked(workflows.start).mock.calls[0]?.[2];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
describe("request_research", () => {
|
|
149
|
+
test("starts a run keyed by the session, so a later turn can find it", async () => {
|
|
150
|
+
const workflows = stubWorkflows();
|
|
151
|
+
const ctx = createToolContext({ workflows });
|
|
152
|
+
const result = await run("request_research", { topic: "otters" }, ctx);
|
|
153
|
+
|
|
154
|
+
expect(workflows.start).toHaveBeenCalledWith(
|
|
155
|
+
research,
|
|
156
|
+
{ topic: "otters", requestedBy: ctx.sessionId },
|
|
157
|
+
// `key` is the DURABLE handle — a later call finds the run by it — and
|
|
158
|
+
// `notify` is the live one: this session is told when the run lands, which
|
|
159
|
+
// is what makes the agent's "I'll let you know" true.
|
|
160
|
+
{ key: ctx.sessionId, notify: expect.stringContaining("read the summary") },
|
|
161
|
+
);
|
|
162
|
+
expect(result).toMatchObject({ started: true, runId: "wrun_stub", topic: "otters" });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("asks to be TOLD when the run lands, rather than waiting to be asked", async () => {
|
|
166
|
+
// The gap this closes: the agent promised an update, the run finished, and
|
|
167
|
+
// nothing made it speak — so the caller had to think to ask again. A voice
|
|
168
|
+
// agent that starts durable work and never announces it is the shape to
|
|
169
|
+
// avoid, and one option is the whole fix.
|
|
170
|
+
const options = await workflowsStartOptions();
|
|
171
|
+
expect(options, "request_research started no run").toBeDefined();
|
|
172
|
+
expect(typeof options?.notify).toBe("string");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("passes the definition rather than its name", async () => {
|
|
176
|
+
const workflows = stubWorkflows();
|
|
177
|
+
await run("request_research", { topic: "otters" }, createToolContext({ workflows }));
|
|
178
|
+
// The def overload is what types the input and turns a rename into a compile
|
|
179
|
+
// error; a string would still work at runtime and lose both.
|
|
180
|
+
expect(vi.mocked(workflows.start).mock.calls[0]?.[0]).toBe(research);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe("research_status", () => {
|
|
185
|
+
test("says nothing was started when the key has no runs", async () => {
|
|
186
|
+
const ctx = createToolContext({ workflows: stubWorkflows([]) });
|
|
187
|
+
const result = await run("research_status", {}, ctx);
|
|
188
|
+
expect(result).toMatchObject({ runs: [], note: "Nothing started yet." });
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("reads a completed run's summary and source count back", async () => {
|
|
192
|
+
const runs = [
|
|
193
|
+
createRunSnapshot({
|
|
194
|
+
workflow: "research",
|
|
195
|
+
status: "completed",
|
|
196
|
+
output: { topic: "otters", summary: "Otters use tools.", sources: 3, filedAt: "now" },
|
|
197
|
+
}),
|
|
198
|
+
];
|
|
199
|
+
const ctx = createToolContext({ workflows: stubWorkflows(runs) });
|
|
200
|
+
const result = (await run("research_status", {}, ctx)) as { runs: string[] };
|
|
201
|
+
expect(result.runs[0]).toContain("Otters use tools.");
|
|
202
|
+
expect(result.runs[0]).toContain("3 sources");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("reports a live run as still working rather than as empty", async () => {
|
|
206
|
+
const ctx = createToolContext({
|
|
207
|
+
workflows: stubWorkflows([createRunSnapshot({ workflow: "research", status: "running" })]),
|
|
208
|
+
});
|
|
209
|
+
const result = (await run("research_status", {}, ctx)) as { runs: string[] };
|
|
210
|
+
expect(result.runs[0]).toContain("Still working on it.");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("surfaces a failed run's message instead of swallowing it", async () => {
|
|
214
|
+
const runs = [
|
|
215
|
+
createRunSnapshot({ workflow: "research", status: "failed", error: "model unavailable" }),
|
|
216
|
+
];
|
|
217
|
+
const ctx = createToolContext({ workflows: stubWorkflows(runs) });
|
|
218
|
+
const result = (await run("research_status", {}, ctx)) as { runs: string[] };
|
|
219
|
+
expect(result.runs[0]).toContain("model unavailable");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("bounds how many past runs it reads aloud", async () => {
|
|
223
|
+
const workflows = stubWorkflows([]);
|
|
224
|
+
const ctx = createToolContext({ workflows });
|
|
225
|
+
await run("research_status", {}, ctx);
|
|
226
|
+
// A voice reply cannot be a list of twenty runs.
|
|
227
|
+
expect(workflows.find).toHaveBeenCalledWith(research, ctx.sessionId, { limit: 3 });
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe("research_progress", () => {
|
|
232
|
+
test("reads the run's own progress line rather than its status", async () => {
|
|
233
|
+
const workflows = stubWorkflows([
|
|
234
|
+
createRunSnapshot({ workflow: "research", status: "running" }),
|
|
235
|
+
]);
|
|
236
|
+
vi.mocked(workflows.stream).mockResolvedValue(createProgressStream(["Found 3 sources."]));
|
|
237
|
+
const ctx = createToolContext({ workflows });
|
|
238
|
+
const result = await run("research_progress", {}, ctx);
|
|
239
|
+
expect(result).toMatchObject({ progress: "Found 3 sources." });
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("asks for the LAST line, not the whole log", async () => {
|
|
243
|
+
// A voice reply cannot recite every line the run has written.
|
|
244
|
+
const workflows = stubWorkflows([
|
|
245
|
+
createRunSnapshot({ workflow: "research", status: "running" }),
|
|
246
|
+
]);
|
|
247
|
+
vi.mocked(workflows.stream).mockResolvedValue(createProgressStream(["a"]));
|
|
248
|
+
await run("research_progress", {}, createToolContext({ workflows }));
|
|
249
|
+
expect(workflows.stream).toHaveBeenCalledWith("wrun_1", { startIndex: -1 });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("a run that has written nothing yet says so WITHOUT opening the stream", async () => {
|
|
253
|
+
// Not a shortcut: an empty progress channel is never closed, so reading one
|
|
254
|
+
// would wait for a line that arrives whenever the next step writes — i.e.
|
|
255
|
+
// the tool hangs instead of answering. The tail is how that is known.
|
|
256
|
+
const workflows = stubWorkflows([
|
|
257
|
+
createRunSnapshot({ workflow: "research", status: "running" }),
|
|
258
|
+
]);
|
|
259
|
+
vi.mocked(workflows.streamTail).mockResolvedValue(-1);
|
|
260
|
+
const result = await run("research_progress", {}, createToolContext({ workflows }));
|
|
261
|
+
expect(result).toMatchObject({ note: "Started, nothing to report yet." });
|
|
262
|
+
expect(workflows.stream).not.toHaveBeenCalled();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("says nothing was started when the key has no runs", async () => {
|
|
266
|
+
const workflows = stubWorkflows([]);
|
|
267
|
+
const result = await run("research_progress", {}, createToolContext({ workflows }));
|
|
268
|
+
expect(result).toMatchObject({ note: "Nothing started yet." });
|
|
269
|
+
expect(workflows.stream).not.toHaveBeenCalled();
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
describe("file_it_now", () => {
|
|
274
|
+
test("wakes the sleeping run so the review wait ends early", async () => {
|
|
275
|
+
const workflows = stubWorkflows([
|
|
276
|
+
createRunSnapshot({ workflow: "research", status: "running" }),
|
|
277
|
+
]);
|
|
278
|
+
vi.mocked(workflows.wakeUp).mockResolvedValue(1);
|
|
279
|
+
const result = await run("file_it_now", {}, createToolContext({ workflows }));
|
|
280
|
+
expect(workflows.wakeUp).toHaveBeenCalledWith("wrun_1");
|
|
281
|
+
expect(result).toMatchObject({ filed: true });
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
test("a run that was not waiting is reported honestly, not as a failure", async () => {
|
|
285
|
+
// `wakeUp` answering 0 means the run had already moved past its sleep — the
|
|
286
|
+
// same shape as `cancel` answering false.
|
|
287
|
+
const workflows = stubWorkflows([
|
|
288
|
+
createRunSnapshot({ workflow: "research", status: "running" }),
|
|
289
|
+
]);
|
|
290
|
+
vi.mocked(workflows.wakeUp).mockResolvedValue(0);
|
|
291
|
+
const result = await run("file_it_now", {}, createToolContext({ workflows }));
|
|
292
|
+
expect(result).toMatchObject({ filed: false });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("says nothing was started when the key has no runs", async () => {
|
|
296
|
+
const workflows = stubWorkflows([]);
|
|
297
|
+
const result = await run("file_it_now", {}, createToolContext({ workflows }));
|
|
298
|
+
expect(result).toMatchObject({ note: "Nothing started yet." });
|
|
299
|
+
expect(workflows.wakeUp).not.toHaveBeenCalled();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("the pure helpers", () => {
|
|
304
|
+
test("dedupe keeps the first occurrence of each URL", () => {
|
|
305
|
+
const sources = [
|
|
306
|
+
{ title: "One", url: "https://a.example" },
|
|
307
|
+
{ title: "One again", url: "https://a.example" },
|
|
308
|
+
{ title: "Two", url: "https://b.example" },
|
|
309
|
+
];
|
|
310
|
+
expect(dedupe(sources)).toEqual([sources[0], sources[2]]);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("countSources counts DISTINCT sources across every angle", () => {
|
|
314
|
+
// What the voice agent quotes. Two researchers finding the same page is one
|
|
315
|
+
// source, and reporting two would overstate the research.
|
|
316
|
+
const shared = { title: "Shared", url: "https://a.example" };
|
|
317
|
+
expect(
|
|
318
|
+
countSources([
|
|
319
|
+
{ angle: "one", findings: "…", sources: [shared, { title: "B", url: "https://b" }] },
|
|
320
|
+
{ angle: "two", findings: "…", sources: [shared] },
|
|
321
|
+
]),
|
|
322
|
+
).toBe(2);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe("the steps that research", () => {
|
|
327
|
+
beforeEach(() => {
|
|
328
|
+
// `stepEnv` falls back to the process env when no host has published one,
|
|
329
|
+
// which is exactly the case a spec is. `unstubEnvs` clears it per test.
|
|
330
|
+
vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The SDK's fake gateway, installed.
|
|
335
|
+
*
|
|
336
|
+
* The fake itself is `@alexkroman1/aai/testing`'s — it answers a QUEUE of
|
|
337
|
+
* completions, repeating the last, which is what a spec needs for a loop that
|
|
338
|
+
* is a CONVERSATION (search, then read, then stop) rather than one call. What
|
|
339
|
+
* stays here is the INSTALLATION, because the lifetime of a global stub is
|
|
340
|
+
* vitest's business and the SDK helper deliberately carries no test-runner
|
|
341
|
+
* dependency.
|
|
342
|
+
*/
|
|
343
|
+
|
|
344
|
+
/** The prompt the Nth model call carried. */
|
|
345
|
+
function promptOf(calls: readonly StubGatewayCall[], at: number): string {
|
|
346
|
+
return calls[at]?.prompt ?? "";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const brief = { brief: "How otters use tools", criteria: ["Which species", "How it is learned"] };
|
|
350
|
+
|
|
351
|
+
test("writeBrief turns a spoken request into a brief and its criteria", async () => {
|
|
352
|
+
const calls = stubGateway([
|
|
353
|
+
JSON.stringify({ brief: "How otters use tools", criteria: ["Which species"] }),
|
|
354
|
+
]);
|
|
355
|
+
expect(await writeBrief("otters")).toEqual({
|
|
356
|
+
brief: "How otters use tools",
|
|
357
|
+
criteria: ["Which species"],
|
|
358
|
+
});
|
|
359
|
+
expect(promptOf(calls, 0)).toContain("otters");
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("writeBrief falls back to the topic rather than filing an empty brief", async () => {
|
|
363
|
+
// The caller said something; a model that returns no brief must not erase it.
|
|
364
|
+
stubGateway([JSON.stringify({ criteria: [] })]);
|
|
365
|
+
expect(await writeBrief("otters")).toEqual({ brief: "otters", criteria: [] });
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("planAngles asks the model for the fan-out's width", async () => {
|
|
369
|
+
const calls = stubGateway([JSON.stringify({ angles: ["Tool use", "Which species"] })]);
|
|
370
|
+
expect(await planAngles(brief)).toEqual(["Tool use", "Which species"]);
|
|
371
|
+
// The angles are measured against the brief, so the criteria travel with it.
|
|
372
|
+
expect(promptOf(calls, 0)).toContain("Which species");
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
test("planAngles researches the brief itself when no angles come back", async () => {
|
|
376
|
+
// Nothing to fan out over is a plan failure, not an empty result — and the
|
|
377
|
+
// brief is the one angle that is always available.
|
|
378
|
+
stubGateway([JSON.stringify({ angles: [] })]);
|
|
379
|
+
expect(await planAngles(brief)).toEqual([brief.brief]);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("investigate stops when the model says so, without inventing findings", async () => {
|
|
383
|
+
const calls = stubGateway([JSON.stringify({ action: "stop", why: "nothing to add" })]);
|
|
384
|
+
expect(await investigate(brief, "Tool use")).toEqual({
|
|
385
|
+
angle: "Tool use",
|
|
386
|
+
findings: "Nothing was found on this angle.",
|
|
387
|
+
sources: [],
|
|
388
|
+
});
|
|
389
|
+
// One call: it stopped, so there was nothing to compress.
|
|
390
|
+
expect(calls).toHaveLength(1);
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("investigate searches, reads, and compresses what it saw", async () => {
|
|
394
|
+
const calls = stubGateway([
|
|
395
|
+
JSON.stringify({ action: "search", query: "otter tool use" }),
|
|
396
|
+
JSON.stringify({ action: "read", url: "https://otters.example/tools" }),
|
|
397
|
+
JSON.stringify({ action: "stop", why: "enough" }),
|
|
398
|
+
JSON.stringify({
|
|
399
|
+
findings: "Sea otters crack shellfish with stones [1].",
|
|
400
|
+
sources: [{ title: "Otters", url: "https://otters.example/tools" }],
|
|
401
|
+
}),
|
|
402
|
+
]);
|
|
403
|
+
|
|
404
|
+
const note = await investigate(brief, "Tool use");
|
|
405
|
+
|
|
406
|
+
expect(webSearch).toHaveBeenCalledWith({ query: "otter tool use", max_results: 5 });
|
|
407
|
+
expect(visitWebpage).toHaveBeenCalledWith("https://otters.example/tools");
|
|
408
|
+
expect(note.findings).toContain("crack shellfish");
|
|
409
|
+
expect(note.sources).toEqual([{ title: "Otters", url: "https://otters.example/tools" }]);
|
|
410
|
+
// Everything the researcher saw reaches the compression stage, which is what
|
|
411
|
+
// keeps the journaled result small without summarizing the findings away.
|
|
412
|
+
expect(promptOf(calls, 3)).toContain("The page body.");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("investigate stops at its BUDGET, whatever the model asks for", async () => {
|
|
416
|
+
// The budget is the mechanism, not the prompt: a run whose cost is decided
|
|
417
|
+
// by a model is a run nobody can price.
|
|
418
|
+
const calls = stubGateway([JSON.stringify({ action: "search", query: "again" })]);
|
|
419
|
+
await investigate(brief, "Tool use");
|
|
420
|
+
// Six actions, then one compression call.
|
|
421
|
+
expect(calls).toHaveLength(7);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
test("a failed search costs an action rather than the whole angle", async () => {
|
|
425
|
+
vi.mocked(webSearch).mockRejectedValueOnce(new Error("search is down"));
|
|
426
|
+
const calls = stubGateway([
|
|
427
|
+
JSON.stringify({ action: "search", query: "otters" }),
|
|
428
|
+
JSON.stringify({ action: "stop", why: "give up" }),
|
|
429
|
+
JSON.stringify({ findings: "Nothing usable.", sources: [] }),
|
|
430
|
+
]);
|
|
431
|
+
const note = await investigate(brief, "Tool use");
|
|
432
|
+
expect(note.findings).toBe("Nothing usable.");
|
|
433
|
+
expect(promptOf(calls, 2)).toContain("search is down");
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("investigate retries beyond the default, because a rate limit is expected", () => {
|
|
437
|
+
expect(investigate.maxRetries).toBeGreaterThan(3);
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
test("a rate limit is RETRYABLE, so the DevKit tries again", async () => {
|
|
441
|
+
// The message alone cannot say this — a 429 and a 401 read alike — so what
|
|
442
|
+
// is asserted is the class the DevKit actually branches on.
|
|
443
|
+
stubGateway([""], { status: 429 });
|
|
444
|
+
const err = await investigate(brief, "Tool use").catch((thrown: unknown) => thrown);
|
|
445
|
+
expect(RetryableError.is(err)).toBe(true);
|
|
446
|
+
expect((err as Error).message).toMatch(/HTTP 429/);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("a rejected request is FATAL rather than retried five times", async () => {
|
|
450
|
+
stubGateway([""], { status: 401 });
|
|
451
|
+
const err = await investigate(brief, "Tool use").catch((thrown: unknown) => thrown);
|
|
452
|
+
expect(FatalError.is(err)).toBe(true);
|
|
453
|
+
expect((err as Error).message).toMatch(/HTTP 401/);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("a missing key is FATAL, naming the key", async () => {
|
|
457
|
+
vi.stubEnv("ASSEMBLYAI_API_KEY", "");
|
|
458
|
+
stubGateway(["anything"]);
|
|
459
|
+
const err = await investigate(brief, "Tool use").catch((thrown: unknown) => thrown);
|
|
460
|
+
expect(FatalError.is(err)).toBe(true);
|
|
461
|
+
expect((err as Error).message).toMatch(/ASSEMBLYAI_API_KEY/);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test("a malformed `sources` falls back to what the researcher was shown", async () => {
|
|
465
|
+
// `.catch(undefined)` on that field rather than a bare `.optional()`: the
|
|
466
|
+
// findings are already compressed by this point, and throwing them away to
|
|
467
|
+
// research the angle again is the expensive way to handle one bad field.
|
|
468
|
+
const calls = stubGateway([
|
|
469
|
+
JSON.stringify({ action: "search", query: "otters" }),
|
|
470
|
+
JSON.stringify({ action: "stop" }),
|
|
471
|
+
JSON.stringify({ findings: "Otters use stones.", sources: "not a list" }),
|
|
472
|
+
]);
|
|
473
|
+
const note = await investigate(brief, "Tool use");
|
|
474
|
+
|
|
475
|
+
expect(note.findings).toBe("Otters use stones.");
|
|
476
|
+
expect(note.sources).toEqual([{ title: "Otters", url: "https://otters.example/tools" }]);
|
|
477
|
+
// Three calls, not four: the reply was USED, not retried.
|
|
478
|
+
expect(calls).toHaveLength(3);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
test("a reply that is not JSON throws plainly, because a retry may well obey", async () => {
|
|
482
|
+
stubGateway(["I would rather write you an essay."]);
|
|
483
|
+
await expect(writeBrief("otters")).rejects.toThrow(/Expected JSON/);
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
test("findGaps asks nothing when the first wave found nothing", async () => {
|
|
487
|
+
const calls = stubGateway([JSON.stringify({ angles: ["anything"] })]);
|
|
488
|
+
expect(await findGaps(brief, [])).toEqual([]);
|
|
489
|
+
expect(calls).toHaveLength(0);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("findGaps names what is still unanswered against the criteria", async () => {
|
|
493
|
+
const calls = stubGateway([JSON.stringify({ angles: ["How it is learned"] })]);
|
|
494
|
+
const gaps = await findGaps(brief, [
|
|
495
|
+
{ angle: "Tool use", findings: "They use stones.", sources: [] },
|
|
496
|
+
]);
|
|
497
|
+
expect(gaps).toEqual(["How it is learned"]);
|
|
498
|
+
expect(promptOf(calls, 0)).toContain("They use stones.");
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("writeReport writes the report AND the sentence a phone can carry", async () => {
|
|
502
|
+
// Two model calls in ONE step, because they are one decision: a resume must
|
|
503
|
+
// never pair a new summary with an old report.
|
|
504
|
+
const calls = stubGateway(["# Otters\n\nThey use stones [1].", "Otters use stones as tools."]);
|
|
505
|
+
const written = await writeReport("otters", brief, [
|
|
506
|
+
{ angle: "Tool use", findings: "They use stones.", sources: [] },
|
|
507
|
+
]);
|
|
508
|
+
|
|
509
|
+
expect(written.report).toContain("# Otters");
|
|
510
|
+
expect(written.summary).toBe("Otters use stones as tools.");
|
|
511
|
+
expect(calls).toHaveLength(2);
|
|
512
|
+
// Nothing researched is dropped on the way in.
|
|
513
|
+
expect(promptOf(calls, 0)).toContain("They use stones.");
|
|
514
|
+
// …and the summary is a reduction OF the report, not a second pass at the
|
|
515
|
+
// findings — which is what keeps it consistent with what a page renders.
|
|
516
|
+
expect(promptOf(calls, 1)).toContain("# Otters");
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("an empty completion throws rather than filing a blank report", async () => {
|
|
520
|
+
stubGateway([""]);
|
|
521
|
+
await expect(
|
|
522
|
+
writeReport("otters", brief, [{ angle: "a", findings: "b", sources: [] }]),
|
|
523
|
+
).rejects.toThrow(/empty completion/);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* A voice agent that hands work off to a durable workflow — the worked example
|
|
4
|
+
* for `agent({ workflows })`.
|
|
5
|
+
*
|
|
6
|
+
* This is the HANDOFF shape: a caller is on the line, so a tool starts a run and
|
|
7
|
+
* answers the turn. When the workflow IS the product — a form rather than a
|
|
8
|
+
* call — the agent is declared with `workflowApp()` instead and has no session
|
|
9
|
+
* at all; `link-digest` is that one, at its smallest.
|
|
10
|
+
*
|
|
11
|
+
* The whole point is the thing a voice agent cannot otherwise do: **answer the
|
|
12
|
+
* caller now, finish the work later.** Research takes minutes; the caller is on
|
|
13
|
+
* the line. So `request_research` starts a run and returns in the same turn, the
|
|
14
|
+
* run outlives the call, and a LATER call reads the result back.
|
|
15
|
+
*
|
|
16
|
+
* ## The four tools are four files
|
|
17
|
+
*
|
|
18
|
+
* `tools/` is the tool list — a file there IS a tool, named by its own filename —
|
|
19
|
+
* so this module declares the agent and the workflow it hands off to, and nothing
|
|
20
|
+
* about tools. The declaration they all share lives in `shared.ts`, because a
|
|
21
|
+
* tool starts a run by passing the DEFINITION rather than its name.
|
|
22
|
+
*
|
|
23
|
+
* ## And it SAYS SO when the work lands
|
|
24
|
+
*
|
|
25
|
+
* `start(…, { notify })` is what closes the loop that used to be open: the agent
|
|
26
|
+
* promised an update, the run finished, and nothing made it speak — the caller
|
|
27
|
+
* had to think to ask again. With it, a finished run takes an unprompted,
|
|
28
|
+
* interruptible turn on this session, built from the run's own output.
|
|
29
|
+
*
|
|
30
|
+
* Two limits worth knowing, both by construction. It reaches the session that
|
|
31
|
+
* STARTED the run, only while that session is alive — an announcement into a
|
|
32
|
+
* call that has ended is nobody's — and it needs a transport that can take an
|
|
33
|
+
* unprompted turn, which pipeline mode can and S2S cannot. That is why `key`
|
|
34
|
+
* stays: the next call still finds the run.
|
|
35
|
+
*
|
|
36
|
+
* ## The correlation key is what makes the second call possible
|
|
37
|
+
*
|
|
38
|
+
* `start()` hands back a `runId`, and the obvious place for a tool to keep it is
|
|
39
|
+
* a `sessionSlot` — which is swept shortly after the caller hangs up. So the
|
|
40
|
+
* run outlives the session and the only handle to it does not. Passing
|
|
41
|
+
* `{ key: ctx.sessionId }` puts the run in an index the agent can search later
|
|
42
|
+
* with `find`, without maintaining its own table.
|
|
43
|
+
*
|
|
44
|
+
* `ctx.sessionId` keys THIS call. A real desk would key on something that
|
|
45
|
+
* survives across calls — the caller's phone number, an account id — so
|
|
46
|
+
* "what happened to my research?" works from a different session. The mechanism
|
|
47
|
+
* is identical; only the key changes.
|
|
48
|
+
*
|
|
49
|
+
* ## What is NOT here
|
|
50
|
+
*
|
|
51
|
+
* No `ctx.step`, no `ctx.waitFor`. Steps are `"use step"` functions in
|
|
52
|
+
* `workflows/research.ts` and waitpoints are the Workflow DevKit's own
|
|
53
|
+
* `defineHook()`. The SDK's job is declaring the workflow and starting runs; the
|
|
54
|
+
* durable execution belongs to `workflow`.
|
|
55
|
+
*
|
|
56
|
+
* ## The research is real, and it really searches the web
|
|
57
|
+
*
|
|
58
|
+
* `workflows/research.ts` is a deep-research pass, not three model calls in a
|
|
59
|
+
* row: it writes a brief, plans the angles worth pursuing, gives each angle its
|
|
60
|
+
* own researcher step that SEARCHES and READS until its budget runs out, asks
|
|
61
|
+
* what is still unanswered, and only then writes the report. The search and the
|
|
62
|
+
* page reads go through `webSearch`/`visitWebpage` from `@alexkroman1/aai/tools`
|
|
63
|
+
* — the same implementations behind the model-facing builtins, which is the
|
|
64
|
+
* point: a step is not a lesser environment than a tool body.
|
|
65
|
+
*
|
|
66
|
+
* The model calls go through the same `ASSEMBLYAI_API_KEY` this agent's voice
|
|
67
|
+
* pipeline uses. A step is handed no `ToolContext`, so it reads that key with
|
|
68
|
+
* `requireStepEnv` rather than `ctx.env`; see that file's module doc for the one
|
|
69
|
+
* thing that changes under `aai dev` (the key has to be in `.env`, not just your
|
|
70
|
+
* shell).
|
|
71
|
+
*
|
|
72
|
+
* Requires storage (`aai storage enable`, or `DATABASE_URL` under `aai dev`) —
|
|
73
|
+
* runs and the key index both live there.
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
import { agent } from "@alexkroman1/aai";
|
|
77
|
+
import { research } from "./shared.ts";
|
|
78
|
+
|
|
79
|
+
export default agent({
|
|
80
|
+
name: "Research Desk",
|
|
81
|
+
greeting: "Research desk. What would you like me to look into?",
|
|
82
|
+
systemPrompt: [
|
|
83
|
+
"You take research requests over the phone and read back results.",
|
|
84
|
+
"When someone asks you to research something, call request_research and tell them",
|
|
85
|
+
"you have started it — do NOT wait for it or promise a time. You WILL be told",
|
|
86
|
+
"when it lands, so it is safe to say you will let them know.",
|
|
87
|
+
"When someone asks about earlier work, call research_status.",
|
|
88
|
+
"If they ask what is happening right now, call research_progress.",
|
|
89
|
+
"If they say they need it immediately, call file_it_now.",
|
|
90
|
+
"Keep replies to one or two sentences; this is a voice call.",
|
|
91
|
+
].join(" "),
|
|
92
|
+
|
|
93
|
+
workflows: { research },
|
|
94
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Copyright 2026 the AAI authors. MIT license.
|
|
2
|
+
/**
|
|
3
|
+
* The workflow DECLARATION, in a module both `agent.ts` and every tool can
|
|
4
|
+
* import.
|
|
5
|
+
*
|
|
6
|
+
* It lives here rather than in `agent.ts` because all four tools name it —
|
|
7
|
+
* `ctx.workflows.start(research, …)` takes the definition itself rather than its
|
|
8
|
+
* name, which is what types the input and makes a typo a compile error instead of
|
|
9
|
+
* a rejected promise the model reads as a tool failure. A tool is its own file,
|
|
10
|
+
* so "both halves import the declaration" needs the declaration to have a home
|
|
11
|
+
* that is neither half.
|
|
12
|
+
*
|
|
13
|
+
* The `"use workflow"` BODY stays in `workflows/research.ts`: the Workflow DevKit
|
|
14
|
+
* builder scans that directory and rewrites what it finds, and a body written
|
|
15
|
+
* anywhere else runs inline once with no durability and nothing saying so.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { workflow } from "@alexkroman1/aai";
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
import { researchFlow } from "./workflows/research.ts";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The declaration: schema, description, and the directive body.
|
|
24
|
+
*
|
|
25
|
+
* Exported so a client page could derive its output type with `WorkflowOutputOf`.
|
|
26
|
+
*/
|
|
27
|
+
export const research = workflow({
|
|
28
|
+
description:
|
|
29
|
+
"Research a topic properly — brief, angles, web search per angle, a gap pass, then a written report",
|
|
30
|
+
input: z.object({
|
|
31
|
+
topic: z.string().min(3).describe("What to research"),
|
|
32
|
+
requestedBy: z.string().describe("Who asked — used when filing the result"),
|
|
33
|
+
}),
|
|
34
|
+
run: researchFlow,
|
|
35
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { tool } from "@alexkroman1/aai";
|
|
2
|
+
import { research } from "../shared.ts";
|
|
3
|
+
|
|
4
|
+
export default tool({
|
|
5
|
+
description:
|
|
6
|
+
"Skip the review wait on the research and file it immediately. Use when the caller says they need it now.",
|
|
7
|
+
execute: async (_args, ctx) => {
|
|
8
|
+
const [latest] = await ctx.workflows.find(research, ctx.sessionId, { limit: 1 });
|
|
9
|
+
if (!latest) return { note: "Nothing started yet." };
|
|
10
|
+
// The counterpart of the `sleep` in `workflows/research.ts`. Without it
|
|
11
|
+
// the only handle on a sleeping run is `cancel`, so "send it now" and
|
|
12
|
+
// "throw it away" would be the same button — and the wait a real desk
|
|
13
|
+
// uses is hours, not the thirty seconds this template ships.
|
|
14
|
+
//
|
|
15
|
+
// `0` is an honest answer, not a failure: the run had already moved past
|
|
16
|
+
// its wait, or finished.
|
|
17
|
+
const woken = await ctx.workflows.wakeUp(latest.runId);
|
|
18
|
+
return woken > 0
|
|
19
|
+
? { filed: true, note: "Filing it now." }
|
|
20
|
+
: { filed: false, note: "That one was not waiting — it has already moved on." };
|
|
21
|
+
},
|
|
22
|
+
});
|