@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,540 @@
1
+ import { generateText, isStepCount } from "ai";
2
+ import { CHUNK_SOFT_MS } from "../platform.js";
3
+ import { stepAllowance } from "../agent/budget.js";
4
+ import { isTransientAiError, nonRecoverableKind } from "../agent/inference.js";
5
+ import { validateRecipe } from "../contract/validation.js";
6
+ import { renderSubagentPrompt } from "./prompt.js";
7
+ /**
8
+ * Trim the conversation to the most recent `window` turns (plus the seed message),
9
+ * cutting at an assistant boundary so no tool-result message is left orphaned.
10
+ * Older turns fall out of context — the recipe's soul directs the model to persist
11
+ * anything durable to its workspace, which the window never touches.
12
+ */
13
+ export function windowMessages(messages, window) {
14
+ if (messages.length <= 1)
15
+ return messages;
16
+ const assistantIdx = [];
17
+ for (let i = 1; i < messages.length; i++) {
18
+ if (messages[i].role === "assistant")
19
+ assistantIdx.push(i);
20
+ }
21
+ if (assistantIdx.length <= window)
22
+ return messages;
23
+ const start = assistantIdx[assistantIdx.length - window];
24
+ return [messages[0], ...messages.slice(start)];
25
+ }
26
+ /** What replaces a tool result that has aged out of the detail window. */
27
+ export const ELIDED_TOOL_OUTPUT = "[output from an earlier turn, trimmed to save context]";
28
+ /**
29
+ * Below this many serialized characters a result is not worth stubbing — the
30
+ * stub would be most of what it replaced.
31
+ */
32
+ const MIN_ELIDABLE_OUTPUT = 200;
33
+ /** Serialized size of a tool result's output, for the "worth stubbing" test. */
34
+ function outputSize(output) {
35
+ if (output.type === "text" || output.type === "error-text")
36
+ return output.value.length;
37
+ if (output.type === "execution-denied")
38
+ return (output.reason ?? "").length;
39
+ try {
40
+ return JSON.stringify(output.value).length;
41
+ }
42
+ catch {
43
+ return Number.POSITIVE_INFINITY;
44
+ }
45
+ }
46
+ /**
47
+ * Shrink the window without shortening it: stub out the *payloads* of tool
48
+ * results the model has moved past, leaving every message, every tool call — and
49
+ * therefore every note the model wrote itself — exactly where it was.
50
+ *
51
+ * This is the other half of {@link windowMessages}, and it exists because the two
52
+ * things a rolling window holds have opposite value curves. A recipe's own
53
+ * reasoning and its tool-call *inputs* stay useful for as long as the run does; a
54
+ * tool *result* is a snapshot of a world that has since moved, and it is also
55
+ * where nearly all the tokens are. Dropping whole turns to bound context throws
56
+ * both away together, which is why a short window makes a model re-derive what it
57
+ * already knew.
58
+ *
59
+ * A result's output survives if any of:
60
+ *
61
+ * 1. it is within the last `keepRecent` assistant messages — the same unit
62
+ * `windowMessages` counts in, so the two knobs are commensurable;
63
+ * 2. **it is the newest result for its tool**, at any age. Tools may hold state
64
+ * about what they have already shown this chunk and answer a repeat with
65
+ * "unchanged since you last looked" — a real optimization that becomes a lie
66
+ * the moment the render it points at is gone. Keeping the newest per tool is
67
+ * what makes "your latest view" a thing the model can still see, and it is why
68
+ * `keepRecent` can be small;
69
+ * 3. it reports a failure or a denial — short, diagnostic, and losing *why* a
70
+ * call failed costs a retry to rediscover for no saving;
71
+ * 4. it is already small enough that stubbing it saves nothing.
72
+ *
73
+ * Idempotent: a stubbed part is small, so a later pass leaves it alone.
74
+ *
75
+ * `keepRecent` is normalized rather than trusted — see the note on `keep` below.
76
+ * Zero is a legal, meaningful value: no turn is recent, so rule 1 protects
77
+ * nothing and rules 2-4 carry the whole of what survives.
78
+ */
79
+ export function elideToolOutputs(messages, keepRecent) {
80
+ /**
81
+ * Rule 1's width, normalized to a non-negative integer.
82
+ *
83
+ * Not defensive habit: `keepRecent` is a config value reaching a function whose
84
+ * arithmetic indexes an array with it, and every out-of-contract value used to
85
+ * land on the *same* wrong branch. `assistantIdx[len - 0]` is `undefined`, as is
86
+ * any negative or `NaN` index, and `i >= undefined` is false for every `i` — so
87
+ * `cutoff` silently meant "no turn is recent" instead of throwing or clamping.
88
+ * That happens to be right for 0 and wrong for everything else, which is the
89
+ * worst way for a guard to fail: correct until the day someone passes -1.
90
+ *
91
+ * So 0 now says it explicitly (see `cutoff`), and nothing else can reach it by
92
+ * accident. `NaN` is the one value with no reading at all, and it takes the
93
+ * conservative floor — still bounded by rules 2-4, which keep the newest result
94
+ * per tool, every failure, and everything already small. Both infinities keep
95
+ * the reading they plainly have: an unboundedly wide window keeps everything, a
96
+ * negative one keeps nothing.
97
+ */
98
+ const keep = Number.isNaN(keepRecent)
99
+ ? 0
100
+ : Math.max(0, Math.trunc(keepRecent));
101
+ // Where the detail window starts, counted in assistant messages so it lines up
102
+ // with `historyWindow`. Everything before it is a candidate for elision, so
103
+ // `messages.length` is the honest spelling of an empty window and 0 the honest
104
+ // spelling of a window covering everything (fewer assistant messages than the
105
+ // window ⇒ keep all).
106
+ const assistantIdx = [];
107
+ for (const [i, m] of messages.entries()) {
108
+ if (m.role === "assistant")
109
+ assistantIdx.push(i);
110
+ }
111
+ const cutoff = keep === 0
112
+ ? messages.length
113
+ : assistantIdx.length <= keep
114
+ ? 0
115
+ : assistantIdx[assistantIdx.length - keep];
116
+ if (cutoff === 0)
117
+ return messages;
118
+ // The newest result per tool, so rule 2 can be a lookup rather than a scan.
119
+ const newestPerTool = new Map();
120
+ for (const [i, message] of messages.entries()) {
121
+ if (message.role !== "tool")
122
+ continue;
123
+ for (const part of message.content) {
124
+ if (part.type === "tool-result")
125
+ newestPerTool.set(part.toolName, i);
126
+ }
127
+ }
128
+ let changed = false;
129
+ const out = messages.map((message, i) => {
130
+ if (message.role !== "tool" || i >= cutoff)
131
+ return message;
132
+ let messageChanged = false;
133
+ const content = message.content.map((part) => {
134
+ if (part.type !== "tool-result")
135
+ return part;
136
+ if (newestPerTool.get(part.toolName) === i)
137
+ return part;
138
+ if (part.output.type === "error-text" ||
139
+ part.output.type === "error-json" ||
140
+ part.output.type === "execution-denied")
141
+ return part;
142
+ if (outputSize(part.output) < MIN_ELIDABLE_OUTPUT)
143
+ return part;
144
+ messageChanged = true;
145
+ return {
146
+ ...part,
147
+ output: { type: "text", value: ELIDED_TOOL_OUTPUT }
148
+ };
149
+ });
150
+ if (!messageChanged)
151
+ return message;
152
+ changed = true;
153
+ return { ...message, content };
154
+ });
155
+ return changed ? out : messages;
156
+ }
157
+ /** Human-readable elapsed time for the metrics footer. */
158
+ function formatDuration(ms) {
159
+ const total = Math.round(ms / 1000);
160
+ const h = Math.floor(total / 3600);
161
+ const m = Math.floor((total % 3600) / 60);
162
+ const s = total % 60;
163
+ if (h > 0)
164
+ return `${h}h ${m}m ${s}s`;
165
+ if (m > 0)
166
+ return `${m}m ${s}s`;
167
+ return `${s}s`;
168
+ }
169
+ function metricsFooter(state, now) {
170
+ return (`\n\n---\nRan ${state.turns} model turn(s) across ${state.llmCalls} model ` +
171
+ `call(s) in ${formatDuration(now - state.startedAtMs)}.`);
172
+ }
173
+ /**
174
+ * Has this execution spent its whole-run budget? Turns and wall-clock are one
175
+ * predicate because they mean the same thing to the runner — the run is over and
176
+ * owes a report — and because checking only turns is what let a slow-turning
177
+ * recipe run for hours while its turn counter looked healthy.
178
+ *
179
+ * `startedAtMs` rides in the checkpoint, so the deadline survives chunk
180
+ * boundaries, step retries and isolate restarts without any storage of its own.
181
+ */
182
+ function budgetSpent(state, deps) {
183
+ return (state.turns >= deps.limits.maxTurns ||
184
+ deps.now() - state.startedAtMs >= deps.limits.maxWallMs);
185
+ }
186
+ function completed(state, deps, text, modelId) {
187
+ const finalText = deps.reportMetrics
188
+ ? text + metricsFooter(state, deps.now())
189
+ : text;
190
+ return {
191
+ outcome: {
192
+ done: true,
193
+ result: {
194
+ status: "completed",
195
+ resultParts: [{ kind: "text", text: finalText }],
196
+ modelId
197
+ },
198
+ progress: deps.progress
199
+ },
200
+ state
201
+ };
202
+ }
203
+ /**
204
+ * Fail a chunk on an error no second attempt can clear.
205
+ *
206
+ * Terminal for this subtask, and deliberately *not* a throw: a throw is retried
207
+ * by the Workflow step, which is exactly the spend this classification exists to
208
+ * avoid. The parent round then hits the same condition on its own inference and
209
+ * fails carrying the kind, which is where an operator-facing message gets
210
+ * attached — a subagent has no channel of its own to say "a human must fix
211
+ * this", only this row's `error` string.
212
+ */
213
+ function nonRecoverableOutcome(state, deps, modelId, kind, diagnostic) {
214
+ console.error("[recipe-runner] non-recoverable model failure", {
215
+ model: modelId,
216
+ kind,
217
+ diagnostic
218
+ });
219
+ return {
220
+ outcome: {
221
+ done: true,
222
+ result: {
223
+ status: "failed",
224
+ error: `${kind}: ${diagnostic}`,
225
+ modelId
226
+ },
227
+ progress: deps.progress
228
+ },
229
+ state
230
+ };
231
+ }
232
+ /**
233
+ * Run one durable chunk. Returns a terminal result (natural completion, budget
234
+ * exhaustion, or exhausted models) or a `done: false` yield with the progress
235
+ * emitted this chunk. Throws only on a transient platform fault, so the Workflow
236
+ * step retries and resumes from the last checkpoint.
237
+ */
238
+ export async function runResumableChunk(prev, deps) {
239
+ const state = prev ?? {
240
+ messages: [{ role: "user", content: deps.seedPrompt }],
241
+ turns: 0,
242
+ llmCalls: 0,
243
+ startedAtMs: deps.now()
244
+ };
245
+ /**
246
+ * Hand the chunk back with no terminal result. An abort takes this exit too —
247
+ * that is the whole point: nothing terminal is produced, so the facet caches
248
+ * nothing and no bogus failure can replay on a later retry.
249
+ */
250
+ const yielded = () => ({
251
+ outcome: { done: false, progress: deps.progress },
252
+ state
253
+ });
254
+ // Already canceled before this chunk started: don't call a model at all.
255
+ if (deps.abortSignal?.aborted)
256
+ return yielded();
257
+ // The durable enforcement point for the whole-run budget: it reads persisted
258
+ // state before any model call, so every chunk re-checks it however the previous
259
+ // one ended. It also covers the retry that resumes from a checkpoint taken on
260
+ // the final allowed turn before the chunk returned — e.g. summarizeBudget's own
261
+ // call threw a transient fault and the Workflow step retried. The budget is
262
+ // already spent, so summarize now instead of running another unbudgeted,
263
+ // side-effecting turn (which `stopWhen`'s `Math.max(1, …)` would otherwise force).
264
+ if (budgetSpent(state, deps)) {
265
+ return summarizeBudget(state, deps);
266
+ }
267
+ const chunkStartMs = deps.now();
268
+ const onStepEnd = async (step) => {
269
+ state.turns += 1;
270
+ // Window first (drops whole turns at an assistant boundary), then elide what
271
+ // survived. Both before the checkpoint, so the durable `run_state` row
272
+ // shrinks with the context rather than tracking the untrimmed run.
273
+ state.messages = elideToolOutputs(windowMessages([...state.messages, ...step.response.messages], deps.historyWindow), deps.toolOutputWindow);
274
+ await deps.checkpoint(state);
275
+ };
276
+ /**
277
+ * Four boundaries, and only the first two are budgets. A chunk ends on
278
+ * whichever comes first; the run ends only on a budget.
279
+ *
280
+ * Rebuilt per attempt rather than once per chunk, and that is the whole point:
281
+ * `isStepCount` counts within one `generateText` call, so a `stopWhen` shared
282
+ * with the fallback would hand it the turn allowance the primary already spent.
283
+ * `onStepEnd` has moved `state.turns` by then, so recomputing here charges the
284
+ * fallback for what the run has actually used.
285
+ */
286
+ const boundaries = () => [
287
+ // The turn budget — all of what is left of it. There is deliberately no
288
+ // per-chunk turn allowance: a turn count cannot bound a step's *duration*,
289
+ // which is the only thing the step timeout cares about, so the wall-clock
290
+ // predicate below owns that job alone.
291
+ isStepCount(stepAllowance(deps.limits.maxTurns, state.turns)),
292
+ // The run-wide deadline. Without it the entry guard would only observe the
293
+ // deadline at the next chunk boundary, up to `chunkSoftMs` past it.
294
+ () => deps.now() - state.startedAtMs >= deps.limits.maxWallMs,
295
+ // Not a budget: the configured step timeout, which needs this chunk to
296
+ // checkpoint and hand back a fresh step before it trips. Soft, and only
297
+ // checked here between turns — the turn already in flight when it trips still
298
+ // runs to completion, which is why `STEP_TIMEOUT_MS - CHUNK_SOFT_MS` is sized
299
+ // to cover a whole turn rather than a nominal moment. See `platform.ts`.
300
+ () => deps.now() - chunkStartMs >= deps.chunkSoftMs,
301
+ // Not a budget either: publish progress to the user promptly.
302
+ () => deps.progress.length > 0
303
+ ];
304
+ const attempt = async (model, modelId) => {
305
+ state.llmCalls += 1;
306
+ let result;
307
+ try {
308
+ result = await generateText({
309
+ model: model(),
310
+ instructions: deps.system,
311
+ messages: state.messages,
312
+ tools: deps.tools,
313
+ stopWhen: boundaries(),
314
+ maxOutputTokens: deps.maxOutputTokens,
315
+ // Not a duplicate of the fallback: the fallback answers "this model
316
+ // cannot do it", and a 429 says "not yet". See `ModelConfig.maxRetries`.
317
+ maxRetries: deps.maxRetries,
318
+ abortSignal: deps.abortSignal,
319
+ onStepEnd
320
+ });
321
+ }
322
+ catch (error) {
323
+ // Check the signal before the error: an abort surfaces as a rejection, and
324
+ // reading it as bad model output would spend the fallback and cache a
325
+ // failure for work that was cancelled on purpose.
326
+ if (deps.abortSignal?.aborted)
327
+ return { kind: "aborted" };
328
+ return { kind: "failed", diagnostic: String(error), error, modelId };
329
+ }
330
+ if (deps.abortSignal?.aborted)
331
+ return { kind: "aborted" };
332
+ if (result.finishReason === "length") {
333
+ // Its own warning, not just a diagnostic string: hitting the output ceiling
334
+ // is a tuning signal about `config.model.maxOutputTokens`, distinct from the
335
+ // model producing bad output, and the two are indistinguishable once folded
336
+ // into the "recipe exhausted" message.
337
+ console.warn("[recipe-runner] model output truncated", {
338
+ model: modelId,
339
+ maxOutputTokens: deps.maxOutputTokens
340
+ });
341
+ return {
342
+ kind: "failed",
343
+ diagnostic: "truncated (finish_reason=length)",
344
+ modelId
345
+ };
346
+ }
347
+ if (result.finishReason === "stop") {
348
+ const text = result.text.trim();
349
+ return text === ""
350
+ ? { kind: "failed", diagnostic: "empty final reply", modelId }
351
+ : { kind: "completed", text, modelId };
352
+ }
353
+ // Not a final answer (e.g. finish_reason=tool-calls): a stop condition fired
354
+ // mid-loop — the chunk yielded a durable boundary with more work to do.
355
+ return { kind: "yield" };
356
+ };
357
+ let a = await attempt(deps.models.primary, deps.models.primaryId());
358
+ if (a.kind === "aborted")
359
+ return yielded();
360
+ if (a.kind === "failed") {
361
+ // Checked before the fallback, not after: the second slot would present the
362
+ // same rejected credential. Returned rather than thrown — a throw here is
363
+ // retried by the Workflow step, which is the other cost this avoids. The
364
+ // chunk fails, and the parent's next round classifies it properly.
365
+ const blocked = nonRecoverableKind(a.error);
366
+ if (blocked) {
367
+ return nonRecoverableOutcome(state, deps, a.modelId, blocked, a.diagnostic);
368
+ }
369
+ console.warn("[recipe-runner] primary attempt failed, trying fallback", {
370
+ model: a.modelId,
371
+ diagnostic: a.diagnostic
372
+ });
373
+ const primaryFailure = a;
374
+ a = await attempt(deps.models.fallback, deps.models.fallbackId());
375
+ if (a.kind === "aborted")
376
+ return yielded();
377
+ if (a.kind === "failed") {
378
+ // Both attempts failed. A transient fault anywhere means a retry could
379
+ // succeed — throw it for the Workflow step (most recent first).
380
+ for (const failed of [a, primaryFailure]) {
381
+ if (failed.error !== undefined && isTransientAiError(failed.error)) {
382
+ throw failed.error;
383
+ }
384
+ }
385
+ return {
386
+ outcome: {
387
+ done: true,
388
+ result: {
389
+ status: "failed",
390
+ error: `recipe exhausted: primary (${primaryFailure.modelId}): ` +
391
+ `${primaryFailure.diagnostic}; fallback (${a.modelId}): ${a.diagnostic}`,
392
+ modelId: a.modelId
393
+ },
394
+ progress: deps.progress
395
+ },
396
+ state
397
+ };
398
+ }
399
+ }
400
+ if (a.kind === "completed")
401
+ return completed(state, deps, a.text, a.modelId);
402
+ // The chunk yielded. If the run budget is spent, force a final summary so the
403
+ // run still returns useful output; otherwise ask the Workflow for another chunk.
404
+ if (budgetSpent(state, deps)) {
405
+ return summarizeBudget(state, deps);
406
+ }
407
+ return yielded();
408
+ }
409
+ /**
410
+ * The run budget — turns or wall-clock — is exhausted mid-loop: run one final
411
+ * no-tools call asking the model to produce its answer/report from the work so
412
+ * far. Primary → fallback, same transient/deterministic split. This is what makes
413
+ * "uncapped but bounded" safe — the ceiling yields a report instead of a dropped
414
+ * run.
415
+ *
416
+ * The message deliberately does not name *which* budget ran out. The model can
417
+ * do nothing differently either way, and the one instruction that matters —
418
+ * report now, take no more actions — is the same.
419
+ */
420
+ async function summarizeBudget(state, deps) {
421
+ const messages = [
422
+ ...state.messages,
423
+ {
424
+ role: "user",
425
+ content: "You have reached your execution budget and can take no more actions. " +
426
+ "Write your final answer or report now, based on the work so far."
427
+ }
428
+ ];
429
+ const summarize = async (model, modelId) => {
430
+ state.llmCalls += 1;
431
+ let result;
432
+ try {
433
+ result = await generateText({
434
+ model: model(),
435
+ instructions: deps.system,
436
+ messages,
437
+ stopWhen: isStepCount(1),
438
+ maxOutputTokens: deps.maxOutputTokens,
439
+ // Retries on this model, honouring `retry-after`, before the fallback.
440
+ // See `ModelConfig.maxRetries`.
441
+ maxRetries: deps.maxRetries,
442
+ abortSignal: deps.abortSignal
443
+ });
444
+ }
445
+ catch (error) {
446
+ if (deps.abortSignal?.aborted)
447
+ return { kind: "aborted" };
448
+ return { kind: "failed", diagnostic: String(error), error, modelId };
449
+ }
450
+ if (deps.abortSignal?.aborted)
451
+ return { kind: "aborted" };
452
+ const text = result.text.trim();
453
+ return text === ""
454
+ ? { kind: "failed", diagnostic: "empty summary", modelId }
455
+ : { kind: "completed", text, modelId };
456
+ };
457
+ // An abort yields with no terminal result, exactly as in the main loop: the
458
+ // budget summary is output, and cancelled work publishes none.
459
+ const yielded = () => ({
460
+ outcome: { done: false, progress: deps.progress },
461
+ state
462
+ });
463
+ let a = await summarize(deps.models.primary, deps.models.primaryId());
464
+ if (a.kind === "aborted")
465
+ return yielded();
466
+ if (a.kind === "failed") {
467
+ // Same rule as the work loop: no fallback on a credential the API already
468
+ // rejected. A summary is the cheapest call in the run, but it is not free.
469
+ const blocked = nonRecoverableKind(a.error);
470
+ if (blocked) {
471
+ return nonRecoverableOutcome(state, deps, a.modelId, blocked, a.diagnostic);
472
+ }
473
+ const primaryFailure = a;
474
+ a = await summarize(deps.models.fallback, deps.models.fallbackId());
475
+ if (a.kind === "aborted")
476
+ return yielded();
477
+ if (a.kind === "failed") {
478
+ for (const failed of [a, primaryFailure]) {
479
+ if (failed.error !== undefined && isTransientAiError(failed.error)) {
480
+ throw failed.error;
481
+ }
482
+ }
483
+ // Even the summary failed: return a plain budget-exhausted notice.
484
+ const text = "Reached the execution budget without producing a final report.";
485
+ return completed(state, deps, text, a.modelId);
486
+ }
487
+ }
488
+ return a.kind === "completed"
489
+ ? completed(state, deps, a.text, a.modelId)
490
+ : completed(state, deps, "Reached the execution budget.", deps.models.fallbackId());
491
+ }
492
+ /**
493
+ * Run one recipe execution to a terminal result, driving {@link runResumableChunk}
494
+ * chunk by chunk in memory. A run that fits its budget finishes in one chunk;
495
+ * otherwise it loops until the budget yields a summary. Used by tests and any
496
+ * caller wanting the whole outcome; the facet drives chunks durably instead, for
497
+ * crash-safety across the Workflow.
498
+ *
499
+ * Throws only on a transient platform fault (as {@link runResumableChunk} does).
500
+ */
501
+ export async function runRecipeExecution(request, deps) {
502
+ if (request.prompt.trim() === "") {
503
+ return { status: "failed", error: "empty subtask prompt", modelId: null };
504
+ }
505
+ const recipe = validateRecipe(request.recipe, deps.policy);
506
+ const { system, prompt } = renderSubagentPrompt({ ...request, recipe });
507
+ const now = deps.now ?? Date.now;
508
+ let state = null;
509
+ // A chunk always advances ≥1 turn unless it completes, so `maxTurns` chunks is
510
+ // the ceiling and this can never spin. Turn-derived on purpose: `maxWallMs` and
511
+ // `chunkSoftMs` only ever end a run *sooner*, so neither can loosen the bound.
512
+ const maxChunks = recipe.limits.maxTurns + 2;
513
+ for (let chunk = 0; chunk < maxChunks; chunk++) {
514
+ const chunkDeps = {
515
+ system,
516
+ seedPrompt: prompt,
517
+ models: deps.models,
518
+ tools: deps.tools,
519
+ limits: recipe.limits,
520
+ chunkSoftMs: CHUNK_SOFT_MS,
521
+ historyWindow: recipe.historyWindow,
522
+ toolOutputWindow: deps.toolOutputWindow,
523
+ reportMetrics: recipe.reportMetrics,
524
+ maxOutputTokens: deps.maxOutputTokens,
525
+ maxRetries: deps.maxRetries,
526
+ now,
527
+ progress: [],
528
+ checkpoint: () => { }
529
+ };
530
+ const { outcome, state: next } = await runResumableChunk(state, chunkDeps);
531
+ if (outcome.done)
532
+ return outcome.result;
533
+ state = next;
534
+ }
535
+ return {
536
+ status: "failed",
537
+ error: `recipe did not terminate within ${maxChunks} chunks`,
538
+ modelId: null
539
+ };
540
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The narrow file-store surface the resumable runner and its tool families use.
3
+ *
4
+ * Core declares the *interface* and the caps; it does not declare a backend.
5
+ * The predecessor backed this with `@cloudflare/shell`, which is experimental
6
+ * ("expect breaking changes") — so an agent that never delegates file work should
7
+ * not carry it. `@dynamicagents/plugins/workspace` supplies the shell-backed
8
+ * implementation and the model-facing `ws_read`/`ws_write`/`ws_list` tools; core
9
+ * only needs to be able to *name* a workspace, because {@link ToolFamilyContext}
10
+ * hands one to every tool family.
11
+ */
12
+ export interface WorkspaceHandle {
13
+ /** File content, or null if the file does not exist. */
14
+ read(path: string): Promise<string | null>;
15
+ /** Write (create or overwrite) a text file. Parent directories are created. */
16
+ write(path: string, content: string): Promise<void>;
17
+ /** Whether a file or directory exists at the path. */
18
+ exists(path: string): Promise<boolean>;
19
+ /** Delete a file. Returns whether a file was removed. */
20
+ remove(path: string): Promise<boolean>;
21
+ /** Immediate entries under `dir` (default root): their path and byte size. */
22
+ list(dir?: string): Promise<WorkspaceEntry[]>;
23
+ /** Parse a JSON file, or null if it does not exist. Throws on malformed JSON. */
24
+ readJson<T>(path: string): Promise<T | null>;
25
+ /** Serialize a value to a JSON file (pretty-printed). */
26
+ writeJson(path: string, value: unknown): Promise<void>;
27
+ }
28
+ export interface WorkspaceEntry {
29
+ path: string;
30
+ type: "file" | "directory" | "symlink";
31
+ size: number;
32
+ }
33
+ /**
34
+ * The backend surface {@link makeWorkspaceHandle} needs, declared **structurally**
35
+ * rather than as `Pick<Workspace, …>`.
36
+ *
37
+ * That is the whole reason core can stay free of `@cloudflare/shell`: a shell
38
+ * `Workspace` satisfies this by construction, and so does a plain in-memory fake
39
+ * in a test, without either being named here.
40
+ */
41
+ export interface WorkspaceBacking {
42
+ readFile(path: string): Promise<string | null>;
43
+ writeFile(path: string, content: string): Promise<void>;
44
+ exists(path: string): Promise<boolean>;
45
+ deleteFile(path: string): Promise<boolean>;
46
+ readDir(dir?: string): Promise<readonly WorkspaceEntry[]>;
47
+ getWorkspaceInfo(): Promise<{
48
+ fileCount: number;
49
+ }>;
50
+ }
51
+ /**
52
+ * An in-memory {@link WorkspaceBacking} — the fallback when no installed plugin
53
+ * declares one.
54
+ *
55
+ * Deliberately **not durable**, and that is the honest behaviour rather than a
56
+ * shortcut: a durable stand-in would have to invent a storage layout that a real
57
+ * backend would then have to migrate away from. An agent that delegates file
58
+ * work installs a backend; one that does not never writes a file, and pays
59
+ * nothing for the option. What this buys is that `createAgentRuntime` composes
60
+ * without a workspace plugin at all, so `SubagentRuntime.workspaceBacking` can
61
+ * stay required and a host never writes a null check.
62
+ *
63
+ * Scoped per call, so each execution gets its own map, exactly as a facet's own
64
+ * SQLite would give it its own tables. Contents are lost on isolate eviction —
65
+ * the resumable runner treats a lost workspace as a resumable state everywhere
66
+ * it matters.
67
+ *
68
+ * Signature matches {@link AgentPlugin.workspaceBacking}; both arguments are
69
+ * ignored.
70
+ */
71
+ export declare function memoryWorkspaceBacking(_sql?: SqlStorage, _name?: () => string | undefined): WorkspaceBacking;
72
+ /** Per-file byte ceiling — safely under the 2 MB Durable Object SQLite row limit. */
73
+ export declare const WORKSPACE_MAX_FILE_BYTES: number;
74
+ /** Max number of files in one workspace — a cheap guard against runaway writes. */
75
+ export declare const WORKSPACE_MAX_FILES = 200;
76
+ export declare class WorkspaceLimitError extends Error {
77
+ constructor(message: string);
78
+ }
79
+ /**
80
+ * Build a {@link WorkspaceHandle} over a shell workspace (or a test fake),
81
+ * enforcing the per-file and file-count caps. The caps degrade a misbehaving
82
+ * recipe to an explicit error rather than letting it exceed the DO row limit or
83
+ * fill storage.
84
+ */
85
+ export declare function makeWorkspaceHandle(ws: WorkspaceBacking): WorkspaceHandle;