@alexkroman1/aai-cli 13.2.0 → 14.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin.mjs +1 -1
- package/dist/{_agent-BTwZJu4P.mjs → _agent-BzUeqOdj.mjs} +33 -10
- package/dist/_agent.d.ts +23 -0
- package/dist/{_bundler-C31rlcYz.mjs → _bundler-DolUCMxu.mjs} +9 -8
- package/dist/_cli-common.d.ts +45 -6
- package/dist/{_config-CmJOFsAP.mjs → _config-DYzC6WMD.mjs} +7 -4
- package/dist/_config.d.ts +25 -1
- package/dist/_dev-env.d.ts +1 -1
- package/dist/{_dev-server-DWvkNoIS.mjs → _dev-server-CSMqF8PN.mjs} +28 -30
- package/dist/_dev-vite-config.d.ts +3 -3
- package/dist/_e2e-test-utils.d.ts +15 -7
- package/dist/{_init-BURbdHNM.mjs → _init-CQ8idAwo.mjs} +64 -30
- package/dist/{_output-CKkmWs7i.mjs → _output-DBP9Op_d.mjs} +43 -3
- package/dist/_output.d.ts +24 -7
- package/dist/{_preflight-Co1G9Jww.mjs → _preflight-CcAR577V.mjs} +3 -3
- package/dist/_resource-commands.d.ts +9 -9
- package/dist/{_server-common-6e0QI4mq.mjs → _server-common-vILJp3it.mjs} +3 -3
- package/dist/{_slug-api-CfGQq1Tt.mjs → _slug-api-CGvc2YSQ.mjs} +19 -10
- package/dist/_slug-api.d.ts +19 -0
- package/dist/{_studio-B1waXMuR.mjs → _studio-DnR_BqFp.mjs} +9 -5
- package/dist/_studio-commands.d.ts +13 -13
- package/dist/{_templates-0Zr2z6yA.mjs → _templates-CK4oKoeX.mjs} +3 -3
- package/dist/{_typecheck-gate-BXvUNnfs.mjs → _typecheck-gate-U3jcil8L.mjs} +5 -4
- package/dist/_typecheck-gate.d.ts +3 -1
- package/dist/{_ui-u7T4YooX.mjs → _ui-DCt4qZrk.mjs} +1 -1
- package/dist/{_utils-B8QmtFhK.mjs → _utils-D5JGcjiW.mjs} +44 -2
- package/dist/_utils.d.ts +35 -0
- package/dist/{_vite-env-D9Qj6KPX.mjs → _vite-env-kejF0p2L.mjs} +1 -1
- package/dist/{_workflow-determinism-f9J-5Zlj.mjs → _workflow-determinism-CvpqMpdh.mjs} +19 -27
- package/dist/_workflow-determinism.d.ts +3 -3
- package/dist/build-Mxk8gWvX.mjs +108 -0
- package/dist/build.d.ts +5 -0
- package/dist/cli.mjs +172 -116
- package/dist/{client-bundler-j5c46x0G.mjs → client-bundler-BJgREAh6.mjs} +4 -4
- package/dist/client-bundler.mjs +1 -1
- package/dist/{delete-X_CRgCf6.mjs → delete-BdFnbnVR.mjs} +6 -6
- package/dist/{deploy-BzZ-r4iM.mjs → deploy-uAJ4NukN.mjs} +14 -14
- package/dist/{dev-BDykFvcc.mjs → dev-DApPSaE_.mjs} +5 -5
- package/dist/{eval-0qEOdm0l.mjs → eval-BK47A_K5.mjs} +27 -11
- package/dist/eval.d.ts +2 -0
- package/dist/{init-BtJI306C.mjs → init-DukDxECd.mjs} +13 -21
- package/dist/{login-DeUETobb.mjs → login-DaLrPOFg.mjs} +6 -8
- package/dist/{logs-hWnJ66Bl.mjs → logs-7BRwq0cG.mjs} +22 -15
- package/dist/project-config.mjs +1 -1
- package/dist/scaffold/.env.example +17 -1
- package/dist/scaffold/CLAUDE.md +84 -56
- package/dist/scaffold/package.json +6 -5
- package/dist/scaffold/server.mjs +129 -25
- package/dist/secret-Cjp912yF.mjs +162 -0
- package/dist/secret.d.ts +48 -13
- package/dist/{studio-BxAS_FQQ.mjs → studio-CpHlNHUZ.mjs} +15 -17
- package/dist/templates/briefing-desk/agent.test.ts +7 -3
- package/dist/templates/call-audit/agent.test.ts +25 -9
- package/dist/templates/call-audit/agent.ts +2 -2
- package/dist/templates/call-audit/client.tsx +40 -58
- package/dist/templates/call-audit/workflows/audit.ts +7 -5
- package/dist/templates/call-audit/workflows/ingest.ts +7 -7
- package/dist/templates/call-audit/workflows/media.ts +12 -37
- package/dist/templates/call-audit/workflows/summarize.ts +8 -8
- package/dist/templates/call-audit/workflows/sync-api.ts +3 -3
- package/dist/templates/dispatch-center/agent.test.ts +7 -7
- package/dist/templates/dispatch-center/client.tsx +53 -61
- package/dist/templates/dispatch-center/shared.ts +0 -8
- package/dist/templates/dispatch-center/tools/ops_dashboard.ts +4 -1
- package/dist/templates/embedded-assets/agent.eval.test.ts +4 -2
- package/dist/templates/embedded-assets/agent.test.ts +7 -1
- package/dist/templates/health-assistant/agent.eval.test.ts +4 -2
- package/dist/templates/health-assistant/agent.test.ts +8 -4
- package/dist/templates/infocom-adventure/client.tsx +40 -36
- package/dist/templates/link-digest/agent.test.ts +10 -4
- package/dist/templates/link-digest/agent.ts +2 -2
- package/dist/templates/link-digest/client.tsx +42 -36
- package/dist/templates/link-digest/workflows/digest.ts +13 -16
- package/dist/templates/night-owl/agent.eval.test.ts +4 -2
- package/dist/templates/night-owl/agent.test.ts +6 -1
- package/dist/templates/night-owl/client.tsx +8 -2
- package/dist/templates/pipeline-simple/agent.test.ts +33 -16
- package/dist/templates/pizza-ordering/client.tsx +5 -4
- package/dist/templates/pizza-ordering/shared.ts +3 -8
- package/dist/templates/pizza-ordering/tools/add_pizza.ts +3 -2
- package/dist/templates/pizza-ordering/tools/place_order.ts +3 -2
- package/dist/templates/pizza-ordering/tools/remove_pizza.ts +3 -2
- package/dist/templates/pizza-ordering/tools/update_pizza.ts +3 -2
- package/dist/templates/pizza-ordering/tools/view_order.ts +3 -2
- package/dist/templates/plan-and-execute/agent.test.ts +10 -5
- package/dist/templates/plan-and-execute/client.tsx +2 -2
- package/dist/templates/podcast-digest/agent.test.ts +33 -14
- package/dist/templates/podcast-digest/client.tsx +27 -36
- package/dist/templates/podcast-digest/workflows/digest.ts +21 -19
- package/dist/templates/podcast-digest/workflows/feeds.ts +61 -33
- package/dist/templates/podcast-digest/workflows/slack.ts +7 -7
- package/dist/templates/recap-workflow/agent.eval.test.ts +21 -13
- package/dist/templates/recap-workflow/agent.test.ts +49 -32
- package/dist/templates/recap-workflow/workflows/recap.ts +24 -28
- package/dist/templates/redline/agent.eval.test.ts +5 -8
- package/dist/templates/redline/agent.test.ts +8 -2
- package/dist/templates/redline/client.tsx +47 -48
- package/dist/templates/redline/workflows/redline.ts +17 -17
- package/dist/templates/research-workflow/agent.eval.test.ts +5 -5
- package/dist/templates/research-workflow/agent.test.ts +17 -8
- package/dist/templates/research-workflow/workflows/research.ts +28 -30
- package/dist/templates/retail/agent.test.ts +17 -15
- package/dist/templates/retail/cancel.ts +7 -6
- package/dist/templates/retail/client.tsx +39 -58
- package/dist/templates/retail/payment.ts +4 -3
- package/dist/templates/retail/pending.test.ts +19 -19
- package/dist/templates/retail/registry.test.ts +99 -58
- package/dist/templates/retail/store.test.ts +5 -5
- package/dist/templates/retail/swap.ts +8 -11
- package/dist/templates/simple/agent.test.ts +39 -6
- package/dist/templates/solo-rpg/agent.eval.test.ts +2 -16
- package/dist/templates/solo-rpg/agent.test.ts +12 -12
- package/dist/templates/solo-rpg/client.tsx +3 -3
- package/dist/templates/spoken-summary/agent.test.ts +21 -12
- package/dist/templates/spoken-summary/agent.ts +3 -3
- package/dist/templates/spoken-summary/client.tsx +36 -47
- package/dist/templates/spoken-summary/workflows/summarize.ts +11 -11
- package/dist/templates/spoken-summary/workflows/transcribe.ts +14 -14
- package/dist/templates/support-line/client.tsx +4 -3
- package/dist/templates/support-line/prompts.ts +0 -2
- package/dist/templates/transcription-workflow/agent.test.ts +36 -20
- package/dist/templates/transcription-workflow/agent.ts +2 -2
- package/dist/templates/transcription-workflow/client.tsx +34 -40
- package/dist/templates/transcription-workflow/recover.ts +1 -1
- package/dist/templates/transcription-workflow/run-panel.tsx +34 -14
- package/dist/templates/transcription-workflow/total-latency.tsx +46 -20
- package/dist/templates/transcription-workflow/workflows/batch.ts +15 -15
- package/dist/templates/transcription-workflow/workflows/downsample.ts +1 -1
- package/dist/templates/transcription-workflow/workflows/normalize.ts +15 -19
- package/dist/templates/transcription-workflow/workflows/stream.ts +12 -12
- package/dist/templates/transcription-workflow/workflows/sync-api.ts +3 -3
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +23 -34
- package/dist/templates/travel-concierge/agent.eval.test.ts +2 -2
- package/dist/templates/travel-concierge/agent.test.ts +15 -7
- package/dist/templates/travel-concierge/client.tsx +2 -2
- package/dist/templates/travel-concierge/shared.ts +10 -14
- package/dist/templates/travel-concierge/tools/lookup_booking.ts +4 -3
- package/dist/templates/travel-concierge/tools/search_car_rentals.ts +3 -2
- package/dist/templates/travel-concierge/tools/search_excursions.ts +3 -2
- package/dist/templates/travel-concierge/tools/search_flights.ts +3 -2
- package/dist/templates/travel-concierge/tools/search_hotels.ts +3 -2
- package/dist/test-D032V2cA.mjs +242 -0
- package/dist/test.d.ts +97 -31
- package/dist/typecheck.mjs +2 -2
- package/dist/worker-bundler.mjs +3 -3
- package/dist/{workflow-BBu5oEDw.mjs → workflow-CVioyO3V.mjs} +8 -7
- package/package.json +10 -10
- package/dist/bin.d.mts +0 -2
- package/dist/build-BLlLOrng.mjs +0 -66
- package/dist/secret-B-3T5tvr.mjs +0 -61
- package/dist/test-CiLab-AA.mjs +0 -182
- package/dist/tsdown.config.d.ts +0 -2
|
@@ -45,8 +45,8 @@
|
|
|
45
45
|
* ingest result, once as the plan) and buy nothing.
|
|
46
46
|
*/
|
|
47
47
|
|
|
48
|
-
import type {
|
|
49
|
-
import { encodeWav, mapConcurrent,
|
|
48
|
+
import type { WorkflowContext } from "@alexkroman1/aai";
|
|
49
|
+
import { encodeWav, mapConcurrent, stepReadUpload, stepReport } from "@alexkroman1/aai/step";
|
|
50
50
|
import { countWords, formatDuration } from "@alexkroman1/aai/utils";
|
|
51
51
|
// ERASED at build time, so the body can name the schema's own output type without
|
|
52
52
|
// a runtime cycle back through `agent.ts` — the same mechanism `client.tsx` uses
|
|
@@ -156,7 +156,7 @@ export type CallAudit = {
|
|
|
156
156
|
*/
|
|
157
157
|
export async function auditFlow(
|
|
158
158
|
input: WorkflowInputOf<typeof audit>,
|
|
159
|
-
ctx:
|
|
159
|
+
ctx: WorkflowContext,
|
|
160
160
|
): Promise<CallAudit> {
|
|
161
161
|
// Both at once: neither needs the other, and issued together they are one round
|
|
162
162
|
// trip instead of two before any audio moves. The ORDER is still a pure function
|
|
@@ -259,12 +259,14 @@ export async function transcribeSegment(audioId: string, segment: Segment): Prom
|
|
|
259
259
|
// ORDER is not guaranteed here and does not need to be — the calls go out
|
|
260
260
|
// together, so their lines interleave by completion, and `segment.index` is what
|
|
261
261
|
// puts the TRANSCRIPT back in order.
|
|
262
|
-
await
|
|
262
|
+
await stepReport(
|
|
263
|
+
`Transcribing ${formatDuration(segment.startMs)}–${formatDuration(segment.endMs)}.`,
|
|
264
|
+
);
|
|
263
265
|
|
|
264
266
|
// `[start, end)`, the same half-open pair `planSegments` produced — the store
|
|
265
267
|
// owns the conversion to HTTP's inclusive range, so there is no `- 1` here to get
|
|
266
268
|
// wrong.
|
|
267
|
-
const audio = await
|
|
269
|
+
const audio = await stepReadUpload(audioId, { start: segment.startByte, end: segment.endByte });
|
|
268
270
|
const text = await transcribeSpan(
|
|
269
271
|
encodeWav(audio.bytes, ANALYSIS_FORMAT),
|
|
270
272
|
`segment-${segment.index}.wav`,
|
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
import { readFile, stat } from "node:fs/promises";
|
|
64
64
|
import { join } from "node:path";
|
|
65
65
|
import { probeMedia, runFfmpeg } from "@alexkroman1/aai/ffmpeg";
|
|
66
|
-
import { pcmDurationMs,
|
|
66
|
+
import { pcmDurationMs, stepReport, stepRequireCompleteUpload } from "@alexkroman1/aai/step";
|
|
67
67
|
import { throwFatalStepError, throwFfmpegStepError } from "@alexkroman1/aai/step-errors";
|
|
68
68
|
import { readUploadToFile, withTempDir, writeUploadFromFile } from "@alexkroman1/aai/step-files";
|
|
69
69
|
import { formatBytes, formatDuration, plural } from "@alexkroman1/aai/utils";
|
|
@@ -132,10 +132,10 @@ export type Ingested = {
|
|
|
132
132
|
// nothing. Removing them is what took the file under the floor: a directive
|
|
133
133
|
// propping a coverage number up is the least useful statement in the tree.
|
|
134
134
|
export async function ingestRecording(uploadId: string): Promise<Ingested> {
|
|
135
|
-
// `
|
|
135
|
+
// `stepRequireCompleteUpload`, not `stepUploadInfo`: `size` is the readable PREFIX, so
|
|
136
136
|
// an upload still arriving would be copied short and levelled as the whole call.
|
|
137
|
-
const stored = await
|
|
138
|
-
await
|
|
137
|
+
const stored = await stepRequireCompleteUpload(uploadId);
|
|
138
|
+
await stepReport(`Reading ${stored.name || uploadId} (${formatBytes(stored.size)}).`);
|
|
139
139
|
|
|
140
140
|
return await withTempDir(
|
|
141
141
|
async (dir) => {
|
|
@@ -148,7 +148,7 @@ export async function ingestRecording(uploadId: string): Promise<Ingested> {
|
|
|
148
148
|
// `STEP_FILE_READ_CONCURRENCY` of them. Passing `size` means "I am judging
|
|
149
149
|
// completeness myself", which is what a body polling a still-arriving
|
|
150
150
|
// upload needs and is the opposite of what happened above: this step has
|
|
151
|
-
// already called `
|
|
151
|
+
// already called `stepRequireCompleteUpload`, so the file IS whole and the
|
|
152
152
|
// windows may land in any order. Omitting it lets `readUploadToFile`
|
|
153
153
|
// establish that for itself and fan out. The cost is one metadata round
|
|
154
154
|
// trip, against the dozens of window reads it overlaps.
|
|
@@ -162,7 +162,7 @@ export async function ingestRecording(uploadId: string): Promise<Ingested> {
|
|
|
162
162
|
throwFfmpegStepError,
|
|
163
163
|
);
|
|
164
164
|
const codec = probed.audio?.codec ?? "unknown";
|
|
165
|
-
await
|
|
165
|
+
await stepReport(
|
|
166
166
|
`Levelling ${describeSource(codec, probed.durationSec)} to ${ANALYSIS_FORMAT.sampleRate / 1000} kHz mono.`,
|
|
167
167
|
);
|
|
168
168
|
|
|
@@ -206,7 +206,7 @@ export async function ingestRecording(uploadId: string): Promise<Ingested> {
|
|
|
206
206
|
type: "application/octet-stream",
|
|
207
207
|
});
|
|
208
208
|
|
|
209
|
-
await
|
|
209
|
+
await stepReport(
|
|
210
210
|
`Levelled ${formatDuration(durationMs)} from ${loudness.inputLufs} LUFS, ` +
|
|
211
211
|
`${Math.round(speechFraction(silences, durationMs / 1000) * 100)}% speech across ` +
|
|
212
212
|
`${silences.length} ${plural(silences.length, "pause")}.`,
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* ## The argv is ours, so it is BUILT rather than embedded
|
|
17
17
|
*
|
|
18
18
|
* `runFfmpeg` passes `args` through verbatim — no `-y`, no `-loglevel` — so the
|
|
19
|
-
* standing flags
|
|
19
|
+
* standing flags come from `ffmpegBaseArgs` on `@alexkroman1/aai/ffmpeg`, and
|
|
20
20
|
* every invocation's real argv is a value a test can assert on. It is also why
|
|
21
21
|
* the filter strings below carry no shell quoting: each is ONE element of an
|
|
22
22
|
* argv array, so the commas that chain filters and the colons that separate their
|
|
@@ -58,7 +58,9 @@
|
|
|
58
58
|
* longer than the cap gets the blind cut, and says so.
|
|
59
59
|
*/
|
|
60
60
|
|
|
61
|
+
import { ffmpegBaseArgs } from "@alexkroman1/aai/ffmpeg";
|
|
61
62
|
import type { PcmFormat } from "@alexkroman1/aai/step";
|
|
63
|
+
import { safeJsonParse } from "@alexkroman1/aai/utils";
|
|
62
64
|
import { z } from "zod";
|
|
63
65
|
|
|
64
66
|
/**
|
|
@@ -151,20 +153,6 @@ export const MAX_SEGMENT_SECONDS = 110;
|
|
|
151
153
|
*/
|
|
152
154
|
export const MIN_SEGMENT_SECONDS = 1;
|
|
153
155
|
|
|
154
|
-
/** The standing flags, on every invocation this desk makes. */
|
|
155
|
-
export function standardFlags(): string[] {
|
|
156
|
-
return [
|
|
157
|
-
"-hide_banner",
|
|
158
|
-
// Progress lines are noise in a captured stderr, and the SDK keeps only a
|
|
159
|
-
// tail of it — so suppressing them is what leaves room for the diagnosis.
|
|
160
|
-
"-nostats",
|
|
161
|
-
// In a guest there is no terminal, and an ffmpeg that decides to read stdin
|
|
162
|
-
// is a process that never exits.
|
|
163
|
-
"-nostdin",
|
|
164
|
-
"-y",
|
|
165
|
-
];
|
|
166
|
-
}
|
|
167
|
-
|
|
168
156
|
/** A loudness measurement, as `loudnorm`'s first pass reports it. */
|
|
169
157
|
export type Loudness = {
|
|
170
158
|
/** Integrated loudness, LUFS. */
|
|
@@ -233,9 +221,9 @@ export class MediaAnalysisError extends Error {
|
|
|
233
221
|
*/
|
|
234
222
|
export function measureLoudnessArgs(input: string): string[] {
|
|
235
223
|
return [
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
"info",
|
|
224
|
+
// `info`, not the default `error`: `print_format=json` reports through the
|
|
225
|
+
// LOG, so at `error` this pass runs, succeeds, and prints nothing.
|
|
226
|
+
...ffmpegBaseArgs({ loglevel: "info" }),
|
|
239
227
|
"-i",
|
|
240
228
|
input,
|
|
241
229
|
"-af",
|
|
@@ -273,7 +261,7 @@ export function parseLoudness(stderr: string): Loudness {
|
|
|
273
261
|
// different remedies, and the second message quotes the value ffmpeg actually
|
|
274
262
|
// printed — which needs the block still in hand, so the gate cannot be folded
|
|
275
263
|
// into the schema below.
|
|
276
|
-
const block = LoudnessBlock.safeParse(
|
|
264
|
+
const block = LoudnessBlock.safeParse(safeJsonParse(stderr.slice(open, close + 1)));
|
|
277
265
|
if (!block.success) {
|
|
278
266
|
throw new MediaAnalysisError("The loudness pass printed a block that is not JSON.");
|
|
279
267
|
}
|
|
@@ -301,7 +289,7 @@ export function parseLoudness(stderr: string): Loudness {
|
|
|
301
289
|
/**
|
|
302
290
|
* Is the found `{…}` an object at all?
|
|
303
291
|
*
|
|
304
|
-
* The reachable failure is {@link
|
|
292
|
+
* The reachable failure is {@link safeJsonParse} answering `undefined` — a brace pair
|
|
305
293
|
* found in ffmpeg's chatter with something other than JSON between them — and
|
|
306
294
|
* that is a different sentence from a value being unreadable, which is why this
|
|
307
295
|
* gate exists at all rather than being folded into {@link LoudnessValues}.
|
|
@@ -370,12 +358,10 @@ export function normalizeArgs(
|
|
|
370
358
|
].join(":");
|
|
371
359
|
|
|
372
360
|
return [
|
|
373
|
-
...standardFlags(),
|
|
374
361
|
// Quiet, and the analysis still arrives: `ametadata` writes its file
|
|
375
362
|
// directly rather than through the log, which is the property that lets this
|
|
376
363
|
// pass be both silent and complete.
|
|
377
|
-
|
|
378
|
-
"error",
|
|
364
|
+
...ffmpegBaseArgs(),
|
|
379
365
|
"-i",
|
|
380
366
|
input,
|
|
381
367
|
"-af",
|
|
@@ -456,7 +442,7 @@ export function parseSilences(log: string, durationSec: number): Silence[] {
|
|
|
456
442
|
* Exact rather than rounded, and that distinction cost a bug: `pcmDurationMs`
|
|
457
443
|
* answers whole MILLISECONDS, so a 640,500-byte file reports 20,016 ms where it
|
|
458
444
|
* really holds 20,015.625. Planning from the rounded number put the last segment's
|
|
459
|
-
* `endByte` at 640,512 — twelve bytes past the end of the file. `
|
|
445
|
+
* `endByte` at 640,512 — twelve bytes past the end of the file. `stepReadUpload` clamps
|
|
460
446
|
* a window to the stored size, so nothing threw; the plan was simply describing
|
|
461
447
|
* audio that does not exist. Verified against a real ffmpeg, which is the only
|
|
462
448
|
* place a 12-byte error was ever going to show up.
|
|
@@ -474,7 +460,7 @@ export function durationSeconds(totalBytes: number): number {
|
|
|
474
460
|
* Greedy from the front: a segment grows until the next cut candidate would take
|
|
475
461
|
* it past {@link MAX_SEGMENT_SECONDS}, so it ends at the LAST pause that still
|
|
476
462
|
* fits. Segments are therefore contiguous and non-overlapping — together they are
|
|
477
|
-
* the whole recording, each one addressable as a single `
|
|
463
|
+
* the whole recording, each one addressable as a single `stepReadUpload` window.
|
|
478
464
|
*
|
|
479
465
|
* Three properties, each of which a simpler version gets wrong:
|
|
480
466
|
*
|
|
@@ -600,9 +586,7 @@ export function planSegments(silences: readonly Silence[], totalBytes: number):
|
|
|
600
586
|
*/
|
|
601
587
|
export function masterArgs(input: string, output: string): string[] {
|
|
602
588
|
return [
|
|
603
|
-
...
|
|
604
|
-
"-loglevel",
|
|
605
|
-
"error",
|
|
589
|
+
...ffmpegBaseArgs(),
|
|
606
590
|
"-i",
|
|
607
591
|
input,
|
|
608
592
|
"-af",
|
|
@@ -652,12 +636,3 @@ function value(line: string, key: string): number | undefined {
|
|
|
652
636
|
const parsed = Number(text);
|
|
653
637
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
654
638
|
}
|
|
655
|
-
|
|
656
|
-
/** `JSON.parse` that answers `undefined` rather than throwing, so the caller frames the error. */
|
|
657
|
-
function safeJson(text: string): unknown {
|
|
658
|
-
try {
|
|
659
|
-
return JSON.parse(text);
|
|
660
|
-
} catch {
|
|
661
|
-
return undefined;
|
|
662
|
-
}
|
|
663
|
-
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* `spoken-summary` owns the audio ROUND TRIP and is the template to read for it:
|
|
12
12
|
* why `stepSpeak` exists at all (a `TtsSession` is an event stream wired into a
|
|
13
13
|
* live pipeline's playback, and a step has no turn to be part of and has to return
|
|
14
|
-
* a VALUE), why `
|
|
14
|
+
* a VALUE), why `stepWriteUpload` is its other half, and why speaking and storing must
|
|
15
15
|
* be one step. None of that is restated here.
|
|
16
16
|
*
|
|
17
17
|
* **What this file adds is the pass AFTER the synthesis**, and it is the second
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
import { stat, writeFile } from "node:fs/promises";
|
|
36
36
|
import { join } from "node:path";
|
|
37
37
|
import { runFfmpeg } from "@alexkroman1/aai/ffmpeg";
|
|
38
|
-
import {
|
|
39
|
-
import {
|
|
38
|
+
import { stepReport, stepSpeak } from "@alexkroman1/aai/step";
|
|
39
|
+
import { stepGenerateJsonOrFail, throwFfmpegStepError } from "@alexkroman1/aai/step-errors";
|
|
40
40
|
import { withTempDir, writeUploadFromFile } from "@alexkroman1/aai/step-files";
|
|
41
41
|
import { formatBytes, formatDuration, omitUndefined, plural } from "@alexkroman1/aai/utils";
|
|
42
42
|
import { z } from "zod";
|
|
@@ -105,8 +105,8 @@ export async function summarize(
|
|
|
105
105
|
source: string,
|
|
106
106
|
durationMs: number,
|
|
107
107
|
): Promise<CallSummary> {
|
|
108
|
-
await
|
|
109
|
-
const reply = await
|
|
108
|
+
await stepReport("Reading the transcript.");
|
|
109
|
+
const reply = await stepGenerateJsonOrFail(
|
|
110
110
|
`Audit this transcript of a recorded call (${source}, ${formatDuration(durationMs)}).\n\n` +
|
|
111
111
|
"Answer with JSON only, in this shape:\n" +
|
|
112
112
|
`{"headline": "...", "risks": ["..."], "actions": ["..."], "spoken": "..."}\n\n` +
|
|
@@ -125,12 +125,12 @@ export async function summarize(
|
|
|
125
125
|
system: "You audit recorded calls. You answer with JSON and nothing else.",
|
|
126
126
|
schema: AuditReply,
|
|
127
127
|
},
|
|
128
|
-
// The `
|
|
128
|
+
// The `OrFail` caller is `stepGenerateJson` plus `throwStepError`, which is
|
|
129
129
|
// what reads the gateway's own status: a 429 is worth another attempt and a 400
|
|
130
130
|
// is not, and that is what tells the DevKit which.
|
|
131
131
|
);
|
|
132
132
|
|
|
133
|
-
await
|
|
133
|
+
await stepReport(
|
|
134
134
|
`Found ${reply.risks.length} ${plural(reply.risks.length, "risk")} and ` +
|
|
135
135
|
`${reply.actions.length} ${plural(reply.actions.length, "action")}.`,
|
|
136
136
|
);
|
|
@@ -186,7 +186,7 @@ export async function narrate(
|
|
|
186
186
|
type: "audio/mpeg",
|
|
187
187
|
});
|
|
188
188
|
|
|
189
|
-
await
|
|
189
|
+
await stepReport(
|
|
190
190
|
`Recorded a ${Math.round(spoken.durationMs / 1000)}s audit in ${spoken.voice}'s voice — ` +
|
|
191
191
|
`${formatBytes(bytes)} of MP3, from ${formatBytes(spoken.audio.byteLength)} of WAV.`,
|
|
192
192
|
);
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* the DevKit, and that belongs somewhere a spec can reach it.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import { stepTranscribeSyncOrFail } from "@alexkroman1/aai/step-errors";
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Transcribe one complete WAV.
|
|
@@ -21,7 +21,7 @@ import { stepTranscribeSyncClassified } from "@alexkroman1/aai/step-errors";
|
|
|
21
21
|
* stores headerless PCM on purpose (see `media.ts`) and puts a header back with
|
|
22
22
|
* `encodeWav` for exactly this call.
|
|
23
23
|
*
|
|
24
|
-
* `
|
|
24
|
+
* `stepTranscribeSyncOrFail` — the SDK's own `stepTranscribeSync` plus
|
|
25
25
|
* `throwStepError`, and nothing else — is the whole of what this adds, and it is
|
|
26
26
|
* where the three-way call is made: a `FatalError` stops the DevKit retrying something that
|
|
27
27
|
* will answer the same way, a bare `RetryableError` retries in ONE SECOND (that
|
|
@@ -39,6 +39,6 @@ export async function transcribeSpan(
|
|
|
39
39
|
filename: string,
|
|
40
40
|
label: string,
|
|
41
41
|
): Promise<string> {
|
|
42
|
-
const { text } = await
|
|
42
|
+
const { text } = await stepTranscribeSyncOrFail(bytes, { filename, label });
|
|
43
43
|
return text;
|
|
44
44
|
}
|
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
ToolInputSchema,
|
|
7
7
|
} from "@alexkroman1/aai";
|
|
8
8
|
import { isToolFailure } from "@alexkroman1/aai";
|
|
9
|
-
import { createToolContext,
|
|
9
|
+
import { createToolContext, expectToolOk } from "@alexkroman1/aai/testing";
|
|
10
10
|
import { describe, expect, test } from "vitest";
|
|
11
11
|
import { callFlow, dispatchSlot } from "./shared.ts";
|
|
12
12
|
import incidentAddNote from "./tools/incident_add_note.ts";
|
|
@@ -32,7 +32,7 @@ const makeCtx = (): ToolContext => createToolContext();
|
|
|
32
32
|
* shapes the assertions below used to restate, which were a second copy of each
|
|
33
33
|
* tool's return type that could not go stale loudly.
|
|
34
34
|
*
|
|
35
|
-
* The unwrap itself is `
|
|
35
|
+
* The unwrap itself is `expectToolOk` from `@alexkroman1/aai/testing`; the hand-rolled
|
|
36
36
|
* copy that used to sit here was byte-identical to three other templates'.
|
|
37
37
|
*/
|
|
38
38
|
type Result<T extends ToolDef<ToolInputSchema>> = Extract<
|
|
@@ -78,7 +78,7 @@ describe("dispatch-center template", () => {
|
|
|
78
78
|
const ctx = makeCtx();
|
|
79
79
|
const incidentId = await createIncidentFor(ctx, "cardiac arrest, patient not breathing");
|
|
80
80
|
|
|
81
|
-
const result =
|
|
81
|
+
const result = expectToolOk<Result<typeof resourcesDispatch>>(
|
|
82
82
|
await resourcesDispatch.execute({ incidentId, callsigns: ["auto"] }, ctx),
|
|
83
83
|
);
|
|
84
84
|
|
|
@@ -205,13 +205,13 @@ describe("the call flow", () => {
|
|
|
205
205
|
expect(created.state).toBe("working.triaging");
|
|
206
206
|
expect(created.instruction).toMatch(/incident_triage/);
|
|
207
207
|
|
|
208
|
-
const triaged =
|
|
208
|
+
const triaged = expectToolOk<Result<typeof incidentTriage>>(
|
|
209
209
|
await incidentTriage.execute({ incidentId: created.incidentId, severity: "critical" }, ctx),
|
|
210
210
|
);
|
|
211
211
|
expect(triaged.triageScore).toBeGreaterThan(0);
|
|
212
212
|
expect(at(ctx).state).toBe("working.dispatching");
|
|
213
213
|
|
|
214
|
-
|
|
214
|
+
expectToolOk(
|
|
215
215
|
await resourcesDispatch.execute({ incidentId: created.incidentId, autoDispatch: true }, ctx),
|
|
216
216
|
);
|
|
217
217
|
expect(at(ctx).state).toBe("working.monitoring");
|
|
@@ -226,7 +226,7 @@ describe("the call flow", () => {
|
|
|
226
226
|
|
|
227
227
|
// Every requested callsign is unknown, so no unit moved — and the call has
|
|
228
228
|
// not moved on either.
|
|
229
|
-
const result =
|
|
229
|
+
const result = expectToolOk<Result<typeof resourcesDispatch>>(
|
|
230
230
|
await resourcesDispatch.execute({ incidentId, callsigns: ["Ghost-1"] }, ctx),
|
|
231
231
|
);
|
|
232
232
|
expect(result.dispatched).toHaveLength(0);
|
|
@@ -245,7 +245,7 @@ describe("the call flow", () => {
|
|
|
245
245
|
|
|
246
246
|
// The first incident is still workable — the position tracks the call in
|
|
247
247
|
// hand, and the tools are addressed by id.
|
|
248
|
-
|
|
248
|
+
expectToolOk(await incidentAddNote.execute({ incidentId: first, note: "crews on scene" }, ctx));
|
|
249
249
|
});
|
|
250
250
|
|
|
251
251
|
test("a failed tool does not advance the flow", async () => {
|
|
@@ -1,12 +1,16 @@
|
|
|
1
|
+
import { plural } from "@alexkroman1/aai/utils";
|
|
1
2
|
import "@alexkroman1/aai-ui/styles.css";
|
|
2
|
-
import type { AgentState, ConversationItem
|
|
3
|
+
import type { AgentState, ConversationItem } from "@alexkroman1/aai-ui";
|
|
3
4
|
import {
|
|
5
|
+
AGENT_STATE_LABELS,
|
|
4
6
|
AutoScroll,
|
|
5
|
-
|
|
7
|
+
mountClient,
|
|
8
|
+
SessionErrorBanner,
|
|
6
9
|
useAgentState,
|
|
7
10
|
useConversation,
|
|
8
|
-
|
|
11
|
+
useSessionActions,
|
|
9
12
|
useSessionSelector,
|
|
13
|
+
useSessionStatus,
|
|
10
14
|
} from "@alexkroman1/aai-ui";
|
|
11
15
|
import type { DispatchState, IncidentSummary, Severity, Status } from "./shared.ts";
|
|
12
16
|
import { dashboardProjection } from "./shared.ts";
|
|
@@ -77,6 +81,20 @@ const STATE_COLORS = {
|
|
|
77
81
|
error: "#6b7280",
|
|
78
82
|
} satisfies Record<AgentState, string>;
|
|
79
83
|
|
|
84
|
+
/*
|
|
85
|
+
* The board's own vocabulary, spread over the package's record rather than
|
|
86
|
+
* written as a ternary chain. Only three of the seven states get a dispatch
|
|
87
|
+
* word; the rest come from `AGENT_STATE_LABELS`, so a state added upstream
|
|
88
|
+
* reads as something rather than falling through to whichever arm the chain
|
|
89
|
+
* ended on.
|
|
90
|
+
*/
|
|
91
|
+
const STATE_LABELS: Record<AgentState, string> = {
|
|
92
|
+
...AGENT_STATE_LABELS,
|
|
93
|
+
listening: "LISTENING",
|
|
94
|
+
thinking: "PROCESSING",
|
|
95
|
+
speaking: "TRANSMITTING",
|
|
96
|
+
};
|
|
97
|
+
|
|
80
98
|
function Panel({ title, children }: { title: string; children: React.ReactNode }) {
|
|
81
99
|
return (
|
|
82
100
|
<div className="rounded-lg p-3" style={{ background: "#1a1a2e", border: "1px solid #1e293b" }}>
|
|
@@ -230,8 +248,16 @@ function Conversation() {
|
|
|
230
248
|
{streaming}
|
|
231
249
|
</div>
|
|
232
250
|
)}
|
|
251
|
+
{/* Same contract as the shipped `MessageList`'s indicator: three pulsing
|
|
252
|
+
dots are the only sign the desk is working, and to a screen reader
|
|
253
|
+
they are punctuation. */}
|
|
233
254
|
{thinking && (
|
|
234
|
-
<div
|
|
255
|
+
<div
|
|
256
|
+
role="status"
|
|
257
|
+
aria-label="Dispatch is thinking"
|
|
258
|
+
className="self-start text-[11px] px-3.5"
|
|
259
|
+
style={{ color: "#64748b" }}
|
|
260
|
+
>
|
|
235
261
|
<span style={{ animation: "dc-pulse 1.2s ease-in-out infinite" }}>· · ·</span>
|
|
236
262
|
</div>
|
|
237
263
|
)}
|
|
@@ -261,7 +287,7 @@ function Conversation() {
|
|
|
261
287
|
* it one level up dragged the incident board through every snapshot change.
|
|
262
288
|
*/
|
|
263
289
|
function StatusReadout() {
|
|
264
|
-
const state =
|
|
290
|
+
const state = useSessionStatus();
|
|
265
291
|
return (
|
|
266
292
|
<>
|
|
267
293
|
<span
|
|
@@ -278,70 +304,36 @@ function StatusReadout() {
|
|
|
278
304
|
title={state}
|
|
279
305
|
/>
|
|
280
306
|
<span className="text-[11px] font-normal normal-case" style={{ color: "#64748b" }}>
|
|
281
|
-
{state
|
|
282
|
-
? "LISTENING"
|
|
283
|
-
: state === "thinking"
|
|
284
|
-
? "PROCESSING"
|
|
285
|
-
: state === "speaking"
|
|
286
|
-
? "TRANSMITTING"
|
|
287
|
-
: state.toUpperCase()}
|
|
307
|
+
{STATE_LABELS[state]}
|
|
288
308
|
</span>
|
|
289
309
|
</>
|
|
290
310
|
);
|
|
291
311
|
}
|
|
292
312
|
|
|
293
|
-
|
|
294
|
-
* The
|
|
313
|
+
/*
|
|
314
|
+
* The shift controls.
|
|
295
315
|
*
|
|
296
|
-
* `
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
* board cannot use as its frame.
|
|
316
|
+
* `useSessionActions()` for the methods and two one-field selectors for the
|
|
317
|
+
* flags, rather than the whole-snapshot `useSession()` this used to hold. The
|
|
318
|
+
* methods are what the row is really after, and `useSession()` re-renders on
|
|
319
|
+
* every snapshot change — so four buttons re-rendered at STT-partial rate to
|
|
320
|
+
* read two booleans that flip once a shift.
|
|
302
321
|
*/
|
|
303
|
-
function ErrorBanner() {
|
|
304
|
-
const error = useSessionSelector((s) => s.error);
|
|
305
|
-
if (!error) return null;
|
|
306
|
-
return (
|
|
307
|
-
<div
|
|
308
|
-
role="alert"
|
|
309
|
-
className="px-4 py-2 text-xs"
|
|
310
|
-
style={{ background: "#450a0a", color: "#fca5a5", borderTop: "1px solid #991b1b" }}
|
|
311
|
-
>
|
|
312
|
-
ERROR: {error.message} ({error.code})
|
|
313
|
-
</div>
|
|
314
|
-
);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Start a fresh conversation without leaving the console.
|
|
319
|
-
*
|
|
320
|
-
* Written out here rather than reached for on the session, because there is no
|
|
321
|
-
* one method that does it: `reset()` clears the CONVERSATION and keeps the
|
|
322
|
-
* session, which is wrong for any agent that also keeps session-scoped state —
|
|
323
|
-
* this one's incident board would come back with the next tool call.
|
|
324
|
-
*/
|
|
325
|
-
function newConversation(session: Session): void {
|
|
326
|
-
session.end();
|
|
327
|
-
session.start();
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
/** The shift controls. The one place a whole-session read is what is wanted:
|
|
331
|
-
* it needs `started`, `running` and four methods, and it is four buttons. */
|
|
332
322
|
function ShiftControls({ logged }: { logged: number }) {
|
|
333
|
-
const
|
|
323
|
+
const { start, toggle, restart, end } = useSessionActions();
|
|
324
|
+
const started = useSessionSelector((s) => s.started);
|
|
325
|
+
const running = useSessionSelector((s) => s.running);
|
|
334
326
|
return (
|
|
335
327
|
<div
|
|
336
328
|
className="flex items-center gap-2.5 px-4 py-3"
|
|
337
329
|
style={{ background: "#111827", borderTop: "1px solid #1e293b" }}
|
|
338
330
|
>
|
|
339
|
-
{!
|
|
331
|
+
{!started ? (
|
|
340
332
|
<button
|
|
341
333
|
type="button"
|
|
342
334
|
className="px-4 py-2 border-none rounded-md font-mono text-xs font-semibold uppercase tracking-wider cursor-pointer text-white"
|
|
343
335
|
style={{ background: "#2563eb" }}
|
|
344
|
-
onClick={() =>
|
|
336
|
+
onClick={() => start()}
|
|
345
337
|
>
|
|
346
338
|
Start Dispatch
|
|
347
339
|
</button>
|
|
@@ -351,12 +343,12 @@ function ShiftControls({ logged }: { logged: number }) {
|
|
|
351
343
|
type="button"
|
|
352
344
|
className="px-4 py-2 border-none rounded-md font-mono text-xs font-semibold uppercase tracking-wider cursor-pointer"
|
|
353
345
|
style={{
|
|
354
|
-
background:
|
|
355
|
-
color:
|
|
346
|
+
background: running ? "#334155" : "#2563eb",
|
|
347
|
+
color: running ? "#e2e8f0" : "white",
|
|
356
348
|
}}
|
|
357
|
-
onClick={() =>
|
|
349
|
+
onClick={() => toggle()}
|
|
358
350
|
>
|
|
359
|
-
{
|
|
351
|
+
{running ? "Pause" : "Resume"}
|
|
360
352
|
</button>
|
|
361
353
|
{/* The one-click new conversation the default shell's
|
|
362
354
|
`<Controls>` gives every other template — a custom
|
|
@@ -375,7 +367,7 @@ function ShiftControls({ logged }: { logged: number }) {
|
|
|
375
367
|
type="button"
|
|
376
368
|
className="px-4 py-2 border-none rounded-md font-mono text-xs font-semibold uppercase tracking-wider cursor-pointer"
|
|
377
369
|
style={{ background: "#1e293b", color: "#e2e8f0" }}
|
|
378
|
-
onClick={
|
|
370
|
+
onClick={restart}
|
|
379
371
|
>
|
|
380
372
|
New Conversation
|
|
381
373
|
</button>
|
|
@@ -388,7 +380,7 @@ function ShiftControls({ logged }: { logged: number }) {
|
|
|
388
380
|
type="button"
|
|
389
381
|
className="px-4 py-2 border-none rounded-md font-mono text-xs font-semibold uppercase tracking-wider cursor-pointer text-white"
|
|
390
382
|
style={{ background: "#dc2626" }}
|
|
391
|
-
onClick={() =>
|
|
383
|
+
onClick={() => end()}
|
|
392
384
|
>
|
|
393
385
|
End Shift
|
|
394
386
|
</button>
|
|
@@ -396,7 +388,7 @@ function ShiftControls({ logged }: { logged: number }) {
|
|
|
396
388
|
)}
|
|
397
389
|
<div className="flex-1" />
|
|
398
390
|
<span className="text-[10px]" style={{ color: "#475569" }}>
|
|
399
|
-
{logged}
|
|
391
|
+
{logged} {plural(logged, "incident")} logged
|
|
400
392
|
</span>
|
|
401
393
|
</div>
|
|
402
394
|
);
|
|
@@ -471,7 +463,7 @@ function App() {
|
|
|
471
463
|
style={{ borderRight: "1px solid #1e293b" }}
|
|
472
464
|
>
|
|
473
465
|
<Conversation />
|
|
474
|
-
<
|
|
466
|
+
<SessionErrorBanner className="rounded-none border-x-0 border-b-0" />
|
|
475
467
|
<ShiftControls logged={incidentList.length} />
|
|
476
468
|
</div>
|
|
477
469
|
|
|
@@ -524,7 +516,7 @@ function App() {
|
|
|
524
516
|
);
|
|
525
517
|
}
|
|
526
518
|
|
|
527
|
-
|
|
519
|
+
mountClient({
|
|
528
520
|
component: App,
|
|
529
521
|
theme: {
|
|
530
522
|
bg: "#0a0a0f",
|
|
@@ -71,7 +71,6 @@ export interface Incident {
|
|
|
71
71
|
createdAt: number;
|
|
72
72
|
updatedAt: number;
|
|
73
73
|
escalationLevel: number;
|
|
74
|
-
protocolsActivated: string[];
|
|
75
74
|
casualties: { confirmed: number; estimated: number; treated: number };
|
|
76
75
|
hazards: string[];
|
|
77
76
|
}
|
|
@@ -82,7 +81,6 @@ export interface DispatchState {
|
|
|
82
81
|
incidentCounter: number;
|
|
83
82
|
mutualAidCounter: number;
|
|
84
83
|
alertLevel: "green" | "yellow" | "orange" | "red";
|
|
85
|
-
mutualAidRequested: boolean;
|
|
86
84
|
}
|
|
87
85
|
|
|
88
86
|
/**
|
|
@@ -157,7 +155,6 @@ export function createDefaultState(): DispatchState {
|
|
|
157
155
|
incidentCounter: 0,
|
|
158
156
|
mutualAidCounter: 0,
|
|
159
157
|
alertLevel: "green",
|
|
160
|
-
mutualAidRequested: false,
|
|
161
158
|
};
|
|
162
159
|
}
|
|
163
160
|
|
|
@@ -348,7 +345,6 @@ export function createIncident(state: DispatchState, overrides: Partial<Incident
|
|
|
348
345
|
createdAt: time,
|
|
349
346
|
updatedAt: time,
|
|
350
347
|
escalationLevel: 0,
|
|
351
|
-
protocolsActivated: [],
|
|
352
348
|
casualties: { confirmed: 0, estimated: 0, treated: 0 },
|
|
353
349
|
hazards: [],
|
|
354
350
|
...overrides,
|
|
@@ -730,8 +726,4 @@ export function recalculateAlertLevel(state: DispatchState): void {
|
|
|
730
726
|
} else {
|
|
731
727
|
state.alertLevel = "green";
|
|
732
728
|
}
|
|
733
|
-
|
|
734
|
-
// Mutual aid tracks system posture: requested at red alert, stood down
|
|
735
|
-
// when the alert level drops back below red.
|
|
736
|
-
state.mutualAidRequested = state.alertLevel === "red";
|
|
737
729
|
}
|
|
@@ -25,7 +25,10 @@ export default dispatchSlot.tool({
|
|
|
25
25
|
|
|
26
26
|
return {
|
|
27
27
|
systemAlertLevel: state.alertLevel,
|
|
28
|
-
|
|
28
|
+
// Derived, not stored: mutual aid is requested at red alert and stood
|
|
29
|
+
// down when the level drops back below it, so the alert level IS the
|
|
30
|
+
// fact and the two can never disagree.
|
|
31
|
+
mutualAidActive: state.alertLevel === "red",
|
|
29
32
|
resourceUtilization: `${utilization}%`,
|
|
30
33
|
resourceSummary,
|
|
31
34
|
activeIncidentCount: activeIncidents.length,
|
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
/**
|
|
14
14
|
* The def a DEPLOYED agent runs: authored, plus what `tools/` declares.
|
|
15
15
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
16
|
+
* Taken from `virtual:aai/agent` rather than a hand-written glob: the plugin
|
|
17
|
+
* expands it against THIS file's own directory, so the spec needs no glob and
|
|
18
|
+
* no shared helper — which matters because this file SHIPS, and a scaffolded
|
|
19
|
+
* project has no repo helper to import. Without
|
|
18
20
|
* it this agent would have no tools and would answer every question from the
|
|
19
21
|
* model's own memory, which is exactly what the cases below forbid.
|
|
20
22
|
*
|
|
@@ -30,7 +30,13 @@ describe("embedded-assets template", () => {
|
|
|
30
30
|
// `agent()` takes no `tools` field: a file in `tools/` IS the tool, and
|
|
31
31
|
// nothing imports it. Discovery is what puts it in front of the model, so a
|
|
32
32
|
// template whose tools are never resolved ships a model with no tools.
|
|
33
|
-
|
|
33
|
+
// `arrayContaining` rather than an exact list: adding a file to `tools/` is
|
|
34
|
+
// the edit this template most invites, and an exact list would redden on
|
|
35
|
+
// it. Losing one of these two is still a failure — that is the half worth
|
|
36
|
+
// asserting.
|
|
37
|
+
expect(Object.keys(agentDef.tools ?? {})).toEqual(
|
|
38
|
+
expect.arrayContaining(["list_topics", "search_knowledge"]),
|
|
39
|
+
);
|
|
34
40
|
});
|
|
35
41
|
});
|
|
36
42
|
|