@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,678 @@
1
+ import { TaskState } from "@a2a-js/sdk";
2
+ import { validateRecipe } from "../contract/validation.js";
3
+ import { stateOf } from "../db/index.js";
4
+ import { finalReplyMessageId, roundAckMessageId, sessionText } from "../agent/history.js";
5
+ import { newTurnBudget } from "../agent/budget.js";
6
+ import { FINGERPRINT_MISMATCH, subagentName } from "../subagent/index.js";
7
+ import { DynamicAgent } from "../host/agent.js";
8
+ import { buildTurnInstructions, runTurn } from "./turn.js";
9
+ /**
10
+ * A **delegating** agent: the round loop, the durable Subtasks it hands out, and
11
+ * the isolated subagent execution beneath them.
12
+ *
13
+ * Everything {@link DynamicAgent} gives every agent, plus the half that only a
14
+ * delegating one needs — and it is all mechanism. A Workflow drives it through
15
+ * native Cloudflare RPC (`runTaskTurn`, `scanSubtasks`, `executeSubtaskChunk`,
16
+ * …), never HTTP: the DO is a private implementation detail of the Worker.
17
+ *
18
+ * ## Why core owns this
19
+ *
20
+ * Every method below is either idempotent recovery, cancellation ordering, or
21
+ * child lifecycle, and each one has an ordering that is load-bearing and
22
+ * invisible: the acknowledgment is appended *before* the rows exist; a fresh
23
+ * execution deletes its stale child but an ambiguous retry must not; a successful
24
+ * chunk defers its child's deletion to a single post-delivery sweep. Get any of
25
+ * them backwards and nothing fails to compile, nothing fails a lint, and the
26
+ * damage shows up as a duplicated reply or a false-positive error weeks later.
27
+ *
28
+ * None of it varies between agents. What varies is the {@link RoundPolicy} — the
29
+ * words — and the plugins.
30
+ *
31
+ * ## What this class does not know
32
+ *
33
+ * Nothing here names a domain. `resolveRuntime`, `enrichResult` and `onAbort` are
34
+ * hooks on `AgentPlugin`, so a plugin that leases an external session or scores a
35
+ * result gets that without a branch anywhere in this file. That inversion is what
36
+ * lets one class body serve every delegating agent.
37
+ */
38
+ export class RoundAgentBase extends DynamicAgent {
39
+ _instructions;
40
+ /** The prompt suffixes, built once from this agent's installed subtask types. */
41
+ get instructions() {
42
+ return (this._instructions ??= buildTurnInstructions(this.roundPolicy(), this.runtime.types, this.config.maxSubtasks, this.config.mainAgentLimits));
43
+ }
44
+ /**
45
+ * The main agent's **work tools** for this caller — the `execute`-bearing tools
46
+ * every round runs its loop over. The control tools that *end* a round are not
47
+ * here; `runTurn` adds those.
48
+ *
49
+ * The Session's own `set_context`/`load_context` come first, with the installed
50
+ * plugins' tools layered over them: a soul that instructs the model to record
51
+ * durable facts with `set_context` needs it actually on the call.
52
+ *
53
+ * Which plugin tools appear is the plugins' business, not this class's. A
54
+ * plugin may shape its surface from durable state — offering a search only once
55
+ * there is something to search, because a tool whose only possible answer is
56
+ * "nothing here yet" costs a call to discover that and costs every round the
57
+ * tokens to describe it.
58
+ */
59
+ async mainAgentTools(session) {
60
+ return {
61
+ ...(await session.tools()),
62
+ ...(await this.runtime.mainAgentTools({ session }))
63
+ };
64
+ }
65
+ /** Age out this agent's subtask rows alongside the task rows. */
66
+ cleanupAgentState() {
67
+ this.db.subtasks.cleanup();
68
+ }
69
+ // --- The task round loop (turn → execute → turn → …) ---------------------
70
+ //
71
+ // The parent-owned half of the Task flow. The Workflow drives these over DO RPC
72
+ // (it cannot touch this SQLite or this Session directly); each is a durable
73
+ // step, so every method here is safe to call again after a crash — a round is
74
+ // idempotent on its durable output, and execution recovers from either the
75
+ // parent row or the child's cached result.
76
+ /**
77
+ * One main-agent round: answer the user, or delegate a durable set of Subtasks
78
+ * and return the acknowledgment the user sees while it runs.
79
+ *
80
+ * This is the RPC boundary, so it is where the round's cost becomes a field. The
81
+ * budget is created here, handed to {@link decideRound} to be spent, and read
82
+ * back exactly once — so no branch of the round has to remember to report a
83
+ * number, and none can report the wrong one.
84
+ */
85
+ async runTaskTurn(input) {
86
+ // Before anything can reach a model: a round that calls out mint-signed
87
+ // needs this deployment's own origin, and this is where it arrives.
88
+ this.noteSelfOrigin(input.push?.jku);
89
+ const budget = newTurnBudget(input.turnsRemaining);
90
+ const verdict = await this.decideRound(input, budget);
91
+ return { ...verdict, turns: budget.spent };
92
+ }
93
+ /**
94
+ * The round itself, charging `budget` as it goes.
95
+ *
96
+ * Idempotent, and the recovery order is the contract:
97
+ *
98
+ * 1. A canceled Task stops here.
99
+ * 2. A durable **final reply** means some round already answered — return it
100
+ * without inference. Re-answering could produce different words for a reply
101
+ * the user may already have received.
102
+ * 3. Durable **rows for this round** mean this round already delegated —
103
+ * recover its acknowledgment from the Session, with no inference and no
104
+ * duplicate rows.
105
+ * 4. Otherwise, infer.
106
+ *
107
+ * Cancellation is re-read **after** inference too, not just before it: the model
108
+ * call is the widest window in the round, and neither the Subtask rows nor the
109
+ * callback may land for a Task the caller already gave up on. The reply is
110
+ * already in the Session by then (`runTurn` appends under deterministic ids
111
+ * before returning) — that is durable history, not output the user sees.
112
+ *
113
+ * Returns a typed `failed` result when both models produce unusable output and
114
+ * no durable work exists to fall back on (the Workflow routes it to failed
115
+ * delivery); throws only on a transient fault, for the step to retry.
116
+ */
117
+ async decideRound(input, budget) {
118
+ const { taskId, text, identity, round, mode, push } = input;
119
+ const session = this.getSession(identity);
120
+ const policy = this.roundPolicy();
121
+ const channel = push ? this.push(push) : undefined;
122
+ if (await this.isTaskCanceled(taskId))
123
+ return { status: "canceled" };
124
+ const answered = await session.getMessage(finalReplyMessageId(taskId));
125
+ if (answered) {
126
+ return { status: "replied", reply: sessionText(answered) };
127
+ }
128
+ const existing = this.db.subtasks.listRound(taskId, round);
129
+ if (existing.length > 0) {
130
+ const stored = await session.getMessage(roundAckMessageId(taskId, round));
131
+ const reply = stored ? sessionText(stored) : policy.copy.recoveredReply;
132
+ if (!stored) {
133
+ // Unreachable: the ack is appended before the rows are persisted. Warn
134
+ // and deliver a neutral acknowledgement rather than poisoning a Task
135
+ // whose subtasks are valid and ready to run.
136
+ console.warn("[agent] round ack missing on recovery", {
137
+ taskId,
138
+ round
139
+ });
140
+ }
141
+ await channel?.working(reply, `ack:${round}`);
142
+ return { status: "delegated", reply, subtasks: existing };
143
+ }
144
+ const metadata = { taskId, round };
145
+ const outcome = await runTurn({
146
+ session,
147
+ taskId,
148
+ round,
149
+ text,
150
+ mode,
151
+ budget,
152
+ systemSuffix: this.callerContext(identity),
153
+ tools: await this.mainAgentTools(session),
154
+ models: this.modelPair(metadata),
155
+ branches: this.compositionBranches(taskId),
156
+ types: this.runtime.types,
157
+ maxSubtasks: this.config.maxSubtasks,
158
+ maxOutputTokens: this.config.model.maxOutputTokens,
159
+ maxRetries: this.config.model.maxRetries,
160
+ instructions: this.instructions,
161
+ partialNote: policy.copy.partialNote,
162
+ // The key carries the round so two rounds of one Task cannot collide on
163
+ // the gatekeeper, which a bare step index would.
164
+ onContent: channel?.stream((step) => `r${round}:step:${step}`)
165
+ });
166
+ // Terminal for this round with nothing to persist — the kind rides out with
167
+ // it, and the Workflow turns it into words.
168
+ if (outcome.status === "failed")
169
+ return outcome;
170
+ // Cancelled while the model worked: persist nothing and publish nothing. The
171
+ // turns stay charged — the model ran, whatever became of its output.
172
+ if (await this.isTaskCanceled(taskId))
173
+ return { status: "canceled" };
174
+ if (outcome.status === "replied") {
175
+ return { status: "replied", reply: outcome.reply };
176
+ }
177
+ // The ack is durable in the Session before the rows exist. A crash in this
178
+ // window re-runs the round and persists the *retry's* drafts under the
179
+ // *first* attempt's ack — both are valid outputs of the same input, and no
180
+ // invariant breaks. The reverse order could strand persisted subtasks with no
181
+ // recoverable acknowledgment.
182
+ const subtasks = this.db.subtasks.createDecomposition(taskId, round, outcome.drafts);
183
+ await channel?.working(outcome.reply, `ack:${round}`);
184
+ return { status: "delegated", reply: outcome.reply, subtasks };
185
+ }
186
+ /**
187
+ * Every round's branches for a Task, in stable ordinal order — what a round
188
+ * needs to reunite each earlier `delegate` call with its result. Built inside
189
+ * the DO and consumed here, so the 1 MiB Workflow-step cap that keeps
190
+ * {@link SubtaskScan} down to ids does not apply.
191
+ */
192
+ compositionBranches(taskId) {
193
+ return this.db.subtasks.list(taskId).map((s) => ({
194
+ subtaskId: s.id,
195
+ round: s.round,
196
+ ordinal: s.ordinal,
197
+ type: s.type,
198
+ prompt: s.prompt,
199
+ params: s.params,
200
+ status: s.status,
201
+ resultParts: s.resultParts,
202
+ error: s.error
203
+ }));
204
+ }
205
+ /** A Task's Subtasks, every round, in stable ordinal order. */
206
+ async listSubtasks(taskId) {
207
+ return this.db.subtasks.list(taskId);
208
+ }
209
+ /**
210
+ * The Workflow's scan for **one round's** Subtasks: report a cancellation, or
211
+ * return the ids that still owe an outcome, in ordinal order.
212
+ *
213
+ * Scoped to the round because the Workflow drives one round at a time: an
214
+ * earlier round's rows are already terminal and would only widen a projection
215
+ * that has a size cap.
216
+ *
217
+ * `running` counts alongside `pending` on purpose. `executeSubtaskChunk`
218
+ * accepts a row that is either: the latter is its ambiguous-retry path, where a
219
+ * previous attempt crashed mid-execution and the managed child's fingerprint
220
+ * cache may still hold the terminal result that makes the retry free. So a row
221
+ * stranded `running` is re-runnable, and omitting it here would abandon it.
222
+ *
223
+ * Ordinal order comes from {@link listRound} and is not incidental: these ids
224
+ * become durable Workflow step names, so the traversal that produces them has
225
+ * to be deterministic.
226
+ *
227
+ * The cancellation verdict rides along rather than being probed separately, so
228
+ * the scan costs one round trip and cannot act on a stale answer.
229
+ */
230
+ async scanSubtasks(taskId, round) {
231
+ if (await this.isTaskCanceled(taskId))
232
+ return { canceled: true };
233
+ const ids = this.db.subtasks
234
+ .listRound(taskId, round)
235
+ .filter((s) => s.status === "pending" || s.status === "running")
236
+ .map((s) => s.id);
237
+ return { canceled: false, ids };
238
+ }
239
+ /** Parent cancellation: cancel every still-pending Subtask. Returns the count. */
240
+ async cancelPendingSubtasks(taskId) {
241
+ return this.db.subtasks.cancelPending(taskId);
242
+ }
243
+ /**
244
+ * Force one branch terminal after the Workflow gave up on it: its
245
+ * `execute:<id>` step exhausted every retry, so `executeSubtaskChunk` will not
246
+ * be called again and no one else will resolve the row.
247
+ *
248
+ * The Workflow fails the *branch* rather than the Task so composition can
249
+ * disclose the gap while sibling branches keep their durable results. The
250
+ * managed child releases its external state and is then swept, both
251
+ * best-effort — nothing will read its cache now, but an abandoned run may still
252
+ * hold something outside this system, and dropping the child is not a reason to
253
+ * leak it. Idempotent: a no-op once the row is terminal.
254
+ */
255
+ async failSubtask(id, error) {
256
+ const subtask = this.db.subtasks.get(id);
257
+ if (!subtask)
258
+ return;
259
+ // `fail` is a guarded `running|pending -> failed`, and **its verdict is the
260
+ // whole idempotency claim above** — read it before tearing anything down. A
261
+ // late workflow failure that lost the race to a real result would otherwise
262
+ // still release the branch's runtime, abort its child and delete it, tearing
263
+ // down a branch that had already succeeded.
264
+ //
265
+ // Cleanup for an already-terminal row belongs to `sweepTaskChildren`, which
266
+ // runs after delivery and knows the whole task is done with — the same
267
+ // teardown `executeSubtaskChunk` defers on its success path, because
268
+ // aborting a facet in the same tick its RPC returned makes telemetry record
269
+ // the success as a failure.
270
+ if (!this.db.subtasks.fail(id, error))
271
+ return;
272
+ const name = subagentName(subtask.taskId, id);
273
+ await this.releaseRuntimeQuietly(subtask);
274
+ await this.abortChildQuietly(name, this.toolFamiliesForType(subtask.type));
275
+ await this.deleteChildQuietly(name);
276
+ }
277
+ /**
278
+ * Run **one durable chunk** of a Subtask in an isolated, managed subagent,
279
+ * posting any progress the chunk emitted and durably recording a terminal
280
+ * outcome.
281
+ *
282
+ * The Workflow calls this repeatedly (chunk 0, 1, …) until it returns
283
+ * `done: true` — a single-chunk recipe finishes on chunk 0, a long one spans
284
+ * many. The row status distinguishes the cases with no chunk-number bookkeeping:
285
+ * chunk 0 claims `pending → running` (fresh — delete any stale child); every
286
+ * later chunk (and every retry) finds the row already `running` and leaves the
287
+ * child alone so its checkpointed run state resumes.
288
+ *
289
+ * The lifecycle rules that make it safe to re-run:
290
+ *
291
+ * - A terminal row short-circuits: the result is already durable.
292
+ * - A **fresh** execution deletes any stale child first.
293
+ * - An **ambiguous retry** (row already `running`) must *not* delete the child.
294
+ * - A **successful** chunk does *not* delete its child here — deletion is
295
+ * deferred to a single post-delivery {@link sweepTaskChildren}, so a facet is
296
+ * never aborted in the same tick its RPC returned (telemetry would mis-record
297
+ * that as a failure). The result is still copied into the parent before any
298
+ * delete; that now happens strictly later.
299
+ *
300
+ * Throws on a transient fault (the step retries and the child resumes from its
301
+ * checkpoint) and when the row is in a status this cannot accept — a subtask
302
+ * that is neither `pending` nor `running` nor already terminal. Both are bugs,
303
+ * not outcomes.
304
+ */
305
+ async executeSubtaskChunk(id, chunk, push) {
306
+ // Recorded here rather than left to `this.push(push)` below, which runs only
307
+ // after the chunk has already executed — and the child is handed this
308
+ // origin on the way in.
309
+ this.noteSelfOrigin(push?.jku);
310
+ const prepared = await this.prepareChunk(id);
311
+ if (prepared.kind === "terminal") {
312
+ return { done: true, status: prepared.subtask.status, progress: [] };
313
+ }
314
+ const { request, recipe, name, runtime } = prepared;
315
+ const outcome = await this.executeChunkInChild(name, request, chunk, runtime);
316
+ // The Task may have been canceled while the chunk ran — checked *before* any
317
+ // progress is published, so a canceled Task emits nothing further. Applies to
318
+ // a yield as much as to a terminal chunk: a run interrupted mid-flight by
319
+ // cancellation yields rather than caching a bogus failure.
320
+ if (await this.isTaskCanceled(request.taskId)) {
321
+ this.db.subtasks.cancelRunning(id);
322
+ await this.releaseRuntime(request);
323
+ await this.abortChildQuietly(name, recipe.toolFamilies);
324
+ await this.deleteChildQuietly(name);
325
+ return {
326
+ done: true,
327
+ status: this.requireSubtask(id).status,
328
+ progress: outcome.progress
329
+ };
330
+ }
331
+ // Post progress the chunk emitted (best-effort; `working` never throws).
332
+ // Deterministic keys let the gatekeeper dedupe a re-posted event on replay.
333
+ if (push) {
334
+ const channel = this.push(push);
335
+ for (const event of outcome.progress) {
336
+ await channel.working(event.text, event.key);
337
+ }
338
+ }
339
+ if (!outcome.done) {
340
+ return { done: false, status: "running", progress: outcome.progress };
341
+ }
342
+ // Let the owning plugin amend the terminal result before it is persisted —
343
+ // e.g. append a score the subagent had no way to read. Returning the result
344
+ // unchanged is always valid, and a plugin that declares no hook gets this for
345
+ // free.
346
+ const result = await this.runtime.enrichResult({ request, runtime }, outcome.result);
347
+ const persisted = this.persistResult(id, result);
348
+ if (!persisted) {
349
+ const current = this.requireSubtask(id);
350
+ if (current.status === "pending" || current.status === "running") {
351
+ throw new Error(`subtask ${id} could not record its result (status=${current.status})`);
352
+ }
353
+ await this.deleteChildQuietly(name);
354
+ return { done: true, status: current.status, progress: outcome.progress };
355
+ }
356
+ // The result is durable in the parent now, but the child is **not** deleted
357
+ // here. `deleteSubAgent` aborts the facet, and aborting it in the same tick
358
+ // this `executeChunk` RPC returned stamps that already-successful invocation
359
+ // `outcome:exception` in telemetry — a false-positive error on every
360
+ // completed Subtask. The parent sweeps all of a Task's children once, after
361
+ // delivery, when every `execute` step has unwound.
362
+ return {
363
+ done: true,
364
+ status: this.requireSubtask(id).status,
365
+ progress: outcome.progress
366
+ };
367
+ }
368
+ /**
369
+ * Delete every managed child this Task created — called **once**, from the
370
+ * Workflow's delivery step, after the Task is terminal.
371
+ *
372
+ * Per-Subtask deletion is deferred to here rather than run right after each
373
+ * successful chunk because `deleteSubAgent` aborts the facet: aborting a child
374
+ * in the same tick its `executeChunk` RPC returned records that
375
+ * already-successful invocation as `outcome:exception`, which is pure
376
+ * false-positive error noise (one per completed Subtask). By delivery every
377
+ * `execute` step has unwound, so these deletes hit **idle** facets and record
378
+ * nothing. Best-effort and idempotent — a name with no live facet is a silent
379
+ * no-op — so a Workflow replay of the sweep step is safe.
380
+ *
381
+ * Cancellation paths do their own child cleanup, so a canceled Task that never
382
+ * reaches delivery does not leak.
383
+ */
384
+ async sweepTaskChildren(taskId) {
385
+ for (const subtask of this.db.subtasks.list(taskId)) {
386
+ await this.deleteChildQuietly(subagentName(taskId, subtask.id));
387
+ }
388
+ }
389
+ /**
390
+ * The shared front half of a chunk: resolve terminal/cancel short-circuits,
391
+ * validate the Recipe, claim the row (fresh-vs-retry), and assemble the
392
+ * execution request. Deterministic every chunk, so the request — and thus its
393
+ * fingerprint — is identical across a run's chunks and their retries.
394
+ */
395
+ async prepareChunk(id) {
396
+ const subtask = this.db.subtasks.get(id);
397
+ if (!subtask)
398
+ throw new Error(`unknown subtask: ${id}`);
399
+ const name = subagentName(subtask.taskId, id);
400
+ if (subtask.status !== "pending" && subtask.status !== "running") {
401
+ // Already terminal. Sweep the child in case a previous run persisted the
402
+ // result and crashed before deleting it.
403
+ await this.deleteChildQuietly(name);
404
+ return { kind: "terminal", subtask };
405
+ }
406
+ if (await this.isTaskCanceled(subtask.taskId)) {
407
+ // Start no new work. A row left `running` by a crashed attempt is resolved
408
+ // here — `cancelPending` only reaches pending rows.
409
+ if (subtask.status === "running") {
410
+ this.db.subtasks.cancelRunning(id);
411
+ await this.releaseRuntimeQuietly(subtask);
412
+ await this.abortChildQuietly(name, this.toolFamiliesForType(subtask.type));
413
+ await this.deleteChildQuietly(name);
414
+ return { kind: "terminal", subtask: this.requireSubtask(id) };
415
+ }
416
+ return { kind: "terminal", subtask };
417
+ }
418
+ let recipe;
419
+ let validated;
420
+ try {
421
+ recipe = this.runtime.types.resolveRecipe(subtask.type);
422
+ validated = validateRecipe(recipe, this.runtime.policy);
423
+ }
424
+ catch (err) {
425
+ // An unknown/retired type or a disabled/soul-less Recipe is a
426
+ // configuration bug, not a transient fault. Record it as a branch failure
427
+ // so a later round can disclose the gap, rather than as a throw that would
428
+ // be retried forever.
429
+ const recipeId = recipe?.key ?? subtask.type;
430
+ const recipeVersion = recipe?.version ?? 0;
431
+ const message = recipe
432
+ ? `recipe ${recipeId} unusable: ${String(err)}`
433
+ : `unknown subtask type "${subtask.type}": ${String(err)}`;
434
+ this.db.subtasks.start(id, { recipeId, recipeVersion });
435
+ this.db.subtasks.fail(id, message);
436
+ return { kind: "terminal", subtask: this.requireSubtask(id) };
437
+ }
438
+ // Claim the row. Winning the `pending → running` transition distinguishes a
439
+ // fresh execution (chunk 0) from a retry/continuation — the difference that
440
+ // decides whether the child may be deleted.
441
+ const claimed = this.db.subtasks.start(id, {
442
+ recipeId: validated.key,
443
+ recipeVersion: validated.version
444
+ });
445
+ if (claimed) {
446
+ await this.deleteChildQuietly(name);
447
+ }
448
+ else {
449
+ const current = this.requireSubtask(id);
450
+ if (current.status !== "running") {
451
+ return { kind: "terminal", subtask: current };
452
+ }
453
+ // Ambiguous retry / later chunk: leave the child so its run state resumes.
454
+ }
455
+ const request = {
456
+ taskId: subtask.taskId,
457
+ subtaskId: id,
458
+ type: subtask.type,
459
+ recipe: validated,
460
+ prompt: subtask.prompt,
461
+ references: subtask.references,
462
+ params: subtask.params
463
+ };
464
+ return {
465
+ kind: "ready",
466
+ request,
467
+ recipe: validated,
468
+ name,
469
+ // Resolve the session state this execution needs and no model can supply —
470
+ // a leased external resource, a session handle, a cookie jar — by asking
471
+ // the plugin that owns the type. `{}` for a type whose plugin declares no
472
+ // `resolveRuntime`, which is most of them.
473
+ //
474
+ // Called once per **chunk**, not once per run, and deliberately outside the
475
+ // fingerprint: what it returns can legitimately change between two chunks
476
+ // of one run, and must not make a retry look like different work.
477
+ runtime: await this.runtime.resolveRuntime({
478
+ taskId: subtask.taskId,
479
+ subtaskId: id,
480
+ type: subtask.type,
481
+ params: subtask.params,
482
+ toolFamilies: validated.toolFamilies
483
+ })
484
+ };
485
+ }
486
+ /**
487
+ * Invoke the managed child for one chunk, recreating it once on a fingerprint
488
+ * mismatch (a stale child from a *different* request — recoverable exactly once;
489
+ * a second mismatch is a genuine lifecycle bug and must surface).
490
+ */
491
+ async executeChunkInChild(name, request, chunk, runtime) {
492
+ // A facet has no request path of its own: it is reached only from here, so
493
+ // this is the only way it can learn what this deployment is called. Passed
494
+ // as its own argument, never folded into `request`, for the same reason
495
+ // `chunk` is — the request is fingerprinted, and this is not part of what
496
+ // the execution *is*, so it must not be able to make a retry look like a
497
+ // different one. Pinned on both sides, so it cannot change under a run;
498
+ // undefined only on an instance no turn has reached, where the facet's own
499
+ // `requireSelfOrigin` produces the readable error.
500
+ const selfOrigin = this.selfOrigin();
501
+ const child = await this.subAgent(this.subagentClass(), name);
502
+ try {
503
+ return await child.executeChunk(request, chunk, runtime, selfOrigin);
504
+ }
505
+ catch (err) {
506
+ if (!String(err).includes(FINGERPRINT_MISMATCH))
507
+ throw err;
508
+ console.warn("[agent] stale subagent state, recreating", { name });
509
+ await this.deleteSubAgent(this.subagentClass(), name);
510
+ const fresh = await this.subAgent(this.subagentClass(), name);
511
+ return await fresh.executeChunk(request, chunk, runtime, selfOrigin);
512
+ }
513
+ }
514
+ /** Let the owning plugin release whatever `resolveRuntime` acquired. */
515
+ releaseRuntime(request) {
516
+ return this.runtime.onAbort({
517
+ taskId: request.taskId,
518
+ subtaskId: request.subtaskId,
519
+ type: request.type,
520
+ params: request.params,
521
+ toolFamilies: request.recipe.toolFamilies
522
+ });
523
+ }
524
+ /** The same, from a durable row rather than a built request. Best-effort. */
525
+ async releaseRuntimeQuietly(subtask) {
526
+ try {
527
+ await this.runtime.onAbort({
528
+ taskId: subtask.taskId,
529
+ subtaskId: subtask.id,
530
+ type: subtask.type,
531
+ params: subtask.params,
532
+ toolFamilies: this.toolFamiliesForType(subtask.type)
533
+ });
534
+ }
535
+ catch (err) {
536
+ console.warn("[agent] plugin runtime release failed", {
537
+ subtaskId: subtask.id,
538
+ err: String(err)
539
+ });
540
+ }
541
+ }
542
+ /** The validated tool families for a Subtask type, or none if unusable. */
543
+ toolFamiliesForType(type) {
544
+ try {
545
+ return validateRecipe(this.runtime.types.resolveRecipe(type), this.runtime.policy).toolFamilies;
546
+ }
547
+ catch {
548
+ return [];
549
+ }
550
+ }
551
+ /**
552
+ * Best-effort release of a child's external state on cancellation (e.g. close a
553
+ * leased resource recorded in its workspace). Swallows failures — an unreleased
554
+ * resource is a documented residual, not a reason to fail cancellation.
555
+ */
556
+ async abortChildQuietly(name, toolFamilies) {
557
+ if (toolFamilies.length === 0)
558
+ return;
559
+ try {
560
+ const child = await this.subAgent(this.subagentClass(), name);
561
+ await child.abortExecution(toolFamilies);
562
+ }
563
+ catch (err) {
564
+ console.warn("[agent] subagent abort failed", { name, err: String(err) });
565
+ }
566
+ }
567
+ /** Persist a child's terminal outcome. Returns whether the guarded write applied. */
568
+ persistResult(id, result) {
569
+ if (result.status === "failed") {
570
+ return this.db.subtasks.fail(id, result.error);
571
+ }
572
+ try {
573
+ return this.db.subtasks.complete(id, result.resultParts);
574
+ }
575
+ catch (err) {
576
+ // A "completed" result with no usable text breaks the child's contract.
577
+ // Record it as a failure — retrying would only replay the same bad result
578
+ // from the child's cache forever.
579
+ console.warn("[agent] malformed completed result", {
580
+ subtaskId: id,
581
+ err: String(err)
582
+ });
583
+ return this.db.subtasks.fail(id, `malformed result: ${String(err)}`);
584
+ }
585
+ }
586
+ /** Re-read a Subtask that must exist (it was just written). */
587
+ requireSubtask(id) {
588
+ const row = this.db.subtasks.get(id);
589
+ if (!row)
590
+ throw new Error(`subtask ${id} disappeared`);
591
+ return row;
592
+ }
593
+ /** Delete a managed child, swallowing failures (used on best-effort sweeps). */
594
+ async deleteChildQuietly(name) {
595
+ try {
596
+ await this.deleteSubAgent(this.subagentClass(), name);
597
+ }
598
+ catch (err) {
599
+ console.warn("[agent] subagent cleanup failed", {
600
+ name,
601
+ err: String(err)
602
+ });
603
+ }
604
+ }
605
+ /** Whether the parent Task has been canceled (checked before and after work). */
606
+ async isTaskCanceled(taskId) {
607
+ const task = this.db.tasks.get(taskId);
608
+ return task !== null && stateOf(task) === TaskState.TASK_STATE_CANCELED;
609
+ }
610
+ /**
611
+ * Interrupt a canceled Task's live children: each `running` Subtask's managed
612
+ * child gets `abortRun`, so a long recipe stops at its current model call
613
+ * instead of at the next chunk boundary (up to `chunkSoftMs` later). A subtask
614
+ * that already finished (e.g. one branch completed while another was
615
+ * still running) is deliberately retained until the terminal-delivery sweep —
616
+ * but a canceled Task never reaches delivery, so its idle child is deleted
617
+ * here instead, or it would leak until the 30-day row cleanup regardless of
618
+ * that row's own age.
619
+ *
620
+ * Only `running` rows have a live RPC to abort. `subAgent` *creates* a facet
621
+ * that does not exist, so calling it for a `pending` row (no facet was ever
622
+ * made) would materialize one just to delete it — `deleteChildQuietly` is a
623
+ * silent no-op there, so it is called unconditionally instead of branching on
624
+ * status. Bounded by `maxSubtasks`. Best-effort throughout: a child that
625
+ * cannot be reached is logged, never fatal — cancellation must not fail
626
+ * because cleanup did.
627
+ *
628
+ * The `pending` rows are transitioned here too, and that is not bookkeeping:
629
+ * it is the only thing that resolves them. Nothing else is coming back to a
630
+ * pending row once a Task is canceled — the Workflow's scheduler runs a
631
+ * single pass and does not re-scan, and `prepareChunk` reports a canceled
632
+ * Task's pending row as terminal *without* claiming it, so a branch whose RPC
633
+ * had not yet reached the claim when the cancellation landed simply returns.
634
+ * Left to the loop below, which only deletes the child, the row would sit
635
+ * non-terminal until the 30-day cleanup.
636
+ */
637
+ async onTaskCanceled(taskId) {
638
+ // First, and outside the loop: `cancelPending` is one guarded bulk
639
+ // `pending -> canceled`, so it cannot be skipped by a best-effort teardown
640
+ // below throwing partway through, and a branch that won the claim a moment
641
+ // ago is left alone to resolve through `cancelRunning` on its own path.
642
+ this.db.subtasks.cancelPending(taskId);
643
+ for (const subtask of this.db.subtasks.list(taskId)) {
644
+ const name = subagentName(taskId, subtask.id);
645
+ if (subtask.status !== "running") {
646
+ await this.deleteChildQuietly(name);
647
+ continue;
648
+ }
649
+ try {
650
+ const child = await this.subAgent(this.subagentClass(), name);
651
+ // `false` means there was no in-flight RPC to interrupt. That is not the
652
+ // "nothing to do" case it looks like: a `running` row whose isolate was
653
+ // evicted or crashed has no live promise, so nobody is coming back to
654
+ // transition it. The chunk path resolves a running row when its result
655
+ // returns; here the result never will.
656
+ //
657
+ // Left alone, the row stays `running` until the 30-day sweep, and — the
658
+ // part that actually costs something — its child facet is never aborted
659
+ // or deleted, so whatever external state the recipe's `abort` hook would
660
+ // have released stays held. Finish the transition and run the same
661
+ // cleanup the post-chunk cancellation path does.
662
+ if (await child.abortRun())
663
+ continue;
664
+ if (this.db.subtasks.cancelRunning(subtask.id)) {
665
+ await this.releaseRuntimeQuietly(subtask);
666
+ await this.abortChildQuietly(name, this.toolFamiliesForType(subtask.type));
667
+ await this.deleteChildQuietly(name);
668
+ }
669
+ }
670
+ catch (err) {
671
+ console.warn("[agent] subagent abortRun failed", {
672
+ name,
673
+ err: String(err)
674
+ });
675
+ }
676
+ }
677
+ }
678
+ }