mixdog 0.9.92 → 0.9.94

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 (103) hide show
  1. package/README.md +147 -51
  2. package/package.json +6 -5
  3. package/scripts/code-graph-description-contract.mjs +6 -8
  4. package/scripts/tmp-cdp-errors.mjs +41 -0
  5. package/scripts/tmp-cdp-inspect.mjs +41 -0
  6. package/scripts/tui-transcript-jitter-harness.mjs +2 -18
  7. package/src/rules/agent/00-core.md +1 -2
  8. package/src/rules/agent/30-explorer.md +22 -16
  9. package/src/rules/lead/01-general.md +1 -0
  10. package/src/rules/shared/01-tool.md +27 -25
  11. package/src/runtime/agent/orchestrator/agent-runtime/cache-strategy.mjs +16 -3
  12. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +22 -6
  13. package/src/runtime/agent/orchestrator/agent-trace.mjs +17 -0
  14. package/src/runtime/agent/orchestrator/context/collect.mjs +8 -3
  15. package/src/runtime/agent/orchestrator/providers/anthropic-effort.mjs +9 -1
  16. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +79 -21
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +40 -19
  18. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +18 -1
  19. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +75 -15
  20. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +44 -8
  21. package/src/runtime/agent/orchestrator/providers/openai-ws-events.mjs +3 -0
  22. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +2 -0
  23. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +22 -5
  24. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +42 -2
  25. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +35 -29
  26. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +11 -2
  27. package/src/runtime/agent/orchestrator/session/loop/tool-classify.mjs +5 -6
  28. package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +31 -2
  29. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -0
  30. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +60 -31
  31. package/src/runtime/agent/orchestrator/session/manager.mjs +1 -1
  32. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +12 -3
  33. package/src/runtime/agent/orchestrator/session/store/listing.mjs +17 -0
  34. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +101 -0
  35. package/src/runtime/agent/orchestrator/session/store.mjs +30 -0
  36. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +119 -109
  37. package/src/runtime/agent/orchestrator/session/tool-result-offload.mjs +98 -3
  38. package/src/runtime/agent/orchestrator/stall-policy.mjs +31 -21
  39. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +3 -3
  40. package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +3 -3
  41. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +8 -2
  42. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +16 -10
  43. package/src/runtime/agent/orchestrator/tools/builtin/fuzzy-match.mjs +12 -3
  44. package/src/runtime/agent/orchestrator/tools/builtin/grep-formatting.mjs +22 -0
  45. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-context-expander.mjs +491 -0
  46. package/src/runtime/agent/orchestrator/tools/builtin/lib/grep-output.mjs +91 -13
  47. package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +90 -27
  48. package/src/runtime/agent/orchestrator/tools/builtin/path-utils.mjs +6 -1
  49. package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +1 -1
  50. package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +19 -9
  51. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +7 -2
  52. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +32 -4
  53. package/src/runtime/agent/orchestrator/tools/builtin/search-builders.mjs +16 -1
  54. package/src/runtime/agent/orchestrator/tools/builtin/search-tool.mjs +543 -19
  55. package/src/runtime/agent/orchestrator/tools/builtin/shell-analysis.mjs +5 -2
  56. package/src/runtime/agent/orchestrator/tools/builtin/shell-output.mjs +3 -3
  57. package/src/runtime/agent/orchestrator/tools/builtin/tool-output-limit.mjs +48 -0
  58. package/src/runtime/agent/orchestrator/tools/builtin.mjs +71 -1
  59. package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +4 -2
  60. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +51 -2
  61. package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +6 -17
  62. package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +2 -4
  63. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +4 -3
  64. package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +47 -14
  65. package/src/runtime/agent/orchestrator/tools/patch/dispatch.mjs +3 -3
  66. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +3 -3
  67. package/src/runtime/agent/orchestrator/tools/patch/v4a-convert.mjs +100 -0
  68. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +6 -5
  69. package/src/runtime/agent/orchestrator/tools/shell-command.mjs +9 -0
  70. package/src/runtime/agent/orchestrator/tools/shell-exec-output.mjs +1 -1
  71. package/src/runtime/agent/orchestrator/tools/shell-state.mjs +32 -2
  72. package/src/runtime/channels/backends/discord-gateway.mjs +6 -32
  73. package/src/runtime/channels/lib/inbound-handler.mjs +19 -3
  74. package/src/runtime/channels/lib/scheduler.mjs +51 -3
  75. package/src/runtime/channels/lib/worker-main.mjs +4 -0
  76. package/src/runtime/channels/tool-defs.mjs +4 -2
  77. package/src/runtime/memory/lib/query-handlers.mjs +11 -3
  78. package/src/runtime/memory/lib/tool-call-handler.mjs +16 -1
  79. package/src/runtime/memory/tool-defs.mjs +5 -5
  80. package/src/runtime/shared/background-tasks.mjs +10 -3
  81. package/src/runtime/shared/channel-notification-routing.mjs +8 -2
  82. package/src/runtime/shared/child-spawn-gate.mjs +50 -26
  83. package/src/runtime/shared/llm/http-agent.mjs +11 -0
  84. package/src/runtime/shared/task-notification-envelope.mjs +11 -2
  85. package/src/runtime/shared/tool-card-model.mjs +6 -2
  86. package/src/runtime/shared/tool-surface.mjs +7 -2
  87. package/src/session-runtime/lifecycle-api.mjs +26 -1
  88. package/src/session-runtime/provider-usage.mjs +26 -2
  89. package/src/session-runtime/tool-catalog-data.mjs +5 -2
  90. package/src/session-runtime/workflow.mjs +7 -5
  91. package/src/standalone/agent-tool/tag-registry.mjs +5 -1
  92. package/src/standalone/explore-tool.mjs +2 -2
  93. package/src/tui/app/use-transcript-window.mjs +7 -1
  94. package/src/tui/components/Spinner.jsx +18 -9
  95. package/src/tui/dist/index.mjs +179 -70
  96. package/src/tui/engine/agent-envelope.mjs +52 -3
  97. package/src/tui/engine/live-share.mjs +23 -3
  98. package/src/tui/engine/session-api.mjs +7 -0
  99. package/src/tui/engine/turn.mjs +85 -13
  100. package/src/tui/engine.mjs +32 -47
  101. package/src/tui/index.jsx +7 -0
  102. package/src/workflows/solo/WORKFLOW.md +0 -6
  103. package/src/workflows/solo-bench/WORKFLOW.md +17 -0
@@ -7,7 +7,10 @@
7
7
  * (scripts/openai-oauth-http-sse-toolcall-smoke.mjs) and fallback headers.
8
8
  */
9
9
  import { randomBytes } from 'crypto';
10
+ import zlib from 'node:zlib';
10
11
  import {
12
+ extractCacheWriteTokens,
13
+ extractCachedTokens,
11
14
  traceAgentFetch,
12
15
  traceAgentSse,
13
16
  traceAgentUsage,
@@ -47,6 +50,21 @@ const CODEX_REQUEST_MAX_RETRIES = 4;
47
50
  const CODEX_REQUEST_BACKOFF_MS = Object.freeze([200, 400, 800, 1600]);
48
51
  const CODEX_RETRY_JITTER_RATIO = 0.1;
49
52
 
53
+ // Request-body zstd gate (see sendViaHttpSse). Namespace import: on Node
54
+ // runtimes without zlib zstd bindings the named export would fail at module
55
+ // load, so the call site typeof-guards zlib.zstdCompressSync instead.
56
+ const OPENAI_REQ_ZSTD_MIN_BYTES = 8 * 1024;
57
+ let _openaiReqZstdLatch = false;
58
+ function _openaiReqZstdDisabled() {
59
+ return _openaiReqZstdLatch || process.env.MIXDOG_OPENAI_REQ_ZSTD === '0';
60
+ }
61
+ function _disableOpenaiReqZstd() { _openaiReqZstdLatch = true; }
62
+ function _zstdHeaders(headers, bodyForSend) {
63
+ return bodyForSend.encoding
64
+ ? { ...headers, 'Content-Encoding': bodyForSend.encoding }
65
+ : headers;
66
+ }
67
+
50
68
  export function _envFlag(name, fallback = true) {
51
69
  const raw = process.env[name];
52
70
  if (raw == null || raw === '') return fallback;
@@ -72,11 +90,6 @@ function _parseJsonObject(value) {
72
90
  }
73
91
  }
74
92
 
75
- function _extractCachedTokens(usage) {
76
- const details = usage?.input_tokens_details || usage?.prompt_tokens_details || {};
77
- return Number(details.cached_tokens ?? details.cached ?? usage?.cached_tokens ?? 0) || 0;
78
- }
79
-
80
93
  function _sseEventsFromBuffer(buffer) {
81
94
  const frames = [];
82
95
  let rest = buffer.replace(/\r\n/g, '\n');
@@ -231,6 +244,19 @@ export async function sendViaHttpSse({
231
244
  const responsesUrl = auth?.type === 'openai-direct'
232
245
  ? OPENAI_DIRECT_RESPONSES_URL
233
246
  : CODEX_RESPONSES_URL;
247
+ // Request-body zstd (codex parity: core client.rs
248
+ // responses_request_compression enables zstd for the codex backend on the
249
+ // OpenAI provider — the server decompresses Content-Encoding: zstd).
250
+ // openai-direct is excluded: only the codex backend is verified. Env
251
+ // kill-switch plus a process-wide latch flipped on the first 400 seen on
252
+ // a compressed request, which then replays that attempt uncompressed.
253
+ const _rawReqBytes = Buffer.from(JSON.stringify(body));
254
+ let _reqBodyForSend = auth?.type !== 'openai-direct'
255
+ && !_openaiReqZstdDisabled()
256
+ && typeof zlib.zstdCompressSync === 'function'
257
+ && _rawReqBytes.length >= OPENAI_REQ_ZSTD_MIN_BYTES
258
+ ? { bytes: zlib.zstdCompressSync(_rawReqBytes), encoding: 'zstd' }
259
+ : { bytes: _rawReqBytes, encoding: null };
234
260
  let response;
235
261
  for (let attempt = 0; attempt <= CODEX_REQUEST_MAX_RETRIES; attempt++) {
236
262
  const headerTimeout = createTimeoutSignal(
@@ -253,8 +279,8 @@ export async function sendViaHttpSse({
253
279
  } catch {}
254
280
  response = await fetchFn(responsesUrl, {
255
281
  method: 'POST',
256
- headers,
257
- body: JSON.stringify(body),
282
+ headers: _zstdHeaders(headers, _reqBodyForSend),
283
+ body: _reqBodyForSend.bytes,
258
284
  signal: headerTimeout.signal,
259
285
  dispatcher: getLlmDispatcher(),
260
286
  });
@@ -267,6 +293,15 @@ export async function sendViaHttpSse({
267
293
  headerTimeout.cleanup();
268
294
  }
269
295
 
296
+ // zstd rejection fallback: a 400 on a compressed request latches
297
+ // compression OFF process-wide and replays this attempt uncompressed.
298
+ if (response && response.status === 400 && _reqBodyForSend.encoding) {
299
+ _disableOpenaiReqZstd();
300
+ _reqBodyForSend = { bytes: _rawReqBytes, encoding: null };
301
+ await response.arrayBuffer().catch(() => {});
302
+ response = undefined;
303
+ continue;
304
+ }
270
305
  const retryableStatus = response && response.status >= 500 && response.status <= 599;
271
306
  // Typed transient transport failures only (errno / SDK connection
272
307
  // type). An unknown pre-response failure throws immediately instead of
@@ -783,7 +818,8 @@ export async function sendViaHttpSse({
783
818
  usage = {
784
819
  inputTokens: resp.usage.input_tokens || 0,
785
820
  outputTokens: resp.usage.output_tokens || 0,
786
- cachedTokens: _extractCachedTokens(resp.usage),
821
+ cachedTokens: extractCachedTokens(resp.usage),
822
+ cacheWriteTokens: extractCacheWriteTokens(resp.usage),
787
823
  promptTokens: resp.usage.input_tokens || 0,
788
824
  raw: serviceTier ? { ...resp.usage, service_tier: serviceTier } : resp.usage,
789
825
  };
@@ -39,6 +39,9 @@ export function _combineUsageWithWarmup(actual, warmup, { separateMainContext =
39
39
  inputTokens: _usageNum(actual.inputTokens) + _usageNum(warmup.inputTokens),
40
40
  outputTokens: _usageNum(actual.outputTokens) + _usageNum(warmup.outputTokens),
41
41
  cachedTokens: _usageNum(actual.cachedTokens) + _usageNum(warmup.cachedTokens),
42
+ ...(actual.cacheWriteTokens != null || warmup.cacheWriteTokens != null
43
+ ? { cacheWriteTokens: _usageNum(actual.cacheWriteTokens) + _usageNum(warmup.cacheWriteTokens) }
44
+ : {}),
42
45
  promptTokens: _usageNum(actual.promptTokens) + _usageNum(warmup.promptTokens),
43
46
  warmupInputTokens: _usageNum(warmup.inputTokens),
44
47
  warmupCachedTokens: _usageNum(warmup.cachedTokens),
@@ -16,6 +16,7 @@
16
16
  import { randomBytes } from 'crypto';
17
17
  import { performance } from 'node:perf_hooks';
18
18
  import {
19
+ extractCacheWriteTokens,
19
20
  extractCachedTokens,
20
21
  appendAgentTrace,
21
22
  } from '../agent-trace.mjs';
@@ -1090,6 +1091,7 @@ export async function _streamResponse({
1090
1091
  inputTokens: u.input_tokens || 0,
1091
1092
  outputTokens: u.output_tokens || 0,
1092
1093
  cachedTokens: extractCachedTokens(u),
1094
+ cacheWriteTokens: extractCacheWriteTokens(u),
1093
1095
  // openai-oauth reports input_tokens as the total
1094
1096
  // prompt volume (cached portion is a subset, not
1095
1097
  // additive). Alias into the cross-provider
@@ -227,7 +227,17 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
227
227
  // turn's push (deferBodies below) collapse to markers now — the model has
228
228
  // already seen them on that turn's follow-up send. Failed bodies stay
229
229
  // verbatim for retry.
230
- compactSettledToolCallBodies(messages);
230
+ // Out-of-loop transcript mutations (post-turn/manual compaction in
231
+ // manager/compaction-runner.mjs) run where no send opts exist; they park a
232
+ // one-shot intent on the session so the FIRST send of the next turn tags
233
+ // its expected cache break instead of an unexplained prefix mismatch.
234
+ if (!opts.cacheBreakIntent && typeof sessionRef?.pendingCacheBreakIntent === 'string') {
235
+ opts.cacheBreakIntent = sessionRef.pendingCacheBreakIntent;
236
+ delete sessionRef.pendingCacheBreakIntent;
237
+ }
238
+ if (compactSettledToolCallBodies(messages) && !opts.cacheBreakIntent) {
239
+ opts.cacheBreakIntent = 'deferred_body_compaction';
240
+ }
231
241
  // ---- Codex turn stop hook (refs/codex core/src/session/turn.rs:372-404) --
232
242
  // A no-tool assistant message is TERMINAL. Only a structured provider
233
243
  // follow-up signal (end_turn=false / pause_turn), pending input, tool
@@ -526,7 +536,10 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
526
536
  const _m = messages[_i];
527
537
  if (_m && _m.role === 'tool' && typeof _m.content === 'string' && _m.content.includes('⚠')) {
528
538
  const _stripped = stripSoftWarns(_m.content);
529
- if (_stripped !== _m.content) _m.content = _stripped;
539
+ if (_stripped !== _m.content) {
540
+ _m.content = _stripped;
541
+ if (!opts.cacheBreakIntent) opts.cacheBreakIntent = 'soft_warn_strip';
542
+ }
530
543
  }
531
544
  }
532
545
  sendTools = snapshotProviderRequestTools({
@@ -645,14 +658,16 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
645
658
  );
646
659
  const _sendEndedAt = Date.now();
647
660
  if (_sendResult.action === 'retry') {
648
- delete opts.cacheBreakIntent;
661
+ // Keep opts.cacheBreakIntent: the failed send never consumed the
662
+ // tag, and the reactive-compact retry that follows IS the tagged
663
+ // transition — deleting it here made retry-side cache_break rows
664
+ // log intentional_transition: null.
649
665
  contextOverflowRetryUsed = true;
650
666
  reactiveOverflowRetryPending = true;
651
667
  continue;
652
668
  }
653
669
  if (_sendResult.action === 'retry_transport') {
654
670
  _transportRetriesUsed += 1;
655
- delete opts.cacheBreakIntent;
656
671
  continue;
657
672
  }
658
673
  response = _sendResult.response;
@@ -1021,7 +1036,9 @@ export async function agentLoop(provider, messages, model, tools, onToolCall, cw
1021
1036
  // Settle earlier deferred bodies before this turn's message lands:
1022
1037
  // every previous call already has its result row, so successful bodies
1023
1038
  // compact to markers while failed ones keep their full retry text.
1024
- compactSettledToolCallBodies(messages);
1039
+ if (compactSettledToolCallBodies(messages) && !opts.cacheBreakIntent) {
1040
+ opts.cacheBreakIntent = 'deferred_body_compaction';
1041
+ }
1025
1042
  messages.push(_assistantTurnMsg);
1026
1043
  try { opts.onAssistantMessageCommitted?.(_assistantTurnMsg); } catch {}
1027
1044
  const _callsToExecute = calls;
@@ -5,6 +5,7 @@
5
5
  import { join, resolve as _pathResolve, isAbsolute as _pathIsAbs, normalize as _pathNorm } from 'node:path';
6
6
  import { writeJsonAtomicSync } from '../../../../shared/atomic-file.mjs';
7
7
  import { _normalizeCacheKey } from './util.mjs';
8
+ import { GREP_AUTO_CONTEXT_LINES } from '../../tools/builtin/path-utils.mjs';
8
9
 
9
10
  const MAX_PER_SESSION = 100;
10
11
 
@@ -43,6 +44,22 @@ function _firstArg(args, names) {
43
44
  return undefined;
44
45
  }
45
46
 
47
+ const _GREP_CONTEXT_GROUPS = [
48
+ ['-A', ['-A', 'A', 'after', 'after_context', 'afterContext', '--after-context', 'after-context', 'afterLines', 'after_lines']],
49
+ ['-B', ['-B', 'B', 'before', 'before_context', 'beforeContext', '--before-context', 'before-context', 'beforeLines', 'before_lines']],
50
+ ['context', ['context', '-C', 'C', 'context_lines', 'contextLines', '--context', 'contextN', 'around', 'surrounding']],
51
+ ];
52
+
53
+ function _canonicalizeGrepContextArgs(args) {
54
+ for (const [canonical, aliases] of _GREP_CONTEXT_GROUPS) {
55
+ const value = _firstArg(args, aliases);
56
+ for (const alias of aliases) delete args[alias];
57
+ if (value === undefined) continue;
58
+ const numeric = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
59
+ args[canonical] = Number.isFinite(numeric) && Number.isInteger(numeric) ? numeric : value;
60
+ }
61
+ }
62
+
46
63
  function _canonicalToolArgs(toolName, args) {
47
64
  if (!args || typeof args !== 'object') return args;
48
65
  const next = { ...args };
@@ -59,12 +76,35 @@ function _canonicalToolArgs(toolName, args) {
59
76
  const alias = _firstArg(next, ['root', 'directory', 'dir']);
60
77
  if (alias !== undefined) next.path = alias;
61
78
  }
79
+ _canonicalizeGrepContextArgs(next);
62
80
  if ((next.output_mode === undefined || next.output_mode === null || next.output_mode === '') && typeof next.mode === 'string') {
63
81
  const mode = next.mode.trim();
64
- if (['files_with_matches', 'content', 'count'].includes(mode)) next.output_mode = mode;
82
+ if (['files_with_matches', 'content', 'content_with_context', 'count'].includes(mode)) next.output_mode = mode;
65
83
  }
66
84
  for (const k of ['query', 'regex', 'regexp', 'needle', 'search', 'literal', 'file_pattern', 'filePattern', 'include', 'includes', 'files', 'root', 'directory', 'dir']) delete next[k];
67
- if (next.output_mode && next.mode === next.output_mode) delete next.mode;
85
+ delete next.mode;
86
+
87
+ // Canonicalize by execution semantics, not caller spelling:
88
+ // omitted/content_with_context => content + automatic 25-line context;
89
+ // context:0 => bare content; context flags are ignored in count/files.
90
+ const requestedMode = typeof next.output_mode === 'string' ? next.output_mode.trim() : '';
91
+ if (requestedMode === 'files_with_matches' || requestedMode === 'count') {
92
+ next.output_mode = requestedMode;
93
+ delete next['-A'];
94
+ delete next['-B'];
95
+ delete next.context;
96
+ } else {
97
+ const hasExplicitContext = ['-A', '-B', 'context']
98
+ .some((key) => Object.prototype.hasOwnProperty.call(next, key));
99
+ next.output_mode = 'content';
100
+ if ((requestedMode === '' || requestedMode === 'content_with_context') && !hasExplicitContext) {
101
+ next.context = GREP_AUTO_CONTEXT_LINES;
102
+ }
103
+ if (next.context === 0 && !Object.prototype.hasOwnProperty.call(next, '-A')
104
+ && !Object.prototype.hasOwnProperty.call(next, '-B')) {
105
+ delete next.context;
106
+ }
107
+ }
68
108
  } else if (toolName === 'glob') {
69
109
  if (next.pattern === undefined || next.pattern === null || next.pattern === '') {
70
110
  const alias = _firstArg(next, ['glob', 'file_pattern', 'filePattern', 'name', 'include', 'includes', 'files']);
@@ -1,18 +1,18 @@
1
1
  // Eager tool-dispatch controller, extracted from agent-loop.mjs. Owns the
2
2
  // per-turn pending promise map, the intra-turn in-flight signature set, and
3
- // the mutation epoch. FULL-PARALLEL policy: every tool call starts executing
4
- // the instant the provider streams its tool_use event (or at batch start),
5
- // shell/MCP/writes included — the model owns ordering by splitting dependent
6
- // work into separate turns. Only apply_patch (ordered mutation) waits for the
7
- // serial batch loop.
3
+ // the mutation epoch. Read-only calls may start while the provider is still
4
+ // streaming. Side-effect calls stream-start too until the first apply_patch
5
+ // appears in the stream (they are definitively in segment 0); after that they
6
+ // wait for their segment bounded by apply_patch barriers. Patches execute
7
+ // serially as barriers, so a failed patch can skip every later side effect.
8
8
  import { normalizeToolEnvelope } from './tool-envelope.mjs';
9
9
  import { isInvalidToolArgsMarker } from '../providers/openai-compat-stream.mjs';
10
- import { _intraTurnSig, _isMutationTool, _isReadTool, _isScopedCacheableTool, _isShellTool, _stripMcpPrefix } from './loop/tool-classify.mjs';
10
+ import { _intraTurnSig, _isMutationTool, _isOrderedGateSkippable, _isReadTool, _isScopedCacheableTool, _stripMcpPrefix } from './loop/tool-classify.mjs';
11
11
  import { tryReadCached, tryScopedToolCached } from './read-dedup.mjs';
12
12
  import { preDispatchDenyForSession } from './loop/pre-dispatch-deny.mjs';
13
13
  import { executeTool } from './loop/tool-exec.mjs';
14
14
  import { crossTurnSignature } from './loop/completion-guards.mjs';
15
- import { getToolKind, isParallelDispatchable, isToolCallDedupEligible } from './loop/tool-helpers.mjs';
15
+ import { getToolKind, isEagerDispatchable, isParallelDispatchable, isToolCallDedupEligible } from './loop/tool-helpers.mjs';
16
16
 
17
17
  export function createEagerDispatcher({
18
18
  tools, cwd, sessionId, sessionRef, signal, opts,
@@ -40,18 +40,11 @@ export function createEagerDispatcher({
40
40
  // resets at the turn boundary without leaking across getIterations().
41
41
  const _eagerInFlightSigs = new Map();
42
42
  const epoch = { mutation: 0 };
43
- // Patch→shell ordering insurance: a shell call that appears AFTER an
44
- // apply_patch in the same assistant turn must not eager-start before
45
- // that patch has executed (serial body runs both in call order).
46
- // Reads are already safe via the mutationEpoch re-execution gate;
47
- // only shell's side effects would consume pre-patch file state.
48
- let _streamSawMutation = false;
49
- const _hasEarlierMutation = (calls, index) => {
50
- for (let k = 0; k < index; k += 1) {
51
- if (_isMutationTool(calls[k]?.name)) return true;
52
- }
53
- return false;
54
- };
43
+ // True once an apply_patch tool_use has been seen in THIS turn's
44
+ // stream. Before that, every side-effect call streamed so far sits
45
+ // before the first patch in call order (segment 0), so it may start
46
+ // immediately — a patch that arrives later cannot gate an EARLIER call.
47
+ let _streamSeenMutation = false;
55
48
  const startEagerTool = (call) => {
56
49
  if (!call?.id || pending.has(call.id) || !isParallelDispatchable(call.name)) return null;
57
50
  // Never eager-execute a call whose arguments failed to parse
@@ -107,7 +100,7 @@ export function createEagerDispatcher({
107
100
  if (_dedupEligible) _eagerInFlightSigs.set(_sig, call.id);
108
101
  entry.promise = (async () => {
109
102
  try {
110
- return { ok: true, value: await executeToolFn(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: getNextIteration() }) };
103
+ return { ok: true, value: await executeToolFn(call.name, call.arguments, cwd, sessionId, sessionRef, { toolCallId: call.id, signal, notifyFn: opts.notifyFn, toolApprovalHook: opts.onToolApproval, iteration: getNextIteration(), deferShellCwdCommit: true }) };
111
104
  } catch (error) {
112
105
  return { ok: false, error };
113
106
  }
@@ -168,16 +161,21 @@ export function createEagerDispatcher({
168
161
  return entry;
169
162
  };
170
163
  const startEagerRun = (calls, startIndex, dupSet) => {
164
+ const _nextOrderedMutationIndex = calls.findIndex(
165
+ (call, index) => index >= startIndex && _isMutationTool(call?.name),
166
+ );
171
167
  for (let j = startIndex; j < calls.length; j += 1) {
172
168
  const call = calls[j];
173
- // Full-parallel: only the ordered mutation (apply_patch) is
174
- // skipped — it executes in the serial batch body. No barrier:
175
- // later calls keep starting in parallel past it.
169
+ // Side effects may eager-start in the current segment but not
170
+ // across the next apply_patch barrier. Known read-only work
171
+ // may cross it and is protected by mutation-epoch re-execution.
176
172
  if (!call?.id || !isParallelDispatchable(call.name)) continue;
177
173
  if (dupSet && dupSet.has(call.id)) continue;
178
- // Patch→shell insurance: leave a shell that follows an
179
- // apply_patch to the serial body so it runs after the patch.
180
- if (_isShellTool(call.name) && _hasEarlierMutation(calls, j)) continue;
174
+ if (
175
+ _nextOrderedMutationIndex >= 0
176
+ && j >= _nextOrderedMutationIndex
177
+ && _isOrderedGateSkippable(call.name)
178
+ ) continue;
181
179
  // A null return here is NOT a state barrier. It means a
182
180
  // non-barrier stub — intra-turn in-flight dup, repeat-failure /
183
181
  // cross-turn dedup, pre-dispatch-deny, invalid-args, or a cache
@@ -188,9 +186,17 @@ export function createEagerDispatcher({
188
186
  }
189
187
  };
190
188
  const onToolCall = (call) => {
191
- if (_isMutationTool(call?.name)) { _streamSawMutation = true; return; }
192
- if (!isParallelDispatchable(call?.name)) return;
193
- if (_streamSawMutation && _isShellTool(call.name)) return;
189
+ if (_isMutationTool(call?.name)) {
190
+ _streamSeenMutation = true;
191
+ return;
192
+ }
193
+ // Declared read-only calls always overlap streaming (epoch guard
194
+ // re-executes them if a later patch lands). Side-effect calls may
195
+ // stream-start only while NO apply_patch has streamed yet: their
196
+ // call-order position is already fixed before the first barrier,
197
+ // matching exactly what startEagerRun would do post-batch. Once a
198
+ // patch has streamed, later side effects wait for their segment.
199
+ if (!isEagerDispatchable(call?.name, tools) && _streamSeenMutation) return;
194
200
  startEagerTool(call);
195
201
  };
196
202
  return { pending, epoch, startEagerTool, startEagerRun, onToolCall };
@@ -59,6 +59,9 @@ function compactStoredToolArgString(value, key = '', opts = {}) {
59
59
  const isLong = isBody || STORED_TOOL_ARG_LONG_KEY_RE.test(key);
60
60
  const limit = isLong ? STORED_TOOL_ARG_LIMIT : Infinity;
61
61
  if (value.length <= limit) return value;
62
+ // A marker is about to replace verbatim text — report the mutation so
63
+ // sweep callers can tag the resulting prefix-cache break.
64
+ try { opts.onCompacted?.(); } catch { /* observability only */ }
62
65
  const hash = createHash('sha256').update(value).digest('hex').slice(0, 16);
63
66
  // Body markers carry the recovery instruction inline: the compaction
64
67
  // detectors only require the `[mixdog compacted ...]` shape (no ']' or
@@ -115,14 +118,19 @@ export function compactToolCallsForHistory(calls, opts = {}) {
115
118
  // contract as restoreToolCallBodyForId);
116
119
  // - a call with no result row yet (current batch / interrupted turn) is
117
120
  // left untouched.
121
+ // Returns true when at least one body was actually collapsed to a marker
122
+ // (i.e. the transcript prefix changed), so callers can tag the intentional
123
+ // cache break instead of logging an unexplained input_prefix_mismatch.
118
124
  export function compactSettledToolCallBodies(messages) {
119
- if (!Array.isArray(messages)) return;
125
+ if (!Array.isArray(messages)) return false;
120
126
  const resultKinds = new Map();
121
127
  for (const message of messages) {
122
128
  if (message?.role === 'tool' && message.toolCallId) {
123
129
  resultKinds.set(message.toolCallId, message.toolKind || 'normal');
124
130
  }
125
131
  }
132
+ let changed = false;
133
+ const sweepOpts = { onCompacted: () => { changed = true; } };
126
134
  for (const message of messages) {
127
135
  if (message?.role !== 'assistant' || !Array.isArray(message.toolCalls)) continue;
128
136
  for (const call of message.toolCalls) {
@@ -130,9 +138,10 @@ export function compactSettledToolCallBodies(messages) {
130
138
  if (!call.arguments || typeof call.arguments !== 'object') continue;
131
139
  const kind = call.id ? resultKinds.get(call.id) : undefined;
132
140
  if (kind === undefined || kind === 'error') continue;
133
- call.arguments = compactStoredToolArgValue(call.arguments);
141
+ call.arguments = compactStoredToolArgValue(call.arguments, '', 0, sweepOpts);
134
142
  }
135
143
  }
144
+ return changed;
136
145
  }
137
146
 
138
147
  // Restore retry-safe long command/script text for ONE failed tool call inside a
@@ -15,12 +15,11 @@ export function _isMutationTool(name) {
15
15
  const n = _stripMcpPrefix(name);
16
16
  return n === 'apply_patch';
17
17
  }
18
- // Side-effect-free read-only tools that stay parallel even after an earlier
19
- // ordered mutation failed in the same batch. Everything NOT in this set is
20
- // treated as ordered-gate-skippable (see _isOrderedGateSkippable): apply_patch,
21
- // shell/bash_session, write/edit-style tools, and any (non-mixdog) MCP tool
22
- // whose effects are unknown. Kept separate from _isMutationTool, which stays
23
- // apply_patch-only for epoch-mutation counting and eager-dispatch gating.
18
+ // Side-effect-free read-only tools that may eager-start across an upcoming
19
+ // apply_patch barrier and may keep running after an earlier patch fails.
20
+ // Everything NOT in this set waits for its side-effect segment and is skipped
21
+ // after a failed patch. Kept separate from _isMutationTool, which stays
22
+ // apply_patch-only for epoch counting.
24
23
  const ORDERED_GATE_SAFE_READONLY_TOOLS = new Set([
25
24
  'read',
26
25
  'find',
@@ -226,8 +226,37 @@ export async function executeTool(name, args, cwd, callerSessionId, sessionRef,
226
226
  return result;
227
227
  }
228
228
  if (name === 'apply_patch') {
229
- const patchArgs = typeof args === 'string' ? { patch: args } : args;
230
- return executePatchTool(name, patchArgs, cwd, { sessionId: callerSessionId, toolCallId: executeOpts.toolCallId || null });
229
+ const patchArgs = typeof args === 'string' ? { patch: args } : { ...(args || {}) };
230
+ // post_shell: optional verification command executed through the normal
231
+ // one-shot shell path ONLY after the patch applies cleanly; a failed
232
+ // patch skips it. Patch text and shell output return as ONE tool
233
+ // result, so patch+verify costs a single call. Runtime-only knob:
234
+ // stripped before executePatchTool sees the args.
235
+ const postShell = typeof patchArgs.post_shell === 'string' && patchArgs.post_shell.trim()
236
+ ? patchArgs.post_shell.trim()
237
+ : null;
238
+ delete patchArgs.post_shell;
239
+ const patchResult = await executePatchTool(name, patchArgs, cwd, {
240
+ sessionId: callerSessionId,
241
+ toolCallId: executeOpts.toolCallId || null,
242
+ });
243
+ if (!postShell) return patchResult;
244
+ const patchNorm = normalizeToolEnvelope(patchResult);
245
+ const patchText = typeof patchNorm.result === 'string' ? patchNorm.result : String(patchNorm.result ?? '');
246
+ // Text-based failure detection only: legacy string returns normalize
247
+ // to explicitSuccess:false even on success, so that flag is unusable here.
248
+ const patchFailed = /^Error[\s:[]/.test(patchText.trimStart());
249
+ if (patchFailed) {
250
+ return `${patchText}\n--- post_shell skipped: patch failed ---`;
251
+ }
252
+ const shellRes = await executeBuiltinTool('shell', { command: postShell }, cwd, completionToolOpts);
253
+ const shellNorm = normalizeToolEnvelope(shellRes);
254
+ const shellText = typeof shellNorm.result === 'string' ? shellNorm.result : String(shellNorm.result ?? '');
255
+ const shellFailed = /^Error[\s:[]/.test(shellText.trimStart());
256
+ const header = shellFailed
257
+ ? '--- post_shell FAILED (patch is applied; fix and re-verify) ---'
258
+ : '--- post_shell ---';
259
+ return `${patchText}\n\n${header}\n${shellText}`;
231
260
  }
232
261
  if (isBuiltinTool(name)) {
233
262
  // clientHostPid threaded for the same per-terminal job-scope reason as
@@ -25,6 +25,7 @@ import {
25
25
  compactTypeForSession,
26
26
  } from './context-meta.mjs';
27
27
  import { resolveSemanticSummaryModel } from '../loop/compact-policy.mjs';
28
+ import { traceAgentCompact, messagePrefixHash } from '../../agent-trace.mjs';
28
29
  import { uncachedInputTokensForProvider } from './usage-metrics.mjs';
29
30
  import { pruneOffloadSession } from '../tool-result-offload.mjs';
30
31
  import { _getPendingMessagesForSession } from './pending-messages.mjs';
@@ -302,6 +303,7 @@ export async function runSessionCompaction(session, opts = {}) {
302
303
  semanticCompact: false,
303
304
  };
304
305
  const budget = targetBudgetTokens;
306
+ const compactStartedAt = Date.now();
305
307
  try { await opts.onStageChange?.('compacting'); } catch { /* best-effort */ }
306
308
  const provider = opts.provider || getProvider(session.provider) || null;
307
309
  let compacted;
@@ -486,6 +488,31 @@ export async function runSessionCompaction(session, opts = {}) {
486
488
  lastRecallFastTrackError: recallFastTrackError?.message || null,
487
489
  lastError: compactError?.message || semanticCompactError?.message || recallFastTrackError?.message || String(compactError || semanticCompactError || recallFastTrackError || 'compact failed'),
488
490
  };
491
+ // compact_meta parity with the loop's pre-send pass: the out-of-loop
492
+ // (post-turn/manual) compaction failure was previously invisible to
493
+ // trace analytics.
494
+ traceAgentCompact({
495
+ sessionId: opts.sessionId || session.id || null,
496
+ stage: mode === 'auto' ? 'post_turn' : 'manual',
497
+ trigger: mode,
498
+ compact_type: compactType,
499
+ compact_changed: false,
500
+ before_count: messages.length,
501
+ after_count: messages.length,
502
+ context_window: positiveContextWindow(session.contextWindow) || null,
503
+ budget_tokens: boundary,
504
+ boundary_tokens: boundary,
505
+ target_budget_tokens: budget,
506
+ reserve_tokens: reserveTokens,
507
+ pressure_tokens: pressureTokens,
508
+ trigger_tokens: triggerTokens,
509
+ message_tokens_est: beforeMessageTokens,
510
+ duration_ms: Date.now() - compactStartedAt,
511
+ provider: session.provider || null,
512
+ model: session.model || null,
513
+ error: session.compaction.lastError,
514
+ error_code: 'compact_failed',
515
+ });
489
516
  return {
490
517
  changed: false,
491
518
  error: session.compaction.lastError,
@@ -574,6 +601,39 @@ export async function runSessionCompaction(session, opts = {}) {
574
601
  compactCount: (session.compaction?.compactCount || 0) + (changed ? 1 : 0),
575
602
  };
576
603
  if (changed) invalidateProviderContextBaseline(session);
604
+ // Observability parity with the loop's pre-send pass: record the
605
+ // out-of-loop mutation as compact_meta and park a one-shot intent so the
606
+ // next turn's first send tags its cache break instead of logging an
607
+ // unexplained input_prefix_mismatch (observed live: a 403k→10k post-turn
608
+ // compact traced as intentional_transition: null with no compact_meta).
609
+ let beforePrefixHash = null;
610
+ try { beforePrefixHash = messagePrefixHash(messages); } catch { /* best-effort */ }
611
+ traceAgentCompact({
612
+ sessionId: pruneSessionId || null,
613
+ stage: mode === 'auto' ? 'post_turn' : 'manual',
614
+ trigger: mode,
615
+ compact_type: compactType,
616
+ compact_changed: changed,
617
+ input_prefix_hash: beforePrefixHash,
618
+ before_count: messages.length,
619
+ after_count: compacted.length,
620
+ before_bytes: beforeEncoded ? Buffer.byteLength(beforeEncoded, 'utf8') : null,
621
+ after_bytes: afterEncoded ? Buffer.byteLength(afterEncoded, 'utf8') : null,
622
+ context_window: positiveContextWindow(session.contextWindow) || null,
623
+ budget_tokens: boundary,
624
+ boundary_tokens: boundary,
625
+ target_budget_tokens: budget,
626
+ reserve_tokens: reserveTokens,
627
+ pressure_tokens: pressureTokens,
628
+ trigger_tokens: triggerTokens,
629
+ message_tokens_est: beforeMessageTokens,
630
+ duration_ms: Date.now() - compactStartedAt,
631
+ provider: session.provider || null,
632
+ model: session.model || null,
633
+ });
634
+ if (changed) {
635
+ session.pendingCacheBreakIntent = mode === 'auto' ? 'post_turn_compaction' : 'manual_compaction';
636
+ }
577
637
  return {
578
638
  changed,
579
639
  reason: unchangedReason,