@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.
- package/LICENSE +201 -0
- package/README.md +522 -0
- package/dist/a2a/agent-stub.d.ts +60 -0
- package/dist/a2a/agent-stub.js +1 -0
- package/dist/a2a/caller-token.d.ts +44 -0
- package/dist/a2a/caller-token.js +61 -0
- package/dist/a2a/caller.d.ts +23 -0
- package/dist/a2a/caller.js +33 -0
- package/dist/a2a/card.d.ts +158 -0
- package/dist/a2a/card.js +163 -0
- package/dist/a2a/context.d.ts +19 -0
- package/dist/a2a/context.js +61 -0
- package/dist/a2a/deliver.d.ts +159 -0
- package/dist/a2a/deliver.js +185 -0
- package/dist/a2a/executor.d.ts +84 -0
- package/dist/a2a/executor.js +106 -0
- package/dist/a2a/index.d.ts +35 -0
- package/dist/a2a/index.js +34 -0
- package/dist/a2a/notify.d.ts +106 -0
- package/dist/a2a/notify.js +180 -0
- package/dist/a2a/parts.d.ts +45 -0
- package/dist/a2a/parts.js +94 -0
- package/dist/a2a/push.d.ts +70 -0
- package/dist/a2a/push.js +53 -0
- package/dist/a2a/self-origin.d.ts +91 -0
- package/dist/a2a/self-origin.js +114 -0
- package/dist/a2a/task-store.d.ts +44 -0
- package/dist/a2a/task-store.js +99 -0
- package/dist/a2a/task.d.ts +85 -0
- package/dist/a2a/task.js +15 -0
- package/dist/a2a/verify.d.ts +80 -0
- package/dist/a2a/verify.js +143 -0
- package/dist/agent/budget.d.ts +46 -0
- package/dist/agent/budget.js +42 -0
- package/dist/agent/control.d.ts +109 -0
- package/dist/agent/control.js +115 -0
- package/dist/agent/errors.d.ts +85 -0
- package/dist/agent/errors.js +64 -0
- package/dist/agent/final-reply.d.ts +49 -0
- package/dist/agent/final-reply.js +68 -0
- package/dist/agent/history.d.ts +97 -0
- package/dist/agent/history.js +133 -0
- package/dist/agent/index.d.ts +29 -0
- package/dist/agent/index.js +29 -0
- package/dist/agent/inference.d.ts +110 -0
- package/dist/agent/inference.js +120 -0
- package/dist/agent/model.d.ts +90 -0
- package/dist/agent/model.js +1 -0
- package/dist/agent/session.d.ts +100 -0
- package/dist/agent/session.js +82 -0
- package/dist/agent/workers-ai/index.d.ts +23 -0
- package/dist/agent/workers-ai/index.js +23 -0
- package/dist/agent/workers-ai/runtime.d.ts +42 -0
- package/dist/agent/workers-ai/runtime.js +63 -0
- package/dist/alarm/index.d.ts +77 -0
- package/dist/alarm/index.js +116 -0
- package/dist/config.d.ts +202 -0
- package/dist/config.js +135 -0
- package/dist/contract/index.d.ts +9 -0
- package/dist/contract/index.js +8 -0
- package/dist/contract/plugin.d.ts +324 -0
- package/dist/contract/plugin.js +114 -0
- package/dist/contract/recipe.d.ts +180 -0
- package/dist/contract/recipe.js +1 -0
- package/dist/contract/validation.d.ts +91 -0
- package/dist/contract/validation.js +84 -0
- package/dist/db/db.d.ts +147 -0
- package/dist/db/db.js +90 -0
- package/dist/db/index.d.ts +8 -0
- package/dist/db/index.js +8 -0
- package/dist/db/migrations/index.d.ts +20 -0
- package/dist/db/migrations/index.js +60 -0
- package/dist/db/models/subtasks.d.ts +100 -0
- package/dist/db/models/subtasks.js +241 -0
- package/dist/db/models/tasks.d.ts +118 -0
- package/dist/db/models/tasks.js +274 -0
- package/dist/db/schema.d.ts +468 -0
- package/dist/db/schema.js +88 -0
- package/dist/env.d.ts +53 -0
- package/dist/env.js +47 -0
- package/dist/host/agent.d.ts +305 -0
- package/dist/host/agent.js +400 -0
- package/dist/host/index.d.ts +20 -0
- package/dist/host/index.js +19 -0
- package/dist/host/plugin-host.d.ts +42 -0
- package/dist/host/plugin-host.js +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +17 -0
- package/dist/job/index.d.ts +21 -0
- package/dist/job/index.js +21 -0
- package/dist/job/lifecycle.d.ts +176 -0
- package/dist/job/lifecycle.js +230 -0
- package/dist/job/state.d.ts +92 -0
- package/dist/job/state.js +40 -0
- package/dist/platform.d.ts +138 -0
- package/dist/platform.js +140 -0
- package/dist/round/agent.d.ts +271 -0
- package/dist/round/agent.js +678 -0
- package/dist/round/index.d.ts +25 -0
- package/dist/round/index.js +23 -0
- package/dist/round/policy.d.ts +98 -0
- package/dist/round/policy.js +1 -0
- package/dist/round/subagent.d.ts +87 -0
- package/dist/round/subagent.js +108 -0
- package/dist/round/turn.d.ts +249 -0
- package/dist/round/turn.js +564 -0
- package/dist/round/workflow.d.ts +147 -0
- package/dist/round/workflow.js +387 -0
- package/dist/runtime/index.d.ts +127 -0
- package/dist/runtime/index.js +186 -0
- package/dist/runtime/tool-families.d.ts +32 -0
- package/dist/runtime/tool-families.js +61 -0
- package/dist/subagent/fingerprint.d.ts +37 -0
- package/dist/subagent/fingerprint.js +92 -0
- package/dist/subagent/index.d.ts +169 -0
- package/dist/subagent/index.js +330 -0
- package/dist/subagent/prompt.d.ts +32 -0
- package/dist/subagent/prompt.js +37 -0
- package/dist/subagent/run.d.ts +157 -0
- package/dist/subagent/run.js +540 -0
- package/dist/subagent/workspace.d.ts +85 -0
- package/dist/subagent/workspace.js +127 -0
- package/dist/subtasks/catalog.d.ts +40 -0
- package/dist/subtasks/catalog.js +36 -0
- package/dist/subtasks/decomposition.d.ts +85 -0
- package/dist/subtasks/decomposition.js +156 -0
- package/dist/subtasks/delegate.d.ts +120 -0
- package/dist/subtasks/delegate.js +131 -0
- package/dist/subtasks/index.d.ts +9 -0
- package/dist/subtasks/index.js +9 -0
- package/dist/subtasks/subtask-types.d.ts +91 -0
- package/dist/subtasks/subtask-types.js +103 -0
- package/dist/subtasks/types.d.ts +295 -0
- package/dist/subtasks/types.js +15 -0
- package/dist/testing/auth.d.ts +34 -0
- package/dist/testing/auth.js +35 -0
- package/dist/testing/do.d.ts +29 -0
- package/dist/testing/do.js +25 -0
- package/dist/testing/fake-session.d.ts +26 -0
- package/dist/testing/fake-session.js +37 -0
- package/dist/testing/fixtures.d.ts +64 -0
- package/dist/testing/fixtures.js +104 -0
- package/dist/testing/harness.d.ts +97 -0
- package/dist/testing/harness.js +138 -0
- package/dist/testing/index.d.ts +31 -0
- package/dist/testing/index.js +35 -0
- package/dist/testing/mock-model.d.ts +77 -0
- package/dist/testing/mock-model.js +136 -0
- package/dist/testing/node.d.ts +56 -0
- package/dist/testing/node.js +56 -0
- package/dist/testing/vcr-global-setup.d.ts +12 -0
- package/dist/testing/vcr-global-setup.js +15 -0
- package/dist/testing/vcr-shared.d.ts +38 -0
- package/dist/testing/vcr-shared.js +33 -0
- package/dist/testing/vcr-spec.d.ts +25 -0
- package/dist/testing/vcr-spec.js +124 -0
- package/dist/testing/vcr-store.d.ts +86 -0
- package/dist/testing/vcr-store.js +191 -0
- package/dist/testing/vcr.d.ts +117 -0
- package/dist/testing/vcr.js +275 -0
- package/dist/worker/define-agent.d.ts +123 -0
- package/dist/worker/define-agent.js +20 -0
- package/dist/worker/index.d.ts +218 -0
- package/dist/worker/index.js +369 -0
- package/eslint-rules/index.js +31 -0
- package/eslint-rules/no-deprecated-object-properties.js +81 -0
- package/package.json +178 -0
- 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;
|