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