@inflexa-ai/harness 0.39.2 → 0.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/README.md +2 -3
  2. package/dist/agents/conversation-agent.d.ts +3 -0
  3. package/dist/agents/conversation-agent.js +13 -2
  4. package/dist/agents/report-session-agent.d.ts +3 -0
  5. package/dist/agents/report-session-agent.js +2 -0
  6. package/dist/agents/sandbox/cheminformatics-agent.js +0 -3
  7. package/dist/agents/sandbox/shared.d.ts +11 -2
  8. package/dist/agents/sandbox/shared.js +4 -0
  9. package/dist/app/chat-turn.d.ts +75 -12
  10. package/dist/app/chat-turn.js +149 -21
  11. package/dist/app/message-assembly.d.ts +16 -52
  12. package/dist/app/message-assembly.js +41 -68
  13. package/dist/app/run-activity.d.ts +4 -6
  14. package/dist/app/run-activity.js +7 -27
  15. package/dist/app/spawn-report-session.d.ts +6 -11
  16. package/dist/app/spawn-report-session.js +5 -6
  17. package/dist/app/synthesize-run.d.ts +3 -0
  18. package/dist/app/synthesize-run.js +1 -0
  19. package/dist/billing/usage-recorder.d.ts +3 -4
  20. package/dist/billing/usage-recorder.js +3 -4
  21. package/dist/contracts/chat-parts.d.ts +11 -1
  22. package/dist/contracts/chat-parts.js +1 -0
  23. package/dist/contracts/index.d.ts +1 -1
  24. package/dist/contracts/part-registry.js +1 -0
  25. package/dist/contracts/report-blocks.d.ts +8 -8
  26. package/dist/contracts/schemas/chat-parts.d.ts +32 -9
  27. package/dist/contracts/schemas/chat-parts.js +10 -0
  28. package/dist/execution/artifact-metadata.d.ts +26 -33
  29. package/dist/execution/artifact-metadata.js +44 -106
  30. package/dist/execution/post-step-pipeline.d.ts +23 -16
  31. package/dist/execution/post-step-pipeline.js +19 -21
  32. package/dist/execution/run-synthesis.d.ts +3 -0
  33. package/dist/execution/run-synthesis.js +11 -1
  34. package/dist/execution/step-summary.d.ts +21 -30
  35. package/dist/execution/step-summary.js +30 -57
  36. package/dist/index.d.ts +9 -8
  37. package/dist/index.js +15 -34
  38. package/dist/lib/zod-issues.js +2 -4
  39. package/dist/loop/compaction.d.ts +20 -0
  40. package/dist/loop/compaction.js +2 -0
  41. package/dist/loop/continue-agent.d.ts +40 -0
  42. package/dist/loop/continue-agent.js +38 -0
  43. package/dist/loop/metrics.d.ts +30 -22
  44. package/dist/loop/metrics.js +51 -22
  45. package/dist/loop/run-agent.d.ts +111 -13
  46. package/dist/loop/run-agent.js +467 -220
  47. package/dist/loop/run-to-terminal.d.ts +16 -17
  48. package/dist/loop/run-to-terminal.js +24 -35
  49. package/dist/loop/tool-mask.d.ts +23 -0
  50. package/dist/loop/tool-mask.js +46 -0
  51. package/dist/loop/tool-output.d.ts +48 -0
  52. package/dist/loop/tool-output.js +79 -0
  53. package/dist/loop/types.d.ts +6 -3
  54. package/dist/memory/ai-sdk-message-storage.d.ts +49 -2
  55. package/dist/memory/ai-sdk-message-storage.js +78 -2
  56. package/dist/memory/conversation-display-recorder.d.ts +6 -0
  57. package/dist/memory/conversation-display-recorder.js +64 -22
  58. package/dist/memory/conversation-display-replay.d.ts +5 -14
  59. package/dist/memory/conversation-display-replay.js +66 -32
  60. package/dist/memory/conversation-display-storage.d.ts +2 -1
  61. package/dist/memory/conversation-display-storage.js +2 -1
  62. package/dist/memory/conversation-view.d.ts +53 -0
  63. package/dist/memory/conversation-view.js +130 -0
  64. package/dist/memory/count-tokens.d.ts +5 -7
  65. package/dist/memory/count-tokens.js +7 -9
  66. package/dist/memory/thread-history.d.ts +74 -102
  67. package/dist/memory/thread-history.js +181 -246
  68. package/dist/memory/thread-store.d.ts +3 -3
  69. package/dist/memory/thread-store.js +7 -2
  70. package/dist/memory/tool-call-integrity.d.ts +15 -21
  71. package/dist/memory/tool-call-integrity.js +31 -38
  72. package/dist/memory/working-memory.d.ts +2 -5
  73. package/dist/memory/working-memory.js +2 -5
  74. package/dist/prompts/SOUL.d.ts +2 -2
  75. package/dist/prompts/SOUL.js +2 -7
  76. package/dist/prompts/analogical-reasoner.d.ts +1 -1
  77. package/dist/prompts/analogical-reasoner.js +16 -32
  78. package/dist/prompts/briefing.js +1 -1
  79. package/dist/prompts/compaction.d.ts +4 -0
  80. package/dist/prompts/compaction.js +24 -0
  81. package/dist/prompts/conversation.d.ts +1 -1
  82. package/dist/prompts/conversation.js +26 -35
  83. package/dist/prompts/execute-analysis/step-summary.js +7 -5
  84. package/dist/prompts/literature-reviewer.d.ts +1 -1
  85. package/dist/prompts/literature-reviewer.js +29 -34
  86. package/dist/prompts/planner.js +15 -29
  87. package/dist/prompts/report-session.d.ts +1 -1
  88. package/dist/prompts/report-session.js +2 -33
  89. package/dist/prompts/sandbox/bulk-transcriptomics-agent.d.ts +1 -1
  90. package/dist/prompts/sandbox/bulk-transcriptomics-agent.js +1 -2
  91. package/dist/prompts/sandbox/cheminformatics-agent.d.ts +1 -1
  92. package/dist/prompts/sandbox/cheminformatics-agent.js +10 -29
  93. package/dist/prompts/sandbox/chromatin-agent.d.ts +1 -1
  94. package/dist/prompts/sandbox/chromatin-agent.js +1 -2
  95. package/dist/prompts/sandbox/data-profiler.js +6 -3
  96. package/dist/prompts/sandbox/dna-methylation-agent.d.ts +1 -1
  97. package/dist/prompts/sandbox/dna-methylation-agent.js +2 -7
  98. package/dist/prompts/sandbox/drug-repurposing-agent.d.ts +1 -1
  99. package/dist/prompts/sandbox/drug-repurposing-agent.js +20 -24
  100. package/dist/prompts/sandbox/enrichment-agent.d.ts +1 -1
  101. package/dist/prompts/sandbox/enrichment-agent.js +3 -2
  102. package/dist/prompts/sandbox/genomic-variant-agent.d.ts +1 -1
  103. package/dist/prompts/sandbox/genomic-variant-agent.js +0 -2
  104. package/dist/prompts/sandbox/immune-profiling-agent.d.ts +1 -1
  105. package/dist/prompts/sandbox/immune-profiling-agent.js +7 -14
  106. package/dist/prompts/sandbox/metabolomics-agent.d.ts +1 -1
  107. package/dist/prompts/sandbox/metabolomics-agent.js +0 -2
  108. package/dist/prompts/sandbox/microbiome-agent.d.ts +1 -1
  109. package/dist/prompts/sandbox/microbiome-agent.js +14 -21
  110. package/dist/prompts/sandbox/multi-omics-integration-agent.d.ts +1 -1
  111. package/dist/prompts/sandbox/multi-omics-integration-agent.js +9 -19
  112. package/dist/prompts/sandbox/multimodal-sc-agent.d.ts +1 -1
  113. package/dist/prompts/sandbox/multimodal-sc-agent.js +4 -5
  114. package/dist/prompts/sandbox/network-agent.d.ts +1 -1
  115. package/dist/prompts/sandbox/network-agent.js +7 -8
  116. package/dist/prompts/sandbox/pkpd-clinical-response-agent.d.ts +1 -1
  117. package/dist/prompts/sandbox/pkpd-clinical-response-agent.js +19 -19
  118. package/dist/prompts/sandbox/proteomics-agent.d.ts +1 -1
  119. package/dist/prompts/sandbox/proteomics-agent.js +3 -4
  120. package/dist/prompts/sandbox/scientific-executor.d.ts +1 -1
  121. package/dist/prompts/sandbox/scientific-executor.js +7 -10
  122. package/dist/prompts/sandbox/single-cell-agent.d.ts +1 -1
  123. package/dist/prompts/sandbox/single-cell-agent.js +2 -4
  124. package/dist/prompts/sandbox/spatial-omics-agent.d.ts +1 -1
  125. package/dist/prompts/sandbox/spatial-omics-agent.js +3 -5
  126. package/dist/prompts/sandbox/statistical-modeling-agent.d.ts +1 -1
  127. package/dist/prompts/sandbox/statistical-modeling-agent.js +1 -1
  128. package/dist/prompts/sandbox/translational-safety-agent.d.ts +1 -1
  129. package/dist/prompts/sandbox/translational-safety-agent.js +10 -19
  130. package/dist/prompts/sandbox-standards.d.ts +7 -7
  131. package/dist/prompts/sandbox-standards.js +74 -76
  132. package/dist/prompts/synthesis-agent.d.ts +1 -1
  133. package/dist/prompts/synthesis-agent.js +16 -42
  134. package/dist/providers/ai-sdk.d.ts +39 -6
  135. package/dist/providers/ai-sdk.js +117 -18
  136. package/dist/providers/errors.d.ts +2 -0
  137. package/dist/providers/errors.js +5 -0
  138. package/dist/providers/prompt-cache.d.ts +43 -41
  139. package/dist/providers/prompt-cache.js +47 -41
  140. package/dist/providers/reasoning.d.ts +10 -21
  141. package/dist/providers/reasoning.js +10 -21
  142. package/dist/providers/types.d.ts +21 -5
  143. package/dist/report-model/authoring-grammar.d.ts +2 -2
  144. package/dist/runtime/assemble.d.ts +9 -7
  145. package/dist/runtime/assemble.js +7 -3
  146. package/dist/sandbox/create-sandbox.d.ts +2 -2
  147. package/dist/sandbox/submit-exec.d.ts +1 -1
  148. package/dist/schemas/artifact-manifest.d.ts +4 -4
  149. package/dist/state/init.js +39 -4
  150. package/dist/state/purge-analysis.js +4 -0
  151. package/dist/state/tool-outputs.d.ts +4 -0
  152. package/dist/state/tool-outputs.js +30 -0
  153. package/dist/tasks/data-profile.d.ts +3 -0
  154. package/dist/tasks/data-profile.js +2 -0
  155. package/dist/tools/ad-hoc-router.d.ts +8 -0
  156. package/dist/tools/ad-hoc-router.js +21 -2
  157. package/dist/tools/display/show-user.d.ts +1 -1
  158. package/dist/tools/execute-analysis.d.ts +3 -0
  159. package/dist/tools/execute-analysis.js +9 -1
  160. package/dist/tools/read-tool-output.d.ts +7 -0
  161. package/dist/tools/read-tool-output.js +130 -0
  162. package/dist/tools/research/generate-analogy-report.d.ts +10 -26
  163. package/dist/tools/research/generate-analogy-report.js +76 -163
  164. package/dist/tools/research/generate-plan.d.ts +3 -0
  165. package/dist/tools/research/generate-plan.js +4 -1
  166. package/dist/tools/research/literature-reviewer.d.ts +3 -0
  167. package/dist/tools/research/literature-reviewer.js +3 -0
  168. package/dist/tools/sandbox/report-blocker.d.ts +18 -5
  169. package/dist/tools/sandbox/report-blocker.js +54 -6
  170. package/dist/tools/sandbox/submit-file-metadata.d.ts +30 -0
  171. package/dist/tools/sandbox/submit-file-metadata.js +67 -0
  172. package/dist/tools/start-report-session.d.ts +1 -3
  173. package/dist/tools/start-report-session.js +0 -2
  174. package/dist/tools/workspace/execute-command.js +7 -4
  175. package/dist/tools/workspace/result-bounds.d.ts +4 -4
  176. package/dist/tools/workspace/result-bounds.js +6 -5
  177. package/dist/tools/workspace/workspace-search.d.ts +1 -1
  178. package/dist/workflows/execute-analysis.d.ts +3 -0
  179. package/dist/workflows/execute-analysis.js +1 -0
  180. package/dist/workflows/sandbox-step.d.ts +30 -9
  181. package/dist/workflows/sandbox-step.js +37 -8
  182. package/package.json +9 -9
  183. package/dist/providers/anthropic.d.ts +0 -25
  184. package/dist/providers/anthropic.js +0 -28
@@ -5,21 +5,25 @@ import { stripNulCharacters } from "../input-sanitization.js";
5
5
  import { createNoopLogger } from "../lib/console-logger.js";
6
6
  import { deliverNotice } from "../lib/hooks.js";
7
7
  import { ATTR_INFLEXA_TOOL_USE_ID, passThroughSpan, stableSpan } from "../lib/otel-spans.js";
8
- import { ResultError } from "../lib/result.js";
8
+ import { ResultError, unwrapOrThrow } from "../lib/result.js";
9
9
  import { describeZodIssueShapes } from "../lib/zod-issue-shape.js";
10
10
  import { hintForZodIssue, repairToolInput } from "../lib/zod-issues.js";
11
- import { markInterruptedMessage, syntheticUserMessage } from "../memory/ai-sdk-message-storage.js";
12
- import { stripUnansweredToolCalls } from "../memory/tool-call-integrity.js";
13
- import { classifyProviderError } from "../providers/errors.js";
14
- import { DEFAULT_PROMPT_CACHE, withPromptCacheBreakpoint } from "../providers/prompt-cache.js";
15
- import { DEFAULT_REASONING } from "../providers/reasoning.js";
11
+ import { dropMarkerMessage, markCompactionExchange, markInterruptedMessage, summaryMarkerMessage, syntheticUserMessage, } from "../memory/ai-sdk-message-storage.js";
12
+ import { conversationView, keptTurnsForDrop, viewTokens } from "../memory/conversation-view.js";
13
+ import { answerUnansweredToolCalls } from "../memory/tool-call-integrity.js";
14
+ import { classifyProviderError, extractStatus } from "../providers/errors.js";
15
+ import { DEFAULT_PROMPT_CACHE, withPromptCacheBreakpoint, withSystemPromptBreakpoint } from "../providers/prompt-cache.js";
16
+ import { COMPACTION_MAX_REQUESTS } from "./compaction.js";
17
+ import { continueAgent } from "./continue-agent.js";
16
18
  import { resultStep } from "./run-step.js";
17
19
  import { AskRejectedError, UnavailableAsk } from "../tools/approval/contract.js";
18
20
  import { isToolError, readToolResultImages } from "../tools/define-tool.js";
19
21
  import { labelToolFailure, labelToolValidationFailure, recordToolException, traceAgentRun, traceToolCall } from "./genai-spans.js";
20
- import { addChatUsage, hasReportedUsage, recordAgentRun } from "./metrics.js";
22
+ import { addChatUsage, countChatTokens, hasReportedUsage, recordAgentRun } from "./metrics.js";
21
23
  import { computeDetail, computeResultDetail } from "./tool-detail.js";
24
+ import { refusalsFor } from "./tool-mask.js";
22
25
  import { toolOutcomeForOutputType } from "./tool-outcome.js";
26
+ import { excerptOf, keptTextOf, READ_TOOL_OUTPUT_TOOL_ID, resultTextOf, TOOL_RESULT_CAP, toolOutputRef, withExcerpt, } from "./tool-output.js";
23
27
  const TRUNCATED_PROSE_STEER = "Your previous reply was cut off at the output-token limit; continue concisely, or finish via your terminal tool.";
24
28
  const TRUNCATED_TOOL_USE_ERROR = "Your previous tool call was cut off at the output-token limit and was not executed. Retry with a smaller payload, writing in incremental pieces.";
25
29
  export const DEFAULT_STEP_NAME_FORMATTER = {
@@ -27,17 +31,43 @@ export const DEFAULT_STEP_NAME_FORMATTER = {
27
31
  tool: (name, id) => `tool-${name}-${id}`,
28
32
  };
29
33
  /** A run that receives no turn accumulator is the turn's root. See `RunAgentOptions.turnUsage`. */
30
- const isTurnRoot = (opts) => opts.turnUsage === undefined;
34
+ export const isTurnRoot = (opts) => opts.turnUsage === undefined;
31
35
  export function runAgent(agent, initial, session, opts) {
32
36
  return traceAgentRun(agent.id, session, isTurnRoot(opts), () => runAgentLoop(agent, initial, session, opts));
33
37
  }
38
+ /** The harness request of the wrap-up at the iteration cap. */
39
+ export const WRAP_UP_REQUEST = "You reached the limit of tool rounds for this run, thus no tool can run now. Answer in text with the results that you have.";
40
+ /** The cap of wrap-up requests: one answer, and one more request after a refused call. */
41
+ export const WRAP_UP_MAX_REQUESTS = 2;
34
42
  async function runAgentLoop(agent, initial, session, opts) {
43
+ const formatStepName = opts.formatStepName ?? DEFAULT_STEP_NAME_FORMATTER;
44
+ const loop = openLoop(agent, initial, session, opts, agent.id);
45
+ const task = await loop.runSegment({ mask: opts.toolMask, maxRequests: agent.maxIterations, stepNames: formatStepName, firstIndex: 0, checksBudget: true });
46
+ if (task !== "capped")
47
+ return task;
48
+ // The wrap-up refuses calls through the mask, not `toolChoice` (see its
49
+ // CAUTION), and keeps step name `llm(maxIterations + k)` so replay finds the
50
+ // reply an earlier, single-call wrap-up stored under the same name.
51
+ const wrapUp = await loop.runSegment({
52
+ mask: "none",
53
+ maxRequests: WRAP_UP_MAX_REQUESTS,
54
+ stepNames: { llm: (k) => formatStepName.llm(agent.maxIterations + k), tool: formatStepName.tool },
55
+ firstIndex: agent.maxIterations,
56
+ requestText: WRAP_UP_REQUEST,
57
+ closesCappedRun: true,
58
+ });
59
+ return wrapUp === "capped" ? loop.endCapped() : wrapUp;
60
+ }
61
+ /**
62
+ * Open the loop over one conversation. `metricAgentId` is the id that usage
63
+ * and run metrics count under, which is not always `agent.id`.
64
+ */
65
+ export function openLoop(agent, initial, session, opts, metricAgentId) {
35
66
  const { provider, signal, emit, runStep } = opts;
36
67
  // The step of a model call or a tool call writes no span. The `chat` span and
37
68
  // the `execute_tool` span under it describe the call already, and they attach
38
69
  // to the `invoke_agent` span of this run.
39
70
  const callStep = (name, fn) => passThroughSpan(name, () => runStep(name, fn));
40
- const formatStepName = opts.formatStepName ?? DEFAULT_STEP_NAME_FORMATTER;
41
71
  const configuredFatalLoopError = opts.isFatalLoopError ?? (() => false);
42
72
  // AbortError is control flow, not a tool failure. Always compose it with the
43
73
  // host's fatal predicate so request cancellation cannot be converted into a
@@ -47,10 +77,29 @@ async function runAgentLoop(agent, initial, session, opts) {
47
77
  throw new Error(`Provider/model cannot run tool-required agent "${agent.id}"`);
48
78
  }
49
79
  const messages = [...initial];
80
+ // The loop never changes a message before `givenCount`, because the sink can hold it already.
81
+ let givenCount = initial.length;
82
+ // Stays true when the sink rejects, thus the throw path does not call the sink again.
83
+ let sinkPending = false;
84
+ const giveRound = async () => {
85
+ if (opts.onRound === undefined || messages.length === givenCount)
86
+ return;
87
+ sinkPending = true;
88
+ await opts.onRound({ messages: messages.slice(givenCount) });
89
+ sinkPending = false;
90
+ givenCount = messages.length;
91
+ };
50
92
  const source = {
51
93
  agentId: session.provenance.agentId ?? agent.id,
52
94
  callPath: session.provenance.callPath,
53
95
  };
96
+ const policy = opts.compaction;
97
+ const viewOptions = { keepFirstTurn: policy?.keepFirstTurn === true };
98
+ const viewOf = () => (policy === undefined ? messages : conversationView(messages, viewOptions).messages);
99
+ // The steer of a truncated prose reply and the reply after it complete one reply, thus no exchange goes between them.
100
+ let continuesTruncation = false;
101
+ let compactionStopped = false;
102
+ let compactions = 0;
54
103
  // Bound from the same `source` the emitted events carry: one derivation feeding
55
104
  // both sinks, so a record and an event cannot disagree about who produced them.
56
105
  // `callPath` rides as an array rather than a joined string — the queryable form;
@@ -61,6 +110,13 @@ async function runAgentLoop(agent, initial, session, opts) {
61
110
  // The collector belongs to this run, thus two loops never share one.
62
111
  const encoding = { placement: imagePlacementFor(provider.capabilities), deferredImages: [], log };
63
112
  const toolsById = new Map(agent.tools.map((t) => [t.id, t]));
113
+ // Only the read tool of the agent can read a kept text, thus an agent without it keeps nothing.
114
+ const cut = {
115
+ store: toolsById.has(READ_TOOL_OUTPUT_TOOL_ID) ? opts.toolOutputStore : undefined,
116
+ session,
117
+ invocationId: opts.invocationId,
118
+ log,
119
+ };
64
120
  const toolDefs = Object.fromEntries(agent.tools.map((t) => [
65
121
  t.id,
66
122
  aiTool({
@@ -79,13 +135,13 @@ async function runAgentLoop(agent, initial, session, opts) {
79
135
  // turn total. Descendants receive the root's object and mutate it in place.
80
136
  const turnUsage = opts.turnUsage ?? {};
81
137
  const turnRoot = isTurnRoot(opts);
82
- const toolCtx = (tu) => ({
138
+ const toolCtx = (tu, names) => ({
83
139
  invocationId: tu.toolCallId,
84
140
  session,
85
141
  signal,
86
142
  emit,
87
143
  runStep: (name, fn) => {
88
- const stepName = `${formatStepName.tool(tu.toolName, tu.toolCallId)}:${name}`;
144
+ const stepName = `${names.tool(tu.toolName, tu.toolCallId)}:${name}`;
89
145
  return runStep(stepName, () => {
90
146
  stableSpan(stepName, `tool:${tu.toolName}:${name}`, { [ATTR_INFLEXA_TOOL_USE_ID]: tu.toolCallId });
91
147
  return fn();
@@ -104,12 +160,12 @@ async function runAgentLoop(agent, initial, session, opts) {
104
160
  let toolErrorCount = 0;
105
161
  // Resolved once, not per iteration: an identical policy across every call is
106
162
  // itself part of the cache contract — the request prefix has to be
107
- // byte-identical to be read back. The breakpoint it places is re-derived per
108
- // call, because it rides the last message and the transcript grows.
163
+ // byte-identical to be read back. The message breakpoint is re-derived per
164
+ // call because it rides the transcript. The system prompt depends only on
165
+ // the agent type, so it is computed once.
109
166
  const promptCache = opts.promptCache ?? DEFAULT_PROMPT_CACHE;
110
- // The depth is a neutral name, and the provider package resolves it for the
111
- // model. A vendor key here would turn that resolution off.
112
- const reasoning = opts.reasoning ?? DEFAULT_REASONING;
167
+ const system = withSystemPromptBreakpoint(agent.systemPrompt, promptCache);
168
+ const reasoningField = opts.reasoning === undefined ? {} : { reasoning: opts.reasoning };
113
169
  const usage = {};
114
170
  // Exactly one record per completed run — never one per iteration. That bound is
115
171
  // what keeps the default level affordable for an agent that runs long; the
@@ -122,71 +178,74 @@ async function runAgentLoop(agent, initial, session, opts) {
122
178
  * carry a complete tool call beside ANY finish reason, because the call
123
179
  * streams before the stop reason arrives, and only the dispatching branches
124
180
  * (`tool-calls`, a call-carrying `stop`, and `length`) run one. A call this
125
- * run never dispatched is stripped
126
- * rather than executed (a filtered or aborted reply must not act) or answered
127
- * with an invented result (the model would read a fabrication as ground
128
- * truth). Without the strip, the unanswered call violates the wire contract —
129
- * every tool call carries a result — and the provider boundary then refuses
130
- * the whole transcript on every later turn of the thread. Scoped past
181
+ * run never dispatched gets a not-run result instead of being executed or
182
+ * dropped: every tool call needs a result, or the provider refuses the
183
+ * transcript on the next turn. The fix only appends; it never edits or
184
+ * removes a message. Scoped past
131
185
  * `initial`: the caller's prefix is not this run's to repair.
132
186
  */
133
187
  const settleTranscript = () => {
134
- const droppedCalls = stripUnansweredToolCalls(messages, initial.length);
135
- if (droppedCalls.length === 0)
188
+ const answeredCalls = answerUnansweredToolCalls(messages, initial.length);
189
+ if (answeredCalls.length === 0)
136
190
  return;
137
- log.warn("undispatched tool calls stripped at run exit", {
138
- toolCallIds: droppedCalls.map((d) => d.toolCallId),
139
- tools: droppedCalls.map((d) => d.toolName),
191
+ log.warn("unanswered tool calls answered at run exit", {
192
+ toolCallIds: answeredCalls.map((d) => d.toolCallId),
193
+ tools: answeredCalls.map((d) => d.toolName),
140
194
  });
141
195
  };
142
196
  const usageRecorder = opts.usageRecorder ?? createNoopUsageRecorder();
143
- /**
144
- * Fold one completed call into both rollups and hand it to the recorder.
145
- * Called at the fold point, with the reply in hand — before any branch that
146
- * can end the run — so a call that completed is accounted for even when the
147
- * run later aborts or dies.
148
- *
149
- * A call that reported nothing produces no record. Model ids are identity,
150
- * not usage: a reply carrying only `requestedModelId`/`servedModelId` still
151
- * reported nothing to account for, so it is folded (to no effect) and left
152
- * out of the ledger rather than entered as an all-absent record.
153
- *
154
- * `record` is a notice. The loop does not wait for its result, thus a
155
- * recorder that blocks does not make the run slower.
156
- */
157
- const accountForCall = (reply, stepName) => {
158
- addChatUsage(usage, reply.usage);
159
- addChatUsage(turnUsage, reply.usage);
160
- const reported = reply.usage;
161
- if (reported === undefined || !hasReportedUsage(reported))
162
- return;
163
- void deliverNotice(log, "UsageRecorder.record", usageRecorder.record({
164
- recordKey: recordKeyFor(session, opts.invocationId, stepName),
165
- agentId: source.agentId,
166
- callPath: source.callPath,
167
- scope: session.scope,
168
- ...(session.runFrame?.runId === undefined ? {} : { runId: session.runFrame.runId }),
169
- ...(session.runFrame?.stepId === undefined ? {} : { stepId: session.runFrame.stepId }),
170
- ...(reply.requestedModelId === undefined ? {} : { requestedModelId: reply.requestedModelId }),
171
- ...(reply.servedModelId === undefined ? {} : { servedModelId: reply.servedModelId }),
172
- usage: reported,
173
- }));
197
+ const accounting = {
198
+ session,
199
+ agentId: source.agentId,
200
+ callPath: source.callPath,
201
+ ...(opts.invocationId === undefined ? {} : { invocationId: opts.invocationId }),
202
+ usageRecorder,
203
+ logger: log,
204
+ rollups: [usage, turnUsage],
174
205
  };
175
206
  /** The rollups this loop stamps on its finish — each absent when nothing reported. */
176
207
  const finishUsage = () => ({
177
208
  ...(hasReportedUsage(usage) ? { usage: { ...usage } } : {}),
178
209
  ...(turnRoot && hasReportedUsage(turnUsage) ? { turnUsage: { ...turnUsage } } : {}),
179
210
  });
211
+ /**
212
+ * One model request over the view of the transcript. Token counters grow inside
213
+ * the step body, so a replayed step (a stored reply) does not double-count.
214
+ */
215
+ const callModel = async (stepName, endsOnRequestRefusal) => {
216
+ const request = {
217
+ system,
218
+ messages: withPromptCacheBreakpoint(viewOf(), promptCache),
219
+ tools: toolDefs,
220
+ ...(opts.toolChoice !== undefined ? { toolChoice: opts.toolChoice } : {}),
221
+ ...reasoningField,
222
+ };
223
+ const chat = () => provider.chat(request, session, signal).map(countChatTokens(metricAgentId));
224
+ if (!endsOnRequestRefusal) {
225
+ const reply = await resultStep(callStep)(stepName, chat);
226
+ accountForChatCall(reply, { ...accounting, stepName });
227
+ return reply;
228
+ }
229
+ // The step gives the `Result` as its value, thus a refusal ends the segment and no step throws for it.
230
+ const result = await callStep(stepName, async () => await chat());
231
+ const refusal = result.isErr() ? requestRefusalOf(result.error) : undefined;
232
+ if (refusal !== undefined)
233
+ return { refusal };
234
+ const reply = unwrapOrThrow(result);
235
+ accountForChatCall(reply, { ...accounting, stepName });
236
+ return reply;
237
+ };
180
238
  // The user said no. A subsequent model call would only let the agent argue
181
239
  // with the decision, or spend a call acknowledging it; the denial tool result
182
240
  // is itself what the surface renders, so the turn ends the moment a denial
183
241
  // lands in a dispatch round — after the concurrent siblings in that same round
184
242
  // have completed and been appended. Mirrors the clean-stop terminal path.
185
- const stopOnDenial = async (i) => {
243
+ const stopOnDenial = async (index) => {
186
244
  settleTranscript();
187
- await emit({ type: "iteration", source, index: i, final: true });
188
- recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
245
+ await emit({ type: "iteration", source, index, final: true });
246
+ recordAgentRun({ agentId: metricAgentId, iterations, cappedOut: false });
189
247
  logFinish("warn", "denied", false);
248
+ await giveRound();
190
249
  return { messages, finish: { reason: "denied", cappedOut: false, truncationRecoveries, ...finishUsage() } };
191
250
  };
192
251
  /**
@@ -243,152 +302,306 @@ async function runAgentLoop(agent, initial, session, opts) {
243
302
  }
244
303
  return errored;
245
304
  };
246
- const stopOnResolved = async (i) => {
305
+ // The calls of each tool that passed the mask and the budget in this run.
306
+ const used = new Map();
307
+ /**
308
+ * Dispatch one round under the run's mask and budget. A refused call still
309
+ * gets its own step slot, with an error result instead of running.
310
+ */
311
+ const dispatchRound = (calls, mask, names) => {
312
+ const refusals = refusalsFor(calls, mask, opts.toolBudget, used);
313
+ return dispatchTools(calls, refusals, toolsById, (tu) => toolCtx(tu, names), isFatalLoopError, callStep, names.tool, encoding, cut);
314
+ };
315
+ const stopOnResolved = async (index) => {
247
316
  settleTranscript();
248
- await emit({ type: "iteration", source, index: i, final: true });
249
- recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
317
+ await emit({ type: "iteration", source, index, final: true });
318
+ recordAgentRun({ agentId: metricAgentId, iterations, cappedOut: false });
319
+ await giveRound();
250
320
  return { messages, finish: { reason: "stop", cappedOut: false, truncationRecoveries } };
251
321
  };
252
- for (let i = 0; i < agent.maxIterations; i++) {
253
- iterations = i + 1;
254
- const request = {
255
- system: agent.systemPrompt,
256
- messages: withPromptCacheBreakpoint(messages, promptCache),
257
- tools: toolDefs,
258
- ...(opts.toolChoice !== undefined ? { toolChoice: opts.toolChoice } : {}),
259
- reasoning,
260
- };
261
- const llmStepName = formatStepName.llm(i);
262
- const reply = await resultStep(callStep)(llmStepName, () => provider.chat(request, session, signal));
263
- accountForCall(reply, llmStepName);
264
- if (reply.finishReason === "aborted") {
265
- // An interrupted turn keeps whatever the model produced before the cut, but
266
- // never an empty shell: a partial with no content adds no message, so a
267
- // no-output abort leaves the transcript at the initial prefix. The marker
268
- // then rides the last assistant message this run produced — the partial when
269
- // it has content, or the tool-calling step when the abort landed mid-dispatch
270
- // — an assistant role no turn-boundary reader observes. "aborted" is not
271
- // "tool-calls", so this falls into the terminal return below.
272
- if (assistantHasContent(reply.message))
273
- messages.push(reply.message);
274
- markLastLoopAssistant(messages, initial.length);
322
+ const endOnRefusal = async (refusal) => {
323
+ settleTranscript();
324
+ recordAgentRun({ agentId: metricAgentId, iterations, cappedOut: false });
325
+ logFinish("warn", "error", false);
326
+ await giveRound();
327
+ return { messages, finish: { reason: "error", cappedOut: false, truncationRecoveries, ...finishUsage() }, refusal };
328
+ };
329
+ /** The result of the run when an abort ends the exchange, or `undefined` when the run continues. */
330
+ const compact = async (active, tokensBefore, index) => {
331
+ const id = randomUUID();
332
+ const stepNamespace = `compaction-${compactions++}`;
333
+ const startedAt = performance.now();
334
+ const emitPart = (data) => emit({ type: "data-compaction", source, data });
335
+ await emitPart({ id, status: "running", tokensBefore });
336
+ const { onRound: _onRound, compaction: _compaction, ...conversationOptions } = opts;
337
+ let settled = false;
338
+ try {
339
+ const exchange = await continueAgent(agent, viewOf(), {
340
+ text: active.request,
341
+ mask: active.mask,
342
+ maxRequests: COMPACTION_MAX_REQUESTS,
343
+ stepNamespace,
344
+ accountingAgentId: `${agent.id}-compaction`,
345
+ endOnRequestRefusal: true,
346
+ }, session, { ...conversationOptions, provider: active.provider, turnUsage });
347
+ messages.push(...exchange.messages.map((message) => markCompactionExchange(message, id)));
348
+ await giveRound();
349
+ if (exchange.finish.reason === "aborted") {
350
+ settled = true;
351
+ await emitPart({ id, status: "failed", tokensBefore, durationMs: Math.round(performance.now() - startedAt) });
352
+ settleTranscript();
353
+ await emit({ type: "iteration", source, index, final: true });
354
+ recordAgentRun({ agentId: metricAgentId, iterations, cappedOut: false });
355
+ logFinish("info", "aborted", false);
356
+ await giveRound();
357
+ return { messages, finish: { reason: "aborted", cappedOut: false, truncationRecoveries, ...finishUsage() } };
358
+ }
359
+ const summary = summaryOf(exchange);
360
+ const keptTurns = summary === undefined ? keptTurnsForDrop(messages, viewOptions, active.budget) : 0;
361
+ const markerOf = (tokensAfter, durationMs) => summary === undefined
362
+ ? dropMarkerMessage({ kind: "drop", id, tokensBefore, tokensAfter, durationMs, keptTurns })
363
+ : summaryMarkerMessage(summary, { kind: "summary", id, tokensBefore, tokensAfter, durationMs });
364
+ // The figures of a marker do not change the view, thus a draft marker gives the view of the real one.
365
+ const view = conversationView([...messages, markerOf(0, 0)], viewOptions).messages;
366
+ const records = await active.recordsAfter(view);
367
+ const tokensAfter = viewTokens([...view, ...records]);
368
+ const durationMs = Math.round(performance.now() - startedAt);
369
+ messages.push(markerOf(tokensAfter, durationMs), ...records);
370
+ await giveRound();
371
+ settled = true;
372
+ await emitPart({ id, status: summary === undefined ? "failed" : "done", tokensBefore, tokensAfter, durationMs });
373
+ const fields = { compactionId: id, tokensBefore, tokensAfter, durationMs };
374
+ if (summary === undefined) {
375
+ const refusal = exchange.refusal === undefined ? {} : { status: exchange.refusal.status, providerError: exchange.refusal.message };
376
+ log.warn("compaction gave no summary, thus the oldest turns left the view", { ...fields, keptTurns, ...refusal });
377
+ }
378
+ else {
379
+ log.info("conversation compacted", fields);
380
+ }
381
+ if (summary !== undefined && tokensAfter > active.budget) {
382
+ log.warn("the compacted view still exceeds the budget, thus the run compacts no more", {
383
+ compactionId: id,
384
+ tokensAfter,
385
+ budget: active.budget,
386
+ });
387
+ }
388
+ if (summary === undefined || tokensAfter > active.budget)
389
+ compactionStopped = true;
390
+ return undefined;
275
391
  }
276
- else {
277
- messages.push(reply.message);
392
+ finally {
393
+ if (!settled)
394
+ await emitPart({ id, status: "failed", tokensBefore, durationMs: Math.round(performance.now() - startedAt) });
278
395
  }
279
- const toolCalls = toolCallParts(reply.message);
280
- if (reply.finishReason === "length") {
281
- truncationRecoveries++;
282
- await emit({ type: "iteration", source, index: i, final: false });
283
- // `tools` is what the model asked for; the trailing call was cut off at the
284
- // output limit and is never dispatched, which the distinct message records.
285
- log.debug("iteration truncated at output limit", { iteration: i, tools: toolCalls.map((t) => t.toolName), truncationRecoveries });
286
- if (toolCalls.length === 0) {
287
- // Stamped synthetic, not left as a bare `user` message: the wire format needs a user turn
288
- // after a truncated assistant message, but this one is the loop's own nudge, and thread
289
- // storage treats a genuine `user` message as the start of a conversation turn. Unmarked, it
290
- // would split this turn in two everywhere that boundary is read.
291
- messages.push(syntheticUserMessage(TRUNCATED_PROSE_STEER));
396
+ };
397
+ const compactOverBudget = async (index) => {
398
+ if (policy === undefined || continuesTruncation || compactionStopped)
399
+ return undefined;
400
+ const tokensBefore = viewTokens(viewOf());
401
+ return tokensBefore > policy.budget ? compact(policy, tokensBefore, index) : undefined;
402
+ };
403
+ const runSegment = async (segment) => {
404
+ const names = segment.stepNames;
405
+ if (segment.requestText !== undefined)
406
+ messages.push(syntheticUserMessage(segment.requestText));
407
+ for (let k = 0; k < segment.maxRequests; k++) {
408
+ const index = segment.firstIndex + k;
409
+ // The last request of a segment that closes a capped run ends the run,
410
+ // whatever its reply, thus its `iteration` event is the final one.
411
+ const endsRun = segment.closesCappedRun === true && k === segment.maxRequests - 1;
412
+ await giveRound();
413
+ if (segment.checksBudget === true) {
414
+ const ended = await compactOverBudget(index);
415
+ if (ended !== undefined)
416
+ return ended;
417
+ }
418
+ if (segment.closesCappedRun !== true)
419
+ iterations++;
420
+ const roundStart = messages.length;
421
+ const called = await callModel(names.llm(k), segment.endsOnRequestRefusal === true);
422
+ continuesTruncation = false;
423
+ if ("refusal" in called)
424
+ return endOnRefusal(called.refusal);
425
+ const reply = called;
426
+ if (reply.finishReason === "aborted") {
427
+ // An interrupted turn keeps whatever the model produced before the cut, but
428
+ // never an empty shell: a partial with no content adds no message. Only the
429
+ // partial carries the marker, because the sink can hold each earlier message.
430
+ // "aborted" is not "tool-calls", so this falls into the terminal return below.
431
+ if (assistantHasContent(reply.message))
432
+ messages.push(reply.message);
433
+ markLastLoopAssistant(messages, roundStart);
434
+ }
435
+ else {
436
+ messages.push(reply.message);
437
+ }
438
+ const toolCalls = toolCallParts(reply.message);
439
+ if (reply.finishReason === "length") {
440
+ truncationRecoveries++;
441
+ await emit({ type: "iteration", source, index, final: endsRun });
442
+ // `tools` is what the model asked for; the trailing call was cut off at the
443
+ // output limit and is never dispatched, which the distinct message records.
444
+ log.debug("iteration truncated at output limit", { iteration: index, tools: toolCalls.map((t) => t.toolName), truncationRecoveries });
445
+ if (toolCalls.length === 0) {
446
+ // Stamped synthetic, not left as a bare `user` message: the wire format needs a user turn
447
+ // after a truncated assistant message, but this one is the loop's own nudge, and thread
448
+ // storage treats a genuine `user` message as the start of a conversation turn. Unmarked, it
449
+ // would split this turn in two everywhere that boundary is read.
450
+ messages.push(syntheticUserMessage(TRUNCATED_PROSE_STEER));
451
+ continuesTruncation = true;
452
+ continue;
453
+ }
454
+ const trailing = toolCalls[toolCalls.length - 1];
455
+ const earlier = toolCalls.slice(0, -1);
456
+ const earlierDetails = roundDetails(earlier);
457
+ for (const [idx, tu] of earlier.entries()) {
458
+ await emit({
459
+ type: "tool-started",
460
+ source,
461
+ toolUseId: tu.toolCallId,
462
+ name: tu.toolName,
463
+ input: tu.input,
464
+ ...detailField(earlierDetails[idx]),
465
+ });
466
+ }
467
+ const { results, durations, resultDetails } = await dispatchRound(earlier, segment.mask, names);
468
+ const errored = await settleRound(earlier, results, earlierDetails, resultDetails, durations);
469
+ results.push(errorResult(trailing, TRUNCATED_TOOL_USE_ERROR));
470
+ // The trailing call was never dispatched, but it reaches the model as an error
471
+ // result like any other — so it counts, or `toolErrors` reports a cleaner run
472
+ // than the model actually read. It gets no `tool-finished` event: no
473
+ // `tool-started` was emitted for it either, and the pair must stay balanced.
474
+ toolCallCount++;
475
+ toolErrorCount++;
476
+ errored.push(trailing.toolName);
477
+ log.debug("tool results returned errors", { iteration: index, tools: errored });
478
+ messages.push({ role: "tool", content: results });
479
+ appendDeferredImages(messages, results, encoding.deferredImages);
480
+ if (hasDenial(results))
481
+ return stopOnDenial(index);
482
+ if (opts.resolved?.())
483
+ return stopOnResolved(index);
292
484
  continue;
293
485
  }
294
- const trailing = toolCalls[toolCalls.length - 1];
295
- const earlier = toolCalls.slice(0, -1);
296
- const earlierDetails = roundDetails(earlier);
297
- for (const [idx, tu] of earlier.entries()) {
298
- await emit({ type: "tool-started", source, toolUseId: tu.toolCallId, name: tu.toolName, input: tu.input, ...detailField(earlierDetails[idx]) });
486
+ // A local model behind an OpenAI-compatible server routinely labels a
487
+ // tool-calling reply `stop`. The calls are complete and the model waits
488
+ // on their results, thus the mislabeled round dispatches like a
489
+ // `tool-calls` one. Every other terminal answers its calls with the
490
+ // not-run result: a filtered or aborted reply must not act.
491
+ const dispatchesRound = reply.finishReason === "tool-calls" || (reply.finishReason === "stop" && toolCalls.length > 0);
492
+ if (!dispatchesRound) {
493
+ settleTranscript();
494
+ await emit({ type: "iteration", source, index, final: true });
495
+ if (segment.closesCappedRun === true) {
496
+ // The cap was already reached; `reason` still distinguishes an
497
+ // abort (user cut the turn) from a plain `max_iterations` cap-out.
498
+ const reason = reply.finishReason === "aborted" ? "aborted" : "max_iterations";
499
+ recordAgentRun({ agentId: metricAgentId, iterations, cappedOut: true });
500
+ logFinish("warn", reason, true);
501
+ await giveRound();
502
+ return { messages, finish: { reason, cappedOut: true, truncationRecoveries, ...finishUsage() } };
503
+ }
504
+ recordAgentRun({ agentId: metricAgentId, iterations, cappedOut: false });
505
+ logFinish("info", reply.finishReason, false);
506
+ await giveRound();
507
+ return {
508
+ messages,
509
+ finish: {
510
+ reason: reply.finishReason,
511
+ ...(reply.rawFinishReason === undefined ? {} : { rawFinishReason: reply.rawFinishReason }),
512
+ cappedOut: false,
513
+ truncationRecoveries,
514
+ ...finishUsage(),
515
+ },
516
+ };
299
517
  }
300
- const { results, durations, resultDetails } = await dispatchTools(earlier, toolsById, toolCtx, isFatalLoopError, callStep, formatStepName.tool, encoding);
301
- const errored = await settleRound(earlier, results, earlierDetails, resultDetails, durations);
302
- results.push(errorResult(trailing, TRUNCATED_TOOL_USE_ERROR));
303
- // The trailing call was never dispatched, but it reaches the model as an error
304
- // result like any other — so it counts, or `toolErrors` reports a cleaner run
305
- // than the model actually read. It gets no `tool-finished` event: no
306
- // `tool-started` was emitted for it either, and the pair must stay balanced.
307
- toolCallCount++;
308
- toolErrorCount++;
309
- errored.push(trailing.toolName);
310
- log.debug("tool results returned errors", { iteration: i, tools: errored });
518
+ await emit({ type: "iteration", source, index, final: endsRun });
519
+ log.debug("iteration", { iteration: index, tools: toolCalls.map((t) => t.toolName) });
520
+ const details = roundDetails(toolCalls);
521
+ for (const [idx, tu] of toolCalls.entries()) {
522
+ await emit({ type: "tool-started", source, toolUseId: tu.toolCallId, name: tu.toolName, input: tu.input, ...detailField(details[idx]) });
523
+ }
524
+ const { results, durations, resultDetails } = await dispatchRound(toolCalls, segment.mask, names);
525
+ const errored = await settleRound(toolCalls, results, details, resultDetails, durations);
526
+ if (errored.length > 0)
527
+ log.debug("tool results returned errors", { iteration: index, tools: errored });
311
528
  messages.push({ role: "tool", content: results });
312
529
  appendDeferredImages(messages, results, encoding.deferredImages);
313
530
  if (hasDenial(results))
314
- return stopOnDenial(i);
531
+ return stopOnDenial(index);
315
532
  if (opts.resolved?.())
316
- return stopOnResolved(i);
317
- continue;
533
+ return stopOnResolved(index);
318
534
  }
319
- // A local model behind an OpenAI-compatible server routinely labels a
320
- // tool-calling reply `stop`. The calls are complete and the model waits
321
- // on their results, thus the mislabeled round dispatches like a
322
- // `tool-calls` one. Every other terminal keeps the strip: a filtered or
323
- // aborted reply must not act.
324
- const dispatchesRound = reply.finishReason === "tool-calls" || (reply.finishReason === "stop" && toolCalls.length > 0);
325
- if (!dispatchesRound) {
326
- settleTranscript();
327
- // The settle can remove a call-only aborted partial whole; the
328
- // interruption marker then rides the last assistant that survived,
329
- // matching the no-output abort contract above.
330
- if (reply.finishReason === "aborted")
331
- markLastLoopAssistant(messages, initial.length);
332
- await emit({ type: "iteration", source, index: i, final: true });
333
- recordAgentRun({ agentId: agent.id, iterations, cappedOut: false, usage });
334
- logFinish("info", reply.finishReason, false);
335
- return {
336
- messages,
337
- finish: {
338
- reason: reply.finishReason,
339
- ...(reply.rawFinishReason === undefined ? {} : { rawFinishReason: reply.rawFinishReason }),
340
- cappedOut: false,
341
- truncationRecoveries,
342
- ...finishUsage(),
343
- },
344
- };
535
+ return "capped";
536
+ };
537
+ const endCapped = async () => {
538
+ settleTranscript();
539
+ recordAgentRun({ agentId: metricAgentId, iterations, cappedOut: true });
540
+ logFinish("warn", "max_iterations", true);
541
+ await giveRound();
542
+ return { messages, finish: { reason: "max_iterations", cappedOut: true, truncationRecoveries, ...finishUsage() } };
543
+ };
544
+ // A throw keeps the open round, and the not-run results keep that stored round valid on the wire.
545
+ const giveRoundBeforeThrow = async (body) => {
546
+ if (opts.onRound === undefined)
547
+ return body();
548
+ try {
549
+ return await body();
345
550
  }
346
- await emit({ type: "iteration", source, index: i, final: false });
347
- log.debug("iteration", { iteration: i, tools: toolCalls.map((t) => t.toolName) });
348
- const details = roundDetails(toolCalls);
349
- for (const [idx, tu] of toolCalls.entries()) {
350
- await emit({ type: "tool-started", source, toolUseId: tu.toolCallId, name: tu.toolName, input: tu.input, ...detailField(details[idx]) });
551
+ catch (err) {
552
+ if (sinkPending)
553
+ throw err;
554
+ answerUnansweredToolCalls(messages, givenCount);
555
+ // A rejection of this call must not hide the error of the run.
556
+ await giveRound().catch(() => undefined);
557
+ throw err;
351
558
  }
352
- const { results, durations, resultDetails } = await dispatchTools(toolCalls, toolsById, toolCtx, isFatalLoopError, callStep, formatStepName.tool, encoding);
353
- const errored = await settleRound(toolCalls, results, details, resultDetails, durations);
354
- if (errored.length > 0)
355
- log.debug("tool results returned errors", { iteration: i, tools: errored });
356
- messages.push({ role: "tool", content: results });
357
- appendDeferredImages(messages, results, encoding.deferredImages);
358
- if (hasDenial(results))
359
- return stopOnDenial(i);
360
- if (opts.resolved?.())
361
- return stopOnResolved(i);
362
- }
363
- // The wrap-up keeps the tool set of the loop and forbids a call through
364
- // `toolChoice`. A changed tool set rewrites the cached prefix, and a model
365
- // that binds its signed thinking blocks to the prefix can reject the
366
- // transcript.
367
- const wrapUpStepName = formatStepName.llm(agent.maxIterations);
368
- const wrapUp = await resultStep(callStep)(wrapUpStepName, () => provider.chat({ system: agent.systemPrompt, messages: withPromptCacheBreakpoint(messages, promptCache), tools: toolDefs, toolChoice: "none", reasoning }, session, signal));
369
- accountForCall(wrapUp, wrapUpStepName);
370
- if (wrapUp.finishReason === "aborted") {
371
- // An abort during the wrap-up is still the user cutting the turn — the
372
- // same event the in-loop path handles — so it gets the identical treatment: keep a
373
- // partial only when it carries content, and stamp the marker on the last assistant
374
- // this run produced. Reporting it as a plain cap-out would hide the interruption
375
- // from every downstream reader; `cappedOut` stays true because the loop genuinely
376
- // exhausted its iterations, while the reason carries the abort.
377
- if (assistantHasContent(wrapUp.message))
378
- messages.push(wrapUp.message);
379
- settleTranscript();
380
- markLastLoopAssistant(messages, initial.length);
381
- await emit({ type: "iteration", source, index: agent.maxIterations, final: true });
382
- recordAgentRun({ agentId: agent.id, iterations, cappedOut: true, usage });
383
- logFinish("warn", "aborted", true);
384
- return { messages, finish: { reason: "aborted", cappedOut: true, truncationRecoveries, ...finishUsage() } };
385
- }
386
- messages.push(wrapUp.message);
387
- settleTranscript();
388
- await emit({ type: "iteration", source, index: agent.maxIterations, final: true });
389
- recordAgentRun({ agentId: agent.id, iterations, cappedOut: true, usage });
390
- logFinish("warn", "max_iterations", true);
391
- return { messages, finish: { reason: "max_iterations", cappedOut: true, truncationRecoveries, ...finishUsage() } };
559
+ };
560
+ return {
561
+ runSegment: (segment) => giveRoundBeforeThrow(() => runSegment(segment)),
562
+ endCapped: () => giveRoundBeforeThrow(endCapped),
563
+ };
564
+ }
565
+ /** The statuses of a request that the provider refuses for its content, for example a request past the context window. */
566
+ const REFUSAL_STATUSES = new Set([400, 413]);
567
+ function requestRefusalOf(error) {
568
+ if (error.type !== "provider")
569
+ return undefined;
570
+ const status = extractStatus(error);
571
+ return status !== undefined && REFUSAL_STATUSES.has(status) ? { status, message: error.message } : undefined;
572
+ }
573
+ /** The text of the last reply of an exchange, when that reply ends the exchange with the finish reason `stop`. */
574
+ function summaryOf(exchange) {
575
+ if (exchange.finish.reason !== "stop")
576
+ return undefined;
577
+ const text = finalText(exchange.messages).trim();
578
+ return text.length > 0 ? text : undefined;
579
+ }
580
+ /**
581
+ * Called at the fold point, before any run-ending branch, so an aborted or
582
+ * dead run is still accounted. Token counters grow inside the call's step
583
+ * body, not here, because a replay re-delivers the stored reply to this
584
+ * function too; the record re-sends under the same idempotency key, which an
585
+ * upserting sink counts once.
586
+ */
587
+ export function accountForChatCall(reply, call) {
588
+ for (const rollup of call.rollups)
589
+ addChatUsage(rollup, reply.usage);
590
+ const reported = reply.usage;
591
+ if (reported === undefined || !hasReportedUsage(reported))
592
+ return;
593
+ const { session } = call;
594
+ void deliverNotice(call.logger, "UsageRecorder.record", call.usageRecorder.record({
595
+ recordKey: recordKeyFor(session, call.invocationId, call.stepName),
596
+ agentId: call.agentId,
597
+ callPath: call.callPath,
598
+ scope: session.scope,
599
+ ...(session.runFrame?.runId === undefined ? {} : { runId: session.runFrame.runId }),
600
+ ...(session.runFrame?.stepId === undefined ? {} : { stepId: session.runFrame.stepId }),
601
+ ...(reply.requestedModelId === undefined ? {} : { requestedModelId: reply.requestedModelId }),
602
+ ...(reply.servedModelId === undefined ? {} : { servedModelId: reply.servedModelId }),
603
+ usage: reported,
604
+ }));
392
605
  }
393
606
  /**
394
607
  * Delimiter joining the call path into one key segment.
@@ -398,11 +611,13 @@ async function runAgentLoop(agent, initial, session, opts) {
398
611
  * UUIDs or the profiler's `data-profile` literal, step ids are path-safe plan
399
612
  * ids (`T1S1`, `synthesis`, `profile`), tool-call ids are provider-issued
400
613
  * opaque tokens, and the loop's step names are `llm-{n}` / `tool-{name}-{id}`
401
- * with an optional `salvage:` prefix — none of them can contain `>`.
614
+ * with an optional continuation namespace (`salvage:`, `file-metadata:`,
615
+ * `step-summary:`) — none of them can contain `>`.
402
616
  */
403
617
  const CALL_PATH_DELIMITER = ">";
404
618
  /**
405
- * Idempotency key for one call's usage record:
619
+ * Idempotency key for one call's usage record, and for the kept text of one cut
620
+ * tool result under the tool step name of its call:
406
621
  * `{runId}[:{stepId}]:{callPath}[:{invocationId}]:{stepName}`.
407
622
  *
408
623
  * Every component is a fact the same call carries again on every replay, so a
@@ -452,14 +667,11 @@ function assistantHasContent(message) {
452
667
  return message.content.length > 0;
453
668
  }
454
669
  /**
455
- * Stamp the interruption marker on the last assistant message the loop produced
456
- * this run — an index at or beyond the `initial` prefix — replacing the slot with
457
- * a marked copy so the mark rides into `appendTurn` and the stored row. When the
458
- * turn produced no assistant message beyond `initial` (a no-output abort on a
459
- * fresh turn), there is nothing to mark and the transcript is left untouched.
670
+ * Mark the aborted partial of the last request. A message before `roundStart` stays unmarked,
671
+ * because the round sink can hold it already.
460
672
  */
461
- function markLastLoopAssistant(messages, initialCount) {
462
- for (let idx = messages.length - 1; idx >= initialCount; idx--) {
673
+ function markLastLoopAssistant(messages, roundStart) {
674
+ for (let idx = messages.length - 1; idx >= roundStart; idx--) {
463
675
  const message = messages[idx];
464
676
  if (message.role === "assistant") {
465
677
  messages[idx] = markInterruptedMessage(message);
@@ -527,19 +739,27 @@ function appendDeferredImages(messages, results, deferredImages) {
527
739
  /**
528
740
  * Dispatch one round of tool calls, and measure the time of each call.
529
741
  *
530
- * `results`, `durations` and `resultDetails` are positionally aligned with `toolUses`.
742
+ * `refusals`, `results`, `durations` and `resultDetails` are positionally aligned
743
+ * with `toolUses`; `refusals[i]` is the refusal text, or `undefined` if the call runs.
744
+ *
745
+ * A refused call still runs inside the same step wrapper and step name as a
746
+ * dispatched one, keeping a round's step sequence identical for replay.
531
747
  *
532
748
  * Each measurement brackets the same unit that the loop awaits for that call. For a
533
749
  * step-mode call that unit is `runStep`, thus the figure includes the durable-step
534
750
  * wrapper. The wrapper is part of what the call cost, and a cached replay of a step
535
751
  * is genuinely fast. A bracket inside the step would report a body that did not run.
536
752
  */
537
- async function dispatchTools(toolUses, toolsById, toolCtx, isFatalLoopError, runStep, toolStepName, encoding) {
538
- const dispatch = (tu) => traceToolCall(tu, () => dispatchTool(tu, toolsById, toolCtx(tu), isFatalLoopError, encoding), (dispatched) => outcomeOf(dispatched.result));
753
+ async function dispatchTools(toolUses, refusals, toolsById, toolCtx, isFatalLoopError, runStep, toolStepName, encoding, cut) {
754
+ // A refusal writes no tool span, because the tool does not run.
755
+ const dispatch = (tu, refusal) => refusal !== undefined
756
+ ? Promise.resolve({ result: errorResult(tu, refusal) })
757
+ : traceToolCall(tu, () => dispatchTool(tu, toolsById, toolCtx(tu), isFatalLoopError, encoding, cut, toolStepName(tu.toolName, tu.toolCallId)), (dispatched) => outcomeOf(dispatched.result));
539
758
  const results = new Array(toolUses.length);
540
759
  // The array starts with holes, which read as `undefined`. The element type
541
- // says so, thus it agrees with what `settleRound` accepts. Every index is in
542
- // fact assigned, because the mode partition below covers each call.
760
+ // says so, thus it agrees with what `settleRound` accepts. Every index is
761
+ // assigned except the index of a refused call, because the mode partition
762
+ // below covers each call.
543
763
  const durations = new Array(toolUses.length);
544
764
  // A hole stays a hole for a call that described no result: an error, a
545
765
  // denial, and a tool with no result hook each leave the index unassigned,
@@ -558,30 +778,27 @@ async function dispatchTools(toolUses, toolsById, toolCtx, isFatalLoopError, run
558
778
  stepTools.push({ tu, idx });
559
779
  }
560
780
  await Promise.all(stepTools.map(({ tu, idx }) => {
781
+ const refusal = refusals[idx];
561
782
  const startedAt = performance.now();
562
- return runStep(toolStepName(tu.toolName, tu.toolCallId), () => dispatch(tu)).then((settled) => {
563
- durations[idx] = elapsedMs(startedAt);
783
+ return runStep(toolStepName(tu.toolName, tu.toolCallId), () => dispatch(tu, refusal)).then((settled) => {
784
+ if (refusal === undefined)
785
+ durations[idx] = elapsedMs(startedAt);
564
786
  const dispatched = readDispatchedStep(settled);
565
787
  results[idx] = dispatched.result;
566
788
  if (dispatched.detail !== undefined)
567
789
  resultDetails[idx] = dispatched.detail;
568
790
  });
569
791
  }));
570
- for (const { tu, idx } of workflowTools) {
792
+ // A workflow-mode and an inline-mode call run unwrapped, one after another.
793
+ for (const { tu, idx } of [...workflowTools, ...inlineTools]) {
794
+ const refusal = refusals[idx];
571
795
  const startedAt = performance.now();
572
- const dispatched = await dispatch(tu);
796
+ const dispatched = await dispatch(tu, refusal);
573
797
  results[idx] = dispatched.result;
574
798
  if (dispatched.detail !== undefined)
575
799
  resultDetails[idx] = dispatched.detail;
576
- durations[idx] = elapsedMs(startedAt);
577
- }
578
- for (const { tu, idx } of inlineTools) {
579
- const startedAt = performance.now();
580
- const dispatched = await dispatch(tu);
581
- results[idx] = dispatched.result;
582
- if (dispatched.detail !== undefined)
583
- resultDetails[idx] = dispatched.detail;
584
- durations[idx] = elapsedMs(startedAt);
800
+ if (refusal === undefined)
801
+ durations[idx] = elapsedMs(startedAt);
585
802
  }
586
803
  return { results, durations, resultDetails };
587
804
  }
@@ -602,7 +819,37 @@ function elapsedMs(startedAt) {
602
819
  function readDispatchedStep(settled) {
603
820
  return "result" in settled ? settled : { result: settled };
604
821
  }
605
- async function dispatchTool(tu, toolsById, ctx, isFatalLoopError, encoding) {
822
+ /** For a step-mode tool this runs inside the durable step of the call, thus a replay gives the stored excerpt. */
823
+ async function dispatchTool(tu, toolsById, ctx, isFatalLoopError, encoding, cut, stepName) {
824
+ const dispatched = await callTool(tu, toolsById, ctx, isFatalLoopError, encoding);
825
+ return { ...dispatched, result: await cutLongResult(dispatched.result, cut, stepName) };
826
+ }
827
+ /** The excerpt does not depend on the outcome of the put, thus a replay gives the same bytes. */
828
+ async function cutLongResult(result, cut, stepName) {
829
+ const text = resultTextOf(result.output);
830
+ if (text === undefined || text.length <= TOOL_RESULT_CAP)
831
+ return result;
832
+ if (cut.store === undefined)
833
+ return withExcerpt(result, excerptOf(text, undefined));
834
+ const { session } = cut;
835
+ const ref = toolOutputRef(recordKeyFor(session, cut.invocationId, stepName));
836
+ const content = keptTextOf(text);
837
+ const put = await cut.store.put({
838
+ analysisId: session.scope.analysisId,
839
+ ref,
840
+ toolName: result.toolName,
841
+ toolCallId: result.toolCallId,
842
+ // A run session carries the thread of the chat that started the run, but a run belongs to its analysis.
843
+ ...(session.runFrame === undefined && session.scope.threadId !== undefined ? { threadId: session.scope.threadId } : {}),
844
+ content,
845
+ totalLength: text.length,
846
+ });
847
+ if (put.isErr()) {
848
+ cut.log.warn("tool output not kept", { toolName: result.toolName, toolCallId: result.toolCallId, ref, errorType: put.error.type });
849
+ }
850
+ return withExcerpt(result, excerptOf(text, { ref, keptLength: content.length }));
851
+ }
852
+ async function callTool(tu, toolsById, ctx, isFatalLoopError, encoding) {
606
853
  const tool = toolsById.get(tu.toolName);
607
854
  if (tool === undefined) {
608
855
  labelToolFailure("unknown_tool");