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
@@ -0,0 +1,642 @@
1
+ // Tool-call batch processor, extracted from agent-loop.mjs. Runs the whole
2
+ // per-assistant-turn tool phase: intra-turn duplicate pre-pass, the serial
3
+ // call loop (eager-result collection, cross-turn dedup, repeat-failure guard,
4
+ // cache read/write, offload/compress, hooks, envelope newMessages), the
5
+ // per-batch newMessages flush, PostToolBatch hook, and completion-first
6
+ // steering. Mutable counters (dedupStubTotal/editCount) are threaded in/out;
7
+ // crossTurnCalls/epoch/pending mutate by reference. Behavior identical.
8
+ import { resolve as resolvePath, isAbsolute } from 'path';
9
+ import { canonicalizeBuiltinToolName, isBuiltinTool } from '../tools/builtin.mjs';
10
+ import { takeApplyPatchUiDiff } from '../tools/patch.mjs';
11
+ import { compressToolResult } from '../tools/result-compression.mjs';
12
+ import { appendAgentTrace, traceAgentTool, traceAgentToolFailure } from '../agent-trace.mjs';
13
+ import { markSessionToolCall, updateSessionStage } from './manager.mjs';
14
+ import { classifyResultKind } from './result-classification.mjs';
15
+ import { normalizeToolEnvelope } from './tool-envelope.mjs';
16
+ import { maybeOffloadToolResult } from './tool-result-offload.mjs';
17
+ import {
18
+ tryReadCached, setReadCached, invalidatePathForSession, markPostEdit,
19
+ consumePostEditMark, clearReadDedupSession, extractTouchedPathsFromPatch,
20
+ tryScopedToolCached, setScopedToolCached, clearScopedToolsForSession,
21
+ clearScopedToolsForSessionPaths, invalidatePrefetchCache,
22
+ } from './read-dedup.mjs';
23
+ import { isInvalidToolArgsMarker, formatInvalidToolArgsResult } from '../providers/openai-compat-stream.mjs';
24
+ import {
25
+ _stripMcpPrefix, _isReadTool, _isMutationTool, _isScopedCacheableTool,
26
+ _isShellTool, _intraTurnSig,
27
+ } from './loop/tool-classify.mjs';
28
+ import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
29
+ import { executeTool } from './loop/tool-exec.mjs';
30
+ import { crossTurnSignature, crossTurnDedupStub, isEditProgressTool } from './loop/completion-guards.mjs';
31
+ import { getToolKind, isEagerDispatchable, parseNativeToolSearchPayload } from './loop/tool-helpers.mjs';
32
+ import { restoreToolCallBodyForId } from './loop/stored-tool-args.mjs';
33
+
34
+ export async function processToolBatch(ctx) {
35
+ const {
36
+ calls, messages, tools, cwd, sessionId, sessionRef, signal, opts,
37
+ iterations, assistantTurnMsg, pending, epoch, startEagerRun,
38
+ crossTurnCalls, crossTurnCap, sessionAgent, steeringLadder,
39
+ pushToolResultMessage, throwIfAborted, repeatFailLimit,
40
+ } = ctx;
41
+ let dedupStubTotal = ctx.dedupStubTotal;
42
+ let editCount = ctx.editCount;
43
+ // Execute each tool and append results.
44
+ //
45
+ // Intra-turn duplicate suppression: when an LLM emits two tool_use
46
+ // blocks with identical (name, args) inside the SAME assistant turn,
47
+ // re-executing wastes tokens. Restricted to tools with
48
+ // `readOnlyHint:true` (= isEagerDispatchable) — bash/apply_patch
49
+ // may be intentional repeats with distinct side effects.
50
+ // Pre-pass identifies duplicates BEFORE startEagerRun so eager
51
+ // dispatch also skips them, not just the for-body.
52
+ const _duplicateCallIds = new Set();
53
+ const _dupFirstId = new Map();
54
+ {
55
+ const _firstIdBySig = new Map();
56
+ for (const c of calls) {
57
+ if (!c?.id) continue;
58
+ if (!isEagerDispatchable(c.name, tools)) {
59
+ _firstIdBySig.clear();
60
+ continue;
61
+ }
62
+ const sig = _intraTurnSig(c.name, c.arguments);
63
+ const first = _firstIdBySig.get(sig);
64
+ if (first === undefined) {
65
+ _firstIdBySig.set(sig, c.id);
66
+ } else {
67
+ _duplicateCallIds.add(c.id);
68
+ _dupFirstId.set(c.id, first);
69
+ }
70
+ }
71
+ }
72
+ // R15: per-turn scalar read-count Map. Lifetime = this turn's tool-call batch.
73
+ // Declared between the duplicate-detection block and the for-loop so it resets
74
+ // Per-batch buffer for the general `newMessages` tool-result channel.
75
+ // A tool MAY return a `{ __toolEnvelope, result, newMessages }` envelope;
76
+ // its newMessages (e.g. the Skill SKILL.md body as a role:'user' message)
77
+ // are collected here across EVERY call in this assistant turn and flushed
78
+ // ONCE, AFTER the batch's last tool_result is pushed — never interleaved
79
+ // between two tool results of the same multi-tool turn (which would put a
80
+ // user message between tool(A) and tool(B) and break provider pairing).
81
+ const _batchNewMessages = [];
82
+ for (let callIndex = 0; callIndex < calls.length; callIndex += 1) {
83
+ const call = calls[callIndex];
84
+ if (isBuiltinTool(call.name)) {
85
+ call.name = canonicalizeBuiltinToolName(call.name);
86
+ }
87
+ if (_duplicateCallIds.has(call.id)) {
88
+ const _firstId = _dupFirstId.get(call.id);
89
+ 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.`;
90
+ pushToolResultMessage({
91
+ role: 'tool',
92
+ content: _stub,
93
+ toolCallId: call.id,
94
+ });
95
+ continue;
96
+ }
97
+ // Cross-turn identical-call stub (Step 2): a SUCCESSFUL read-only
98
+ // (isEagerDispatchable) call whose (name,args) signature already ran
99
+ // in an EARLIER turn is not re-executed — its result is unchanged and
100
+ // already in context. Warn at the 2nd occurrence; append the "stuck"
101
+ // escalation tail once the session has emitted 5+ dedup stubs total.
102
+ // Never applies to write/bash/MCP/skill tools (not eager-dispatchable).
103
+ if (isEagerDispatchable(call.name, tools)) {
104
+ const _ctSig = crossTurnSignature(call.name, call.arguments);
105
+ const _prior = crossTurnCalls.get(_ctSig);
106
+ if (_prior && _prior.firstIteration < iterations) {
107
+ _prior.count += 1;
108
+ dedupStubTotal += 1;
109
+ const _stub = crossTurnDedupStub(call.name, _prior.firstIteration, dedupStubTotal >= 5);
110
+ pushToolResultMessage({
111
+ role: 'tool',
112
+ content: _stub,
113
+ toolCallId: call.id,
114
+ });
115
+ try {
116
+ appendAgentTrace({
117
+ sessionId,
118
+ iteration: iterations,
119
+ kind: 'steer',
120
+ payload: {
121
+ tag: 'cross_turn_dedup',
122
+ tool: call.name,
123
+ occurrence: _prior.count,
124
+ first_iteration: _prior.firstIteration,
125
+ dedup_stub_total: dedupStubTotal,
126
+ },
127
+ agent: sessionAgent || null,
128
+ });
129
+ } catch { /* best-effort */ }
130
+ continue;
131
+ }
132
+ }
133
+ // Cross-iteration repeat-failure guard. Distinct from the
134
+ // intra-turn dedup above (which spans ONE assistant turn and
135
+ // resets every turn): when the model re-issues an IDENTICAL
136
+ // (name,args) call that has already failed repeatFailLimit times
137
+ // in a row across iterations, stop re-executing — the result will
138
+ // not change, and each retry burns a full (often slow) LLM
139
+ // round-trip until the hard iteration cap. Steer it to change
140
+ // approach instead.
141
+ const _repeatFailSig = _intraTurnSig(call.name, call.arguments);
142
+ {
143
+ const _rfg = sessionRef?._repeatFailGuard;
144
+ if (_rfg && _rfg.sig === _repeatFailSig && _rfg.count >= repeatFailLimit) {
145
+ pushToolResultMessage({
146
+ role: 'tool',
147
+ 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.`,
148
+ toolCallId: call.id,
149
+ });
150
+ continue;
151
+ }
152
+ }
153
+ if (sessionId) markSessionToolCall(sessionId, call.name);
154
+ let result;
155
+ let toolStartedAt;
156
+ let toolEndedAt;
157
+ const toolKind = getToolKind(call.name);
158
+ // Cross-turn read dedup: if the path's stat tuple (mtime/size/ino/dev)
159
+ // is unchanged since a prior read in THIS session, return the cached
160
+ // body instead of executing. Both scalar and array/object-array path
161
+ // forms are cached — keyed by (abs, offset, limit, mode, n) per entry.
162
+ //
163
+ // Scoped-tool cache (grep/glob/list + graph lookups): same idea
164
+ // but keyed by (toolName, canonical args) without per-file stat.
165
+ // These tools scan many files so a single stat tuple cannot cover
166
+ // them. The scoped cache registers dependency roots and write-class
167
+ // tools evict entries whose root contains the touched path.
168
+ let _readCacheHit = null;
169
+ let _scopedCacheHit = null;
170
+ let _executeOk = false;
171
+ let _resultKind = 'normal';
172
+ // Invalid-args guard (native convergence): the provider parser tags
173
+ // a tool call whose arguments JSON could not be parsed with an
174
+ // invalid-args marker instead of throwing or swallowing to {}.
175
+ // Such a call must NOT execute — there are no usable arguments and
176
+ // permission/cache checks are meaningless. Skip straight to the
177
+ // error-feedback path so the model gets an is_error tool_result and
178
+ // re-issues the call with valid JSON in the same turn.
179
+ const _invalidArgs = isInvalidToolArgsMarker(call.arguments);
180
+ if (_invalidArgs) {
181
+ // no cache lookup for an un-parseable call
182
+ } else if (sessionId && _isReadTool(call.name)) {
183
+ _readCacheHit = tryReadCached({ sessionId, args: call.arguments, cwd });
184
+ } else if (sessionId && _isScopedCacheableTool(call.name)) {
185
+ _scopedCacheHit = tryScopedToolCached({ sessionId, toolName: _stripMcpPrefix(call.name), args: call.arguments, cwd });
186
+ }
187
+ try {
188
+ if (_invalidArgs) {
189
+ toolStartedAt = Date.now();
190
+ toolEndedAt = toolStartedAt;
191
+ result = formatInvalidToolArgsResult(call);
192
+ _resultKind = 'error';
193
+ _executeOk = false;
194
+ } else if (_readCacheHit !== null) {
195
+ toolStartedAt = Date.now();
196
+ toolEndedAt = toolStartedAt;
197
+ const _body = _readCacheHit.content;
198
+ // Return the cached body byte-for-byte instead of a
199
+ // human-readable cache marker. The marker made public
200
+ // agents treat a successful cached read as a
201
+ // meta instruction and repeat the same read loop.
202
+ result = _body;
203
+ _resultKind = 'cache-hit';
204
+ _executeOk = true;
205
+ } else if (_scopedCacheHit !== null) {
206
+ toolStartedAt = Date.now();
207
+ toolEndedAt = toolStartedAt;
208
+ const _body = _scopedCacheHit.content;
209
+ result = _body;
210
+ _resultKind = 'scoped-cache-hit';
211
+ _executeOk = true;
212
+ } else {
213
+ // Fallback for providers that don't stream tool calls early:
214
+ // execute a contiguous read-only run in parallel, but never
215
+ // cross a write/bash/MCP boundary that may change state.
216
+ if (isEagerDispatchable(call.name, tools)) {
217
+ startEagerRun(calls, callIndex, _duplicateCallIds);
218
+ }
219
+ let eager = pending.get(call.id);
220
+ if (eager !== undefined && eager.mutationEpoch < epoch.mutation) {
221
+ pending.delete(call.id);
222
+ eager = undefined;
223
+ }
224
+ if (eager !== undefined) {
225
+ toolStartedAt = eager.startedAt;
226
+ const settled = await eager.promise;
227
+ if (!settled.ok) throw settled.error;
228
+ result = settled.value;
229
+ toolEndedAt = eager.endedAt ?? Date.now();
230
+ const _eagerKind = classifyResultKind(result);
231
+ if (_eagerKind === 'error') {
232
+ _resultKind = 'error';
233
+ _executeOk = false;
234
+ } else {
235
+ _executeOk = true;
236
+ }
237
+ } else {
238
+ toolStartedAt = Date.now();
239
+ // Runtime pre-dispatch deny. Schema profiles may hide
240
+ // tools for routing efficiency, but this remains the
241
+ // control-plane boundary for any tool_use that still
242
+ // reaches the loop. preDispatchDenyForSession is the SHARED helper
243
+ // used by both the eager dispatch path (startEagerTool)
244
+ // and this serial path — keeps the agent-owned control-
245
+ // plane reject and no-tool role guards consistent across
246
+ // both paths.
247
+ const _denyMsg = preDispatchDenyForSession(sessionRef, call, toolKind);
248
+ if (_denyMsg !== null) {
249
+ result = _denyMsg;
250
+ toolEndedAt = Date.now();
251
+ _resultKind = 'error';
252
+ } else {
253
+ result = await executeTool(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: iterations });
254
+ toolEndedAt = Date.now();
255
+ // Boundary: tool-return string convention → structural kind.
256
+ // The only prefix check in this codebase; downstream layers
257
+ // operate on _resultKind.
258
+ if (classifyResultKind(result) === 'error') {
259
+ _resultKind = 'error';
260
+ _executeOk = false;
261
+ } else {
262
+ _executeOk = true;
263
+ }
264
+ // _resultKind stays 'normal' when tool returned a non-error string.
265
+ }
266
+ }
267
+ } // close: else branch of _readCacheHit check
268
+ }
269
+ catch (err) {
270
+ if (toolStartedAt === undefined) toolStartedAt = Date.now();
271
+ toolEndedAt = Date.now();
272
+ result = `Error: ${err instanceof Error ? err.message : String(err)}`;
273
+ _resultKind = 'error';
274
+ }
275
+ // CENTRAL ENVELOPE NORMALIZE (general newMessages channel).
276
+ // executeTool (serial + eager) and cache/error paths above all
277
+ // funnel into `result`. Split ONCE here: downstream post-processing
278
+ // (classifyResultKind / maybeOffloadToolResult / compressToolResult /
279
+ // traceAgentTool / cache writes / messages.push) sees ONLY the
280
+ // model-visible `result`; the `newMessages` ride a per-batch buffer
281
+ // flushed after the batch's last tool_result (never interleaved).
282
+ {
283
+ const _env = normalizeToolEnvelope(result);
284
+ result = _env.result;
285
+ if (_env.newMessages.length) _batchNewMessages.push(..._env.newMessages);
286
+ }
287
+ // Bounded-map cleanup: a scoped-cache outcome recorded for this call.id
288
+ // (via _scopedCacheOutcomeForCall) is only ever consumed/deleted on the
289
+ // success path below (_executeOk && _resultKind==='normal'). A failed or
290
+ // errored call would otherwise leak its entry in
291
+ // sessionRef._scopedCacheOutcomeByCallId forever — reclaim it here.
292
+ if (sessionRef?._scopedCacheOutcomeByCallId instanceof Map && call?.id && (!_executeOk || _resultKind === 'error')) {
293
+ sessionRef._scopedCacheOutcomeByCallId.delete(call.id);
294
+ }
295
+ // PostToolUseFailure: a tool that resolved to a failure (thrown-error
296
+ // path -> `Error:` string, or an is_error result classified as
297
+ // 'error') fires the optional session failure hook. Same shape as
298
+ // afterToolHook; `result` carries the error text. Best-effort — a
299
+ // hook error must never wedge the tool loop.
300
+ if (!_executeOk || _resultKind === 'error') {
301
+ const _afterToolFailureHook = typeof opts.afterToolFailureHook === 'function'
302
+ ? opts.afterToolFailureHook
303
+ : sessionRef?.afterToolFailureHook;
304
+ if (typeof _afterToolFailureHook === 'function') {
305
+ try {
306
+ await _afterToolFailureHook({
307
+ name: call.name,
308
+ args: call.arguments,
309
+ cwd,
310
+ sessionId,
311
+ toolCallId: call.id,
312
+ result: typeof result === 'string' ? result : String(result ?? ''),
313
+ });
314
+ } catch { /* best-effort: PostToolUseFailure hook must never break the loop */ }
315
+ }
316
+ }
317
+ // Update the cross-iteration repeat-failure guard with this call's
318
+ // outcome: bump the consecutive-failure count for an identical
319
+ // signature, or clear it the moment the same call succeeds.
320
+ if (sessionRef) {
321
+ const _failed = !_executeOk || _resultKind === 'error';
322
+ if (_failed) {
323
+ sessionRef._repeatFailGuard = (sessionRef._repeatFailGuard?.sig === _repeatFailSig)
324
+ ? { sig: _repeatFailSig, count: sessionRef._repeatFailGuard.count + 1 }
325
+ : { sig: _repeatFailSig, count: 1 };
326
+ } else if (sessionRef._repeatFailGuard?.sig === _repeatFailSig) {
327
+ sessionRef._repeatFailGuard = null;
328
+ }
329
+ }
330
+ // A failed executed call keeps its FULL argument body in history so the
331
+ // model can retry against the original (a large apply_patch `patch`
332
+ // would otherwise be hidden behind a
333
+ // `[mixdog compacted …]` placeholder). Restored IMMEDIATELY — not at end
334
+ // of loop — so an abort or post-processing throw after this point cannot
335
+ // leave a failed patch compacted. Cache-safe: assistantTurnMsg is not
336
+ // transmitted until the next provider.send. Early-continue paths (dedup /
337
+ // repeat-failure-guard) never reach here and stay compacted.
338
+ if ((!_executeOk || _resultKind === 'error') && call?.id) {
339
+ restoreToolCallBodyForId(assistantTurnMsg, calls, call.id);
340
+ }
341
+ // Cross-turn cache maintenance — gate on both _executeOk and _resultKind==='normal'.
342
+ // _executeOk=false catches permission-blocked / catch-path / partial-fail results.
343
+ // _resultKind==='normal' ensures cache-hit refs are never re-stored (structural,
344
+ // no prefix sniffing).
345
+ // NOTE: setReadCached / setScopedToolCached are deferred below (after
346
+ // compressToolResult) so the cache holds the same content as conversation
347
+ // history. Cache-hit refs point to a tool_use_id whose message body matches
348
+ // exactly what's stored — no phantom full body.
349
+ if (sessionId && _executeOk && _resultKind === 'normal') {
350
+ const _toolBare = _stripMcpPrefix(call.name);
351
+ if (_readCacheHit === null && _isReadTool(call.name)) {
352
+ // Post-patch advisory: handle BOTH scalar and array forms
353
+ // of args.path. The array form (path:[a,b,c] or
354
+ // path:[{path:a},{path:b}]) was a coverage gap in R1 —
355
+ // an LLM that patches X then reads [X,Y] should still see
356
+ // the advisory for X.
357
+ const _argsPath = call.arguments?.path;
358
+ const _pathList = [];
359
+ if (typeof _argsPath === 'string') {
360
+ _pathList.push(_argsPath);
361
+ } else if (typeof call.arguments?.file_path === 'string') {
362
+ _pathList.push(call.arguments.file_path);
363
+ } else if (Array.isArray(_argsPath)) {
364
+ for (const _item of _argsPath) {
365
+ if (typeof _item === 'string') _pathList.push(_item);
366
+ else if (_item && typeof _item === 'object') {
367
+ const _itemPath = typeof _item.path === 'string' ? _item.path : _item.file_path;
368
+ if (typeof _itemPath === 'string') _pathList.push(_itemPath);
369
+ }
370
+ }
371
+ }
372
+ const _marks = [];
373
+ for (const _p of _pathList) {
374
+ const _m = consumePostEditMark({ sessionId, path: _p, cwd });
375
+ if (_m) _marks.push({ path: _p, mark: _m });
376
+ }
377
+ } else if (_toolBare === 'apply_patch') {
378
+ // apply_patch's args are a unified-diff text in `patch`
379
+ // (resolved against `base_path` or cwd). Parse the diff
380
+ // headers (`--- a/path` / `+++ b/path`) to extract the
381
+ // touched paths and invalidate / mark each one. Falls
382
+ // back to a full session clear only when no paths could
383
+ // be parsed (malformed diff or unknown format).
384
+ const _argsBase = call.arguments?.base_path;
385
+ const _patchBase = (typeof _argsBase === 'string' && _argsBase.length > 0)
386
+ ? (isAbsolute(_argsBase) ? _argsBase : resolvePath(cwd || process.cwd(), _argsBase))
387
+ : (cwd || process.cwd());
388
+ const _touched = extractTouchedPathsFromPatch(call.arguments?.patch);
389
+ if (_touched.length > 0) {
390
+ for (const _p of _touched) {
391
+ invalidatePathForSession(sessionId, _p, _patchBase);
392
+ markPostEdit({ sessionId, path: _p, cwd: _patchBase, toolName: 'apply_patch' });
393
+ // R20: cross-dispatch prefetch cache invalidation.
394
+ invalidatePrefetchCache(_p, _patchBase);
395
+ }
396
+ } else {
397
+ clearReadDedupSession(sessionId);
398
+ // R20: path unknown — can't target; no-op on prefetch cache
399
+ // (stat-validation at lookup time will naturally reject stale entries).
400
+ }
401
+ // Targeted scoped-cache invalidation: only evict entries whose
402
+ // dep paths intersect the touched set. Full wipe is the fallback
403
+ // when no paths were extracted (D).
404
+ if (_touched.length > 0) {
405
+ clearScopedToolsForSessionPaths(sessionId, _touched, _patchBase);
406
+ } else {
407
+ clearScopedToolsForSession(sessionId);
408
+ }
409
+ }
410
+ } // end _executeOk+_resultKind gate (scoped tool cache set)
411
+ // E: mutation tools (apply_patch) must invalidate caches
412
+ // even on returned-error/partial-fail — the file state is unknown after
413
+ // an error exit, and some tools report failure as an Error: result string
414
+ // rather than throwing.
415
+ // This block runs unconditionally (not gated on _executeOk or _resultKind).
416
+ if (sessionId && (!_executeOk || _resultKind === 'error') && _stripMcpPrefix(call.name) === 'apply_patch') {
417
+ clearReadDedupSession(sessionId);
418
+ }
419
+ if (_isMutationTool(call.name)) {
420
+ epoch.mutation += 1;
421
+ }
422
+ // Bash always clears scoped cache UNCONDITIONALLY — a mutating bash
423
+ // that throws or fails partway can still leave stale find_symbol / grep entries.
424
+ // Must not be gated on _executeOk or _resultKind.
425
+ if (sessionId && _isShellTool(call.name)) {
426
+ clearScopedToolsForSession(sessionId);
427
+ }
428
+ // R17 compression pipeline — correct ordering (compress → cache → push):
429
+ // 1. compressToolResult: lossless ANSI/dedup/separator passes.
430
+ // 2. setReadCached / setScopedToolCached: cache stores the SAME result that
431
+ // goes into conversation history. Cache-hit refs point to the tool_use_id
432
+ // whose message body matches — no phantom full body.
433
+ // 3. offload → hint → message push.
434
+ // Offload FIRST — before compress. Large RAW output goes to a disk sidecar
435
+ // + ~2K preview before any in-place shrink (lossless compress) can reduce
436
+ // it below the offload threshold and pre-empt the sidecar. When offload
437
+ // fires it replaces `result` with a short preview stub (<2K) referencing
438
+ // the on-disk path; the later compress is a no-op on that stub. compress
439
+ // then only touches output that stayed inline (<= threshold).
440
+ // Per-tool post-processing backstop. The executeTool try/catch
441
+ // above terminates BEFORE offload/compress/trim/hint/cache writes/
442
+ // trace/messages.push, so a maybeOffloadToolResult rejection (or
443
+ // any downstream throw) would otherwise leave the assistant
444
+ // tool_use message with no matching tool result. Wrap the whole
445
+ // post-processing window through messages.push() in a catch; on
446
+ // failure push a synthetic Error: tool result for this call.id
447
+ // and skip the cache writes for it.
448
+ let _postProcessOk = true;
449
+ let _nativeToolSearch = null;
450
+ try {
451
+ // Offload thresholds are keyed by BARE tool name
452
+ // (INLINE_THRESHOLD_BY_TOOL: grep=20k, bash=30k, read=Infinity, ...),
453
+ // so strip the MCP prefix exactly as the cache write below does.
454
+ // Otherwise an mcp__..__grep name misses its 20k grep cap and
455
+ // silently falls back to the 50k default — per-tool limits ignored.
456
+ const _toolBare = _stripMcpPrefix(call.name);
457
+ _nativeToolSearch = parseNativeToolSearchPayload(call.name, result);
458
+ if (_nativeToolSearch?.summary) result = _nativeToolSearch.summary;
459
+ result = await maybeOffloadToolResult(sessionId, call.id, _toolBare, result);
460
+ result = compressToolResult(call.name, call.arguments, result, { sessionId, toolKind });
461
+ traceAgentTool({
462
+ sessionId,
463
+ iteration: iterations,
464
+ toolName: call.name,
465
+ toolKind,
466
+ toolMs: toolEndedAt - toolStartedAt,
467
+ toolArgs: call.arguments,
468
+ agent: sessionRef?.agent || null,
469
+ model: sessionRef?.model || null,
470
+ resultKind: _resultKind,
471
+ resultText: result,
472
+ cwd,
473
+ });
474
+ // Cache stores run AFTER compress+trim+offload+hint AND after all other
475
+ // post-processing (trace) so stored content == history content. Placing
476
+ // the cache writes immediately before messages.push ensures ANY throw
477
+ // earlier in post-processing skips the cache entirely — no stale or
478
+ // partial result is ever cached. Cache-hit refs pointing to an offloaded
479
+ // tool_use will show the offload stub; LLM can still recover the full
480
+ // body via the disk path in that stub.
481
+ if (sessionId && _executeOk && _resultKind === 'normal') {
482
+ if (_scopedCacheHit === null && _isScopedCacheableTool(call.name)) {
483
+ const _outcomeMap = sessionRef?._scopedCacheOutcomeByCallId instanceof Map
484
+ ? sessionRef._scopedCacheOutcomeByCallId : null;
485
+ const _outcome = _outcomeMap?.get(call.id);
486
+ setScopedToolCached({
487
+ sessionId,
488
+ toolName: _toolBare,
489
+ args: call.arguments,
490
+ cwd,
491
+ content: result,
492
+ toolUseId: call.id,
493
+ complete: _outcome ? _outcome.complete : true,
494
+ });
495
+ _outcomeMap?.delete(call.id);
496
+ }
497
+ if (_readCacheHit === null && _isReadTool(call.name)) {
498
+ // Pass tool_use id so future cache-hits can reference the body's location in history.
499
+ setReadCached({ sessionId, args: call.arguments, cwd, content: result, toolUseId: call.id });
500
+ }
501
+ }
502
+ // UI-only: apply_patch stashes the standard unified diff keyed
503
+ // by tool_use id (never in the model-visible result). Attach it
504
+ // here as a side-channel field so the TUI's expanded (ctrl+o)
505
+ // raw view renders a colored +/- diff. The provider lowering
506
+ // (anthropic/openai/etc.) never reads `uiDiff`, so the model
507
+ // sees only `content` (the compact summary) — no token bloat.
508
+ const _applyPatchUiDiff = _stripMcpPrefix(call.name) === 'apply_patch'
509
+ ? takeApplyPatchUiDiff(call.id)
510
+ : null;
511
+ pushToolResultMessage({
512
+ role: 'tool',
513
+ content: result,
514
+ toolCallId: call.id,
515
+ toolKind: _resultKind,
516
+ ...(_nativeToolSearch ? { nativeToolSearch: _nativeToolSearch } : {}),
517
+ ...(_applyPatchUiDiff ? { uiDiff: _applyPatchUiDiff } : {}),
518
+ });
519
+ // Completion-first bookkeeping (Steps 1 & 2). Only successful
520
+ // executions count. Edit/progress = any executed tool whose def
521
+ // lacks readOnlyHint (apply_patch/bash/MCP-write/skill/...).
522
+ // Read-only successful calls seed the cross-turn dedup map.
523
+ if (_executeOk) {
524
+ const _isEager = isEagerDispatchable(call.name, tools);
525
+ if (_isEager) {
526
+ const _ctSig = crossTurnSignature(call.name, call.arguments);
527
+ if (!crossTurnCalls.has(_ctSig)) {
528
+ crossTurnCalls.set(_ctSig, { count: 1, firstIteration: iterations });
529
+ if (crossTurnCalls.size > crossTurnCap) {
530
+ const _oldest = crossTurnCalls.keys().next().value;
531
+ crossTurnCalls.delete(_oldest);
532
+ }
533
+ }
534
+ } else {
535
+ // A successful mutating (non-eager) tool invalidates the
536
+ // cross-turn dedup map wholesale: any prior read/grep may
537
+ // now return different content, so a post-edit
538
+ // verification read must NOT be stubbed as "unchanged".
539
+ if (isEditProgressTool(call.name, false)) {
540
+ crossTurnCalls.clear();
541
+ editCount += 1;
542
+ }
543
+ }
544
+ }
545
+ } catch (postErr) {
546
+ _postProcessOk = false;
547
+ // Reviewer fix: the exec itself succeeded — if it was a
548
+ // mutating edit-progress tool, the file changes are real even
549
+ // though post-processing failed, so the cross-turn dedup map
550
+ // must still be invalidated (otherwise a later verification
551
+ // read could be stubbed as "unchanged" against stale sigs).
552
+ if (_executeOk && !isEagerDispatchable(call.name, tools) && isEditProgressTool(call.name, false)) {
553
+ crossTurnCalls.clear();
554
+ editCount += 1;
555
+ }
556
+ // Post-processing failed AFTER a successful exec: the result is
557
+ // replaced with an error below, so preserve this call's full body
558
+ // too for a clean retry (mirrors the failed-exec path above).
559
+ if (call?.id) restoreToolCallBodyForId(assistantTurnMsg, calls, call.id);
560
+ const _postMsg = `Error: tool result post-processing failed for "${call.name}": ${postErr instanceof Error ? postErr.message : String(postErr)}`;
561
+ traceAgentToolFailure({
562
+ sessionId,
563
+ iteration: iterations,
564
+ toolName: call.name,
565
+ toolKind,
566
+ toolMs: toolEndedAt && toolStartedAt ? toolEndedAt - toolStartedAt : null,
567
+ toolArgs: call.arguments,
568
+ agent: sessionRef?.agent || null,
569
+ model: sessionRef?.model || null,
570
+ cwd,
571
+ resultText: _postMsg,
572
+ resultKind: 'error',
573
+ });
574
+ // Always emit a matching tool result so the assistant
575
+ // tool_use isn't orphaned. Cache writes are placed at the
576
+ // end of the try block (immediately before messages.push),
577
+ // so ANY throw in post-processing reaches this catch before
578
+ // the cache is written — stale/partial results are never
579
+ // cached. The next read on the same path/scope re-executes
580
+ // naturally.
581
+ pushToolResultMessage({
582
+ role: 'tool',
583
+ content: _postMsg,
584
+ toolCallId: call.id,
585
+ toolKind: 'error',
586
+ });
587
+ }
588
+ // Soft-cancel after each tool: if close landed during execution,
589
+ // discard the rest of the batch and skip the next provider.send.
590
+ throwIfAborted();
591
+ }
592
+ // Flush the per-batch newMessages channel. All tool_results for this
593
+ // assistant turn are now pushed; appending the injected role:'user'
594
+ // messages here (AFTER the last tool_result, BEFORE the next provider
595
+ // send) keeps provider pairing valid — no user message is interleaved
596
+ // between tool(A) and tool(B). pre-send repairTranscriptBeforeProviderSend
597
+ // normalizes any residual ordering. The injected messages carry their
598
+ // own meta flag (e.g. meta:'skill') so compaction's latest-human-prompt
599
+ // selection does not mistake them for the user's request.
600
+ for (const _nm of _batchNewMessages) {
601
+ if (!_nm || _nm.role !== 'user' || typeof _nm.content !== 'string' || !_nm.content) continue;
602
+ messages.push({ role: 'user', content: _nm.content, ...(_nm.meta ? { meta: _nm.meta } : {}) });
603
+ }
604
+ // PostToolBatch: the full parallel batch of tool calls for this
605
+ // assistant turn has resolved and all tool_results are pushed. Fire the
606
+ // optional session hook before the next model call. No matcher event.
607
+ // Block support: if the hook returns blocked===true, inject its reason
608
+ // as a system-note user message for the next send (natural mechanism —
609
+ // same channel the newMessages flush just used). Best-effort otherwise.
610
+ {
611
+ const _afterToolBatchHook = typeof opts.afterToolBatchHook === 'function'
612
+ ? opts.afterToolBatchHook
613
+ : sessionRef?.afterToolBatchHook;
614
+ if (typeof _afterToolBatchHook === 'function' && calls.length > 0) {
615
+ try {
616
+ const _batchDecision = await _afterToolBatchHook({
617
+ sessionId,
618
+ cwd,
619
+ toolCount: calls.length,
620
+ });
621
+ if (_batchDecision?.blocked === true) {
622
+ const _reason = String(_batchDecision.reason || 'PostToolBatch hook blocked continuation').trim();
623
+ if (_reason) {
624
+ messages.push({ role: 'user', content: `<system-reminder>\n${_reason}\n</system-reminder>`, meta: 'hook' });
625
+ }
626
+ }
627
+ } catch { /* best-effort: PostToolBatch hook must never break the loop */ }
628
+ }
629
+ }
630
+ // Completion-first steering hints (missed-parallelism / all-read-only /
631
+ // serial-rewording). At most ONE hint per turn. The ladder controller
632
+ // owns the cumulative counters and streaks.
633
+ steeringLadder.emitPostBatchSteering(calls, false);
634
+ // Mid-turn steering is drained at the next loop's pre-send point,
635
+ // AFTER any auto-compact pass. Draining here would put the steering
636
+ // user turn after the fresh tool results before compaction runs; then
637
+ // semantic/recall compaction would treat those fresh tool results as
638
+ // prior history before the model sees them.
639
+ // About to re-send with tool results — transition back to connecting for the next turn.
640
+ if (sessionId) updateSessionStage(sessionId, 'connecting');
641
+ return { dedupStubTotal, editCount };
642
+ }