@alexkroman1/aai-cli 10.0.0 → 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.
Files changed (45) hide show
  1. package/dist/{_bundler-B31MqYaD.mjs → _bundler-C31rlcYz.mjs} +1 -1
  2. package/dist/{_dev-server-Bo5dSBzQ.mjs → _dev-server-DWvkNoIS.mjs} +3 -3
  3. package/dist/{_vite-env-BNveawd1.mjs → _vite-env-D9Qj6KPX.mjs} +4 -2
  4. package/dist/_workflow-determinism-f9J-5Zlj.mjs +206 -0
  5. package/dist/_workflow-determinism.d.ts +118 -0
  6. package/dist/{build-DKC2e9o_.mjs → build-BLlLOrng.mjs} +4 -2
  7. package/dist/cli.mjs +372 -353
  8. package/dist/{client-bundler-CWnG42cU.mjs → client-bundler-j5c46x0G.mjs} +3 -2
  9. package/dist/client-bundler.mjs +1 -1
  10. package/dist/{deploy-CchW5pmw.mjs → deploy-BzZ-r4iM.mjs} +6 -4
  11. package/dist/{dev-BfVl1uBA.mjs → dev-BDykFvcc.mjs} +1 -1
  12. package/dist/{eval-DwNwdqmS.mjs → eval-0qEOdm0l.mjs} +1 -1
  13. package/dist/{init-BRfoc2EK.mjs → init-BtJI306C.mjs} +1 -1
  14. package/dist/{login-BBuM1sxH.mjs → login-DeUETobb.mjs} +4 -1
  15. package/dist/scaffold/CLAUDE.md +190 -11
  16. package/dist/scaffold/package.json +4 -4
  17. package/dist/{studio-wVWNLREn.mjs → studio-BxAS_FQQ.mjs} +4 -1
  18. package/dist/templates/call-audit/agent.test.ts +105 -26
  19. package/dist/templates/call-audit/workflows/audit.ts +9 -26
  20. package/dist/templates/dispatch-center/client.tsx +36 -2
  21. package/dist/templates/infocom-adventure/client.tsx +37 -9
  22. package/dist/templates/link-digest/agent.eval.test.ts +1 -1
  23. package/dist/templates/link-digest/agent.test.ts +164 -8
  24. package/dist/templates/link-digest/workflows/digest.ts +63 -20
  25. package/dist/templates/podcast-digest/agent.eval.test.ts +7 -4
  26. package/dist/templates/podcast-digest/agent.test.ts +265 -24
  27. package/dist/templates/podcast-digest/workflows/digest.ts +2 -2
  28. package/dist/templates/podcast-digest/workflows/feeds.ts +78 -73
  29. package/dist/templates/recap-workflow/agent.test.ts +206 -20
  30. package/dist/templates/recap-workflow/workflows/recap.ts +17 -14
  31. package/dist/templates/redline/agent.test.ts +137 -11
  32. package/dist/templates/research-workflow/agent.eval.test.ts +1 -1
  33. package/dist/templates/research-workflow/agent.test.ts +131 -9
  34. package/dist/templates/research-workflow/workflows/research.ts +1 -1
  35. package/dist/templates/retail/client.tsx +30 -2
  36. package/dist/templates/spoken-summary/agent.eval.test.ts +4 -1
  37. package/dist/templates/spoken-summary/agent.test.ts +117 -8
  38. package/dist/templates/spoken-summary/workflows/summarize.ts +1 -1
  39. package/dist/templates/transcription-workflow/agent.test.ts +127 -13
  40. package/dist/templates/transcription-workflow/workflows/batch.ts +6 -6
  41. package/dist/templates/transcription-workflow/workflows/stream.ts +4 -3
  42. package/dist/templates/transcription-workflow/workflows/transcribe.ts +4 -23
  43. package/dist/{test-CvwgeVSQ.mjs → test-CiLab-AA.mjs} +2 -1
  44. package/dist/worker-bundler.mjs +1 -1
  45. 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 one step → when the run began
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 one step → when it finished
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
- // `clockStart` and `clockEnd` are two NAMES for one function, deliberately.
167
- // `(name, occurrence)` step identity would tell two `now` calls apart on its
168
- // own (`now#0`, `now#1`), but a run's history is read by a person: `clockEnd`
169
- // says which end it is where `now#1` makes the reader count call sites.
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.step("clockStart", () => now()),
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.step("clockEnd", () => now());
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 three methods, and it is three buttons. */
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
- /** Pause/resume and hang-up. The only place a whole-session subscription is
224
- * still needed: the ACTIONS live on `useSession()`, and `useSessionCore` — the
225
- * narrow way `<Controls>` reaches them — is not on the public surface. */
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
- {/* end() hangs up and drops the sessionId, so the
244
- session-scoped game state starts over and the title
245
- screen returns. (session.reset() keeps the same
246
- sessionId and would resume the old game.) */}
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.end()}
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 workflow BODY is not tested here: it is only durable once the Workflow
14
- * DevKit's build has transformed it, so a unit test of it would exercise a plain
15
- * async function and prove nothing about replay. Its STEPS are, and directly —
16
- * a step is an ordinary exported async function, so
17
- * async function, so its HTML handling, its JSON contract with the model and its
18
- * `FatalError` guards are all testable.
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 { createWorkflowCtx, schemaInputIssues } from "@alexkroman1/aai/testing";
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 { beforeEach, describe, expect, test, vi } from "vitest";
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 { report } from "@alexkroman1/aai/step";
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 { decodeHtmlEntities } from "@alexkroman1/aai/utils";
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
- await report("Pulling out the claims worth keeping.");
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
- /** The document's `<title>`, when it has one. */
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
- const title = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1];
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
- * Deliberately crude, and the crudeness is the honest part: a real extractor is
230
- * a readability implementation and a dependency, where this is four `replace`
231
- * calls that get most of an article. What it MUST do is drop `<script>` and
232
- * `<style>` CONTENT stripping tags alone leaves a page's JavaScript in the
233
- * prompt, which is both expensive and a way to smuggle instructions past the
234
- * reader.
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 decodeHtmlEntities(
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([{ duration: interval }, { duration: interval }]);
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