mixdog 0.9.19 → 0.9.21

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 (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -34
  3. package/package.json +3 -2
  4. package/scripts/build-tui.mjs +6 -0
  5. package/scripts/hook-bus-test.mjs +8 -0
  6. package/scripts/log-writer-guard-smoke.mjs +131 -0
  7. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  8. package/scripts/recall-bench-cases.json +0 -1
  9. package/scripts/recall-quality-cases.json +1 -2
  10. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  11. package/scripts/tool-smoke.mjs +150 -45
  12. package/src/defaults/skills/setup/SKILL.md +327 -0
  13. package/src/help.mjs +2 -5
  14. package/src/lib/mixdog-debug.cjs +13 -0
  15. package/src/mixdog-session-runtime.mjs +7 -3328
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  19. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  20. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  23. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  24. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  25. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  26. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  27. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  28. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  29. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  30. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  32. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  34. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  35. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  36. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  37. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  38. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  39. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  40. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  41. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  42. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  44. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  45. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  46. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  47. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  48. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  49. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  50. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  51. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  52. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  55. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  56. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  57. package/src/runtime/channels/index.mjs +6 -2183
  58. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  59. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  60. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  61. package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
  62. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  63. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  64. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  65. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  66. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  67. package/src/runtime/channels/lib/worker-main.mjs +777 -0
  68. package/src/runtime/memory/index.mjs +163 -1725
  69. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  70. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  71. package/src/runtime/memory/lib/http-router.mjs +811 -0
  72. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  73. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  74. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  75. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  76. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  77. package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
  78. package/src/runtime/memory/lib/memory.mjs +39 -0
  79. package/src/runtime/memory/lib/query-handlers.mjs +2 -2
  80. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  81. package/src/runtime/shared/atomic-file.mjs +138 -80
  82. package/src/runtime/shared/child-guardian.mjs +61 -3
  83. package/src/session-runtime/boot-profile.mjs +36 -0
  84. package/src/session-runtime/channel-config-api.mjs +70 -0
  85. package/src/session-runtime/context-status.mjs +181 -0
  86. package/src/session-runtime/env.mjs +17 -0
  87. package/src/session-runtime/lifecycle-api.mjs +242 -0
  88. package/src/session-runtime/model-route-api.mjs +198 -0
  89. package/src/session-runtime/provider-auth-api.mjs +135 -0
  90. package/src/session-runtime/resource-api.mjs +282 -0
  91. package/src/session-runtime/runtime-core.mjs +2104 -0
  92. package/src/session-runtime/session-turn-api.mjs +274 -0
  93. package/src/session-runtime/tool-catalog.mjs +18 -264
  94. package/src/session-runtime/tool-defs.mjs +2 -2
  95. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  96. package/src/standalone/agent-tool.mjs +2 -2
  97. package/src/standalone/channel-worker.mjs +67 -7
  98. package/src/standalone/folder-dialog.mjs +4 -1
  99. package/src/standalone/memory-runtime-proxy.mjs +154 -17
  100. package/src/standalone/seeds.mjs +28 -1
  101. package/src/tui/App.jsx +40 -28
  102. package/src/tui/app/core-memory-picker.mjs +1 -1
  103. package/src/tui/app/doctor.mjs +175 -0
  104. package/src/tui/app/slash-commands.mjs +1 -0
  105. package/src/tui/app/slash-dispatch.mjs +9 -0
  106. package/src/tui/app/use-mouse-input.mjs +6 -0
  107. package/src/tui/app/use-transcript-scroll.mjs +77 -3
  108. package/src/tui/dist/index.mjs +2851 -2162
  109. package/src/tui/engine/context-state.mjs +145 -0
  110. package/src/tui/engine/prompt-history.mjs +27 -0
  111. package/src/tui/engine/session-api-ext.mjs +478 -0
  112. package/src/tui/engine/session-api.mjs +564 -0
  113. package/src/tui/engine/session-flow.mjs +485 -0
  114. package/src/tui/engine/turn.mjs +1078 -0
  115. package/src/tui/engine.mjs +68 -2620
  116. package/src/tui/index.jsx +7 -0
  117. package/vendor/ink/build/ink.js +16 -1
  118. package/vendor/ink/build/output.js +30 -4
  119. package/vendor/ink/build/render.js +5 -0
  120. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -1,96 +1,11 @@
1
- import { classifyResultKind } from './result-classification.mjs';
2
- import { canonicalizeBuiltinToolName, executeBuiltinTool, isBuiltinTool } from '../tools/builtin.mjs';
3
- import { takeApplyPatchUiDiff } from '../tools/patch.mjs';
4
- import { executeInternalTool, isInternalTool } from '../internal-tools.mjs';
5
- import { normalizeToolEnvelope } from './tool-envelope.mjs';
6
- import { traceAgentLoop, traceAgentTool, traceAgentToolFailure, traceAgentCompact, estimateProviderPayloadBytes, messagePrefixHash, appendAgentTrace } from '../agent-trace.mjs';
7
- import { resolveSessionMaxLoopIterations } from '../agent-runtime/agent-loop-policy.mjs';
8
- import { isAgentOwner } from '../agent-owner.mjs';
9
- import { markSessionToolCall, updateSessionStage, SessionClosedError, bumpUsageMetricsEpoch } from './manager.mjs';
10
- import {
11
- pruneToolOutputs,
12
- pruneToolOutputsUnanchored,
13
- semanticCompactMessages,
14
- effectiveBudget as compactEffectiveBudget,
15
- DEFAULT_COMPACT_TYPE,
16
- } from './compact.mjs';
17
- import { isContextOverflowError } from '../providers/retry-classifier.mjs';
18
- import { stripSoftWarns } from '../tool-loop-guard.mjs';
19
- import { maybeOffloadToolResult } from './tool-result-offload.mjs';
20
- import { tryReadCached, setReadCached, invalidatePathForSession, markPostEdit, consumePostEditMark, clearReadDedupSession, extractTouchedPathsFromPatch, tryScopedToolCached, setScopedToolCached, clearScopedToolsForSession, clearScopedToolsForSessionPaths, invalidatePrefetchCache } from './read-dedup.mjs';
21
- import { isInvalidToolArgsMarker, formatInvalidToolArgsResult } from '../providers/openai-compat-stream.mjs';
22
-
23
- import {
24
- _stripMcpPrefix,
25
- _isReadTool,
26
- _isMutationTool,
27
- _isScopedCacheableTool,
28
- _isShellTool,
29
- _intraTurnSig,
30
- } from './loop/tool-classify.mjs';
31
- import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
32
- import { runRecallFastTrackCompact } from './loop/recall-fasttrack.mjs';
33
- import { executeTool, _scopedCacheOutcomeForCall } from './loop/tool-exec.mjs';
34
-
35
- // classifyResultKind is imported from result-classification.mjs at the top of
36
- // this file; import it from there directly rather than via this module.
37
- import { compressToolResult, recordToolBatch } from '../tools/result-compression.mjs';
38
-
39
-
40
- import { resolve as resolvePath, isAbsolute } from 'path';
41
- import {
42
- estimateMessagesTokensSafe,
43
- compactDiagnosticError,
44
- compactByteLength,
45
- compactDebugLog,
46
- } from './loop/compact-debug.mjs';
47
- import { mergeSteeringEntries, steeringContentText } from './loop/steering.mjs';
48
- import {
49
- crossTurnSignature,
50
- crossTurnDedupStub,
51
- ITERATION_CAP_REFUSAL_STUB,
52
- } from './loop/completion-guards.mjs';
53
- import { isEditProgressTool } from './loop/completion-guards.mjs';
54
- import { agentContextOverflowError } from './loop/context-overflow.mjs';
55
- import { positiveTokenInt } from './loop/env.mjs';
56
- import { normalizeUsage, addUsage } from './loop/usage.mjs';
57
- import { HIDDEN_AGENT_NAMES } from './loop/hidden-agents.mjs';
58
- import {
59
- resolveWorkerCompactPolicy,
60
- compactionTelemetryPressureTokens,
61
- compactTargetBudget,
62
- shouldCompactForSession,
63
- countPrunedToolOutputs,
64
- rememberCompactTelemetry,
65
- emitCompactEvent,
66
- compactEventType,
67
- } from './loop/compact-policy.mjs';
68
- import {
69
- isEagerDispatchable,
70
- messagesArrayChanged,
71
- getToolKind,
72
- normalizeHookUpdatedToolOutput,
73
- resolveToolResultAfterHook,
74
- parseNativeToolSearchPayload,
75
- buildAgentBashSessionArgs,
76
- formatMissingToolApprovalUiDenial,
77
- resolvePreToolAskApproval,
78
- approvalGranted,
79
- approvalReason,
80
- } from './loop/tool-helpers.mjs';
81
- import {
82
- compactToolCallsForHistory,
83
- restoreToolCallBodyForId,
84
- } from './loop/stored-tool-args.mjs';
85
- import { repairTranscriptBeforeProviderSend } from './loop/transcript-repair.mjs';
86
- import { classifyTerminationReason, INCOMPLETE_STOP_REASONS } from './loop/termination.mjs';
87
- import { createSteeringLadder } from './loop/steering-ladder.mjs';
88
-
89
- // Facade re-exports: these symbols moved to split modules under ./loop/ but
90
- // remain part of loop.mjs's public surface (imported by scripts/tests and other
91
- // runtime modules). Re-export the already-imported local bindings so every
92
- // existing import path keeps working (no duplicate module binding).
1
+ // Thin facade. The agent loop implementation moved to ./agent-loop.mjs and the
2
+ // pre-send auto-compact pass to ./pre-send-compact.mjs. This module re-exports
3
+ // the exact public surface (agentLoop plus the tool/approval/transcript
4
+ // helpers) so every existing import path -- scripts/tests, the manager.mjs
5
+ // dynamic import('./loop.mjs'), and other runtime modules -- keeps resolving
6
+ // unchanged.
93
7
  export {
8
+ agentLoop,
94
9
  preDispatchDenyForSession,
95
10
  repairTranscriptBeforeProviderSend,
96
11
  normalizeHookUpdatedToolOutput,
@@ -100,1771 +15,4 @@ export {
100
15
  resolvePreToolAskApproval,
101
16
  approvalGranted,
102
17
  approvalReason,
103
- };
104
-
105
- // Hard iteration ceiling for every agent loop. Reset to 0 whenever the
106
- // transcript is compacted (see the trim block below): a long task that keeps
107
- // compacting can proceed past this count, while a tight NON-compacting loop
108
- // still stops here and returns the accumulated transcript.
109
- // Consecutive identical-AND-failing tool calls (same name+args, error result)
110
- // tolerated across iterations before the loop refuses to re-execute and steers
111
- // the model to change approach. Distinct from the hard iteration cap above:
112
- // this catches tight deterministic-failure loops (e.g. a command that errors
113
- // the same way every time) far earlier than 100 iterations.
114
- const REPEAT_FAIL_LIMIT = 3;
115
- // _scopedCacheOutcomeForCall and executeTool moved to ./loop/tool-exec.mjs
116
- // (imported above).
117
- /**
118
- * Agent loop: send → tool_call → execute → re-send → repeat until text.
119
- * sendOpts may include:
120
- * - `effort` (provider-specific)
121
- * - `fast` (boolean)
122
- * - `sessionId` — enables runtime liveness markers (optional)
123
- * - `signal` — AbortSignal; checked at each iteration boundary and after each
124
- * tool. When aborted, throws SessionClosedError so the ask
125
- * wrapper can propagate a clean cancellation.
126
- * - `onStageChange(stage)` / `onStreamDelta()` — forwarded to provider.send for heartbeats
127
- */
128
- // Stop reasons that signal the turn was cut short mid-synthesis (token cap,
129
- // provider pause). Empty content + one of these reasons means the worker
130
- // was not done — re-prompt instead of accepting empty as final.
131
- // Covers Anthropic (pause_turn, max_tokens), OpenAI (length), Gemini
132
- // (MAX_TOKENS, OTHER), and case variants.
133
- export async function agentLoop(provider, messages, model, tools, onToolCall, cwd, sendOpts) {
134
- let iterations = 0;
135
- let toolCallsTotal = 0;
136
- let lastUsage;
137
- let firstTurnUsage;
138
- let response;
139
- let contextOverflowRetryUsed = false;
140
- // Set when the hard iteration-cap break below fires. Consumed at the final
141
- // return to tag terminationReason='iteration_cap' so a worker that exhausts
142
- // the loop without a final answer surfaces to Lead as an explicit error
143
- // instead of a silent empty "completed".
144
- let terminatedByCap = false;
145
- // Set when a provider context-overflow refusal triggers the in-turn
146
- // reactive compact retry below; consumed by the next pre-send compact pass
147
- // so its telemetry/events carry trigger:'reactive' (distinct from the
148
- // proactive pre-send pressure trigger). Cleared after that pass reads it.
149
- let reactiveOverflowRetryPending = false;
150
- const opts = sendOpts || {};
151
- const sessionId = opts.sessionId || null;
152
- const signal = opts.signal || null;
153
- const sessionAgent = opts.session?.agent;
154
- const forcedFirstTool = opts.forcedFirstTool ?? null;
155
- const forcedFirstToolDef = forcedFirstTool
156
- ? tools.find(tool => tool?.name === forcedFirstTool)
157
- : null;
158
- // Opaque providerState passthrough. The loop never inspects provider-native
159
- // payloads; the originating provider owns them. Stateful Responses
160
- // providers may use it for continuation anchors.
161
- let providerState = opts.providerState ?? undefined;
162
- const throwIfAborted = () => {
163
- if (signal?.aborted) {
164
- const reason = signal.reason instanceof Error ? signal.reason : null;
165
- // Preserve any structured abort reason (SessionClosedError,
166
- // StreamStalledAbortError, etc.). Fallback to SessionClosedError
167
- // when the reason is not an Error instance.
168
- if (reason) throw reason;
169
- throw new SessionClosedError(sessionId || 'unknown', 'agent loop aborted');
170
- }
171
- };
172
- const sessionRef = opts.session || null;
173
- const loopUsageMetricsEpoch = () => Number(sessionRef?.usageMetricsEpoch) || 0;
174
- const loopUsageMetricsTurnId = () => Number(sessionRef?.usageMetricsTurnId) || 0;
175
- // Sub-agent (worker/heavy-worker/reviewer/debugger/explore/…) sessions
176
- // drop mid-turn assistant preamble text outright. Only the final
177
- // <final-answer> reply is consumed by Lead, so any "Now let me…" prose
178
- // that precedes a tool call is pure noise — both for live surfacing AND
179
- // for the agent's own history (where it re-enters context as input
180
- // tokens on every later turn). Drop it at the runtime, no model-side rule:
181
- // - streaming : opts.onTextDelta suppressed (token-by-token preamble)
182
- // - buffered : opts.onAssistantText skipped (response.content below)
183
- // - history : tool-call turn content blanked before messages.push
184
- // Reasoning/thinking deltas, tool calls, and the final answer are kept.
185
- const suppressMidTurnText = isAgentOwner(sessionRef);
186
- if (suppressMidTurnText) opts.onTextDelta = undefined;
187
- const pushToolResultMessage = (message) => {
188
- messages.push(message);
189
- try { opts.onToolResult?.(message); } catch {}
190
- };
191
- const drainSteeringIntoMessages = (stage = 'mid-turn', options = {}) => {
192
- if (typeof opts.drainSteering !== 'function') return false;
193
- let steerMsgs = [];
194
- try { steerMsgs = opts.drainSteering(sessionId) || []; }
195
- catch { steerMsgs = []; }
196
- const merged = mergeSteeringEntries(steerMsgs);
197
- if (!merged) return false;
198
- if (typeof options.beforeAppend === 'function') {
199
- try { options.beforeAppend(); } catch { /* best-effort hook */ }
200
- }
201
- messages.push({ role: 'user', content: merged.content });
202
- try { opts.onSteerMessage?.(merged.text || steeringContentText(merged.content)); } catch {}
203
- if (sessionId) {
204
- try { process.stderr.write(`[steer] sess=${sessionId} injected ${stage} user message (merged=${merged.count} len=${String(merged.text || '').length})\n`); } catch {}
205
- }
206
- return true;
207
- };
208
- const pushIntermediateAssistantResponse = (resp) => {
209
- if (!resp) return false;
210
- const content = typeof resp.content === 'string' ? resp.content : (resp.content == null ? '' : String(resp.content));
211
- const reasoningContent = typeof resp.reasoningContent === 'string' && resp.reasoningContent
212
- ? resp.reasoningContent
213
- : '';
214
- const reasoningItems = Array.isArray(resp.reasoningItems) && resp.reasoningItems.length
215
- ? resp.reasoningItems
216
- : null;
217
- if (!content && !reasoningContent && !reasoningItems) return false;
218
- messages.push({
219
- role: 'assistant',
220
- content,
221
- ...(reasoningItems ? { reasoningItems } : {}),
222
- ...(reasoningContent ? { reasoningContent } : {}),
223
- });
224
- return true;
225
- };
226
- const maxLoopIterations = resolveSessionMaxLoopIterations(sessionRef);
227
- // ---- Completion-first loop guards (worker runaway prevention) ----
228
- // Step 1 (escalation ladder) + the missed-parallelism / serial-rewording
229
- // steering hints live in the createSteeringLadder controller below; it owns
230
- // their cumulative counters and emits at most one hint per turn.
231
- // _editCount counts any executed tool call whose def lacks readOnlyHint
232
- // (i.e. edit/progress: apply_patch, bash, MCP writes, skills, ...).
233
- let _editCount = 0;
234
- // Step 2: cross-turn identical read-only call dedup. Map keyed by
235
- // signature(name + stableStringify(args)) → { count, firstIteration }.
236
- // Populated only for SUCCESSFUL isEagerDispatchable (read-only) calls.
237
- // Bounded to 500 entries (drop-oldest / insertion order).
238
- const _crossTurnCalls = new Map();
239
- const _CROSS_TURN_CAP = 500;
240
- let _dedupStubTotal = 0;
241
- // Hard-cap final-answer turn: one tool-less wrap-up turn granted when the
242
- // hard iteration cap fires, so the session ends with text, not empty.
243
- let _capFinalTurnUsed = false;
244
- // True while the granted hard-cap final turn is active (no tool defs).
245
- let _capFinalToolsDisabled = false;
246
- // Consecutive empty-turn contract nudges. A model that answers the same
247
- // nudge with another empty turn is in a deterministic livelock (same
248
- // context in → same empty completion out); re-sending an identical nudge
249
- // 199× just burns the iteration budget (observed: sess_10400…9dfdc436,
250
- // 199 identical nudges to the 200-iteration cap). Cap the streak and end
251
- // the loop as an explicit empty termination instead.
252
- let _emptyNudgeStreak = 0;
253
- const EMPTY_NUDGE_MAX = 3;
254
- // Completion-first steering ladder controller. Owns the (cumulative) level-1
255
- // fire count, the all-read-only / serial-single / same-file-grep streaks,
256
- // and the level-2 latch. Threaded via live getters so it reads the loop's
257
- // current `iterations` / `_editCount` on every call (no stale snapshots).
258
- const _steeringLadder = createSteeringLadder({
259
- sessionId,
260
- sessionAgent,
261
- tools,
262
- getIterations: () => iterations,
263
- getEditCount: () => _editCount,
264
- readOnlyRole: String(sessionRef?.permission || sessionRef?.toolPermission || '') === 'read',
265
- pushUserMessage: (msg) => messages.push(msg),
266
- pushSystemReminder: (text) => messages.push({ role: 'user', content: `<system-reminder>\n${text}\n</system-reminder>`, meta: 'hook' }),
267
- });
268
- // Tool execution must use the session cwd even when the caller omitted the
269
- // legacy positional cwd argument. Agent workers always carry their cwd on
270
- // sessionRef; falling through to pwd()/process.cwd() resolves relatives
271
- // against the host/plugin root instead of the worker workspace.
272
- cwd = cwd || sessionRef?.cwd || undefined;
273
- // Staged pre-cap warnings + one true hard stop. The ONLY count-based
274
- // forced termination is the hard cap at maxLoopIterations (default 200):
275
- // a genuine runaway guard. Before it, staged warnings fire at 50%/75%/90%
276
- // of the cap steering the model to converge — warnings only, nothing is
277
- // cut off early. Other runaway protection is behavior-based (steering
278
- // ladder hints, REPEAT_FAIL_LIMIT), never a lower iteration count.
279
- let _iterWarnStage = 0;
280
- const _iterWarnAt = [
281
- Math.floor(maxLoopIterations * 0.5),
282
- Math.floor(maxLoopIterations * 0.75),
283
- Math.floor(maxLoopIterations * 0.9),
284
- ];
285
- while (true) {
286
- throwIfAborted();
287
- if (iterations >= maxLoopIterations) {
288
- // Final-answer turn: instead of breaking mid-transcript (which
289
- // yields an empty final for locator-style agents that never got to
290
- // answer), give the model ONE tool-less text turn to wrap up, then
291
- // stop (empty sendTools + refusal stubs if tools are requested).
292
- if (_capFinalTurnUsed) {
293
- process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); stopping loop.\n`);
294
- terminatedByCap = true;
295
- // The granted final turn produced no text (model kept emitting
296
- // tool calls into refusal stubs, or thinking-only). Synthesize a
297
- // non-empty final so callers never see an empty response.
298
- if (response && !String(response.content || '').trim()) {
299
- response.content = sessionAgent === 'explorer'
300
- ? 'EXPLORATION_FAILED'
301
- : '[iteration cap reached before final text]';
302
- if (Array.isArray(response.toolCalls)) response.toolCalls = [];
303
- }
304
- break;
305
- }
306
- _capFinalTurnUsed = true;
307
- _capFinalToolsDisabled = true;
308
- messages.push({ role: 'user', content: '<system-reminder>\nIteration cap reached — tools disabled; answer with your best result from context.\n</system-reminder>', meta: 'hook' });
309
- process.stderr.write(`[loop] hard iteration cap ${maxLoopIterations} reached (sess=${sessionId || 'unknown'}); forcing final text turn.\n`);
310
- }
311
- if (_iterWarnStage < _iterWarnAt.length && iterations >= _iterWarnAt[_iterWarnStage]) {
312
- _iterWarnStage += 1;
313
- const warnAt = _iterWarnAt[_iterWarnStage - 1];
314
- const stageMsg = _iterWarnStage === 1
315
- ? `Iteration budget notice: ${warnAt} of ${maxLoopIterations} iterations used. Converge on a conclusion: prefer finishing the current objective over opening new exploration.`
316
- : `Iteration budget warning (stage ${_iterWarnStage}): ${warnAt} of ${maxLoopIterations} iterations used — the loop hard-stops at ${maxLoopIterations}. Wrap up now: summarize progress, state what remains, and finish with your best current result.`;
317
- messages.push({ role: 'user', content: `<system-reminder>\n${stageMsg}\n</system-reminder>`, meta: 'hook' });
318
- process.stderr.write(`[loop] iteration warning stage ${_iterWarnStage} at ${iterations} (sess=${sessionId || 'unknown'}); continuing with steer.\n`);
319
- try {
320
- appendAgentTrace({
321
- sessionId,
322
- iteration: iterations,
323
- kind: 'steer',
324
- payload: { tag: 'iteration_warning', stage: _iterWarnStage, at: iterations, unit: maxLoopIterations },
325
- agent: sessionAgent || null,
326
- });
327
- } catch { /* best-effort */ }
328
- }
329
- // Drain queued steering/prompts BEFORE the
330
- // pre-send compact check. The compact decision must see the exact
331
- // message set that the next provider.send would receive, including
332
- // tool results plus any queued user input/notifications.
333
- drainSteeringIntoMessages('pre-send');
334
- const compactPolicy = resolveWorkerCompactPolicy(sessionRef, tools);
335
- if (compactPolicy?.auto) {
336
- // Snapshot pre-compact shape so compact_meta can record the actual
337
- // mutation (or no-op) for prefix-mutation forensics. Bytes are
338
- // a best-effort JSON.stringify length — close enough to the
339
- // payload we hand the provider for prefix-cache analysis.
340
- const beforeCount = messages.length;
341
- // beforeBytes is only ever read inside the shouldCompact telemetry
342
- // branches below. Computing it eagerly serialized the ENTIRE message
343
- // array (Buffer.byteLength(JSON.stringify(messages))) on every loop
344
- // iteration — including the common no-compact path — which grows
345
- // linearly with transcript size and was a real per-iteration drag.
346
- // Defer it to a memoized lazy getter so the no-compact path pays
347
- // nothing and the compact path still gets an exact byte count once.
348
- let _beforeBytes;
349
- let _beforeBytesComputed = false;
350
- const getBeforeBytes = () => {
351
- if (_beforeBytesComputed) return _beforeBytes;
352
- _beforeBytesComputed = true;
353
- try { _beforeBytes = Buffer.byteLength(JSON.stringify(messages), 'utf8'); } catch { _beforeBytes = null; }
354
- return _beforeBytes;
355
- };
356
- const messageTokensEst = estimateMessagesTokensSafe(messages);
357
- const reactivePending = reactiveOverflowRetryPending === true;
358
- const shouldCompact = shouldCompactForSession(messageTokensEst, compactPolicy, { forceReactive: reactivePending });
359
- const pressureTokens = compactionTelemetryPressureTokens(messageTokensEst, compactPolicy, { reactivePending });
360
- // A pending reactive-overflow retry makes THIS compact pass the
361
- // recovery from a provider overflow refusal, not the proactive
362
- // pressure trigger. Tag the emitted events so telemetry can tell
363
- // them apart. Hoisted above the shouldCompact branch because the
364
- // PostCompact hook below fires on BOTH paths (fixes a
365
- // ReferenceError on the no-compact path).
366
- const compactTrigger = reactivePending ? 'reactive' : 'auto';
367
- const compactBudgetTokens = shouldCompact
368
- ? (compactTargetBudget({ ...compactPolicy, pressureTokens }) || compactPolicy.boundaryTokens)
369
- : compactPolicy.boundaryTokens;
370
- if (!shouldCompact) {
371
- rememberCompactTelemetry(sessionRef, compactPolicy, {
372
- stage: 'pre_send_check',
373
- beforeTokens: messageTokensEst,
374
- afterTokens: messageTokensEst,
375
- pressureTokens,
376
- });
377
- } else {
378
- try { await opts.onStageChange?.('compacting'); } catch { /* best-effort */ }
379
- const compactStartedAt = Date.now();
380
- // Clear the one-shot reactive-overflow flag now that this
381
- // compact pass is consuming it (compactTrigger already
382
- // captured it above).
383
- reactiveOverflowRetryPending = false;
384
- // PreCompact: bridge to the standard hook bus before compaction
385
- // runs. session-property hook (manager/loop have no bus access).
386
- // { trigger } normalized to 'auto'|'manual'. Best-effort.
387
- {
388
- const _preCompactHook = typeof opts.preCompactHook === 'function'
389
- ? opts.preCompactHook
390
- : sessionRef?.preCompactHook;
391
- if (typeof _preCompactHook === 'function') {
392
- try { await _preCompactHook({ sessionId, cwd, trigger: compactTrigger === 'manual' ? 'manual' : 'auto' }); }
393
- catch { /* best-effort: PreCompact hook must never break compaction */ }
394
- }
395
- }
396
- rememberCompactTelemetry(sessionRef, compactPolicy, {
397
- stage: 'compacting',
398
- beforeTokens: messageTokensEst,
399
- afterTokens: messageTokensEst,
400
- pressureTokens,
401
- trigger: compactTrigger,
402
- });
403
- let compacted;
404
- let pruneCount = 0;
405
- let summaryChanged = false;
406
- let semanticCompactResult = null;
407
- let semanticCompactError = null;
408
- let recallFastTrackResult = null;
409
- let recallFastTrackError = null;
410
- try {
411
- let compactInputMessages = messages;
412
- if (compactPolicy.prune) {
413
- const pruned = pruneToolOutputs(messages, compactPolicy.boundaryTokens, {
414
- reserveTokens: compactPolicy.reserveTokens,
415
- });
416
- pruneCount = countPrunedToolOutputs(messages, pruned);
417
- compactInputMessages = pruned;
418
- }
419
- if (compactPolicy.recallFastTrack) {
420
- try {
421
- recallFastTrackResult = await runRecallFastTrackCompact({
422
- sessionRef,
423
- messages: compactInputMessages,
424
- compactBudgetTokens,
425
- compactPolicy,
426
- sessionId,
427
- signal,
428
- });
429
- const recallMessages = Array.isArray(recallFastTrackResult?.messages)
430
- ? recallFastTrackResult.messages
431
- : null;
432
- if (!recallMessages) throw new Error('recall-fasttrack compact produced no messages');
433
- compacted = recallMessages;
434
- } catch (recallErr) {
435
- recallFastTrackError = recallErr;
436
- try {
437
- process.stderr.write(
438
- `[loop] recall-fasttrack compact failed (sess=${sessionId || 'unknown'}): ` +
439
- `${recallErr?.message || recallErr}\n`,
440
- );
441
- } catch { /* best-effort */ }
442
- throw recallErr;
443
- }
444
- } else if (compactPolicy.semantic) {
445
- try {
446
- semanticCompactResult = await semanticCompactMessages(
447
- provider,
448
- compactInputMessages,
449
- model,
450
- compactBudgetTokens,
451
- {
452
- reserveTokens: compactPolicy.reserveTokens,
453
- providerName: sessionRef.provider || provider?.name || null,
454
- sessionId,
455
- signal,
456
- sendOpts: opts,
457
- promptCacheKey: opts.promptCacheKey || null,
458
- providerCacheKey: opts.providerCacheKey || null,
459
- timeoutMs: compactPolicy.semanticTimeoutMs,
460
- tailTurns: compactPolicy.tailTurns,
461
- keepTokens: compactPolicy.keepTokens,
462
- preserveRecentTokens: compactPolicy.preserveRecentTokens,
463
- force: true,
464
- },
465
- );
466
- const semanticMessages = Array.isArray(semanticCompactResult?.messages)
467
- ? semanticCompactResult.messages
468
- : null;
469
- if (!semanticMessages) throw new Error('semantic compact produced no messages');
470
- compacted = semanticMessages;
471
- if (semanticCompactResult?.usage) {
472
- lastUsage = addUsage(lastUsage, semanticCompactResult.usage);
473
- if (!firstTurnUsage) firstTurnUsage = normalizeUsage(semanticCompactResult.usage);
474
- if (sessionId && opts.onUsageDelta) {
475
- try {
476
- opts.onUsageDelta({
477
- sessionId,
478
- iterationIndex: iterations + 1,
479
- usageMetricsTurnId: loopUsageMetricsTurnId(),
480
- usageMetricsEpoch: loopUsageMetricsEpoch(),
481
- deltaInput: semanticCompactResult.usage.inputTokens || 0,
482
- deltaOutput: semanticCompactResult.usage.outputTokens || 0,
483
- deltaCachedRead: semanticCompactResult.usage.cachedTokens || 0,
484
- deltaCacheWrite: semanticCompactResult.usage.cacheWriteTokens || 0,
485
- source: 'semantic_compact',
486
- ts: Date.now(),
487
- });
488
- } catch { /* best-effort */ }
489
- }
490
- }
491
- } catch (semanticErr) {
492
- semanticCompactError = semanticErr;
493
- try {
494
- process.stderr.write(
495
- `[loop] semantic compact failed (sess=${sessionId || 'unknown'}): ` +
496
- `${semanticErr?.message || semanticErr}\n`,
497
- );
498
- } catch { /* best-effort */ }
499
- throw semanticErr;
500
- }
501
- } else {
502
- throw new Error(`compact type ${compactPolicy.compactType || compactPolicy.type || DEFAULT_COMPACT_TYPE} is unavailable for auto compact`);
503
- }
504
- summaryChanged = messagesArrayChanged(compactInputMessages, compacted);
505
- } catch (compactErr) {
506
- // Anchor-independent prune safety net. When SEMANTIC compact
507
- // throws (e.g. a degenerate single-turn transcript, or a
508
- // summary that cannot fit), attempt one non-LLM prune that
509
- // needs no user anchor: middle-truncate the oldest oversized
510
- // tool_result bodies until the transcript fits the budget.
511
- // If it shrinks the transcript we continue with that result
512
- // instead of escalating to overflow. Structure/pairing is
513
- // preserved (only string content shrinks) and the result is
514
- // re-reconciled inside the helper.
515
- //
516
- // GATED to the non-recall path: a recall-fasttrack failure
517
- // must NOT be silently recovered by this prune (that would
518
- // change the type-2 path's contract by shipping a pruned
519
- // transcript with no recall output). When recallFastTrackError
520
- // is set the fallback is skipped and the original overflow
521
- // escalation runs unchanged.
522
- if (!recallFastTrackError) {
523
- try {
524
- // Accept only if the pruned transcript fits the SAME
525
- // effective budget the prune targets (compactBudgetTokens
526
- // minus the request reserve) — comparing against the raw
527
- // compactBudgetTokens would accept a result with no
528
- // reserve headroom and overflow on the very next send.
529
- const acceptThreshold = compactEffectiveBudget(compactBudgetTokens, {
530
- reserveTokens: compactPolicy.reserveTokens,
531
- });
532
- const salvaged = pruneToolOutputsUnanchored(messages, compactBudgetTokens, {
533
- reserveTokens: compactPolicy.reserveTokens,
534
- });
535
- if (messagesArrayChanged(messages, salvaged)
536
- && estimateMessagesTokensSafe(salvaged) <= acceptThreshold) {
537
- compacted = salvaged;
538
- pruneCount = countPrunedToolOutputs(messages, salvaged);
539
- summaryChanged = true;
540
- }
541
- } catch { /* fall through to overflow escalation */ }
542
- }
543
- if (compacted !== undefined) {
544
- try {
545
- process.stderr.write(
546
- `[loop] compact fallback prune recovered (sess=${sessionId || 'unknown'}): ` +
547
- `${compactErr?.message || compactErr}\n`,
548
- );
549
- } catch { /* best-effort */ }
550
- } else {
551
- const compactFailMsg = compactErr && compactErr.message ? compactErr.message : String(compactErr);
552
- const semanticFailMsg = semanticCompactError?.message || null;
553
- const recallFailMsg = recallFastTrackError?.message || null;
554
- const compactFailCode = compactErr?.code
555
- || (compactErr?.name === 'AgentContextOverflowError' ? 'AGENT_CONTEXT_OVERFLOW' : null)
556
- || 'compact_failed';
557
- rememberCompactTelemetry(sessionRef, compactPolicy, {
558
- stage: 'overflow_failed',
559
- beforeTokens: messageTokensEst,
560
- afterTokens: messageTokensEst,
561
- pressureTokens,
562
- trigger: compactTrigger,
563
- semanticError: semanticFailMsg,
564
- recallFastTrackError: recallFailMsg,
565
- compactError: semanticFailMsg || recallFailMsg || compactFailMsg,
566
- pruneCount,
567
- durationMs: Date.now() - compactStartedAt,
568
- });
569
- traceAgentCompact({
570
- sessionId,
571
- iteration: iterations + 1,
572
- stage: 'pre_send',
573
- trigger: compactTrigger,
574
- compact_type: compactPolicy.compactType || compactPolicy.type || DEFAULT_COMPACT_TYPE,
575
- prune_count: pruneCount,
576
- compact_changed: false,
577
- input_prefix_hash: messagePrefixHash(messages),
578
- before_count: beforeCount,
579
- after_count: messages.length,
580
- before_bytes: getBeforeBytes(),
581
- after_bytes: getBeforeBytes(),
582
- context_window: compactPolicy.contextWindow,
583
- budget_tokens: compactPolicy.boundaryTokens,
584
- boundary_tokens: compactPolicy.boundaryTokens,
585
- target_budget_tokens: compactBudgetTokens,
586
- reserve_tokens: compactPolicy.reserveTokens,
587
- pressure_tokens: pressureTokens,
588
- trigger_tokens: compactPolicy.triggerTokens,
589
- message_tokens_est: messageTokensEst,
590
- duration_ms: Date.now() - compactStartedAt,
591
- provider: sessionRef.provider,
592
- model: sessionRef.model || model,
593
- error: compactFailMsg,
594
- error_code: compactFailCode,
595
- details: {
596
- semantic: semanticCompactResult?.diagnostics || null,
597
- recallFastTrack: recallFastTrackResult?.diagnostics || null,
598
- semanticError: semanticFailMsg,
599
- recallFastTrackError: recallFailMsg,
600
- },
601
- });
602
- emitCompactEvent(opts, {
603
- sessionId,
604
- stage: 'pre_send',
605
- trigger: compactTrigger,
606
- status: 'failed',
607
- compactType: compactEventType(compactPolicy),
608
- beforeTokens: messageTokensEst,
609
- afterTokens: messageTokensEst,
610
- beforeMessages: beforeCount,
611
- afterMessages: messages.length,
612
- pressureTokens,
613
- triggerTokens: compactPolicy.triggerTokens,
614
- boundaryTokens: compactPolicy.boundaryTokens,
615
- targetBudgetTokens: compactBudgetTokens,
616
- reserveTokens: compactPolicy.reserveTokens,
617
- semantic: compactPolicy.semantic === true,
618
- recallFastTrack: compactPolicy.recallFastTrack === true,
619
- pruneCount,
620
- durationMs: Date.now() - compactStartedAt,
621
- error: compactErr && compactErr.message ? compactErr.message : String(compactErr),
622
- });
623
- throw agentContextOverflowError({
624
- stage: 'pre_send',
625
- sessionId,
626
- sessionRef,
627
- model,
628
- budgetTokens: compactBudgetTokens,
629
- reserveTokens: compactPolicy.reserveTokens,
630
- messageTokensEst,
631
- }, compactErr);
632
- }
633
- }
634
- try { await opts.onStageChange?.('requesting'); } catch { /* best-effort */ }
635
- const compactChanged = messagesArrayChanged(messages, compacted);
636
- if (compactChanged) {
637
- messages.length = 0;
638
- messages.push(...compacted);
639
- // Compacting/pruning the transcript invalidates the
640
- // server-side conversation anchor (xAI Responses / openai-oauth
641
- // WS rely on previous_response_id which points at a
642
- // now-mutated prefix). Drop providerState so the next send
643
- // starts a fresh chain.
644
- providerState = undefined;
645
- // Compaction shrank the transcript, so prior turns no
646
- // longer pressure the window — reset the iteration counter
647
- // so a steadily-compacting long task isn't killed by the
648
- // cap, while a non-compacting tight loop still hits it.
649
- iterations = 0;
650
- // New loop epoch so persistIterationMetrics idempotency keys do not
651
- // collide when iteration indices restart at 1 (incl. iter 1 → iter 1).
652
- if (sessionRef) bumpUsageMetricsEpoch(sessionRef);
653
- }
654
- const afterTokens = estimateMessagesTokensSafe(messages);
655
- const compactDurationMs = Date.now() - compactStartedAt;
656
- rememberCompactTelemetry(sessionRef, compactPolicy, {
657
- stage: 'pre_send',
658
- beforeTokens: messageTokensEst,
659
- afterTokens,
660
- pressureTokens,
661
- compactChanged,
662
- semanticCompact: semanticCompactResult?.semantic === true,
663
- semanticError: semanticCompactError?.message || null,
664
- recallFastTrack: recallFastTrackResult?.recallFastTrack === true,
665
- recallFastTrackError: recallFastTrackError?.message || null,
666
- compactError: null,
667
- pruneCount,
668
- durationMs: compactDurationMs,
669
- });
670
- let afterBytes = null;
671
- try { afterBytes = Buffer.byteLength(JSON.stringify(messages), 'utf8'); } catch { afterBytes = null; }
672
- traceAgentCompact({
673
- sessionId,
674
- iteration: iterations + 1,
675
- stage: 'pre_send',
676
- trigger: compactTrigger,
677
- compact_type: compactPolicy.compactType || compactPolicy.type || DEFAULT_COMPACT_TYPE,
678
- prune_count: pruneCount,
679
- compact_changed: compactChanged || summaryChanged,
680
- input_prefix_hash: messagePrefixHash(messages),
681
- before_count: beforeCount,
682
- after_count: messages.length,
683
- before_bytes: getBeforeBytes(),
684
- after_bytes: afterBytes,
685
- context_window: compactPolicy.contextWindow,
686
- budget_tokens: compactPolicy.boundaryTokens,
687
- boundary_tokens: compactPolicy.boundaryTokens,
688
- target_budget_tokens: compactBudgetTokens,
689
- reserve_tokens: compactPolicy.reserveTokens,
690
- pressure_tokens: pressureTokens,
691
- trigger_tokens: compactPolicy.triggerTokens,
692
- message_tokens_est: messageTokensEst,
693
- duration_ms: compactDurationMs,
694
- provider: sessionRef.provider,
695
- model: sessionRef.model || model,
696
- details: {
697
- semantic: semanticCompactResult?.diagnostics || null,
698
- recallFastTrack: recallFastTrackResult?.diagnostics || null,
699
- },
700
- });
701
- emitCompactEvent(opts, {
702
- sessionId,
703
- stage: 'pre_send',
704
- trigger: compactTrigger,
705
- status: compactChanged || summaryChanged || pruneCount > 0 ? 'compacted' : 'no_change',
706
- compactType: compactEventType(compactPolicy),
707
- beforeTokens: messageTokensEst,
708
- afterTokens,
709
- beforeMessages: beforeCount,
710
- afterMessages: messages.length,
711
- pressureTokens,
712
- triggerTokens: compactPolicy.triggerTokens,
713
- boundaryTokens: compactPolicy.boundaryTokens,
714
- targetBudgetTokens: compactBudgetTokens,
715
- reserveTokens: compactPolicy.reserveTokens,
716
- changed: compactChanged || summaryChanged,
717
- semantic: semanticCompactResult?.semantic === true,
718
- recallFastTrack: recallFastTrackResult?.recallFastTrack === true,
719
- pruneCount,
720
- durationMs: compactDurationMs,
721
- });
722
- }
723
- // PostCompact: bridge to the standard hook bus after compaction
724
- // completes. session-property hook; { trigger } 'auto'|'manual'.
725
- {
726
- const _postCompactHook = typeof opts.postCompactHook === 'function'
727
- ? opts.postCompactHook
728
- : sessionRef?.postCompactHook;
729
- if (typeof _postCompactHook === 'function') {
730
- try { await _postCompactHook({ sessionId, cwd, trigger: compactTrigger === 'manual' ? 'manual' : 'auto' }); }
731
- catch { /* best-effort: PostCompact hook must never break the loop */ }
732
- }
733
- }
734
- }
735
- const nextIteration = iterations + 1;
736
- opts.iteration = nextIteration;
737
- opts.providerState = providerState;
738
- if (forcedFirstTool && toolCallsTotal === 0) {
739
- opts.toolChoice = 'required';
740
- } else {
741
- delete opts.toolChoice;
742
- }
743
- // Hard-cap final turn: send NO tool definitions so the provider can
744
- // only emit text. Overrides the forced-first-tool path.
745
- const sendTools = _capFinalToolsDisabled
746
- ? []
747
- : (forcedFirstToolDef && toolCallsTotal === 0 ? [forcedFirstToolDef] : tools);
748
- // Eager-dispatch queue: when the provider streams a tool-call event,
749
- // start read-only tools immediately so execution overlaps with the
750
- // remaining SSE parse. Writes and unknown tools wait until send()
751
- // returns and run serially in the call-order loop below.
752
- const pending = new Map();
753
- // Streaming-time intra-turn dedup. When the LLM emits two
754
- // tool_use blocks with identical (name, args) signatures in
755
- // sequence, the provider's onToolCall fires for both BEFORE
756
- // the iter for-body runs, so the batch-level pre-pass would be
757
- // too late to prevent the eager dispatch of the second one.
758
- // Track signatures of in-flight eager calls and skip starting a
759
- // second one for the same sig. The duplicate's executeTool is
760
- // never invoked; the for-body's pre-pass marks it as a duplicate
761
- // and emits a stub tool_result. The sig is NOT cleared when the
762
- // eager promise settles (see finally below): a streaming onToolCall
763
- // can deliver a same-turn identical call AFTER the first promise
764
- // settles but BEFORE the deferred cache set (:1256), and the static
765
- // pre-pass (:909) only runs after send() returns — so clearing the
766
- // sig on settle would let that second streaming eager call
767
- // re-execute. A fresh Map() is created per turn, so the sig set
768
- // resets at the turn boundary without leaking across iterations.
769
- const _eagerInFlightSigs = new Map();
770
- let _mutationEpoch = 0;
771
- const startEagerTool = (call) => {
772
- if (!call?.id || pending.has(call.id) || !isEagerDispatchable(call.name, tools)) return null;
773
- // Never eager-execute a call whose arguments failed to parse
774
- // (invalid-args marker). It has no usable arguments; the serial
775
- // body handles it via the invalid-args feedback path.
776
- if (isInvalidToolArgsMarker(call.arguments)) return null;
777
- const _sig = _intraTurnSig(call.name, call.arguments);
778
- if (_eagerInFlightSigs.has(_sig)) return null;
779
- // Repeat-failure guard also gates eager dispatch (reviewer-flagged):
780
- // streaming onToolCall / startEagerRun would otherwise re-run an
781
- // identical read-only call that already failed REPEAT_FAIL_LIMIT
782
- // times before the serial for-body guard runs. Returning null here
783
- // lets the serial body push the [repeat-failure-guard] stub.
784
- {
785
- const _rfg = sessionRef?._repeatFailGuard;
786
- if (_rfg && _rfg.sig === _sig && _rfg.count >= REPEAT_FAIL_LIMIT) return null;
787
- }
788
- // Cross-turn dedup also gates eager dispatch (mirror of the
789
- // repeat-failure guard above): a read-only call whose (name,args)
790
- // signature already ran in an EARLIER turn must NOT be eagerly
791
- // re-executed — the serial for-body pushes the [cross-turn-dedup]
792
- // stub instead. Without this gate startEagerRun/onToolCall would
793
- // re-run the call before the serial dedup check ever sees it.
794
- {
795
- const _ctSig = crossTurnSignature(call.name, call.arguments);
796
- const _prior = _crossTurnCalls.get(_ctSig);
797
- if (_prior && _prior.firstIteration < iterations) return null;
798
- }
799
- const toolKind = getToolKind(call.name);
800
- // Shared pre-dispatch deny: identical predicate runs in the
801
- // serial path below. If any role/permission guard would reject
802
- // this call there, never start it eagerly here.
803
- if (preDispatchDenyForSession(sessionRef, call, toolKind) !== null) return null;
804
- const entry = { startedAt: Date.now(), endedAt: null, mutationEpoch: _mutationEpoch };
805
- _eagerInFlightSigs.set(_sig, call.id);
806
- entry.promise = (async () => {
807
- try {
808
- return { ok: true, value: await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: nextIteration }) };
809
- } catch (error) {
810
- return { ok: false, error };
811
- }
812
- })()
813
- .then((settled) => {
814
- entry.endedAt = Date.now();
815
- // EARLY UI-ONLY NOTIFY (completion-order, NOT history).
816
- // The serial result-collection loop below `await`s each
817
- // eager promise strictly in CALL order, so a fast call[1]
818
- // that settles before a slow call[0] cannot surface its
819
- // tool card completion until call[0] resolves. Fire
820
- // onToolResult here — the instant THIS eager tool settles —
821
- // so parallel cards complete independently in the order they
822
- // actually finish.
823
- //
824
- // This message is NOT pushed into `messages`: provider
825
- // history ordering stays exactly call-order. The serial loop
826
- // still builds the REAL tool_result and pushes it via
827
- // pushToolResultMessage (which fires onToolResult AGAIN for
828
- // the same toolCallId in call order — the TUI dedupes by id,
829
- // so the duplicate notify is harmless). __earlyNotify marks
830
- // this as the pre-history, UI-only signal.
831
- //
832
- // Only genuinely-executed eager promises reach here:
833
- // startEagerTool never creates an entry for dedup /
834
- // repeat-failure-guard / pre-dispatch-deny / invalid-args
835
- // calls (they return null above), so those `continue`-before-
836
- // execution stub paths can never early-notify (contract #5).
837
- try {
838
- // UI-only: surface the model-VISIBLE result (envelope
839
- // stub for envelope returns), never the envelope object
840
- // or its injected newMessages body — no [object Object],
841
- // no full skill body in the tool card.
842
- const _earlyVisible = settled && settled.ok
843
- ? normalizeToolEnvelope(settled.value).result
844
- : null;
845
- const _earlyContent = settled && settled.ok
846
- ? (typeof _earlyVisible === 'string'
847
- ? _earlyVisible
848
- : (_earlyVisible == null ? '' : String(_earlyVisible)))
849
- : `Error: ${settled && settled.error instanceof Error ? settled.error.message : String(settled && settled.error)}`;
850
- opts.onToolResult?.({
851
- role: 'tool',
852
- toolCallId: call.id,
853
- content: _earlyContent,
854
- isError: !(settled && settled.ok),
855
- __earlyNotify: true,
856
- });
857
- } catch { /* best-effort — UI notify must never break the eager path */ }
858
- // Intentionally do NOT delete _sig here — see the block
859
- // comment above. The sig must outlive promise settlement
860
- // so a later same-turn streaming duplicate stays blocked
861
- // at the _eagerInFlightSigs.has(_sig) guard until the turn
862
- // boundary recreates the Map.
863
- return settled;
864
- });
865
- pending.set(call.id, entry);
866
- return entry;
867
- };
868
- const startEagerRun = (calls, startIndex, dupSet) => {
869
- for (let j = startIndex; j < calls.length; j += 1) {
870
- const call = calls[j];
871
- if (!call?.id || !isEagerDispatchable(call.name, tools)) break;
872
- if (dupSet && dupSet.has(call.id)) continue;
873
- if (!startEagerTool(call) && !pending.has(call.id)) break;
874
- }
875
- };
876
- let _streamEagerBlocked = false;
877
- opts.onToolCall = (call) => {
878
- if (!isEagerDispatchable(call?.name, tools)) {
879
- _streamEagerBlocked = true;
880
- return;
881
- }
882
- if (_streamEagerBlocked) return;
883
- startEagerTool(call);
884
- };
885
- // Reattach separated tool results, then drop only truly dangling
886
- // assistant/orphan pairs before the provider sees the transcript.
887
- repairTranscriptBeforeProviderSend(messages, sessionId);
888
- // Strip soft-warn markers from prior tool results before the next
889
- // send. Marker bytes (Tool-budget(xN), Same-file reads(xN), etc.)
890
- // mutate every turn with dynamic counters, so leaving them in the
891
- // transcript breaks server-side prefix cache lookup on later turns.
892
- // The current turn's marker (if any) is appended AFTER this strip,
893
- // so the model still sees the self-correct hint on its own iteration.
894
- for (let _i = 0; _i < messages.length; _i++) {
895
- const _m = messages[_i];
896
- if (_m && _m.role === 'tool' && typeof _m.content === 'string' && _m.content.includes('⚠')) {
897
- const _stripped = stripSoftWarns(_m.content);
898
- if (_stripped !== _m.content) _m.content = _stripped;
899
- }
900
- }
901
- const sendStartedAt = Date.now();
902
- try {
903
- response = await provider.send(messages, model, sendTools.length ? sendTools : undefined, opts);
904
- } catch (sendErr) {
905
- // Partial-final recovery (owner-notify fix): the recurring "worker
906
- // finished but the task hung / no result delivered" case is a FINAL,
907
- // no-tool summary stream that wedges (ping-only) AFTER all real tool
908
- // work completed in earlier iterations. The provider attaches its
909
- // partial stream state to the StreamStalledError. When the stall
910
- // carries streamed assistant text, has NO pending tool_use, and did
911
- // NOT emit a tool call this iteration, accept the partial as a
912
- // successful terminal response (deliver the summary we have) instead
913
- // of throwing — which would strand/notify-as-failure a turn whose
914
- // work actually succeeded. A stall WITH a pending/emitted tool call
915
- // is NOT recoverable (a tool whose input never completed must never
916
- // look done) and falls through to the normal error path.
917
- if (
918
- sendErr?.streamStalled === true
919
- && sendErr.pendingToolUse !== true
920
- // NOT gated on unsafeToRetry: live-text stalls stamp
921
- // unsafeToRetry=true (replay would double-render), but
922
- // ACCEPTING the already-streamed partial is exactly the safe
923
- // move (CC rule). Only an emitted tool call blocks acceptance.
924
- && sendErr.emittedToolCall !== true
925
- && typeof sendErr.partialContent === 'string'
926
- && sendErr.partialContent.trim().length > 0
927
- && !(Array.isArray(sendErr.partialToolCalls) && sendErr.partialToolCalls.length > 0)
928
- ) {
929
- try {
930
- process.stderr.write(
931
- `[loop] final stream stalled with partial text (sess=${sessionId || 'unknown'} `
932
- + `iter=${nextIteration} len=${sendErr.partialContent.length}); `
933
- + `accepting as partial-final success\n`,
934
- );
935
- } catch { /* best-effort */ }
936
- response = {
937
- content: sendErr.partialContent,
938
- model: sendErr.partialModel || model,
939
- toolCalls: undefined,
940
- usage: sendErr.partialUsage || undefined,
941
- stopReason: sendErr.partialStopReason || 'end_turn',
942
- hasThinkingContent: sendErr.partialHasThinking === true,
943
- partialFinal: true,
944
- };
945
- } else
946
- // Partial tool-call recovery (agent-hang fix): a stream that stalls
947
- // AFTER fully-parsed tool calls were emitted used to lose the whole
948
- // turn — unsafeToRetry blocks the mid-stream replay (correct: a
949
- // replay would re-run side-effecting tools) and the old code threw,
950
- // discarding tool work that had ALREADY completed via eager dispatch.
951
- // But the parsed calls are complete (pendingToolUse false ⇒ no
952
- // half-streamed tool input), so instead of replaying the request we
953
- // accept the partial as a normal tool-call turn and fall through to
954
- // the standard execution path: eager-dispatched (read-only) calls
955
- // resolve from the pending map without re-running, side-effecting
956
- // calls were never started during streaming and execute exactly
957
- // once. providerState stays undefined so the next iteration resends
958
- // a full frame on a fresh stream.
959
- if (
960
- sendErr?.streamStalled === true
961
- && sendErr.pendingToolUse !== true
962
- && Array.isArray(sendErr.partialToolCalls)
963
- && sendErr.partialToolCalls.length > 0
964
- ) {
965
- try {
966
- process.stderr.write(
967
- `[loop] stream stalled after ${sendErr.partialToolCalls.length} complete tool call(s) `
968
- + `(sess=${sessionId || 'unknown'} iter=${nextIteration}); `
969
- + `recovering as tool-call turn instead of failing\n`,
970
- );
971
- } catch { /* best-effort */ }
972
- try {
973
- appendAgentTrace({
974
- kind: 'stall_tool_recovery',
975
- sessionId: sessionId || null,
976
- iteration: nextIteration,
977
- toolCalls: sendErr.partialToolCalls.length,
978
- partialContentLen: typeof sendErr.partialContent === 'string' ? sendErr.partialContent.length : 0,
979
- });
980
- } catch { /* best-effort */ }
981
- response = {
982
- content: typeof sendErr.partialContent === 'string' ? sendErr.partialContent : '',
983
- model: sendErr.partialModel || model,
984
- toolCalls: sendErr.partialToolCalls.slice(),
985
- usage: sendErr.partialUsage || undefined,
986
- stopReason: 'tool_use',
987
- hasThinkingContent: sendErr.partialHasThinking === true,
988
- partialToolRecovery: true,
989
- };
990
- } else
991
- // Context-window-exceeded is a deterministic refusal from the API.
992
- // Recover context overflow reactively by compacting and retrying
993
- // in the same active turn. MixDog's proactive estimator can miss a
994
- // provider-specific overhead spike, so do one reactive retry by
995
- // marking the live session over-threshold and looping back through
996
- // the normal pre-send auto-compact path. If compaction/retry still
997
- // fails, surface the overflow normally.
998
- if (
999
- !isContextOverflowError(sendErr)
1000
- || !(sessionRef && typeof sessionRef.contextWindow === 'number')
1001
- ) {
1002
- throw sendErr;
1003
- }
1004
- const compactPolicyForRetry = resolveWorkerCompactPolicy(sessionRef, sendTools.length ? sendTools : tools);
1005
- if (!contextOverflowRetryUsed && compactPolicyForRetry?.auto) {
1006
- contextOverflowRetryUsed = true;
1007
- // Mark the next pre-send compact as REACTIVE (driven by a
1008
- // provider overflow refusal) rather than the normal proactive
1009
- // pressure trigger, so the compact event/telemetry the loop
1010
- // emits on the retry is distinguishable downstream.
1011
- reactiveOverflowRetryPending = true;
1012
- opts.onToolCall = undefined;
1013
- try {
1014
- process.stderr.write(
1015
- `[loop] context overflow on send (sess=${sessionId || 'unknown'} iter=${nextIteration}); ` +
1016
- `reactive compact retry messages=${messages.length}\n`,
1017
- );
1018
- } catch { /* best-effort */ }
1019
- continue;
1020
- }
1021
- try {
1022
- process.stderr.write(
1023
- `[loop] context overflow on send (sess=${sessionId || 'unknown'} iter=${nextIteration}); ` +
1024
- `surfacing overflow after reactive compact retry messages=${messages.length}\n`,
1025
- );
1026
- } catch { /* best-effort */ }
1027
- throw agentContextOverflowError({
1028
- stage: 'send',
1029
- sessionId,
1030
- sessionRef,
1031
- model,
1032
- budgetTokens: sessionRef.contextWindow,
1033
- reserveTokens: compactPolicyForRetry?.reserveTokens,
1034
- messageTokensEst: estimateMessagesTokensSafe(messages),
1035
- }, sendErr);
1036
- }
1037
- opts.onToolCall = undefined;
1038
- contextOverflowRetryUsed = false;
1039
- // Capture opaque state for the next turn (may be undefined — that's
1040
- // the stateless contract for providers that don't use continuation).
1041
- providerState = response?.providerState ?? undefined;
1042
- iterations = nextIteration;
1043
- // Payload byte estimate serializes the FULL messages+tools array —
1044
- // only pay that cost when verbose loop tracing is actually enabled
1045
- // (traceAgentLoop is a no-op otherwise).
1046
- if (process.env.MIXDOG_AGENT_TRACE_VERBOSE === '1') {
1047
- traceAgentLoop({
1048
- sessionId,
1049
- iteration: iterations,
1050
- sendMs: Date.now() - sendStartedAt,
1051
- messageCount: Array.isArray(messages) ? messages.length : 0,
1052
- bodyBytesEst: estimateProviderPayloadBytes(messages, model, sendTools),
1053
- });
1054
- }
1055
- // Accumulate usage across iterations — every billable slot, not just
1056
- // input/output. Anthropic cache_read/cache_write typically stay 0 on
1057
- // the first iteration and surge on later ones (warm prefix reuse),
1058
- // so aggregating only the head would silently drop most of the
1059
- // cache-side tokens.
1060
- if (response.usage) {
1061
- const hadUsage = !!lastUsage;
1062
- lastUsage = addUsage(lastUsage, response.usage);
1063
- if (!hadUsage) {
1064
- // Snapshot the first turn separately so callers can show
1065
- // iter1 vs final cache-hit ratios — first iter is the
1066
- // warm-prefix signal, final iter is the steady-state
1067
- // efficiency signal after tool-result accumulation.
1068
- firstTurnUsage = { ...lastUsage };
1069
- }
1070
- }
1071
- // Provider may have returned despite an abort (SDKs that don't honour
1072
- // signal) — bail before processing any of its output.
1073
- throwIfAborted();
1074
- // P1 audit fix (Step4): a text-only turn truncated by the provider's
1075
- // max-output limit (response.truncated, set by the provider layer
1076
- // when stopReason==='length' AND content is non-empty) used to look
1077
- // identical to a clean completion — the model's answer could be
1078
- // silently cut mid-sentence with zero signal to the operator. Surface
1079
- // it as a one-line stderr warning + trace event WITHOUT failing the
1080
- // turn (the partial content is still usable and the loop's own
1081
- // isIncompleteStop nudge below already re-prompts when content is
1082
- // empty).
1083
- if (response?.truncated === true) {
1084
- try {
1085
- process.stderr.write(
1086
- `[loop] provider output truncated at max-output limit (sess=${sessionId || 'unknown'} `
1087
- + `iter=${iterations} stopReason=${response.stopReason ?? response.stop_reason ?? 'length'} `
1088
- + `contentLen=${typeof response.content === 'string' ? response.content.length : 0}); `
1089
- + `answer may be cut off mid-sentence.\n`,
1090
- );
1091
- } catch { /* best-effort */ }
1092
- try {
1093
- appendAgentTrace({
1094
- sessionId,
1095
- iteration: iterations,
1096
- kind: 'output_truncated',
1097
- payload: {
1098
- stop_reason: response.stopReason ?? response.stop_reason ?? 'length',
1099
- content_len: typeof response.content === 'string' ? response.content.length : 0,
1100
- agent: sessionAgent || null,
1101
- },
1102
- });
1103
- } catch { /* best-effort */ }
1104
- }
1105
- // Incremental metric persistence (fix A): push per-iteration token delta
1106
- // immediately so watchdog / agent type=list sees live totals mid-turn.
1107
- if (sessionId && opts.onUsageDelta && response.usage) {
1108
- try {
1109
- opts.onUsageDelta({
1110
- sessionId,
1111
- iterationIndex: iterations,
1112
- usageMetricsTurnId: loopUsageMetricsTurnId(),
1113
- source: 'provider_send',
1114
- usageMetricsEpoch: loopUsageMetricsEpoch(),
1115
- deltaInput: response.usage.inputTokens || 0,
1116
- deltaOutput: response.usage.outputTokens || 0,
1117
- deltaPrompt: response.usage.promptTokens || 0,
1118
- // Cache delta carried alongside input/output so live metrics
1119
- // reflect the same token classes the terminal aggregate adds;
1120
- // additive — callers that ignore these fields keep working.
1121
- deltaCachedRead: response.usage.cachedTokens || 0,
1122
- deltaCacheWrite: response.usage.cacheWriteTokens || 0,
1123
- ts: Date.now(),
1124
- });
1125
- } catch { /* best-effort — never break the loop */ }
1126
- }
1127
- // No tool calls. For PUBLIC agents, the agent contract
1128
- // (rules/agent/00-core.md) requires either a tool call or a final
1129
- // handoff text (fragments).
1130
- // A text-only turn without those tags violates the contract (e.g.
1131
- // Opus 4.6 emits 'Now I'll polish…' preamble before its first tool
1132
- // call) and used to leave the session idle until the idle sweep
1133
- // collected it. Re-prompt the worker with a contract reminder on each
1134
- // empty turn (hard iteration cap bounds total turns). Hidden roles
1135
- // (cycle1-agent / cycle2-agent / explorer /
1136
- // scheduler-task / webhook-handler) are exempt:
1137
- // their own role rules define a different output contract (pipe-
1138
- // separated chunker output, structured pipe-format, etc.) and a
1139
- // text-only terminal turn is the correct shape — nudging them
1140
- // produces a contradictory user message that traps the model in a
1141
- // tool-call-blocked vs contract-required oscillation.
1142
- if (!response.toolCalls?.length) {
1143
- // No tool calls. Decide between final-answer accept vs nudge.
1144
- // Reviewer fix: a zero-tool turn (final-pre-send steering drain or
1145
- // contract nudge `continue`) must not bridge the all-read-only
1146
- // streak across non-tool turns — that would fire level-2 early on
1147
- // a worker that paused to synthesize text mid-run.
1148
- _steeringLadder.resetAllReadOnlyStreak();
1149
- // - has content + non-hidden role → valid final, break.
1150
- // - empty content + hidden role → contract allows text-only
1151
- // terminal turn, break.
1152
- // - empty content + non-hidden role → contract nudge, continue.
1153
- const hasContent = typeof response.content === 'string' && response.content.trim().length > 0;
1154
- const isHidden = HIDDEN_AGENT_NAMES.has(sessionAgent);
1155
- const stopReason = response.stopReason ?? response.stop_reason ?? null;
1156
- const isIncompleteStop = stopReason && INCOMPLETE_STOP_REASONS.has(stopReason);
1157
- // A user/schedule notification can arrive while provider.send() is
1158
- // returning a terminal no-tool response. Drain once before accepting
1159
- // it as final so the queued input is handled in the same active turn
1160
- // instead of waiting for post-turn TUI drain. If the model already
1161
- // produced assistant text, persist that as an intermediate assistant
1162
- // message before appending the steered user message.
1163
- if (drainSteeringIntoMessages('final-pre-send', {
1164
- beforeAppend: () => pushIntermediateAssistantResponse(response),
1165
- })) {
1166
- continue;
1167
- }
1168
- if (!hasContent && !isHidden) {
1169
- _emptyNudgeStreak += 1;
1170
- if (_emptyNudgeStreak > EMPTY_NUDGE_MAX) {
1171
- // Livelock: identical nudges keep producing identical empty
1172
- // completions. Stop re-prompting; classifyTerminationReason
1173
- // tags this final empty response as 'empty' so the caller
1174
- // surfaces an explicit error instead of a silent finish.
1175
- process.stderr.write(`[loop] empty-turn nudge cap ${EMPTY_NUDGE_MAX} reached (sess=${sessionId || 'unknown'}); ending loop as empty termination.\n`);
1176
- break;
1177
- }
1178
- let nudgeMsg;
1179
- if (isIncompleteStop) {
1180
- nudgeMsg = `[mixdog-runtime] Previous turn ended mid-synthesis (stopReason=${stopReason}) with empty content. Continue — emit your final handoff (fragments, file:line) with your synthesis so far, or call more tools to finish.`;
1181
- } else {
1182
- // Vary the nudge per attempt — a byte-identical repeat
1183
- // reinforces the empty-completion pattern it is trying to
1184
- // break (the request context stays effectively constant).
1185
- nudgeMsg = `[mixdog-runtime] Your previous response was empty (no handoff text and no tool call) — attempt ${_emptyNudgeStreak}/${EMPTY_NUDGE_MAX}. Either emit your final handoff text now, or continue with tool calls. Do not return an empty turn.`;
1186
- }
1187
- messages.push({ role: 'user', content: nudgeMsg });
1188
- continue;
1189
- }
1190
- break;
1191
- }
1192
- _emptyNudgeStreak = 0;
1193
- const calls = response.toolCalls;
1194
- toolCallsTotal += calls.length;
1195
- // Surface any mid-turn assistant text (preamble that precedes a tool
1196
- // call) to the UI. Providers that stream text via onTextDelta already
1197
- // rendered it; providers that return the text only in response.content
1198
- // (no deltas) would otherwise show nothing before the tool card. The
1199
- // engine de-dups against already-streamed text, so emitting here is
1200
- // safe for both paths. Sub-agent sessions suppress it entirely
1201
- // (suppressMidTurnText) — Lead only consumes the final answer.
1202
- if (!suppressMidTurnText && typeof response.content === 'string' && response.content.trim()) {
1203
- try { opts.onAssistantText?.(response.content); } catch { /* best-effort */ }
1204
- }
1205
- // Per-turn batch shape — one row per assistant turn so trace
1206
- // consumers can derive multi-tool adoption ratio without scanning
1207
- // every assistant message body.
1208
- recordToolBatch(sessionId, calls.length);
1209
- await Promise.resolve(onToolCall?.(iterations, calls));
1210
- // Append assistant message with tool calls. reasoningItems is the
1211
- // OpenAI Responses API replay payload (encrypted_content blobs);
1212
- // providers that ignore it just see an extra field and drop it,
1213
- // openai-oauth.convertMessagesToResponsesInput emits matching
1214
- // type:'reasoning' input items on the next turn to keep the openai-oauth
1215
- // server-side cache prefix stable.
1216
- const _assistantTurnMsg = {
1217
- role: 'assistant',
1218
- // Sub-agent tool-call turns carry only mid-turn preamble in
1219
- // response.content (the real result rides the later final-answer
1220
- // turn). Blank it so it never accumulates as input tokens.
1221
- content: suppressMidTurnText ? '' : (response.content || ''),
1222
- toolCalls: compactToolCallsForHistory(calls),
1223
- // Anthropic adaptive thinking: prior-turn thinking blocks must be
1224
- // returned verbatim (signature intact; empty thinking allowed) and
1225
- // are REQUIRED back before tool_use blocks on tool-continuation
1226
- // turns. Store them so toAnthropicMessages can build assistantBlocks
1227
- // = [...thinking, tool_use...]. Other providers ignore this field.
1228
- ...(Array.isArray(response.thinkingBlocks) && response.thinkingBlocks.length
1229
- ? { thinkingBlocks: response.thinkingBlocks }
1230
- : {}),
1231
- ...(Array.isArray(response.reasoningItems) && response.reasoningItems.length
1232
- ? { reasoningItems: response.reasoningItems }
1233
- : {}),
1234
- ...(typeof response.reasoningContent === 'string' && response.reasoningContent
1235
- ? { reasoningContent: response.reasoningContent }
1236
- : {}),
1237
- };
1238
- messages.push(_assistantTurnMsg);
1239
- // Hard-cap final turn: tools are disabled but the model still emitted
1240
- // tool calls. Do NOT execute them — push a refusal stub for each.
1241
- if (_capFinalToolsDisabled) {
1242
- for (const _c of calls) {
1243
- pushToolResultMessage({
1244
- role: 'tool',
1245
- content: ITERATION_CAP_REFUSAL_STUB,
1246
- toolCallId: _c.id,
1247
- toolKind: 'error',
1248
- });
1249
- }
1250
- if (sessionId) updateSessionStage(sessionId, 'connecting');
1251
- continue;
1252
- }
1253
- // Execute each tool and append results.
1254
- //
1255
- // Intra-turn duplicate suppression: when an LLM emits two tool_use
1256
- // blocks with identical (name, args) inside the SAME assistant turn,
1257
- // re-executing wastes tokens. Restricted to tools with
1258
- // `readOnlyHint:true` (= isEagerDispatchable) — bash/apply_patch
1259
- // may be intentional repeats with distinct side effects.
1260
- // Pre-pass identifies duplicates BEFORE startEagerRun so eager
1261
- // dispatch also skips them, not just the for-body.
1262
- const _duplicateCallIds = new Set();
1263
- const _dupFirstId = new Map();
1264
- {
1265
- const _firstIdBySig = new Map();
1266
- for (const c of calls) {
1267
- if (!c?.id) continue;
1268
- if (!isEagerDispatchable(c.name, tools)) {
1269
- _firstIdBySig.clear();
1270
- continue;
1271
- }
1272
- const sig = _intraTurnSig(c.name, c.arguments);
1273
- const first = _firstIdBySig.get(sig);
1274
- if (first === undefined) {
1275
- _firstIdBySig.set(sig, c.id);
1276
- } else {
1277
- _duplicateCallIds.add(c.id);
1278
- _dupFirstId.set(c.id, first);
1279
- }
1280
- }
1281
- }
1282
- // R15: per-turn scalar read-count Map. Lifetime = this turn's tool-call batch.
1283
- // Declared between the duplicate-detection block and the for-loop so it resets
1284
- // Per-batch buffer for the general `newMessages` tool-result channel.
1285
- // A tool MAY return a `{ __toolEnvelope, result, newMessages }` envelope;
1286
- // its newMessages (e.g. the Skill SKILL.md body as a role:'user' message)
1287
- // are collected here across EVERY call in this assistant turn and flushed
1288
- // ONCE, AFTER the batch's last tool_result is pushed — never interleaved
1289
- // between two tool results of the same multi-tool turn (which would put a
1290
- // user message between tool(A) and tool(B) and break provider pairing).
1291
- const _batchNewMessages = [];
1292
- for (let callIndex = 0; callIndex < calls.length; callIndex += 1) {
1293
- const call = calls[callIndex];
1294
- if (isBuiltinTool(call.name)) {
1295
- call.name = canonicalizeBuiltinToolName(call.name);
1296
- }
1297
- if (_duplicateCallIds.has(call.id)) {
1298
- const _firstId = _dupFirstId.get(call.id);
1299
- const _stub = `[intra-turn-dedup] identical read-only \`${call.name}\` call was already executed in this same assistant turn as tool_use_id=${_firstId}. The first call's tool_result is in context immediately above; skipping re-execution to save tokens. If you needed a different slice of the file, narrow the next call (different path / offset / limit / pattern) so it has a distinct signature.`;
1300
- pushToolResultMessage({
1301
- role: 'tool',
1302
- content: _stub,
1303
- toolCallId: call.id,
1304
- });
1305
- continue;
1306
- }
1307
- // Cross-turn identical-call stub (Step 2): a SUCCESSFUL read-only
1308
- // (isEagerDispatchable) call whose (name,args) signature already ran
1309
- // in an EARLIER turn is not re-executed — its result is unchanged and
1310
- // already in context. Warn at the 2nd occurrence; append the "stuck"
1311
- // escalation tail once the session has emitted 5+ dedup stubs total.
1312
- // Never applies to write/bash/MCP/skill tools (not eager-dispatchable).
1313
- if (isEagerDispatchable(call.name, tools)) {
1314
- const _ctSig = crossTurnSignature(call.name, call.arguments);
1315
- const _prior = _crossTurnCalls.get(_ctSig);
1316
- if (_prior && _prior.firstIteration < iterations) {
1317
- _prior.count += 1;
1318
- _dedupStubTotal += 1;
1319
- const _stub = crossTurnDedupStub(call.name, _prior.firstIteration, _dedupStubTotal >= 5);
1320
- pushToolResultMessage({
1321
- role: 'tool',
1322
- content: _stub,
1323
- toolCallId: call.id,
1324
- });
1325
- try {
1326
- appendAgentTrace({
1327
- sessionId,
1328
- iteration: iterations,
1329
- kind: 'steer',
1330
- payload: {
1331
- tag: 'cross_turn_dedup',
1332
- tool: call.name,
1333
- occurrence: _prior.count,
1334
- first_iteration: _prior.firstIteration,
1335
- dedup_stub_total: _dedupStubTotal,
1336
- },
1337
- agent: sessionAgent || null,
1338
- });
1339
- } catch { /* best-effort */ }
1340
- continue;
1341
- }
1342
- }
1343
- // Cross-iteration repeat-failure guard. Distinct from the
1344
- // intra-turn dedup above (which spans ONE assistant turn and
1345
- // resets every turn): when the model re-issues an IDENTICAL
1346
- // (name,args) call that has already failed REPEAT_FAIL_LIMIT times
1347
- // in a row across iterations, stop re-executing — the result will
1348
- // not change, and each retry burns a full (often slow) LLM
1349
- // round-trip until the hard iteration cap. Steer it to change
1350
- // approach instead.
1351
- const _repeatFailSig = _intraTurnSig(call.name, call.arguments);
1352
- {
1353
- const _rfg = sessionRef?._repeatFailGuard;
1354
- if (_rfg && _rfg.sig === _repeatFailSig && _rfg.count >= REPEAT_FAIL_LIMIT) {
1355
- pushToolResultMessage({
1356
- role: 'tool',
1357
- content: `[repeat-failure-guard] This exact \`${call.name}\` call (identical arguments) has already failed ${_rfg.count} times in a row; not re-executing because the result will not change. Change approach: use different arguments, a different tool, or skip this step.`,
1358
- toolCallId: call.id,
1359
- });
1360
- continue;
1361
- }
1362
- }
1363
- if (sessionId) markSessionToolCall(sessionId, call.name);
1364
- let result;
1365
- let toolStartedAt;
1366
- let toolEndedAt;
1367
- const toolKind = getToolKind(call.name);
1368
- // Cross-turn read dedup: if the path's stat tuple (mtime/size/ino/dev)
1369
- // is unchanged since a prior read in THIS session, return the cached
1370
- // body instead of executing. Both scalar and array/object-array path
1371
- // forms are cached — keyed by (abs, offset, limit, mode, n) per entry.
1372
- //
1373
- // Scoped-tool cache (grep/glob/list + graph lookups): same idea
1374
- // but keyed by (toolName, canonical args) without per-file stat.
1375
- // These tools scan many files so a single stat tuple cannot cover
1376
- // them. The scoped cache registers dependency roots and write-class
1377
- // tools evict entries whose root contains the touched path.
1378
- let _readCacheHit = null;
1379
- let _scopedCacheHit = null;
1380
- let _executeOk = false;
1381
- let _resultKind = 'normal';
1382
- // Invalid-args guard (native convergence): the provider parser tags
1383
- // a tool call whose arguments JSON could not be parsed with an
1384
- // invalid-args marker instead of throwing or swallowing to {}.
1385
- // Such a call must NOT execute — there are no usable arguments and
1386
- // permission/cache checks are meaningless. Skip straight to the
1387
- // error-feedback path so the model gets an is_error tool_result and
1388
- // re-issues the call with valid JSON in the same turn.
1389
- const _invalidArgs = isInvalidToolArgsMarker(call.arguments);
1390
- if (_invalidArgs) {
1391
- // no cache lookup for an un-parseable call
1392
- } else if (sessionId && _isReadTool(call.name)) {
1393
- _readCacheHit = tryReadCached({ sessionId, args: call.arguments, cwd });
1394
- } else if (sessionId && _isScopedCacheableTool(call.name)) {
1395
- _scopedCacheHit = tryScopedToolCached({ sessionId, toolName: _stripMcpPrefix(call.name), args: call.arguments, cwd });
1396
- }
1397
- try {
1398
- if (_invalidArgs) {
1399
- toolStartedAt = Date.now();
1400
- toolEndedAt = toolStartedAt;
1401
- result = formatInvalidToolArgsResult(call);
1402
- _resultKind = 'error';
1403
- _executeOk = false;
1404
- } else if (_readCacheHit !== null) {
1405
- toolStartedAt = Date.now();
1406
- toolEndedAt = toolStartedAt;
1407
- const _body = _readCacheHit.content;
1408
- // Return the cached body byte-for-byte instead of a
1409
- // human-readable cache marker. The marker made public
1410
- // agents treat a successful cached read as a
1411
- // meta instruction and repeat the same read loop.
1412
- result = _body;
1413
- _resultKind = 'cache-hit';
1414
- _executeOk = true;
1415
- } else if (_scopedCacheHit !== null) {
1416
- toolStartedAt = Date.now();
1417
- toolEndedAt = toolStartedAt;
1418
- const _body = _scopedCacheHit.content;
1419
- result = _body;
1420
- _resultKind = 'scoped-cache-hit';
1421
- _executeOk = true;
1422
- } else {
1423
- // Fallback for providers that don't stream tool calls early:
1424
- // execute a contiguous read-only run in parallel, but never
1425
- // cross a write/bash/MCP boundary that may change state.
1426
- if (isEagerDispatchable(call.name, tools)) {
1427
- startEagerRun(calls, callIndex, _duplicateCallIds);
1428
- }
1429
- let eager = pending.get(call.id);
1430
- if (eager !== undefined && eager.mutationEpoch < _mutationEpoch) {
1431
- pending.delete(call.id);
1432
- eager = undefined;
1433
- }
1434
- if (eager !== undefined) {
1435
- toolStartedAt = eager.startedAt;
1436
- const settled = await eager.promise;
1437
- if (!settled.ok) throw settled.error;
1438
- result = settled.value;
1439
- toolEndedAt = eager.endedAt ?? Date.now();
1440
- const _eagerKind = classifyResultKind(result);
1441
- if (_eagerKind === 'error') {
1442
- _resultKind = 'error';
1443
- _executeOk = false;
1444
- } else {
1445
- _executeOk = true;
1446
- }
1447
- } else {
1448
- toolStartedAt = Date.now();
1449
- // Runtime pre-dispatch deny. Schema profiles may hide
1450
- // tools for routing efficiency, but this remains the
1451
- // control-plane boundary for any tool_use that still
1452
- // reaches the loop. preDispatchDenyForSession is the SHARED helper
1453
- // used by both the eager dispatch path (startEagerTool)
1454
- // and this serial path — keeps the agent-owned control-
1455
- // plane reject and no-tool role guards consistent across
1456
- // both paths.
1457
- const _denyMsg = preDispatchDenyForSession(sessionRef, call, toolKind);
1458
- if (_denyMsg !== null) {
1459
- result = _denyMsg;
1460
- toolEndedAt = Date.now();
1461
- _resultKind = 'error';
1462
- } else {
1463
- result = await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: iterations });
1464
- toolEndedAt = Date.now();
1465
- // Boundary: tool-return string convention → structural kind.
1466
- // The only prefix check in this codebase; downstream layers
1467
- // operate on _resultKind.
1468
- if (classifyResultKind(result) === 'error') {
1469
- _resultKind = 'error';
1470
- _executeOk = false;
1471
- } else {
1472
- _executeOk = true;
1473
- }
1474
- // _resultKind stays 'normal' when tool returned a non-error string.
1475
- }
1476
- }
1477
- } // close: else branch of _readCacheHit check
1478
- }
1479
- catch (err) {
1480
- if (toolStartedAt === undefined) toolStartedAt = Date.now();
1481
- toolEndedAt = Date.now();
1482
- result = `Error: ${err instanceof Error ? err.message : String(err)}`;
1483
- _resultKind = 'error';
1484
- }
1485
- // CENTRAL ENVELOPE NORMALIZE (general newMessages channel).
1486
- // executeTool (serial + eager) and cache/error paths above all
1487
- // funnel into `result`. Split ONCE here: downstream post-processing
1488
- // (classifyResultKind / maybeOffloadToolResult / compressToolResult /
1489
- // traceAgentTool / cache writes / messages.push) sees ONLY the
1490
- // model-visible `result`; the `newMessages` ride a per-batch buffer
1491
- // flushed after the batch's last tool_result (never interleaved).
1492
- {
1493
- const _env = normalizeToolEnvelope(result);
1494
- result = _env.result;
1495
- if (_env.newMessages.length) _batchNewMessages.push(..._env.newMessages);
1496
- }
1497
- // Bounded-map cleanup: a scoped-cache outcome recorded for this call.id
1498
- // (via _scopedCacheOutcomeForCall) is only ever consumed/deleted on the
1499
- // success path below (_executeOk && _resultKind==='normal'). A failed or
1500
- // errored call would otherwise leak its entry in
1501
- // sessionRef._scopedCacheOutcomeByCallId forever — reclaim it here.
1502
- if (sessionRef?._scopedCacheOutcomeByCallId instanceof Map && call?.id && (!_executeOk || _resultKind === 'error')) {
1503
- sessionRef._scopedCacheOutcomeByCallId.delete(call.id);
1504
- }
1505
- // PostToolUseFailure: a tool that resolved to a failure (thrown-error
1506
- // path -> `Error:` string, or an is_error result classified as
1507
- // 'error') fires the optional session failure hook. Same shape as
1508
- // afterToolHook; `result` carries the error text. Best-effort — a
1509
- // hook error must never wedge the tool loop.
1510
- if (!_executeOk || _resultKind === 'error') {
1511
- const _afterToolFailureHook = typeof opts.afterToolFailureHook === 'function'
1512
- ? opts.afterToolFailureHook
1513
- : sessionRef?.afterToolFailureHook;
1514
- if (typeof _afterToolFailureHook === 'function') {
1515
- try {
1516
- await _afterToolFailureHook({
1517
- name: call.name,
1518
- args: call.arguments,
1519
- cwd,
1520
- sessionId,
1521
- toolCallId: call.id,
1522
- result: typeof result === 'string' ? result : String(result ?? ''),
1523
- });
1524
- } catch { /* best-effort: PostToolUseFailure hook must never break the loop */ }
1525
- }
1526
- }
1527
- // Update the cross-iteration repeat-failure guard with this call's
1528
- // outcome: bump the consecutive-failure count for an identical
1529
- // signature, or clear it the moment the same call succeeds.
1530
- if (sessionRef) {
1531
- const _failed = !_executeOk || _resultKind === 'error';
1532
- if (_failed) {
1533
- sessionRef._repeatFailGuard = (sessionRef._repeatFailGuard?.sig === _repeatFailSig)
1534
- ? { sig: _repeatFailSig, count: sessionRef._repeatFailGuard.count + 1 }
1535
- : { sig: _repeatFailSig, count: 1 };
1536
- } else if (sessionRef._repeatFailGuard?.sig === _repeatFailSig) {
1537
- sessionRef._repeatFailGuard = null;
1538
- }
1539
- }
1540
- // A failed executed call keeps its FULL argument body in history so the
1541
- // model can retry against the original (a large apply_patch `patch`
1542
- // would otherwise be hidden behind a
1543
- // `[mixdog compacted …]` placeholder). Restored IMMEDIATELY — not at end
1544
- // of loop — so an abort or post-processing throw after this point cannot
1545
- // leave a failed patch compacted. Cache-safe: _assistantTurnMsg is not
1546
- // transmitted until the next provider.send. Early-continue paths (dedup /
1547
- // repeat-failure-guard) never reach here and stay compacted.
1548
- if ((!_executeOk || _resultKind === 'error') && call?.id) {
1549
- restoreToolCallBodyForId(_assistantTurnMsg, calls, call.id);
1550
- }
1551
- // Cross-turn cache maintenance — gate on both _executeOk and _resultKind==='normal'.
1552
- // _executeOk=false catches permission-blocked / catch-path / partial-fail results.
1553
- // _resultKind==='normal' ensures cache-hit refs are never re-stored (structural,
1554
- // no prefix sniffing).
1555
- // NOTE: setReadCached / setScopedToolCached are deferred below (after
1556
- // compressToolResult) so the cache holds the same content as conversation
1557
- // history. Cache-hit refs point to a tool_use_id whose message body matches
1558
- // exactly what's stored — no phantom full body.
1559
- if (sessionId && _executeOk && _resultKind === 'normal') {
1560
- const _toolBare = _stripMcpPrefix(call.name);
1561
- if (_readCacheHit === null && _isReadTool(call.name)) {
1562
- // Post-patch advisory: handle BOTH scalar and array forms
1563
- // of args.path. The array form (path:[a,b,c] or
1564
- // path:[{path:a},{path:b}]) was a coverage gap in R1 —
1565
- // an LLM that patches X then reads [X,Y] should still see
1566
- // the advisory for X.
1567
- const _argsPath = call.arguments?.path;
1568
- const _pathList = [];
1569
- if (typeof _argsPath === 'string') {
1570
- _pathList.push(_argsPath);
1571
- } else if (typeof call.arguments?.file_path === 'string') {
1572
- _pathList.push(call.arguments.file_path);
1573
- } else if (Array.isArray(_argsPath)) {
1574
- for (const _item of _argsPath) {
1575
- if (typeof _item === 'string') _pathList.push(_item);
1576
- else if (_item && typeof _item === 'object') {
1577
- const _itemPath = typeof _item.path === 'string' ? _item.path : _item.file_path;
1578
- if (typeof _itemPath === 'string') _pathList.push(_itemPath);
1579
- }
1580
- }
1581
- }
1582
- const _marks = [];
1583
- for (const _p of _pathList) {
1584
- const _m = consumePostEditMark({ sessionId, path: _p, cwd });
1585
- if (_m) _marks.push({ path: _p, mark: _m });
1586
- }
1587
- } else if (_toolBare === 'apply_patch') {
1588
- // apply_patch's args are a unified-diff text in `patch`
1589
- // (resolved against `base_path` or cwd). Parse the diff
1590
- // headers (`--- a/path` / `+++ b/path`) to extract the
1591
- // touched paths and invalidate / mark each one. Falls
1592
- // back to a full session clear only when no paths could
1593
- // be parsed (malformed diff or unknown format).
1594
- const _argsBase = call.arguments?.base_path;
1595
- const _patchBase = (typeof _argsBase === 'string' && _argsBase.length > 0)
1596
- ? (isAbsolute(_argsBase) ? _argsBase : resolvePath(cwd || process.cwd(), _argsBase))
1597
- : (cwd || process.cwd());
1598
- const _touched = extractTouchedPathsFromPatch(call.arguments?.patch);
1599
- if (_touched.length > 0) {
1600
- for (const _p of _touched) {
1601
- invalidatePathForSession(sessionId, _p, _patchBase);
1602
- markPostEdit({ sessionId, path: _p, cwd: _patchBase, toolName: 'apply_patch' });
1603
- // R20: cross-dispatch prefetch cache invalidation.
1604
- invalidatePrefetchCache(_p, _patchBase);
1605
- }
1606
- } else {
1607
- clearReadDedupSession(sessionId);
1608
- // R20: path unknown — can't target; no-op on prefetch cache
1609
- // (stat-validation at lookup time will naturally reject stale entries).
1610
- }
1611
- // Targeted scoped-cache invalidation: only evict entries whose
1612
- // dep paths intersect the touched set. Full wipe is the fallback
1613
- // when no paths were extracted (D).
1614
- if (_touched.length > 0) {
1615
- clearScopedToolsForSessionPaths(sessionId, _touched, _patchBase);
1616
- } else {
1617
- clearScopedToolsForSession(sessionId);
1618
- }
1619
- }
1620
- } // end _executeOk+_resultKind gate (scoped tool cache set)
1621
- // E: mutation tools (apply_patch) must invalidate caches
1622
- // even on returned-error/partial-fail — the file state is unknown after
1623
- // an error exit, and some tools report failure as an Error: result string
1624
- // rather than throwing.
1625
- // This block runs unconditionally (not gated on _executeOk or _resultKind).
1626
- if (sessionId && (!_executeOk || _resultKind === 'error') && _stripMcpPrefix(call.name) === 'apply_patch') {
1627
- clearReadDedupSession(sessionId);
1628
- }
1629
- if (_isMutationTool(call.name)) {
1630
- _mutationEpoch += 1;
1631
- }
1632
- // Bash always clears scoped cache UNCONDITIONALLY — a mutating bash
1633
- // that throws or fails partway can still leave stale find_symbol / grep entries.
1634
- // Must not be gated on _executeOk or _resultKind.
1635
- if (sessionId && _isShellTool(call.name)) {
1636
- clearScopedToolsForSession(sessionId);
1637
- }
1638
- // R17 compression pipeline — correct ordering (compress → cache → push):
1639
- // 1. compressToolResult: lossless ANSI/dedup/separator passes.
1640
- // 2. setReadCached / setScopedToolCached: cache stores the SAME result that
1641
- // goes into conversation history. Cache-hit refs point to the tool_use_id
1642
- // whose message body matches — no phantom full body.
1643
- // 3. offload → hint → message push.
1644
- // Offload FIRST — before compress. Large RAW output goes to a disk sidecar
1645
- // + ~2K preview before any in-place shrink (lossless compress) can reduce
1646
- // it below the offload threshold and pre-empt the sidecar. When offload
1647
- // fires it replaces `result` with a short preview stub (<2K) referencing
1648
- // the on-disk path; the later compress is a no-op on that stub. compress
1649
- // then only touches output that stayed inline (<= threshold).
1650
- // Per-tool post-processing backstop. The executeTool try/catch
1651
- // above terminates BEFORE offload/compress/trim/hint/cache writes/
1652
- // trace/messages.push, so a maybeOffloadToolResult rejection (or
1653
- // any downstream throw) would otherwise leave the assistant
1654
- // tool_use message with no matching tool result. Wrap the whole
1655
- // post-processing window through messages.push() in a catch; on
1656
- // failure push a synthetic Error: tool result for this call.id
1657
- // and skip the cache writes for it.
1658
- let _postProcessOk = true;
1659
- let _nativeToolSearch = null;
1660
- try {
1661
- // Offload thresholds are keyed by BARE tool name
1662
- // (INLINE_THRESHOLD_BY_TOOL: grep=20k, bash=30k, read=Infinity, ...),
1663
- // so strip the MCP prefix exactly as the cache write below does.
1664
- // Otherwise an mcp__..__grep name misses its 20k grep cap and
1665
- // silently falls back to the 50k default — per-tool limits ignored.
1666
- const _toolBare = _stripMcpPrefix(call.name);
1667
- _nativeToolSearch = parseNativeToolSearchPayload(call.name, result);
1668
- if (_nativeToolSearch?.summary) result = _nativeToolSearch.summary;
1669
- result = await maybeOffloadToolResult(sessionId, call.id, _toolBare, result);
1670
- result = compressToolResult(call.name, call.arguments, result, { sessionId, toolKind });
1671
- traceAgentTool({
1672
- sessionId,
1673
- iteration: iterations,
1674
- toolName: call.name,
1675
- toolKind,
1676
- toolMs: toolEndedAt - toolStartedAt,
1677
- toolArgs: call.arguments,
1678
- agent: sessionRef?.agent || null,
1679
- model: sessionRef?.model || null,
1680
- resultKind: _resultKind,
1681
- resultText: result,
1682
- cwd,
1683
- });
1684
- // Cache stores run AFTER compress+trim+offload+hint AND after all other
1685
- // post-processing (trace) so stored content == history content. Placing
1686
- // the cache writes immediately before messages.push ensures ANY throw
1687
- // earlier in post-processing skips the cache entirely — no stale or
1688
- // partial result is ever cached. Cache-hit refs pointing to an offloaded
1689
- // tool_use will show the offload stub; LLM can still recover the full
1690
- // body via the disk path in that stub.
1691
- if (sessionId && _executeOk && _resultKind === 'normal') {
1692
- if (_scopedCacheHit === null && _isScopedCacheableTool(call.name)) {
1693
- const _outcomeMap = sessionRef?._scopedCacheOutcomeByCallId instanceof Map
1694
- ? sessionRef._scopedCacheOutcomeByCallId : null;
1695
- const _outcome = _outcomeMap?.get(call.id);
1696
- setScopedToolCached({
1697
- sessionId,
1698
- toolName: _toolBare,
1699
- args: call.arguments,
1700
- cwd,
1701
- content: result,
1702
- toolUseId: call.id,
1703
- complete: _outcome ? _outcome.complete : true,
1704
- });
1705
- _outcomeMap?.delete(call.id);
1706
- }
1707
- if (_readCacheHit === null && _isReadTool(call.name)) {
1708
- // Pass tool_use id so future cache-hits can reference the body's location in history.
1709
- setReadCached({ sessionId, args: call.arguments, cwd, content: result, toolUseId: call.id });
1710
- }
1711
- }
1712
- // UI-only: apply_patch stashes the standard unified diff keyed
1713
- // by tool_use id (never in the model-visible result). Attach it
1714
- // here as a side-channel field so the TUI's expanded (ctrl+o)
1715
- // raw view renders a colored +/- diff. The provider lowering
1716
- // (anthropic/openai/etc.) never reads `uiDiff`, so the model
1717
- // sees only `content` (the compact summary) — no token bloat.
1718
- const _applyPatchUiDiff = _stripMcpPrefix(call.name) === 'apply_patch'
1719
- ? takeApplyPatchUiDiff(call.id)
1720
- : null;
1721
- pushToolResultMessage({
1722
- role: 'tool',
1723
- content: result,
1724
- toolCallId: call.id,
1725
- toolKind: _resultKind,
1726
- ...(_nativeToolSearch ? { nativeToolSearch: _nativeToolSearch } : {}),
1727
- ...(_applyPatchUiDiff ? { uiDiff: _applyPatchUiDiff } : {}),
1728
- });
1729
- // Completion-first bookkeeping (Steps 1 & 2). Only successful
1730
- // executions count. Edit/progress = any executed tool whose def
1731
- // lacks readOnlyHint (apply_patch/bash/MCP-write/skill/...).
1732
- // Read-only successful calls seed the cross-turn dedup map.
1733
- if (_executeOk) {
1734
- const _isEager = isEagerDispatchable(call.name, tools);
1735
- if (_isEager) {
1736
- const _ctSig = crossTurnSignature(call.name, call.arguments);
1737
- if (!_crossTurnCalls.has(_ctSig)) {
1738
- _crossTurnCalls.set(_ctSig, { count: 1, firstIteration: iterations });
1739
- if (_crossTurnCalls.size > _CROSS_TURN_CAP) {
1740
- const _oldest = _crossTurnCalls.keys().next().value;
1741
- _crossTurnCalls.delete(_oldest);
1742
- }
1743
- }
1744
- } else {
1745
- // A successful mutating (non-eager) tool invalidates the
1746
- // cross-turn dedup map wholesale: any prior read/grep may
1747
- // now return different content, so a post-edit
1748
- // verification read must NOT be stubbed as "unchanged".
1749
- if (isEditProgressTool(call.name, false)) {
1750
- _crossTurnCalls.clear();
1751
- _editCount += 1;
1752
- }
1753
- }
1754
- }
1755
- } catch (postErr) {
1756
- _postProcessOk = false;
1757
- // Reviewer fix: the exec itself succeeded — if it was a
1758
- // mutating edit-progress tool, the file changes are real even
1759
- // though post-processing failed, so the cross-turn dedup map
1760
- // must still be invalidated (otherwise a later verification
1761
- // read could be stubbed as "unchanged" against stale sigs).
1762
- if (_executeOk && !isEagerDispatchable(call.name, tools) && isEditProgressTool(call.name, false)) {
1763
- _crossTurnCalls.clear();
1764
- _editCount += 1;
1765
- }
1766
- // Post-processing failed AFTER a successful exec: the result is
1767
- // replaced with an error below, so preserve this call's full body
1768
- // too for a clean retry (mirrors the failed-exec path above).
1769
- if (call?.id) restoreToolCallBodyForId(_assistantTurnMsg, calls, call.id);
1770
- const _postMsg = `Error: tool result post-processing failed for "${call.name}": ${postErr instanceof Error ? postErr.message : String(postErr)}`;
1771
- traceAgentToolFailure({
1772
- sessionId,
1773
- iteration: iterations,
1774
- toolName: call.name,
1775
- toolKind,
1776
- toolMs: toolEndedAt && toolStartedAt ? toolEndedAt - toolStartedAt : null,
1777
- toolArgs: call.arguments,
1778
- agent: sessionRef?.agent || null,
1779
- model: sessionRef?.model || null,
1780
- cwd,
1781
- resultText: _postMsg,
1782
- resultKind: 'error',
1783
- });
1784
- // Always emit a matching tool result so the assistant
1785
- // tool_use isn't orphaned. Cache writes are placed at the
1786
- // end of the try block (immediately before messages.push),
1787
- // so ANY throw in post-processing reaches this catch before
1788
- // the cache is written — stale/partial results are never
1789
- // cached. The next read on the same path/scope re-executes
1790
- // naturally.
1791
- pushToolResultMessage({
1792
- role: 'tool',
1793
- content: _postMsg,
1794
- toolCallId: call.id,
1795
- toolKind: 'error',
1796
- });
1797
- }
1798
- // Soft-cancel after each tool: if close landed during execution,
1799
- // discard the rest of the batch and skip the next provider.send.
1800
- throwIfAborted();
1801
- }
1802
- // Flush the per-batch newMessages channel. All tool_results for this
1803
- // assistant turn are now pushed; appending the injected role:'user'
1804
- // messages here (AFTER the last tool_result, BEFORE the next provider
1805
- // send) keeps provider pairing valid — no user message is interleaved
1806
- // between tool(A) and tool(B). pre-send repairTranscriptBeforeProviderSend
1807
- // normalizes any residual ordering. The injected messages carry their
1808
- // own meta flag (e.g. meta:'skill') so compaction's latest-human-prompt
1809
- // selection does not mistake them for the user's request.
1810
- for (const _nm of _batchNewMessages) {
1811
- if (!_nm || _nm.role !== 'user' || typeof _nm.content !== 'string' || !_nm.content) continue;
1812
- messages.push({ role: 'user', content: _nm.content, ...(_nm.meta ? { meta: _nm.meta } : {}) });
1813
- }
1814
- // PostToolBatch: the full parallel batch of tool calls for this
1815
- // assistant turn has resolved and all tool_results are pushed. Fire the
1816
- // optional session hook before the next model call. No matcher event.
1817
- // Block support: if the hook returns blocked===true, inject its reason
1818
- // as a system-note user message for the next send (natural mechanism —
1819
- // same channel the newMessages flush just used). Best-effort otherwise.
1820
- {
1821
- const _afterToolBatchHook = typeof opts.afterToolBatchHook === 'function'
1822
- ? opts.afterToolBatchHook
1823
- : sessionRef?.afterToolBatchHook;
1824
- if (typeof _afterToolBatchHook === 'function' && calls.length > 0) {
1825
- try {
1826
- const _batchDecision = await _afterToolBatchHook({
1827
- sessionId,
1828
- cwd,
1829
- toolCount: calls.length,
1830
- });
1831
- if (_batchDecision?.blocked === true) {
1832
- const _reason = String(_batchDecision.reason || 'PostToolBatch hook blocked continuation').trim();
1833
- if (_reason) {
1834
- messages.push({ role: 'user', content: `<system-reminder>\n${_reason}\n</system-reminder>`, meta: 'hook' });
1835
- }
1836
- }
1837
- } catch { /* best-effort: PostToolBatch hook must never break the loop */ }
1838
- }
1839
- }
1840
- // Completion-first steering hints (missed-parallelism / all-read-only /
1841
- // serial-rewording). At most ONE hint per turn. The ladder controller
1842
- // owns the cumulative counters and streaks.
1843
- _steeringLadder.emitPostBatchSteering(calls, false);
1844
- // Mid-turn steering is drained at the next loop's pre-send point,
1845
- // AFTER any auto-compact pass. Draining here would put the steering
1846
- // user turn after the fresh tool results before compaction runs; then
1847
- // semantic/recall compaction would treat those fresh tool results as
1848
- // prior history before the model sees them.
1849
- // About to re-send with tool results — transition back to connecting for the next turn.
1850
- if (sessionId) updateSessionStage(sessionId, 'connecting');
1851
- }
1852
- // Classify WHY the loop ended so agent-tool can promote an empty/abnormal
1853
- // finish to an explicit Lead-facing error instead of a silent empty
1854
- // "completed" (see classifyTerminationReason in ./loop/termination.mjs).
1855
- const terminationReason = classifyTerminationReason(response, {
1856
- terminatedByCap,
1857
- sessionAgent,
1858
- });
1859
- return {
1860
- ...response,
1861
- usage: lastUsage || response.usage,
1862
- lastTurnUsage: response.usage,
1863
- firstTurnUsage: firstTurnUsage || response.usage,
1864
- iterations,
1865
- toolCallsTotal,
1866
- providerState,
1867
- terminationReason,
1868
- maxLoopIterations,
1869
- };
1870
- }
18
+ } from './agent-loop.mjs';