@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,147 @@
1
+ import type { WorkflowStep } from "cloudflare:workers";
2
+ import type { CoreConfig } from "../config.js";
3
+ import type { GatekeeperIdentity } from "../a2a/verify.js";
4
+ import type { RoundFailureKind } from "../agent/inference.js";
5
+ import type { RoundAgentBase } from "./agent.js";
6
+ import type { RoundPolicy } from "./policy.js";
7
+ /**
8
+ * The async task controller. The gatekeeper does not wait for a synchronous reply:
9
+ * the Worker accepts a turn (returns a `submitted` Task) and hands the actual work
10
+ * to this durable Workflow, which orchestrates it end to end and delivers the
11
+ * reply to the gatekeeper's push-notification webhook.
12
+ *
13
+ * The shape is a **round loop**, not a fixed sequence of phases:
14
+ *
15
+ * 0. **Pre-work** — resolve the caller's agent, mark the Task working.
16
+ * 1. **Round** — one main-agent inference that either answers the user (the Task
17
+ * is done) or delegates durable Subtasks plus the acknowledgment the user sees
18
+ * while they run.
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.
23
+ * 3. **Deliver** — persist the terminal Task, then POST a signed callback.
24
+ *
25
+ * The main agent is never forced either way. A round that has run out of budget —
26
+ * `mainAgentLimits`, in turns or in wall clock — is handed no tools but the
27
+ * answer, so it has to give one; every other round chooses. That is the whole
28
+ * reason this is a loop, and the whole termination argument.
29
+ *
30
+ * Why a Workflow (not a DO alarm or `waitUntil`): `step.do(...)` gives durable,
31
+ * independently-retried steps that survive isolate eviction, and a future
32
+ * `escalate` decision (ask the human, then continue) slots in cleanly as another
33
+ * branch of the loop built on `step.waitForEvent(...)`.
34
+ *
35
+ * A Workflow is a separate entrypoint and cannot touch the agent DO's SQLite
36
+ * directly, so: the task inputs travel as the workflow **payload**, and the agent
37
+ * runtime plus task state are reached only through **native DO RPC**.
38
+ *
39
+ * Idempotency: the instance id is derived from the gatekeeper's `messageId`
40
+ * (deterministic across dispatch retries), so a re-dispatch never starts a second
41
+ * run. Within a run, every step is re-runnable: the Subtask rows and the Session
42
+ * are the source of truth, and each round recovers from them rather than
43
+ * re-inferring.
44
+ */
45
+ export interface HandleTaskParams {
46
+ /** The accepted task id (echoed back to the gatekeeper on the callback). */
47
+ taskId: string;
48
+ /** The user turn text to answer. */
49
+ text: string;
50
+ /** The verified calling gatekeeper-agent identity (keys the DO + the Session). */
51
+ identity: GatekeeperIdentity;
52
+ /** A2A context id, echoed on the completed Task. */
53
+ contextId: string;
54
+ /** Gatekeeper push-notification webhook (also the callback JWT `aud`). */
55
+ pushUrl: string;
56
+ /** Per-task validation token the gatekeeper set; echoed in the callback header. */
57
+ pushToken: string;
58
+ /** This agent's card-signing JWKS URL — the callback JWT `jku` (pinned key). */
59
+ jku: string;
60
+ }
61
+ /**
62
+ * What distinguishes one agent's use of this loop from another's.
63
+ *
64
+ * The whole body below is agent-agnostic — it names no soul, no plugin and no
65
+ * model. Two delegating agents differ only in these values, which is why they
66
+ * share one workflow body behind two thin entrypoints rather than two copies of a
67
+ * 250-line orchestration.
68
+ */
69
+ export interface HandleTaskDeps {
70
+ /**
71
+ * Route to the right DO class for the verified caller.
72
+ *
73
+ * Called **once per step body**, not once per run, so it must stay a cheap
74
+ * pure lookup — a namespace `get`, nothing cached and nothing awaited. See
75
+ * {@link ResolveAgent} for why the result must never be hoisted.
76
+ */
77
+ resolveAgent: (identity: GatekeeperIdentity) => AgentStub;
78
+ /** Resolved config — the loop reads `mainAgentLimits` and `maxSubtasks`. */
79
+ config: CoreConfig;
80
+ /** The user-facing copy. Only `copy.taskFailed` is read out here. */
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;
98
+ /**
99
+ * The deployment's Ed25519 private JWK, for the terminal callback. Passed
100
+ * rather than read off a module-scope `env` so this stays a pure function of
101
+ * its arguments — and so a Worker whose secret is named something else works
102
+ * with no change here.
103
+ */
104
+ signingKey: string;
105
+ /**
106
+ * Log prefix for the abandoned-task line, conventionally the agent's tenant
107
+ * id. Optional because nothing here needs it to work — but a deployment that
108
+ * mounts several agents on one Worker gets one log stream, and without this
109
+ * every one of them reports going quiet under the same name.
110
+ */
111
+ label?: string;
112
+ }
113
+ /**
114
+ * The caller's agent DO stub — every phase runs through it.
115
+ *
116
+ * Typed on the abstract base rather than a concrete class: the orchestration
117
+ * below calls only methods the base declares, and every delegating agent's stub
118
+ * satisfies it.
119
+ */
120
+ type AgentStub = DurableObjectStub<RoundAgentBase>;
121
+ /**
122
+ * The orchestration, split from the `WorkflowEntrypoint` wiring so it can be
123
+ * driven with a fake `step` in tests (workerd forbids constructing a
124
+ * `WorkflowEntrypoint` outside the runtime) — and so a second agent can reuse it
125
+ * with different deps.
126
+ *
127
+ * ## What the wrapper adds, and why it is not the host's job
128
+ *
129
+ * Core distinguishes two ways a turn ends badly. A **typed** failure is a value
130
+ * and {@link deliver} carries it. A **transient** fault throws, so the step
131
+ * retries and recovers from the durable rows without paying for a second
132
+ * inference. Neither covers a transient fault that never stops being one: the
133
+ * step exhausts its retries, {@link orchestrate} unwinds, the delivery below is
134
+ * never reached, and the instance errors with the Task still in `working` — the
135
+ * user told nothing, and the runtime recording a hang. See
136
+ * {@link deliverAbandonedTask}, which was written for a deployed agent that did
137
+ * exactly this on 2026-08-19.
138
+ *
139
+ * This is caught **here** rather than left to each `WorkflowEntrypoint` because
140
+ * everything the recovery needs is already in {@link HandleTaskDeps}: the stub
141
+ * (typed on `RoundAgentBase`, so `saveTask` and `sweepTaskChildren` are both
142
+ * reachable), `policy.copy.taskFailed`, and `signingKey`. A host has nothing to
143
+ * add — so asking it to remember buys nothing and costs exactly what it cost the
144
+ * starter, where three of four agents never wrote the `catch` at all.
145
+ */
146
+ export declare function runHandleTask(p: HandleTaskParams, step: WorkflowStep, deps: HandleTaskDeps): Promise<void>;
147
+ export {};
@@ -0,0 +1,387 @@
1
+ import { CHUNK_STEP, MAX_CHUNKS_PER_BRANCH, STEP_TIMEOUT_MS } from "../platform.js";
2
+ import { buildCompletedTask, buildFailedTask } from "../a2a/notify.js";
3
+ import { deliverAbandonedTask, 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
+ }
33
+ /**
34
+ * The orchestration, split from the `WorkflowEntrypoint` wiring so it can be
35
+ * driven with a fake `step` in tests (workerd forbids constructing a
36
+ * `WorkflowEntrypoint` outside the runtime) — and so a second agent can reuse it
37
+ * with different deps.
38
+ *
39
+ * ## What the wrapper adds, and why it is not the host's job
40
+ *
41
+ * Core distinguishes two ways a turn ends badly. A **typed** failure is a value
42
+ * and {@link deliver} carries it. A **transient** fault throws, so the step
43
+ * retries and recovers from the durable rows without paying for a second
44
+ * inference. Neither covers a transient fault that never stops being one: the
45
+ * step exhausts its retries, {@link orchestrate} unwinds, the delivery below is
46
+ * never reached, and the instance errors with the Task still in `working` — the
47
+ * user told nothing, and the runtime recording a hang. See
48
+ * {@link deliverAbandonedTask}, which was written for a deployed agent that did
49
+ * exactly this on 2026-08-19.
50
+ *
51
+ * This is caught **here** rather than left to each `WorkflowEntrypoint` because
52
+ * everything the recovery needs is already in {@link HandleTaskDeps}: the stub
53
+ * (typed on `RoundAgentBase`, so `saveTask` and `sweepTaskChildren` are both
54
+ * reachable), `policy.copy.taskFailed`, and `signingKey`. A host has nothing to
55
+ * add — so asking it to remember buys nothing and costs exactly what it cost the
56
+ * starter, where three of four agents never wrote the `catch` at all.
57
+ */
58
+ export async function runHandleTask(p, step, deps) {
59
+ try {
60
+ await orchestrate(p, step, deps);
61
+ }
62
+ catch (cause) {
63
+ // Everything this needs is already in `deps` — which is the argument for it
64
+ // living here rather than in each host's `catch`. Four agents in the starter
65
+ // called this function and only one had written that `catch`; the other three
66
+ // carried the 2026-08-19 failure silently. A guard nobody can forget is worth
67
+ // more than a helper everybody must remember.
68
+ await deliverAbandonedTask(step, cause, {
69
+ push: {
70
+ taskId: p.taskId,
71
+ contextId: p.contextId,
72
+ pushUrl: p.pushUrl,
73
+ pushToken: p.pushToken,
74
+ jku: p.jku
75
+ },
76
+ signingKey: deps.signingKey,
77
+ // Resolved inside each closure, never hoisted — see {@link ResolveAgent}.
78
+ saveTask: (task) => deps.resolveAgent(p.identity).saveTask(task),
79
+ // The round never got far enough to say *which* credential or model was at
80
+ // fault, so `failureCopy` has nothing to answer and the policy's own words
81
+ // are the honest ones. The diagnostic is logged instead.
82
+ text: deps.policy.copy.taskFailed,
83
+ sweep: async () => {
84
+ await deps.resolveAgent(p.identity).sweepTaskChildren(p.taskId);
85
+ },
86
+ label: deps.label
87
+ });
88
+ }
89
+ }
90
+ /**
91
+ * The orchestration proper — every ordinary outcome ends inside here, and
92
+ * anything that escapes is what {@link runHandleTask} turns into a delivered
93
+ * failure.
94
+ *
95
+ * Every `step.do` return here is a small projection — a status, an id, a reply.
96
+ * Never a Subtask row: a step return is capped at 1 MiB and a Subtask carries
97
+ * verbatim history snapshots, so the rows stay in the DO and the Workflow carries
98
+ * references to them.
99
+ *
100
+ * **Step names are durable cache keys.** Everything inside the round loop carries
101
+ * its round for that reason: `turn:<round>`, `deadline:<round>`, `scan:<round>`,
102
+ * `cancel:<round>`. Renaming one silently re-runs its effect on replay — and the
103
+ * recovery path in {@link runHandleTask} runs under its own prefix for the same
104
+ * reason, so a second delivery cannot be handed this one's cached results.
105
+ */
106
+ async function orchestrate(p, step, deps) {
107
+ const limits = deps.config.mainAgentLimits;
108
+ // Pre-work. Routing is pure, so it needs no step of its own — but it is
109
+ // deliberately *not* resolved here into a value the steps below close over.
110
+ // See {@link ResolveAgent}.
111
+ const agent = () => deps.resolveAgent(p.identity);
112
+ const push = {
113
+ taskId: p.taskId,
114
+ contextId: p.contextId,
115
+ pushUrl: p.pushUrl,
116
+ pushToken: p.pushToken,
117
+ jku: p.jku
118
+ };
119
+ const started = await step.do("working", async () => (await agent().markWorking(p.taskId)) === "ok");
120
+ if (!started)
121
+ return;
122
+ // Main-agent turns spent so far, across every round. Summed from cached step
123
+ // returns, so a replay reconstructs the identical number and the `mode` input
124
+ // below stays deterministic.
125
+ let turnsUsed = 0;
126
+ // The Task's own start, in a step so replays read the original instant rather
127
+ // than restarting the clock — otherwise a Workflow that retried its way through
128
+ // the night would never observe the deadline it had long since passed.
129
+ //
130
+ // When escalation lands, this is the line that needs care: a Task suspended on
131
+ // `step.waitForEvent(...)` must **rebase** it on resume, or a human's thinking
132
+ // time is charged to the agent and a Task that asked a question is dead before
133
+ // the answer arrives. `turnsUsed` needs no such handling — waiting costs none.
134
+ const startedAtMs = await step.do("started", async () => Date.now());
135
+ // At most one round per turn of the budget, **plus one**: an `open` round always
136
+ // spends at least one turn, so `maxTurns` of them exhaust the budget — and the
137
+ // forced-answer round that follows needs an iteration of its own to happen in.
138
+ // Off by one here and a Task of cheap rounds would fall out of the loop with no
139
+ // reply instead of being made to give one.
140
+ for (let round = 0; round <= limits.maxTurns; round++) {
141
+ // The clock is read *inside a step* so its answer is cached with the round:
142
+ // `mode` is a step input, and a replay that re-read `Date.now()` would
143
+ // reconstruct a different one. Time is the budget a Task can spend without
144
+ // spending the other — a round waiting on slow subtasks moves it while
145
+ // `turnsUsed` does not.
146
+ const overdue = await step.do(`deadline:${round}`, async () => Date.now() - startedAtMs >= limits.maxWallMs);
147
+ // Out of turns or out of time ⇒ this round gets no tools at all and must
148
+ // answer. Not a failure mode: it is how a ceiling returns the work instead of
149
+ // dropping it.
150
+ const mode = turnsUsed >= limits.maxTurns || overdue ? "final" : "open";
151
+ if (mode === "final") {
152
+ // Worth its own line: from the outside, a round the budget ended is
153
+ // indistinguishable from a model that simply chose to answer.
154
+ console.warn("[handle-task] task budget spent, forcing an answer", {
155
+ taskId: p.taskId,
156
+ round,
157
+ turnsUsed,
158
+ overdue
159
+ });
160
+ }
161
+ // The main agent decides. `runTaskTurn` persists whatever the round produced
162
+ // — a final reply, or the Subtask rows plus the acknowledgment it already
163
+ // pushed — so this step returns only the verdict plus what it cost. A typed
164
+ // `failed` is a real outcome (both models produced unusable output, with no
165
+ // durable work to fall back on) and routes to failed delivery; a transient
166
+ // fault throws and the step retries, recovering from the durable rows with no
167
+ // second inference.
168
+ const turn = await step.do(`turn:${round}`, turnStep(deps.config), async () => {
169
+ // Projected to a plain object: an RPC return carries a `Disposable` brand a
170
+ // step result cannot serialize. Every branch must carry `turns` — a field
171
+ // this projection drops is a field the budget never sees.
172
+ const result = await agent().runTaskTurn({
173
+ taskId: p.taskId,
174
+ text: p.text,
175
+ identity: p.identity,
176
+ round,
177
+ mode,
178
+ turnsRemaining: limits.maxTurns - turnsUsed,
179
+ push
180
+ });
181
+ if (result.status === "replied")
182
+ return {
183
+ status: result.status,
184
+ reply: result.reply,
185
+ turns: result.turns
186
+ };
187
+ if (result.status === "failed")
188
+ return {
189
+ status: result.status,
190
+ kind: result.kind,
191
+ error: result.error,
192
+ turns: result.turns
193
+ };
194
+ return { status: result.status, turns: result.turns };
195
+ });
196
+ turnsUsed += turn.turns;
197
+ if (turn.status === "canceled")
198
+ return;
199
+ // The round produced no answer. `kind` is the whole difference between the
200
+ // two ways that happens — models that could not do it, versus a fault that
201
+ // stopped the round on its first attempt and that only a human can clear —
202
+ // and it exists to be turned into words the reader can act on. Same
203
+ // delivery either way; the diagnostic is logged, never shown.
204
+ if (turn.status === "failed") {
205
+ console.error("[handle-task] round failed", {
206
+ taskId: p.taskId,
207
+ round,
208
+ kind: turn.kind,
209
+ error: turn.error
210
+ });
211
+ await deliver(p, step, agent, null, deps, {
212
+ kind: turn.kind,
213
+ detail: turn.error
214
+ });
215
+ return;
216
+ }
217
+ if (turn.status === "replied") {
218
+ await deliver(p, step, agent, turn.reply, deps);
219
+ return;
220
+ }
221
+ // Delegated: run this round's Subtasks, then loop and let the model decide
222
+ // again.
223
+ const executed = await executeSubtasks(p, step, agent, round, push);
224
+ if (executed === "canceled")
225
+ return;
226
+ }
227
+ // Unreachable: a `final` round is handed only `final_reply`, so it either
228
+ // answers or fails, and both return above. Reaching here means a round
229
+ // delegated with no turns left to do it with.
230
+ console.error("[handle-task] round budget exhausted without a reply", {
231
+ taskId: p.taskId
232
+ });
233
+ await deliver(p, step, agent, null, deps);
234
+ }
235
+ /**
236
+ * Run every Subtask one round delegated, concurrently, to termination.
237
+ *
238
+ * **One pass is the whole thing.** A round's Subtasks are independent of one
239
+ * another, so they are all runnable the moment they exist, and `runBranch` is
240
+ * contractually obliged to leave its row terminal — it resolves a deterministic
241
+ * failure itself and has a `fail:<id>` backstop once the retries are gone. So
242
+ * there is nothing left to re-scan afterwards, and no way for this to make no
243
+ * progress. Sequencing between units of work is the round loop's job.
244
+ *
245
+ * Both step names carry the round, because step names are durable cache keys: two
246
+ * rounds of the same Task reusing `scan` would replay the first round's cached
247
+ * answer into the second.
248
+ */
249
+ async function executeSubtasks(p, step, agent, round, push) {
250
+ // One durable step: `scanSubtasks` reports cancellation and returns the ids
251
+ // still owing an outcome — one round trip, one consistent answer. It writes
252
+ // nothing, so a replay that re-runs it costs only the read.
253
+ const scan = await step.do(`scan:${round}`, async () => {
254
+ const result = await agent().scanSubtasks(p.taskId, round);
255
+ return result.canceled
256
+ ? { canceled: true, ids: [] }
257
+ : { canceled: false, ids: result.ids };
258
+ });
259
+ if (scan.canceled) {
260
+ await step.do(`cancel:${round}`, async () => {
261
+ await agent().cancelPendingSubtasks(p.taskId);
262
+ });
263
+ return "canceled";
264
+ }
265
+ // Every Subtask runs concurrently — the per-round Subtask maximum is the only
266
+ // fan-out bound. `runBranch` never rejects, so a single branch cannot fast-fail
267
+ // `Promise.all` and strand its siblings' durable results.
268
+ //
269
+ // A cancellation arriving mid-pass is still honored, just not from here:
270
+ // `onTaskCanceled` aborts the live children *and* transitions every row still
271
+ // `pending` in the same sweep, and `executeSubtaskChunk` re-checks before
272
+ // publishing. That transition is what lets this pass end without a second
273
+ // scan. Without it, a branch whose RPC had not yet claimed its row when the
274
+ // cancellation landed would return terminal while leaving the row `pending`,
275
+ // and — since the next round's turn reports `canceled` and the workflow exits
276
+ // — nothing would resolve it before the 30-day cleanup.
277
+ await Promise.all(scan.ids.map((id) => runBranch(p, step, agent, id, push)));
278
+ return "done";
279
+ }
280
+ /**
281
+ * Run one Subtask to termination as a sequence of durable **chunk** steps, and
282
+ * make sure the row ends terminal either way.
283
+ *
284
+ * `executeSubtaskChunk(id, chunk)` advances one chunk: a single-chunk recipe is
285
+ * `done` on chunk 0 (step `execute:<id>`); a long recipe yields `done: false` and
286
+ * the loop runs the next chunk (`execute:<id>:chunk:<n>`) until it terminates.
287
+ * Each chunk is its own retryable step, and the child resumes from its
288
+ * checkpoint — so no step approaches the {@link CHUNK_STEP} timeout. `CHUNK_SOFT_MS`
289
+ * is what holds that true, and is sized against it rather than the other way
290
+ * round; a boundary here is not free, so it wants to be rare, not frequent.
291
+ *
292
+ * It resolves a deterministic branch failure into a `failed` row itself and
293
+ * throws only on a transient fault (retry me) or a lifecycle bug. So a throw that
294
+ * survives every retry — or a run that never terminates within the chunk budget —
295
+ * means nobody is left to resolve this row: fail *the branch* and let the next
296
+ * round disclose the gap, rather than discarding the durable work its siblings
297
+ * finished.
298
+ *
299
+ * What bounds a branch is its Recipe's turns and wall clock, both enforced inside
300
+ * the child, both ending in a report rather than a kill. `MAX_CHUNKS_PER_BRANCH`
301
+ * is a platform backstop held unreachable by design, so the `failSubtask` below
302
+ * should never fire — if it does, a Recipe has been given more turns than the cap
303
+ * allows.
304
+ *
305
+ * Step ids are unique across rounds (SQLite assigns them), so these names need no
306
+ * round prefix.
307
+ */
308
+ async function runBranch(p, step, agent, id, push) {
309
+ try {
310
+ for (let chunk = 0; chunk < MAX_CHUNKS_PER_BRANCH; chunk++) {
311
+ // Chunk 0 keeps the plain `execute:<id>` step name so single-chunk branches
312
+ // replay identically; later chunks append `:chunk:<n>`.
313
+ const stepName = chunk === 0 ? `execute:${id}` : `execute:${id}:chunk:${chunk}`;
314
+ const done = await step.do(stepName, CHUNK_STEP, async () => {
315
+ // The DO posts any progress itself; the step returns only the verdict.
316
+ const outcome = await agent().executeSubtaskChunk(id, chunk, push);
317
+ return outcome.done;
318
+ });
319
+ if (done)
320
+ return;
321
+ }
322
+ // Unreachable while every Recipe's `maxTurns` stays under the cap: a chunk
323
+ // that yields always advanced a turn, so the budget summary comes first.
324
+ console.error("[handle-task] subtask exceeded its chunk budget", {
325
+ taskId: p.taskId,
326
+ subtaskId: id
327
+ });
328
+ await step.do(`fail:${id}`, async () => {
329
+ await agent().failSubtask(id, `execution exceeded ${MAX_CHUNKS_PER_BRANCH} chunks`);
330
+ });
331
+ }
332
+ catch (err) {
333
+ console.error("[handle-task] subtask execution exhausted retries", {
334
+ taskId: p.taskId,
335
+ subtaskId: id,
336
+ err: String(err)
337
+ });
338
+ await step.do(`fail:${id}`, async () => {
339
+ await agent().failSubtask(id, `execution exhausted retries: ${String(err)}`);
340
+ });
341
+ }
342
+ }
343
+ /**
344
+ * Persist the terminal Task, then notify the gatekeeper. A null `reply` delivers a
345
+ * `failed` Task with the policy's user-safe text; the diagnostic is already
346
+ * logged. Given a `failure`, the host's {@link HandleTaskDeps.failureCopy} may
347
+ * replace that text — same delivery, different words.
348
+ *
349
+ * `failure` is optional because only a round's own inference carries a kind. The
350
+ * other path here — a budget that ran out mid-delegation — is not a model failure
351
+ * and is deliberately not given a kind of its own until something needs to tell
352
+ * it apart.
353
+ *
354
+ * The delivery itself is {@link deliverTerminalTask}, which is shared with agents
355
+ * that never delegate. What is a round's own is the two things passed to it: the
356
+ * choice of terminal Task, and the child sweep.
357
+ */
358
+ async function deliver(p, step, agent, reply, deps, failure) {
359
+ // Resolved outside the step body so a replay cannot take a different branch
360
+ // than the write it is replaying.
361
+ const failedText = (failure && deps.failureCopy?.(failure.kind, failure.detail)) ||
362
+ deps.policy.copy.taskFailed;
363
+ await deliverTerminalTask(step, {
364
+ push: {
365
+ taskId: p.taskId,
366
+ contextId: p.contextId,
367
+ pushUrl: p.pushUrl,
368
+ pushToken: p.pushToken,
369
+ jku: p.jku
370
+ },
371
+ signingKey: deps.signingKey,
372
+ // `agent()` inside the body, never hoisted: a stub is a live connection and
373
+ // a severed one never reconnects.
374
+ saveTask: (task) => agent().saveTask(task),
375
+ terminal: () => reply !== null
376
+ ? buildCompletedTask(p.taskId, p.contextId, reply)
377
+ : buildFailedTask(p.taskId, p.contextId, failedText),
378
+ // Sweep this Task's managed children now that it is terminal and every
379
+ // `execute` step has unwound. Deleting them here — rather than right after
380
+ // each successful chunk — keeps `deleteSubAgent`'s facet-abort from landing
381
+ // on a still-open `executeChunk` RPC, which telemetry mis-records as a
382
+ // failure. Best-effort and idempotent, so it is safe on replay.
383
+ sweep: async () => {
384
+ await agent().sweepTaskChildren(p.taskId);
385
+ }
386
+ });
387
+ }
@@ -0,0 +1,127 @@
1
+ import type { ToolSet } from "ai";
2
+ import type { SessionMessage } from "agents/experimental/memory/session";
3
+ import { type CoreConfig, type CoreConfigOverrides } from "../config.js";
4
+ import { type AgentPlugin, type EnrichResultContext, type MainAgentToolContext, type ResolveRuntimeContext, type ToolFamilyBuilder, type TurnGateContext } from "../contract/plugin.js";
5
+ import type { PluginStore } from "../db/db.js";
6
+ import { type WorkspaceBacking } from "../subagent/workspace.js";
7
+ import type { RecipePolicy } from "../contract/validation.js";
8
+ import { type SubtaskTypeRegistry } from "../subtasks/subtask-types.js";
9
+ import type { RecipeExecutionResult, SubtaskRuntime } from "../subtasks/types.js";
10
+ export { buildRecipeTools, collectToolFamilies } from "./tool-families.js";
11
+ /**
12
+ * The agent runtime: everything that used to be a module-level constant,
13
+ * resolved once per Durable Object instance from the host's config and its
14
+ * installed plugins.
15
+ *
16
+ * This is the whole point of the package split. In the predecessor repo the
17
+ * subtask registry was imported at module scope and every derived value — the
18
+ * type map, the delegate tool's enum and description, the round contract, the
19
+ * known-tool-family allowlist — was computed at *import time*. That made the
20
+ * registry unoverridable, pulled every domain's module into every bundle, and
21
+ * could not read `env`, which does not exist at module scope on Workers.
22
+ *
23
+ * Build it in `onStart`:
24
+ *
25
+ * ```ts
26
+ * async onStart() {
27
+ * this.runtime = createAgentRuntime({
28
+ * config: { model: { chatModelId: "…" } },
29
+ * plugins: plugins(this.env)
30
+ * });
31
+ * }
32
+ * ```
33
+ */
34
+ export interface AgentRuntime {
35
+ config: CoreConfig;
36
+ plugins: readonly AgentPlugin[];
37
+ /** The installed subtask types — what `delegate` may name. */
38
+ types: SubtaskTypeRegistry;
39
+ /** Every tool family the installed plugins registered, by name. */
40
+ toolFamilies: ReadonlyMap<string, ToolFamilyBuilder>;
41
+ /** The capability boundary `validateRecipe` enforces. */
42
+ policy: RecipePolicy;
43
+ /** Plugin-owned stores, to hand to `new AgentDB(storage, { stores })`. */
44
+ stores: readonly PluginStore[];
45
+ /** Every binding and secret the installed plugins require of the host. */
46
+ requirements: {
47
+ secrets: string[];
48
+ bindings: string[];
49
+ };
50
+ /**
51
+ * The subagent workspace backend — the one plugin that declared it, or an
52
+ * in-memory fallback when none did. Always defined, so a host writes
53
+ * `workspaceBacking: runtime.workspaceBacking` into its `SubagentRuntime`
54
+ * unconditionally.
55
+ */
56
+ workspaceBacking: (sql: SqlStorage, name: () => string | undefined) => WorkspaceBacking;
57
+ /** The plugin that declared a subtask type, or null. */
58
+ pluginForType(type: string): AgentPlugin | null;
59
+ /** Tools the installed plugins offer the *main* agent, merged. */
60
+ mainAgentTools(ctx: MainAgentToolContext): Promise<ToolSet>;
61
+ /**
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.
66
+ */
67
+ renderCapabilities(): string;
68
+ /**
69
+ * Ask every plugin declaring {@link AgentPlugin.shouldHandleTurn} whether this
70
+ * turn should run. `true` when none declares one, and `false` if any single
71
+ * gate declines.
72
+ *
73
+ * Never rejects: a gate that fails is logged against its plugin key and
74
+ * counted as `true`, because the failure mode of a broken gate must be a noisy
75
+ * agent, never a silent one.
76
+ */
77
+ shouldHandleTurn(ctx: TurnGateContext): Promise<boolean>;
78
+ /**
79
+ * Announce the messages a compaction is folding into a summary to every plugin
80
+ * declaring {@link AgentPlugin.onMessagesDisplaced}. Pass it straight to
81
+ * `buildAgentSession`'s option of the same name — it reads no `this`, so the
82
+ * bare reference works.
83
+ *
84
+ * Never rejects: listeners are fanned out with `Promise.allSettled` and each
85
+ * rejection is logged against the plugin key that caused it.
86
+ */
87
+ onMessagesDisplaced(messages: SessionMessage[]): Promise<void>;
88
+ /**
89
+ * Resolve the session state an execution needs, by asking the plugin that owns
90
+ * its type. Returns `{}` for a type whose plugin declares no
91
+ * `resolveRuntime` — most of them.
92
+ */
93
+ resolveRuntime(ctx: ResolveRuntimeContext): Promise<SubtaskRuntime>;
94
+ /** Let the owning plugin amend a terminal result before it is persisted. */
95
+ enrichResult(ctx: EnrichResultContext, result: RecipeExecutionResult): Promise<RecipeExecutionResult>;
96
+ /** Let the owning plugin release whatever `resolveRuntime` acquired. */
97
+ onAbort(ctx: ResolveRuntimeContext): Promise<void>;
98
+ }
99
+ export interface CreateAgentRuntimeOptions {
100
+ plugins: readonly AgentPlugin[];
101
+ /**
102
+ * Required, because {@link CoreConfigOverrides} requires a model pair and core
103
+ * ships no default for it. Everything else in it stays optional.
104
+ */
105
+ config: CoreConfigOverrides;
106
+ /**
107
+ * Verify that every secret and binding the plugins declared is actually
108
+ * present, given the Worker `env`. Off by default because core cannot know
109
+ * which of a consumer's bindings are optional; pass `env` to switch it on.
110
+ *
111
+ * Typed `object`, not `Record<string, unknown>`, and that is not looseness.
112
+ * `Env` is the ambient interface `wrangler types` generates into a consumer's
113
+ * `worker-configuration.d.ts`; an interface has no index signature, so it does
114
+ * not satisfy `Record<string, unknown>` and every consumer would have to cast
115
+ * their own `this.env` to pass it. Requiring a cast to opt into a *check* is
116
+ * how the check goes unused.
117
+ */
118
+ env?: object;
119
+ }
120
+ /**
121
+ * Thrown when the installed plugins and the host disagree — a contract-version
122
+ * skew, a duplicate key, or a missing binding. Always at DO start, never mid-request.
123
+ */
124
+ export declare class RuntimeSetupError extends Error {
125
+ constructor(message: string);
126
+ }
127
+ export declare function createAgentRuntime(options: CreateAgentRuntimeOptions): AgentRuntime;