@dynamicagents/core 0.9.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 (168) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +522 -0
  3. package/dist/a2a/agent-stub.d.ts +60 -0
  4. package/dist/a2a/agent-stub.js +1 -0
  5. package/dist/a2a/caller-token.d.ts +44 -0
  6. package/dist/a2a/caller-token.js +61 -0
  7. package/dist/a2a/caller.d.ts +23 -0
  8. package/dist/a2a/caller.js +33 -0
  9. package/dist/a2a/card.d.ts +158 -0
  10. package/dist/a2a/card.js +163 -0
  11. package/dist/a2a/context.d.ts +19 -0
  12. package/dist/a2a/context.js +61 -0
  13. package/dist/a2a/deliver.d.ts +159 -0
  14. package/dist/a2a/deliver.js +185 -0
  15. package/dist/a2a/executor.d.ts +84 -0
  16. package/dist/a2a/executor.js +106 -0
  17. package/dist/a2a/index.d.ts +35 -0
  18. package/dist/a2a/index.js +34 -0
  19. package/dist/a2a/notify.d.ts +106 -0
  20. package/dist/a2a/notify.js +180 -0
  21. package/dist/a2a/parts.d.ts +45 -0
  22. package/dist/a2a/parts.js +94 -0
  23. package/dist/a2a/push.d.ts +70 -0
  24. package/dist/a2a/push.js +53 -0
  25. package/dist/a2a/self-origin.d.ts +91 -0
  26. package/dist/a2a/self-origin.js +114 -0
  27. package/dist/a2a/task-store.d.ts +44 -0
  28. package/dist/a2a/task-store.js +99 -0
  29. package/dist/a2a/task.d.ts +85 -0
  30. package/dist/a2a/task.js +15 -0
  31. package/dist/a2a/verify.d.ts +80 -0
  32. package/dist/a2a/verify.js +143 -0
  33. package/dist/agent/budget.d.ts +46 -0
  34. package/dist/agent/budget.js +42 -0
  35. package/dist/agent/control.d.ts +109 -0
  36. package/dist/agent/control.js +115 -0
  37. package/dist/agent/errors.d.ts +85 -0
  38. package/dist/agent/errors.js +64 -0
  39. package/dist/agent/final-reply.d.ts +49 -0
  40. package/dist/agent/final-reply.js +68 -0
  41. package/dist/agent/history.d.ts +97 -0
  42. package/dist/agent/history.js +133 -0
  43. package/dist/agent/index.d.ts +29 -0
  44. package/dist/agent/index.js +29 -0
  45. package/dist/agent/inference.d.ts +110 -0
  46. package/dist/agent/inference.js +120 -0
  47. package/dist/agent/model.d.ts +90 -0
  48. package/dist/agent/model.js +1 -0
  49. package/dist/agent/session.d.ts +100 -0
  50. package/dist/agent/session.js +82 -0
  51. package/dist/agent/workers-ai/index.d.ts +23 -0
  52. package/dist/agent/workers-ai/index.js +23 -0
  53. package/dist/agent/workers-ai/runtime.d.ts +42 -0
  54. package/dist/agent/workers-ai/runtime.js +63 -0
  55. package/dist/alarm/index.d.ts +77 -0
  56. package/dist/alarm/index.js +116 -0
  57. package/dist/config.d.ts +202 -0
  58. package/dist/config.js +135 -0
  59. package/dist/contract/index.d.ts +9 -0
  60. package/dist/contract/index.js +8 -0
  61. package/dist/contract/plugin.d.ts +324 -0
  62. package/dist/contract/plugin.js +114 -0
  63. package/dist/contract/recipe.d.ts +180 -0
  64. package/dist/contract/recipe.js +1 -0
  65. package/dist/contract/validation.d.ts +91 -0
  66. package/dist/contract/validation.js +84 -0
  67. package/dist/db/db.d.ts +147 -0
  68. package/dist/db/db.js +90 -0
  69. package/dist/db/index.d.ts +8 -0
  70. package/dist/db/index.js +8 -0
  71. package/dist/db/migrations/index.d.ts +20 -0
  72. package/dist/db/migrations/index.js +60 -0
  73. package/dist/db/models/subtasks.d.ts +100 -0
  74. package/dist/db/models/subtasks.js +241 -0
  75. package/dist/db/models/tasks.d.ts +118 -0
  76. package/dist/db/models/tasks.js +274 -0
  77. package/dist/db/schema.d.ts +468 -0
  78. package/dist/db/schema.js +88 -0
  79. package/dist/env.d.ts +53 -0
  80. package/dist/env.js +47 -0
  81. package/dist/host/agent.d.ts +305 -0
  82. package/dist/host/agent.js +400 -0
  83. package/dist/host/index.d.ts +20 -0
  84. package/dist/host/index.js +19 -0
  85. package/dist/host/plugin-host.d.ts +42 -0
  86. package/dist/host/plugin-host.js +1 -0
  87. package/dist/index.d.ts +19 -0
  88. package/dist/index.js +17 -0
  89. package/dist/job/index.d.ts +21 -0
  90. package/dist/job/index.js +21 -0
  91. package/dist/job/lifecycle.d.ts +176 -0
  92. package/dist/job/lifecycle.js +230 -0
  93. package/dist/job/state.d.ts +92 -0
  94. package/dist/job/state.js +40 -0
  95. package/dist/platform.d.ts +138 -0
  96. package/dist/platform.js +140 -0
  97. package/dist/round/agent.d.ts +271 -0
  98. package/dist/round/agent.js +678 -0
  99. package/dist/round/index.d.ts +25 -0
  100. package/dist/round/index.js +23 -0
  101. package/dist/round/policy.d.ts +98 -0
  102. package/dist/round/policy.js +1 -0
  103. package/dist/round/subagent.d.ts +87 -0
  104. package/dist/round/subagent.js +108 -0
  105. package/dist/round/turn.d.ts +249 -0
  106. package/dist/round/turn.js +564 -0
  107. package/dist/round/workflow.d.ts +147 -0
  108. package/dist/round/workflow.js +387 -0
  109. package/dist/runtime/index.d.ts +127 -0
  110. package/dist/runtime/index.js +186 -0
  111. package/dist/runtime/tool-families.d.ts +32 -0
  112. package/dist/runtime/tool-families.js +61 -0
  113. package/dist/subagent/fingerprint.d.ts +37 -0
  114. package/dist/subagent/fingerprint.js +92 -0
  115. package/dist/subagent/index.d.ts +169 -0
  116. package/dist/subagent/index.js +330 -0
  117. package/dist/subagent/prompt.d.ts +32 -0
  118. package/dist/subagent/prompt.js +37 -0
  119. package/dist/subagent/run.d.ts +157 -0
  120. package/dist/subagent/run.js +540 -0
  121. package/dist/subagent/workspace.d.ts +85 -0
  122. package/dist/subagent/workspace.js +127 -0
  123. package/dist/subtasks/catalog.d.ts +40 -0
  124. package/dist/subtasks/catalog.js +36 -0
  125. package/dist/subtasks/decomposition.d.ts +85 -0
  126. package/dist/subtasks/decomposition.js +156 -0
  127. package/dist/subtasks/delegate.d.ts +120 -0
  128. package/dist/subtasks/delegate.js +131 -0
  129. package/dist/subtasks/index.d.ts +9 -0
  130. package/dist/subtasks/index.js +9 -0
  131. package/dist/subtasks/subtask-types.d.ts +91 -0
  132. package/dist/subtasks/subtask-types.js +103 -0
  133. package/dist/subtasks/types.d.ts +295 -0
  134. package/dist/subtasks/types.js +15 -0
  135. package/dist/testing/auth.d.ts +34 -0
  136. package/dist/testing/auth.js +35 -0
  137. package/dist/testing/do.d.ts +29 -0
  138. package/dist/testing/do.js +25 -0
  139. package/dist/testing/fake-session.d.ts +26 -0
  140. package/dist/testing/fake-session.js +37 -0
  141. package/dist/testing/fixtures.d.ts +64 -0
  142. package/dist/testing/fixtures.js +104 -0
  143. package/dist/testing/harness.d.ts +97 -0
  144. package/dist/testing/harness.js +138 -0
  145. package/dist/testing/index.d.ts +31 -0
  146. package/dist/testing/index.js +35 -0
  147. package/dist/testing/mock-model.d.ts +77 -0
  148. package/dist/testing/mock-model.js +136 -0
  149. package/dist/testing/node.d.ts +56 -0
  150. package/dist/testing/node.js +56 -0
  151. package/dist/testing/vcr-global-setup.d.ts +12 -0
  152. package/dist/testing/vcr-global-setup.js +15 -0
  153. package/dist/testing/vcr-shared.d.ts +38 -0
  154. package/dist/testing/vcr-shared.js +33 -0
  155. package/dist/testing/vcr-spec.d.ts +25 -0
  156. package/dist/testing/vcr-spec.js +124 -0
  157. package/dist/testing/vcr-store.d.ts +86 -0
  158. package/dist/testing/vcr-store.js +191 -0
  159. package/dist/testing/vcr.d.ts +117 -0
  160. package/dist/testing/vcr.js +275 -0
  161. package/dist/worker/define-agent.d.ts +123 -0
  162. package/dist/worker/define-agent.js +20 -0
  163. package/dist/worker/index.d.ts +218 -0
  164. package/dist/worker/index.js +369 -0
  165. package/eslint-rules/index.js +31 -0
  166. package/eslint-rules/no-deprecated-object-properties.js +81 -0
  167. package/package.json +178 -0
  168. package/scripts/generate-keys.mjs +48 -0
@@ -0,0 +1,305 @@
1
+ import { Agent, type Schedule } from "agents";
2
+ import { type Task } from "@a2a-js/sdk";
3
+ import { type AgentRuntime } from "../runtime/index.js";
4
+ import type { AgentPlugin } from "../contract/plugin.js";
5
+ import { type CoreConfig, type CoreConfigOverrides, type ModelConfig } from "../config.js";
6
+ import type { A2ASecretsEnv, AiEnv } from "../env.js";
7
+ import { AgentDB } from "../db/index.js";
8
+ import type { GatekeeperIdentity } from "../a2a/verify.js";
9
+ import type { PlainTask } from "../a2a/task.js";
10
+ import type { TaskListQuery } from "../a2a/agent-stub.js";
11
+ import { type PushChannel, type TurnPushContext } from "../a2a/push.js";
12
+ import { type SessionLike } from "../agent/session.js";
13
+ import type { AiGatewayMetadata, ModelPair, ModelRuntime } from "../agent/model.js";
14
+ import type { PluginHost } from "./plugin-host.js";
15
+ /**
16
+ * The Durable Object body every Dynamic Agents agent has, whatever loop it runs.
17
+ *
18
+ * ## Why this is core's and not the app's
19
+ *
20
+ * It was the app's, in the starter, and it was written twice — once for the
21
+ * delegating round agent, once for the single-turn proactive one. The two copies
22
+ * were identical for ~180 lines: the memoized runtime/db/models getters, the
23
+ * `onStart` that must await migrations before the SDK dispatches any RPC, the
24
+ * cron registration guard, the session with its displacement fan-out, the
25
+ * `identityKey` timing, and the task RPC surface.
26
+ *
27
+ * They did not stay identical. The second copy dropped `markWorking`'s
28
+ * cancellation verdict on the floor and probed with a separate `getTask` before
29
+ * writing a terminal Task — so a canceled task still burned a model call, and the
30
+ * gatekeeper could still receive a `completed` callback for a task the caller had
31
+ * abandoned. Both are lifecycle invariants, both were documented in the first
32
+ * copy, and neither is visible to a type checker or a linter.
33
+ *
34
+ * That is the argument for this class. **None of it is policy.** How a turn is
35
+ * shaped, what ends it, what the model is told — all of that stays with the
36
+ * agent, and an agent that wants a different loop simply does not extend
37
+ * {@link file://../round/agent.ts RoundAgentBase}. What is here is the part where
38
+ * being different is only ever a bug.
39
+ *
40
+ * ## The three seams
41
+ *
42
+ * ```ts
43
+ * export class MyAgent extends DynamicAgent<Env> {
44
+ * protected agentConfig() { return MY_CONFIG; }
45
+ * protected agentPlugins(host: PluginHost<Env>) { return plugins(host); }
46
+ * protected agentSoul(capabilities: string) { return soulPrompt(capabilities); }
47
+ * }
48
+ * ```
49
+ *
50
+ * One Durable Object instance per verified caller (keyed by the gatekeeper JWT's
51
+ * `identity.key`), each owning **one continuous Session** — durable history plus
52
+ * a self-edited `memory` block, backed by `this.sql`. All of a caller's turns, in
53
+ * any channel or thread, accumulate into that one conversation.
54
+ */
55
+ export declare abstract class DynamicAgent<TEnv extends Cloudflare.Env & AiEnv & A2ASecretsEnv = Cloudflare.Env & AiEnv & A2ASecretsEnv> extends Agent<TEnv> {
56
+ private session?;
57
+ private _runtime?;
58
+ private _models?;
59
+ private _pair?;
60
+ private _db?;
61
+ /**
62
+ * The verified caller this instance belongs to, set on the first turn.
63
+ *
64
+ * `onStart` runs before any request, so it is not known when `agentPlugins()`
65
+ * is built — which is why anything per-caller takes a thunk. The DO is keyed
66
+ * 1:1 by this value, so it is constant once set.
67
+ *
68
+ * In-memory, and deliberately so — see {@link requireIdentityKey}, which does
69
+ * not depend on it surviving.
70
+ */
71
+ private identityKey?;
72
+ /**
73
+ * This deployment's own public origin, learned from the `jku` every turn
74
+ * carries and **pinned on the first one** this instance serves.
75
+ *
76
+ * Unlike {@link identityKey} this is shared by concurrent turns — the object
77
+ * is keyed by caller, not by origin — so it is pinned rather than
78
+ * last-write-wins: an immutable field cannot change under a credential thunk
79
+ * that reads it while a turn awaits a model call. See {@link SelfOrigin} for
80
+ * the full argument, and for why nothing is persisted.
81
+ */
82
+ private readonly selfOriginMemo;
83
+ /**
84
+ * Test-only model injection. A **field**, not a constructor argument or an RPC
85
+ * parameter, so it never appears on the generated DO stub: production callers
86
+ * cannot reach it, and no model configuration crosses the RPC boundary.
87
+ */
88
+ modelsOverride?: ModelPair;
89
+ /** This agent's config overrides. Merged onto core's baseline once, at start. */
90
+ protected abstract agentConfig(): CoreConfigOverrides;
91
+ /** This agent's installed capabilities. Conventionally its `./plugins.ts`. */
92
+ protected abstract agentPlugins(host: PluginHost<TEnv>): AgentPlugin[];
93
+ /**
94
+ * This agent's identity, with the installed plugins' capability blocks already
95
+ * rendered in. Core ships no prompt copy — this is yours to write.
96
+ */
97
+ protected abstract agentSoul(capabilities: string): string;
98
+ /**
99
+ * Everything that would otherwise be a module-level constant, resolved once
100
+ * per DO instance from this agent's config and its installed plugins.
101
+ *
102
+ * Resolving a registry at *import* time is the one thing the package split
103
+ * exists to prevent: it freezes the registry before `env` exists (which on
104
+ * Workers is always), defeats tree-shaking, and makes per-agent plugin
105
+ * selection impossible.
106
+ */
107
+ protected get runtime(): AgentRuntime;
108
+ /** The resolved config. */
109
+ protected get config(): CoreConfig;
110
+ /** The agent's database (drizzle + migrations), built once per DO instance. */
111
+ protected get db(): AgentDB;
112
+ /**
113
+ * Which provider this agent's loops run on. Defaults to Workers AI; override
114
+ * to run on something else.
115
+ *
116
+ * The seam is here rather than in `models` because `models` memoizes, and a
117
+ * subclass overriding a memoized getter has to remember to keep the caching —
118
+ * a trap that only shows up as a performance bug. This is called once.
119
+ *
120
+ * `ModelRuntime` is the whole contract: return anything satisfying it and
121
+ * every loop in core keeps working unchanged. Core ships one implementation,
122
+ * {@link file://../agent/workers-ai/index.ts `agent/workers-ai`} (the default
123
+ * below); a second provider is one more
124
+ * {@link file://../agent/model.ts ModelRuntimeFactory}, defined here or in the
125
+ * consumer, not a change to anything on this path.
126
+ *
127
+ * Takes the resolved {@link ModelConfig} rather than reading `this.config`, so
128
+ * that this signature matches
129
+ * {@link file://../round/subagent.ts RecipeSubagentHost.modelRuntime} — an
130
+ * agent and its facet **must** run the same provider, and identical seams are
131
+ * what let one factory serve both instead of two hand-copied bodies.
132
+ */
133
+ protected modelRuntime(model: ModelConfig): ModelRuntime;
134
+ /** The model runtime for this instance, built lazily and memoized. */
135
+ protected get models(): ModelRuntime;
136
+ /**
137
+ * What this agent's plugins are handed. Built from
138
+ * {@link resolvedModelIds} rather than `this.config`, which would be a cycle —
139
+ * building the runtime is what needs these.
140
+ */
141
+ protected pluginHost(): PluginHost<TEnv>;
142
+ /**
143
+ * The model settings a locally-declared recipe runs on, resolved *before* the
144
+ * runtime exists.
145
+ *
146
+ * Deliberately not `this.config` — that would be a cycle. `resolveConfig` is
147
+ * cheap and pure and fills in core's baseline, so this is the same result the
148
+ * runtime lands on; that matters because every recipe runs on exactly this
149
+ * pair — `RecipePolicy` carries it and `validateRecipe` stamps it on.
150
+ */
151
+ private resolvedModelIds;
152
+ onStart(): Promise<void>;
153
+ /**
154
+ * Cron handler: delete task rows older than 30 days. Runs Sunday 01:00 UTC.
155
+ *
156
+ * A plugin's own tables are its business — core's journal does not reach them,
157
+ * and neither does this sweep. A subclass with more durable state of its own
158
+ * overrides {@link cleanupAgentState}.
159
+ */
160
+ cleanupOldTasks(_payload: Record<string, never>, _schedule: Schedule): Promise<void>;
161
+ /** Extra durable state to age out alongside the task rows. Default: none. */
162
+ protected cleanupAgentState(): void;
163
+ /**
164
+ * The main agent's primary/fallback pair. With `metadata` it builds a fresh
165
+ * pair carrying that AI Gateway correlation tag (so an AI Gateway log ties the call
166
+ * to its task and round); without it — the Session's own compaction model — it
167
+ * reuses a memoized default. A test `modelsOverride` always wins.
168
+ */
169
+ protected modelPair(metadata?: AiGatewayMetadata): ModelPair;
170
+ /**
171
+ * The one continuous Session for this caller (rebuilt from `this.sql` after
172
+ * eviction). Memoized — `identity` is constant for the DO's life, since the DO
173
+ * is keyed 1:1 by `identity.key`.
174
+ *
175
+ * `onMessagesDisplaced` is the whole integration for anything that wants the
176
+ * messages a compaction folds away: core performs the compaction, so core
177
+ * announces the loss, and the runtime fans it out to every plugin that asked.
178
+ */
179
+ getSession(identity: GatekeeperIdentity): SessionLike;
180
+ /**
181
+ * The caller key, which is present on every path that can reach a plugin.
182
+ *
183
+ * ## Why this does not just read the field
184
+ *
185
+ * `identityKey` is set on the first turn and lives in the isolate. An isolate
186
+ * does not live as long as the work does: it can be evicted between two rounds
187
+ * of the same task, and it can be reset outright — "Durable Object connection
188
+ * closed because the object was reset" — while a Workflow step is mid-flight.
189
+ * The next call arrives on a fresh instance where the field is empty, and
190
+ * every per-caller thunk built off it throws.
191
+ *
192
+ * That failure is disproportionate to its cause. A plugin asking which caller
193
+ * it is serving gets an exception, mid-task, on an object whose entire purpose
194
+ * is to be that caller's — and because the throw happens inside a tool or a
195
+ * runtime resolution rather than at the edge, it surfaces as a failed branch
196
+ * rather than as anything an operator can read.
197
+ *
198
+ * So the object answers from itself. `define-agent` routes with
199
+ * `ns.get(ns.idFromName(identity.key))`, which means the caller key *is* this
200
+ * object's name and the platform hands it back on `ctx.id.name` — durable, free
201
+ * and correct by construction: an object cannot disagree with the name it was
202
+ * addressed by.
203
+ *
204
+ * The field still wins when it is set. `id.name` is undefined for an object
205
+ * addressed by `newUniqueId()` or a raw id string, so it is a fallback rather
206
+ * than the source of truth, and the throw is kept for the case where neither
207
+ * exists.
208
+ */
209
+ protected requireIdentityKey(): string;
210
+ /**
211
+ * Offer this deployment's own origin from a value that carries it. The first
212
+ * usable one is kept for the life of the instance.
213
+ *
214
+ * Called wherever a {@link TurnPushContext} arrives — here for every agent
215
+ * shape, and at the entry of `RoundAgentBase`'s two RPCs, where the origin is
216
+ * needed *before* this channel would be built. All three matter because any of
217
+ * them can be the call that wakes a fresh isolate. Cheap and unfailing: past
218
+ * the first turn it is one truthiness check, and an unusable value is ignored
219
+ * rather than thrown, because a turn must not fail over this.
220
+ */
221
+ protected noteSelfOrigin(url: string | undefined): void;
222
+ /**
223
+ * This deployment's own public origin, if a turn has carried it to this
224
+ * instance yet. Constant once set, so it reads the same from any turn running
225
+ * on this object. See {@link SelfOrigin}.
226
+ */
227
+ protected selfOrigin(): string | undefined;
228
+ /**
229
+ * The same, for a caller that cannot proceed without it — signing a caller
230
+ * token with {@link file://../a2a/caller-token.ts signCallerToken} above all,
231
+ * whose `iss` this is. Throws naming the timing rather than producing a token
232
+ * with a nonsense issuer.
233
+ */
234
+ protected requireSelfOrigin(): string;
235
+ /** The gatekeeper callback channel for one turn. See {@link PushChannel}. */
236
+ protected push(context: TurnPushContext): PushChannel;
237
+ /**
238
+ * The per-request system-prompt suffix describing the verified caller.
239
+ *
240
+ * A rendering of a protocol fact rather than prompt copy, so core supplies one
241
+ * — see {@link callerContext}. Override it to name what a workspace id means in
242
+ * your deployment; do not use it to say who the *user* is, which this is not.
243
+ */
244
+ protected callerContext(identity: GatekeeperIdentity): string;
245
+ beginTask(input: {
246
+ messageId: string;
247
+ taskId: string;
248
+ contextId: string;
249
+ }): Promise<PlainTask>;
250
+ getTask(taskId: string): Promise<PlainTask | null>;
251
+ listTasks(query: TaskListQuery): Promise<{
252
+ tasks: PlainTask[];
253
+ totalSize: number;
254
+ }>;
255
+ /**
256
+ * Persist a Task, returning **whether the guarded write applied**.
257
+ *
258
+ * That boolean is the cancellation check, and a caller must key its callback on
259
+ * it: `AgentDB` refuses to write a terminal state over a `canceled` row and
260
+ * does that read and write in one synchronous pass inside the DO. Probing with
261
+ * {@link getTask} first and saving second leaves a window — between the two
262
+ * calls, and again between the save and the notify — in which a cancel lands
263
+ * and the gatekeeper still receives a `completed` callback.
264
+ *
265
+ * A `canceled` state routes to {@link markCanceled} instead of a plain write,
266
+ * so a `tasks/cancel` arriving through the a2a-js TaskStore and one arriving
267
+ * through {@link cancelTask} converge on the same interruption path.
268
+ */
269
+ saveTask(task: Task): Promise<boolean>;
270
+ /**
271
+ * Move the Task to `working`. Returns `"canceled"` when the caller cancelled
272
+ * first — read it and stop, rather than probing with a separate
273
+ * {@link getTask}, which reopens the gap between asking and acting.
274
+ *
275
+ * Anything else is `"ok"`, including an unknown row and a row already `working`
276
+ * (a replayed step): only an actual cancellation stops the pipeline.
277
+ */
278
+ markWorking(taskId: string): Promise<"ok" | "canceled">;
279
+ cancelTask(taskId: string): Promise<PlainTask | null>;
280
+ /**
281
+ * The one place a Task becomes canceled: flip the row — terminal, so every
282
+ * non-canceled write is refused afterwards — then interrupt whatever is still
283
+ * running for it.
284
+ *
285
+ * `task` is supplied when the caller already built the canceled Task (the
286
+ * a2a-js cancel branch attaches its own status message); otherwise the row's
287
+ * own guarded flip produces it. Both paths are guarded against the same race:
288
+ * a task that already reached `completed`/`failed` refuses the write, and its
289
+ * verdict — not a `get` read straight after, which would return that
290
+ * unchanged terminal row and be mistaken for a successful cancellation — is
291
+ * what decides whether {@link onTaskCanceled} runs at all.
292
+ */
293
+ private markCanceled;
294
+ /**
295
+ * Interrupt work still in flight for a task that has just been canceled.
296
+ *
297
+ * Default: nothing, which is right for an agent whose turn is a single
298
+ * inference — the row is terminal and the next guarded write refuses. An agent
299
+ * with children overrides this to abort them.
300
+ *
301
+ * **Must be best-effort.** Cancellation has already been recorded by the time
302
+ * this runs, and it must not fail because cleanup did.
303
+ */
304
+ protected onTaskCanceled(_taskId: string): Promise<void>;
305
+ }