@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,564 @@
|
|
|
1
|
+
import { generateText, hasToolCall, isStepCount } from "ai";
|
|
2
|
+
import { appendOnce } from "../agent/session.js";
|
|
3
|
+
import { deterministicSessionMessage, finalReplyMessageId, parseRoundAckMessageId, roundAckMessageId, sessionText, taskUserMessageId } from "../agent/history.js";
|
|
4
|
+
import { buildIntermediateContentHandler, isTransientAiError, nonRecoverableKind } from "../agent/inference.js";
|
|
5
|
+
import { controlTools, controlToolSet } from "../agent/control.js";
|
|
6
|
+
import { FINAL_REPLY_TOOL_NAME } from "../agent/final-reply.js";
|
|
7
|
+
import { stepAllowance } from "../agent/budget.js";
|
|
8
|
+
import { DELEGATE_TOOL_NAME, delegateCallInput, delegateCallOutput, delegateToolCallId } from "../subtasks/delegate.js";
|
|
9
|
+
import { isCatalogEligible } from "../subtasks/catalog.js";
|
|
10
|
+
/**
|
|
11
|
+
* Build both prompt suffixes for one configured agent.
|
|
12
|
+
*
|
|
13
|
+
* The agent's contract, then whatever the delegable types have to say about being
|
|
14
|
+
* delegated — each declared by the type that owns it (`SubtaskTypeSpec`) and
|
|
15
|
+
* collected by the runtime's registry, so no domain is named by the policy. That
|
|
16
|
+
* is the rule the two prompt fields on a subtask type exist to hold: everything
|
|
17
|
+
* the main agent is told about a domain is declared by the plugin that owns it,
|
|
18
|
+
* never written inside the loop.
|
|
19
|
+
*/
|
|
20
|
+
export function buildTurnInstructions(policy, types, maxSubtasks, limits) {
|
|
21
|
+
const guidance = types.renderDelegationGuidance({
|
|
22
|
+
delegateTool: DELEGATE_TOOL_NAME,
|
|
23
|
+
finalReplyTool: FINAL_REPLY_TOOL_NAME
|
|
24
|
+
});
|
|
25
|
+
const open = policy.roundContract({ typeKeys: types.keys, maxSubtasks }) +
|
|
26
|
+
(guidance ? `\n\n${guidance}` : "");
|
|
27
|
+
return { open, final: open + policy.finalRoundNote(limits) };
|
|
28
|
+
}
|
|
29
|
+
/** Join one branch's parts into its text block. */
|
|
30
|
+
function branchText(branch) {
|
|
31
|
+
return (branch.resultParts ?? []).map((p) => p.text).join("\n");
|
|
32
|
+
}
|
|
33
|
+
/** Group every branch by the round that delegated it, preserving ordinal order. */
|
|
34
|
+
function byRound(branches) {
|
|
35
|
+
const rounds = new Map();
|
|
36
|
+
for (const branch of branches) {
|
|
37
|
+
const existing = rounds.get(branch.round);
|
|
38
|
+
if (existing)
|
|
39
|
+
existing.push(branch);
|
|
40
|
+
else
|
|
41
|
+
rounds.set(branch.round, [branch]);
|
|
42
|
+
}
|
|
43
|
+
return rounds;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Rebuild one round's `delegate` call and pair it with its result.
|
|
47
|
+
*
|
|
48
|
+
* Both halves are real. That round's model genuinely emitted this call; the
|
|
49
|
+
* subagents genuinely produced these outcomes. All that separates them is a
|
|
50
|
+
* Workflow boundary and, often, hours — so the pair is reconstructed here rather
|
|
51
|
+
* than carried, from the durable rows that are the record of what happened.
|
|
52
|
+
*
|
|
53
|
+
* Failed and skipped branches are included so the model can disclose them rather
|
|
54
|
+
* than quietly answering as if the work had been done — and, since a failed
|
|
55
|
+
* branch carries its reason in `output`, so it can tell a wall it should stop
|
|
56
|
+
* at from a hiccup worth retrying (see `delegateCallOutput`).
|
|
57
|
+
*/
|
|
58
|
+
function delegationPair(taskId, round, replyText, branches) {
|
|
59
|
+
const toolCallId = delegateToolCallId(taskId, round);
|
|
60
|
+
const content = [];
|
|
61
|
+
// The acknowledgment the user already saw, if it is still in history.
|
|
62
|
+
if (replyText)
|
|
63
|
+
content.push({ type: "text", text: replyText });
|
|
64
|
+
content.push({
|
|
65
|
+
type: "tool-call",
|
|
66
|
+
toolCallId,
|
|
67
|
+
toolName: DELEGATE_TOOL_NAME,
|
|
68
|
+
input: delegateCallInput(replyText ?? "", branches)
|
|
69
|
+
});
|
|
70
|
+
return [
|
|
71
|
+
{ role: "assistant", content },
|
|
72
|
+
{
|
|
73
|
+
role: "tool",
|
|
74
|
+
content: [
|
|
75
|
+
{
|
|
76
|
+
type: "tool-result",
|
|
77
|
+
toolCallId,
|
|
78
|
+
toolName: DELEGATE_TOOL_NAME,
|
|
79
|
+
output: { type: "json", value: delegateCallOutput(branches) }
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
];
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Render the model's view for one round: the conversation, with every earlier
|
|
87
|
+
* round's delegation restored as the call-and-result it actually was, and every
|
|
88
|
+
* referenceable turn marked with the catalog index the model selects it by.
|
|
89
|
+
*
|
|
90
|
+
* One pass, two jobs, because they have to agree. The `[ref N]` markers use
|
|
91
|
+
* `isCatalogEligible` — the same predicate the catalog is numbered with — so a
|
|
92
|
+
* marker and its entry can never drift: compaction summaries (`assistant` role,
|
|
93
|
+
* generated) stay in the messages unmarked, readable for context but structurally
|
|
94
|
+
* uncitable as conversation evidence, which is exactly the intent.
|
|
95
|
+
*
|
|
96
|
+
* A round's acknowledgment is stored as plain assistant text (history is
|
|
97
|
+
* text-only, and stays that way — `sessionText`, the catalog, compaction, and
|
|
98
|
+
* recall all read text parts). So its `delegate` call is re-attached to that
|
|
99
|
+
* message here, and the result appended after it, for this one inference call. The
|
|
100
|
+
* pair is emitted together, anchored on the ack's deterministic id, so a `tool`
|
|
101
|
+
* message can never be orphaned from its call — and an ack that has been compacted
|
|
102
|
+
* away still gets its pair, appended at the end minus the acknowledgment text: a
|
|
103
|
+
* result the model cannot place beats a malformed history.
|
|
104
|
+
*
|
|
105
|
+
* Acks are deliberately **not** catalog-eligible: they are the agent's own
|
|
106
|
+
* scaffolding, and a subtask referencing "I'm on it" as verbatim conversation
|
|
107
|
+
* evidence would be noise. That holds for every ack in the Session, not only the
|
|
108
|
+
* ones this render can pair with branches — see `parseRoundAckMessageId`.
|
|
109
|
+
*
|
|
110
|
+
* Everything here is ephemeral — scaffolding for this call only. Reference text is
|
|
111
|
+
* snapshotted from the catalog, so no `[ref N]` prefix ever reaches a Subtask, and
|
|
112
|
+
* the Session never sees any of this markup.
|
|
113
|
+
*/
|
|
114
|
+
export function renderTurnMessages(history, taskId, branches) {
|
|
115
|
+
const rounds = byRound(branches);
|
|
116
|
+
const ackIds = new Map([...rounds.keys()].map((round) => [roundAckMessageId(taskId, round), round]));
|
|
117
|
+
const catalog = [];
|
|
118
|
+
const messages = [];
|
|
119
|
+
const anchored = new Set();
|
|
120
|
+
for (const message of history) {
|
|
121
|
+
if (message.role !== "user" && message.role !== "assistant")
|
|
122
|
+
continue;
|
|
123
|
+
const role = message.role;
|
|
124
|
+
const text = sessionText(message);
|
|
125
|
+
const round = ackIds.get(message.id);
|
|
126
|
+
if (round !== undefined) {
|
|
127
|
+
messages.push(...delegationPair(taskId, round, text, rounds.get(round) ?? []));
|
|
128
|
+
anchored.add(round);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// An acknowledgment with no branches behind it — recognized by id, since
|
|
132
|
+
// nothing in the message body distinguishes an ack from ordinary assistant
|
|
133
|
+
// prose. This Task's own is the crash-window leftover: the ack landed, the
|
|
134
|
+
// rows did not, and this render belongs to the retry that will decide the
|
|
135
|
+
// round again. Dropping it is what makes that retry a clean re-decision —
|
|
136
|
+
// left in, it reads as "already delegated" and invites the model to answer
|
|
137
|
+
// instead of delegating, ending the Task with no work done. Another Task's
|
|
138
|
+
// ack is real history the user saw, so it stays as context, but uncitable:
|
|
139
|
+
// no round of *this* Task can hold it up as conversation evidence.
|
|
140
|
+
const ack = parseRoundAckMessageId(message.id);
|
|
141
|
+
if (ack) {
|
|
142
|
+
if (ack.taskId !== taskId)
|
|
143
|
+
messages.push({ role, content: text });
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (!isCatalogEligible(message)) {
|
|
147
|
+
// Not referenceable (a compaction summary): still context for reasoning.
|
|
148
|
+
messages.push({ role, content: text });
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
const index = catalog.length + 1;
|
|
152
|
+
catalog.push({ index, role, text });
|
|
153
|
+
messages.push({ role, content: `[ref ${index}] ${text}` });
|
|
154
|
+
}
|
|
155
|
+
// Rounds whose acknowledgment is no longer in history (compacted away by a
|
|
156
|
+
// concurrent task), in round order so the results still read chronologically.
|
|
157
|
+
const orphaned = [...rounds.entries()]
|
|
158
|
+
.filter(([round]) => !anchored.has(round))
|
|
159
|
+
.sort(([a], [b]) => a - b);
|
|
160
|
+
for (const [round, roundBranches] of orphaned) {
|
|
161
|
+
messages.push(...delegationPair(taskId, round, null, roundBranches));
|
|
162
|
+
}
|
|
163
|
+
return { messages, catalog };
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Deterministic fallback reply: the successful branches' text in ordinal order,
|
|
167
|
+
* plus the policy's short note when some branches did not succeed.
|
|
168
|
+
*
|
|
169
|
+
* Used when a round's inference is unavailable but its predecessors' work is
|
|
170
|
+
* durable. Failing the whole Task because the answering model is down would throw
|
|
171
|
+
* away good results the user asked for.
|
|
172
|
+
*/
|
|
173
|
+
export function joinSuccessfulBranches(branches, partialNote) {
|
|
174
|
+
const successes = branches.filter((b) => b.status === "completed");
|
|
175
|
+
const body = successes.map(branchText).join("\n\n");
|
|
176
|
+
const incomplete = branches.length > successes.length;
|
|
177
|
+
return incomplete ? `${body}\n\n${partialNote}` : body;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* One attempt against a single model: let it work, and take whichever ending it
|
|
181
|
+
* lands on.
|
|
182
|
+
*
|
|
183
|
+
* Takes the model **factory**, not a model: resolving it can throw (a missing
|
|
184
|
+
* binding, a bad id), and that has to count as this attempt failing so the other
|
|
185
|
+
* model still gets its turn.
|
|
186
|
+
*
|
|
187
|
+
* The model uses its work tools freely — answering well can genuinely need a
|
|
188
|
+
* lookup or a recall — and the loop ends by calling a control tool. None has an
|
|
189
|
+
* `execute`, so there is nothing to continue from and the loop halts on the call.
|
|
190
|
+
* Two endings in one step are resolved by `ControlTool.precedence`.
|
|
191
|
+
*
|
|
192
|
+
* Every control call is then parsed by the tool that owns it, because nothing else
|
|
193
|
+
* has: an execute-less tool's input never passes through the SDK's validation. A
|
|
194
|
+
* parse failure comes back as `rejected` — a repairable failure, not a dead
|
|
195
|
+
* attempt.
|
|
196
|
+
*
|
|
197
|
+
* Charges the budget as it goes, whether it succeeds or not: a failed attempt cost
|
|
198
|
+
* exactly as much as a successful one, and a call that died on its fourth step
|
|
199
|
+
* still spent four turns.
|
|
200
|
+
*/
|
|
201
|
+
async function attempt(args, control, model, instructions, messages) {
|
|
202
|
+
const final = args.mode === "final";
|
|
203
|
+
// A `final` round is handed nothing to work with, only the way out. Leaving the
|
|
204
|
+
// work tools on would invite it to spend a budget it has already spent — and
|
|
205
|
+
// the composing round has every branch result in its messages already, so the
|
|
206
|
+
// thing it needs is not a lookup but an ending.
|
|
207
|
+
const workTools = final ? {} : args.tools;
|
|
208
|
+
// One step per attempt for a `final` round: it exists to produce the answer, and
|
|
209
|
+
// that answer is deliberately spent *beyond* the budget rather than out of it.
|
|
210
|
+
//
|
|
211
|
+
// An `open` round gets whatever the shared budget still holds, read here rather
|
|
212
|
+
// than at the top of the round — so the fallback sees what the primary spent
|
|
213
|
+
// without anyone having to subtract it. See `stepAllowance` for the floor.
|
|
214
|
+
const stepBudget = final
|
|
215
|
+
? 1
|
|
216
|
+
: stepAllowance(args.budget.allowance, args.budget.spent);
|
|
217
|
+
const content = args.onContent
|
|
218
|
+
? buildIntermediateContentHandler(args.onContent, [
|
|
219
|
+
DELEGATE_TOOL_NAME,
|
|
220
|
+
FINAL_REPLY_TOOL_NAME
|
|
221
|
+
])
|
|
222
|
+
: undefined;
|
|
223
|
+
try {
|
|
224
|
+
const result = await generateText({
|
|
225
|
+
model: model(),
|
|
226
|
+
instructions,
|
|
227
|
+
messages,
|
|
228
|
+
// Control tools are declared *first*: tool order is part of the prompt, and
|
|
229
|
+
// the two endings are the thing every round has to reach. Work tool names
|
|
230
|
+
// are compile-time constants and none collides with a control name, so the
|
|
231
|
+
// spread order costs nothing.
|
|
232
|
+
tools: { ...controlToolSet(control), ...workTools },
|
|
233
|
+
// Every ending is a control call, so the model must always call something.
|
|
234
|
+
// Work tools stay freely available — `required` constrains the *shape* of a
|
|
235
|
+
// step's output, not which tool is chosen.
|
|
236
|
+
toolChoice: "required",
|
|
237
|
+
maxOutputTokens: args.maxOutputTokens,
|
|
238
|
+
stopWhen: [
|
|
239
|
+
isStepCount(stepBudget),
|
|
240
|
+
// Halt on any ending this round declares, so a new control tool needs no
|
|
241
|
+
// change here.
|
|
242
|
+
...control.map((c) => hasToolCall(c.name))
|
|
243
|
+
],
|
|
244
|
+
// Retries on *this* model before the slot is given up, honouring the
|
|
245
|
+
// provider's own `retry-after`. Not a duplicate of the fallback: the
|
|
246
|
+
// fallback answers "this model cannot do it", and a 429 says "not yet" —
|
|
247
|
+
// and when both slots share a credential the fallback cannot even answer
|
|
248
|
+
// that. See `ModelConfig.maxRetries`.
|
|
249
|
+
maxRetries: args.maxRetries,
|
|
250
|
+
// Charged here rather than from `result.steps` so a throw mid-loop still
|
|
251
|
+
// bills the steps already spent — the `catch` below has no `result` to read.
|
|
252
|
+
onStepEnd: async (step) => {
|
|
253
|
+
args.budget.spent += 1;
|
|
254
|
+
if (content)
|
|
255
|
+
await content(step);
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
// The most committal ending the model reached, and every call it made to that
|
|
259
|
+
// tool. Ranking by precedence rather than by position keeps "which ending
|
|
260
|
+
// wins" a property the tools declare, not a chain of ifs here.
|
|
261
|
+
const reached = control
|
|
262
|
+
.map((c) => ({
|
|
263
|
+
control: c,
|
|
264
|
+
inputs: result.toolCalls
|
|
265
|
+
.filter((call) => call.toolName === c.name)
|
|
266
|
+
.map((call) => call.input)
|
|
267
|
+
}))
|
|
268
|
+
.filter((c) => c.inputs.length > 0)
|
|
269
|
+
.sort((a, b) => b.control.precedence - a.control.precedence)[0];
|
|
270
|
+
if (reached) {
|
|
271
|
+
// The call that counts, per the tool — a repeated `final_reply` means its
|
|
272
|
+
// last one. Held outside the `try` so a rejection shows the model *that*
|
|
273
|
+
// call and not whichever came first, which it may already have moved past.
|
|
274
|
+
// Only a `select` that throws leaves it unresolved, and that error is about
|
|
275
|
+
// the repeats themselves, so the first call represents them as well as any.
|
|
276
|
+
let subject = reached.inputs[0];
|
|
277
|
+
try {
|
|
278
|
+
subject = reached.control.select(reached.inputs);
|
|
279
|
+
return { ok: true, decision: reached.control.parse(subject) };
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
// The model ended the round but the call cannot be used. Repairable: it is
|
|
283
|
+
// handed this error and asked again, rather than costing the whole slot.
|
|
284
|
+
return {
|
|
285
|
+
ok: false,
|
|
286
|
+
error,
|
|
287
|
+
rejected: {
|
|
288
|
+
toolName: reached.control.name,
|
|
289
|
+
input: subject
|
|
290
|
+
}
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// No control call. Either the model ran out of steps mid-tool-use, or it
|
|
295
|
+
// ignored `toolChoice: "required"` and narrated an action instead of taking
|
|
296
|
+
// one — the failure this whole design exists to catch. Failing the attempt
|
|
297
|
+
// hands the round to the fallback model rather than shipping the narration to
|
|
298
|
+
// the user as if it were an answer.
|
|
299
|
+
if (result.finishReason === "length") {
|
|
300
|
+
console.warn("[turn] model output truncated", {
|
|
301
|
+
taskId: args.taskId,
|
|
302
|
+
round: args.round,
|
|
303
|
+
maxOutputTokens: args.maxOutputTokens
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
ok: false,
|
|
308
|
+
error: new Error(`round produced no decision (finishReason=${result.finishReason}, textLength=${result.text.trim().length})`)
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
return { ok: false, error };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* How many times one model may be shown its own rejected control call and asked
|
|
317
|
+
* again, before the round gives up on that slot.
|
|
318
|
+
*
|
|
319
|
+
* Repair belongs to the **slot**, not the round. A rejected call is not evidence
|
|
320
|
+
* that a model is unavailable — it is a model that understood the request and got
|
|
321
|
+
* the shape wrong, which is the one failure it can actually fix once it is shown
|
|
322
|
+
* the rejection. Falling straight through to the fallback instead spends a whole
|
|
323
|
+
* second model on a fresh guess that has no idea the first one failed: that is how
|
|
324
|
+
* two slots produced the identical missing-param error and killed a round either
|
|
325
|
+
* of them could have repaired.
|
|
326
|
+
*
|
|
327
|
+
* The fallback keeps its real job — covering a primary that could not answer at
|
|
328
|
+
* all — and is still reached once repairs run out, since a model that cannot get
|
|
329
|
+
* the shape right in four tries has earned a second opinion.
|
|
330
|
+
*/
|
|
331
|
+
const MAX_REPAIR_ATTEMPTS = 3;
|
|
332
|
+
/**
|
|
333
|
+
* The id a repaired exchange is anchored on, derived from the Task and round like
|
|
334
|
+
* every other id here. Suffixed per repair, so several rejected calls can sit in
|
|
335
|
+
* one attempt's messages without colliding.
|
|
336
|
+
*
|
|
337
|
+
* Underscore-separated for the same reason as
|
|
338
|
+
* {@link file://../subtasks/delegate.ts delegateToolCallId}: this reaches a
|
|
339
|
+
* provider as a `tool_use.id`, and Anthropic rejects anything outside
|
|
340
|
+
* `^[a-zA-Z0-9_-]+$`. A repair exchange is exactly the moment a round is already
|
|
341
|
+
* in trouble, so an id that 400s here turns a recoverable bad call into a dead
|
|
342
|
+
* round.
|
|
343
|
+
*/
|
|
344
|
+
function controlCallId(taskId, round) {
|
|
345
|
+
return `task_${taskId}_round_${round}_control`;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* A rejected control call paired with its rejection, as the exchange the model has
|
|
349
|
+
* to see in order to fix it.
|
|
350
|
+
*
|
|
351
|
+
* This is deliberately the same shape the SDK produces for a work tool that failed
|
|
352
|
+
* — the call, then an `error-text` result carrying the reason. A work tool gets
|
|
353
|
+
* this for free and models already know how to read it; a control tool halts the
|
|
354
|
+
* loop before the SDK can, so the round builds it by hand. Nothing here is
|
|
355
|
+
* specific to which control tool was refused.
|
|
356
|
+
*
|
|
357
|
+
* Shaped as a real tool exchange rather than a prose "that was wrong" user turn,
|
|
358
|
+
* because that is what it is — and an assistant tool-call with no matching result
|
|
359
|
+
* is a malformed message list to every provider.
|
|
360
|
+
*
|
|
361
|
+
* Entirely ephemeral. These messages exist for the next `generateText` call and are
|
|
362
|
+
* never appended to the Session: the durable record of a round is the ending it
|
|
363
|
+
* landed on, and a call that was thrown out is not something a later round should
|
|
364
|
+
* be able to read back as history.
|
|
365
|
+
*/
|
|
366
|
+
function repairExchange(toolCallId, rejected, error) {
|
|
367
|
+
return [
|
|
368
|
+
{
|
|
369
|
+
role: "assistant",
|
|
370
|
+
content: [
|
|
371
|
+
{
|
|
372
|
+
type: "tool-call",
|
|
373
|
+
toolCallId,
|
|
374
|
+
toolName: rejected.toolName,
|
|
375
|
+
input: rejected.input
|
|
376
|
+
}
|
|
377
|
+
]
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
role: "tool",
|
|
381
|
+
content: [
|
|
382
|
+
{
|
|
383
|
+
type: "tool-result",
|
|
384
|
+
toolCallId,
|
|
385
|
+
toolName: rejected.toolName,
|
|
386
|
+
output: {
|
|
387
|
+
type: "error-text",
|
|
388
|
+
value: `${String(error)}\n\n` +
|
|
389
|
+
`The round did not end and nothing was started. Call ${rejected.toolName} ` +
|
|
390
|
+
`again, keeping the parts that were fine and fixing only what the error names.`
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
]
|
|
394
|
+
}
|
|
395
|
+
];
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* Run one round against the continuous Session: append the user turn (round 0),
|
|
399
|
+
* let the model decide over the indexed history, validate any delegation against
|
|
400
|
+
* this round's catalog, and persist what the user will see.
|
|
401
|
+
*
|
|
402
|
+
* Every append uses a deterministic id, so a Workflow-step re-run neither
|
|
403
|
+
* duplicates the turn nor changes an already-delivered reply.
|
|
404
|
+
*
|
|
405
|
+
* Two nested recoveries, and they answer different failures. Within a slot, a
|
|
406
|
+
* decomposition the catalog rejects is handed back to the *same* model as a failed
|
|
407
|
+
* tool result, up to {@link MAX_REPAIR_ATTEMPTS} times — a shape error is the one
|
|
408
|
+
* thing a model can fix once it sees it. Across slots, an attempt that produced no
|
|
409
|
+
* decision at all moves to the fallback model, which is what that slot is for.
|
|
410
|
+
*
|
|
411
|
+
* Throws only on a transient platform fault (for the Workflow step to retry).
|
|
412
|
+
* A deterministic failure that outlasts every repair on both slots, with durable
|
|
413
|
+
* work behind it, degrades to {@link joinSuccessfulBranches} rather than discarding
|
|
414
|
+
* completed branches; with nothing behind it, it resolves to
|
|
415
|
+
* `{ status: "failed", kind: "exhausted" }`.
|
|
416
|
+
*
|
|
417
|
+
* The third failure is neither, and it short-circuits the *model* recoveries
|
|
418
|
+
* above: a {@link nonRecoverableKind} error ends the round from wherever it
|
|
419
|
+
* happens, carrying that kind — without repairing and **without reaching the
|
|
420
|
+
* fallback slot**, both of which would only present the same dead credential
|
|
421
|
+
* again. See that function for why the transient/deterministic split cannot
|
|
422
|
+
* express it.
|
|
423
|
+
*
|
|
424
|
+
* It does **not** skip the deterministic join. That path needs no credential —
|
|
425
|
+
* it is string concatenation over rows that are already durable — so completed
|
|
426
|
+
* branches are still delivered, and the credential fault reaches the operator
|
|
427
|
+
* through the log rather than by throwing away finished work.
|
|
428
|
+
*/
|
|
429
|
+
export async function runTurn(args) {
|
|
430
|
+
const { session, taskId, round, text, systemSuffix, models, branches } = args;
|
|
431
|
+
if (round === 0) {
|
|
432
|
+
await appendOnce(session, deterministicSessionMessage(taskUserMessageId(taskId), "user", text));
|
|
433
|
+
}
|
|
434
|
+
const history = await session.getHistory();
|
|
435
|
+
const { messages, catalog } = renderTurnMessages(history, taskId, branches);
|
|
436
|
+
const system = (await session.refreshSystemPrompt()) +
|
|
437
|
+
systemSuffix +
|
|
438
|
+
(args.mode === "final" ? args.instructions.final : args.instructions.open);
|
|
439
|
+
// This round's endings, built with the catalog a `delegate` is checked against.
|
|
440
|
+
const control = controlTools({
|
|
441
|
+
catalog,
|
|
442
|
+
delegable: args.mode !== "final",
|
|
443
|
+
types: args.types,
|
|
444
|
+
maxSubtasks: args.maxSubtasks
|
|
445
|
+
});
|
|
446
|
+
const diagnostics = [];
|
|
447
|
+
const errors = [];
|
|
448
|
+
for (const slot of ["primary", "fallback"]) {
|
|
449
|
+
const modelId = slot === "primary" ? models.primaryId() : models.fallbackId();
|
|
450
|
+
const model = slot === "primary" ? models.primary : models.fallback;
|
|
451
|
+
// This slot's own view: the round's messages plus whatever repair exchange it
|
|
452
|
+
// accumulates. A fresh copy per slot, so a fallback that is reached is never
|
|
453
|
+
// handed the primary's rejected calls to be confused by.
|
|
454
|
+
const slotMessages = [...messages];
|
|
455
|
+
for (let repair = 0; repair <= MAX_REPAIR_ATTEMPTS; repair += 1) {
|
|
456
|
+
// Both slots draw on the one `args.budget`, which each attempt reads on entry
|
|
457
|
+
// and charges as it works. A fallback attempt is spend, not a free retry —
|
|
458
|
+
// and so is a repair.
|
|
459
|
+
const outcome = await attempt(args, control, model, system, slotMessages);
|
|
460
|
+
if (!outcome.ok) {
|
|
461
|
+
// Before anything else, and before the fallback slot exists as an
|
|
462
|
+
// option: a failure nothing can clear ends the round here. Repairing
|
|
463
|
+
// asks a dead credential to try again; falling through spends the
|
|
464
|
+
// second slot presenting the *same* dead credential. Both are pure
|
|
465
|
+
// cost, and both delay the only useful outcome — telling an operator
|
|
466
|
+
// what to fix.
|
|
467
|
+
const nonRecoverable = nonRecoverableKind(outcome.error);
|
|
468
|
+
if (nonRecoverable) {
|
|
469
|
+
console.error("[turn] non-recoverable model failure", {
|
|
470
|
+
taskId,
|
|
471
|
+
round,
|
|
472
|
+
model: modelId,
|
|
473
|
+
kind: nonRecoverable,
|
|
474
|
+
error: String(outcome.error)
|
|
475
|
+
});
|
|
476
|
+
// What ends here is *inference*, not the round's ability to answer.
|
|
477
|
+
// Branches that already completed are durable rows, and joining them
|
|
478
|
+
// costs no credential — so the same rescue the exhausted path takes
|
|
479
|
+
// applies, and the operator hears about the fault from the log above.
|
|
480
|
+
const joined = await deterministicJoin(args);
|
|
481
|
+
if (joined)
|
|
482
|
+
return joined;
|
|
483
|
+
return {
|
|
484
|
+
status: "failed",
|
|
485
|
+
kind: nonRecoverable,
|
|
486
|
+
error: String(outcome.error)
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
errors.push(outcome.error);
|
|
490
|
+
const { rejected } = outcome;
|
|
491
|
+
diagnostics.push(rejected
|
|
492
|
+
? `${slot} (${modelId}, attempt ${repair + 1}): ${String(outcome.error)}`
|
|
493
|
+
: `${slot} (${modelId}): ${String(outcome.error)}`);
|
|
494
|
+
console.warn(rejected
|
|
495
|
+
? "[turn] control call rejected"
|
|
496
|
+
: "[turn] model attempt failed", {
|
|
497
|
+
taskId,
|
|
498
|
+
round,
|
|
499
|
+
model: modelId,
|
|
500
|
+
...(rejected ? { tool: rejected.toolName, repair } : {}),
|
|
501
|
+
error: String(outcome.error)
|
|
502
|
+
});
|
|
503
|
+
// No rejected call means no ending to correct — the attempt produced
|
|
504
|
+
// nothing, which is the failure the fallback slot exists for.
|
|
505
|
+
//
|
|
506
|
+
// Otherwise repair, while this slot has both attempts and turns left. Past
|
|
507
|
+
// the allowance every attempt gets `stepAllowance`'s one-step floor, which
|
|
508
|
+
// is enough to reach an ending but not to reconsider one — so retrying
|
|
509
|
+
// there buys a worse call at a real cost.
|
|
510
|
+
if (rejected &&
|
|
511
|
+
repair < MAX_REPAIR_ATTEMPTS &&
|
|
512
|
+
args.budget.spent < args.budget.allowance) {
|
|
513
|
+
slotMessages.push(...repairExchange(`${controlCallId(taskId, round)}_repair_${repair}`, rejected, outcome.error));
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
break;
|
|
517
|
+
}
|
|
518
|
+
if (outcome.decision.kind === "reply") {
|
|
519
|
+
// A throw here is a storage fault: it propagates so the step retries.
|
|
520
|
+
const reply = await appendOnce(session, deterministicSessionMessage(finalReplyMessageId(taskId), "assistant", outcome.decision.text));
|
|
521
|
+
return { status: "replied", reply };
|
|
522
|
+
}
|
|
523
|
+
const stored = await appendOnce(session, deterministicSessionMessage(roundAckMessageId(taskId, round), "assistant", outcome.decision.reply));
|
|
524
|
+
return {
|
|
525
|
+
status: "delegated",
|
|
526
|
+
reply: stored,
|
|
527
|
+
drafts: outcome.decision.drafts
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
// A transient fault is not a decision failure — let the step retry rather than
|
|
532
|
+
// failing the user's Task over Workers-AI capacity.
|
|
533
|
+
const transient = errors.find((e) => isTransientAiError(e));
|
|
534
|
+
if (transient)
|
|
535
|
+
throw transient;
|
|
536
|
+
const detail = `round ${round} exhausted both models — ${diagnostics.join("; ")}`;
|
|
537
|
+
// Both models failed deterministically. Any branch results behind us are durable
|
|
538
|
+
// and useful; deliver them joined rather than failing a Task whose work is done.
|
|
539
|
+
const joined = await deterministicJoin(args);
|
|
540
|
+
if (joined)
|
|
541
|
+
return joined;
|
|
542
|
+
return { status: "failed", kind: "exhausted", error: detail };
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Deliver the branch results this round already has, when no model will produce
|
|
546
|
+
* an answer over them.
|
|
547
|
+
*
|
|
548
|
+
* The one recovery on this file that needs **no** model: a filter, a join and a
|
|
549
|
+
* durable append. That is why both failure paths reach it — a ladder that ran out
|
|
550
|
+
* of attempts, and one that stopped on a fault no attempt could clear. Neither
|
|
551
|
+
* has an answer to write; both have work worth returning.
|
|
552
|
+
*
|
|
553
|
+
* `undefined` when nothing completed, which is the caller's signal to fail with
|
|
554
|
+
* its own kind. No branches means nothing to join, and a Task with no work behind
|
|
555
|
+
* it should not report success.
|
|
556
|
+
*/
|
|
557
|
+
async function deterministicJoin(args) {
|
|
558
|
+
const { session, taskId, round, branches } = args;
|
|
559
|
+
if (!branches.some((b) => b.status === "completed"))
|
|
560
|
+
return undefined;
|
|
561
|
+
console.warn("[turn] falling back to deterministic join", { taskId, round });
|
|
562
|
+
const reply = await appendOnce(session, deterministicSessionMessage(finalReplyMessageId(taskId), "assistant", joinSuccessfulBranches(branches, args.partialNote)));
|
|
563
|
+
return { status: "replied", reply };
|
|
564
|
+
}
|