@alexkroman1/aai-cli 10.0.1 → 11.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/dist/{_bundler-B31MqYaD.mjs → _bundler-C31rlcYz.mjs} +1 -1
- package/dist/{_dev-server-Bo5dSBzQ.mjs → _dev-server-DWvkNoIS.mjs} +3 -3
- package/dist/{_vite-env-BNveawd1.mjs → _vite-env-D9Qj6KPX.mjs} +4 -2
- package/dist/_workflow-determinism-f9J-5Zlj.mjs +206 -0
- package/dist/_workflow-determinism.d.ts +118 -0
- package/dist/{build-DKC2e9o_.mjs → build-BLlLOrng.mjs} +4 -2
- package/dist/cli.mjs +372 -353
- package/dist/{client-bundler-CWnG42cU.mjs → client-bundler-j5c46x0G.mjs} +3 -2
- package/dist/client-bundler.mjs +1 -1
- package/dist/{deploy-CchW5pmw.mjs → deploy-BzZ-r4iM.mjs} +6 -4
- package/dist/{dev-BfVl1uBA.mjs → dev-BDykFvcc.mjs} +1 -1
- package/dist/{eval-DwNwdqmS.mjs → eval-0qEOdm0l.mjs} +1 -1
- package/dist/{init-BRfoc2EK.mjs → init-BtJI306C.mjs} +1 -1
- package/dist/{login-BBuM1sxH.mjs → login-DeUETobb.mjs} +4 -1
- package/dist/scaffold/CLAUDE.md +190 -11
- package/dist/scaffold/package.json +4 -4
- package/dist/{studio-wVWNLREn.mjs → studio-BxAS_FQQ.mjs} +4 -1
- package/dist/templates/call-audit/agent.test.ts +105 -26
- package/dist/templates/call-audit/workflows/audit.ts +9 -26
- package/dist/templates/dispatch-center/client.tsx +36 -2
- package/dist/templates/infocom-adventure/client.tsx +37 -9
- package/dist/templates/link-digest/agent.eval.test.ts +1 -1
- package/dist/templates/link-digest/agent.test.ts +164 -8
- package/dist/templates/link-digest/workflows/digest.ts +63 -20
- package/dist/templates/podcast-digest/agent.eval.test.ts +7 -4
- package/dist/templates/podcast-digest/agent.test.ts +265 -24
- package/dist/templates/podcast-digest/workflows/digest.ts +2 -2
- package/dist/templates/podcast-digest/workflows/feeds.ts +78 -73
- package/dist/templates/recap-workflow/agent.test.ts +206 -20
- package/dist/templates/recap-workflow/workflows/recap.ts +17 -14
- package/dist/templates/redline/agent.test.ts +137 -11
- package/dist/templates/research-workflow/agent.eval.test.ts +1 -1
- package/dist/templates/research-workflow/agent.test.ts +131 -9
- package/dist/templates/research-workflow/workflows/research.ts +1 -1
- package/dist/templates/retail/client.tsx +30 -2
- package/dist/templates/spoken-summary/agent.eval.test.ts +4 -1
- package/dist/templates/spoken-summary/agent.test.ts +117 -8
- package/dist/templates/spoken-summary/workflows/summarize.ts +1 -1
- package/dist/templates/transcription-workflow/agent.test.ts +127 -13
- package/dist/templates/transcription-workflow/workflows/batch.ts +6 -6
- package/dist/templates/transcription-workflow/workflows/stream.ts +4 -3
- package/dist/templates/transcription-workflow/workflows/transcribe.ts +4 -23
- package/dist/{test-CvwgeVSQ.mjs → test-CiLab-AA.mjs} +2 -1
- package/dist/worker-bundler.mjs +1 -1
- package/package.json +25 -24
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
* The workflow body, and the fan-out it plans.
|
|
4
4
|
*
|
|
5
5
|
* ```text
|
|
6
|
-
* now
|
|
6
|
+
* ctx.now() journaled → when the run began
|
|
7
7
|
* ingestRecording one step → levelled PCM + every pause (ingest.ts)
|
|
8
8
|
* planSegments the BODY → where to cut (media.ts, pure)
|
|
9
9
|
* transcribeSegment N steps → one sync API request each, bounded
|
|
10
10
|
* summarize one step → headline, risks, actions (summarize.ts)
|
|
11
11
|
* narrate one step → an MP3 of the summary (summarize.ts)
|
|
12
|
-
* now
|
|
12
|
+
* ctx.now() journaled → when it finished
|
|
13
13
|
* ```
|
|
14
14
|
*
|
|
15
15
|
* Read `transcription-workflow` first: it owns the fan-out — why the sync
|
|
@@ -163,10 +163,11 @@ export async function auditFlow(
|
|
|
163
163
|
// of this expression — the two calls go out synchronously, left to right — which
|
|
164
164
|
// is what a replay reproduces.
|
|
165
165
|
//
|
|
166
|
-
// `
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
166
|
+
// `ctx.now()` rather than a step of its own: the engine journals the read under
|
|
167
|
+
// its own key, so it is the moment the run really reached this line however
|
|
168
|
+
// many times the line is walked. This was a `ctx.step("clockStart", now)` over
|
|
169
|
+
// an exported one-line clock read, which is what everybody writes until the
|
|
170
|
+
// affordance exists.
|
|
170
171
|
// `maxAttempts: 6` was `ingestRecording.maxRetries = 5` — five retries AFTER
|
|
171
172
|
// the first, so six in all. More than the default 3, and not because a
|
|
172
173
|
// conversion is flaky: a corrupt file fails identically forever, and
|
|
@@ -175,7 +176,7 @@ export async function auditFlow(
|
|
|
175
176
|
// out of the store and writes a whole one back, and either can lose a
|
|
176
177
|
// connection on a file this size.
|
|
177
178
|
const [startedAt, ingested] = await Promise.all([
|
|
178
|
-
ctx.
|
|
179
|
+
ctx.now(),
|
|
179
180
|
ctx.step("ingestRecording", () => ingestRecording(input.recording), { maxAttempts: 6 }),
|
|
180
181
|
]);
|
|
181
182
|
|
|
@@ -206,7 +207,7 @@ export async function auditFlow(
|
|
|
206
207
|
summarize(transcript, ingested.source, ingested.durationMs),
|
|
207
208
|
);
|
|
208
209
|
const spoken = await ctx.step("narrate", () => narrate(summary.spoken, input.voice));
|
|
209
|
-
const finishedAt = await ctx.
|
|
210
|
+
const finishedAt = await ctx.now();
|
|
210
211
|
|
|
211
212
|
// Whatever this returns is what a caller reads as `output` on a completed run —
|
|
212
213
|
// so it is what the page renders, typed through `WorkflowOutputOf`. Assembled in
|
|
@@ -273,24 +274,6 @@ export async function transcribeSegment(audioId: string, segment: Segment): Prom
|
|
|
273
274
|
return { index: segment.index, text };
|
|
274
275
|
}
|
|
275
276
|
|
|
276
|
-
/**
|
|
277
|
-
* When it is now, as epoch ms.
|
|
278
|
-
*
|
|
279
|
-
* A STEP, and that is the whole reason it exists rather than a `Date.now()` in the
|
|
280
|
-
* body: a body replays from the top on every resume, so a clock read there returns
|
|
281
|
-
* a different value each time and every duration derived from it would be a
|
|
282
|
-
* different duration. A step's result is journaled, so this is the moment the run
|
|
283
|
-
* really reached this line however many times it is replayed.
|
|
284
|
-
*
|
|
285
|
-
* Called twice — once at each end — rather than a `startClock`/`elapsed` pair,
|
|
286
|
-
* because the alternative is a step taking every field of the output so it can
|
|
287
|
-
* subtract inside itself. Two journal entries and a subtraction in the body is the
|
|
288
|
-
* smaller thing.
|
|
289
|
-
*/
|
|
290
|
-
export async function now(): Promise<number> {
|
|
291
|
-
return Date.now();
|
|
292
|
-
}
|
|
293
|
-
|
|
294
277
|
/**
|
|
295
278
|
* Join the segment transcripts into one.
|
|
296
279
|
*
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import "@alexkroman1/aai-ui/styles.css";
|
|
2
|
-
import type { AgentState, ConversationItem } from "@alexkroman1/aai-ui";
|
|
2
|
+
import type { AgentState, ConversationItem, Session } from "@alexkroman1/aai-ui";
|
|
3
3
|
import {
|
|
4
4
|
AutoScroll,
|
|
5
5
|
client,
|
|
@@ -314,8 +314,21 @@ function ErrorBanner() {
|
|
|
314
314
|
);
|
|
315
315
|
}
|
|
316
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
|
+
|
|
317
330
|
/** The shift controls. The one place a whole-session read is what is wanted:
|
|
318
|
-
* it needs `started`, `running` and
|
|
331
|
+
* it needs `started`, `running` and four methods, and it is four buttons. */
|
|
319
332
|
function ShiftControls({ logged }: { logged: number }) {
|
|
320
333
|
const session = useSession();
|
|
321
334
|
return (
|
|
@@ -345,6 +358,27 @@ function ShiftControls({ logged }: { logged: number }) {
|
|
|
345
358
|
>
|
|
346
359
|
{session.running ? "Pause" : "Resume"}
|
|
347
360
|
</button>
|
|
361
|
+
{/* The one-click new conversation the default shell's
|
|
362
|
+
`<Controls>` gives every other template — a custom
|
|
363
|
+
`component:` renders no `<Controls>`, so a console like
|
|
364
|
+
this one has to say it itself.
|
|
365
|
+
|
|
366
|
+
end() then start(), NOT reset(): reset() clears the
|
|
367
|
+
conversation and leaves the agent's own session-scoped
|
|
368
|
+
state behind, so the next tool call would repopulate the
|
|
369
|
+
shift that was just abandoned. end() drops the resume
|
|
370
|
+
identity, so the redial is a brand-new session (fresh
|
|
371
|
+
incident board, greeting included), and start() puts the
|
|
372
|
+
console straight back on the call rather than at the
|
|
373
|
+
"Start Dispatch" screen. */}
|
|
374
|
+
<button
|
|
375
|
+
type="button"
|
|
376
|
+
className="px-4 py-2 border-none rounded-md font-mono text-xs font-semibold uppercase tracking-wider cursor-pointer"
|
|
377
|
+
style={{ background: "#1e293b", color: "#e2e8f0" }}
|
|
378
|
+
onClick={() => newConversation(session)}
|
|
379
|
+
>
|
|
380
|
+
New Conversation
|
|
381
|
+
</button>
|
|
348
382
|
{/* end() hangs up and flips `started` back, so the UI
|
|
349
383
|
returns to "Start Dispatch" and the next start is a
|
|
350
384
|
brand-new shift (fresh incident board, greeting
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import "@alexkroman1/aai-ui/styles.css";
|
|
2
|
-
import type { AgentState } from "@alexkroman1/aai-ui";
|
|
2
|
+
import type { AgentState, Session } from "@alexkroman1/aai-ui";
|
|
3
3
|
import {
|
|
4
4
|
AutoScroll,
|
|
5
5
|
client,
|
|
@@ -220,9 +220,26 @@ function ErrorBanner() {
|
|
|
220
220
|
);
|
|
221
221
|
}
|
|
222
222
|
|
|
223
|
-
/**
|
|
224
|
-
*
|
|
225
|
-
*
|
|
223
|
+
/**
|
|
224
|
+
* Start a fresh conversation without leaving the game screen.
|
|
225
|
+
*
|
|
226
|
+
* Written out here rather than reached for on the session, because there is no
|
|
227
|
+
* one method that does it: `reset()` clears the CONVERSATION and keeps the
|
|
228
|
+
* session, which is wrong for any agent that also keeps session-scoped state —
|
|
229
|
+
* this game's world would come back with the next tool call. `end()` drops the
|
|
230
|
+
* resume identity, so the redial is a brand-new session (fresh world, opening
|
|
231
|
+
* scene included), and `start()` puts the player straight into it rather than
|
|
232
|
+
* back at the title screen.
|
|
233
|
+
*/
|
|
234
|
+
function newConversation(session: Session): void {
|
|
235
|
+
session.end();
|
|
236
|
+
session.start();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Pause/resume, new game and hang-up. The only place a whole-session
|
|
240
|
+
* subscription is still needed: the ACTIONS live on `useSession()`, and
|
|
241
|
+
* `useSessionCore` — the narrow way `<Controls>` reaches them — is not on the
|
|
242
|
+
* public surface. */
|
|
226
243
|
function Footer() {
|
|
227
244
|
const session = useSession();
|
|
228
245
|
return (
|
|
@@ -240,18 +257,29 @@ function Footer() {
|
|
|
240
257
|
>
|
|
241
258
|
{session.running ? "[P]ause" : "[R]esume"}
|
|
242
259
|
</button>
|
|
243
|
-
{/*
|
|
244
|
-
|
|
245
|
-
screen
|
|
246
|
-
|
|
260
|
+
{/* The one-click new conversation the default shell's `<Controls>`
|
|
261
|
+
gives every other template — a custom `component:` renders no
|
|
262
|
+
`<Controls>`, so this screen has to say it itself. Here that is a
|
|
263
|
+
new game: end() drops the sessionId, so the session-scoped game
|
|
264
|
+
state starts over, and start() deals the player straight into it. */}
|
|
247
265
|
<button
|
|
248
266
|
type="button"
|
|
249
267
|
className="px-4 py-1 bg-transparent cursor-pointer uppercase tracking-wider font-mono text-[11px]"
|
|
250
268
|
style={{ color: GREEN_DIM, border: `1px solid ${GREEN_DARK}` }}
|
|
251
|
-
onClick={() => session
|
|
269
|
+
onClick={() => newConversation(session)}
|
|
252
270
|
>
|
|
253
271
|
[N]ew Game
|
|
254
272
|
</button>
|
|
273
|
+
{/* The hang-up: end() alone flips `started` back, so the title screen
|
|
274
|
+
returns and nothing is dialled until the player asks for it. */}
|
|
275
|
+
<button
|
|
276
|
+
type="button"
|
|
277
|
+
className="px-4 py-1 bg-transparent cursor-pointer uppercase tracking-wider font-mono text-[11px]"
|
|
278
|
+
style={{ color: GREEN_DIM, border: `1px solid ${GREEN_DARK}` }}
|
|
279
|
+
onClick={() => session.end()}
|
|
280
|
+
>
|
|
281
|
+
[Q]uit
|
|
282
|
+
</button>
|
|
255
283
|
</div>
|
|
256
284
|
</div>
|
|
257
285
|
);
|
|
@@ -152,7 +152,7 @@ describeWorkflowEval(agentDef, (test) => {
|
|
|
152
152
|
expect(run.reported).toContain("Filing the digest.");
|
|
153
153
|
// The one thing this harness cannot do, stated as an assertion rather than
|
|
154
154
|
// left implied: the durable wait was ASKED FOR and not taken.
|
|
155
|
-
expect(run.slept).toEqual([{ duration: SETTLE_MS }]);
|
|
155
|
+
expect(run.slept).toEqual([{ label: "settle", duration: SETTLE_MS }]);
|
|
156
156
|
});
|
|
157
157
|
|
|
158
158
|
test("fails terminally on a page with no readable text", async ({ app, mode }) => {
|
|
@@ -10,26 +10,40 @@
|
|
|
10
10
|
* rename here is a runtime 400 there), and the input schema (which is both the
|
|
11
11
|
* call-site validation and the JSON Schema `GET /workflows` serves).
|
|
12
12
|
*
|
|
13
|
-
* The
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
13
|
+
* The STEPS are exercised directly — a step is an ordinary exported async
|
|
14
|
+
* function, so its HTML handling, its JSON contract with the model and its
|
|
15
|
+
* `FatalError` guards are all testable without an engine.
|
|
16
|
+
*
|
|
17
|
+
* And so is the BODY, durably, which it was not: this file used to say the body
|
|
18
|
+
* "is only durable once the Workflow DevKit's build has transformed it, so a
|
|
19
|
+
* unit test of it would exercise a plain async function and prove nothing about
|
|
20
|
+
* replay". That stopped being true when the DevKit was replaced — the engine
|
|
21
|
+
* runs a run off the agent's own `workflows` declaration, in process, with no
|
|
22
|
+
* bundler in the path. `runWorkflow` from `@alexkroman1/aai-runtime/testing` is
|
|
23
|
+
* that engine, so the last block below asserts the thing this template exists to
|
|
24
|
+
* demonstrate: the run SUSPENDS on its settle window and resumes past it without
|
|
25
|
+
* fetching the page or paying the model again.
|
|
19
26
|
*/
|
|
20
27
|
|
|
21
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
createWorkflowCtx,
|
|
30
|
+
schemaInputIssues,
|
|
31
|
+
stubGatewayRoute,
|
|
32
|
+
stubStepInfo,
|
|
33
|
+
} from "@alexkroman1/aai/testing";
|
|
22
34
|
import {
|
|
23
35
|
installStubStepFetch,
|
|
24
36
|
installStubGateway as stubGateway,
|
|
25
37
|
} from "@alexkroman1/aai/testing/vitest";
|
|
26
|
-
import {
|
|
38
|
+
import { runWorkflow } from "@alexkroman1/aai-runtime/testing";
|
|
39
|
+
import { beforeEach, describe, expect, onTestFinished, test, vi } from "vitest";
|
|
27
40
|
import agentDef, { digest } from "./agent.ts";
|
|
28
41
|
import {
|
|
29
42
|
digestFlow,
|
|
30
43
|
extractText,
|
|
31
44
|
extractTitle,
|
|
32
45
|
fetchArticle,
|
|
46
|
+
SETTLE_MS,
|
|
33
47
|
summarize,
|
|
34
48
|
} from "./workflows/digest.ts";
|
|
35
49
|
|
|
@@ -214,6 +228,30 @@ describe("summarize", () => {
|
|
|
214
228
|
await expect(summarize(ARTICLE)).rejects.toThrow(/ASSEMBLYAI_API_KEY/);
|
|
215
229
|
});
|
|
216
230
|
|
|
231
|
+
test("asks for something simpler on the LAST attempt, not on the first", async () => {
|
|
232
|
+
// The branch the extra attempts exist for, and it is only reachable from a
|
|
233
|
+
// spec through `stubStepInfo`: outside a run `stepInfo()` answers
|
|
234
|
+
// `undefined`, which the step reads as the ordinary path. Five attempts of
|
|
235
|
+
// the same ask having failed, the sixth changes the ask.
|
|
236
|
+
onTestFinished(stubStepInfo({ attempt: 6, maxAttempts: 6 }).restore);
|
|
237
|
+
const calls = stubGateway('{"headline":"H","points":["a","b","c"]}');
|
|
238
|
+
|
|
239
|
+
await summarize(ARTICLE);
|
|
240
|
+
|
|
241
|
+
expect(calls[0]?.system).toContain("one short sentence");
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test("asks the ordinary way when the attempt is not the last", async () => {
|
|
245
|
+
// The half that makes the case above mean something: a spec that only
|
|
246
|
+
// asserted the fallback would pass against a step that always degraded.
|
|
247
|
+
onTestFinished(stubStepInfo({ attempt: 1, maxAttempts: 6 }).restore);
|
|
248
|
+
const calls = stubGateway('{"headline":"H","points":["a","b","c"]}');
|
|
249
|
+
|
|
250
|
+
await summarize(ARTICLE);
|
|
251
|
+
|
|
252
|
+
expect(calls[0]?.system).not.toContain("one short sentence");
|
|
253
|
+
});
|
|
254
|
+
|
|
217
255
|
test("is called with more attempts than the default, because a rate limit and a bad format both happen", async () => {
|
|
218
256
|
// The retry policy is an argument to `ctx.step` now, not a `maxRetries`
|
|
219
257
|
// property on the function — so the assertion is about the BODY's call,
|
|
@@ -232,3 +270,121 @@ describe("summarize", () => {
|
|
|
232
270
|
expect(ctx.steps.map((step) => step.name)).toEqual(["fetchArticle", "summarize", "file"]);
|
|
233
271
|
});
|
|
234
272
|
});
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The run itself, against a real durable engine.
|
|
276
|
+
*
|
|
277
|
+
* `runWorkflow` starts the declared workflow on
|
|
278
|
+
* `createInProcessWorkflowEngine` over a memory journal — the same composition
|
|
279
|
+
* root `aai dev` uses — and supplies what a deployment's queue supplies: one
|
|
280
|
+
* delivery at a time, and a suspension recorded rather than waited out. So a
|
|
281
|
+
* `ctx.sleep` a deployed run would take ten seconds over (or six hours, which
|
|
282
|
+
* the body's own comment says is the interesting version) costs this file
|
|
283
|
+
* nothing, and what is asserted is the property the template is FOR.
|
|
284
|
+
*
|
|
285
|
+
* The steps are stubbed at the same two seams the blocks above use, which is
|
|
286
|
+
* what makes this affordable: the body is real, the engine is real, the journal
|
|
287
|
+
* is real, and only the page and the model are not.
|
|
288
|
+
*/
|
|
289
|
+
describe("the run is DURABLE", () => {
|
|
290
|
+
const PAGE = `<html><title>Otters</title><body><p>${"Otters use tools. ".repeat(20)}</p></body></html>`;
|
|
291
|
+
const REPLY = '{"headline":"Otters use tools","points":["They do."]}';
|
|
292
|
+
|
|
293
|
+
beforeEach(() => {
|
|
294
|
+
// The same fallback the `summarize` block above relies on: `stepEnv` reads
|
|
295
|
+
// the process env when no host has published one, which is what a spec is.
|
|
296
|
+
// A run whose step cannot read its credential fails FATALLY and the whole
|
|
297
|
+
// durability claim would be made about a run that never got past its first
|
|
298
|
+
// model call.
|
|
299
|
+
vi.stubEnv("ASSEMBLYAI_API_KEY", "sk-test");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* The page and the model, behind ONE published `stepFetch`.
|
|
304
|
+
*
|
|
305
|
+
* The composition `stubGatewayRoute` exists for, and the reason it has to be
|
|
306
|
+
* this way here rather than `installStubGateway` beside a page stub: a step's
|
|
307
|
+
* HTTP — the model call included — goes through the published slot, so a page
|
|
308
|
+
* stub installed alongside answers the gateway request with HTML and the
|
|
309
|
+
* summarize step retries six times against it. The blocks above never hit
|
|
310
|
+
* that because each stubs one seam at a time.
|
|
311
|
+
*
|
|
312
|
+
* Both call logs come back, which is what makes a replay countable.
|
|
313
|
+
*/
|
|
314
|
+
function stubWorld() {
|
|
315
|
+
const model = stubGatewayRoute(REPLY);
|
|
316
|
+
const page = vi.fn(() => ({
|
|
317
|
+
status: 200,
|
|
318
|
+
body: PAGE,
|
|
319
|
+
headers: { "Content-Type": "text/html" },
|
|
320
|
+
}));
|
|
321
|
+
installStubStepFetch((request) => model.route(request) ?? page());
|
|
322
|
+
return { page, model: model.calls };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
test("suspends on the settle window instead of blocking, with its work already journaled", async () => {
|
|
326
|
+
stubWorld();
|
|
327
|
+
const started = Date.now();
|
|
328
|
+
const run = await runWorkflow(
|
|
329
|
+
digest,
|
|
330
|
+
{ url: "https://example.com/otters" },
|
|
331
|
+
{
|
|
332
|
+
name: "digest",
|
|
333
|
+
},
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
// `running` is the PARKED state — the run is in progress, it is just not
|
|
337
|
+
// executing, which is what a page polling it sees.
|
|
338
|
+
expect(run.status).toBe("running");
|
|
339
|
+
expect(run.wakeAt).toBeGreaterThanOrEqual(started + SETTLE_MS);
|
|
340
|
+
// Everything BEFORE the wait is already durable, and `file` has not run.
|
|
341
|
+
expect(run.steps.map((step) => step.name)).toEqual(["fetchArticle", "summarize"]);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("resumes past the wait without re-reading the page or paying the model again", async () => {
|
|
345
|
+
const { page, model } = stubWorld();
|
|
346
|
+
const run = await runWorkflow(
|
|
347
|
+
digest,
|
|
348
|
+
{ url: "https://example.com/otters" },
|
|
349
|
+
{
|
|
350
|
+
name: "digest",
|
|
351
|
+
},
|
|
352
|
+
);
|
|
353
|
+
await run.advanceSleep();
|
|
354
|
+
|
|
355
|
+
expect(run.status).toBe("completed");
|
|
356
|
+
expect(run.output).toMatchObject({ headline: "Otters use tools", points: ["They do."] });
|
|
357
|
+
expect(run.output?.filedAt).toBeTruthy();
|
|
358
|
+
// Two walks of the body, one fetch and one completion. That is the whole
|
|
359
|
+
// durable-execution claim, and it is why the body splits the fetch from the
|
|
360
|
+
// model call: a resume replays a stranger's page out of the journal rather
|
|
361
|
+
// than requesting it again.
|
|
362
|
+
expect(run.deliveries).toBe(2);
|
|
363
|
+
expect(page).toHaveBeenCalledTimes(1);
|
|
364
|
+
expect(model).toHaveLength(1);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("survives a worker that dies mid-run, and only re-runs what never settled", async () => {
|
|
368
|
+
const { page, model } = stubWorld();
|
|
369
|
+
// Killed on the way into `summarize`: the fetch is journaled, the model call
|
|
370
|
+
// is not. This is the failure a body cannot be written against without being
|
|
371
|
+
// able to produce it.
|
|
372
|
+
const run = await runWorkflow(
|
|
373
|
+
digest,
|
|
374
|
+
{ url: "https://example.com/otters" },
|
|
375
|
+
{
|
|
376
|
+
name: "digest",
|
|
377
|
+
crashAt: "summarize",
|
|
378
|
+
},
|
|
379
|
+
);
|
|
380
|
+
expect(run.crashed).toBe(true);
|
|
381
|
+
expect(run.steps.map((step) => step.name)).toEqual(["fetchArticle"]);
|
|
382
|
+
expect(model).toHaveLength(0);
|
|
383
|
+
|
|
384
|
+
await run.restart();
|
|
385
|
+
await run.advanceSleep();
|
|
386
|
+
expect(run.status).toBe("completed");
|
|
387
|
+
expect(page).toHaveBeenCalledTimes(1);
|
|
388
|
+
expect(model).toHaveLength(1);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
@@ -25,9 +25,10 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import type { WorkflowCtx } from "@alexkroman1/aai";
|
|
28
|
-
import {
|
|
28
|
+
import { htmlToText, pageMetadata } from "@alexkroman1/aai/html";
|
|
29
|
+
import { report, stepInfo } from "@alexkroman1/aai/step";
|
|
29
30
|
import { FatalError, stepFetchOk, stepGenerateJsonClassified } from "@alexkroman1/aai/step-errors";
|
|
30
|
-
import {
|
|
31
|
+
import { omitUndefined } from "@alexkroman1/aai/utils";
|
|
31
32
|
import { z } from "zod";
|
|
32
33
|
|
|
33
34
|
/**
|
|
@@ -51,6 +52,21 @@ const MAX_ARTICLE_CHARS = 24_000;
|
|
|
51
52
|
/** Points the digest reduces a page to. */
|
|
52
53
|
const POINTS = 3;
|
|
53
54
|
|
|
55
|
+
/**
|
|
56
|
+
* The model a LAST attempt falls back to.
|
|
57
|
+
*
|
|
58
|
+
* `summarize` is given six attempts because two different things go wrong here
|
|
59
|
+
* — a rate limit, and a model that answered with prose — and by the sixth the
|
|
60
|
+
* cheap explanation is that this model is not going to produce the shape asked
|
|
61
|
+
* for. A smaller model that returns three plain sentences beats a failed run, so
|
|
62
|
+
* the last try asks a different one rather than the same one again.
|
|
63
|
+
*
|
|
64
|
+
* Unset means the gateway default, which is the same model the agent's own
|
|
65
|
+
* pipeline resolves. Point this at a smaller id for a real deployment; the
|
|
66
|
+
* useful part of the pattern is `stepInfo()?.isLastAttempt`, not the id.
|
|
67
|
+
*/
|
|
68
|
+
const FALLBACK_MODEL: string | undefined = undefined;
|
|
69
|
+
|
|
54
70
|
/** The page fetch's deadline. HTTP has none of its own, and a hung step never ends. */
|
|
55
71
|
const FETCH_TIMEOUT_MS = 30_000;
|
|
56
72
|
|
|
@@ -104,7 +120,7 @@ export async function digestFlow(input: { url: string }, ctx: WorkflowCtx) {
|
|
|
104
120
|
// resumes when it comes due. Nothing about the code changes if it is six
|
|
105
121
|
// hours — which is the interesting version, and the one that makes an
|
|
106
122
|
// overnight digest a digest rather than a slow request.
|
|
107
|
-
await ctx.sleep(SETTLE_MS);
|
|
123
|
+
await ctx.sleep("settle", SETTLE_MS);
|
|
108
124
|
|
|
109
125
|
const filedAt = await ctx.step("file", () => file(digest));
|
|
110
126
|
return { ...digest, filedAt };
|
|
@@ -166,7 +182,20 @@ export async function fetchArticle(url: string): Promise<Article> {
|
|
|
166
182
|
* whole Node runtime is available here, unlike in the body.
|
|
167
183
|
*/
|
|
168
184
|
export async function summarize(article: Article): Promise<Digest> {
|
|
169
|
-
|
|
185
|
+
// Which attempt is this? `undefined` outside a run — which is what a spec
|
|
186
|
+
// calling this directly gets — and a spec MEANS the ordinary path, so the
|
|
187
|
+
// fallback is keyed on `=== true` rather than on truthiness.
|
|
188
|
+
const step = stepInfo();
|
|
189
|
+
const lastChance = step?.isLastAttempt === true;
|
|
190
|
+
// `undefined` on every attempt but the last, which is what lets the option be
|
|
191
|
+
// built with `omitUndefined` rather than a conditional spread — the guard is
|
|
192
|
+
// then the value, which is the case that primitive is for.
|
|
193
|
+
const model = lastChance ? FALLBACK_MODEL : undefined;
|
|
194
|
+
await report(
|
|
195
|
+
lastChance
|
|
196
|
+
? `Last attempt (${step?.attempt} of ${step?.maxAttempts}): asking for something simpler.`
|
|
197
|
+
: "Pulling out the claims worth keeping.",
|
|
198
|
+
);
|
|
170
199
|
|
|
171
200
|
// `stepGenerateJsonClassified` unwraps the fence a model puts around JSON,
|
|
172
201
|
// parses it, and validates it against `DigestReply` — and throws PLAINLY when
|
|
@@ -180,7 +209,16 @@ export async function summarize(article: Article): Promise<Digest> {
|
|
|
180
209
|
schema: DigestReply,
|
|
181
210
|
system:
|
|
182
211
|
`You digest articles. Reply with JSON only: {"headline": string, "points": string[]}. ` +
|
|
183
|
-
`Give exactly ${POINTS} points. No markdown fence, no preamble
|
|
212
|
+
`Give exactly ${POINTS} points. No markdown fence, no preamble.` +
|
|
213
|
+
// The instruction is blunter on the last try for the same reason the
|
|
214
|
+
// model is different: whatever it has been doing for five attempts is
|
|
215
|
+
// not working.
|
|
216
|
+
(lastChance ? " Keep every point to one short sentence." : ""),
|
|
217
|
+
// Read `isLastAttempt`, never `attempt === 6`: the ceiling lives at the
|
|
218
|
+
// `ctx.step` call site in `digestFlow`, so a number written here degrades
|
|
219
|
+
// early on every run the moment somebody changes it there — silently,
|
|
220
|
+
// because the step still returns an answer.
|
|
221
|
+
...omitUndefined({ model }),
|
|
184
222
|
},
|
|
185
223
|
);
|
|
186
224
|
|
|
@@ -217,29 +255,34 @@ export async function file(_digest: Digest): Promise<string> {
|
|
|
217
255
|
|
|
218
256
|
// ---- Pure helpers -----------------------------------------------------------
|
|
219
257
|
|
|
220
|
-
/**
|
|
258
|
+
/**
|
|
259
|
+
* The page's own name for itself.
|
|
260
|
+
*
|
|
261
|
+
* `pageMetadata` prefers `og:title` over the `<title>` element, which is what a
|
|
262
|
+
* digest wants: a `<title>` usually carries the site name and a separator
|
|
263
|
+
* ("Otters and tools | Nature Weekly") that a one-line summary does not.
|
|
264
|
+
*/
|
|
221
265
|
export function extractTitle(html: string): string | undefined {
|
|
222
|
-
|
|
223
|
-
return title ? decodeHtmlEntities(title).replace(/\s+/g, " ").trim() || undefined : undefined;
|
|
266
|
+
return pageMetadata(html).title?.replace(/\s+/g, " ").trim() || undefined;
|
|
224
267
|
}
|
|
225
268
|
|
|
226
269
|
/**
|
|
227
270
|
* Reduce HTML to the text a model should read.
|
|
228
271
|
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
272
|
+
* `htmlToText` is a real HTML parse (`@alexkroman1/aai/html`, over htmlparser2),
|
|
273
|
+
* which matters most for the thing this MUST do: drop `<script>` and `<style>`
|
|
274
|
+
* CONTENT, because a page's JavaScript in the prompt is both expensive and a way
|
|
275
|
+
* to smuggle instructions past the reader. This was four `replace` calls, and
|
|
276
|
+
* they had a hole exactly there — `<script[^>]*>[\s\S]*?<\/script>` needs the
|
|
277
|
+
* close tag, so a page truncated mid-script (a byte cap, a dropped connection)
|
|
278
|
+
* removed nothing and the tag strip put the whole script into the prompt.
|
|
279
|
+
*
|
|
280
|
+
* Whitespace is collapsed to single spaces afterwards because this text crosses
|
|
281
|
+
* a queue between two steps and the blank lines `htmlToText` uses for block
|
|
282
|
+
* structure are bytes the summary does not read.
|
|
235
283
|
*/
|
|
236
284
|
export function extractText(html: string): string {
|
|
237
|
-
return
|
|
238
|
-
html
|
|
239
|
-
.replace(/<(script|style|noscript|template)[^>]*>[\s\S]*?<\/\1>/gi, " ")
|
|
240
|
-
.replace(/<!--[\s\S]*?-->/g, " ")
|
|
241
|
-
.replace(/<[^>]+>/g, " "),
|
|
242
|
-
)
|
|
285
|
+
return htmlToText(html, { maxChars: MAX_ARTICLE_CHARS })
|
|
243
286
|
.replace(/\s+/g, " ")
|
|
244
287
|
.trim()
|
|
245
288
|
.slice(0, MAX_ARTICLE_CHARS);
|
|
@@ -247,7 +247,10 @@ describeWorkflowEval(agentDef, (test) => {
|
|
|
247
247
|
// a run that has delivered everything it owes should end rather than sleep
|
|
248
248
|
// for two hours and then end.
|
|
249
249
|
const interval = scheduleIntervalMs(2, "hours");
|
|
250
|
-
expect(run.slept).toEqual([
|
|
250
|
+
expect(run.slept).toEqual([
|
|
251
|
+
{ label: "nextDigest", duration: interval },
|
|
252
|
+
{ label: "nextDigest", duration: interval },
|
|
253
|
+
]);
|
|
251
254
|
expect(run.slept).toHaveLength(output.digestsScheduled - 1);
|
|
252
255
|
// And no poll waits are mixed in: every job finished on its first poll, so
|
|
253
256
|
// every recorded sleep above is a SCHEDULE sleep.
|
|
@@ -320,9 +323,9 @@ describeWorkflowEval(agentDef, (test) => {
|
|
|
320
323
|
expect(world.calls.filter((call) => call.url.includes("/v2/transcript/"))).toHaveLength(5);
|
|
321
324
|
// Three waits for four rounds — asked for, and recorded rather than taken.
|
|
322
325
|
expect(run.slept).toEqual([
|
|
323
|
-
{ duration: POLL_DELAY_MS },
|
|
324
|
-
{ duration: POLL_DELAY_MS },
|
|
325
|
-
{ duration: POLL_DELAY_MS },
|
|
326
|
+
{ label: "poll", duration: POLL_DELAY_MS },
|
|
327
|
+
{ label: "poll", duration: POLL_DELAY_MS },
|
|
328
|
+
{ label: "poll", duration: POLL_DELAY_MS },
|
|
326
329
|
]);
|
|
327
330
|
|
|
328
331
|
// And the digest is in PUBLICATION order, not completion order. The feed is
|