agents 0.28.1 → 0.30.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 (115) hide show
  1. package/dist/{agent-routing-BMvWTFEp.d.ts → agent-routing-CDbpVjYq.d.ts} +7 -8
  2. package/dist/agent-routing.d.ts +1 -1
  3. package/dist/agent-tool-types.d.ts +1 -1
  4. package/dist/{agent-tools-ohoewW-B.d.ts → agent-tools-C8DH8Yii.d.ts} +2 -2
  5. package/dist/agent-tools.d.ts +1 -1
  6. package/dist/{ai-ahruGw7e.d.ts → ai-iXw0uOGj.d.ts} +3 -3
  7. package/dist/{ai-sdk-C2iTAwUd.js → ai-sdk-DRPh2rRa.js} +185 -125
  8. package/dist/ai-sdk-DRPh2rRa.js.map +1 -0
  9. package/dist/browser/ai.d.ts +1 -1
  10. package/dist/browser/ai.js +2 -2
  11. package/dist/browser/index.d.ts +8 -8
  12. package/dist/browser/index.js +3 -3
  13. package/dist/browser/tools/ai-sdk.d.ts +2 -2
  14. package/dist/browser/tools/ai-sdk.js +1 -1
  15. package/dist/browser/tools/pi.d.ts +7 -3
  16. package/dist/browser/tools/pi.js +9 -5
  17. package/dist/browser/tools/pi.js.map +1 -1
  18. package/dist/browser/tools/tanstack-ai.d.ts +2 -2
  19. package/dist/browser/tools/tanstack-ai.js +1 -1
  20. package/dist/{browser-CEdNlsaD.js → browser-DccPw_wW.js} +130 -79
  21. package/dist/browser-DccPw_wW.js.map +1 -0
  22. package/dist/{browser-D30VlpcA.d.ts → browser-DlXhSAdf.d.ts} +41 -37
  23. package/dist/{browser-tool-4iomavs_.js → browser-tool-B1mhifin.js} +3 -3
  24. package/dist/{browser-tool-4iomavs_.js.map → browser-tool-B1mhifin.js.map} +1 -1
  25. package/dist/{browser-tool-vtrPOV8e.d.ts → browser-tool-Cxjk5sgG.d.ts} +2 -2
  26. package/dist/chat/index.d.ts +143 -71
  27. package/dist/chat/index.js +269 -110
  28. package/dist/chat/index.js.map +1 -1
  29. package/dist/chat-sdk/index.d.ts +1 -1
  30. package/dist/chat-sdk/index.js +1 -1
  31. package/dist/cli.js +53 -52
  32. package/dist/{client-Bp-JwL3p.d.ts → client-C27CtRZr.d.ts} +2 -2
  33. package/dist/client.d.ts +1 -1
  34. package/dist/{connector-DEXzVoaY.d.ts → connector-20DJ6p4k.d.ts} +2 -2
  35. package/dist/{connector-CtZylXdk.js → connector-DAXBOIIi.js} +2 -2
  36. package/dist/{connector-CtZylXdk.js.map → connector-DAXBOIIi.js.map} +1 -1
  37. package/dist/experimental/channels/web/index.js +1 -1
  38. package/dist/harness/container/index.js +1 -1
  39. package/dist/harness/container/index.js.map +1 -1
  40. package/dist/harness/opencode/index.js +1 -1
  41. package/dist/harness/pi/index.d.ts +70 -40
  42. package/dist/harness/pi/index.js +266 -330
  43. package/dist/harness/pi/index.js.map +1 -1
  44. package/dist/{images-C4hiIexM.js → images-xaDv2Fqe.js} +636 -168
  45. package/dist/images-xaDv2Fqe.js.map +1 -0
  46. package/dist/index-DCxk3OSJ.d.ts +378 -0
  47. package/dist/{index-kbSUIer3.d.ts → index-iv9FTXX9.d.ts} +80 -112
  48. package/dist/index.d.ts +1 -1
  49. package/dist/index.js +1 -1
  50. package/dist/{input-schema-xBofjBKC.js → input-schema-B0Etuo-o.js} +2 -2
  51. package/dist/{input-schema-xBofjBKC.js.map → input-schema-B0Etuo-o.js.map} +1 -1
  52. package/dist/lifecycle/index.js +1 -1
  53. package/dist/{lifecycle-DGorpAmA.js → lifecycle-v1rKHV2C.js} +67 -19
  54. package/dist/lifecycle-v1rKHV2C.js.map +1 -0
  55. package/dist/mcp/client/index.d.ts +1 -1
  56. package/dist/mcp/index.d.ts +1 -1
  57. package/dist/mcp/index.js +1 -1
  58. package/dist/models/ai-sdk/index.d.ts +29 -352
  59. package/dist/models/ai-sdk/index.js +1 -1
  60. package/dist/models/opencode/index.d.ts +25 -13
  61. package/dist/models/opencode/index.js +16 -11
  62. package/dist/models/opencode/index.js.map +1 -1
  63. package/dist/models/pi-ai/index.d.ts +13 -14
  64. package/dist/models/pi-ai/index.js +87 -34
  65. package/dist/models/pi-ai/index.js.map +1 -1
  66. package/dist/react.d.ts +2 -2
  67. package/dist/routing/index.d.ts +1 -1
  68. package/dist/serializable.d.ts +1 -1
  69. package/dist/{session-store-DoPY12xM.d.ts → session-store-_IK6dV-w.d.ts} +14 -2
  70. package/dist/{session-store-Do4xkDcf.js → session-store-yvwq1f9x.js} +55 -2
  71. package/dist/session-store-yvwq1f9x.js.map +1 -0
  72. package/dist/{settings-BjAiPd6Z.js → settings-Dk2il2Hf.js} +4 -1
  73. package/dist/settings-Dk2il2Hf.js.map +1 -0
  74. package/dist/skills/index.d.ts +22 -28
  75. package/dist/skills/index.js +183 -143
  76. package/dist/skills/index.js.map +1 -1
  77. package/dist/{src-CQxJFv4l.js → src-DfLzdwlK.js} +3 -3
  78. package/dist/src-DfLzdwlK.js.map +1 -0
  79. package/dist/sub-routing.d.ts +1 -1
  80. package/dist/{tool-VF0qiO7q.js → tool-DkT_DJBm.js} +2 -2
  81. package/dist/tool-DkT_DJBm.js.map +1 -0
  82. package/dist/transport-CPh7YGiw.d.ts +623 -0
  83. package/dist/voice/index.d.ts +1 -1
  84. package/dist/web/fetch/tools/pi.d.ts +10 -6
  85. package/dist/web/fetch/tools/pi.js +12 -8
  86. package/dist/web/fetch/tools/pi.js.map +1 -1
  87. package/dist/web/search/index.js +1 -1
  88. package/dist/web/search/tools/ai-sdk.js +2 -2
  89. package/dist/web/search/tools/pi.d.ts +10 -6
  90. package/dist/web/search/tools/pi.js +13 -9
  91. package/dist/web/search/tools/pi.js.map +1 -1
  92. package/dist/web/search/tools/tanstack-ai.js +2 -2
  93. package/dist/websockets/index.js +1 -1
  94. package/dist/{websockets-C3zy8YR0.js → websockets-CyoeosdO.js} +2 -2
  95. package/dist/{websockets-C3zy8YR0.js.map → websockets-CyoeosdO.js.map} +1 -1
  96. package/dist/workflow-types.d.ts +1 -1
  97. package/dist/workflows.d.ts +1 -1
  98. package/dist/workflows.js +1 -1
  99. package/docs/browse-the-web.md +1 -1
  100. package/docs/chat-agents.md +19 -18
  101. package/docs/harnesses/pi.md +101 -38
  102. package/docs/lifecycle.md +7 -1
  103. package/docs/models-pi-ai.md +66 -20
  104. package/docs/models.md +197 -172
  105. package/package.json +12 -7
  106. package/dist/ai-sdk-C2iTAwUd.js.map +0 -1
  107. package/dist/browser-CEdNlsaD.js.map +0 -1
  108. package/dist/images-C4hiIexM.js.map +0 -1
  109. package/dist/lifecycle-DGorpAmA.js.map +0 -1
  110. package/dist/session-store-Do4xkDcf.js.map +0 -1
  111. package/dist/settings-BjAiPd6Z.js.map +0 -1
  112. package/dist/settings-DnD__xuE.d.ts +0 -349
  113. package/dist/src-CQxJFv4l.js.map +0 -1
  114. package/dist/tool-VF0qiO7q.js.map +0 -1
  115. package/dist/transport-DCyarnwm.d.ts +0 -200
@@ -652,7 +652,7 @@ Durable bookkeeping stays enabled even when automatic continuation is not approp
652
652
 
653
653
  - **Retries or side effects are unsafe:** override `onChatRecovery()` and return `{ continue: false }`. Persist idempotency keys or completion records before external side effects so a recovered turn can tell whether work already happened.
654
654
  - **Cancellation must survive eviction:** an `AbortSignal` only cancels the current in-memory turn. Also persist cancellation intent in agent state or SQL, read it in `onChatRecovery()`, and return `{ continue: false }` when cancellation was requested.
655
- - **Cost must be bounded:** set `maxAttempts`, `noProgressTimeoutMs`, `maxRecoveryWork`, and `maxOomRetries`. Use `shouldKeepRecovering` with durable spend data to stop later attempts. The predicate is not bound to the agent instance, so read spend from a store keyed by `ctx.recoveryRootRequestId`.
655
+ - **Cost must be bounded:** set `maxAttempts`, `noProgressTimeoutMs`, `maxRecoveryWork`, and `maxInterruptedAttempts`. Use `shouldKeepRecovering` with durable spend data to stop later attempts. The predicate is not bound to the agent instance, so read spend from a store keyed by `ctx.recoveryRootRequestId`.
656
656
  - **A provider can resume without a new model call:** use `this.stash()` to save its response ID, retrieve that response in `onChatRecovery()`, and return `{ persist: false, continue: false }`.
657
657
 
658
658
  `chatRecovery` can be configured with budgets and terminal behavior:
@@ -669,10 +669,10 @@ override chatRecovery = {
669
669
  // finite backstop (10000). Set a different value to tune when a turn that
670
670
  // keeps emitting content but never converges is sealed.
671
671
  maxRecoveryWork: 2000,
672
- // Tight retry budget for a Durable Object memory-limit reset (the isolate
673
- // exceeded its 128 MB limit). Defaults to 3; an OOM usually re-OOMs on
674
- // re-run, so recovery seals it with `out_of_memory` after a few attempts.
675
- maxOomRetries: 3,
672
+ // How many of one turn's attempts may die before recovery gives up, for
673
+ // example to a memory-limit reset on every run. Defaults to 5, counting
674
+ // the original turn.
675
+ maxInterruptedAttempts: 5,
676
676
  // Caller policy consulted from the second recovery attempt onward. Return
677
677
  // false to stop recovery. This is where you enforce a token/cost budget.
678
678
  // Note: this is called as `config.shouldKeepRecovering(ctx)`, so it is not
@@ -689,16 +689,16 @@ override chatRecovery = {
689
689
 
690
690
  **`chatRecovery` configuration:**
691
691
 
692
- | Field | Default | Description |
693
- | ---------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
694
- | `maxAttempts` | `10` | Attempt cap before terminal exhaustion. Resets on forward progress, so it catches a tight no-progress alarm loop, not a healthy long turn. |
695
- | `stableTimeoutMs` | `10_000` | How long a recovery attempt waits for the isolate to reach stable state before rescheduling. |
696
- | `terminalMessage` | generic message | The message shown to the user when recovery is given up on. |
697
- | `noProgressTimeoutMs` | `300_000` (5 min) | Primary stuck-turn bound: how long an incident may go without forward progress before it is sealed (`no_progress_timeout`). **Resets on every progress-bearing attempt**, so a turn that keeps producing content survives unbounded interruption. |
698
- | `maxRecoveryWork` | `10000` | Runaway-loop guard. Maximum recovery work since the incident began before a still-progressing turn is sealed (`work_budget_exceeded`), counted in durable stream segments (see [Recovery work units](#recovery-work-units)). A generous finite backstop so an agent that keeps emitting a little content but never converges (for example an isolate that runs out of memory mid-stream on every recovery) cannot loop forever. Work only accrues from the first interruption until the turn completes. Set a higher value, or `Infinity`, for a very long agentic turn that legitimately needs more. |
699
- | `maxOomRetries` | `3` | Tight retry budget for the specific case of a Durable Object isolate exceeding its memory limit and being reset mid-turn. An OOM is usually deterministic (the turn's working set no longer fits in 128 MB) so re-running re-OOMs, but a single OOM can be a transient spike — so recovery retries this many times before sealing with `out_of_memory`. Counts only attempts that ended in an OOM (not total attempts), so a turn interrupted by deploys is unaffected. Set `0` to seal on the first OOM. Far tighter than `maxRecoveryWork` because an OOM is attributable and each re-run re-runs the model. |
700
- | `shouldKeepRecovering` | — | Caller policy consulted from the second recovery attempt onward (never on the first detection, never once a hard bound has sealed the incident). Return `false` to stop recovery. The hook point for a token/cost budget — `ctx.work` is a coarse segment count, not tokens, so track real spend yourself. |
701
- | `onExhausted` | — | Called once when recovery is given up on, before the terminal message is delivered. Inspect `ctx.reason` for why. |
692
+ | Field | Default | Description |
693
+ | ------------------------ | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
694
+ | `maxAttempts` | `10` | Attempt cap before terminal exhaustion. Resets on forward progress, so it catches a tight no-progress alarm loop, not a healthy long turn. |
695
+ | `stableTimeoutMs` | `10_000` | How long a recovery attempt waits for the isolate to reach stable state before rescheduling. |
696
+ | `terminalMessage` | generic message | The message shown to the user when recovery is given up on. |
697
+ | `noProgressTimeoutMs` | `300_000` (5 min) | Primary stuck-turn bound: how long an incident may go without forward progress before it is sealed (`no_progress_timeout`). **Resets on every progress-bearing attempt**, so a turn that keeps producing content survives unbounded interruption. |
698
+ | `maxRecoveryWork` | `10000` | Runaway-loop guard. Maximum recovery work since the incident began before a still-progressing turn is sealed (`work_budget_exceeded`), counted in durable stream segments (see [Recovery work units](#recovery-work-units)). A generous finite backstop so an agent that keeps emitting a little content but never converges (for example an isolate that runs out of memory mid-stream on every recovery) cannot loop forever. Work only accrues from the first interruption until the turn completes. Set a higher value, or `Infinity`, for a very long agentic turn that legitimately needs more. |
699
+ | `maxInterruptedAttempts` | `5` | How many of one turn's attempts may be interrupted before recovery gives up (`interrupted_attempts_exceeded`). An attempt is interrupted when its isolate dies before it finishes: a Durable Object memory-limit reset, a deploy, or any other reset. The original turn counts, so the default runs the model at most five times for a turn that dies every time. The count is taken when the next isolate picks the turn up, before it re-runs anything, because an isolate killed by a memory-limit reset cannot record anything. Progress never resets it. A memory-limit reset and a deploy look the same from the next isolate, so a turn interrupted by five deploys in a row is also sealed. Values below `1` are read as `1`; `Infinity` disables the bound. Replaces `maxOomRetries`, which is still read as `maxInterruptedAttempts: maxOomRetries + 1`. |
700
+ | `shouldKeepRecovering` | — | Caller policy consulted from the second recovery attempt onward (never on the first detection, never once a hard bound has sealed the incident). Return `false` to stop recovery. The hook point for a token/cost budget — `ctx.work` is a coarse segment count, not tokens, so track real spend yourself. |
701
+ | `onExhausted` | — | Called once when recovery is given up on, before the terminal message is delivered. Inspect `ctx.reason` for why. |
702
702
 
703
703
  **`ChatRecoveryProgressContext`** (the `ctx` passed to `shouldKeepRecovering`):
704
704
 
@@ -713,12 +713,13 @@ override chatRecovery = {
713
713
  | `work` | `number` | Monotonic count of durable stream segments produced since the incident opened (not tokens). |
714
714
  | `ageMs` | `number` | Wall-clock ms since the incident's first interruption. |
715
715
 
716
- A progressing turn survives unbounded interruption (for example a dense deploy window) as long as it keeps making forward progress and stays under the `maxRecoveryWork` backstop. Recovery is sealed only by one of these `ctx.reason` values:
716
+ A progressing turn survives repeated interruption (for example a dense deploy window) as long as it keeps making forward progress, stays under the `maxRecoveryWork` backstop, and fewer than `maxInterruptedAttempts` of its attempts die. Recovery is sealed only by one of these `ctx.reason` values:
717
717
 
718
718
  - `no_progress_timeout` — no forward progress within the no-progress window (a stuck turn).
719
719
  - `max_attempts_exceeded` — the attempt cap was spent on a tight no-progress alarm loop.
720
720
  - `work_budget_exceeded` — the turn kept producing content but exceeded `maxRecoveryWork` (a runaway loop).
721
- - `out_of_memory` — recovery attempts kept hitting a Durable Object memory-limit reset until the tight `maxOomRetries` budget drained.
721
+ - `interrupted_attempts_exceeded` — the turn's attempts kept dying before they finished (for example a memory-limit reset on every run) until `maxInterruptedAttempts` of them had been interrupted.
722
+ - `out_of_memory` — the alarm memory-limit circuit breaker sealed the recovery after repeated memory-limit resets.
722
723
  - `recovery_aborted` — your `shouldKeepRecovering` hook returned `false`.
723
724
  - `stable_timeout` — recovery attempts kept timing out waiting for stable state until the budget drained (extreme churn).
724
725
 
@@ -736,7 +737,7 @@ The unit is only meaningful relative to another `ctx.work` reading, so to choose
736
737
 
737
738
  > **Upgrading an explicit `maxRecoveryWork`?** Releases before `agents@0.23.0` (`@cloudflare/ai-chat@0.12.0`, `@cloudflare/think@0.18.0`) counted "credited chunks" (one per tool call, one per text or reasoning segment start, and one per five seconds of streamed deltas), and the default was `1000`. The unit changed to durable segments and the default moved to `10000`. An explicit value is not recalibrated automatically, and there is no fixed conversion factor between the two units: a delta-heavy turn produces far more segments than it used to produce credits, while a turn dominated by tool calls changes much less. Measure `ctx.work` for your own turns before carrying an old value forward.
738
739
 
739
- > **Out-of-memory crash loops have a last-resort backstop.** A severe memory-limit reset can bypass the recovery budgets above entirely — for example if the Durable Object out-of-memories while _loading its state on wake_, before recovery even evaluates, or if the budget's own bookkeeping writes also out-of-memory. Left unhandled, the platform auto-retries the alarm forever, re-running the doomed (billable) turn each cycle. The SDK guards this at the alarm boundary: after `maxAlarmMemoryLimitStrikes` (a base `Agent` static option, default `3`) consecutive alarms end in a memory-limit reset, it seals the interrupted turn with `out_of_memory` and stops the loop, emitting an `alarm:memory_limit_reset` observability event. This bounds the blast radius (and the bill); it does not shrink the working set — a turn whose context genuinely no longer fits in 128 MB needs a smaller transcript/fewer or smaller tool results.
740
+ > **Out-of-memory crash loops.** A turn that exceeds the Durable Object's 128 MB limit on every run is bounded by `maxInterruptedAttempts`: each run that dies is counted when the next isolate picks the turn up, before it re-runs anything, and the fifth seals the turn with `interrupted_attempts_exceeded`. The isolate that hit the limit cannot record anything itself, because every storage call after the reset fails. The alarm boundary also catches memory-limit resets so the platform does not retry the alarm in a tight loop, and emits an `alarm:memory_limit_reset` observability event. Its strike counter (`maxAlarmMemoryLimitStrikes`, a base `Agent` static option, default `3`) only advances while storage still accepts writes, for example when the reset came from another isolate such as D1. None of this shrinks the working set: a turn whose context no longer fits in 128 MB needs a smaller transcript or fewer or smaller tool results.
740
741
 
741
742
  #### Turns waiting on a human are not sealed
742
743
 
@@ -11,10 +11,10 @@ Pi owns everything about a run: the transcript, the inbox of steers and follow-u
11
11
  ## Install the Pi packages
12
12
 
13
13
  ```sh
14
- npm install agents @earendil-works/pi-durable @earendil-works/pi-ai
14
+ npm install agents @earendil-works/pi-durable @earendil-works/pi-ai @earendil-works/chord
15
15
  ```
16
16
 
17
- Both Pi packages are optional peer dependencies of `agents`, at `^1.0.0`. The examples use Workers AI through the `AI` binding. To configure the binding and choose other models, refer to [Models for pi-ai](../models-pi-ai.md).
17
+ The three Pi packages are optional peer dependencies of `agents`, at `~1.4.0`. pi-durable is experimental, and its API can change in minor releases, so `agents` supports one Pi minor at a time. The examples use Workers AI through the `AI` binding. To configure the binding and choose other models, refer to [Models for pi-ai](../models-pi-ai.md).
18
18
 
19
19
  ## Create the harness
20
20
 
@@ -32,12 +32,12 @@ export class Assistant extends Agent<Env> {
32
32
  registry = createRegistry();
33
33
 
34
34
  harness = new PiHarness({
35
- harness: ({ storage, context }) => {
35
+ harness: ({ storage, context, onWake }) => {
36
36
  const models = createModels();
37
37
  models.setProvider(this.ai.provider);
38
38
  return Harness.open(
39
39
  storage,
40
- { models, registry: this.registry },
40
+ { models, registry: this.registry, onWake },
41
41
  context
42
42
  );
43
43
  },
@@ -58,12 +58,14 @@ export class Assistant extends Agent<Env> {
58
58
  }
59
59
  ```
60
60
 
61
- | Option | What it is |
62
- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
63
- | `harness` | Required. Receives `{ storage, context }` and returns Pi's `Harness`, usually from `Harness.open`. Models, the registry, `settings`, `env` and `onReport` are all yours to build here. |
64
- | `defaults` | What a new session starts with: `model`, a pi-ai `Model` such as `ai("@cf/…")`, and `thinkingLevel`. Without a model, a session's prompts end unanswered (`no_model`) until one is set. |
61
+ | Option | What it is |
62
+ | ---------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
63
+ | `harness` | Required. Receives `{ storage, context, onWake }` and returns Pi's `Harness`, usually from `Harness.open`. Pass `onWake` to `Harness.open`, so the object can hibernate while Pi's tasks sleep (see [How recovery works](#how-recovery-works)). Models, the registry, `settings`, `env` and `onReport` are all yours to build here. |
64
+ | `defaults` | What a new session starts with: `model`, a pi-ai `Model` such as `ai("@cf/…")`, and `thinkingLevel`. Without a model, a session's prompts end unanswered (`no_model`) until one is set. |
65
65
 
66
- The factory runs as part of startup, once per isolate. If it throws the operation that triggered it fails and the next operation tries again.
66
+ Create the registry per object, as the `registry` field above does, not at module scope: Pi keeps every `Harness` subscribed to its registry until the `Harness` closes, and an evicted object never closes it.
67
+
68
+ The factory runs once per open: at startup, and again after a storage error (see [How recovery works](#how-recovery-works)), so keep it safe to run more than once. If it throws the operation that triggered it fails and the next operation tries again.
67
69
 
68
70
  A model in `defaults` or `session.setModel()` is stored by its provider and id only. Pi resolves it at each request against the `Models` the factory opened Pi with, so it must be a model those `Models` list. Options passed to `ai(id, options)` there, such as `fallback`, are not applied.
69
71
 
@@ -89,18 +91,22 @@ const wordCount: ToolRegistration<typeof WordCount> = {
89
91
  // `text` is typed from `parameters` and is validated by Pi.
90
92
  async execute({ text }) {
91
93
  const words = text.split(/\s+/).filter(Boolean).length;
92
- return { content: [{ type: "text", text: String(words) }] };
94
+ return { output: [{ type: "text", text: String(words) }] };
93
95
  }
94
96
  };
95
97
 
96
- const registry = createRegistry();
97
- registry.install({
98
- name: "writing",
99
- sections: [
100
- { key: "preamble", render: () => "You are an editor.", tag: false }
101
- ],
102
- tools: [wordCount]
103
- });
98
+ // One per object, as a field: `registry = createWritingRegistry();`
99
+ function createWritingRegistry() {
100
+ const registry = createRegistry();
101
+ registry.install({
102
+ name: "writing",
103
+ sections: [
104
+ { key: "preamble", render: () => "You are an editor.", tag: false }
105
+ ],
106
+ tools: [wordCount]
107
+ });
108
+ return registry;
109
+ }
104
110
  ```
105
111
 
106
112
  - **Tool arguments are typed from the schema.** Type a tool as `ToolRegistration<typeof Parameters>`; a bare `ToolRegistration` types its arguments as `unknown`.
@@ -117,9 +123,13 @@ Pi's extensions can do more: hooks on model requests and tool calls (`hook(ToolT
117
123
  ```ts
118
124
  import { skills } from "agents/harness/pi";
119
125
 
120
- harness: async ({ storage, context }) => {
126
+ harness: async ({ storage, context, onWake }) => {
121
127
  this.registry.install(await skills(sources));
122
- return Harness.open(storage, { models, registry: this.registry }, context);
128
+ return Harness.open(
129
+ storage,
130
+ { models, registry: this.registry, onWake },
131
+ context
132
+ );
123
133
  };
124
134
  ```
125
135
 
@@ -135,14 +145,18 @@ import { browserTool } from "agents/browser/pi";
135
145
 
136
146
  readonly browser = new Browser({ provider: browserRun(this.env.BROWSER) });
137
147
 
138
- harness: async ({ storage, context }) => {
148
+ harness: async ({ storage, context, onWake }) => {
139
149
  this.registry.install({
140
150
  name: "browser",
141
151
  tools: [
142
152
  browserTool({ ctx: this.ctx, browser: this.browser, loader: this.env.LOADER })
143
153
  ]
144
154
  });
145
- return Harness.open(storage, { models, registry: this.registry }, context);
155
+ return Harness.open(
156
+ storage,
157
+ { models, registry: this.registry, onWake },
158
+ context
159
+ );
146
160
  };
147
161
 
148
162
  readonly lifecycle = Lifecycle.install(this).use(this.browser).use(this.harness);
@@ -169,27 +183,76 @@ const receipt = await session.submit("Summarize the latest report", {
169
183
  const result = await session.wait(receipt.operationId);
170
184
  ```
171
185
 
172
- | Call | What it does |
173
- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
174
- | `prompt(input)` | Submits and waits: the answer, and the transcript after it. |
175
- | `submit(input, { operationId, whenBusy })` | Returns once Pi has durably accepted the input, before the model runs. A busy session queues it as a follow-up; `whenBusy: "steer"` joins the running work after its current tool round. |
176
- | `wait(operationId, signal?)` | The operation's result once Pi settles it. Aborting `signal` stops only the wait, not the work. |
177
- | `steer(input)` | `submit` with `whenBusy: "steer"`. |
178
- | `abort(operationId?)` | Withdraws a queued operation, or with no id, stops everything running in the session. |
179
- | `pending()` | Operations Pi has not settled yet: queued, or part of the running work. |
180
- | `messages()` | Pi's transcript entries since the newest reset. |
181
- | `events()` | Pi's agent events: a `snapshot`, then one batch per commit. |
182
- | `reset(handoff?)` | Starts a new context, optionally from a handoff note. |
183
- | `setModel(model)` | Changes this session's model. |
184
- | `busy()` | Whether a run is going. |
185
-
186
- `harness.prompt()`, `submit()`, `wait()`, `abort()`, `messages()` and `pending()` act on the root session, or on `{ session }`. `harness.sessions` has `create()`, `fork(from)`, `get(id)` and `list()`, which includes sessions subagents created. `ROOT_SESSION` is the root's id.
186
+ | Call | What it does |
187
+ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
188
+ | `prompt(input)` | Submits and waits: the answer, and the transcript after it. |
189
+ | `submit(input, { operationId, whenBusy, entry })` | Hands the input to Pi and returns as soon as Pi has saved it, without waiting for the model. If the session is already working, the input waits until that work finishes. With `whenBusy: "steer"`, it joins the running work instead, after the current round of tool calls. `entry` records where the input came from (see below). |
190
+ | `wait(operationId, signal?)` | The operation's result once Pi settles it. Aborting `signal` stops only the wait, not the work. |
191
+ | `steer(input)` | `submit` with `whenBusy: "steer"`. |
192
+ | `abort(operationId?)` | Withdraws a queued operation, or with no id, stops everything running in the session. |
193
+ | `pending()` | Operations Pi has not settled yet: queued, part of the running work, or `parked` (see [How recovery works](#how-recovery-works)). |
194
+ | `retry()` | Runs again the tasks Pi's restart limit parked in the session's work. Resolves `false` when nothing was parked. |
195
+ | `messages()` | Pi's transcript entries since the newest reset. |
196
+ | `events()` | Pi's agent events: a `snapshot`, then one batch per commit. |
197
+ | `reset(handoff?)` | Starts a new context, optionally from a handoff note. |
198
+ | `setModel(model)` | Changes this session's model. |
199
+ | `busy()` | Whether a run is going. |
200
+
201
+ `harness.prompt()`, `submit()`, `wait()`, `abort()`, `retry()`, `messages()` and `pending()` act on the root session, or on `{ session }`. `harness.sessions` has `create()`, `fork(from)`, `get(id)` and `list()`, which includes sessions subagents created. `ROOT_SESSION` is the root's id.
187
202
 
188
203
  `messages()` and `events()` are Pi's own records, not a UI-ready chat transcript. The example below folds them into one.
189
204
 
205
+ ### Label input the user did not type
206
+
207
+ Pi records every submitted input as a `pi.user` entry, as if the user typed it. Your code often submits input of its own, such as a prompt from a scheduled job, a webhook payload or the outcome of an approval. To record where that input came from, pass `entry` with a `kind` of your own and any `data` you want to keep with it:
208
+
209
+ ```ts
210
+ await session.submit("The deploy was approved. Run the migration.", {
211
+ entry: { kind: "app.approval", data: { approved: true, by: "ben" } }
212
+ });
213
+ ```
214
+
215
+ The model sees no difference: it still gets the text as a user message. Only the transcript changes. The input is stored with your `kind` and `data` instead of `pi.user`, and `messages()` and `events()` return both, so your UI can show it differently, for example as "Approved by ben" rather than as something the user said. Pi reserves kinds that start with `pi.`, so use a prefix of your own, such as `app.`.
216
+
217
+ The kind is a label, not a permission. It records what your code said the input was, and nothing checks it. The model reads the input as if the user wrote it, so do not put anything in it that you would not let the user send.
218
+
219
+ The example app folds the transcript into chat messages, ignores `kind` and `data`, and shows every input as a user message. To show labelled input differently, read them from the entry.
220
+
221
+ ### Inputs queued behind a failed run
222
+
223
+ A run fails with `model_error` when the model errors and Pi does not retry or the error outlasts its retries, and with `no_model` when the session has no usable model. Its own operation settles `unanswered` with that reason. Pi's `settings.afterFailedRun` decides what happens to the inputs queued behind it. Refer to [Failures and Recovery](https://github.com/earendil-works/pi/tree/main/packages/durable#failures-and-recovery) in the pi-durable README for what each value does. Set it in the factory:
224
+
225
+ ```ts
226
+ harness: ({ storage, context, onWake }) =>
227
+ Harness.open(
228
+ storage,
229
+ {
230
+ models,
231
+ registry: this.registry,
232
+ onWake,
233
+ settings: { afterFailedRun: "withdraw" }
234
+ },
235
+ context
236
+ );
237
+ ```
238
+
239
+ | Value | What callers see |
240
+ | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
241
+ | `keep` (default) | `pending()` lists the inputs as `queued`, `busy()` is `false`, and `wait()` keeps waiting. The next `submit()` runs them first, in order, then the new input. The next `reset()` runs them in the new context. |
242
+ | `continue` | `wait()` returns their results as usual. If the cause persists, such as a missing model, each queued input fails in its own run. |
243
+ | `withdraw` | `wait()` returns `{ status: "unanswered", reason: "run_failed" }`, and `pending()` no longer lists them. |
244
+
245
+ With `keep`, the kept inputs are not work to Pi: its `wake()` counts tasks, not queued inputs. The wake job completes, and the object can hibernate until the next `submit()` or `reset()`. Pass a `signal` to `wait()` if a caller should not wait that long. To withdraw a kept input, call `abort(operationId)`, which settles it `unanswered` with reason `aborted`. `session.abort()` with no id stops the whole session, which withdraws every kept input too.
246
+
190
247
  ## How recovery works
191
248
 
192
- The harness schedules one lifecycle job per session with work. The job waits on Pi's tasks, refreshes itself as a heartbeat while they run, and completes when they settle. If the object is evicted or crashes, the job is still due, so its alarm restarts the object. Pi reopens its stored state and resumes the session's work: a replay-safe tool call runs again, and an unsafe one is reported to the model as interrupted.
249
+ The harness schedules one lifecycle job, driven by Pi's `wake()`. While Pi has work running, the job waits on it and refreshes itself as a heartbeat. While Pi's tasks sleep, such as a retry backoff, the job is due when the first one wakes. Pi says when through the `onWake` the factory passes to `Harness.open`, and sets no in-memory timer of its own, so the object can hibernate until then. Without `onWake` the harness still works, but Pi's timer keeps the object in memory, and billed, while a task sleeps, and the harness logs a warning. When Pi has nothing to do, the job completes. If the object is evicted or crashes, the job is still due, so its alarm restarts the object. Pi reopens its stored state and resumes the session's work: a replay-safe tool call runs again, and an unsafe one is reported to the model as interrupted.
250
+
251
+ A task that keeps crashing the object would restart it forever, so Pi parks a task cut off by a crash or eviction `settings.restartLimit` times in a row (default 5) without progress. A parked task is not work: the job completes and the object can hibernate. Its operations show as `parked` in `pending()`, and `wait()` keeps waiting. If your factory passes `onReport` to `Harness.open`, Pi reports it there as `task_parked`. Once you have dealt with the cause, `session.retry()` runs it again and wakes the harness; `session.abort()` gives up on it instead.
252
+
253
+ A storage error closes Pi for good: the call that hit it rejects with the error, and calls and `events()` streams already underway end with Pi's `SessionFailed`, so retry them. If your factory passes `onReport` to `Harness.open`, Pi reports the failure there as `session_failed`. Once the failed Pi has closed, the harness opens Pi again, which runs the factory again, on the next call or the job's next step. Closing waits for running task code, such as a tool call, to return, so code that ignores its abort signal delays the reopen. Pi recovers from what it committed and resumes its work the same way.
254
+
255
+ If a step of the job fails, such as when the factory throws, the job is not dropped. It tries again after 30 seconds, doubling the delay with each failure in a row up to 10 minutes, and goes back to its usual schedule once a step succeeds. A Durable Object memory-limit reset that fails Pi while the job waits on it counts against the alarm's memory-limit circuit breaker. Lifecycle backs the job off and drops it after repeated resets. The next call opens Pi again and pushes the job again, so Pi's restart limit is what eventually parks a task that keeps causing resets.
193
256
 
194
257
  Every operation waits for the object's startup, which runs the factory. A call over RPC that arrives first waits for it rather than opening Pi on its own.
195
258
 
package/docs/lifecycle.md CHANGED
@@ -234,7 +234,13 @@ physical alarm automatically — there is no explicit rearm call. When the
234
234
  alarm fires, Lifecycle drives due jobs in due order as an event loop, then
235
235
  runs host `onAlarm()`, then re-arms from queue state. Before driving any job
236
236
  it arms a deadman pre-alarm so an isolate death mid-drive still wakes the
237
- object to resume.
237
+ object to resume. While the queue holds jobs, it also arms a fallback alarm
238
+ before startup runs, at the next job's time but at least 30 seconds out. An
239
+ alarm whose startup throws still leaves that wake, whether or not the
240
+ platform's own alarm retries run, so the failed startup is retried then. A
241
+ startup that pushes or reschedules jobs and then throws, from an alarm or a
242
+ request, arms the same fallback from the queue as it stands, so those jobs
243
+ keep a wake too.
238
244
 
239
245
  A job's drive result decides what happens next: returning nothing completes
240
246
  and deletes it, `{ rescheduleAt }` suspends it until a future time, and
@@ -43,8 +43,10 @@ design: a model this package cannot name exactly is yours to construct.
43
43
  created on first use.
44
44
  3. **No provider keys.** AI Gateway holds the credential — unified billing, or a key you
45
45
  store on the gateway (BYOK) — so nothing vendor-shaped needs a secret in your Worker.
46
- 4. **The binding is the only way in.** Workers AI goes through `env.AI.run`, vendor models
47
- through `env.AI.gateway(id).run`; there is no HTTP transport and no API token to hold.
46
+ 4. **The binding, or an account id and token.** Workers AI and catalog models go through
47
+ `env.AI.run`, other model objects through the binding's `fetch` to AI Gateway's
48
+ provider-native path. With [an account id and API token](#account-id-and-api-token) the
49
+ same requests go over HTTP instead.
48
50
  5. Everything here is in beta and may change in a minor release.
49
51
 
50
52
  ## Quick start
@@ -77,6 +79,26 @@ export default {
77
79
  };
78
80
  ```
79
81
 
82
+ ### Account id and API token
83
+
84
+ To use a gateway in another account, or to call from outside a Worker, pass `accountId` and
85
+ `apiToken` instead of `binding`. Everything else is the same, and the gateway still defaults to
86
+ `default`:
87
+
88
+ ```ts
89
+ const ai = createAI({
90
+ accountId: "27b146402af2103944379f33841b6234",
91
+ apiToken: env.CLOUDFLARE_API_TOKEN,
92
+ gateway: "project-gateway"
93
+ });
94
+ ```
95
+
96
+ Workers AI and catalog models go to AI Gateway's
97
+ [REST API](https://developers.cloudflare.com/ai-gateway/usage/rest-api/), which runs them on
98
+ `/run` as `env.AI.run` does. Other model objects go to the provider-native path on
99
+ `gateway.ai.cloudflare.com`, with the token as `cf-aig-authorization`. Create the token with
100
+ **Workers AI Read** and **AI Gateway Run**; it is sent to those two hosts only.
101
+
80
102
  ## Where a model comes from
81
103
 
82
104
  ```ts
@@ -144,6 +166,10 @@ A session stores its model as `{ provider, modelId }`, not as a model object, so
144
166
  `@cf/…` and `<provider>/<id>` forms there. Options given to `ai(model, options)` do not reach a
145
167
  session; set them on `createAI` instead.
146
168
 
169
+ A model stream that is cut off, or that sends nothing for `streamIdleTimeoutMs`, ends in an error
170
+ pi-ai's `isRetryableAssistantError()` treats as transient, so the harness retries the turn under
171
+ its retry settings rather than failing it.
172
+
147
173
  `ai.streamFn` is `streamSimple`, bound, for frameworks that take a stream function, such as
148
174
  pi-agent-core's `Agent({ streamFn })`.
149
175
 
@@ -179,7 +205,7 @@ ai("anthropic/claude-opus-4.8", {
179
205
  eventId: "turn-17",
180
206
  requestTimeoutMs: 30_000,
181
207
  retries: { maxAttempts: 3, backoff: "exponential", retryDelayMs: 500 },
182
- zdr: true, // Zero Data Retention, routed models on unified billing
208
+ byokAlias: "team-a", // which stored BYOK key, by alias
183
209
  fallback: ["@cf/zai-org/glm-4.7-flash"], // ids or models, in any mix
184
210
  headers: { "x-request-source": "docs" },
185
211
  sessionAffinity: "chat-42", // Workers AI prefix-cache routing
@@ -211,20 +237,29 @@ then model, then `createAI` settings.
211
237
 
212
238
  ## How a request travels
213
239
 
214
- | Model | `api` | Transport |
215
- | ------------------------ | -------------------- | ----------------------------------------- |
216
- | `@cf/…` | `cloudflare-ai` | `env.AI.run` + Cloudflare's compat layer |
217
- | Anthropic (any registry) | `anthropic-messages` | gateway universal → `v1/messages` |
218
- | OpenAI (any registry) | `openai-responses` | gateway universal → `v1/responses` |
219
- | Groq, DeepSeek, xAI, … | `openai-completions` | gateway universal → `v1/chat/completions` |
220
-
221
- Everything that is not `@cf/` goes through **AI Gateway's universal request**: the vendor's
222
- own body, built by pi-ai's own converter, posted to the gateway with the provider slug taken
223
- from the model's `baseUrl` host. The gateway forwards it to the vendor with the credential it
224
- holds, and streams the vendor's own SSE back untouched — pi-ai's stream processors parse it,
225
- as they would if you had called the vendor directly, the thinking knobs aside (below). No
226
- credential header leaves your Worker, and the model's `api`, `provider` and `id` are never
227
- rewritten, so thinking signatures and encrypted reasoning items replay on the next turn.
240
+ | Model | `api` | Through the binding | With an account id and token |
241
+ | --------------------------------------- | -------------------- | --------------------------------------------------------- | ------------------------------------------------------ |
242
+ | `@cf/…`, `dynamic/…` | `cloudflare-ai` | `env.AI.run` + Cloudflare's compat layer | the REST API + Cloudflare's compat layer |
243
+ | `anthropic/…` from pi's gateway catalog | `anthropic-messages` | `env.AI.run` → AI Gateway `/run`, Messages in and out | the REST API's `/ai/v1/messages` → AI Gateway `/run` |
244
+ | `openai/…` from pi's gateway catalog | `openai-responses` | `env.AI.run` → AI Gateway `/run`, Responses in and out | the REST API's `/ai/v1/responses` → AI Gateway `/run` |
245
+ | Any other model object | its own `api` | the binding's `fetch` → AI Gateway's provider-native path | `gateway.ai.cloudflare.com` → the provider-native path |
246
+
247
+ A model from pi's Cloudflare AI Gateway catalog runs on `env.AI.run` under the gateway's
248
+ catalog id (`anthropic/claude-opus-4.8`). pi-ai spells Claude ids as Anthropic does
249
+ (`claude-opus-4-8`), so `agents` lists and resolves them with the catalog's dot instead. The body is pi-ai's own,
250
+ built by pi-ai's converter for the model's `api`, and it is sent in that format: `/run`
251
+ converts nothing, so the answer, streams included, is the shape pi-ai's stream processors
252
+ read. `/run` picks the provider and bills through Unified Billing or a key stored on the
253
+ gateway.
254
+
255
+ Any other model object — one from pi's Anthropic, OpenAI or Groq registry, with the vendor's
256
+ own id — goes to AI Gateway's **provider-native path** through the binding's `fetch`, with the
257
+ provider slug taken from the model's `baseUrl` host. The gateway forwards it to the vendor with
258
+ the credential it holds and streams the vendor's own SSE back untouched.
259
+
260
+ Either way no vendor credential leaves your Worker, and the model's `api`, `provider` and
261
+ `id` are never rewritten, so thinking signatures and encrypted reasoning items replay on the
262
+ next turn.
228
263
 
229
264
  The `api` markers in that table are the only ones this package routes, and the table is the
230
265
  whole list: `cloudflare-ai`, `anthropic-messages`, `openai-responses`, `openai-completions`.
@@ -266,8 +301,10 @@ gateway, an unpaid account) become a `CloudflareAIError`, so fallback and retry
266
301
  way they treat Workers AI failures. A model whose `api` is not one of the four routed markers
267
302
  never gets that far: the stream ends with an `error` event naming the `api`.
268
303
 
269
- `zdr` turns Zero Data Retention on or off for one request, whatever the gateway's own setting.
270
- It applies to a routed model on unified billing and is sent as `cf-aig-zdr`.
304
+ `byokAlias` picks a stored key by alias when the gateway holds several for one provider. It is
305
+ sent as `cf-aig-byok-alias` on a catalog model and on any other model object. `zdr` turns Zero
306
+ Data Retention on or off for a Unified Billing request, and applies to model objects outside
307
+ pi's gateway catalog only: `/run` does not read it.
271
308
 
272
309
  ## Observability
273
310
 
@@ -287,6 +324,15 @@ A failed dispatch ends the stream with an `error` event whose message carries a
287
324
  `cloudflare-error` diagnostic (`status`, `code`, `logId`), and `onResponse` fires with the raw
288
325
  status and headers for every request.
289
326
 
327
+ ## Stream resume
328
+
329
+ Through the binding, a model from pi's AI Gateway catalog streams from `/run`, which names the run in
330
+ `cf-aig-run-id` and keeps its events. If the connection drops, the provider reconnects through
331
+ the binding and replays from the last complete event, so the stream continues with nothing lost
332
+ or repeated. It gives up after five drops or once the gateway no longer holds the run, and an
333
+ abort never resumes. Workers AI streams and models on the provider-native path have no run id
334
+ and are not resumed. The run id is on the `cloudflare` diagnostic as `runId`.
335
+
290
336
  ## Fallback
291
337
 
292
338
  ```ts
@@ -324,7 +370,7 @@ Every other model in the catalog takes the full set.
324
370
 
325
371
  ## Beta
326
372
 
327
- This subpath needs `@earendil-works/pi-ai@^1.0.0`, an optional peer of `agents`. `agents`
373
+ This subpath needs `@earendil-works/pi-ai@~1.4.0`, an optional peer of `agents`. `agents`
328
374
  takes no vendor package as a dependency, optional or otherwise. The surface is in beta
329
375
  and may change in a minor release. An unlisted `@cf/` id resolves with default metadata (a
330
376
  128k context window, 8192 output tokens, zero cost); pass overrides when you know better.