mixdog 0.9.2 → 0.9.3

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 (154) hide show
  1. package/package.json +2 -1
  2. package/scripts/anthropic-maxtokens-test.mjs +119 -0
  3. package/scripts/build-tui.mjs +13 -1
  4. package/scripts/explore-bench.mjs +124 -0
  5. package/scripts/hook-bus-test.mjs +191 -0
  6. package/scripts/path-suffix-test.mjs +57 -0
  7. package/scripts/recall-bench.mjs +207 -0
  8. package/scripts/tool-smoke.mjs +7 -4
  9. package/src/agents/debugger/AGENT.md +2 -2
  10. package/src/agents/heavy-worker/AGENT.md +20 -11
  11. package/src/agents/reviewer/AGENT.md +2 -2
  12. package/src/agents/worker/AGENT.md +17 -11
  13. package/src/mixdog-session-runtime.mjs +424 -1812
  14. package/src/repl.mjs +5 -5
  15. package/src/rules/agent/30-explorer.md +8 -11
  16. package/src/rules/lead/lead-tool.md +9 -5
  17. package/src/rules/shared/01-tool.md +11 -5
  18. package/src/runtime/agent/orchestrator/context/collect.mjs +51 -0
  19. package/src/runtime/agent/orchestrator/mcp/client.mjs +6 -2
  20. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +1 -1
  21. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens.mjs +93 -0
  22. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +22 -68
  23. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +46 -7
  24. package/src/runtime/agent/orchestrator/providers/api-usage.mjs +1 -13
  25. package/src/runtime/agent/orchestrator/providers/gemini.mjs +1 -1
  26. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +1 -1
  27. package/src/runtime/agent/orchestrator/providers/lib/usage-primitives.mjs +32 -0
  28. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +54 -20
  29. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +19 -12
  30. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +7 -5
  31. package/src/runtime/agent/orchestrator/providers/openai-oauth-ws.mjs +33 -23
  32. package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +31 -14
  33. package/src/runtime/agent/orchestrator/providers/opencode-go-usage.mjs +38 -12
  34. package/src/runtime/agent/orchestrator/providers/retry-classifier.mjs +7 -8
  35. package/src/runtime/agent/orchestrator/session/loop/compact-debug.mjs +28 -0
  36. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +262 -0
  37. package/src/runtime/agent/orchestrator/session/loop/context-overflow.mjs +38 -0
  38. package/src/runtime/agent/orchestrator/session/loop/env.mjs +14 -0
  39. package/src/runtime/agent/orchestrator/session/loop/hidden-agents.mjs +21 -0
  40. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +49 -0
  41. package/src/runtime/agent/orchestrator/session/loop/steering.mjs +63 -0
  42. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +100 -0
  43. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +52 -0
  44. package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +218 -0
  45. package/src/runtime/agent/orchestrator/session/loop/transcript-repair.mjs +101 -0
  46. package/src/runtime/agent/orchestrator/session/loop/usage.mjs +35 -0
  47. package/src/runtime/agent/orchestrator/session/loop.mjs +169 -918
  48. package/src/runtime/agent/orchestrator/session/manager/context-meta.mjs +227 -0
  49. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +235 -0
  50. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +137 -0
  51. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +155 -0
  52. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +303 -0
  53. package/src/runtime/agent/orchestrator/session/manager.mjs +65 -1032
  54. package/src/runtime/agent/orchestrator/stall-policy.mjs +3 -3
  55. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +2 -2
  56. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.mjs +241 -0
  57. package/src/runtime/agent/orchestrator/tools/builtin/external-tool-adapters.test.mjs +162 -0
  58. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +1 -1
  59. package/src/runtime/agent/orchestrator/tools/builtin/path-diagnostics.mjs +42 -2
  60. package/src/runtime/agent/orchestrator/tools/builtin/read-formatting.mjs +1 -1
  61. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +11 -4
  62. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +5 -0
  63. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +50 -39
  64. package/src/runtime/agent/orchestrator/tools/builtin.mjs +11 -0
  65. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +303 -0
  66. package/src/runtime/agent/orchestrator/tools/code-graph/constants.mjs +43 -0
  67. package/src/runtime/agent/orchestrator/tools/code-graph/disk-cache.mjs +382 -0
  68. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +497 -0
  69. package/src/runtime/agent/orchestrator/tools/code-graph/graph-binary.mjs +295 -0
  70. package/src/runtime/agent/orchestrator/tools/code-graph/graph-model.mjs +158 -0
  71. package/src/runtime/agent/orchestrator/tools/code-graph/lang-predicates.mjs +128 -0
  72. package/src/runtime/agent/orchestrator/tools/code-graph/memory-cache.mjs +66 -0
  73. package/src/runtime/agent/orchestrator/tools/code-graph/project-root.mjs +44 -0
  74. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +1192 -0
  75. package/src/runtime/agent/orchestrator/tools/code-graph/source-access.mjs +81 -0
  76. package/src/runtime/agent/orchestrator/tools/code-graph/span.mjs +19 -0
  77. package/src/runtime/agent/orchestrator/tools/code-graph/symbol-index.mjs +280 -0
  78. package/src/runtime/agent/orchestrator/tools/code-graph/text-mask.mjs +347 -0
  79. package/src/runtime/agent/orchestrator/tools/code-graph.mjs +36 -4277
  80. package/src/runtime/agent/orchestrator/tools/patch.mjs +3 -3
  81. package/src/runtime/agent/orchestrator/tools/progress-message.mjs +1 -2
  82. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +1 -2
  83. package/src/runtime/agent/orchestrator/tools/shell-snapshot.mjs +2 -4
  84. package/src/runtime/channels/index.mjs +14 -233
  85. package/src/runtime/channels/lib/boot-profile.mjs +23 -0
  86. package/src/runtime/channels/lib/crash-log.mjs +106 -0
  87. package/src/runtime/channels/lib/index-drop-trace.mjs +72 -0
  88. package/src/runtime/channels/lib/output-forwarder.mjs +8 -0
  89. package/src/runtime/channels/lib/telegram-format.mjs +19 -22
  90. package/src/runtime/channels/lib/whisper-language.mjs +42 -0
  91. package/src/runtime/memory/index.mjs +314 -359
  92. package/src/runtime/memory/lib/core-memory-store.mjs +351 -1
  93. package/src/runtime/memory/lib/cycle-signatures.mjs +34 -0
  94. package/src/runtime/memory/lib/http-wire.mjs +57 -0
  95. package/src/runtime/memory/lib/memory-cycle2.mjs +56 -3
  96. package/src/runtime/memory/lib/memory-recall-scope-filter.mjs +24 -0
  97. package/src/runtime/memory/lib/memory-retrievers.mjs +8 -0
  98. package/src/runtime/memory/lib/memory.mjs +20 -0
  99. package/src/runtime/memory/lib/promotion-fingerprint.mjs +50 -0
  100. package/src/runtime/memory/lib/recall-format.mjs +183 -0
  101. package/src/runtime/memory/tool-defs.mjs +4 -4
  102. package/src/runtime/shared/abort-controller.mjs +1 -1
  103. package/src/runtime/shared/background-tasks.mjs +2 -3
  104. package/src/runtime/shared/buffered-appender.mjs +149 -0
  105. package/src/runtime/shared/task-notification-envelope.mjs +98 -0
  106. package/src/runtime/shared/task-notification-envelope.test.mjs +107 -0
  107. package/src/runtime/shared/tool-execution-contract.mjs +2 -2
  108. package/src/runtime/shared/transcript-writer.mjs +29 -2
  109. package/src/session-runtime/config-helpers.mjs +209 -0
  110. package/src/session-runtime/effort.mjs +128 -0
  111. package/src/session-runtime/fs-utils.mjs +10 -0
  112. package/src/session-runtime/model-capabilities.mjs +130 -0
  113. package/src/session-runtime/output-styles.mjs +124 -0
  114. package/src/session-runtime/plugin-mcp.mjs +114 -0
  115. package/src/session-runtime/session-text.mjs +100 -0
  116. package/src/session-runtime/statusline-route.mjs +35 -0
  117. package/src/session-runtime/tool-catalog.mjs +720 -0
  118. package/src/session-runtime/workflow.mjs +358 -0
  119. package/src/standalone/agent-tool.mjs +49 -10
  120. package/src/standalone/channel-worker.mjs +3 -2
  121. package/src/standalone/explore-tool.mjs +10 -3
  122. package/src/standalone/hook-bus.mjs +165 -8
  123. package/src/standalone/opencode-go-login.mjs +121 -0
  124. package/src/standalone/provider-admin.mjs +14 -3
  125. package/src/tui/App.jsx +884 -143
  126. package/src/tui/components/PromptInput.jsx +272 -15
  127. package/src/tui/components/ToolExecution.jsx +20 -10
  128. package/src/tui/components/tool-output-format.mjs +2 -2
  129. package/src/tui/dist/index.mjs +1771 -1947
  130. package/src/tui/engine/agent-envelope.mjs +296 -0
  131. package/src/tui/engine/boot-profile.mjs +21 -0
  132. package/src/tui/engine/labels.mjs +67 -0
  133. package/src/tui/engine/notice-text.mjs +112 -0
  134. package/src/tui/engine/queue-helpers.mjs +161 -0
  135. package/src/tui/engine/session-stats.mjs +46 -0
  136. package/src/tui/engine/tool-call-fields.mjs +23 -0
  137. package/src/tui/engine/tool-result-text.mjs +126 -0
  138. package/src/tui/engine.mjs +311 -851
  139. package/src/tui/input-editing.mjs +58 -8
  140. package/src/tui/input-editing.selection.test.mjs +75 -0
  141. package/src/tui/keyboard-protocol.mjs +2 -2
  142. package/src/tui/lib/voice-recorder.mjs +35 -19
  143. package/src/tui/markdown/format-token.mjs +7 -8
  144. package/src/tui/markdown/format-token.test.mjs +3 -3
  145. package/src/tui/paste-attachments.mjs +38 -0
  146. package/src/tui/paste-fix.test.mjs +119 -0
  147. package/src/tui/themes/base.mjs +2 -2
  148. package/src/tui/themes/kanagawa.mjs +4 -4
  149. package/src/tui/themes/teal.mjs +4 -5
  150. package/src/tui/themes/utils.mjs +1 -1
  151. package/src/ui/statusline.mjs +49 -0
  152. package/src/workflows/default/WORKFLOW.md +16 -9
  153. package/src/workflows/sequential/WORKFLOW.md +16 -11
  154. package/src/workflows/solo/WORKFLOW.md +5 -1
@@ -1,33 +1,21 @@
1
1
  import { classifyResultKind } from './result-classification.mjs';
2
2
  import { executeMcpTool, isMcpTool, mcpToolHasField } from '../mcp/client.mjs';
3
- import { canonicalizeBuiltinToolName, executeBuiltinTool, formatUnknownBuiltinToolMessage, isBuiltinTool } from '../tools/builtin.mjs';
3
+ import { canonicalizeBuiltinToolName, executeBuiltinTool, formatUnknownBuiltinToolMessage, isBuiltinTool, isExternalAdapterTool } from '../tools/builtin.mjs';
4
4
  import { executeBashSessionTool } from '../tools/bash-session.mjs';
5
5
  import { executePatchTool, takeApplyPatchUiDiff } from '../tools/patch.mjs';
6
6
  import { executeInternalTool, isInternalTool } from '../internal-tools.mjs';
7
- import { collectSkillsCached, loadSkillResource, buildSkillToolEnvelope } from '../context/collect.mjs';
8
7
  import { normalizeToolEnvelope, makeToolEnvelope } from './tool-envelope.mjs';
9
8
  import { traceAgentLoop, traceAgentTool, traceAgentToolFailure, traceAgentCompact, estimateProviderPayloadBytes, messagePrefixHash, appendAgentTrace } from '../agent-trace.mjs';
10
9
  import { resolveSessionMaxLoopIterations } from '../agent-runtime/agent-loop-policy.mjs';
11
10
  import { isAgentOwner } from '../agent-owner.mjs';
12
11
  import { markSessionToolCall, updateSessionStage, SessionClosedError, getSessionAbortSignal, enqueuePendingMessage, bumpUsageMetricsEpoch } from './manager.mjs';
13
- import {
14
- estimateMessagesTokens,
15
- estimateRequestReserveTokens,
16
- sanitizeToolPairs,
17
- resolveCompactBufferRatio,
18
- resolveCompactBufferTokens,
19
- } from './context-utils.mjs';
20
12
  import {
21
13
  recallFastTrackCompactMessages,
22
14
  pruneToolOutputs,
23
15
  pruneToolOutputsUnanchored,
24
16
  semanticCompactMessages,
25
17
  effectiveBudget as compactEffectiveBudget,
26
- compactTypeIsRecallFastTrack,
27
- compactTypeIsSemantic,
28
- normalizeCompactType,
29
18
  DEFAULT_COMPACT_TYPE,
30
- DEFAULT_COMPACTION_KEEP_TOKENS,
31
19
  drainSessionCycle1,
32
20
  countRawPendingRows,
33
21
  } from './compact.mjs';
@@ -40,26 +28,15 @@ import { modelVisibleToolCompletionMessage } from '../../../shared/tool-executio
40
28
  import { createHash } from 'crypto';
41
29
  import { isInvalidToolArgsMarker, formatInvalidToolArgsResult } from '../providers/openai-compat-stream.mjs';
42
30
 
43
- // Tool-name classification for cross-turn read dedup.
44
- // Strips the MCP prefix so direct calls and MCP-wrapped calls share the
45
- // same cache.
46
- function _stripMcpPrefix(name) {
47
- return typeof name === 'string' && name.startsWith(MCP_TOOL_PREFIX)
48
- ? name.slice(MCP_TOOL_PREFIX.length) : name;
49
- }
50
- function _isReadTool(name) {
51
- return _stripMcpPrefix(name) === 'read';
52
- }
53
- function _isMutationTool(name) {
54
- const n = _stripMcpPrefix(name);
55
- return n === 'apply_patch';
56
- }
57
- const SCOPED_CACHEABLE_TOOLS = new Set([
58
- 'code_graph',
59
- 'grep',
60
- 'list',
61
- 'glob',
62
- ]);
31
+ import {
32
+ _stripMcpPrefix,
33
+ _isReadTool,
34
+ _isMutationTool,
35
+ _isScopedCacheableTool,
36
+ _isShellTool,
37
+ _intraTurnSig,
38
+ } from './loop/tool-classify.mjs';
39
+ import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
63
40
  let codeGraphRuntimePromise = null;
64
41
  async function executeCodeGraphToolLazy(name, args, cwd, signal = null, options = {}) {
65
42
  codeGraphRuntimePromise ??= import('../tools/code-graph.mjs');
@@ -67,218 +44,72 @@ async function executeCodeGraphToolLazy(name, args, cwd, signal = null, options
67
44
  if (typeof mod.executeCodeGraphTool !== 'function') throw new Error('code_graph runtime is not available');
68
45
  return mod.executeCodeGraphTool(name, args, cwd, signal, options);
69
46
  }
70
- function _isScopedCacheableTool(name) {
71
- const n = _stripMcpPrefix(name);
72
- return SCOPED_CACHEABLE_TOOLS.has(n);
73
- }
74
- function _isShellTool(name) {
75
- const n = _stripMcpPrefix(name);
76
- return n === 'shell' || n === 'bash_session';
77
- }
78
47
 
79
48
  // classifyResultKind is imported from result-classification.mjs at the top of
80
49
  // this file; import it from there directly rather than via this module.
81
-
82
- // Canonical signature for intra-turn duplicate detection. Sorting keys
83
- // produces a stable hash regardless of arg-object key order. Anything
84
- // non-serializable falls back to String(args) — still deterministic for
85
- // the model's typical structured-arg shape.
86
- function _canonicalArgs(args) {
87
- if (args == null || typeof args !== 'object') {
88
- try { return JSON.stringify(args); } catch { return String(args); }
89
- }
90
- try {
91
- const keys = Object.keys(args).sort();
92
- const sorted = {};
93
- for (const k of keys) sorted[k] = args[k];
94
- return JSON.stringify(sorted);
95
- } catch { return String(args); }
96
- }
97
- function _intraTurnSig(name, args) {
98
- return createHash('sha256').update(`${name}:${_canonicalArgs(args)}`).digest('hex').slice(0, 16);
99
- }
100
-
101
- // Shared pre-dispatch deny — single source of truth for the remaining
102
- // control-plane / role scoping rejects. Called by BOTH the eager dispatch
103
- // path (startEagerTool) and the serial dispatch path (executeTool body).
104
- // Returns null when the call is allowed to proceed; otherwise returns the
105
- // Error string the serial path would emit. The eager caller ignores the
106
- // message body and just treats non-null as "do not start eager".
107
- //
108
- // This is NOT a permission gate — runtime permission enforcement was removed
109
- // (every tool call is trusted). What remains is architectural scoping:
110
- // agent workers are sandboxed to code/research tools. They must never reach
111
- // owner/host control surfaces: session management, the ENTIRE channels module
112
- // (Discord messaging, schedules, webhook/config, channel-bridge toggle,
113
- // command injection), or host input injection. Explicit name list (no imports)
114
- // keeps this hot-path gate dependency-free; add new owner/channel tools here.
115
- const WORKER_DENIED_TOOLS = new Set([
116
- // session control-plane — unified into the single `agent` tool
117
- // (type=spawn|send|close|list). Denying the one name blocks all worker
118
- // session control.
119
- 'agent',
120
- // channels module (owner/Discord-facing)
121
- 'reply', 'react', 'edit_message', 'download_attachment', 'fetch',
122
- 'schedule_status', 'trigger_schedule', 'schedule_control',
123
- 'activate_channel_bridge', 'reload_config', 'inject_command',
124
- // host input injection
125
- 'inject_input',
126
- ]);
127
- function _preDispatchDeny(call, toolKind, sessionRef) {
128
- const name = call?.name;
129
- if (typeof name !== 'string' || !name) return null;
130
- const _agentOwned = sessionRef?.scope?.startsWith?.('agent:')
131
- || isAgentOwner(sessionRef);
132
- const _controlPlaneTool = WORKER_DENIED_TOOLS.has(name);
133
- if (_agentOwned && _controlPlaneTool) {
134
- return `Error: control-plane tool "${name}" is Lead-only and not available to agent workers.`;
135
- }
136
- const noToolAgent = sessionRef?.agent === 'cycle1-agent' || sessionRef?.agent === 'cycle2-agent';
137
- if (noToolAgent) {
138
- return `Error: tool "${name}" is not available in agent "${sessionRef.agent}". Re-emit the answer as pipe-separated text per the agent's output format (first character a digit, NO tool_use blocks, NO JSON, NO prose, NO apology).`;
139
- }
140
- return null;
141
- }
142
- /** Exported for smoke tests — same runtime deny as the agent loop. */
143
- export function preDispatchDenyForSession(sessionRef, call, toolKind = 'builtin') {
144
- return _preDispatchDeny(call, toolKind, sessionRef);
145
- }
146
50
  import { compressToolResult, recordToolBatch } from '../tools/result-compression.mjs';
147
51
 
148
52
 
149
- import { readFileSync as _readFileSync } from 'fs';
150
- import { fileURLToPath } from 'url';
151
- import { dirname, resolve as resolvePath, isAbsolute } from 'path';
152
- const MCP_TOOL_PREFIX = 'mcp__plugin_mixdog_mixdog__';
153
- const COMPACT_SAFETY_PERCENT = 1.00;
154
- const COMPACT_BUFFER_MAX_WINDOW_FRACTION = 0.25;
155
-
156
- function estimateMessagesTokensSafe(messages) {
157
- try { return estimateMessagesTokens(messages); }
158
- catch { return null; }
159
- }
160
-
161
- function compactDebugEnabled() {
162
- return String(process.env.MIXDOG_COMPACT_DEBUG || '').trim() === '1';
163
- }
164
-
165
- function compactDiagnosticError(err) {
166
- if (!err) return null;
167
- const text = String(err?.message || err);
168
- return text.length > 500 ? `${text.slice(0, 499)}…` : text;
169
- }
170
-
171
- function compactByteLength(text) {
172
- try { return Buffer.byteLength(String(text || ''), 'utf8'); }
173
- catch { return String(text || '').length; }
174
- }
175
-
176
- function compactDebugLog(scope, details = {}) {
177
- if (!compactDebugEnabled()) return;
178
- try { process.stderr.write(`[compact] ${scope} ${JSON.stringify(details)}\n`); }
179
- catch { /* best-effort diagnostics only */ }
180
- }
181
-
182
- function steeringContentText(content) {
183
- if (typeof content === 'string') return content;
184
- if (Array.isArray(content)) {
185
- return content.map((part) => {
186
- if (typeof part === 'string') return part;
187
- if (part?.type === 'text') return part.text || '';
188
- if (part?.type === 'image') return '[Image]';
189
- return part?.text || '';
190
- }).filter(Boolean).join('\n');
191
- }
192
- return String(content ?? '');
193
- }
194
-
195
- function normalizeSteeringEntry(entry) {
196
- if (typeof entry === 'string') {
197
- const text = entry.trim();
198
- return text ? { content: text, text } : null;
199
- }
200
- if (!entry || typeof entry !== 'object') return null;
201
- const content = Object.prototype.hasOwnProperty.call(entry, 'content') ? entry.content : entry;
202
- const text = typeof entry.text === 'string' ? entry.text.trim() : steeringContentText(content).trim();
203
- if (Array.isArray(content)) return content.length > 0 ? { content, text } : null;
204
- if (typeof content === 'string') {
205
- const value = content.trim();
206
- return value ? { content: value, text: text || value } : null;
207
- }
208
- const fallback = steeringContentText(content).trim();
209
- return fallback ? { content: fallback, text: text || fallback } : null;
210
- }
211
-
212
- function mergeSteeringEntries(entries) {
213
- const normalized = (Array.isArray(entries) ? entries : [])
214
- .map(normalizeSteeringEntry)
215
- .filter(Boolean);
216
- if (normalized.length === 0) return null;
217
- const displayText = normalized.map((entry) => entry.text || steeringContentText(entry.content))
218
- .filter((text) => String(text || '').trim())
219
- .join('\n');
220
- if (normalized.every((entry) => typeof entry.content === 'string')) {
221
- return {
222
- content: normalized.map((entry) => entry.content).filter(Boolean).join('\n'),
223
- text: displayText,
224
- count: normalized.length,
225
- };
226
- }
227
- const parts = [];
228
- for (const entry of normalized) {
229
- if (typeof entry.content === 'string') {
230
- if (entry.content.trim()) parts.push({ type: 'text', text: entry.content });
231
- } else if (Array.isArray(entry.content)) {
232
- parts.push(...entry.content);
233
- } else {
234
- const text = steeringContentText(entry.content);
235
- if (text.trim()) parts.push({ type: 'text', text });
236
- }
237
- parts.push({ type: 'text', text: '\n' });
238
- }
239
- while (parts.length && parts[parts.length - 1]?.type === 'text' && parts[parts.length - 1]?.text === '\n') parts.pop();
240
- return { content: parts, text: displayText || steeringContentText(parts), count: normalized.length };
241
- }
242
-
243
- class AgentContextOverflowError extends Error {
244
- constructor({ stage, sessionId, provider, model, contextWindow, budgetTokens, reserveTokens, messageTokensEst }, cause) {
245
- const target = [provider, model].filter(Boolean).join('/') || 'target model';
246
- const causeMsg = cause && cause.message ? `: ${cause.message}` : '';
247
- super(
248
- `agent context overflow (${target}, stage=${stage || 'compact'}): ` +
249
- `latest turn cannot fit target context budget=${budgetTokens ?? 'unknown'} ` +
250
- `reserve=${reserveTokens ?? 'unknown'} contextWindow=${contextWindow ?? 'unknown'} ` +
251
- `messageTokensEst=${messageTokensEst ?? 'unknown'}${causeMsg}`,
252
- );
253
- this.name = 'AgentContextOverflowError';
254
- this.code = 'AGENT_CONTEXT_OVERFLOW';
255
- this.sessionId = sessionId || null;
256
- this.provider = provider || null;
257
- this.model = model || null;
258
- this.contextWindow = contextWindow ?? null;
259
- this.budgetTokens = budgetTokens ?? null;
260
- this.reserveTokens = reserveTokens ?? null;
261
- this.messageTokensEst = messageTokensEst ?? null;
262
- if (cause) this.cause = cause;
263
- }
264
- }
53
+ import { resolve as resolvePath, isAbsolute } from 'path';
54
+ import {
55
+ estimateMessagesTokensSafe,
56
+ compactDiagnosticError,
57
+ compactByteLength,
58
+ compactDebugLog,
59
+ } from './loop/compact-debug.mjs';
60
+ import { mergeSteeringEntries, steeringContentText } from './loop/steering.mjs';
61
+ import { agentContextOverflowError } from './loop/context-overflow.mjs';
62
+ import { positiveTokenInt } from './loop/env.mjs';
63
+ import { normalizeUsage, addUsage } from './loop/usage.mjs';
64
+ import { HIDDEN_AGENT_NAMES } from './loop/hidden-agents.mjs';
65
+ import {
66
+ resolveWorkerCompactPolicy,
67
+ compactionTelemetryPressureTokens,
68
+ compactTargetBudget,
69
+ shouldCompactForSession,
70
+ countPrunedToolOutputs,
71
+ rememberCompactTelemetry,
72
+ emitCompactEvent,
73
+ compactEventType,
74
+ } from './loop/compact-policy.mjs';
75
+ import {
76
+ isEagerDispatchable,
77
+ messagesArrayChanged,
78
+ getToolKind,
79
+ buildSkillsListResponse,
80
+ viewSkill,
81
+ normalizeHookUpdatedToolOutput,
82
+ resolveToolResultAfterHook,
83
+ parseNativeToolSearchPayload,
84
+ extractBashSessionId,
85
+ buildAgentBashSessionArgs,
86
+ formatMissingToolApprovalUiDenial,
87
+ resolvePreToolAskApproval,
88
+ approvalGranted,
89
+ approvalReason,
90
+ } from './loop/tool-helpers.mjs';
91
+ import {
92
+ compactToolCallsForHistory,
93
+ restoreToolCallBodyForId,
94
+ } from './loop/stored-tool-args.mjs';
95
+ import { repairTranscriptBeforeProviderSend } from './loop/transcript-repair.mjs';
265
96
 
266
- function agentContextOverflowError({ stage, sessionId, sessionRef, model, budgetTokens, reserveTokens, messageTokensEst }, cause) {
267
- return new AgentContextOverflowError({
268
- stage,
269
- sessionId,
270
- provider: sessionRef?.provider || null,
271
- model: sessionRef?.model || model || null,
272
- contextWindow: sessionRef?.contextWindow ?? null,
273
- budgetTokens,
274
- reserveTokens,
275
- messageTokensEst,
276
- }, cause);
277
- }
97
+ // Facade re-exports: these symbols moved to split modules under ./loop/ but
98
+ // remain part of loop.mjs's public surface (imported by scripts/tests and other
99
+ // runtime modules). Re-export the already-imported local bindings so every
100
+ // existing import path keeps working (no duplicate module binding).
101
+ export {
102
+ preDispatchDenyForSession,
103
+ repairTranscriptBeforeProviderSend,
104
+ normalizeHookUpdatedToolOutput,
105
+ resolveToolResultAfterHook,
106
+ buildAgentBashSessionArgs,
107
+ formatMissingToolApprovalUiDenial,
108
+ resolvePreToolAskApproval,
109
+ approvalGranted,
110
+ approvalReason,
111
+ };
278
112
 
279
- // Cache-hit results always inline the cached body. The earlier size-gated
280
- // `[cache-hit-ref]` branch confused agents whose context did not
281
- // contain the referenced prior tool_result, triggering shell-cat detours.
282
113
  // Hard iteration ceiling for every agent loop. Reset to 0 whenever the
283
114
  // transcript is compacted (see the trim block below): a long task that keeps
284
115
  // compacting can proceed past this count, while a tight NON-compacting loop
@@ -289,185 +120,6 @@ function agentContextOverflowError({ stage, sessionId, sessionRef, model, budget
289
120
  // this catches tight deterministic-failure loops (e.g. a command that errors
290
121
  // the same way every time) far earlier than 100 iterations.
291
122
  const REPEAT_FAIL_LIMIT = 3;
292
- const _AGENTS_JSON = resolvePath(dirname(fileURLToPath(import.meta.url)), '../../../../defaults/agents.json');
293
- let _hiddenAgentsCache = null;
294
- function _getHiddenAgents() {
295
- if (_hiddenAgentsCache) return _hiddenAgentsCache;
296
- try {
297
- _hiddenAgentsCache = JSON.parse(_readFileSync(_AGENTS_JSON, 'utf8'));
298
- } catch { _hiddenAgentsCache = { agents: [] }; }
299
- return _hiddenAgentsCache;
300
- }
301
- // Transcript pairing guard. Anthropic 400-rejects when an assistant message
302
- // ends with tool_use blocks and the next message isn't tool results for
303
- // those exact ids. abort/timeout/error race in the loop body can leave a
304
- // dangling assistant tool_use at the tail (e.g. the structure_probe loop
305
- // running 12 deep then aborting between push-assistant and push-tool).
306
- // Strip any trailing assistant tool_use that has no matching tool result
307
- // so provider.send sees a valid transcript instead of leaking the 400 to
308
- // the user. Repair runs every iteration but is a no-op on healthy paths.
309
- function _ensureTranscriptPairing(msgs, sessionId) {
310
- // Walk backwards to find the last assistant message that emitted
311
- // tool_use, then validate that every id has a matching tool result
312
- // inside the CONTIGUOUS tool-message block immediately following it.
313
- // Earlier guard splice'd the entire tail — which silently deleted any
314
- // user prompt appended after the dangling assistant by manager.mjs:
315
- // when the guard fired with shape
316
- // [..., assistant{a,b}, tool{a}, user{new prompt}]
317
- // the splice removed user{new prompt} along with the orphan suffix.
318
- // Fix: remove only assistant + the contiguous tool block; preserve
319
- // anything past it (user / system / next assistant) untouched.
320
- let popped = 0;
321
- while (msgs.length > 0) {
322
- let lastAssistantIdx = -1;
323
- for (let i = msgs.length - 1; i >= 0; i--) {
324
- const m = msgs[i];
325
- if (m?.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
326
- lastAssistantIdx = i;
327
- break;
328
- }
329
- }
330
- if (lastAssistantIdx === -1) break;
331
- // Collect the contiguous tool messages directly after this assistant.
332
- // Anything past that block is unrelated (next user prompt, system
333
- // marker, etc.) and must survive the repair.
334
- let toolBlockEnd = lastAssistantIdx + 1;
335
- while (toolBlockEnd < msgs.length && msgs[toolBlockEnd]?.role === 'tool') {
336
- toolBlockEnd += 1;
337
- }
338
- const toolBlock = msgs.slice(lastAssistantIdx + 1, toolBlockEnd);
339
- const ids = msgs[lastAssistantIdx].toolCalls.map(c => c.id);
340
- const matched = ids.every(id => toolBlock.some(m => m.toolCallId === id));
341
- if (matched) break;
342
- const removed = toolBlockEnd - lastAssistantIdx;
343
- msgs.splice(lastAssistantIdx, removed);
344
- popped += removed;
345
- }
346
- // Second sweep — catch dangling tool results that survived the
347
- // contiguous-block splice. Anthropic strict spec requires every
348
- // tool result to sit in a contiguous block right after the
349
- // assistant whose toolCalls produced it; a `[..., assistant{a,b},
350
- // tool{a}, user, tool{b}]` shape leaves tool{b} orphaned even
351
- // after assistant + tool{a} are repaired by the loop above.
352
- // Walk back from each tool message to the nearest non-tool
353
- // ancestor; if it is not an assistant whose toolCalls include
354
- // this id, drop the orphan.
355
- for (let i = msgs.length - 1; i >= 0; i--) {
356
- const m = msgs[i];
357
- if (m?.role !== 'tool') continue;
358
- if (!m.toolCallId) {
359
- msgs.splice(i, 1);
360
- popped += 1;
361
- continue;
362
- }
363
- let prevIdx = i - 1;
364
- while (prevIdx >= 0 && msgs[prevIdx]?.role === 'tool') prevIdx--;
365
- const anchor = prevIdx >= 0 ? msgs[prevIdx] : null;
366
- const anchorOk = anchor?.role === 'assistant'
367
- && Array.isArray(anchor.toolCalls)
368
- && anchor.toolCalls.some(c => c.id === m.toolCallId);
369
- if (!anchorOk) {
370
- msgs.splice(i, 1);
371
- popped += 1;
372
- }
373
- }
374
- if (popped > 0 && sessionId) {
375
- try { process.stderr.write(`[transcript-repair] sess=${sessionId} popped=${popped} dangling assistant tool_use\n`); } catch {}
376
- }
377
- }
378
-
379
- /**
380
- * Pre-provider transcript repair for the agent loop. Reattach valid tool
381
- * results (non-destructive) before any destructive orphan pairing cleanup.
382
- * Mutates `messages` in place to preserve the session array reference.
383
- */
384
- export function repairTranscriptBeforeProviderSend(messages, sessionId = null) {
385
- if (!Array.isArray(messages)) return messages;
386
- const sanitized = sanitizeToolPairs(messages);
387
- if (sanitized !== messages) {
388
- messages.length = 0;
389
- messages.push(...sanitized);
390
- }
391
- _ensureTranscriptPairing(messages, sessionId);
392
- return messages;
393
- }
394
-
395
- // Eager-dispatch: tools with readOnlyHint:true in their declaration are safe
396
- // to execute during SSE parsing so tool work overlaps with the rest of the
397
- // stream. Writes, bash, MCP and skills stay serial after send() returns.
398
- function isEagerDispatchable(name, tools) {
399
- if (!Array.isArray(tools)) return false;
400
- const def = tools.find(t => t?.name === name);
401
- return def?.annotations?.readOnlyHint === true;
402
- }
403
- function messagesArrayChanged(before, after) {
404
- if (!Array.isArray(before) || !Array.isArray(after)) return before !== after;
405
- if (before.length !== after.length) return true;
406
- for (let i = 0; i < before.length; i += 1) {
407
- if (before[i] !== after[i]) return true;
408
- }
409
- return false;
410
- }
411
- function normalizeUsage(usage) {
412
- if (!usage) return null;
413
- const costUsd = Number(usage.costUsd);
414
- return {
415
- inputTokens: usage.inputTokens || 0,
416
- outputTokens: usage.outputTokens || 0,
417
- cachedTokens: usage.cachedTokens || 0,
418
- cacheWriteTokens: usage.cacheWriteTokens || 0,
419
- promptTokens: usage.promptTokens || 0,
420
- ...(Number.isFinite(costUsd) ? { costUsd } : {}),
421
- raw: usage.raw,
422
- };
423
- }
424
- function addUsage(total, usage) {
425
- const delta = normalizeUsage(usage);
426
- if (!delta) return total;
427
- if (!total) return { ...delta };
428
- const next = {
429
- ...total,
430
- inputTokens: (total.inputTokens || 0) + delta.inputTokens,
431
- outputTokens: (total.outputTokens || 0) + delta.outputTokens,
432
- cachedTokens: (total.cachedTokens || 0) + delta.cachedTokens,
433
- cacheWriteTokens: (total.cacheWriteTokens || 0) + delta.cacheWriteTokens,
434
- promptTokens: (total.promptTokens || 0) + delta.promptTokens,
435
- };
436
- if (delta.costUsd != null || total.costUsd != null) {
437
- next.costUsd = (total.costUsd || 0) + (delta.costUsd || 0);
438
- }
439
- return next;
440
- }
441
- function positiveTokenInt(value) {
442
- const n = Number(value);
443
- return Number.isFinite(n) && n > 0 ? Math.floor(n) : null;
444
- }
445
- function envFlag(name, fallback = false) {
446
- const v = process.env[name];
447
- if (v === undefined) return fallback;
448
- return !['0', 'false', 'off', 'no'].includes(String(v).trim().toLowerCase());
449
- }
450
- function envTokenInt(name) {
451
- return positiveTokenInt(process.env[name]);
452
- }
453
- function resolveSemanticCompactSetting(sessionRef, cfg = {}) {
454
- if (process.env.MIXDOG_AGENT_COMPACT_SEMANTIC !== undefined) return envFlag('MIXDOG_AGENT_COMPACT_SEMANTIC', true);
455
- if (cfg.semantic === false || cfg.semantic === 'false' || cfg.semantic === 'off') return false;
456
- if (cfg.semantic === true || cfg.semantic === 'true' || cfg.semantic === 'on') return true;
457
- // The compact type is already explicit (`semantic` by default). Honor it
458
- // directly instead of substituting another compaction path.
459
- return true;
460
- }
461
-
462
- function resolveCompactTypeSetting(_sessionRef, cfg = {}) {
463
- const configured = process.env.MIXDOG_AGENT_COMPACT_TYPE
464
- ?? process.env.MIXDOG_COMPACT_TYPE
465
- ?? cfg.type
466
- ?? cfg.compactType
467
- ?? cfg.compact_type;
468
- return normalizeCompactType(configured, DEFAULT_COMPACT_TYPE);
469
- }
470
-
471
123
  async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTokens, compactPolicy, sessionId, signal }) {
472
124
  if (!sessionId) throw new Error('recall-fasttrack requires a session id');
473
125
  const startedAt = Date.now();
@@ -599,473 +251,6 @@ async function runRecallFastTrackCompact({ sessionRef, messages, compactBudgetTo
599
251
  compactDebugLog('recall-fasttrack pipeline', diagnostics);
600
252
  return result;
601
253
  }
602
- const COMPACT_TARGET_RATIO = 0.02;
603
- const COMPACT_TARGET_MIN_TOKENS = 4_000;
604
- const COMPACT_TARGET_MAX_TOKENS = 16_000;
605
- function resolveCompactTargetRatio(cfg = {}) {
606
- const raw = cfg.targetPercent
607
- ?? cfg.targetPct
608
- ?? cfg.targetRatio
609
- ?? cfg.targetFraction
610
- ?? process.env.MIXDOG_AGENT_COMPACT_TARGET_PERCENT
611
- ?? process.env.MIXDOG_COMPACT_TARGET_PERCENT
612
- ?? COMPACT_TARGET_RATIO;
613
- const n = Number(raw);
614
- if (!Number.isFinite(n) || n <= 0) return COMPACT_TARGET_RATIO;
615
- return n > 1 ? n / 100 : n;
616
- }
617
- function resolveCompactTargetTokens(boundaryTokens, cfg = {}) {
618
- const boundary = positiveTokenInt(boundaryTokens);
619
- if (!boundary) return null;
620
- const explicit = positiveTokenInt(cfg.targetTokens ?? cfg.target)
621
- || envTokenInt('MIXDOG_AGENT_COMPACT_TARGET_TOKENS')
622
- || envTokenInt('MIXDOG_COMPACT_TARGET_TOKENS');
623
- if (explicit) return Math.max(1, Math.min(boundary, explicit));
624
- const minTarget = Math.min(boundary, positiveTokenInt(cfg.targetMinTokens ?? cfg.minTargetTokens)
625
- || envTokenInt('MIXDOG_AGENT_COMPACT_TARGET_MIN_TOKENS')
626
- || envTokenInt('MIXDOG_COMPACT_TARGET_MIN_TOKENS')
627
- || COMPACT_TARGET_MIN_TOKENS);
628
- const maxTarget = Math.min(boundary, positiveTokenInt(cfg.targetMaxTokens ?? cfg.maxTargetTokens)
629
- || envTokenInt('MIXDOG_AGENT_COMPACT_TARGET_MAX_TOKENS')
630
- || envTokenInt('MIXDOG_COMPACT_TARGET_MAX_TOKENS')
631
- || COMPACT_TARGET_MAX_TOKENS);
632
- const byRatio = Math.max(1, Math.floor(boundary * resolveCompactTargetRatio(cfg)));
633
- return Math.max(1, Math.min(boundary, maxTarget, Math.max(minTarget, byRatio)));
634
- }
635
- function resolveCompactKeepTokens(cfg = {}) {
636
- return positiveTokenInt(cfg.keepTokens ?? cfg.keep?.tokens ?? cfg.preserveRecentTokens)
637
- || envTokenInt('MIXDOG_AGENT_COMPACT_KEEP_TOKENS')
638
- || DEFAULT_COMPACTION_KEEP_TOKENS;
639
- }
640
- function resolveWorkerCompactPolicy(sessionRef, tools) {
641
- if (!sessionRef) return null;
642
- const cfg = sessionRef.compaction || {};
643
- const auto = cfg.auto !== false && envFlag('MIXDOG_AGENT_COMPACT_AUTO', true);
644
- if (!auto) return { auto: false };
645
- const contextWindow = positiveTokenInt(sessionRef.contextWindow ?? cfg.contextWindow);
646
- const explicitBoundary = positiveTokenInt(sessionRef.compactBoundaryTokens ?? cfg.boundaryTokens);
647
- const autoLimit = positiveTokenInt(sessionRef.autoCompactTokenLimit ?? cfg.autoCompactTokenLimit);
648
- const boundaryTokens = explicitBoundary && contextWindow
649
- ? Math.min(explicitBoundary, contextWindow)
650
- : (explicitBoundary || contextWindow || autoLimit);
651
- if (!boundaryTokens) return null;
652
- const compactBoundaryTokens = Math.max(1, Math.floor(boundaryTokens * COMPACT_SAFETY_PERCENT));
653
- // Only an explicit auto-compact limit STRICTLY BELOW the boundary acts as
654
- // the trigger. A persisted value == boundary (legacy derived full-window
655
- // autoCompactTokenLimit) would set autoTriggerTokens == boundary and
656
- // collapse/override the default trigger, so it is ignored in favor of the
657
- // default boundary trigger.
658
- const autoTriggerTokens = autoLimit && autoLimit < compactBoundaryTokens ? Math.max(1, autoLimit) : null;
659
- // Sanitized explicit limit: only a sub-boundary value is a real auto-compact
660
- // limit. Anything >= boundary is a legacy derived full-window artifact and
661
- // is reported as null so rememberCompactTelemetry does not re-persist it
662
- // back onto the session and re-collapse the buffer on the next turn.
663
- const explicitAutoCompactTokenLimit = autoTriggerTokens;
664
- const bufferTokens = autoTriggerTokens
665
- ? Math.max(0, compactBoundaryTokens - autoTriggerTokens)
666
- : resolveCompactBufferTokens(compactBoundaryTokens, cfg);
667
- const bufferRatio = compactBoundaryTokens ? (bufferTokens / compactBoundaryTokens) : resolveCompactBufferRatio(cfg);
668
- const triggerTokens = autoTriggerTokens || Math.max(1, compactBoundaryTokens - bufferTokens);
669
- const configuredReserve = positiveTokenInt(cfg.reservedTokens)
670
- || envTokenInt('MIXDOG_AGENT_COMPACT_RESERVED_TOKENS')
671
- || 0;
672
- const requestReserve = estimateRequestReserveTokens(tools);
673
- const keepTokens = resolveCompactKeepTokens(cfg);
674
- const compactType = resolveCompactTypeSetting(sessionRef, cfg);
675
- return {
676
- auto: true,
677
- type: compactType,
678
- compactType,
679
- prune: cfg.prune === true || envFlag('MIXDOG_AGENT_COMPACT_PRUNE', false),
680
- boundaryTokens: compactBoundaryTokens,
681
- triggerTokens,
682
- bufferTokens,
683
- bufferRatio,
684
- contextWindow,
685
- rawContextWindow: positiveTokenInt(sessionRef.rawContextWindow ?? cfg.rawContextWindow) || contextWindow,
686
- effectiveContextWindowPercent: Number.isFinite(Number(sessionRef.effectiveContextWindowPercent ?? cfg.effectiveContextWindowPercent))
687
- ? Number(sessionRef.effectiveContextWindowPercent ?? cfg.effectiveContextWindowPercent)
688
- : null,
689
- autoCompactTokenLimit: explicitAutoCompactTokenLimit,
690
- semantic: compactTypeIsSemantic(compactType) && resolveSemanticCompactSetting(sessionRef, cfg),
691
- recallFastTrack: compactTypeIsRecallFastTrack(compactType),
692
- semanticTimeoutMs: positiveTokenInt(cfg.timeoutMs) || envTokenInt('MIXDOG_AGENT_COMPACT_TIMEOUT_MS') || 30_000,
693
- tailTurns: positiveTokenInt(cfg.tailTurns) || envTokenInt('MIXDOG_AGENT_COMPACT_TAIL_TURNS') || 2,
694
- keepTokens,
695
- preserveRecentTokens: positiveTokenInt(cfg.preserveRecentTokens) || envTokenInt('MIXDOG_AGENT_COMPACT_PRESERVE_RECENT_TOKENS') || keepTokens,
696
- reserveTokens: requestReserve + configuredReserve,
697
- requestReserveTokens: requestReserve,
698
- configuredReserveTokens: configuredReserve,
699
- };
700
- }
701
- /** Transcript + request reserve only (never provider lastContextTokens). */
702
- function compactPressureTokens(messageTokensEst, policy) {
703
- if (messageTokensEst === null) return 0;
704
- return Math.max(0, messageTokensEst + (policy?.reserveTokens || 0));
705
- }
706
-
707
- /** Telemetry pressure when a reactive overflow retry forces the next compact. */
708
- function compactionTelemetryPressureTokens(messageTokensEst, policy, { reactivePending = false } = {}) {
709
- const base = compactPressureTokens(messageTokensEst, policy);
710
- if (!reactivePending) return base;
711
- const floor = positiveTokenInt(policy?.triggerTokens) || positiveTokenInt(policy?.boundaryTokens) || 0;
712
- return floor ? Math.max(base, floor) : base;
713
- }
714
- function compactTargetBudget(policy) {
715
- const boundary = positiveTokenInt(policy?.boundaryTokens);
716
- if (!boundary) return null;
717
- const reserve = Math.max(0, Number(policy?.reserveTokens) || 0);
718
- const targetEffective = resolveCompactTargetTokens(boundary, policy) || boundary;
719
- return Math.max(1, Math.min(boundary, targetEffective + reserve));
720
- }
721
- function shouldCompactForSession(messageTokensEst, policy, { forceReactive = false } = {}) {
722
- if (!policy?.auto || !policy.boundaryTokens) return false;
723
- if (forceReactive) return true;
724
- if (messageTokensEst === null) return true;
725
- return compactPressureTokens(messageTokensEst, policy) >= (policy.triggerTokens || policy.boundaryTokens);
726
- }
727
- function countPrunedToolOutputs(before, after) {
728
- if (!Array.isArray(before) || !Array.isArray(after)) return 0;
729
- let count = 0;
730
- const n = Math.min(before.length, after.length);
731
- for (let i = 0; i < n; i += 1) {
732
- if (before[i]?.role !== 'tool' || after[i]?.role !== 'tool') continue;
733
- if (before[i]?.content !== after[i]?.content && after[i]?.compactedKind === 'tool_output_prune') count += 1;
734
- }
735
- return count;
736
- }
737
- function rememberCompactTelemetry(sessionRef, policy, meta = {}) {
738
- if (!sessionRef || !policy) return;
739
- const prev = sessionRef.compaction && typeof sessionRef.compaction === 'object'
740
- ? sessionRef.compaction
741
- : {};
742
- const changed = meta.compactChanged === true || meta.pruneCount > 0;
743
- sessionRef.compaction = {
744
- ...prev,
745
- auto: policy.auto !== false,
746
- prune: policy.prune === true,
747
- reservedTokens: policy.configuredReserveTokens || prev.reservedTokens || null,
748
- requestReserveTokens: policy.requestReserveTokens || 0,
749
- reserveTokens: policy.reserveTokens || 0,
750
- boundaryTokens: policy.boundaryTokens || null,
751
- triggerTokens: policy.triggerTokens || null,
752
- bufferTokens: policy.bufferTokens || 0,
753
- bufferRatio: policy.bufferRatio ?? prev.bufferRatio ?? null,
754
- contextWindow: policy.contextWindow || null,
755
- rawContextWindow: policy.rawContextWindow || null,
756
- effectiveContextWindowPercent: policy.effectiveContextWindowPercent ?? null,
757
- autoCompactTokenLimit: policy.autoCompactTokenLimit || null,
758
- type: policy.compactType || policy.type || DEFAULT_COMPACT_TYPE,
759
- compactType: policy.compactType || policy.type || DEFAULT_COMPACT_TYPE,
760
- semantic: policy.semantic === true ? 'auto' : false,
761
- recallFastTrack: policy.recallFastTrack === true,
762
- semanticModel: policy.semanticModel || null,
763
- semanticTimeoutMs: policy.semanticTimeoutMs || null,
764
- tailTurns: policy.tailTurns || null,
765
- keepTokens: policy.keepTokens || null,
766
- preserveRecentTokens: policy.preserveRecentTokens || null,
767
- lastCheckedAt: Date.now(),
768
- lastBeforeTokens: meta.beforeTokens ?? null,
769
- lastAfterTokens: meta.afterTokens ?? null,
770
- lastPressureTokens: meta.pressureTokens ?? null,
771
- currentEstimatedTokens: meta.pressureTokens ?? prev.currentEstimatedTokens ?? null,
772
- lastApiRequestTokens: positiveTokenInt(sessionRef?.lastContextTokens) || prev.lastApiRequestTokens || null,
773
- lastStage: meta.stage || prev.lastStage || null,
774
- lastChanged: changed,
775
- lastTrigger: meta.trigger || prev.lastTrigger || null,
776
- lastSemantic: meta.semanticCompact === true,
777
- lastSemanticError: Object.hasOwn(meta, 'semanticError')
778
- ? (meta.semanticError ?? null)
779
- : (prev.lastSemanticError ?? null),
780
- lastRecallFastTrack: meta.recallFastTrack === true,
781
- lastRecallFastTrackError: Object.hasOwn(meta, 'recallFastTrackError')
782
- ? (meta.recallFastTrackError ?? null)
783
- : (prev.lastRecallFastTrackError ?? null),
784
- lastError: Object.hasOwn(meta, 'compactError') || Object.hasOwn(meta, 'lastError')
785
- ? (meta.compactError ?? meta.lastError ?? null)
786
- : (prev.lastError ?? null),
787
- lastPruneCount: meta.pruneCount || 0,
788
- lastDurationMs: meta.durationMs != null && Number.isFinite(Number(meta.durationMs))
789
- ? Math.max(0, Math.round(Number(meta.durationMs)))
790
- : null,
791
- compactCount: (prev.compactCount || 0) + (changed ? 1 : 0),
792
- };
793
- if (changed) {
794
- const changedAt = Date.now();
795
- sessionRef.compaction.lastChangedAt = changedAt;
796
- sessionRef.compaction.lastCompactAt = changedAt;
797
- sessionRef.lastContextTokensStaleAfterCompact = true;
798
- }
799
- sessionRef.contextWindow = policy.contextWindow || sessionRef.contextWindow;
800
- sessionRef.rawContextWindow = policy.rawContextWindow || sessionRef.rawContextWindow;
801
- sessionRef.compactBoundaryTokens = policy.boundaryTokens || sessionRef.compactBoundaryTokens || null;
802
- // Persist only the sanitized (sub-boundary) explicit limit. policy.autoCompactTokenLimit
803
- // is already null for legacy derived full-window values, so a stale
804
- // boundary-sized autoCompactTokenLimit on the session is cleared here rather
805
- // than carried forward to re-collapse the buffer next turn.
806
- {
807
- const _boundary = positiveTokenInt(sessionRef.compactBoundaryTokens);
808
- const _prevLimit = positiveTokenInt(sessionRef.autoCompactTokenLimit);
809
- const _keepPrev = _prevLimit && (!_boundary || _prevLimit < _boundary) ? _prevLimit : null;
810
- sessionRef.autoCompactTokenLimit = policy.autoCompactTokenLimit || _keepPrev || null;
811
- }
812
- if (policy.effectiveContextWindowPercent !== null) {
813
- sessionRef.effectiveContextWindowPercent = policy.effectiveContextWindowPercent;
814
- }
815
- }
816
-
817
- function emitCompactEvent(opts, event = {}) {
818
- if (!opts || typeof opts.onCompactEvent !== 'function') return;
819
- try { opts.onCompactEvent({ ts: Date.now(), ...event }); }
820
- catch { /* best-effort UI/log hook */ }
821
- }
822
-
823
- function compactEventType(policy, fallback = DEFAULT_COMPACT_TYPE) {
824
- return policy?.compactType || policy?.type || fallback;
825
- }
826
- const SKILL_TOOL_NAMES = new Set(['Skill', 'skills_list', 'skill_view']);
827
- const SPECIAL_TOOL_NAMES = new Set(['bash_session', 'apply_patch', 'code_graph']);
828
- const BASH_SESSION_HEADER_RE = /\[session: ([^\]\r\n]+)\]/;
829
- const STORED_TOOL_ARG_BODY_KEY_RE = /^(?:content|old_string|new_string|patch|rewrite)$/i;
830
- const STORED_TOOL_ARG_LONG_KEY_RE = /^(?:command|script)$/i;
831
- const STORED_TOOL_ARG_BODY_LIMIT = 2_000;
832
- const STORED_TOOL_ARG_LONG_LIMIT = 8_000;
833
- const STORED_TOOL_ARG_PREVIEW_HEAD = 360;
834
- const STORED_TOOL_ARG_PREVIEW_TAIL = 160;
835
-
836
- function compactStoredToolArgString(value, key = '') {
837
- if (typeof value !== 'string') return value;
838
- const isBody = STORED_TOOL_ARG_BODY_KEY_RE.test(key);
839
- const isLong = isBody || STORED_TOOL_ARG_LONG_KEY_RE.test(key);
840
- const limit = isBody ? STORED_TOOL_ARG_BODY_LIMIT : (isLong ? STORED_TOOL_ARG_LONG_LIMIT : Infinity);
841
- if (value.length <= limit) return value;
842
- const hash = createHash('sha256').update(value).digest('hex').slice(0, 16);
843
- const head = value.slice(0, STORED_TOOL_ARG_PREVIEW_HEAD).replace(/\r\n/g, '\n');
844
- const tail = value.slice(-STORED_TOOL_ARG_PREVIEW_TAIL).replace(/\r\n/g, '\n');
845
- return `[mixdog compacted ${key || 'string'}: ${value.length} chars, sha256:${hash}]\n${head}\n... [middle omitted from stored tool-call args] ...\n${tail}`;
846
- }
847
-
848
- function compactStoredToolArgValue(value, key = '', depth = 0) {
849
- if (value === null || value === undefined) return value;
850
- if (typeof value === 'string') return compactStoredToolArgString(value, key);
851
- if (typeof value !== 'object') return value;
852
- if (depth >= 6) return Array.isArray(value) ? `[${value.length} items]` : '{...}';
853
- if (Array.isArray(value)) {
854
- return value.map((item) => compactStoredToolArgValue(item, key, depth + 1));
855
- }
856
- const out = {};
857
- for (const [k, v] of Object.entries(value)) {
858
- out[k] = compactStoredToolArgValue(v, k, depth + 1);
859
- }
860
- return out;
861
- }
862
-
863
- function compactToolCallsForHistory(calls) {
864
- if (!Array.isArray(calls)) return calls;
865
- return calls.map((call) => {
866
- if (!call || typeof call !== 'object') return call;
867
- return {
868
- ...call,
869
- arguments: compactStoredToolArgValue(call.arguments),
870
- };
871
- });
872
- }
873
-
874
- // Restore the FULL body of ONE tool call inside a history assistant message
875
- // whose toolCalls were compacted at push time. Used for a failed edit call so
876
- // the model sees the original patch/old_string on retry instead of a
877
- // `[mixdog compacted …]` placeholder it cannot act on. Must run BEFORE the
878
- // message is first transmitted so it never mutates an already-cached prefix
879
- // (the prompt cache is content-prefix matched).
880
- //
881
- // Only the compactable body/long keys (patch, old_string, new_string, content,
882
- // rewrite, command, script) are restored, and at ANY depth — compaction is
883
- // recursive (compactStoredToolArgValue), so batch shapes like edits[].old_string
884
- // or writes[].content carry nested compacted bodies too. Every other field
885
- // (e.g. `path`, which a tool may mutate in place during execution) is taken from
886
- // the compacted snapshot captured at push time, before any mutation. The
887
- // compacted args tree is built fresh by compactToolCallsForHistory and is not
888
- // shared with originalCalls, so rebuilding it here is safe.
889
- function restoreToolCallBodyForId(assistantMsg, originalCalls, callId) {
890
- if (!assistantMsg || !Array.isArray(assistantMsg.toolCalls) || !callId) return;
891
- if (!Array.isArray(originalCalls)) return;
892
- const tc = assistantMsg.toolCalls.find((t) => t && t.id === callId);
893
- const orig = originalCalls.find((c) => c && c.id === callId);
894
- if (!tc || !orig) return;
895
- if (!tc.arguments || typeof tc.arguments !== 'object'
896
- || !orig.arguments || typeof orig.arguments !== 'object') return;
897
- tc.arguments = _restoreCompactedBodies(tc.arguments, orig.arguments, '');
898
- }
899
-
900
- // Recursively rebuild a compacted args tree: replace ONLY compactable body/long
901
- // string fields (matched by key at any depth) with their full originals, and
902
- // keep every other field from the compacted snapshot. tcVal and origVal share
903
- // the same structure (compaction only shortens body strings), so the walk
904
- // descends them in parallel; a missing or non-object origVal falls back to the
905
- // compacted value rather than throwing.
906
- function _restoreCompactedBodies(tcVal, origVal, key) {
907
- if ((STORED_TOOL_ARG_BODY_KEY_RE.test(key) || STORED_TOOL_ARG_LONG_KEY_RE.test(key))
908
- && typeof origVal === 'string') {
909
- return origVal;
910
- }
911
- if (Array.isArray(tcVal) && Array.isArray(origVal)) {
912
- return tcVal.map((item, i) => _restoreCompactedBodies(item, origVal[i], key));
913
- }
914
- if (tcVal && typeof tcVal === 'object' && origVal && typeof origVal === 'object') {
915
- const out = {};
916
- for (const k of Object.keys(tcVal)) {
917
- out[k] = (k in origVal) ? _restoreCompactedBodies(tcVal[k], origVal[k], k) : tcVal[k];
918
- }
919
- return out;
920
- }
921
- return tcVal;
922
- }
923
- /**
924
- * Execute a single tool call — routes to MCP or builtin.
925
- */
926
- function getToolKind(name) {
927
- if (SKILL_TOOL_NAMES.has(name)) return 'skill';
928
- if (SPECIAL_TOOL_NAMES.has(name)) return 'builtin';
929
- if (isMcpTool(name)) return 'mcp';
930
- if (isInternalTool(name)) return 'internal';
931
- if (isBuiltinTool(name)) return 'builtin';
932
- return 'builtin';
933
- }
934
- function buildSkillsListResponse(cwd) {
935
- const skills = collectSkillsCached(cwd);
936
- const entries = skills.map(s => ({ name: s.name, description: s.description || '' }));
937
- return JSON.stringify({ skills: entries });
938
- }
939
- function viewSkill(cwd, name) {
940
- if (!name) return 'Error: skill name is required';
941
- const res = loadSkillResource(name, cwd);
942
- if (!res) return `Error: skill "${name}" not found`;
943
- // Return the general tool envelope: the model-visible tool_result is the
944
- // short stub (`Loaded skill: <name>`) and the full SKILL.md body is
945
- // delivered ONCE as a separate injected role:'user' message (newMessages).
946
- return buildSkillToolEnvelope(name, res.content, res.dir);
947
- }
948
-
949
- /** Normalize PostToolUse hook override values (legacy MCP text envelopes only). */
950
- export function normalizeHookUpdatedToolOutput(value) {
951
- if (typeof value === 'string') return value;
952
- if (value == null) return '';
953
- if (typeof value === 'object' && Array.isArray(value.content)) {
954
- const hasNonText = value.content.some((c) => c && typeof c === 'object' && c.type && c.type !== 'text');
955
- if (hasNonText) return value;
956
- return value.content
957
- .map((c) => (c?.type === 'text' ? c.text || '' : JSON.stringify(c)))
958
- .join('\n');
959
- }
960
- return value;
961
- }
962
-
963
- export function resolveToolResultAfterHook(originalResult, hookResult) {
964
- if (!hookResult || typeof hookResult !== 'object' || hookResult.updatedToolOutput === undefined) {
965
- return originalResult;
966
- }
967
- const updated = normalizeHookUpdatedToolOutput(hookResult.updatedToolOutput);
968
- return updated === undefined ? originalResult : updated;
969
- }
970
-
971
- function parseNativeToolSearchPayload(toolName, result) {
972
- if (toolName !== 'tool_search' || typeof result !== 'string') return null;
973
- try {
974
- const parsed = JSON.parse(result);
975
- const native = parsed?.nativeToolSearch;
976
- if (!native || typeof native !== 'object') return null;
977
- const toolReferences = Array.isArray(native.toolReferences)
978
- ? native.toolReferences.map((name) => String(name || '').trim()).filter(Boolean)
979
- : [];
980
- const openaiTools = Array.isArray(native.openaiTools)
981
- ? native.openaiTools.filter((tool) => tool && typeof tool === 'object')
982
- : [];
983
- if (!toolReferences.length && !openaiTools.length) return null;
984
- return {
985
- toolReferences,
986
- openaiTools,
987
- summary: typeof native.summary === 'string' && native.summary
988
- ? native.summary
989
- : `Loaded deferred tools: ${toolReferences.join(', ') || openaiTools.map((tool) => tool.name).filter(Boolean).join(', ')}`,
990
- };
991
- } catch {
992
- return null;
993
- }
994
- }
995
- function extractBashSessionId(result) {
996
- if (typeof result !== 'string') return null;
997
- const match = BASH_SESSION_HEADER_RE.exec(result);
998
- return match ? match[1] : null;
999
- }
1000
-
1001
- export function buildAgentBashSessionArgs(args, sessionRef) {
1002
- if (!isAgentOwner(sessionRef)) return null;
1003
- // run_in_background is a detached one-shot job, incompatible with the
1004
- // persistent bash session. Fall through to the background-job path
1005
- // (executeBuiltinTool -> startBackgroundShellJob) so the worker gets a
1006
- // task_id that task control can resolve — otherwise the persistent
1007
- // session returns a [session: ...] header and task control reports "task not found".
1008
- if (args?.run_in_background === true) return null;
1009
- const routedArgs = { ...(args || {}) };
1010
- const explicitSessionId = typeof routedArgs.session_id === 'string' && routedArgs.session_id.trim()
1011
- ? routedArgs.session_id.trim()
1012
- : null;
1013
- const wantsPersistent = routedArgs.persistent === true || !!explicitSessionId;
1014
- if (!wantsPersistent) return null;
1015
- if (!explicitSessionId && sessionRef?.implicitBashSessionId) {
1016
- routedArgs.session_id = sessionRef.implicitBashSessionId;
1017
- } else if (explicitSessionId) {
1018
- routedArgs.session_id = explicitSessionId;
1019
- }
1020
- delete routedArgs.persistent;
1021
- return routedArgs;
1022
- }
1023
-
1024
- export function formatMissingToolApprovalUiDenial(toolName, askReason) {
1025
- const reason = String(askReason || 'approval requested by hook').trim();
1026
- const name = String(toolName || 'tool');
1027
- return `Error: tool "${name}" denied by hook: approval required but no approval UI is available${reason ? ` (${reason})` : ''}`;
1028
- }
1029
-
1030
- /**
1031
- * Resolve PreToolUse `{ action: 'ask' }` against an optional approval callback.
1032
- * Returns `{ denial }` when the tool must not run; otherwise `{ approval }`.
1033
- */
1034
- export async function resolvePreToolAskApproval({
1035
- toolName,
1036
- args,
1037
- cwd,
1038
- sessionId,
1039
- toolCallId,
1040
- askReason,
1041
- toolApprovalHook,
1042
- }) {
1043
- const name = String(toolName || 'tool');
1044
- const reason = String(askReason || 'approval requested by hook').trim();
1045
- if (typeof toolApprovalHook !== 'function') {
1046
- return { denial: formatMissingToolApprovalUiDenial(name, reason) };
1047
- }
1048
- let approval;
1049
- try {
1050
- approval = await toolApprovalHook({
1051
- name,
1052
- args,
1053
- cwd,
1054
- sessionId,
1055
- toolCallId: toolCallId || null,
1056
- reason,
1057
- });
1058
- } catch (error) {
1059
- const detail = error?.message || String(error || 'approval failed');
1060
- return { denial: `Error: tool "${name}" denied by hook: ${detail}` };
1061
- }
1062
- if (!approvalGranted(approval)) {
1063
- const detail = approvalReason(approval, reason || 'not approved');
1064
- return { denial: `Error: tool "${name}" denied by hook: ${detail}` };
1065
- }
1066
- return { approval };
1067
- }
1068
-
1069
254
  function _scopedCacheOutcomeForCall(sessionRef, toolCallId, toolName, callerSessionId, executeOpts = {}) {
1070
255
  if (executeOpts.scopedCacheOutcome) {
1071
256
  if (sessionRef && toolCallId) {
@@ -1239,6 +424,12 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
1239
424
  // the bash branch above (see resolveJobOwnerHostPid).
1240
425
  return executeBuiltinTool(name, args, cwd, completionToolOpts);
1241
426
  }
427
+ if (isExternalAdapterTool(name)) {
428
+ // Foreign-CLI tool names (StrReplace/Write/bash variants) adapt to a
429
+ // native execution inside executeBuiltinTool's default: case; on a
430
+ // shape mismatch it falls back to the redirect guidance message.
431
+ return executeBuiltinTool(name, args, cwd, completionToolOpts);
432
+ }
1242
433
  return formatUnknownBuiltinToolMessage(name, args, 'tool');
1243
434
  })();
1244
435
  if (typeof afterToolHook === 'function') {
@@ -1276,13 +467,6 @@ async function executeTool(name, args, cwd, callerSessionId, sessionRef, execute
1276
467
  * wrapper can propagate a clean cancellation.
1277
468
  * - `onStageChange(stage)` / `onStreamDelta()` — forwarded to provider.send for heartbeats
1278
469
  */
1279
- // Source of truth: defaults/agents.json (loaded via _getHiddenAgents
1280
- // above). Build the name Set eagerly at module load so HIDDEN_AGENT_NAMES
1281
- // stays in sync with the declarative registry — no hardcoded duplicate.
1282
- const HIDDEN_AGENT_NAMES = new Set(
1283
- (_getHiddenAgents().agents || []).map((r) => r && r.agent).filter((n) => typeof n === 'string' && n.length > 0)
1284
- );
1285
-
1286
470
  // Stop reasons that signal the turn was cut short mid-synthesis (token cap,
1287
471
  // provider pause). Empty content + one of these reasons means the worker
1288
472
  // was not done — re-prompt instead of accepting empty as final.
@@ -1292,22 +476,6 @@ const INCOMPLETE_STOP_REASONS = new Set([
1292
476
  'pause_turn', 'max_tokens', 'length', 'MAX_TOKENS', 'OTHER',
1293
477
  ]);
1294
478
 
1295
- export function approvalGranted(value) {
1296
- if (value === true) return true;
1297
- if (!value || typeof value !== 'object') return false;
1298
- if (value.approved === true || value.allow === true || value.allowed === true) return true;
1299
- const decision = String(value.decision || value.action || value.result || '').trim().toLowerCase();
1300
- return decision === 'approve' || decision === 'approved' || decision === 'allow' || decision === 'yes';
1301
- }
1302
-
1303
- export function approvalReason(value, fallback = '') {
1304
- if (value && typeof value === 'object') {
1305
- const reason = String(value.reason || value.message || '').trim();
1306
- if (reason) return reason;
1307
- }
1308
- return fallback;
1309
- }
1310
-
1311
479
  export async function agentLoop(provider, messages, model, tools, onToolCall, cwd, sendOpts) {
1312
480
  let iterations = 0;
1313
481
  let toolCallsTotal = 0;
@@ -1465,6 +633,18 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1465
633
  // them apart, then clear the one-shot flag.
1466
634
  const compactTrigger = reactiveOverflowRetryPending ? 'reactive' : 'auto';
1467
635
  reactiveOverflowRetryPending = false;
636
+ // PreCompact: bridge to the standard hook bus before compaction
637
+ // runs. session-property hook (manager/loop have no bus access).
638
+ // { trigger } normalized to 'auto'|'manual'. Best-effort.
639
+ {
640
+ const _preCompactHook = typeof opts.preCompactHook === 'function'
641
+ ? opts.preCompactHook
642
+ : sessionRef?.preCompactHook;
643
+ if (typeof _preCompactHook === 'function') {
644
+ try { await _preCompactHook({ sessionId, cwd, trigger: compactTrigger === 'manual' ? 'manual' : 'auto' }); }
645
+ catch { /* best-effort: PreCompact hook must never break compaction */ }
646
+ }
647
+ }
1468
648
  rememberCompactTelemetry(sessionRef, compactPolicy, {
1469
649
  stage: 'compacting',
1470
650
  beforeTokens: messageTokensEst,
@@ -1792,6 +972,17 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1792
972
  durationMs: compactDurationMs,
1793
973
  });
1794
974
  }
975
+ // PostCompact: bridge to the standard hook bus after compaction
976
+ // completes. session-property hook; { trigger } 'auto'|'manual'.
977
+ {
978
+ const _postCompactHook = typeof opts.postCompactHook === 'function'
979
+ ? opts.postCompactHook
980
+ : sessionRef?.postCompactHook;
981
+ if (typeof _postCompactHook === 'function') {
982
+ try { await _postCompactHook({ sessionId, cwd, trigger: compactTrigger === 'manual' ? 'manual' : 'auto' }); }
983
+ catch { /* best-effort: PostCompact hook must never break the loop */ }
984
+ }
985
+ }
1795
986
  }
1796
987
  const nextIteration = iterations + 1;
1797
988
  opts.iteration = nextIteration;
@@ -1846,7 +1037,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1846
1037
  // Shared pre-dispatch deny: identical predicate runs in the
1847
1038
  // serial path below. If any role/permission guard would reject
1848
1039
  // this call there, never start it eagerly here.
1849
- if (_preDispatchDeny(call, toolKind, sessionRef) !== null) return null;
1040
+ if (preDispatchDenyForSession(sessionRef, call, toolKind) !== null) return null;
1850
1041
  const entry = { startedAt: Date.now(), endedAt: null, mutationEpoch: _mutationEpoch };
1851
1042
  _eagerInFlightSigs.set(_sig, call.id);
1852
1043
  entry.promise = (async () => {
@@ -2082,13 +1273,18 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2082
1273
  // the stateless contract for providers that don't use continuation).
2083
1274
  providerState = response?.providerState ?? undefined;
2084
1275
  iterations = nextIteration;
2085
- traceAgentLoop({
2086
- sessionId,
2087
- iteration: iterations,
2088
- sendMs: Date.now() - sendStartedAt,
2089
- messageCount: Array.isArray(messages) ? messages.length : 0,
2090
- bodyBytesEst: estimateProviderPayloadBytes(messages, model, sendTools),
2091
- });
1276
+ // Payload byte estimate serializes the FULL messages+tools array —
1277
+ // only pay that cost when verbose loop tracing is actually enabled
1278
+ // (traceAgentLoop is a no-op otherwise).
1279
+ if (process.env.MIXDOG_AGENT_TRACE_VERBOSE === '1') {
1280
+ traceAgentLoop({
1281
+ sessionId,
1282
+ iteration: iterations,
1283
+ sendMs: Date.now() - sendStartedAt,
1284
+ messageCount: Array.isArray(messages) ? messages.length : 0,
1285
+ bodyBytesEst: estimateProviderPayloadBytes(messages, model, sendTools),
1286
+ });
1287
+ }
2092
1288
  // Accumulate usage across iterations — every billable slot, not just
2093
1289
  // input/output. Anthropic cache_read/cache_write typically stay 0 on
2094
1290
  // the first iteration and surge on later ones (warm prefix reuse),
@@ -2330,8 +1526,7 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2330
1526
  let toolStartedAt;
2331
1527
  let toolEndedAt;
2332
1528
  const toolKind = getToolKind(call.name);
2333
- // Cross-turn read dedup. Mirrors a reference agent's
2334
- // fileReadCache.ts: if the path's stat tuple (mtime/size/ino/dev)
1529
+ // Cross-turn read dedup: if the path's stat tuple (mtime/size/ino/dev)
2335
1530
  // is unchanged since a prior read in THIS session, return the cached
2336
1531
  // body instead of executing. Both scalar and array/object-array path
2337
1532
  // forms are cached — keyed by (abs, offset, limit, mode, n) per entry.
@@ -2415,12 +1610,12 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2415
1610
  // Runtime pre-dispatch deny. Schema profiles may hide
2416
1611
  // tools for routing efficiency, but this remains the
2417
1612
  // control-plane boundary for any tool_use that still
2418
- // reaches the loop. _preDispatchDeny is the SHARED helper
1613
+ // reaches the loop. preDispatchDenyForSession is the SHARED helper
2419
1614
  // used by both the eager dispatch path (startEagerTool)
2420
1615
  // and this serial path — keeps the agent-owned control-
2421
1616
  // plane reject and no-tool role guards consistent across
2422
1617
  // both paths.
2423
- const _denyMsg = _preDispatchDeny(call, toolKind, sessionRef);
1618
+ const _denyMsg = preDispatchDenyForSession(sessionRef, call, toolKind);
2424
1619
  if (_denyMsg !== null) {
2425
1620
  result = _denyMsg;
2426
1621
  toolEndedAt = Date.now();
@@ -2460,6 +1655,36 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2460
1655
  result = _env.result;
2461
1656
  if (_env.newMessages.length) _batchNewMessages.push(..._env.newMessages);
2462
1657
  }
1658
+ // Bounded-map cleanup: a scoped-cache outcome recorded for this call.id
1659
+ // (via _scopedCacheOutcomeForCall) is only ever consumed/deleted on the
1660
+ // success path below (_executeOk && _resultKind==='normal'). A failed or
1661
+ // errored call would otherwise leak its entry in
1662
+ // sessionRef._scopedCacheOutcomeByCallId forever — reclaim it here.
1663
+ if (sessionRef?._scopedCacheOutcomeByCallId && call?.id && (!_executeOk || _resultKind === 'error')) {
1664
+ sessionRef._scopedCacheOutcomeByCallId.delete(call.id);
1665
+ }
1666
+ // PostToolUseFailure: a tool that resolved to a failure (thrown-error
1667
+ // path -> `Error:` string, or an is_error result classified as
1668
+ // 'error') fires the optional session failure hook. Same shape as
1669
+ // afterToolHook; `result` carries the error text. Best-effort — a
1670
+ // hook error must never wedge the tool loop.
1671
+ if (!_executeOk || _resultKind === 'error') {
1672
+ const _afterToolFailureHook = typeof opts.afterToolFailureHook === 'function'
1673
+ ? opts.afterToolFailureHook
1674
+ : sessionRef?.afterToolFailureHook;
1675
+ if (typeof _afterToolFailureHook === 'function') {
1676
+ try {
1677
+ await _afterToolFailureHook({
1678
+ name: call.name,
1679
+ args: call.arguments,
1680
+ cwd,
1681
+ sessionId,
1682
+ toolCallId: call.id,
1683
+ result: typeof result === 'string' ? result : String(result ?? ''),
1684
+ });
1685
+ } catch { /* best-effort: PostToolUseFailure hook must never break the loop */ }
1686
+ }
1687
+ }
2463
1688
  // Update the cross-iteration repeat-failure guard with this call's
2464
1689
  // outcome: bump the consecutive-failure count for an identical
2465
1690
  // signature, or clear it the moment the same call succeeds.
@@ -2710,6 +1935,32 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
2710
1935
  if (!_nm || _nm.role !== 'user' || typeof _nm.content !== 'string' || !_nm.content) continue;
2711
1936
  messages.push({ role: 'user', content: _nm.content, ...(_nm.meta ? { meta: _nm.meta } : {}) });
2712
1937
  }
1938
+ // PostToolBatch: the full parallel batch of tool calls for this
1939
+ // assistant turn has resolved and all tool_results are pushed. Fire the
1940
+ // optional session hook before the next model call. No matcher event.
1941
+ // Block support: if the hook returns blocked===true, inject its reason
1942
+ // as a system-note user message for the next send (natural mechanism —
1943
+ // same channel the newMessages flush just used). Best-effort otherwise.
1944
+ {
1945
+ const _afterToolBatchHook = typeof opts.afterToolBatchHook === 'function'
1946
+ ? opts.afterToolBatchHook
1947
+ : sessionRef?.afterToolBatchHook;
1948
+ if (typeof _afterToolBatchHook === 'function' && calls.length > 0) {
1949
+ try {
1950
+ const _batchDecision = await _afterToolBatchHook({
1951
+ sessionId,
1952
+ cwd,
1953
+ toolCount: calls.length,
1954
+ });
1955
+ if (_batchDecision?.blocked === true) {
1956
+ const _reason = String(_batchDecision.reason || 'PostToolBatch hook blocked continuation').trim();
1957
+ if (_reason) {
1958
+ messages.push({ role: 'user', content: `<system-reminder>\n${_reason}\n</system-reminder>`, meta: 'hook' });
1959
+ }
1960
+ }
1961
+ } catch { /* best-effort: PostToolBatch hook must never break the loop */ }
1962
+ }
1963
+ }
2713
1964
  // Mid-turn steering is drained at the next loop's pre-send point,
2714
1965
  // AFTER any auto-compact pass. Draining here would put the steering
2715
1966
  // user turn after the fresh tool results before compaction runs; then