@adonis-agora/agent 0.31.0 → 0.33.1

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 (238) hide show
  1. package/CHANGELOG.md +795 -0
  2. package/dist/assets/spa/assets/index-DjIMQOxi.js +9 -0
  3. package/dist/assets/spa/index.html +1 -1
  4. package/dist/providers/agent_provider.d.ts.map +1 -1
  5. package/dist/providers/agent_provider.js +140 -2
  6. package/dist/providers/agent_provider.js.map +1 -1
  7. package/dist/src/agent-deps-factory.d.ts +16 -2
  8. package/dist/src/agent-deps-factory.d.ts.map +1 -1
  9. package/dist/src/agent-deps-factory.js +8 -0
  10. package/dist/src/agent-deps-factory.js.map +1 -1
  11. package/dist/src/agent-deps.d.ts +21 -1
  12. package/dist/src/agent-deps.d.ts.map +1 -1
  13. package/dist/src/agent-deps.js.map +1 -1
  14. package/dist/src/agent-loop.d.ts +228 -9
  15. package/dist/src/agent-loop.d.ts.map +1 -1
  16. package/dist/src/agent-loop.js +1360 -209
  17. package/dist/src/agent-loop.js.map +1 -1
  18. package/dist/src/agent-service.d.ts +27 -0
  19. package/dist/src/agent-service.d.ts.map +1 -1
  20. package/dist/src/agent-service.js +29 -0
  21. package/dist/src/agent-service.js.map +1 -1
  22. package/dist/src/ai-sdk/ai-sdk-model.d.ts.map +1 -1
  23. package/dist/src/ai-sdk/ai-sdk-model.js +21 -1
  24. package/dist/src/ai-sdk/ai-sdk-model.js.map +1 -1
  25. package/dist/src/client/sse.d.ts +32 -2
  26. package/dist/src/client/sse.d.ts.map +1 -1
  27. package/dist/src/client/sse.js +45 -2
  28. package/dist/src/client/sse.js.map +1 -1
  29. package/dist/src/define_config.d.ts +41 -7
  30. package/dist/src/define_config.d.ts.map +1 -1
  31. package/dist/src/define_config.js.map +1 -1
  32. package/dist/src/durable/agent-run-workflow.d.ts +10 -11
  33. package/dist/src/durable/agent-run-workflow.d.ts.map +1 -1
  34. package/dist/src/durable/agent-run-workflow.js +49 -21
  35. package/dist/src/durable/agent-run-workflow.js.map +1 -1
  36. package/dist/src/durable/durable-agent-runner.d.ts +3 -2
  37. package/dist/src/durable/durable-agent-runner.d.ts.map +1 -1
  38. package/dist/src/durable/durable-agent-runner.js +2 -2
  39. package/dist/src/durable/durable-agent-runner.js.map +1 -1
  40. package/dist/src/elicitation.d.ts +207 -0
  41. package/dist/src/elicitation.d.ts.map +1 -0
  42. package/dist/src/elicitation.js +315 -0
  43. package/dist/src/elicitation.js.map +1 -0
  44. package/dist/src/evals/approval-prior.d.ts +58 -0
  45. package/dist/src/evals/approval-prior.d.ts.map +1 -0
  46. package/dist/src/evals/approval-prior.js +82 -0
  47. package/dist/src/evals/approval-prior.js.map +1 -0
  48. package/dist/src/evals/evaluate.d.ts +51 -0
  49. package/dist/src/evals/evaluate.d.ts.map +1 -0
  50. package/dist/src/evals/evaluate.js +76 -0
  51. package/dist/src/evals/evaluate.js.map +1 -0
  52. package/dist/src/evals/governance-sample-source.d.ts +23 -0
  53. package/dist/src/evals/governance-sample-source.d.ts.map +1 -0
  54. package/dist/src/evals/governance-sample-source.js +93 -0
  55. package/dist/src/evals/governance-sample-source.js.map +1 -0
  56. package/dist/src/evals/in-memory-score-store.d.ts +16 -0
  57. package/dist/src/evals/in-memory-score-store.d.ts.map +1 -0
  58. package/dist/src/evals/in-memory-score-store.js +57 -0
  59. package/dist/src/evals/in-memory-score-store.js.map +1 -0
  60. package/dist/src/evals/index.d.ts +15 -0
  61. package/dist/src/evals/index.d.ts.map +1 -0
  62. package/dist/src/evals/index.js +14 -0
  63. package/dist/src/evals/index.js.map +1 -0
  64. package/dist/src/evals/judge.d.ts +30 -0
  65. package/dist/src/evals/judge.d.ts.map +1 -0
  66. package/dist/src/evals/judge.js +53 -0
  67. package/dist/src/evals/judge.js.map +1 -0
  68. package/dist/src/evals/live-scoring.d.ts +42 -0
  69. package/dist/src/evals/live-scoring.d.ts.map +1 -0
  70. package/dist/src/evals/live-scoring.js +103 -0
  71. package/dist/src/evals/live-scoring.js.map +1 -0
  72. package/dist/src/evals/sample-source.d.ts +40 -0
  73. package/dist/src/evals/sample-source.d.ts.map +1 -0
  74. package/dist/src/evals/sample-source.js +33 -0
  75. package/dist/src/evals/sample-source.js.map +1 -0
  76. package/dist/src/evals/score-store.d.ts +54 -0
  77. package/dist/src/evals/score-store.d.ts.map +1 -0
  78. package/dist/src/evals/score-store.js +2 -0
  79. package/dist/src/evals/score-store.js.map +1 -0
  80. package/dist/src/evals/scorers/answer-relevancy.scorer.d.ts +28 -0
  81. package/dist/src/evals/scorers/answer-relevancy.scorer.d.ts.map +1 -0
  82. package/dist/src/evals/scorers/answer-relevancy.scorer.js +42 -0
  83. package/dist/src/evals/scorers/answer-relevancy.scorer.js.map +1 -0
  84. package/dist/src/evals/scorers/approval-outcome.scorer.d.ts +22 -0
  85. package/dist/src/evals/scorers/approval-outcome.scorer.d.ts.map +1 -0
  86. package/dist/src/evals/scorers/approval-outcome.scorer.js +60 -0
  87. package/dist/src/evals/scorers/approval-outcome.scorer.js.map +1 -0
  88. package/dist/src/evals/scorers/approval-risk.scorer.d.ts +28 -0
  89. package/dist/src/evals/scorers/approval-risk.scorer.d.ts.map +1 -0
  90. package/dist/src/evals/scorers/approval-risk.scorer.js +54 -0
  91. package/dist/src/evals/scorers/approval-risk.scorer.js.map +1 -0
  92. package/dist/src/evals/scorers/run-completion.scorer.d.ts +18 -0
  93. package/dist/src/evals/scorers/run-completion.scorer.d.ts.map +1 -0
  94. package/dist/src/evals/scorers/run-completion.scorer.js +55 -0
  95. package/dist/src/evals/scorers/run-completion.scorer.js.map +1 -0
  96. package/dist/src/evals/summarize.d.ts +44 -0
  97. package/dist/src/evals/summarize.d.ts.map +1 -0
  98. package/dist/src/evals/summarize.js +87 -0
  99. package/dist/src/evals/summarize.js.map +1 -0
  100. package/dist/src/evals/types.d.ts +81 -0
  101. package/dist/src/evals/types.d.ts.map +1 -0
  102. package/dist/src/evals/types.js +15 -0
  103. package/dist/src/evals/types.js.map +1 -0
  104. package/dist/src/history-window.d.ts +50 -11
  105. package/dist/src/history-window.d.ts.map +1 -1
  106. package/dist/src/history-window.js +84 -12
  107. package/dist/src/history-window.js.map +1 -1
  108. package/dist/src/index.d.ts +9 -2
  109. package/dist/src/index.d.ts.map +1 -1
  110. package/dist/src/index.js +9 -2
  111. package/dist/src/index.js.map +1 -1
  112. package/dist/src/mcp/server.d.ts +8 -0
  113. package/dist/src/mcp/server.d.ts.map +1 -1
  114. package/dist/src/mcp/server.js +27 -1
  115. package/dist/src/mcp/server.js.map +1 -1
  116. package/dist/src/mcp-client/importer.d.ts +75 -0
  117. package/dist/src/mcp-client/importer.d.ts.map +1 -0
  118. package/dist/src/mcp-client/importer.js +161 -0
  119. package/dist/src/mcp-client/importer.js.map +1 -0
  120. package/dist/src/mcp-client/index.d.ts +9 -0
  121. package/dist/src/mcp-client/index.d.ts.map +1 -0
  122. package/dist/src/mcp-client/index.js +8 -0
  123. package/dist/src/mcp-client/index.js.map +1 -0
  124. package/dist/src/mcp-client/options.d.ts +99 -0
  125. package/dist/src/mcp-client/options.d.ts.map +1 -0
  126. package/dist/src/mcp-client/options.js +2 -0
  127. package/dist/src/mcp-client/options.js.map +1 -0
  128. package/dist/src/mcp-client/tool-input.d.ts +39 -0
  129. package/dist/src/mcp-client/tool-input.d.ts.map +1 -0
  130. package/dist/src/mcp-client/tool-input.js +61 -0
  131. package/dist/src/mcp-client/tool-input.js.map +1 -0
  132. package/dist/src/mcp-client/tool-kind.d.ts +37 -0
  133. package/dist/src/mcp-client/tool-kind.d.ts.map +1 -0
  134. package/dist/src/mcp-client/tool-kind.js +20 -0
  135. package/dist/src/mcp-client/tool-kind.js.map +1 -0
  136. package/dist/src/mcp-client/tool-name.d.ts +22 -0
  137. package/dist/src/mcp-client/tool-name.d.ts.map +1 -0
  138. package/dist/src/mcp-client/tool-name.js +35 -0
  139. package/dist/src/mcp-client/tool-name.js.map +1 -0
  140. package/dist/src/mcp-client/tool-source.d.ts +75 -0
  141. package/dist/src/mcp-client/tool-source.d.ts.map +1 -0
  142. package/dist/src/mcp-client/tool-source.js +244 -0
  143. package/dist/src/mcp-client/tool-source.js.map +1 -0
  144. package/dist/src/mcp-client/transient.d.ts +28 -0
  145. package/dist/src/mcp-client/transient.d.ts.map +1 -0
  146. package/dist/src/mcp-client/transient.js +126 -0
  147. package/dist/src/mcp-client/transient.js.map +1 -0
  148. package/dist/src/mcp-client/unsafe-regex.d.ts +23 -0
  149. package/dist/src/mcp-client/unsafe-regex.d.ts.map +1 -0
  150. package/dist/src/mcp-client/unsafe-regex.js +194 -0
  151. package/dist/src/mcp-client/unsafe-regex.js.map +1 -0
  152. package/dist/src/memory.d.ts +448 -0
  153. package/dist/src/memory.d.ts.map +1 -0
  154. package/dist/src/memory.js +338 -0
  155. package/dist/src/memory.js.map +1 -0
  156. package/dist/src/processors.d.ts +134 -0
  157. package/dist/src/processors.d.ts.map +1 -0
  158. package/dist/src/processors.js +215 -0
  159. package/dist/src/processors.js.map +1 -0
  160. package/dist/src/rag/index.d.ts +1 -1
  161. package/dist/src/rag/index.d.ts.map +1 -1
  162. package/dist/src/rag/index.js +1 -1
  163. package/dist/src/rag/index.js.map +1 -1
  164. package/dist/src/rag/pg-vector-store.d.ts +41 -0
  165. package/dist/src/rag/pg-vector-store.d.ts.map +1 -1
  166. package/dist/src/rag/pg-vector-store.js +93 -15
  167. package/dist/src/rag/pg-vector-store.js.map +1 -1
  168. package/dist/src/replay-integrity.d.ts +17 -0
  169. package/dist/src/replay-integrity.d.ts.map +1 -0
  170. package/dist/src/replay-integrity.js +19 -0
  171. package/dist/src/replay-integrity.js.map +1 -0
  172. package/dist/src/runners/inline-agent-runner.d.ts +33 -7
  173. package/dist/src/runners/inline-agent-runner.d.ts.map +1 -1
  174. package/dist/src/runners/inline-agent-runner.js +113 -27
  175. package/dist/src/runners/inline-agent-runner.js.map +1 -1
  176. package/dist/src/skills.d.ts +291 -0
  177. package/dist/src/skills.d.ts.map +1 -0
  178. package/dist/src/skills.js +0 -0
  179. package/dist/src/skills.js.map +1 -0
  180. package/dist/src/spi/agent-runner.d.ts +20 -6
  181. package/dist/src/spi/agent-runner.d.ts.map +1 -1
  182. package/dist/src/spi/agent-store.d.ts +53 -0
  183. package/dist/src/spi/agent-store.d.ts.map +1 -1
  184. package/dist/src/spi/governance-queries.d.ts +5 -0
  185. package/dist/src/spi/governance-queries.d.ts.map +1 -1
  186. package/dist/src/spi/history-window.d.ts +64 -10
  187. package/dist/src/spi/history-window.d.ts.map +1 -1
  188. package/dist/src/spi/model-provider.d.ts +18 -0
  189. package/dist/src/spi/model-provider.d.ts.map +1 -1
  190. package/dist/src/spi/processors.d.ts +161 -0
  191. package/dist/src/spi/processors.d.ts.map +1 -0
  192. package/dist/src/spi/processors.js +57 -0
  193. package/dist/src/spi/processors.js.map +1 -0
  194. package/dist/src/spi/token-stream-sink.d.ts +43 -0
  195. package/dist/src/spi/token-stream-sink.d.ts.map +1 -1
  196. package/dist/src/spi/token-stream-sink.js +17 -1
  197. package/dist/src/spi/token-stream-sink.js.map +1 -1
  198. package/dist/src/sse.d.ts +5 -2
  199. package/dist/src/sse.d.ts.map +1 -1
  200. package/dist/src/sse.js +11 -2
  201. package/dist/src/sse.js.map +1 -1
  202. package/dist/src/stores/lucid-governance-queries.d.ts.map +1 -1
  203. package/dist/src/stores/lucid-governance-queries.js +3 -0
  204. package/dist/src/stores/lucid-governance-queries.js.map +1 -1
  205. package/dist/src/stores/lucid-schema.d.ts.map +1 -1
  206. package/dist/src/stores/lucid-schema.js +15 -0
  207. package/dist/src/stores/lucid-schema.js.map +1 -1
  208. package/dist/src/stores/lucid.d.ts +11 -3
  209. package/dist/src/stores/lucid.d.ts.map +1 -1
  210. package/dist/src/stores/lucid.js +65 -0
  211. package/dist/src/stores/lucid.js.map +1 -1
  212. package/dist/src/structured-output.d.ts +57 -0
  213. package/dist/src/structured-output.d.ts.map +1 -0
  214. package/dist/src/structured-output.js +80 -0
  215. package/dist/src/structured-output.js.map +1 -0
  216. package/dist/src/testing/fake-model-provider.d.ts +8 -4
  217. package/dist/src/testing/fake-model-provider.d.ts.map +1 -1
  218. package/dist/src/testing/fake-model-provider.js +6 -9
  219. package/dist/src/testing/fake-model-provider.js.map +1 -1
  220. package/dist/src/testing/in-memory-governance-queries.d.ts.map +1 -1
  221. package/dist/src/testing/in-memory-governance-queries.js +1 -0
  222. package/dist/src/testing/in-memory-governance-queries.js.map +1 -1
  223. package/dist/src/testing/in-memory-store.d.ts +4 -3
  224. package/dist/src/testing/in-memory-store.d.ts.map +1 -1
  225. package/dist/src/testing/in-memory-store.js +13 -7
  226. package/dist/src/testing/in-memory-store.js.map +1 -1
  227. package/dist/src/testing/index.d.ts +1 -1
  228. package/dist/src/testing/index.d.ts.map +1 -1
  229. package/dist/src/testing/index.js.map +1 -1
  230. package/dist/src/tool-registry.d.ts +9 -0
  231. package/dist/src/tool-registry.d.ts.map +1 -1
  232. package/dist/src/tool-registry.js +11 -0
  233. package/dist/src/tool-registry.js.map +1 -1
  234. package/dist/src/types.d.ts +70 -2
  235. package/dist/src/types.d.ts.map +1 -1
  236. package/package.json +38 -24
  237. package/skills/agent-personas-agents/SKILL.md +16 -12
  238. package/dist/assets/spa/assets/index-3Q2m5_Ax.js +0 -9
@@ -1,5 +1,12 @@
1
1
  import { publishAgentDelegated, publishAgentMessage, publishAgentRetrieved, publishAgentRunFinished, publishAgentRunStarted, publishAgentToolCall, publishAgentToolRetry, spannedAgent, } from './diagnostics.js';
2
+ import { ASK_TOOL_NAME, askInputSchema, askToolDefinition, DEFAULT_INTAKE_PREAMBLE, isHumanDecision, normalizeElicitationReply, settleElicitation, } from './elicitation.js';
3
+ import { buildMemoryBlock, offerMemories, REMEMBER_TOOL_NAME, rememberInputSchema, rememberToolDefinition, writeMemory, } from './memory.js';
4
+ import { createFrameBuffer, createIncrementalGate, gateTail, releaseGatedFrames, resolveGateLookback, resolveOutputGateMode, runInputProcessors, runOutputProcessors, } from './processors.js';
5
+ import { isReplayIntegrityError } from './replay-integrity.js';
6
+ import { buildSkillsBlock, loadSkill, offerSkills, SKILL_TOOL_NAME, skillInputSchema, skillToolDefinition, } from './skills.js';
2
7
  import { estimateCost, resolveModelPrice, } from './spi/pricing-store.js';
8
+ import { OutputRejectedError, } from './spi/processors.js';
9
+ import { DEFAULT_STRUCTURED_OUTPUT_INSTRUCTION, repairInstruction, StructuredOutputError, validateStructured, } from './structured-output.js';
3
10
  import { ToolForbiddenError, ToolInputInvalidError } from './tool-registry.js';
4
11
  import { invokeWithTransientRetry } from './tool-retry.js';
5
12
  /** Renders retrieved passages as a numbered, citable context block appended to the system prompt. */
@@ -23,6 +30,17 @@ function intersectAllow(a, b) {
23
30
  const second = new Set(b);
24
31
  return a.filter((name) => second.has(name));
25
32
  }
33
+ /**
34
+ * The {@link AgentLoopHooks.parallel} implementation for a runner whose checkpoint positions are
35
+ * handed out on the CALL — `@adonis-agora/durable`'s `ctx.localStep` is: it takes `pos.next()`
36
+ * before its first `await`. Every task is invoked here, synchronously and in list order, before any
37
+ * of them is awaited, which is what fixes the block of positions they occupy whatever order they
38
+ * then settle in. Nothing rejects: the caller decides what an individual failure means.
39
+ */
40
+ export function settleAll(tasks) {
41
+ const started = tasks.map((task) => task());
42
+ return Promise.all(started.map((work) => work.then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }))));
43
+ }
26
44
  export class QuotaExceededError extends Error {
27
45
  constructor() {
28
46
  super('Daily token quota exceeded');
@@ -60,6 +78,112 @@ function extractTask(input) {
60
78
  }
61
79
  return JSON.stringify(input);
62
80
  }
81
+ /**
82
+ * How many agent→agent delegations deep a chain may go when the host names no ceiling.
83
+ *
84
+ * A backstop for a chain that is merely LONG. The cycle it would otherwise stand in for is detected
85
+ * directly — see {@link DEFAULT_MAX_AGENT_APPEARANCES}.
86
+ *
87
+ * WHEN IT ACTUALLY BINDS. At one appearance per agent a chain cannot be longer than the number of
88
+ * registered agents, so a deployment with fewer agents than this number never reaches it: the cycle
89
+ * guard always fires first. It starts mattering with a larger fleet than the ceiling, or once a host
90
+ * raises {@link AgentLoopDeps.maxAgentAppearances}, which is what lets a chain revisit an agent and
91
+ * therefore grow past the fleet's size.
92
+ */
93
+ export const MAX_DELEGATION_DEPTH = 5;
94
+ /**
95
+ * How many times one agent may appear on a single delegation chain, when the host names no other
96
+ * number. Once: a chain that reaches an agent it has already passed through is going in circles.
97
+ *
98
+ * Override with {@link AgentLoopDeps.maxAgentAppearances} — a supervisor that genuinely hands work
99
+ * back to an earlier agent needs a larger number, and it is the count of APPEARANCES, so 2 admits
100
+ * exactly one return.
101
+ */
102
+ export const DEFAULT_MAX_AGENT_APPEARANCES = 1;
103
+ /**
104
+ * Why this delegation must not happen, or `null` to let it through.
105
+ *
106
+ * Two different refusals, and the order matters: a CYCLE is named before a depth, because they
107
+ * describe different faults and the depth is the vaguer of the two. `alpha → beta → alpha` points at
108
+ * the wiring; "depth limit of 5 reached" leaves a reader to work out whether the chain was looping or
109
+ * merely long, which is exactly the question a count cannot answer.
110
+ *
111
+ * Decided from the run's own input and the agent's configured ceilings, inside the checkpoint that
112
+ * settles the call's kind — so the verdict is what a replay reads back rather than something each
113
+ * process re-derives.
114
+ */
115
+ function delegationRefusal(args) {
116
+ const { deps, input, targetAgent } = args;
117
+ // `delegationPath` is the chain that REACHED this run, so this run's own agent is the last link of
118
+ // the chain a delegation from here would extend. Naming it is what makes the refusal describe the
119
+ // wiring: a mutual handoff reads `alpha → beta → alpha` rather than an `alpha → alpha` that no
120
+ // edge in the deployment declares. It also makes an agent delegating to ITSELF a cycle on the spot
121
+ // rather than one hop later.
122
+ const ancestry = [
123
+ ...(input.delegationPath ?? []),
124
+ ...(input.agentName !== undefined ? [input.agentName] : []),
125
+ ];
126
+ const appearances = ancestry.filter((name) => name === targetAgent).length;
127
+ const maxAppearances = deps.maxAgentAppearances ?? DEFAULT_MAX_AGENT_APPEARANCES;
128
+ if (appearances >= maxAppearances) {
129
+ const chain = [...ancestry, targetAgent].join(' → ');
130
+ const times = appearances + 1;
131
+ return `delegation cycle: ${chain} — ${targetAgent} ${times} times on one chain`;
132
+ }
133
+ const maxDepth = deps.maxDelegationDepth ?? MAX_DELEGATION_DEPTH;
134
+ if ((input.delegationDepth ?? 0) >= maxDepth) {
135
+ return `delegation depth limit of ${maxDepth} reached`;
136
+ }
137
+ return null;
138
+ }
139
+ /**
140
+ * The gates a delegation must clear. Delegation bypasses `ToolRegistry.invoke` (it is a ctx-level
141
+ * suspend point), so the three checks `invoke` applies have to be re-applied here in the same order
142
+ * — authorization first, then shape:
143
+ * - the offered-tools (persona/agent allow-list) filter: a tool the model was not offered must not
144
+ * run, even if the model names it anyway;
145
+ * - the role/ability re-check. A synthesized delegate spec (`registerDelegateTools` in
146
+ * agent-deps-factory.ts) carries whatever `roles`/`ability` its `delegatesTo` edge declared, and
147
+ * nothing at all when the edge is a bare string — under `DefaultRolesPolicy` that is ADMIN-only,
148
+ * and under an authz posture a tool with no `ability` is always denied;
149
+ * - input validation, so a malformed input is rejected rather than silently coerced.
150
+ *
151
+ * Then the two nesting guards, once the target is known: {@link delegationRefusal} refuses a chain
152
+ * that is going in circles, or one that is merely too deep.
153
+ *
154
+ * Returns the verdict rather than throwing it: this runs inside the call's checkpoint, whose output
155
+ * is what a replay reads back, and it has to run BEFORE any event publication so a refusal never
156
+ * emits `agent.delegated`.
157
+ */
158
+ async function resolveDelegation(deps, input, call, spec) {
159
+ try {
160
+ if (spec === undefined) {
161
+ // Unreachable while the kind is read off this same spec, but an `agent`-kind call with no
162
+ // resolvable spec must fail closed rather than fall through.
163
+ throw new ToolForbiddenError(call.name);
164
+ }
165
+ const offeredAllow = intersectAllow(input.persona?.allowedTools, deps.toolAllowList);
166
+ if (offeredAllow !== undefined && !offeredAllow.includes(call.name)) {
167
+ throw new ToolForbiddenError(call.name);
168
+ }
169
+ if (!(await deps.rolesPolicy.can(input.actor, spec))) {
170
+ throw new ToolForbiddenError(call.name);
171
+ }
172
+ const validation = await spec.inputSchema['~standard'].validate(call.input);
173
+ if (validation.issues !== undefined) {
174
+ throw new ToolInputInvalidError(call.name, validation.issues);
175
+ }
176
+ const targetAgent = spec.targetAgent ?? call.name;
177
+ const refusal = delegationRefusal({ deps, input, targetAgent });
178
+ if (refusal !== null) {
179
+ return { error: refusal };
180
+ }
181
+ return { targetAgent, task: extractTask(validation.value) };
182
+ }
183
+ catch (error) {
184
+ return { error: error instanceof Error ? error.message : String(error) };
185
+ }
186
+ }
63
187
  function deriveTitle(userText) {
64
188
  const trimmed = userText.trim().replace(/\s+/g, ' ');
65
189
  return trimmed.length > 60 ? `${trimmed.slice(0, 57)}...` : trimmed || 'New chat';
@@ -75,6 +199,925 @@ function resolveCostUsd(usage, reportedCostUsd, price) {
75
199
  }
76
200
  return price === undefined ? null : estimateCost(usage, price);
77
201
  }
202
+ /** Renders a folded-history summary as the leading `system` message of a windowed turn. */
203
+ function buildSummaryBlock(summary) {
204
+ return `<conversation_summary>\n${summary}\n</conversation_summary>\nEarlier messages in this thread are no longer included verbatim. Treat the summary above as an accurate record of them.`;
205
+ }
206
+ /**
207
+ * Identifies the split shape to {@link AgentLoopHooks.patched}: a run whose journal holds one
208
+ * `history:window` checkpoint has to keep replaying against that, not against a pure selection that
209
+ * spends no position at all. Reached only when a window is configured, so a deployment with none
210
+ * never spends the marker's position and records byte-identical checkpoints.
211
+ */
212
+ const HISTORY_SELECT_PATCH = 'agent:history-select';
213
+ /** The three answers, normalized out of whatever shape `load:thread` recorded them in. */
214
+ function threadForTurn(recorded) {
215
+ const messages = (recorded?.messages ?? []).map(toModelMessage);
216
+ return {
217
+ messages,
218
+ title: recorded?.title ?? null,
219
+ hasAssistantMessage: recorded?.hasAssistantMessage ?? messages.some((message) => message.role === 'assistant'),
220
+ };
221
+ }
222
+ /**
223
+ * A message as the model sees it — the store's own bookkeeping (`id`, `createdAt`, `usage`,
224
+ * `followUps`, `runId`, `persona`) left behind, so what `load:thread` records is the prompt's rows
225
+ * rather than the table's.
226
+ */
227
+ function toModelMessage(message) {
228
+ return {
229
+ role: message.role,
230
+ content: message.content,
231
+ ...(message.toolCalls !== undefined ? { toolCalls: message.toolCalls } : {}),
232
+ ...(message.toolResults !== undefined ? { toolResults: message.toolResults } : {}),
233
+ ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
234
+ };
235
+ }
236
+ /**
237
+ * How many of the thread's newest rows to ask for — the window's own row ceiling, or nothing at all,
238
+ * which asks for the transcript.
239
+ *
240
+ * A window that SUMMARIZES gets no limit. `summarize` is handed what `select` dropped, and a read
241
+ * bounded to what `select` keeps drops nothing: the turn would fold an empty summary into a prompt
242
+ * that is missing the messages it stands in for, with no error anywhere.
243
+ *
244
+ * A ceiling expressed only in TOKENS gets no limit either, and none can be derived from it: one
245
+ * message can be four tokens or forty thousand, so no row count follows from a token budget. Naming
246
+ * one too low reads fewer rows than `select` would have kept, which changes the prompt itself;
247
+ * leaving it out only costs the read.
248
+ */
249
+ function turnMessageLimit(window) {
250
+ if (window === undefined || window.summarize !== undefined) {
251
+ return undefined;
252
+ }
253
+ return window.maxMessages;
254
+ }
255
+ /**
256
+ * Read the thread: the store's bounded WINDOW where it offers one, else its whole transcript cut to
257
+ * the same bound in process.
258
+ *
259
+ * The probe is structural — the window is an optimization a store either offers or does not, and one
260
+ * that offers none still answers correctly. Which branch ran is invisible to the journal on purpose:
261
+ * both produce the same three answers over the same rows, so the payload `load:thread` records is
262
+ * identical either way and a deployment's choice of store can never decide a run's checkpoints. That
263
+ * is also why the probe lives INSIDE `load:thread` — it adds no position, and a replaying process
264
+ * reads the recorded payload back without calling the store at all.
265
+ *
266
+ * `hasAssistantMessage` is answered over the whole thread, never over the window. It decides a
267
+ * `thread-start` intake, and a window that happens to hold only the user's last questions belongs to
268
+ * a conversation that has still been answered — scanned off the window, such a thread re-introduces
269
+ * itself every turn.
270
+ */
271
+ async function readThreadForTurn(deps, threadId, messageLimit) {
272
+ const windowing = deps.store;
273
+ if (typeof windowing.loadThreadForTurn === 'function') {
274
+ const page = await windowing.loadThreadForTurn({
275
+ threadId,
276
+ ...(messageLimit !== undefined ? { messageLimit } : {}),
277
+ });
278
+ return page === null
279
+ ? { messages: [], title: null, hasAssistantMessage: false }
280
+ : {
281
+ messages: page.messages.map(toModelMessage),
282
+ title: page.title,
283
+ hasAssistantMessage: page.hasAssistantMessage,
284
+ };
285
+ }
286
+ const thread = await deps.store.getThread(threadId);
287
+ const stored = thread?.messages ?? [];
288
+ const window = messageLimit === undefined
289
+ ? stored
290
+ : stored.slice(Math.max(0, stored.length - Math.max(0, messageLimit)));
291
+ return {
292
+ messages: window.map(toModelMessage),
293
+ title: thread?.title ?? null,
294
+ hasAssistantMessage: stored.some((message) => message.role === 'assistant'),
295
+ };
296
+ }
297
+ /**
298
+ * Apply the configured ceiling to the thread's messages. `select` runs OUTSIDE any checkpoint — it
299
+ * is contractually pure and its input is already `load:thread`'s cached result, so a replay reaches
300
+ * the same split without a checkpoint of its own.
301
+ *
302
+ * Summarizing is the opposite: it calls a model. Journaled under `history:summarize`, so a resumed
303
+ * run reads back the summary the suspended attempt produced (and does not pay for it twice) instead
304
+ * of prompting with a different one. Both that checkpoint and the usage row below are reachable
305
+ * only through a window that actually summarizes.
306
+ */
307
+ async function applyHistoryWindow(window, deps, input, hooks, messages, step) {
308
+ const ctx = { actor: input.actor, threadId: input.threadId };
309
+ const { keep, drop } = window.select(messages, ctx);
310
+ const summarize = window.summarize?.bind(window);
311
+ if (drop.length === 0 || summarize === undefined) {
312
+ return keep;
313
+ }
314
+ const summary = await step('history:summarize', () => summarize(drop, ctx));
315
+ if (summary.usage !== undefined) {
316
+ const usage = summary.usage;
317
+ await step('persist:usage:history', () => deps.store.recordUsage({
318
+ threadId: input.threadId,
319
+ actorRef: input.actor.id,
320
+ runId: hooks.runId,
321
+ modelId: summary.modelId ?? deps.modelId ?? 'unknown',
322
+ purpose: 'summary',
323
+ usage,
324
+ }));
325
+ }
326
+ return [{ role: 'system', content: buildSummaryBlock(summary.text) }, ...keep];
327
+ }
328
+ /**
329
+ * The turn's tool list plus the built-ins the module offers — `ask`, `skill`. Appended from CONFIG
330
+ * rather than the registry, because neither is ever registered: see {@link AgentLoopDeps.ask} and
331
+ * {@link AgentLoopDeps.skills}.
332
+ */
333
+ export function withBuiltInTools(args) {
334
+ return [
335
+ ...args.tools,
336
+ ...(args.ask === true ? [askToolDefinition()] : []),
337
+ ...(args.skills ? [skillToolDefinition()] : []),
338
+ ...(args.memory ? [rememberToolDefinition()] : []),
339
+ ];
340
+ }
341
+ /**
342
+ * Does this deployment offer `remember` at all? A provider that serves memory read-only omits
343
+ * `write`, and then the tool is never offered — so a turn's tool list is the same on every process,
344
+ * decided by module config rather than by what a given pod happens to hold.
345
+ */
346
+ function memoryIsWritable(deps) {
347
+ return deps.memory?.provider.write !== undefined;
348
+ }
349
+ /**
350
+ * Park on a question set, through whichever wait the host implemented, and read back whatever that
351
+ * wait delivered as answers.
352
+ *
353
+ * The reduction of a yes/no channel — approve is "confirmed the pre-picked answers", reject is
354
+ * "skipped" — applies to BOTH branches, because the shape of the reply is decided by whoever settled
355
+ * the tool call, not by which hook the host wired. The question set sits in the approvals inbox as a
356
+ * `pending_approval` action, so an operator pressing Approve there sends a `Decision` into a run
357
+ * whose host implements `awaitAnswers` perfectly well.
358
+ *
359
+ * Neither branch moves a checkpoint: the wait sits at the same position either way, so the choice of
360
+ * hook cannot change the sequence a replay lines up with.
361
+ */
362
+ async function awaitElicitation(args) {
363
+ const { hooks, request, ctx } = args;
364
+ if (hooks.awaitAnswers !== undefined) {
365
+ return normalizeElicitationReply(await hooks.awaitAnswers(request, ctx));
366
+ }
367
+ return normalizeElicitationReply(await hooks.awaitApproval({ id: request.id, name: ASK_TOOL_NAME, input: request }, ctx));
368
+ }
369
+ /** How many wrong-shaped replies one approval wait absorbs before {@link awaitDecision} gives up. */
370
+ const MAX_DISCARDED_APPROVAL_REPLIES = 100;
371
+ /**
372
+ * Park on an `action`'s approval until a human's yes/no actually arrives.
373
+ *
374
+ * The wait carries both reply shapes, because a question set parks as a `pending_approval` action
375
+ * too — so a client can address `POST /agent/tool-call/answer` at a call that is in fact waiting on
376
+ * an approve/reject. A set of answers is not a verdict on proposed work; reading its absent
377
+ * `approved` as `false` would persist a rejection nobody made, against a tool whose operator only
378
+ * ever submitted a form. The reply is discarded and the approval stays pending, which is what it is.
379
+ *
380
+ * Waiting again spends another position, and a replay lines up with it: the reply that was discarded
381
+ * is itself journaled at the position it arrived on, so every process replaying the turn discards
382
+ * the same replies in the same order and reaches the same wait.
383
+ */
384
+ async function awaitDecision(args) {
385
+ const { hooks, call, ctx } = args;
386
+ for (let discarded = 0; discarded < MAX_DISCARDED_APPROVAL_REPLIES; discarded += 1) {
387
+ const reply = await hooks.awaitApproval(call, ctx);
388
+ if (isHumanDecision(reply)) {
389
+ return reply;
390
+ }
391
+ }
392
+ // Both shipped runners wait here, so the ceiling is out of reach: a person would have to misdirect
393
+ // the whole budget at one tool call. What it rules out is a HOST whose `awaitApproval` resolves
394
+ // WITHOUT waiting — which would otherwise spin this loop on the only thread the process has.
395
+ throw new Error(`Tool call "${call.id}" was sent ${MAX_DISCARDED_APPROVAL_REPLIES} replies that carried no approve/reject`);
396
+ }
397
+ /**
398
+ * Does the configured intake run this turn? Both inputs are facts the journal already holds — the
399
+ * config, and `load:thread`'s record of whether the thread had an assistant message when the turn
400
+ * began. Neither is re-read from the store here, and that is the point: by the time a replay reaches
401
+ * this line the first attempt has already appended the intake's own assistant message to the thread,
402
+ * so a fresh read would answer differently on the resume than it did on the way in — and the answer
403
+ * decides how many checkpoints follow it.
404
+ */
405
+ function intakeApplies(args) {
406
+ return args.intake.when === 'every-turn' || !args.threadHasAssistant;
407
+ }
408
+ /**
409
+ * The configured intake: post the authored question set, park until a human settles it, and leave
410
+ * the exchange on the transcript as an ordinary tool round-trip so the model reads the answers the
411
+ * same way it reads its own `ask`'s.
412
+ *
413
+ * COSTS NO MODEL CALL. The questions, their options and their pre-picked defaults are all authored
414
+ * on the agent, so nothing here is generated and nothing here is billed — which is also why the
415
+ * total is knowable before the first question is shown.
416
+ *
417
+ * One checkpoint whenever an `intake` is configured, plus two more on the turns it actually asks —
418
+ * all reachable only through that config, so a run that predates the option cannot land on any of
419
+ * them and no {@link AgentLoopHooks.patched} marker is spent keeping the sequence stable for it.
420
+ */
421
+ async function runIntake(args) {
422
+ const { intake, deps, input, hooks, writer, threadHasAssistant } = args;
423
+ const preamble = intake.preamble ?? DEFAULT_INTAKE_PREAMBLE;
424
+ // Derived from the run, so a replay rebuilds the same id without minting one — the id is a
425
+ // checkpoint name's suffix and the signal's own key, so it cannot come from a random source.
426
+ const request = {
427
+ id: `intake-${hooks.runId}`,
428
+ source: 'intake',
429
+ preamble,
430
+ questions: intake.questions,
431
+ };
432
+ const call = {
433
+ id: request.id,
434
+ name: ASK_TOOL_NAME,
435
+ input: { preamble, questions: intake.questions },
436
+ };
437
+ const asked = (await hooks.step('intake:ask', async () => {
438
+ if (!intakeApplies({ intake, threadHasAssistant })) {
439
+ return null;
440
+ }
441
+ const message = await deps.store.appendMessage({
442
+ threadId: input.threadId,
443
+ role: 'assistant',
444
+ content: preamble,
445
+ runId: hooks.runId,
446
+ toolCalls: [call],
447
+ ...(input.persona !== undefined ? { persona: input.persona.id } : {}),
448
+ });
449
+ await deps.store.recordToolCall({
450
+ toolCallId: request.id,
451
+ messageId: message.id,
452
+ toolName: ASK_TOOL_NAME,
453
+ // The store knows read/action only. An unanswered question is work waiting on a human, which
454
+ // is what `action` + `pending_approval` already mean — so it surfaces in an approvals inbox
455
+ // rather than needing one of its own.
456
+ toolType: 'action',
457
+ input: call.input,
458
+ status: 'pending_approval',
459
+ runId: hooks.runId,
460
+ });
461
+ await writer.write({ t: 'elicitation', runId: hooks.runId, id: request.id, request });
462
+ return message.id;
463
+ }));
464
+ if (asked === null || asked === false) {
465
+ return null;
466
+ }
467
+ // A checkpoint that recorded only WHETHER the intake ran carries no message id, so such a run
468
+ // settles the tool-call row alone.
469
+ const messageId = typeof asked === 'string' ? asked : null;
470
+ const reply = await awaitElicitation({
471
+ hooks,
472
+ request,
473
+ ctx: elicitationContext(deps, input, hooks),
474
+ });
475
+ const result = settleElicitation({ request, reply });
476
+ await hooks.step('intake:answers', async () => {
477
+ await deps.store.updateToolCall({
478
+ toolCallId: request.id,
479
+ // A skip is not an answer. Both leave the agent holding the same values, but only one of them
480
+ // is evidence the user chose them, and a reader auditing what the agent was told has to be
481
+ // able to tell those apart.
482
+ status: result.skipped ? 'rejected' : 'executed',
483
+ output: result,
484
+ ...(result.skipped ? { error: 'skipped by the user' } : {}),
485
+ ...(reply.answeredByRef !== undefined ? { executedByRef: reply.answeredByRef } : {}),
486
+ });
487
+ if (messageId !== null) {
488
+ await deps.store.setMessageToolResults(messageId, [
489
+ { id: request.id, name: ASK_TOOL_NAME, output: result },
490
+ ]);
491
+ }
492
+ });
493
+ return {
494
+ role: 'assistant',
495
+ content: preamble,
496
+ toolCalls: [call],
497
+ toolResults: [{ id: request.id, name: ASK_TOOL_NAME, output: result }],
498
+ };
499
+ }
500
+ /** The context a wait is handed. The intake has no tool ctx of its own — it is not a tool call. */
501
+ function elicitationContext(deps, input, hooks) {
502
+ return {
503
+ actor: input.actor,
504
+ threadId: input.threadId,
505
+ runId: hooks.runId,
506
+ requestId: hooks.runId,
507
+ ...(input.persona !== undefined ? { persona: input.persona } : {}),
508
+ ...(input.pageContext !== undefined ? { pageContext: input.pageContext } : {}),
509
+ ...(deps.host !== undefined ? { host: deps.host } : {}),
510
+ };
511
+ }
512
+ /**
513
+ * Identifies the batched tool-call shape to {@link AgentLoopHooks.patched}: batching hoists a turn's
514
+ * `persist:toolcall` checkpoints ahead of its first tool execution, so a run whose journal
515
+ * interleaves them one call at a time has to keep replaying against THAT.
516
+ */
517
+ const PARALLEL_TOOLS_PATCH = 'agent:parallel-tools';
518
+ /**
519
+ * Identifies the message-borne tool results to {@link AgentLoopHooks.patched}: attaching them adds a
520
+ * checkpoint after a turn's last tool, a position a run recorded under the shape without it cannot
521
+ * supply.
522
+ */
523
+ const MESSAGE_TOOL_RESULTS_PATCH = 'agent:message-tool-results';
524
+ /**
525
+ * Record the call and settle its KIND, which decides this call's control flow: an `action` suspends
526
+ * the run on an approval signal (`tool:<runId>:<callId>`), an `agent` delegates to a child run,
527
+ * anything else records a plain step. Resolving it from `deps.registry` in the loop body would tie
528
+ * that branch to the registry of WHICHEVER PROCESS runs the body — and a process whose registry
529
+ * lacks the tool reads `undefined`, falls back to 'read', and then asks for a `tool:` checkpoint
530
+ * where the history holds the approval signal. That is a non-determinism refusal on resume, and it
531
+ * is an approval-gated action about to run with nobody's approval.
532
+ *
533
+ * So the lookup — and the delegation gates that hang off it — happen INSIDE the `persist:toolcall`
534
+ * step, and the verdict is RETURNED from it. The first process to reach this call writes the kind
535
+ * into the journal; every later replay reads it back instead of asking its own registry.
536
+ */
537
+ async function claimToolCall(turn, call) {
538
+ const { deps, input, hooks, messageId, writer } = turn;
539
+ const persona = input.persona;
540
+ const persisted = (await hooks.step(`persist:toolcall:${call.id}`, async () => {
541
+ // The kind the llm checkpoint stamped WINS: it came from the process that offered the tool,
542
+ // whereas this one is only the process claiming the call — and the two disagree exactly when
543
+ // that matters. The fallback is for a journal written before kinds travelled.
544
+ const kind = call.kind ?? declaredKind(deps, call.name);
545
+ if (kind === 'agent') {
546
+ const delegation = await resolveDelegation(deps, input, call, deps.registry.spec(call.name));
547
+ await deps.store.recordToolCall({
548
+ toolCallId: call.id,
549
+ messageId,
550
+ toolName: call.name,
551
+ // The store knows read/action only; a delegation is neither approved nor rejected by a
552
+ // human, so it persists as a read. A refused one is recorded `failed` directly — never
553
+ // `auto_executed` even transiently.
554
+ toolType: 'read',
555
+ input: call.input,
556
+ status: delegation.error !== undefined ? 'failed' : 'auto_executed',
557
+ runId: hooks.runId,
558
+ });
559
+ return { kind, delegation };
560
+ }
561
+ // An `ask` persists exactly as an `action` does: the store knows read/action only, and an
562
+ // unanswered question is work waiting on a human — which is what `pending_approval` already
563
+ // means, so it lands in the approvals inbox a deployment already has.
564
+ const parks = kind === 'action' || kind === 'ask';
565
+ await deps.store.recordToolCall({
566
+ toolCallId: call.id,
567
+ messageId,
568
+ toolName: call.name,
569
+ toolType: parks ? 'action' : 'read',
570
+ input: call.input,
571
+ status: parks ? 'pending_approval' : 'auto_executed',
572
+ runId: hooks.runId,
573
+ });
574
+ // Written from INSIDE this checkpoint, so the frame is streamed once and a replay — which
575
+ // returns the memoized result without re-running the body — never re-posts a form for a
576
+ // decision already made. That is also why it spends no position of its own.
577
+ //
578
+ // An `ask` is left out: `elicitToolCall` posts its `elicitation` frame, which carries the
579
+ // whole question set. Two frames for one parked call would be two forms.
580
+ if (kind === 'action') {
581
+ await writer.write({
582
+ t: 'approval',
583
+ runId: hooks.runId,
584
+ id: call.id,
585
+ toolName: call.name,
586
+ input: call.input,
587
+ });
588
+ }
589
+ return { kind };
590
+ }));
591
+ // A checkpoint written before the kind was journaled carries no output. Such a run is already
592
+ // committed to whatever its first process resolved, so the local registry is the only thing left
593
+ // to consult — reached by those runs alone.
594
+ const toolType = persisted?.kind ?? declaredKind(deps, call.name);
595
+ return {
596
+ call,
597
+ toolType,
598
+ ...(persisted?.delegation !== undefined ? { delegation: persisted.delegation } : {}),
599
+ ctx: {
600
+ actor: input.actor,
601
+ threadId: input.threadId,
602
+ runId: hooks.runId,
603
+ requestId: hooks.runId,
604
+ emitComponent: (name, data) => writer.write({ t: 'component', name, data }),
605
+ ...(persona !== undefined ? { persona } : {}),
606
+ ...(input.pageContext !== undefined ? { pageContext: input.pageContext } : {}),
607
+ ...(deps.host !== undefined ? { host: deps.host } : {}),
608
+ },
609
+ };
610
+ }
611
+ /**
612
+ * Execute one claimed call. Asks for its checkpoint SYNCHRONOUSLY — nothing is awaited before
613
+ * `hooks.step` — which is what lets a batch of these be launched together and still occupy
614
+ * positions in call order (see {@link AgentLoopHooks.parallel}).
615
+ *
616
+ * A tool's own failure is an OUTCOME, not a throw: the model is handed it as a result and adapts.
617
+ * Only the runner's control flow (a suspend / continue-as-new) and a replay-integrity refusal
618
+ * escape — answering either of those with a `persist:toolfail` checkpoint would ask for a position
619
+ * a diverged history has no room for, so the operator reads that second refusal instead of the
620
+ * disagreement that caused it.
621
+ *
622
+ * The retry loop runs INSIDE the `tool:<call.id>` step so it stays replay-safe: a durable step
623
+ * memoizes only its successful result, so on replay the whole step returns cached and the retries
624
+ * never re-run (side effects happen exactly once). The span sits inside the step body for the same
625
+ * reason, and covers all in-place attempts; the tool's output never rides it.
626
+ */
627
+ /**
628
+ * The span's narrower tool vocabulary. An `ask` is settled against a human and a `skill` is read out
629
+ * of the journal, so neither reaches a registry invocation; each maps to the kind whose control flow
630
+ * it shares, for a reader of a trace who only ever sees the three.
631
+ */
632
+ function spanToolType(kind) {
633
+ if (kind === 'ask') {
634
+ return 'action';
635
+ }
636
+ return kind === 'skill' || kind === 'memory' ? 'read' : kind;
637
+ }
638
+ async function invokeClaimedTool(turn, claimed) {
639
+ const { deps, hooks } = turn;
640
+ const { call, ctx, toolType } = claimed;
641
+ try {
642
+ const output = await hooks.step(`tool:${call.id}`, () => spannedAgent('tool.execution', hooks.runId, {
643
+ runId: hooks.runId,
644
+ toolCallId: call.id,
645
+ toolName: call.name,
646
+ toolType: spanToolType(toolType),
647
+ }, () => invokeWithTransientRetry(() => deps.registry.invoke(call.name, call.input, ctx, deps.rolesPolicy), deps.toolTransientRetry ?? {}, {
648
+ ...(hooks.isControlFlowError !== undefined
649
+ ? { isControlFlowError: hooks.isControlFlowError }
650
+ : {}),
651
+ onRetry: (attempt, retryError) => {
652
+ publishAgentToolRetry({
653
+ runId: hooks.runId,
654
+ toolName: call.name,
655
+ toolCallId: call.id,
656
+ attempt,
657
+ message: retryError instanceof Error ? retryError.message : String(retryError),
658
+ });
659
+ },
660
+ }), () => ({})));
661
+ return { status: 'executed', output };
662
+ }
663
+ catch (error) {
664
+ if (isReplayIntegrityError(error) || hooks.isControlFlowError?.(error) === true) {
665
+ throw error;
666
+ }
667
+ return { status: 'failed', error: error instanceof Error ? error.message : String(error) };
668
+ }
669
+ }
670
+ /** Persist one settled invocation and shape the result the model is fed. */
671
+ async function recordToolOutcome(turn, claimed, outcome) {
672
+ const { deps, input, hooks } = turn;
673
+ const { call } = claimed;
674
+ // An `agent` call branches to delegation before reaching here, so anything that isn't an `action`
675
+ // is a read — the same posture the kind fallback itself takes.
676
+ const toolType = claimed.toolType === 'action' ? 'action' : 'read';
677
+ if (outcome.status === 'failed') {
678
+ await hooks.step(`persist:toolfail:${call.id}`, () => deps.store.updateToolCall({ toolCallId: call.id, status: 'failed', error: outcome.error }));
679
+ publishAgentToolCall({ runId: hooks.runId, toolName: call.name, toolType, status: 'failed' });
680
+ return { id: call.id, name: call.name, output: null, error: outcome.error };
681
+ }
682
+ await hooks.step(`persist:toolexec:${call.id}`, () => deps.store.updateToolCall({
683
+ toolCallId: call.id,
684
+ status: 'executed',
685
+ output: outcome.output,
686
+ ...(toolType === 'action' ? { executedByRef: input.actor.id } : {}),
687
+ }));
688
+ publishAgentToolCall({ runId: hooks.runId, toolName: call.name, toolType, status: 'executed' });
689
+ return { id: call.id, name: call.name, output: outcome.output };
690
+ }
691
+ /**
692
+ * Delegate to another agent (an `agent`-kind call). Dispatched at the LOOP level (not in a step)
693
+ * because the durable runner maps it to `ctx.child`, a ctx-level suspend point.
694
+ */
695
+ async function delegateToolCall(turn, claimed) {
696
+ const { deps, input, hooks } = turn;
697
+ const { call } = claimed;
698
+ const delegation = claimed.delegation ??
699
+ (await resolveDelegation(deps, input, call, deps.registry.spec(call.name)));
700
+ if (delegation.error !== undefined) {
701
+ return { id: call.id, name: call.name, output: null, error: delegation.error };
702
+ }
703
+ const { targetAgent, task } = delegation;
704
+ publishAgentDelegated({
705
+ runId: hooks.runId,
706
+ toAgent: targetAgent,
707
+ ...(input.agentName !== undefined ? { fromAgent: input.agentName } : {}),
708
+ });
709
+ const sub = hooks.runAgent
710
+ ? await hooks.runAgent(targetAgent, task)
711
+ : { text: `(no multi-agent support wired; cannot reach "${targetAgent}")` };
712
+ await hooks.step(`persist:toolexec:${call.id}`, () => deps.store.updateToolCall({ toolCallId: call.id, status: 'executed', output: sub }));
713
+ return { id: call.id, name: call.name, output: sub };
714
+ }
715
+ /**
716
+ * Stamp each of a model turn's calls with the kind declared WHERE THE TOOL WAS OFFERED.
717
+ *
718
+ * A call exists only because some process put that tool's definition in front of the model, so that
719
+ * process is the one that certainly knows the tool. Another process might not: a deployment that
720
+ * splits its pods by role has one that serves HTTP and replays run bodies without registering the
721
+ * tool classes, and its registry answers `undefined`. Stamping here, INSIDE the llm checkpoint, is
722
+ * what puts the kind in the journal — so the branch a call takes is a fact about the turn rather
723
+ * than about whichever process happened to replay it (see {@link claimToolCall}).
724
+ *
725
+ * Only unstamped calls are touched: a kind already on a call was settled upstream, and a process
726
+ * further down never second-guesses it.
727
+ */
728
+ function stampToolKinds(result, deps) {
729
+ return {
730
+ ...result,
731
+ toolCalls: result.toolCalls.map((call) => call.kind === undefined ? { ...call, kind: declaredKind(deps, call.name) } : call),
732
+ };
733
+ }
734
+ /**
735
+ * A call's declared kind, resolved from this process's own configuration and registry.
736
+ *
737
+ * Read by {@link stampToolKinds} where the tools were offered, which is the answer that then rides
738
+ * the journal; a claim falls back to it only for a call that arrives unstamped.
739
+ *
740
+ * The reserved `ask` name resolves from module CONFIG, never from the registry: `ask` has no handler
741
+ * to register, and grounding its branch in config is what keeps a process with a partial registry
742
+ * from disagreeing about it. Every other name is the registry's `ToolSpec`, as before.
743
+ */
744
+ function declaredKind(deps, name) {
745
+ if (deps.ask === true && name === ASK_TOOL_NAME) {
746
+ return 'ask';
747
+ }
748
+ if (deps.skills !== undefined && name === SKILL_TOOL_NAME) {
749
+ return 'skill';
750
+ }
751
+ if (memoryIsWritable(deps) && name === REMEMBER_TOOL_NAME) {
752
+ return 'memory';
753
+ }
754
+ return deps.registry.spec(name)?.kind ?? 'read';
755
+ }
756
+ /**
757
+ * Settle an `ask` call against a human. The model authored the questions, so unlike the configured
758
+ * intake there is nothing to persist first — `persist:toolcall` already wrote the row, under the same
759
+ * name and at the same position any other kind would have used.
760
+ *
761
+ * Costs nothing beyond the step that produced it: the question set arrived as the ARGUMENTS of a tool
762
+ * call the model was already making, in a step already billed as `chat`. There is no second model
763
+ * call and therefore no usage row of its own — the opposite of the structured-output pass.
764
+ *
765
+ * The two new positions here (`stream:elicitation:<id>`, and the wait) are reachable only when this
766
+ * call's journaled kind is `ask`, which no run recorded before the kind existed. Every replay reads
767
+ * that kind back rather than re-deciding it, so no run can disagree about whether they are there.
768
+ */
769
+ async function elicitToolCall(turn, claimed) {
770
+ const { deps, hooks } = turn;
771
+ const { call, ctx } = claimed;
772
+ // Validated HERE rather than by the registry, which never sees this tool. Deterministic on the
773
+ // same footing as `validateStructured`: a module-constant schema over an input the `llm:<i>`
774
+ // checkpoint already holds, so every replay reaches the same verdict — and the same branch.
775
+ const parsed = await askInputSchema['~standard'].validate(call.input);
776
+ if (parsed.issues !== undefined) {
777
+ const error = `invalid ask input: ${parsed.issues
778
+ .map((each) => `${(each.path ?? []).join('.') || '(root)'}: ${each.message}`)
779
+ .join('; ')}`;
780
+ await hooks.step(`persist:toolfail:${call.id}`, () => deps.store.updateToolCall({ toolCallId: call.id, status: 'failed', error }));
781
+ // A malformed question set is the model's mistake to fix, so it comes back as a tool failure —
782
+ // the vocabulary the model already knows how to answer — rather than failing the run.
783
+ return { id: call.id, name: call.name, output: null, error };
784
+ }
785
+ const request = {
786
+ id: call.id,
787
+ source: 'ask',
788
+ questions: parsed.value.questions,
789
+ ...(parsed.value.preamble !== undefined ? { preamble: parsed.value.preamble } : {}),
790
+ };
791
+ await hooks.step(`stream:elicitation:${call.id}`, () => Promise.resolve(turn.writer.write({ t: 'elicitation', runId: hooks.runId, id: call.id, request })));
792
+ const reply = await awaitElicitation({ hooks, request, ctx });
793
+ const result = settleElicitation({ request, reply });
794
+ await hooks.step(result.skipped ? `persist:toolreject:${call.id}` : `persist:toolexec:${call.id}`, () => deps.store.updateToolCall({
795
+ toolCallId: call.id,
796
+ status: result.skipped ? 'rejected' : 'executed',
797
+ output: result,
798
+ ...(result.skipped ? { error: 'skipped by the user' } : {}),
799
+ ...(reply.answeredByRef !== undefined ? { executedByRef: reply.answeredByRef } : {}),
800
+ }));
801
+ publishAgentToolCall({
802
+ runId: hooks.runId,
803
+ toolName: call.name,
804
+ toolType: 'action',
805
+ status: result.skipped ? 'rejected' : 'executed',
806
+ });
807
+ return { id: call.id, name: call.name, output: result };
808
+ }
809
+ /** The turn's identity as the skills seam sees it — the same inputs the prompt is resolved from. */
810
+ function skillContext(input) {
811
+ return {
812
+ actor: input.actor,
813
+ threadId: input.threadId,
814
+ ...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
815
+ ...(input.pageContext !== undefined ? { pageContext: input.pageContext } : {}),
816
+ };
817
+ }
818
+ /**
819
+ * Serve one `skill` call: read the body and hand it to the model as an ordinary tool result.
820
+ *
821
+ * WHY THE BODY IS RETURNED FROM THE CHECKPOINT. Which instructions entered a turn's prompt is a
822
+ * decision about the turn, and a decision about a turn has to be readable from its journal — the
823
+ * same property `persist:toolcall` gives the read/action branch. A replay that re-read the provider
824
+ * would compose a DIFFERENT prompt from a skill edited in between, at a transcript position the
825
+ * history already holds: the model would then be answering something nobody can reconstruct from the
826
+ * record. Inside `tool:<callId>` the first attempt's text is the only text there ever was.
827
+ *
828
+ * The positions are a read tool's, exactly (`persist:toolcall` before, `tool:<id>` here,
829
+ * `persist:toolexec:<id>` / `persist:toolfail:<id>` after), so nothing about the shape of a turn
830
+ * depends on whether a call was a skill — only on the kind the journal recorded.
831
+ */
832
+ async function loadSkillIntoTurn(turn, claimed) {
833
+ const { deps, input, hooks } = turn;
834
+ const { call } = claimed;
835
+ const config = deps.skills;
836
+ const offer = turn.skills;
837
+ const outcome = await hooks.step(`tool:${call.id}`, async () => {
838
+ if (config === undefined || offer === undefined) {
839
+ // Reachable where a call's journaled kind is `skill` but this process has no skills
840
+ // configured. A tool failure, because it is the one vocabulary the model can act on, and
841
+ // because failing the run would strand a turn over a lookup.
842
+ return { ok: false, error: 'Skills are not available in this deployment.' };
843
+ }
844
+ const parsed = await skillInputSchema['~standard'].validate(call.input);
845
+ if (parsed.issues !== undefined) {
846
+ return {
847
+ ok: false,
848
+ error: `invalid skill input: ${parsed.issues
849
+ .map((each) => `${(each.path ?? []).join('.') || '(root)'}: ${each.message}`)
850
+ .join('; ')}`,
851
+ };
852
+ }
853
+ const loaded = await loadSkill({
854
+ config,
855
+ offer,
856
+ name: parsed.value.name,
857
+ ctx: skillContext(input),
858
+ });
859
+ if (!loaded.ok) {
860
+ return { ok: false, error: loaded.error };
861
+ }
862
+ return {
863
+ ok: true,
864
+ output: {
865
+ name: loaded.skill.name,
866
+ scope: loaded.skill.scope,
867
+ body: loaded.skill.body,
868
+ ...(loaded.shadows !== undefined ? { shadows: loaded.shadows } : {}),
869
+ },
870
+ };
871
+ });
872
+ return outcome.ok
873
+ ? { status: 'executed', output: outcome.output }
874
+ : { status: 'failed', error: outcome.error };
875
+ }
876
+ /**
877
+ * Serve one `remember` call: write the fact and hand the stored record back as an ordinary tool
878
+ * result.
879
+ *
880
+ * WHY THE WRITE HAPPENS INSIDE THE CHECKPOINT. Two reasons, and the second is the one that is easy
881
+ * to miss. It makes the write idempotent under replay — a resumed run reads the record back from
882
+ * `tool:<callId>` instead of storing a second copy of a fact the model only decided once. And it
883
+ * makes what the model was TOLD about the write part of the journal, so the transcript a later step
884
+ * reads is the one the first attempt built rather than whatever a second write would have returned.
885
+ *
886
+ * The positions are a read tool's, exactly, so nothing about the shape of a turn depends on whether
887
+ * a call wrote a memory — only on the kind the journal recorded.
888
+ */
889
+ async function rememberIntoTurn(turn, claimed) {
890
+ const { deps, input, hooks } = turn;
891
+ const { call } = claimed;
892
+ const config = deps.memory;
893
+ const digest = turn.memory;
894
+ const outcome = await hooks.step(`tool:${call.id}`, async () => {
895
+ if (config === undefined || digest === undefined) {
896
+ // Reachable where a call's journaled kind is `memory` but this process has none configured.
897
+ // A tool failure, because it is the one vocabulary the model can act on, and because failing
898
+ // the run would strand a turn over a lookup.
899
+ return { ok: false, error: 'Memory is not available in this deployment.' };
900
+ }
901
+ const parsed = await rememberInputSchema['~standard'].validate(call.input);
902
+ if (parsed.issues !== undefined) {
903
+ return {
904
+ ok: false,
905
+ error: `invalid remember input: ${parsed.issues
906
+ .map((each) => `${(each.path ?? []).join('.') || '(root)'}: ${each.message}`)
907
+ .join('; ')}`,
908
+ };
909
+ }
910
+ return writeMemory({
911
+ config,
912
+ digest,
913
+ call: parsed.value,
914
+ ctx: skillContext(input),
915
+ runId: hooks.runId,
916
+ });
917
+ });
918
+ return outcome.ok
919
+ ? { status: 'executed', output: outcome.record }
920
+ : { status: 'failed', error: outcome.error };
921
+ }
922
+ /**
923
+ * Run one claimed call's INVOCATION, whatever kind it is. A `skill` reads the catalog the journal
924
+ * holds instead of the registry; everything that reaches here spends the same `tool:<id>` position
925
+ * either way, which is what lets the two be batched together.
926
+ */
927
+ function invokeClaimed(turn, claimed) {
928
+ if (claimed.toolType === 'skill') {
929
+ return loadSkillIntoTurn(turn, claimed);
930
+ }
931
+ return claimed.toolType === 'memory'
932
+ ? rememberIntoTurn(turn, claimed)
933
+ : invokeClaimedTool(turn, claimed);
934
+ }
935
+ /** Everything a claimed call still needs, on its own: delegation or approval, then execute. */
936
+ async function runClaimedToolCall(turn, claimed) {
937
+ const { deps, hooks } = turn;
938
+ const { call, toolType, ctx } = claimed;
939
+ if (toolType === 'agent') {
940
+ return delegateToolCall(turn, claimed);
941
+ }
942
+ if (toolType === 'ask') {
943
+ return elicitToolCall(turn, claimed);
944
+ }
945
+ if (toolType === 'action') {
946
+ const decision = await awaitDecision({ hooks, call, ctx });
947
+ if (!decision.approved) {
948
+ await hooks.step(`persist:toolreject:${call.id}`, () => deps.store.updateToolCall({
949
+ toolCallId: call.id,
950
+ status: 'rejected',
951
+ ...(decision.reason !== undefined ? { error: decision.reason } : {}),
952
+ }));
953
+ publishAgentToolCall({
954
+ runId: hooks.runId,
955
+ toolName: call.name,
956
+ toolType,
957
+ status: 'rejected',
958
+ });
959
+ return {
960
+ id: call.id,
961
+ name: call.name,
962
+ output: { rejected: true, reason: decision.reason ?? DEFAULT_REFUSAL_REASON },
963
+ denied: true,
964
+ // What the MODEL is told. The bare word `rejected` names no actor and reads exactly like a
965
+ // tool that blew up, so the answer that follows speculates about causes ("the key may not
966
+ // exist", "there may be permission restrictions") and offers to run it again. A person's
967
+ // decision is not a fault to diagnose, so this says who decided, that nothing ran, and what
968
+ // not to do next.
969
+ error: refusalNarrative(decision.reason),
970
+ };
971
+ }
972
+ }
973
+ return recordToolOutcome(turn, claimed, await invokeClaimed(turn, claimed));
974
+ }
975
+ /** Stored as the reason when someone declines without giving one — a placeholder, not a quote. */
976
+ export const DEFAULT_REFUSAL_REASON = 'rejected by user';
977
+ /**
978
+ * How a refusal is put to the model. Written as instructions rather than as a status because the
979
+ * model's next move is the whole problem: told only that something was "rejected", it treats the
980
+ * refusal as a fault, lists possible causes it cannot check, and offers to retry the same action —
981
+ * which asks the person to say no twice.
982
+ */
983
+ function refusalNarrative(reason) {
984
+ const base = 'The person was asked to approve this action and declined it. Nothing ran and nothing changed. ' +
985
+ 'This is their decision, not an error, a missing record or a permissions problem — do not ' +
986
+ 'explain it as one, do not guess at causes, and do not run this action again or reach for ' +
987
+ 'another way to do the same thing. Acknowledge the decision and ask what they would like instead.';
988
+ return reason === undefined ? base : `${base} They said: ${reason}`;
989
+ }
990
+ /**
991
+ * Overlap the INVOCATIONS of a batch of claimed calls, and only those. The persist checkpoints on
992
+ * either side stay strictly sequential, in call order — they are asked for after their neighbours'
993
+ * positions are already spent, so nothing about them depends on which tool finished first.
994
+ *
995
+ * A rejection here is the runner unwinding the turn, never a tool's own failure
996
+ * ({@link invokeClaimedTool} reports those). Rethrow the first one in call order and persist
997
+ * NOTHING: the resume replays this whole block, and a `persist:toolexec` written now would sit at
998
+ * the position the replay computes for an earlier call's.
999
+ */
1000
+ async function invokeClaimedToolsTogether(turn, parallel, claimed) {
1001
+ const settled = await parallel(claimed.map((entry) => () => invokeClaimed(turn, entry)));
1002
+ for (const outcome of settled) {
1003
+ if (!outcome.ok) {
1004
+ throw outcome.error;
1005
+ }
1006
+ }
1007
+ const results = [];
1008
+ for (const [index, entry] of claimed.entries()) {
1009
+ const outcome = settled[index];
1010
+ if (outcome?.ok === true) {
1011
+ results.push(await recordToolOutcome(turn, entry, outcome.value));
1012
+ }
1013
+ }
1014
+ return results;
1015
+ }
1016
+ /**
1017
+ * What the formatting pass is shown. By default the QUESTION and the ANSWER — the pass is a
1018
+ * translation of the answer, and its own instruction tells the model to use only what the
1019
+ * conversation already contains, so handing it the whole transcript again would roughly double what
1020
+ * a turn with an `outputSchema` costs for nothing. See {@link AgentLoopDeps.outputFromTranscript}
1021
+ * for the agent that genuinely needs more, and why it is off by default.
1022
+ *
1023
+ * The question comes off the PROCESSED prompt rather than `AgentRunInput.userText`: this pass is a
1024
+ * second route out of the model and has to stand behind the same input chain the streamed turn did,
1025
+ * so a question a processor masked cannot reappear in the clear here.
1026
+ */
1027
+ function restatementPrompt(args) {
1028
+ const restated = { role: 'assistant', content: args.answer };
1029
+ if (args.fromTranscript) {
1030
+ return [...args.messages, restated];
1031
+ }
1032
+ // The last user message that actually SAYS something. The loop feeds a step's tool results back
1033
+ // as an empty `user` message carrying `toolResults`, so scanning for the last `user` role alone
1034
+ // would hand the pass a blank question on every turn that called a tool.
1035
+ for (let index = args.messages.length - 1; index >= 0; index -= 1) {
1036
+ const message = args.messages[index];
1037
+ if (message?.role === 'user' && message.content.length > 0) {
1038
+ return [message, restated];
1039
+ }
1040
+ }
1041
+ return [restated];
1042
+ }
1043
+ /**
1044
+ * Restate a finished answer as `deps.outputSchema`, repairing a rejected reply up to
1045
+ * `outputRepairAttempts` times. Every model call is its own checkpoint (`structured:<step>:<n>`)
1046
+ * with its own usage row, so a suspend between two attempts resumes on the reply the first one got
1047
+ * rather than paying for a third.
1048
+ *
1049
+ * Validation itself sits OUTSIDE the checkpoints, on the same footing as `HistoryWindow.select`: its
1050
+ * inputs are the schema (module config, identical on every process of a deployment) and a reply a
1051
+ * checkpoint already holds, so every replay reaches the same verdict — and therefore the same number
1052
+ * of attempts — without a checkpoint of its own.
1053
+ *
1054
+ * THE OUTPUT CHAIN RULES ON EACH ATTEMPT, before the schema does. This pass is a second route out of
1055
+ * the model, so anything the chain held back from the prose must not be able to leave through here.
1056
+ * It is gated on the WHOLE reply regardless of what the chain declared, because nothing here is
1057
+ * streamed — there is no prefix for an `incremental` processor to release.
1058
+ */
1059
+ async function structureAnswer(args) {
1060
+ const { schema, deps, input, hooks, messages, step, ctx } = args;
1061
+ const instruction = deps.outputInstruction ?? DEFAULT_STRUCTURED_OUTPUT_INSTRUCTION;
1062
+ const maxAttempts = 1 + (deps.outputRepairAttempts ?? 1);
1063
+ const outputProcessors = deps.outputProcessors ?? [];
1064
+ let issues = [];
1065
+ let text = '';
1066
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
1067
+ const system = attempt === 0 ? instruction : repairInstruction({ instruction, issues });
1068
+ const reply = await hooks.step(`structured:${step}:${attempt}`, async () => {
1069
+ // A sink that drops every frame: this call is not the turn's answer, so its tokens must never
1070
+ // reach the reader's stream alongside the prose they are restating.
1071
+ const discard = { write: () => { }, end: () => { } };
1072
+ const turn = await deps.model.runTurn({
1073
+ system,
1074
+ messages,
1075
+ tools: [],
1076
+ sink: discard,
1077
+ outputSchema: schema,
1078
+ });
1079
+ return {
1080
+ text: turn.text,
1081
+ usage: turn.usage,
1082
+ ...(turn.object !== undefined ? { object: turn.object } : {}),
1083
+ ...(turn.modelId !== undefined ? { modelId: turn.modelId } : {}),
1084
+ };
1085
+ });
1086
+ await hooks.step(`persist:usage:structured:${step}:${attempt}`, () => deps.store.recordUsage({
1087
+ threadId: input.threadId,
1088
+ actorRef: input.actor.id,
1089
+ runId: hooks.runId,
1090
+ modelId: reply.modelId ?? deps.modelId ?? 'unknown',
1091
+ purpose: 'structured_output',
1092
+ usage: reply.usage,
1093
+ }));
1094
+ text = reply.text;
1095
+ if (outputProcessors.length > 0) {
1096
+ // Its own checkpoint, after the usage row: the tokens were spent either way, and a refusal
1097
+ // that also hid its own cost would let a mis-tuned gate burn a budget invisibly — the same
1098
+ // ordering the streamed answer's gate takes.
1099
+ const gate = await hooks.step(`process:output:structured:${step}:${attempt}`, () => runOutputProcessors({
1100
+ processors: outputProcessors,
1101
+ answer: { text: reply.text, toolCalls: [] },
1102
+ ctx,
1103
+ }));
1104
+ if (gate.rejection !== undefined) {
1105
+ throw new OutputRejectedError(gate.rejection.processor, gate.rejection.reason);
1106
+ }
1107
+ text = gate.text;
1108
+ }
1109
+ // A provider that constrained its own generation reports a parsed `object`, but that object
1110
+ // describes the reply the chain has just rewritten. Once the two disagree, the gated text is the
1111
+ // only version anything downstream may see, so it is re-parsed rather than trusted.
1112
+ const reported = text === reply.text ? reply.object : undefined;
1113
+ const outcome = await validateStructured({ schema, text, reported });
1114
+ if (outcome.ok) {
1115
+ return outcome.value;
1116
+ }
1117
+ issues = outcome.issues;
1118
+ }
1119
+ throw new StructuredOutputError(issues, text, maxAttempts);
1120
+ }
78
1121
  /**
79
1122
  * The provider-agnostic agent turn, reused by both the inline and durable runners.
80
1123
  * It drives the model→tools→model iteration; the runner supplies the `step`/`awaitApproval`
@@ -84,6 +1127,17 @@ export async function runAgentLoop(deps, input, hooks) {
84
1127
  const maxSteps = deps.maxSteps ?? 8;
85
1128
  const persona = input.persona;
86
1129
  let system = await resolveSystemPrompt(deps, input);
1130
+ const inputProcessors = deps.inputProcessors ?? [];
1131
+ const outputProcessors = deps.outputProcessors ?? [];
1132
+ const gateMode = resolveOutputGateMode(outputProcessors);
1133
+ const gateLookback = resolveGateLookback(outputProcessors);
1134
+ const processorContext = (step) => ({
1135
+ threadId: input.threadId,
1136
+ actor: input.actor,
1137
+ step,
1138
+ ...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
1139
+ ...(persona !== undefined ? { persona: persona.id } : {}),
1140
+ });
87
1141
  // Open the run (turn) row FIRST — before the quota gate — so even a quota-rejected run is tracked
88
1142
  // (the runner settles it `failed`). A checkpointed step so a durable replay reuses the ONE row (no
89
1143
  // duplicate on resume); `run_id` is then stamped onto every message / tool call / usage row below.
@@ -93,6 +1147,7 @@ export async function runAgentLoop(deps, input, hooks) {
93
1147
  actor: input.actor,
94
1148
  durable: hooks.durable ?? false,
95
1149
  ...(input.agentName !== undefined ? { agentName: input.agentName } : {}),
1150
+ ...(input.parentRunId !== undefined ? { parentRunId: input.parentRunId } : {}),
96
1151
  }));
97
1152
  if (deps.quota !== undefined) {
98
1153
  const quota = deps.quota;
@@ -112,23 +1167,21 @@ export async function runAgentLoop(deps, input, hooks) {
112
1167
  ...(persona !== undefined ? { persona: persona.id } : {}),
113
1168
  ...(input.attachments !== undefined ? { attachments: input.attachments } : {}),
114
1169
  }));
115
- const thread = await hooks.step('load:thread', () => deps.store.getThread(input.threadId));
116
- const fullHistory = (thread?.messages ?? []).map((message) => ({
117
- role: message.role,
118
- content: message.content,
119
- ...(message.toolCalls !== undefined ? { toolCalls: message.toolCalls } : {}),
120
- ...(message.toolResults !== undefined ? { toolResults: message.toolResults } : {}),
121
- ...(message.attachments !== undefined ? { attachments: message.attachments } : {}),
122
- }));
123
- // Compaction runs INSIDE the step body (replay-safe): a summarizing impl spends its own tokens,
124
- // which a durable replay must never pay twice. With no window configured this is the identity
125
- // function, so an existing deployment sends byte-identical history.
126
- const modelMessages = deps.historyWindow !== undefined
127
- ? await hooks.step('history:window', async () => deps.historyWindow.apply(fullHistory, {
128
- actor: input.actor,
129
- threadId: input.threadId,
130
- }))
131
- : fullHistory;
1170
+ const thread = threadForTurn(await hooks.step('load:thread', () => readThreadForTurn(deps, input.threadId, turnMessageLimit(deps.historyWindow))));
1171
+ // With no window configured this whole block is skipped, so an existing deployment sends the
1172
+ // messages `load:thread` returned and records byte-identical checkpoints.
1173
+ let modelMessages = thread.messages;
1174
+ if (deps.historyWindow !== undefined) {
1175
+ const window = deps.historyWindow;
1176
+ modelMessages = (await (hooks.patched?.(HISTORY_SELECT_PATCH) ?? Promise.resolve(true)))
1177
+ ? await applyHistoryWindow(window, deps, input, hooks, thread.messages, hooks.step)
1178
+ : // A run whose history holds the single `history:window` checkpoint replays against it: a
1179
+ // completed one returns its recorded messages without re-running anything, and one that has
1180
+ // yet to reach it runs the whole fold INSIDE that one checkpoint — nested checkpoints would
1181
+ // take positions the recorded shape never had, and go missing the moment the outer one
1182
+ // replays from cache.
1183
+ await hooks.step('history:window', () => applyHistoryWindow(window, deps, input, hooks, thread.messages, (_name, fn) => fn()));
1184
+ }
132
1185
  const writer = await hooks.openSink();
133
1186
  let lastText = '';
134
1187
  let steps = 0;
@@ -138,12 +1191,45 @@ export async function runAgentLoop(deps, input, hooks) {
138
1191
  // priced). If NO step had a cost, the run's `cost_usd` stays `null` — never a fabricated `0`.
139
1192
  let totalCost = 0;
140
1193
  let hasCost = false;
1194
+ let structured;
141
1195
  publishAgentRunStarted({
142
1196
  runId: hooks.runId,
143
1197
  threadId: input.threadId,
144
1198
  actorId: input.actor.id,
145
1199
  ...(persona !== undefined ? { persona: persona.id } : {}),
146
1200
  });
1201
+ // What the assistant already believes about this person, folded in ABOVE the retrieved passages
1202
+ // and the skills catalog — most durable first, so a reader meets the standing facts before the
1203
+ // passages this one question pulled.
1204
+ //
1205
+ // One checkpoint, holding the WHOLE digest: the scopes the resolver returned and the entries that
1206
+ // survived selection, precedence and the ceiling. That payload is what the block is rendered from
1207
+ // AND what a later `remember` call is authorized against — so which memories entered this prompt,
1208
+ // and which scopes this turn could write, are facts the journal holds rather than answers a
1209
+ // replaying process's provider would give afresh. Where the host runs an index, the SEARCH happens
1210
+ // in here too: a ranking is the most re-derivable decision in this library, so a replay has to
1211
+ // read its result back rather than ask an index that has since moved.
1212
+ //
1213
+ // THE QUERY IS THE USER'S OWN MESSAGE, and nothing else. It is the only thing available before the
1214
+ // first model call — which is where memory has to be, since a memory is worthless unless it is in
1215
+ // front of the model on the turn nobody thought to look — and it is already a journaled input to
1216
+ // this run, so it needs no determinism machinery of its own. What it fails at is a turn with no
1217
+ // topic ("and the other thing?"): `pinned` is the answer to those, not a cleverer query.
1218
+ let memoryDigest;
1219
+ if (deps.memory !== undefined) {
1220
+ const config = deps.memory;
1221
+ memoryDigest = await hooks.step('memory:digest', () => offerMemories({ config, ctx: skillContext(input), query: input.userText }));
1222
+ // No entries, no block: an actor the assistant has concluded nothing about pays nothing, rather
1223
+ // than reading a heading over an empty list and inferring something from its presence.
1224
+ const digest = memoryDigest;
1225
+ if (digest.entries.length > 0) {
1226
+ system = `${system}\n\n${buildMemoryBlock({
1227
+ entries: digest.entries,
1228
+ writable: memoryIsWritable(deps),
1229
+ partial: digest.omitted > 0 || digest.recalled === true,
1230
+ })}`;
1231
+ }
1232
+ }
147
1233
  // Inject-mode RAG: retrieve once for the user message and fold the passages into the system prompt.
148
1234
  // Wrapped in `hooks.step` so a durable replay reuses the SAME passages (no re-query, deterministic
149
1235
  // prompt). Recorded below as a synthetic auto-executed `retrieve` tool call on the first assistant
@@ -206,6 +1292,25 @@ export async function runAgentLoop(deps, input, hooks) {
206
1292
  count: passages.length,
207
1293
  });
208
1294
  }
1295
+ // The skills catalog, LAST of the things that write the system block (base prompt, persona,
1296
+ // retrieved context, this) — so a reader of the assembled prompt meets the agent's own
1297
+ // instructions before the menu of ones it could go and fetch.
1298
+ //
1299
+ // One checkpoint, holding the WHOLE offer: the scopes the resolver returned and the entries that
1300
+ // survived precedence. That payload is what the block is rendered from and what a later `skill`
1301
+ // call is served against, so the two things a turn's prompt depends on — which scopes applied, and
1302
+ // which skills they yielded — are facts the journal holds rather than answers a replaying
1303
+ // process's provider would give afresh.
1304
+ let skillOffer;
1305
+ if (deps.skills !== undefined) {
1306
+ const config = deps.skills;
1307
+ skillOffer = await hooks.step('skills:catalog', () => offerSkills(config, skillContext(input)));
1308
+ // No entries, no block: an actor whose scopes yield nothing pays nothing, rather than reading a
1309
+ // heading over an empty list and wondering what it was for.
1310
+ if (skillOffer.entries.length > 0) {
1311
+ system = `${system}\n\n${buildSkillsBlock(skillOffer.entries)}`;
1312
+ }
1313
+ }
209
1314
  // Fetched ONCE per run (not per step) and reused for every step's cost estimate below. Returned as
210
1315
  // a plain array (not the Map built from it) so a durable runner can JSON-cache the step's result.
211
1316
  let prices = [];
@@ -214,21 +1319,126 @@ export async function runAgentLoop(deps, input, hooks) {
214
1319
  prices = await hooks.step('pricing:list', () => pricingStore.listCurrentPrices());
215
1320
  }
216
1321
  const priceByModel = new Map(prices.map((price) => [price.modelId, price]));
1322
+ // The configured intake, LAST before the first model call: everything the run records about itself
1323
+ // (the run row, retrieval, prices) is settled before the turn parks on a person, who may take a
1324
+ // day. The whole block is reachable only through `deps.intake`.
1325
+ if (deps.intake !== undefined) {
1326
+ const asked = await runIntake({
1327
+ intake: deps.intake,
1328
+ deps,
1329
+ input,
1330
+ hooks,
1331
+ writer,
1332
+ // Whether this thread had already been answered when the turn began — taken off
1333
+ // `load:thread`'s CACHED result so a replay decides the intake the way the first attempt did.
1334
+ threadHasAssistant: thread.hasAssistantMessage,
1335
+ });
1336
+ if (asked !== null) {
1337
+ modelMessages.push(asked);
1338
+ }
1339
+ }
217
1340
  // NOTE: no try/finally around this loop. A durable runner suspends by THROWING through the stack
218
1341
  // at `awaitApproval` (ctx.waitForSignal); a finally would then call writer.end() on every suspend
219
1342
  // and prematurely close the live stream. We only end on normal completion — the throw propagates
220
1343
  // to the engine, and the resumed replay reaches the writer.end() below.
221
1344
  for (let i = 0; i < maxSteps; i += 1) {
222
- const tools = await deps.registry.definitionsFor(input.actor, deps.rolesPolicy, intersectAllow(persona?.allowedTools, deps.toolAllowList));
1345
+ const tools = withBuiltInTools({
1346
+ tools: await deps.registry.definitionsFor(input.actor, deps.rolesPolicy, intersectAllow(persona?.allowedTools, deps.toolAllowList)),
1347
+ ask: deps.ask,
1348
+ skills: skillOffer !== undefined,
1349
+ memory: memoryDigest !== undefined && memoryIsWritable(deps),
1350
+ });
1351
+ // Every step, not once per run: the transcript grows between steps, so a processor that only saw
1352
+ // the opening prompt would wave through whatever a tool result carried back. The result is a
1353
+ // per-step DERIVED prompt — `modelMessages` stays the loop's canonical transcript, so a redaction
1354
+ // never becomes the thread's own memory of what was said.
1355
+ let prompt = { system, messages: modelMessages };
1356
+ if (inputProcessors.length > 0) {
1357
+ prompt = await hooks.step(`process:input:${i}`, () => runInputProcessors({ processors: inputProcessors, prompt, ctx: processorContext(i) }));
1358
+ }
223
1359
  // Span the model call INSIDE the step body (replay-safe). The turn's raw text/output never rides
224
1360
  // the span — only token counts + name/length metadata (the point events' redaction posture).
225
- const turn = await hooks.step(`llm:${i}`, () => spannedAgent('llm.turn', hooks.runId, { runId: hooks.runId, step: i }, () => deps.model.runTurn({ system, messages: modelMessages, tools, sink: writer }), (result) => ({
1361
+ // Stamped from THIS process's registry, which is the one that built `tools` for the call above —
1362
+ // and stamped INSIDE the checkpoint, so the kinds are journaled with the calls they describe
1363
+ // rather than re-derived by whatever process replays this turn.
1364
+ let turn = await hooks.step(`llm:${i}`, async () => stampToolKinds(await spannedAgent('llm.turn', hooks.runId, { runId: hooks.runId, step: i }, async () => {
1365
+ const incremental = gateMode === 'incremental'
1366
+ ? createIncrementalGate({
1367
+ processors: outputProcessors,
1368
+ ctx: processorContext(i),
1369
+ lookbackChars: gateLookback,
1370
+ writer,
1371
+ })
1372
+ : undefined;
1373
+ const buffer = gateMode === 'whole' ? createFrameBuffer() : undefined;
1374
+ const result = await deps.model.runTurn({
1375
+ system: prompt.system,
1376
+ messages: prompt.messages,
1377
+ tools,
1378
+ sink: incremental?.writer ?? buffer?.writer ?? writer,
1379
+ });
1380
+ if (incremental !== undefined) {
1381
+ await incremental.settled();
1382
+ const refusal = incremental.rejection();
1383
+ return {
1384
+ ...result,
1385
+ releasedText: incremental.released(),
1386
+ ...(refusal !== undefined ? { gateRejection: refusal } : {}),
1387
+ };
1388
+ }
1389
+ return buffer === undefined ? result : { ...result, bufferedFrames: buffer.frames() };
1390
+ }, (result) => ({
226
1391
  ...(result.modelId !== undefined ? { modelId: result.modelId } : {}),
227
1392
  inputTokens: result.usage.inputTokens,
228
1393
  outputTokens: result.usage.outputTokens,
229
1394
  textLength: result.text.length,
230
1395
  toolCalls: result.toolCalls.length,
231
- })));
1396
+ })), deps));
1397
+ // The gate runs before ANYTHING downstream: before the held frames reach the subscriber, before
1398
+ // the assistant message is persisted, before this text becomes the next step's context.
1399
+ let rejection;
1400
+ if (gateMode !== 'off') {
1401
+ const gate = await hooks.step(`process:output:${i}`, async () => {
1402
+ // Which release this turn still owes is read from the JOURNALED model result, never from the
1403
+ // chain configured on whichever process is running now: `releasedText` is the record that a
1404
+ // prefix already reached the reader, so a run that resumes under a re-declared chain cannot
1405
+ // flush the same answer a second time.
1406
+ const released = turn.releasedText;
1407
+ // A refusal an incremental gate already reached. Re-running the chain would bill the same
1408
+ // verdict twice.
1409
+ if (turn.gateRejection !== undefined) {
1410
+ return { text: turn.text, rejection: turn.gateRejection };
1411
+ }
1412
+ const settled = await runOutputProcessors({
1413
+ processors: outputProcessors,
1414
+ answer: { text: turn.text, toolCalls: turn.toolCalls },
1415
+ ctx: processorContext(i),
1416
+ });
1417
+ // Releasing inside the same checkpoint as the verdict is what makes "refused" and "nothing
1418
+ // was streamed" one fact rather than two that a suspend could separate. For an incremental
1419
+ // turn the second half of that is weaker by construction — a prefix is already out — and the
1420
+ // whole-answer pass stays authoritative for both the stream and the store.
1421
+ if (settled.rejection === undefined) {
1422
+ if (released === undefined) {
1423
+ for (const frame of releaseGatedFrames({
1424
+ frames: turn.bufferedFrames ?? [],
1425
+ text: settled.text,
1426
+ })) {
1427
+ await writer.write(frame);
1428
+ }
1429
+ }
1430
+ else {
1431
+ const tail = gateTail({ processors: outputProcessors, released, text: settled.text });
1432
+ if (tail.length > 0) {
1433
+ await writer.write({ t: 'text', v: tail });
1434
+ }
1435
+ }
1436
+ }
1437
+ return settled;
1438
+ });
1439
+ rejection = gate.rejection;
1440
+ turn = { ...turn, text: gate.text };
1441
+ }
232
1442
  // provider-reported model wins over the configured fallback, so cost can't misattribute
233
1443
  const resolvedModelId = turn.modelId ?? deps.modelId ?? 'unknown';
234
1444
  // Provider-reported spend wins; else an estimate from the (once-per-run cached) price list; else
@@ -254,16 +1464,72 @@ export async function runAgentLoop(deps, input, hooks) {
254
1464
  const quota = deps.quota;
255
1465
  await hooks.step(`quota:bump:${i}`, () => quota.bump(input.actor.id, deps.day, turn.usage.inputTokens + turn.usage.outputTokens));
256
1466
  }
1467
+ // Thrown only AFTER the two accounting checkpoints above: those tokens were genuinely spent, and
1468
+ // a refusal that also hid its own cost would let a mis-tuned gate burn a budget invisibly.
1469
+ if (rejection !== undefined) {
1470
+ throw new OutputRejectedError(rejection.processor, rejection.reason);
1471
+ }
257
1472
  steps += 1;
258
1473
  totalInput += turn.usage.inputTokens;
259
1474
  totalOutput += turn.usage.outputTokens;
260
1475
  lastText = turn.text;
1476
+ // Restating the GATED text, never the model's raw reply: the formatting pass is a translation of
1477
+ // the answer that survived the output chain, not a second route out of the model.
1478
+ if (turn.toolCalls.length === 0 && deps.outputSchema !== undefined) {
1479
+ structured = await structureAnswer({
1480
+ schema: deps.outputSchema,
1481
+ deps,
1482
+ input,
1483
+ hooks,
1484
+ messages: restatementPrompt({
1485
+ messages: prompt.messages,
1486
+ answer: turn.text,
1487
+ fromTranscript: deps.outputFromTranscript === true,
1488
+ }),
1489
+ step: i,
1490
+ ctx: processorContext(i),
1491
+ });
1492
+ }
261
1493
  publishAgentMessage({
262
1494
  runId: hooks.runId,
263
1495
  threadId: input.threadId,
264
1496
  role: 'assistant',
265
1497
  textLength: turn.text.length,
266
1498
  });
1499
+ // Inject-mode retrieval and the structured answer both reach a reader as ordinary tool calls.
1500
+ // Their ids come off the RUN rather than the message they hang from, because the message does
1501
+ // not exist yet and both have to be on the append below: a reader pairs a call with its result
1502
+ // off the message, so a call added afterwards renders as a tool still running. Every value here
1503
+ // is already settled by a checkpoint this turn ran (`retrieve`, `structured:<step>:<n>`), so a
1504
+ // replay rebuilds the same pair rather than minting one.
1505
+ const synthetic = [];
1506
+ if (i === 0 && injectedPassages !== undefined) {
1507
+ const call = {
1508
+ id: `retrieve-${hooks.runId}`,
1509
+ name: 'retrieve',
1510
+ input: { query: input.userText },
1511
+ };
1512
+ const output = { passages: injectedPassages };
1513
+ synthetic.push({
1514
+ step: 'persist:retrieval',
1515
+ call,
1516
+ result: { id: call.id, name: call.name, output },
1517
+ });
1518
+ }
1519
+ if (structured !== undefined) {
1520
+ const call = {
1521
+ id: `structured-${hooks.runId}`,
1522
+ name: 'structured_output',
1523
+ input: {},
1524
+ };
1525
+ synthetic.push({
1526
+ step: 'persist:structured',
1527
+ call,
1528
+ result: { id: call.id, name: call.name, output: structured },
1529
+ });
1530
+ }
1531
+ const messageCalls = [...turn.toolCalls, ...synthetic.map((entry) => entry.call)];
1532
+ const syntheticResults = synthetic.map((entry) => entry.result);
267
1533
  const assistant = await hooks.step(`persist:assistant:${i}`, () => deps.store.appendMessage({
268
1534
  threadId: input.threadId,
269
1535
  role: 'assistant',
@@ -271,219 +1537,104 @@ export async function runAgentLoop(deps, input, hooks) {
271
1537
  runId: hooks.runId,
272
1538
  usage: { ...turn.usage, costUsd },
273
1539
  ...(persona !== undefined ? { persona: persona.id } : {}),
274
- ...(turn.toolCalls.length > 0 ? { toolCalls: turn.toolCalls } : {}),
1540
+ ...(messageCalls.length > 0 ? { toolCalls: messageCalls } : {}),
1541
+ ...(syntheticResults.length > 0 ? { toolResults: syntheticResults } : {}),
275
1542
  }));
276
1543
  modelMessages.push({
277
1544
  role: 'assistant',
278
1545
  content: turn.text,
279
- ...(turn.toolCalls.length > 0 ? { toolCalls: turn.toolCalls } : {}),
1546
+ ...(messageCalls.length > 0 ? { toolCalls: messageCalls } : {}),
1547
+ ...(syntheticResults.length > 0 ? { toolResults: syntheticResults } : {}),
280
1548
  });
281
- // Record inject-mode retrieval as a synthetic auto-executed `retrieve` tool call on the assistant
282
- // message it informed — so its passages persist and render as citations exactly like an agentic
283
- // search would, without a new message field. One durable step keeps replay from re-writing it.
284
- if (i === 0 && injectedPassages !== undefined) {
285
- const passages = injectedPassages;
286
- const toolCallId = `retrieve-${assistant.id}`;
287
- await hooks.step(`persist:retrieval:${assistant.id}`, async () => {
1549
+ for (const entry of synthetic) {
1550
+ const { call, result } = entry;
1551
+ await hooks.step(`${entry.step}:${assistant.id}`, async () => {
288
1552
  await deps.store.recordToolCall({
289
- toolCallId,
1553
+ toolCallId: call.id,
290
1554
  messageId: assistant.id,
291
- toolName: 'retrieve',
1555
+ toolName: call.name,
292
1556
  toolType: 'read',
293
- input: { query: input.userText },
1557
+ input: call.input,
294
1558
  status: 'auto_executed',
295
1559
  runId: hooks.runId,
296
1560
  });
297
- await deps.store.updateToolCall({ toolCallId, status: 'executed', output: { passages } });
1561
+ await deps.store.updateToolCall({
1562
+ toolCallId: call.id,
1563
+ status: 'executed',
1564
+ output: result.output,
1565
+ });
298
1566
  });
299
1567
  }
300
1568
  if (turn.toolCalls.length === 0) {
301
1569
  break;
302
1570
  }
303
1571
  const results = [];
304
- for (const call of turn.toolCalls) {
305
- const spec = deps.registry.spec(call.name);
306
- const toolType = spec?.kind ?? 'read';
307
- const ctx = {
308
- actor: input.actor,
309
- threadId: input.threadId,
310
- runId: hooks.runId,
311
- requestId: hooks.runId,
312
- emitComponent: (name, data) => writer.write({ t: 'component', name, data }),
313
- ...(persona !== undefined ? { persona } : {}),
314
- ...(input.pageContext !== undefined ? { pageContext: input.pageContext } : {}),
315
- ...(deps.host !== undefined ? { host: deps.host } : {}),
316
- };
317
- // Delegation: an `agent`-kind tool runs another agent. Handled at the LOOP level (not in a
318
- // step) because the durable runner maps it to `ctx.child`, a ctx-level suspend point.
319
- //
320
- // This bypasses `ToolRegistry.invoke`, so it must apply the same two gates `invoke` applies
321
- // itself — the role/ability re-check AND the offered-tools (persona/agent allow-list) filter
322
- // — BEFORE any persistence or event publication. A synthesized delegate spec
323
- // (`registerDelegateTools` in agent-deps-factory.ts) carries whatever `roles`/`ability` its
324
- // `delegatesTo` edge declared, and nothing when the edge is a bare string — under
325
- // `DefaultRolesPolicy` that is ADMIN-only, under an authz posture a tool with no `ability` is
326
- // always denied — so an unauthorized delegate call must fail closed here exactly like
327
- // `ToolRegistry.invoke` fails closed for every other tool kind.
328
- if (toolType === 'agent') {
329
- const targetAgent = spec?.targetAgent ?? call.name;
330
- let task;
331
- try {
332
- if (spec === undefined) {
333
- // Never actually reachable today (an unresolved name defaults `toolType` to 'read'
334
- // above), but an `agent`-kind call with no resolvable spec must fail closed rather than
335
- // fall through — and this narrows `spec` to `ToolSpec` for the `rolesPolicy.can` call
336
- // below.
337
- throw new ToolForbiddenError(call.name);
338
- }
339
- const offeredAllow = intersectAllow(persona?.allowedTools, deps.toolAllowList);
340
- if (offeredAllow !== undefined && !offeredAllow.includes(call.name)) {
341
- // A tool the model was not offered must not run, even if the model names it anyway.
342
- throw new ToolForbiddenError(call.name);
343
- }
344
- if (!(await deps.rolesPolicy.can(input.actor, spec))) {
345
- throw new ToolForbiddenError(call.name);
346
- }
347
- // The third gate `ToolRegistry.invoke` applies, in the same order: authorization first,
348
- // then shape. Delegation bypasses `invoke` (it is a ctx-level suspend point), so the gate
349
- // has to be re-applied here or a malformed input is silently coerced instead of rejected.
350
- const validation = await spec.inputSchema['~standard'].validate(call.input);
351
- if (validation.issues !== undefined) {
352
- throw new ToolInputInvalidError(call.name, validation.issues);
353
- }
354
- task = extractTask(validation.value);
355
- }
356
- catch (error) {
357
- const message = error instanceof Error ? error.message : String(error);
358
- // A denied delegation is recorded `failed` directly — never `auto_executed` even
359
- // transiently — and never emits `agent.delegated`.
360
- await hooks.step(`persist:toolcall:${call.id}`, () => deps.store.recordToolCall({
361
- toolCallId: call.id,
362
- messageId: assistant.id,
363
- toolName: call.name,
364
- toolType: 'read',
365
- input: call.input,
366
- status: 'failed',
367
- runId: hooks.runId,
368
- }));
369
- results.push({ id: call.id, name: call.name, output: null, error: message });
370
- continue;
371
- }
372
- await hooks.step(`persist:toolcall:${call.id}`, () => deps.store.recordToolCall({
373
- toolCallId: call.id,
374
- messageId: assistant.id,
375
- toolName: call.name,
376
- toolType: 'read',
377
- input: call.input,
378
- status: 'auto_executed',
379
- runId: hooks.runId,
380
- }));
381
- publishAgentDelegated({
382
- runId: hooks.runId,
383
- toAgent: targetAgent,
384
- ...(input.agentName !== undefined ? { fromAgent: input.agentName } : {}),
385
- });
386
- const sub = hooks.runAgent
387
- ? await hooks.runAgent(targetAgent, task)
388
- : { text: `(no multi-agent support wired; cannot reach "${targetAgent}")` };
389
- await hooks.step(`persist:toolexec:${call.id}`, () => deps.store.updateToolCall({ toolCallId: call.id, status: 'executed', output: sub }));
390
- results.push({ id: call.id, name: call.name, output: sub });
391
- continue;
1572
+ const turnCalls = {
1573
+ deps,
1574
+ input,
1575
+ hooks,
1576
+ messageId: assistant.id,
1577
+ writer,
1578
+ ...(skillOffer !== undefined ? { skills: skillOffer } : {}),
1579
+ ...(memoryDigest !== undefined ? { memory: memoryDigest } : {}),
1580
+ };
1581
+ // A model routinely asks for several tools at once, and running them back to back makes the
1582
+ // turn cost their sum. Overlapping them is safe here because a checkpoint position is handed
1583
+ // out on the CALL, not when the work settles: launching every invocation in one tick — what
1584
+ // `hooks.parallel` promises — pins the `tool:` block in call order however the tools then
1585
+ // finish. The claim and persist checkpoints stay sequential around it.
1586
+ //
1587
+ // Only a turn whose every call is a plain `read` qualifies:
1588
+ // - an `action` suspends on an approval signal, which is human time rather than I/O, so
1589
+ // overlapping what follows it buys nothing — and reserving an invocation position for a
1590
+ // call that may yet be REJECTED spends a position the rejected branch never fills.
1591
+ // - an `agent` delegation is `ctx.child`, whose parallel form is the runtime's own `ctx.all`:
1592
+ // one workflow ref over a reserved block, carrying the `parallelGroup` bookkeeping that
1593
+ // makes a fan render as a fan. That is not N independent task closures, so it cannot ride
1594
+ // this hook.
1595
+ // The kinds come from the `persist:toolcall` checkpoints, never from a local registry lookup,
1596
+ // so every process replaying this turn reaches the same verdict.
1597
+ const parallel = hooks.parallel;
1598
+ if (parallel !== undefined &&
1599
+ turn.toolCalls.length > 1 &&
1600
+ (await (hooks.patched?.(PARALLEL_TOOLS_PATCH) ?? Promise.resolve(true)))) {
1601
+ // Claiming the whole turn first is what makes the kinds knowable before the first execution —
1602
+ // and it is also what moves the checkpoints, hence the `patched` gate above.
1603
+ const claimed = [];
1604
+ for (const call of turn.toolCalls) {
1605
+ claimed.push(await claimToolCall(turnCalls, call));
392
1606
  }
393
- if (toolType === 'action') {
394
- await hooks.step(`persist:toolcall:${call.id}`, () => deps.store.recordToolCall({
395
- toolCallId: call.id,
396
- messageId: assistant.id,
397
- toolName: call.name,
398
- toolType: 'action',
399
- input: call.input,
400
- status: 'pending_approval',
401
- runId: hooks.runId,
402
- }));
403
- const decision = await hooks.awaitApproval(call, ctx);
404
- if (!decision.approved) {
405
- await hooks.step(`persist:toolreject:${call.id}`, () => deps.store.updateToolCall({
406
- toolCallId: call.id,
407
- status: 'rejected',
408
- ...(decision.reason !== undefined ? { error: decision.reason } : {}),
409
- }));
410
- results.push({
411
- id: call.id,
412
- name: call.name,
413
- output: { rejected: true, reason: decision.reason ?? 'rejected by user' },
414
- error: 'rejected',
415
- });
416
- publishAgentToolCall({
417
- runId: hooks.runId,
418
- toolName: call.name,
419
- toolType,
420
- status: 'rejected',
421
- });
422
- continue;
423
- }
1607
+ // A `skill` load and a `remember` write qualify alongside a `read`: each takes its position on
1608
+ // the call exactly as a read does, and each spends a read's `tool:`/`persist:` names. What
1609
+ // disqualifies the other kinds is not that they have effects — it is that an `action` suspends
1610
+ // on human time and an `agent` delegation is the runtime's own child-workflow fan.
1611
+ if (claimed.every((entry) => entry.toolType === 'read' || entry.toolType === 'skill' || entry.toolType === 'memory')) {
1612
+ results.push(...(await invokeClaimedToolsTogether(turnCalls, parallel, claimed)));
424
1613
  }
425
1614
  else {
426
- await hooks.step(`persist:toolcall:${call.id}`, () => deps.store.recordToolCall({
427
- toolCallId: call.id,
428
- messageId: assistant.id,
429
- toolName: call.name,
430
- toolType: 'read',
431
- input: call.input,
432
- status: 'auto_executed',
433
- runId: hooks.runId,
434
- }));
435
- }
436
- try {
437
- // The retry loop runs INSIDE the `tool:<call.id>` step so it stays replay-safe: a durable
438
- // step memoizes only its successful result, so on replay the whole step returns cached and
439
- // the retries never re-run (side effects happen exactly once). A classified-transient error
440
- // (DB deadlock / lock-wait timeout / serialization failure) is retried in place; any other
441
- // failure surfaces immediately as before. `false` disables retry entirely.
442
- // Span the tool execution INSIDE the step body (replay-safe). The transient-retry loop runs
443
- // within the span, so the span covers all in-place attempts; the tool's output never rides it.
444
- const output = await hooks.step(`tool:${call.id}`, () => spannedAgent('tool.execution', hooks.runId, { runId: hooks.runId, toolCallId: call.id, toolName: call.name, toolType }, () => invokeWithTransientRetry(() => deps.registry.invoke(call.name, call.input, ctx, deps.rolesPolicy), deps.toolTransientRetry ?? {}, {
445
- ...(hooks.isControlFlowError !== undefined
446
- ? { isControlFlowError: hooks.isControlFlowError }
447
- : {}),
448
- onRetry: (attempt, retryError) => {
449
- publishAgentToolRetry({
450
- runId: hooks.runId,
451
- toolName: call.name,
452
- toolCallId: call.id,
453
- attempt,
454
- message: retryError instanceof Error ? retryError.message : String(retryError),
455
- });
456
- },
457
- }), () => ({})));
458
- await hooks.step(`persist:toolexec:${call.id}`, () => deps.store.updateToolCall({
459
- toolCallId: call.id,
460
- status: 'executed',
461
- output,
462
- ...(toolType === 'action' ? { executedByRef: input.actor.id } : {}),
463
- }));
464
- results.push({ id: call.id, name: call.name, output });
465
- publishAgentToolCall({
466
- runId: hooks.runId,
467
- toolName: call.name,
468
- toolType,
469
- status: 'executed',
470
- });
1615
+ for (const entry of claimed) {
1616
+ results.push(await runClaimedToolCall(turnCalls, entry));
1617
+ }
471
1618
  }
472
- catch (error) {
473
- const message = error instanceof Error ? error.message : String(error);
474
- await hooks.step(`persist:toolfail:${call.id}`, () => deps.store.updateToolCall({ toolCallId: call.id, status: 'failed', error: message }));
475
- results.push({ id: call.id, name: call.name, output: null, error: message });
476
- publishAgentToolCall({
477
- runId: hooks.runId,
478
- toolName: call.name,
479
- toolType,
480
- status: 'failed',
481
- });
1619
+ }
1620
+ else {
1621
+ for (const call of turn.toolCalls) {
1622
+ results.push(await runClaimedToolCall(turnCalls, await claimToolCall(turnCalls, call)));
482
1623
  }
483
1624
  }
1625
+ // The outputs land on the assistant message that made the calls. A reader reopening the thread
1626
+ // pairs a call with its result off THAT message, so results that only reached the tool-call
1627
+ // table leave every tool in the turn rendering as one still running.
1628
+ //
1629
+ // A position of its own, hence the marker: a run that suspended mid-turn under the shape
1630
+ // without it has no room for a checkpoint between the last tool's persist and the next `llm:`.
1631
+ // Every value written here comes from a checkpoint above, so a replay writes the same list.
1632
+ if (await (hooks.patched?.(MESSAGE_TOOL_RESULTS_PATCH) ?? Promise.resolve(true))) {
1633
+ await hooks.step(`persist:toolresults:${i}`, () => deps.store.setMessageToolResults(assistant.id, [...results, ...syntheticResults]));
1634
+ }
484
1635
  modelMessages.push({ role: 'user', content: '', toolResults: results });
485
1636
  }
486
- if (thread !== null && (thread.title === '' || thread.title === 'New chat')) {
1637
+ if (thread.title === '' || thread.title === 'New chat') {
487
1638
  await hooks.step('persist:title', () => deps.store.setTitle(input.threadId, deriveTitle(input.userText)));
488
1639
  }
489
1640
  // Settle the run `completed` with its rollup. Normal completion only — the loop never records a
@@ -507,6 +1658,6 @@ export async function runAgentLoop(deps, input, hooks) {
507
1658
  inputTokens: totalInput,
508
1659
  outputTokens: totalOutput,
509
1660
  });
510
- return { text: lastText };
1661
+ return { text: lastText, ...(structured !== undefined ? { object: structured } : {}) };
511
1662
  }
512
1663
  //# sourceMappingURL=agent-loop.js.map