@loopingai/core 0.5.1 → 0.7.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 (99) hide show
  1. package/README.md +6 -4
  2. package/dist/a2a/caller-token.d.ts +38 -0
  3. package/dist/a2a/caller-token.js +61 -0
  4. package/dist/a2a/deliver.d.ts +49 -0
  5. package/dist/a2a/deliver.js +49 -0
  6. package/dist/a2a/index.d.ts +2 -0
  7. package/dist/a2a/index.js +2 -0
  8. package/dist/a2a/notify.d.ts +4 -3
  9. package/dist/a2a/notify.js +4 -3
  10. package/dist/agent/anthropic/index.d.ts +15 -0
  11. package/dist/agent/anthropic/index.js +19 -0
  12. package/dist/agent/anthropic/language-model.d.ts +59 -0
  13. package/dist/agent/anthropic/language-model.js +442 -0
  14. package/dist/agent/anthropic/prompt.d.ts +84 -0
  15. package/dist/agent/anthropic/prompt.js +541 -0
  16. package/dist/agent/anthropic/runtime.d.ts +79 -0
  17. package/dist/agent/anthropic/runtime.js +130 -0
  18. package/dist/agent/control.js +10 -9
  19. package/dist/agent/errors.d.ts +85 -0
  20. package/dist/agent/errors.js +64 -0
  21. package/dist/agent/final-reply.d.ts +14 -13
  22. package/dist/agent/final-reply.js +28 -11
  23. package/dist/agent/history.d.ts +3 -3
  24. package/dist/agent/history.js +2 -2
  25. package/dist/agent/index.d.ts +4 -2
  26. package/dist/agent/index.js +4 -2
  27. package/dist/agent/inference.d.ts +58 -1
  28. package/dist/agent/inference.js +44 -0
  29. package/dist/agent/model.d.ts +42 -25
  30. package/dist/agent/model.js +1 -48
  31. package/dist/agent/session.d.ts +6 -7
  32. package/dist/agent/session.js +3 -3
  33. package/dist/agent/workers-ai/index.d.ts +23 -0
  34. package/dist/agent/workers-ai/index.js +23 -0
  35. package/dist/agent/workers-ai/runtime.d.ts +42 -0
  36. package/dist/agent/workers-ai/runtime.js +63 -0
  37. package/dist/alarm/index.d.ts +77 -0
  38. package/dist/alarm/index.js +116 -0
  39. package/dist/config.d.ts +49 -15
  40. package/dist/config.js +30 -1
  41. package/dist/contract/plugin.d.ts +63 -3
  42. package/dist/contract/plugin.js +76 -0
  43. package/dist/contract/recipe.d.ts +16 -17
  44. package/dist/db/db.d.ts +0 -1
  45. package/dist/db/migrations/index.js +8 -1
  46. package/dist/db/models/subtasks.d.ts +24 -25
  47. package/dist/db/models/subtasks.js +33 -76
  48. package/dist/db/schema.d.ts +2 -21
  49. package/dist/db/schema.js +2 -4
  50. package/dist/host/agent.d.ts +58 -4
  51. package/dist/host/agent.js +63 -9
  52. package/dist/index.d.ts +2 -2
  53. package/dist/index.js +2 -2
  54. package/dist/platform.d.ts +103 -11
  55. package/dist/platform.js +107 -13
  56. package/dist/round/agent.d.ts +36 -31
  57. package/dist/round/agent.js +61 -89
  58. package/dist/round/index.d.ts +3 -2
  59. package/dist/round/index.js +2 -2
  60. package/dist/round/policy.d.ts +2 -2
  61. package/dist/round/subagent.d.ts +19 -1
  62. package/dist/round/subagent.js +22 -5
  63. package/dist/round/turn.d.ts +32 -13
  64. package/dist/round/turn.js +83 -16
  65. package/dist/round/workflow.d.ts +23 -7
  66. package/dist/round/workflow.js +122 -110
  67. package/dist/runtime/index.d.ts +4 -2
  68. package/dist/runtime/index.js +6 -0
  69. package/dist/subagent/fingerprint.d.ts +2 -2
  70. package/dist/subagent/fingerprint.js +8 -17
  71. package/dist/subagent/index.d.ts +6 -4
  72. package/dist/subagent/index.js +8 -6
  73. package/dist/subagent/prompt.d.ts +4 -5
  74. package/dist/subagent/prompt.js +0 -8
  75. package/dist/subagent/run.d.ts +8 -1
  76. package/dist/subagent/run.js +59 -9
  77. package/dist/subtasks/catalog.d.ts +1 -1
  78. package/dist/subtasks/catalog.js +1 -1
  79. package/dist/subtasks/decomposition.d.ts +16 -20
  80. package/dist/subtasks/decomposition.js +27 -75
  81. package/dist/subtasks/delegate.d.ts +20 -1
  82. package/dist/subtasks/delegate.js +21 -16
  83. package/dist/subtasks/index.d.ts +1 -2
  84. package/dist/subtasks/index.js +1 -2
  85. package/dist/subtasks/subtask-types.d.ts +0 -8
  86. package/dist/subtasks/subtask-types.js +0 -7
  87. package/dist/subtasks/types.d.ts +45 -70
  88. package/dist/testing/do.d.ts +4 -4
  89. package/dist/testing/index.d.ts +1 -1
  90. package/dist/testing/index.js +5 -1
  91. package/dist/testing/mock-model.d.ts +35 -0
  92. package/dist/testing/mock-model.js +75 -0
  93. package/dist/testing/vcr-global-setup.d.ts +1 -3
  94. package/dist/testing/vcr-global-setup.js +1 -3
  95. package/dist/worker/index.d.ts +5 -12
  96. package/dist/worker/index.js +5 -12
  97. package/package.json +23 -1
  98. package/dist/subtasks/scheduler.d.ts +0 -48
  99. package/dist/subtasks/scheduler.js +0 -47
@@ -1,7 +1,7 @@
1
1
  import { generateText, hasToolCall, isStepCount } from "ai";
2
2
  import { appendOnce } from "../agent/session.js";
3
3
  import { deterministicSessionMessage, finalReplyMessageId, parseRoundAckMessageId, roundAckMessageId, sessionText, taskUserMessageId } from "../agent/history.js";
4
- import { buildIntermediateContentHandler, isTransientAiError } from "../agent/inference.js";
4
+ import { buildIntermediateContentHandler, isTransientAiError, nonRecoverableKind } from "../agent/inference.js";
5
5
  import { controlTools, controlToolSet } from "../agent/control.js";
6
6
  import { FINAL_REPLY_TOOL_NAME } from "../agent/final-reply.js";
7
7
  import { stepAllowance } from "../agent/budget.js";
@@ -240,9 +240,12 @@ async function attempt(args, control, model, instructions, messages) {
240
240
  // change here.
241
241
  ...control.map((c) => hasToolCall(c.name))
242
242
  ],
243
- // We do our own primary fallback recovery, so disable the SDK's
244
- // per-model backoff (it would only add latency and duplicate the fallback).
245
- maxRetries: 0,
243
+ // Retries on *this* model before the slot is given up, honouring the
244
+ // provider's own `retry-after`. Not a duplicate of the fallback: the
245
+ // fallback answers "this model cannot do it", and a 429 says "not yet" —
246
+ // and when both slots share a credential the fallback cannot even answer
247
+ // that. See `ModelConfig.maxRetries`.
248
+ maxRetries: args.maxRetries,
246
249
  // Charged here rather than from `result.steps` so a throw mid-loop still
247
250
  // bills the steps already spent — the `catch` below has no `result` to read.
248
251
  onStepEnd: async (step) => {
@@ -329,9 +332,16 @@ const MAX_REPAIR_ATTEMPTS = 3;
329
332
  * The id a repaired exchange is anchored on, derived from the Task and round like
330
333
  * every other id here. Suffixed per repair, so several rejected calls can sit in
331
334
  * one attempt's messages without colliding.
335
+ *
336
+ * Underscore-separated for the same reason as
337
+ * {@link file://../subtasks/delegate.ts delegateToolCallId}: this reaches a
338
+ * provider as a `tool_use.id`, and Anthropic rejects anything outside
339
+ * `^[a-zA-Z0-9_-]+$`. A repair exchange is exactly the moment a round is already
340
+ * in trouble, so an id that 400s here turns a recoverable bad call into a dead
341
+ * round.
332
342
  */
333
343
  function controlCallId(taskId, round) {
334
- return `task:${taskId}:round:${round}:control`;
344
+ return `task_${taskId}_round_${round}_control`;
335
345
  }
336
346
  /**
337
347
  * A rejected control call paired with its rejection, as the exchange the model has
@@ -400,7 +410,20 @@ function repairExchange(toolCallId, rejected, error) {
400
410
  * Throws only on a transient platform fault (for the Workflow step to retry).
401
411
  * A deterministic failure that outlasts every repair on both slots, with durable
402
412
  * work behind it, degrades to {@link joinSuccessfulBranches} rather than discarding
403
- * completed branches; with nothing behind it, it resolves to `{ status: "failed" }`.
413
+ * completed branches; with nothing behind it, it resolves to
414
+ * `{ status: "failed", kind: "exhausted" }`.
415
+ *
416
+ * The third failure is neither, and it short-circuits the *model* recoveries
417
+ * above: a {@link nonRecoverableKind} error ends the round from wherever it
418
+ * happens, carrying that kind — without repairing and **without reaching the
419
+ * fallback slot**, both of which would only present the same dead credential
420
+ * again. See that function for why the transient/deterministic split cannot
421
+ * express it.
422
+ *
423
+ * It does **not** skip the deterministic join. That path needs no credential —
424
+ * it is string concatenation over rows that are already durable — so completed
425
+ * branches are still delivered, and the credential fault reaches the operator
426
+ * through the log rather than by throwing away finished work.
404
427
  */
405
428
  export async function runTurn(args) {
406
429
  const { session, taskId, round, text, systemSuffix, models, branches } = args;
@@ -434,6 +457,34 @@ export async function runTurn(args) {
434
457
  // and so is a repair.
435
458
  const outcome = await attempt(args, control, model, system, slotMessages);
436
459
  if (!outcome.ok) {
460
+ // Before anything else, and before the fallback slot exists as an
461
+ // option: a failure nothing can clear ends the round here. Repairing
462
+ // asks a dead credential to try again; falling through spends the
463
+ // second slot presenting the *same* dead credential. Both are pure
464
+ // cost, and both delay the only useful outcome — telling an operator
465
+ // what to fix.
466
+ const nonRecoverable = nonRecoverableKind(outcome.error);
467
+ if (nonRecoverable) {
468
+ console.error("[turn] non-recoverable model failure", {
469
+ taskId,
470
+ round,
471
+ model: modelId,
472
+ kind: nonRecoverable,
473
+ error: String(outcome.error)
474
+ });
475
+ // What ends here is *inference*, not the round's ability to answer.
476
+ // Branches that already completed are durable rows, and joining them
477
+ // costs no credential — so the same rescue the exhausted path takes
478
+ // applies, and the operator hears about the fault from the log above.
479
+ const joined = await deterministicJoin(args);
480
+ if (joined)
481
+ return joined;
482
+ return {
483
+ status: "failed",
484
+ kind: nonRecoverable,
485
+ error: String(outcome.error)
486
+ };
487
+ }
437
488
  errors.push(outcome.error);
438
489
  const { rejected } = outcome;
439
490
  diagnostics.push(rejected
@@ -458,7 +509,7 @@ export async function runTurn(args) {
458
509
  if (rejected &&
459
510
  repair < MAX_REPAIR_ATTEMPTS &&
460
511
  args.budget.spent < args.budget.allowance) {
461
- slotMessages.push(...repairExchange(`${controlCallId(taskId, round)}:repair:${repair}`, rejected, outcome.error));
512
+ slotMessages.push(...repairExchange(`${controlCallId(taskId, round)}_repair_${repair}`, rejected, outcome.error));
462
513
  continue;
463
514
  }
464
515
  break;
@@ -484,13 +535,29 @@ export async function runTurn(args) {
484
535
  const detail = `round ${round} exhausted both models — ${diagnostics.join("; ")}`;
485
536
  // Both models failed deterministically. Any branch results behind us are durable
486
537
  // and useful; deliver them joined rather than failing a Task whose work is done.
487
- if (branches.some((b) => b.status === "completed")) {
488
- console.warn("[turn] falling back to deterministic join", {
489
- taskId,
490
- round
491
- });
492
- const reply = await appendOnce(session, deterministicSessionMessage(finalReplyMessageId(taskId), "assistant", joinSuccessfulBranches(branches, args.partialNote)));
493
- return { status: "replied", reply };
494
- }
495
- return { status: "failed", error: detail };
538
+ const joined = await deterministicJoin(args);
539
+ if (joined)
540
+ return joined;
541
+ return { status: "failed", kind: "exhausted", error: detail };
542
+ }
543
+ /**
544
+ * Deliver the branch results this round already has, when no model will produce
545
+ * an answer over them.
546
+ *
547
+ * The one recovery on this file that needs **no** model: a filter, a join and a
548
+ * durable append. That is why both failure paths reach it — a ladder that ran out
549
+ * of attempts, and one that stopped on a fault no attempt could clear. Neither
550
+ * has an answer to write; both have work worth returning.
551
+ *
552
+ * `undefined` when nothing completed, which is the caller's signal to fail with
553
+ * its own kind. No branches means nothing to join, and a Task with no work behind
554
+ * it should not report success.
555
+ */
556
+ async function deterministicJoin(args) {
557
+ const { session, taskId, round, branches } = args;
558
+ if (!branches.some((b) => b.status === "completed"))
559
+ return undefined;
560
+ console.warn("[turn] falling back to deterministic join", { taskId, round });
561
+ const reply = await appendOnce(session, deterministicSessionMessage(finalReplyMessageId(taskId), "assistant", joinSuccessfulBranches(branches, args.partialNote)));
562
+ return { status: "replied", reply };
496
563
  }
@@ -1,6 +1,7 @@
1
1
  import type { WorkflowStep } from "cloudflare:workers";
2
2
  import type { CoreConfig } from "../config.js";
3
3
  import type { GatewayIdentity } from "../a2a/verify.js";
4
+ import type { RoundFailureKind } from "../agent/inference.js";
4
5
  import type { RoundAgentBase } from "./agent.js";
5
6
  import type { RoundPolicy } from "./policy.js";
6
7
  /**
@@ -15,10 +16,10 @@ import type { RoundPolicy } from "./policy.js";
15
16
  * 1. **Round** — one main-agent inference that either answers the user (the Task
16
17
  * is done) or delegates durable Subtasks plus the acknowledgment the user sees
17
18
  * while they run.
18
- * 2. **Execute** — a delegating round's Subtask DAG runs in waves, every
19
- * dependency-ready node concurrently, each in an isolated managed subagent.
20
- * Then the loop returns to 1, where the model sees the results and decides
21
- * again answer, or delegate once more.
19
+ * 2. **Execute** — a delegating round's Subtasks all run at once, each in an
20
+ * isolated managed subagent. Then the loop returns to 1, where the model sees
21
+ * the results and decides again answer, or delegate once more. Sequencing
22
+ * lives here, in the loop, not inside a round.
22
23
  * 3. **Deliver** — persist the terminal Task, then POST a signed callback.
23
24
  *
24
25
  * The main agent is never forced either way. A round that has run out of budget —
@@ -78,6 +79,22 @@ export interface HandleTaskDeps {
78
79
  config: CoreConfig;
79
80
  /** The user-facing copy. Only `copy.taskFailed` is read out here. */
80
81
  policy: RoundPolicy;
82
+ /**
83
+ * Terminal copy for a round that produced no answer, by {@link
84
+ * RoundFailureKind} — an expired credential, models that could not do it, and
85
+ * whatever that union grows to cover.
86
+ *
87
+ * A hook rather than more `RoundPolicy` copy, because the useful words are
88
+ * deployment-specific ("run `claude setup-token`, then
89
+ * `wrangler secret put …`") and most agents cannot hit these conditions at
90
+ * all. Returning `undefined` — or omitting this — falls back to
91
+ * `policy.copy.taskFailed`, so an agent that does not care changes nothing,
92
+ * and one that only cares about *some* kinds answers for those alone.
93
+ *
94
+ * Core still owns the delivery: this supplies only the message, so the
95
+ * guarded write that doubles as the cancellation check stays in one place.
96
+ */
97
+ failureCopy?: (kind: RoundFailureKind, detail: string) => string | undefined;
81
98
  /**
82
99
  * The deployment's Ed25519 private JWK, for the terminal callback. Passed
83
100
  * rather than read off a module-scope `env` so this stays a pure function of
@@ -106,9 +123,8 @@ type AgentStub = DurableObjectStub<RoundAgentBase>;
106
123
  * references to them.
107
124
  *
108
125
  * **Step names are durable cache keys.** Everything inside the round loop carries
109
- * its round for that reason: `turn:<round>`, `deadline:<round>`,
110
- * `scan:<round>:<wave>`, `cancel:<round>:<wave>`. Renaming one silently re-runs
111
- * its effect on replay.
126
+ * its round for that reason: `turn:<round>`, `deadline:<round>`, `scan:<round>`,
127
+ * `cancel:<round>`. Renaming one silently re-runs its effect on replay.
112
128
  */
113
129
  export declare function runHandleTask(p: HandleTaskParams, step: WorkflowStep, deps: HandleTaskDeps): Promise<void>;
114
130
  export {};
@@ -1,7 +1,35 @@
1
- import { MAX_CHUNKS_PER_BRANCH } from "../platform.js";
1
+ import { CHUNK_STEP, MAX_CHUNKS_PER_BRANCH, STEP_TIMEOUT_MS } from "../platform.js";
2
2
  import { buildCompletedTask, buildFailedTask } from "../a2a/notify.js";
3
- import { createPushChannel } from "../a2a/push.js";
4
- import { selectWave } from "../subtasks/scheduler.js";
3
+ import { deliverTerminalTask } from "../a2a/deliver.js";
4
+ /**
5
+ * The same retries, and a timeout a **round** can actually be measured against.
6
+ *
7
+ * A chunk and a round are bounded by different things, and sharing one constant
8
+ * hid that. A chunk has {@link CHUNK_SOFT_MS}: it checkpoints and hands back a
9
+ * fresh step, so `STEP_TIMEOUT_MS` is a ceiling it is sized to stay under. A
10
+ * round has no soft deadline at all — `runTurn` runs up to
11
+ * `mainAgentLimits.maxTurns` sequential model-plus-tool steps in one
12
+ * `generateText`, and its only bound is that step count. Twenty turns whose
13
+ * tools each take the {@link file://../platform.ts MAX_TOOL_CALL_MS} they are
14
+ * permitted is hours, not half an hour, so a perfectly legal round could be
15
+ * killed and replayed whole.
16
+ *
17
+ * So the ceiling comes from the agent's own patience: a round cannot usefully
18
+ * outlive the wall clock its Task is allowed, because the `deadline:` step fails
19
+ * the Task at that point anyway. Floored at `STEP_TIMEOUT_MS` so a deliberately
20
+ * tight `maxWallMs` cannot produce a step timeout shorter than the single tool
21
+ * call core tells hosts they may install.
22
+ *
23
+ * This remains a backstop against a hang, not a budget. What actually bounds
24
+ * what a round *spends* is `TurnBudget`, and what bounds the Task is
25
+ * `mainAgentLimits` — both of which are checked whatever this says.
26
+ */
27
+ function turnStep(config) {
28
+ return {
29
+ ...CHUNK_STEP,
30
+ timeout: Math.max(config.mainAgentLimits.maxWallMs, STEP_TIMEOUT_MS)
31
+ };
32
+ }
5
33
  /**
6
34
  * The orchestration itself, split from the `WorkflowEntrypoint` wiring so it can
7
35
  * be driven with a fake `step` in tests (workerd forbids constructing a
@@ -14,9 +42,8 @@ import { selectWave } from "../subtasks/scheduler.js";
14
42
  * references to them.
15
43
  *
16
44
  * **Step names are durable cache keys.** Everything inside the round loop carries
17
- * its round for that reason: `turn:<round>`, `deadline:<round>`,
18
- * `scan:<round>:<wave>`, `cancel:<round>:<wave>`. Renaming one silently re-runs
19
- * its effect on replay.
45
+ * its round for that reason: `turn:<round>`, `deadline:<round>`, `scan:<round>`,
46
+ * `cancel:<round>`. Renaming one silently re-runs its effect on replay.
20
47
  */
21
48
  export async function runHandleTask(p, step, deps) {
22
49
  const limits = deps.config.mainAgentLimits;
@@ -80,7 +107,7 @@ export async function runHandleTask(p, step, deps) {
80
107
  // durable work to fall back on) and routes to failed delivery; a transient
81
108
  // fault throws and the step retries, recovering from the durable rows with no
82
109
  // second inference.
83
- const turn = await step.do(`turn:${round}`, async () => {
110
+ const turn = await step.do(`turn:${round}`, turnStep(deps.config), async () => {
84
111
  // Projected to a plain object: an RPC return carries a `Disposable` brand a
85
112
  // step result cannot serialize. Every branch must carry `turns` — a field
86
113
  // this projection drops is a field the budget never sees.
@@ -102,6 +129,7 @@ export async function runHandleTask(p, step, deps) {
102
129
  if (result.status === "failed")
103
130
  return {
104
131
  status: result.status,
132
+ kind: result.kind,
105
133
  error: result.error,
106
134
  turns: result.turns
107
135
  };
@@ -110,27 +138,33 @@ export async function runHandleTask(p, step, deps) {
110
138
  turnsUsed += turn.turns;
111
139
  if (turn.status === "canceled")
112
140
  return;
141
+ // The round produced no answer. `kind` is the whole difference between the
142
+ // two ways that happens — models that could not do it, versus a fault that
143
+ // stopped the round on its first attempt and that only a human can clear —
144
+ // and it exists to be turned into words the reader can act on. Same
145
+ // delivery either way; the diagnostic is logged, never shown.
113
146
  if (turn.status === "failed") {
114
147
  console.error("[handle-task] round failed", {
115
148
  taskId: p.taskId,
116
149
  round,
150
+ kind: turn.kind,
117
151
  error: turn.error
118
152
  });
119
- await deliver(p, step, agent, null, deps);
153
+ await deliver(p, step, agent, null, deps, {
154
+ kind: turn.kind,
155
+ detail: turn.error
156
+ });
120
157
  return;
121
158
  }
122
159
  if (turn.status === "replied") {
123
160
  await deliver(p, step, agent, turn.reply, deps);
124
161
  return;
125
162
  }
126
- // Delegated: run this round's DAG, then loop and let the model decide again.
127
- const executed = await executeDag(p, step, agent, round, push, deps);
163
+ // Delegated: run this round's Subtasks, then loop and let the model decide
164
+ // again.
165
+ const executed = await executeSubtasks(p, step, agent, round, push);
128
166
  if (executed === "canceled")
129
167
  return;
130
- if (executed === "stuck") {
131
- await deliver(p, step, agent, null, deps);
132
- return;
133
- }
134
168
  }
135
169
  // Unreachable: a `final` round is handed only `final_reply`, so it either
136
170
  // answers or fails, and both return above. Reaching here means a round
@@ -141,58 +175,49 @@ export async function runHandleTask(p, step, deps) {
141
175
  await deliver(p, step, agent, null, deps);
142
176
  }
143
177
  /**
144
- * Drive one round's Subtask DAG to termination, one wave at a time.
178
+ * Run every Subtask one round delegated, concurrently, to termination.
145
179
  *
146
- * Bounded by `maxSubtasks + 1` iterations rather than looping until `done`: a
147
- * wave that reports `ready` always retires at least one active node, so N
148
- * Subtasks need at most N waves of work plus one final scan to observe `done`.
149
- * Exhausting the budget means the DAG stopped making progress, which is the same
150
- * corruption `stuck` names.
180
+ * **One pass is the whole thing.** A round's Subtasks are independent of one
181
+ * another, so they are all runnable the moment they exist, and `runBranch` is
182
+ * contractually obliged to leave its row terminal it resolves a deterministic
183
+ * failure itself and has a `fail:<id>` backstop once the retries are gone. So
184
+ * there is nothing left to re-scan afterwards, and no way for this to make no
185
+ * progress. Sequencing between units of work is the round loop's job.
151
186
  *
152
- * Every step name carries the round, because step names are durable cache keys:
153
- * two rounds of the same Task reusing `scan:0` would replay the first round's
154
- * cached answer into the second.
187
+ * Both step names carry the round, because step names are durable cache keys: two
188
+ * rounds of the same Task reusing `scan` would replay the first round's cached
189
+ * answer into the second.
155
190
  */
156
- async function executeDag(p, step, agent, round, push, deps) {
157
- for (let wave = 0; wave <= deps.config.maxSubtasks; wave++) {
158
- // One durable step per wave: `skipBlockedSubtasks` reports cancellation,
159
- // propagates skips past any branch that just failed, and returns the
160
- // refreshed DAG projection — one round trip, one consistent answer.
161
- const scan = await step.do(`scan:${round}:${wave}`, async () => {
162
- const result = await agent().skipBlockedSubtasks(p.taskId, round);
163
- return result.canceled
164
- ? { canceled: true, nodes: [] }
165
- : { canceled: false, nodes: result.nodes };
191
+ async function executeSubtasks(p, step, agent, round, push) {
192
+ // One durable step: `scanSubtasks` reports cancellation and returns the ids
193
+ // still owing an outcome one round trip, one consistent answer. It writes
194
+ // nothing, so a replay that re-runs it costs only the read.
195
+ const scan = await step.do(`scan:${round}`, async () => {
196
+ const result = await agent().scanSubtasks(p.taskId, round);
197
+ return result.canceled
198
+ ? { canceled: true, ids: [] }
199
+ : { canceled: false, ids: result.ids };
200
+ });
201
+ if (scan.canceled) {
202
+ await step.do(`cancel:${round}`, async () => {
203
+ await agent().cancelPendingSubtasks(p.taskId);
166
204
  });
167
- if (scan.canceled) {
168
- await step.do(`cancel:${round}:${wave}`, async () => {
169
- await agent().cancelPendingSubtasks(p.taskId);
170
- });
171
- return "canceled";
172
- }
173
- const decision = selectWave(scan.nodes);
174
- if (decision.kind === "done")
175
- return "done";
176
- if (decision.kind === "stuck") {
177
- console.error("[handle-task] subtask DAG made no progress", {
178
- taskId: p.taskId,
179
- round,
180
- wave,
181
- active: decision.active
182
- });
183
- return "stuck";
184
- }
185
- // Every dependency-ready node runs concurrently — the per-round Subtask
186
- // maximum is the only fan-out bound. `runBranch` never rejects, so a single
187
- // branch cannot fast-fail `Promise.all` and strand its siblings' durable
188
- // results.
189
- await Promise.all(decision.ids.map((id) => runBranch(p, step, agent, id, push)));
205
+ return "canceled";
190
206
  }
191
- console.error("[handle-task] subtask DAG exceeded its wave budget", {
192
- taskId: p.taskId,
193
- round
194
- });
195
- return "stuck";
207
+ // Every Subtask runs concurrently the per-round Subtask maximum is the only
208
+ // fan-out bound. `runBranch` never rejects, so a single branch cannot fast-fail
209
+ // `Promise.all` and strand its siblings' durable results.
210
+ //
211
+ // A cancellation arriving mid-pass is still honored, just not from here:
212
+ // `onTaskCanceled` aborts the live children *and* transitions every row still
213
+ // `pending` in the same sweep, and `executeSubtaskChunk` re-checks before
214
+ // publishing. That transition is what lets this pass end without a second
215
+ // scan. Without it, a branch whose RPC had not yet claimed its row when the
216
+ // cancellation landed would return terminal while leaving the row `pending`,
217
+ // and — since the next round's turn reports `canceled` and the workflow exits
218
+ // — nothing would resolve it before the 30-day cleanup.
219
+ await Promise.all(scan.ids.map((id) => runBranch(p, step, agent, id, push)));
220
+ return "done";
196
221
  }
197
222
  /**
198
223
  * Run one Subtask to termination as a sequence of durable **chunk** steps, and
@@ -202,7 +227,9 @@ async function executeDag(p, step, agent, round, push, deps) {
202
227
  * `done` on chunk 0 (step `execute:<id>`); a long recipe yields `done: false` and
203
228
  * the loop runs the next chunk (`execute:<id>:chunk:<n>`) until it terminates.
204
229
  * Each chunk is its own retryable step, and the child resumes from its
205
- * checkpoint — so no step approaches the platform timeout.
230
+ * checkpoint — so no step approaches the {@link CHUNK_STEP} timeout. `CHUNK_SOFT_MS`
231
+ * is what holds that true, and is sized against it rather than the other way
232
+ * round; a boundary here is not free, so it wants to be rare, not frequent.
206
233
  *
207
234
  * It resolves a deterministic branch failure into a `failed` row itself and
208
235
  * throws only on a transient fault (retry me) or a lifecycle bug. So a throw that
@@ -226,7 +253,7 @@ async function runBranch(p, step, agent, id, push) {
226
253
  // Chunk 0 keeps the plain `execute:<id>` step name so single-chunk branches
227
254
  // replay identically; later chunks append `:chunk:<n>`.
228
255
  const stepName = chunk === 0 ? `execute:${id}` : `execute:${id}:chunk:${chunk}`;
229
- const done = await step.do(stepName, async () => {
256
+ const done = await step.do(stepName, CHUNK_STEP, async () => {
230
257
  // The DO posts any progress itself; the step returns only the verdict.
231
258
  const outcome = await agent().executeSubtaskChunk(id, chunk, push);
232
259
  return outcome.done;
@@ -258,60 +285,45 @@ async function runBranch(p, step, agent, id, push) {
258
285
  /**
259
286
  * Persist the terminal Task, then notify the gateway. A null `reply` delivers a
260
287
  * `failed` Task with the policy's user-safe text; the diagnostic is already
261
- * logged.
288
+ * logged. Given a `failure`, the host's {@link HandleTaskDeps.failureCopy} may
289
+ * replace that text — same delivery, different words.
262
290
  *
263
- * The Task is built **inside** the step and returned, so `notify` posts exactly
264
- * what was persisted: building it in the body would re-stamp `new Date()` on
265
- * every replay and post a Task that differs from the stored one.
291
+ * `failure` is optional because only a round's own inference carries a kind. The
292
+ * other path here a budget that ran out mid-delegation is not a model failure
293
+ * and is deliberately not given a kind of its own until something needs to tell
294
+ * it apart.
266
295
  *
267
- * **The guarded write is the cancellation check.** `saveTask` refuses to write a
268
- * terminal state over a `canceled` row and says so, and it does that read and
269
- * write in one synchronous pass inside the DO. Probing first and saving second
270
- * would leave a window — between the two calls, and again between this step and
271
- * `notify` — in which a `tasks/cancel` lands and the gateway still receives a
272
- * `completed` callback. Keying the notify on "did the write apply" closes it.
296
+ * The delivery itself is {@link deliverTerminalTask}, which is shared with agents
297
+ * that never delegate. What is a round's own is the two things passed to it: the
298
+ * choice of terminal Task, and the child sweep.
273
299
  */
274
- async function deliver(p, step, agent, reply, deps) {
275
- const task = await step.do("complete", async () => {
276
- const terminal = reply !== null
277
- ? buildCompletedTask(p.taskId, p.contextId, reply)
278
- : buildFailedTask(p.taskId, p.contextId, deps.policy.copy.taskFailed);
279
- return (await agent().saveTask(terminal)) ? terminal : null;
280
- });
281
- if (!task)
282
- return;
283
- // Sweep this Task's managed children now that it is terminal and every `execute`
284
- // step has unwound. Deleting them here — rather than right after each successful
285
- // chunk — keeps `deleteSubAgent`'s facet-abort from landing on a still-open
286
- // `executeChunk` RPC, which telemetry mis-records as a failure. Best-effort and
287
- // idempotent, so it is safe on replay.
288
- //
289
- // Caught, not left to propagate: the terminal Task is already durably saved, so
290
- // a sweep that still fails once the step's own retries are exhausted must not
291
- // block `notify` below — the gateway is owed its result regardless of whether
292
- // this Task's children were reclaimed.
293
- try {
294
- await step.do("sweep", async () => {
295
- await agent().sweepTaskChildren(p.taskId);
296
- });
297
- }
298
- catch (err) {
299
- console.error("[handle-task] sweep failed after retries", {
300
- taskId: p.taskId,
301
- err: String(err)
302
- });
303
- }
304
- // Notify the gateway: a card-key-signed callback POST. Retried by the step on a
305
- // non-2xx; the terminal messageId is deterministic and the gateway is
306
- // idempotent/single-use, so retries are safe. If it ultimately fails, the
307
- // gateway's own reaction backstop clears the pending marker.
308
- await step.do("notify", async () => {
309
- await createPushChannel(deps.signingKey, {
300
+ async function deliver(p, step, agent, reply, deps, failure) {
301
+ // Resolved outside the step body so a replay cannot take a different branch
302
+ // than the write it is replaying.
303
+ const failedText = (failure && deps.failureCopy?.(failure.kind, failure.detail)) ||
304
+ deps.policy.copy.taskFailed;
305
+ await deliverTerminalTask(step, {
306
+ push: {
310
307
  taskId: p.taskId,
311
308
  contextId: p.contextId,
312
309
  pushUrl: p.pushUrl,
313
310
  pushToken: p.pushToken,
314
311
  jku: p.jku
315
- }).deliver(task);
312
+ },
313
+ signingKey: deps.signingKey,
314
+ // `agent()` inside the body, never hoisted: a stub is a live connection and
315
+ // a severed one never reconnects.
316
+ saveTask: (task) => agent().saveTask(task),
317
+ terminal: () => reply !== null
318
+ ? buildCompletedTask(p.taskId, p.contextId, reply)
319
+ : buildFailedTask(p.taskId, p.contextId, failedText),
320
+ // Sweep this Task's managed children now that it is terminal and every
321
+ // `execute` step has unwound. Deleting them here — rather than right after
322
+ // each successful chunk — keeps `deleteSubAgent`'s facet-abort from landing
323
+ // on a still-open `executeChunk` RPC, which telemetry mis-records as a
324
+ // failure. Best-effort and idempotent, so it is safe on replay.
325
+ sweep: async () => {
326
+ await agent().sweepTaskChildren(p.taskId);
327
+ }
316
328
  });
317
329
  }
@@ -59,8 +59,10 @@ export interface AgentRuntime {
59
59
  /** Tools the installed plugins offer the *main* agent, merged. */
60
60
  mainAgentTools(ctx: MainAgentToolContext): Promise<ToolSet>;
61
61
  /**
62
- * Every plugin's `capability` block, for the main agent's soul. Returns `""`
63
- * when none declares one, so a call site can append unconditionally.
62
+ * The capability blocks for the main agent's soul, in plugin declaration
63
+ * order: each plugin's own {@link AgentPlugin.capability} and the one on its
64
+ * {@link AgentPlugin.subtaskType}, if it declares either. Returns `""` when
65
+ * none does, so a call site can append unconditionally.
64
66
  */
65
67
  renderCapabilities(): string;
66
68
  /**
@@ -109,8 +109,14 @@ export function createAgentRuntime(options) {
109
109
  renderCapabilities() {
110
110
  const blocks = [];
111
111
  for (const plugin of plugins) {
112
+ // Both blocks a plugin may declare, emitted adjacently: a plugin that
113
+ // declares a subtask type puts its capability on the *type*, and one
114
+ // that only offers main-agent tools puts it on the plugin. Declaring
115
+ // both is legal and means the model reads both.
112
116
  if (plugin.capability)
113
117
  blocks.push(plugin.capability);
118
+ if (plugin.subtaskType?.capability)
119
+ blocks.push(plugin.subtaskType.capability);
114
120
  }
115
121
  return blocks.join("\n\n");
116
122
  },
@@ -11,8 +11,8 @@ export declare const FINGERPRINT_VERSION = 1;
11
11
  * Canonical JSON of the fields that define an execution's identity, rebuilt as
12
12
  * literals in fixed key order so `JSON.stringify` is deterministic (object
13
13
  * insertion order). Array order is semantic and preserved: the parent builds
14
- * references and dependency results from ordinal-ordered rows, so a retry of the
15
- * same execution is byte-identical.
14
+ * references from ordinal-ordered rows, so a retry of the same execution is
15
+ * byte-identical.
16
16
  *
17
17
  * **Limits are canonicalized as *declared*, not as merged.** The predecessor
18
18
  * merged them against the house baseline first, on the reasoning that `{}` and
@@ -10,8 +10,8 @@ export const FINGERPRINT_VERSION = 1;
10
10
  * Canonical JSON of the fields that define an execution's identity, rebuilt as
11
11
  * literals in fixed key order so `JSON.stringify` is deterministic (object
12
12
  * insertion order). Array order is semantic and preserved: the parent builds
13
- * references and dependency results from ordinal-ordered rows, so a retry of the
14
- * same execution is byte-identical.
13
+ * references from ordinal-ordered rows, so a retry of the same execution is
14
+ * byte-identical.
15
15
  *
16
16
  * **Limits are canonicalized as *declared*, not as merged.** The predecessor
17
17
  * merged them against the house baseline first, on the reasoning that `{}` and
@@ -36,13 +36,12 @@ export function canonicalRequest(request) {
36
36
  recipe: {
37
37
  key: request.recipe.key,
38
38
  version: request.recipe.version,
39
- // No model ids. They used to be hashed because a recipe declared them;
40
- // it no longer can, and their absence here is the same trade this file
41
- // already makes for `limits`: the host's own configuration stays out of
42
- // the fingerprint, so changing it does not invalidate every checkpoint at
43
- // once and restart every in-flight run from turn zero with its budget
44
- // already spent. A run that resumes on a newly configured model resumes
45
- // from conversation state, which is model-independent.
39
+ // No model ids. Their absence is the same trade this file already makes
40
+ // for `limits`: the host's own configuration stays out of the fingerprint,
41
+ // so changing it does not invalidate every checkpoint at once and restart
42
+ // every in-flight run from turn zero with its budget already spent. A run
43
+ // that resumes on a newly configured model resumes from conversation
44
+ // state, which is model-independent.
46
45
  soul: request.recipe.soul,
47
46
  toolFamilies: request.recipe.toolFamilies,
48
47
  enabled: request.recipe.enabled,
@@ -55,14 +54,6 @@ export function canonicalRequest(request) {
55
54
  role: ref.role,
56
55
  text: ref.text
57
56
  })),
58
- dependencyResults: request.dependencyResults.map((dep) => ({
59
- subtaskId: dep.subtaskId,
60
- type: dep.type,
61
- resultParts: dep.resultParts.map((part) => ({
62
- kind: part.kind,
63
- text: part.text
64
- }))
65
- })),
66
57
  // Params ARE identity: the same prompt against a different external resource
67
58
  // is different work, and must not replay a cached result. Key order is fixed
68
59
  // by sorting, so an equivalent params object always canonicalizes identically.