@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,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
+ }