mixdog 0.9.19 → 0.9.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -34
  3. package/package.json +3 -2
  4. package/scripts/build-tui.mjs +6 -0
  5. package/scripts/hook-bus-test.mjs +8 -0
  6. package/scripts/log-writer-guard-smoke.mjs +131 -0
  7. package/scripts/reactive-compact-persist-smoke.mjs +22 -6
  8. package/scripts/recall-bench-cases.json +0 -1
  9. package/scripts/recall-quality-cases.json +1 -2
  10. package/scripts/session-ingest-compaction-smoke.mjs +241 -0
  11. package/scripts/tool-smoke.mjs +150 -45
  12. package/src/defaults/skills/setup/SKILL.md +327 -0
  13. package/src/help.mjs +2 -5
  14. package/src/lib/mixdog-debug.cjs +13 -0
  15. package/src/mixdog-session-runtime.mjs +7 -3328
  16. package/src/runtime/agent/orchestrator/context/collect.mjs +33 -9
  17. package/src/runtime/agent/orchestrator/providers/anthropic.mjs +34 -2
  18. package/src/runtime/agent/orchestrator/providers/gemini.mjs +3 -0
  19. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +67 -10
  20. package/src/runtime/agent/orchestrator/providers/openai-compat.mjs +3 -0
  21. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +40 -3
  22. package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +73 -3
  23. package/src/runtime/agent/orchestrator/session/agent-loop.mjs +663 -0
  24. package/src/runtime/agent/orchestrator/session/compact/engine.mjs +6 -0
  25. package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +153 -0
  26. package/src/runtime/agent/orchestrator/session/loop/recall-fasttrack.mjs +49 -0
  27. package/src/runtime/agent/orchestrator/session/loop/stored-tool-args.mjs +9 -1
  28. package/src/runtime/agent/orchestrator/session/loop.mjs +8 -1860
  29. package/src/runtime/agent/orchestrator/session/manager/agent-runtime-singleton.mjs +29 -0
  30. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +686 -0
  31. package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +60 -12
  32. package/src/runtime/agent/orchestrator/session/manager/env-utils.mjs +8 -0
  33. package/src/runtime/agent/orchestrator/session/manager/idle-cleanup.mjs +159 -0
  34. package/src/runtime/agent/orchestrator/session/manager/message-sanitize.mjs +143 -0
  35. package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +268 -0
  36. package/src/runtime/agent/orchestrator/session/manager/provider-cache-key.mjs +22 -0
  37. package/src/runtime/agent/orchestrator/session/manager/runtime-loaders.mjs +26 -0
  38. package/src/runtime/agent/orchestrator/session/manager/session-close.mjs +124 -0
  39. package/src/runtime/agent/orchestrator/session/manager/session-crud.mjs +258 -0
  40. package/src/runtime/agent/orchestrator/session/manager/session-errors.mjs +20 -0
  41. package/src/runtime/agent/orchestrator/session/manager/session-id.mjs +9 -0
  42. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +475 -0
  43. package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +23 -0
  44. package/src/runtime/agent/orchestrator/session/manager.mjs +88 -2285
  45. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +440 -0
  46. package/src/runtime/agent/orchestrator/session/send-with-recovery.mjs +153 -0
  47. package/src/runtime/agent/orchestrator/session/store.mjs +4 -1
  48. package/src/runtime/agent/orchestrator/session/tool-batch.mjs +642 -0
  49. package/src/runtime/agent/orchestrator/tools/bash-session.mjs +23 -3
  50. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-paths.mjs +108 -2
  51. package/src/runtime/agent/orchestrator/tools/builtin/shell-job-process.mjs +15 -3
  52. package/src/runtime/agent/orchestrator/tools/builtin/shell-jobs.mjs +7 -0
  53. package/src/runtime/agent/orchestrator/tools/builtin/shell-runtime.mjs +24 -2
  54. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +2 -2
  55. package/src/runtime/agent/orchestrator/tools/patch/parsing.mjs +72 -8
  56. package/src/runtime/agent/orchestrator/tools/shell-policy-danger-target.mjs +5 -1
  57. package/src/runtime/channels/index.mjs +6 -2183
  58. package/src/runtime/channels/lib/inbound-handler.mjs +328 -0
  59. package/src/runtime/channels/lib/interaction-handlers.mjs +260 -0
  60. package/src/runtime/channels/lib/network-retry.mjs +23 -0
  61. package/src/runtime/channels/lib/owned-runtime.mjs +627 -0
  62. package/src/runtime/channels/lib/runtime-paths.mjs +20 -9
  63. package/src/runtime/channels/lib/transcript-binding.mjs +336 -0
  64. package/src/runtime/channels/lib/voice-runtime-fetcher.mjs +39 -2
  65. package/src/runtime/channels/lib/worker-bootstrap.mjs +97 -0
  66. package/src/runtime/channels/lib/worker-ipc.mjs +201 -0
  67. package/src/runtime/channels/lib/worker-main.mjs +777 -0
  68. package/src/runtime/memory/index.mjs +163 -1725
  69. package/src/runtime/memory/lib/embedding-provider.mjs +4 -2
  70. package/src/runtime/memory/lib/embedding-worker.mjs +47 -6
  71. package/src/runtime/memory/lib/http-router.mjs +811 -0
  72. package/src/runtime/memory/lib/memory-action-handlers.mjs +901 -0
  73. package/src/runtime/memory/lib/memory-cycle2-gate.mjs +6 -0
  74. package/src/runtime/memory/lib/memory-cycle2-mutations.mjs +46 -9
  75. package/src/runtime/memory/lib/memory-cycle2.mjs +87 -11
  76. package/src/runtime/memory/lib/memory-embed.mjs +28 -7
  77. package/src/runtime/memory/lib/memory-recall-store.mjs +4 -4
  78. package/src/runtime/memory/lib/memory.mjs +39 -0
  79. package/src/runtime/memory/lib/query-handlers.mjs +2 -2
  80. package/src/runtime/memory/lib/session-ingest-runtime.mjs +401 -0
  81. package/src/runtime/shared/atomic-file.mjs +138 -80
  82. package/src/runtime/shared/child-guardian.mjs +61 -3
  83. package/src/session-runtime/boot-profile.mjs +36 -0
  84. package/src/session-runtime/channel-config-api.mjs +70 -0
  85. package/src/session-runtime/context-status.mjs +181 -0
  86. package/src/session-runtime/env.mjs +17 -0
  87. package/src/session-runtime/lifecycle-api.mjs +242 -0
  88. package/src/session-runtime/model-route-api.mjs +198 -0
  89. package/src/session-runtime/provider-auth-api.mjs +135 -0
  90. package/src/session-runtime/resource-api.mjs +282 -0
  91. package/src/session-runtime/runtime-core.mjs +2104 -0
  92. package/src/session-runtime/session-turn-api.mjs +274 -0
  93. package/src/session-runtime/tool-catalog.mjs +18 -264
  94. package/src/session-runtime/tool-defs.mjs +2 -2
  95. package/src/session-runtime/workflow-agents-api.mjs +238 -0
  96. package/src/standalone/agent-tool.mjs +2 -2
  97. package/src/standalone/channel-worker.mjs +67 -7
  98. package/src/standalone/folder-dialog.mjs +4 -1
  99. package/src/standalone/memory-runtime-proxy.mjs +154 -17
  100. package/src/standalone/seeds.mjs +28 -1
  101. package/src/tui/App.jsx +40 -28
  102. package/src/tui/app/core-memory-picker.mjs +1 -1
  103. package/src/tui/app/doctor.mjs +175 -0
  104. package/src/tui/app/slash-commands.mjs +1 -0
  105. package/src/tui/app/slash-dispatch.mjs +9 -0
  106. package/src/tui/app/use-mouse-input.mjs +6 -0
  107. package/src/tui/app/use-transcript-scroll.mjs +77 -3
  108. package/src/tui/dist/index.mjs +2851 -2162
  109. package/src/tui/engine/context-state.mjs +145 -0
  110. package/src/tui/engine/prompt-history.mjs +27 -0
  111. package/src/tui/engine/session-api-ext.mjs +478 -0
  112. package/src/tui/engine/session-api.mjs +564 -0
  113. package/src/tui/engine/session-flow.mjs +485 -0
  114. package/src/tui/engine/turn.mjs +1078 -0
  115. package/src/tui/engine.mjs +68 -2620
  116. package/src/tui/index.jsx +7 -0
  117. package/vendor/ink/build/ink.js +16 -1
  118. package/vendor/ink/build/output.js +30 -4
  119. package/vendor/ink/build/render.js +5 -0
  120. package/src/workflows/sequential/WORKFLOW.md +0 -51
@@ -0,0 +1,440 @@
1
+ // Pre-send auto-compact pass, extracted verbatim from agent-loop.mjs (was
2
+ // loop.mjs). Runs the proactive/reactive compaction decision + execution
3
+ // before each provider.send. Threaded via a state bag so the loop keeps its
4
+ // mutable counters (iterations reset on compaction, usage accumulation, the
5
+ // reactive-overflow flag, providerState invalidation). Behavior identical to
6
+ // the inline block it replaced.
7
+ import {
8
+ resolveWorkerCompactPolicy,
9
+ compactionTelemetryPressureTokens,
10
+ compactTargetBudget,
11
+ shouldCompactForSession,
12
+ countPrunedToolOutputs,
13
+ rememberCompactTelemetry,
14
+ emitCompactEvent,
15
+ compactEventType,
16
+ } from './loop/compact-policy.mjs';
17
+ import {
18
+ pruneToolOutputs,
19
+ pruneToolOutputsUnanchored,
20
+ semanticCompactMessages,
21
+ effectiveBudget as compactEffectiveBudget,
22
+ DEFAULT_COMPACT_TYPE,
23
+ } from './compact.mjs';
24
+ import { runRecallFastTrackCompact } from './loop/recall-fasttrack.mjs';
25
+ import { estimateMessagesTokensSafe } from './loop/compact-debug.mjs';
26
+ import { messagesArrayChanged } from './loop/tool-helpers.mjs';
27
+ import { normalizeUsage, addUsage } from './loop/usage.mjs';
28
+ import { agentContextOverflowError } from './loop/context-overflow.mjs';
29
+ import { traceAgentCompact, messagePrefixHash } from '../agent-trace.mjs';
30
+ import { bumpUsageMetricsEpoch } from './manager.mjs';
31
+
32
+ export async function runPreSendCompactPass(state) {
33
+ const {
34
+ provider, messages, model, tools, sessionRef, sessionId, cwd, opts, signal,
35
+ loopUsageMetricsTurnId, loopUsageMetricsEpoch,
36
+ } = state;
37
+ let { iterations, lastUsage, firstTurnUsage, providerState, reactiveOverflowRetryPending } = state;
38
+ const compactPolicy = resolveWorkerCompactPolicy(sessionRef, tools);
39
+ if (compactPolicy?.auto) {
40
+ // Snapshot pre-compact shape so compact_meta can record the actual
41
+ // mutation (or no-op) for prefix-mutation forensics. Bytes are
42
+ // a best-effort JSON.stringify length — close enough to the
43
+ // payload we hand the provider for prefix-cache analysis.
44
+ const beforeCount = messages.length;
45
+ // beforeBytes is only ever read inside the shouldCompact telemetry
46
+ // branches below. Computing it eagerly serialized the ENTIRE message
47
+ // array (Buffer.byteLength(JSON.stringify(messages))) on every loop
48
+ // iteration — including the common no-compact path — which grows
49
+ // linearly with transcript size and was a real per-iteration drag.
50
+ // Defer it to a memoized lazy getter so the no-compact path pays
51
+ // nothing and the compact path still gets an exact byte count once.
52
+ let _beforeBytes;
53
+ let _beforeBytesComputed = false;
54
+ const getBeforeBytes = () => {
55
+ if (_beforeBytesComputed) return _beforeBytes;
56
+ _beforeBytesComputed = true;
57
+ try { _beforeBytes = Buffer.byteLength(JSON.stringify(messages), 'utf8'); } catch { _beforeBytes = null; }
58
+ return _beforeBytes;
59
+ };
60
+ const messageTokensEst = estimateMessagesTokensSafe(messages);
61
+ const reactivePending = reactiveOverflowRetryPending === true;
62
+ const shouldCompact = shouldCompactForSession(messageTokensEst, compactPolicy, { forceReactive: reactivePending });
63
+ const pressureTokens = compactionTelemetryPressureTokens(messageTokensEst, compactPolicy, { reactivePending });
64
+ // A pending reactive-overflow retry makes THIS compact pass the
65
+ // recovery from a provider overflow refusal, not the proactive
66
+ // pressure trigger. Tag the emitted events so telemetry can tell
67
+ // them apart. Hoisted above the shouldCompact branch because the
68
+ // PostCompact hook below fires on BOTH paths (fixes a
69
+ // ReferenceError on the no-compact path).
70
+ const compactTrigger = reactivePending ? 'reactive' : 'auto';
71
+ const compactBudgetTokens = shouldCompact
72
+ ? (compactTargetBudget({ ...compactPolicy, pressureTokens }) || compactPolicy.boundaryTokens)
73
+ : compactPolicy.boundaryTokens;
74
+ if (!shouldCompact) {
75
+ rememberCompactTelemetry(sessionRef, compactPolicy, {
76
+ stage: 'pre_send_check',
77
+ beforeTokens: messageTokensEst,
78
+ afterTokens: messageTokensEst,
79
+ pressureTokens,
80
+ });
81
+ } else {
82
+ try { await opts.onStageChange?.('compacting'); } catch { /* best-effort */ }
83
+ const compactStartedAt = Date.now();
84
+ // Clear the one-shot reactive-overflow flag now that this
85
+ // compact pass is consuming it (compactTrigger already
86
+ // captured it above).
87
+ reactiveOverflowRetryPending = false;
88
+ // PreCompact: bridge to the standard hook bus before compaction
89
+ // runs. session-property hook (manager/loop have no bus access).
90
+ // { trigger } normalized to 'auto'|'manual'. Best-effort.
91
+ {
92
+ const _preCompactHook = typeof opts.preCompactHook === 'function'
93
+ ? opts.preCompactHook
94
+ : sessionRef?.preCompactHook;
95
+ if (typeof _preCompactHook === 'function') {
96
+ try { await _preCompactHook({ sessionId, cwd, trigger: compactTrigger === 'manual' ? 'manual' : 'auto' }); }
97
+ catch { /* best-effort: PreCompact hook must never break compaction */ }
98
+ }
99
+ }
100
+ rememberCompactTelemetry(sessionRef, compactPolicy, {
101
+ stage: 'compacting',
102
+ beforeTokens: messageTokensEst,
103
+ afterTokens: messageTokensEst,
104
+ pressureTokens,
105
+ trigger: compactTrigger,
106
+ });
107
+ let compacted;
108
+ let pruneCount = 0;
109
+ let summaryChanged = false;
110
+ let semanticCompactResult = null;
111
+ let semanticCompactError = null;
112
+ let recallFastTrackResult = null;
113
+ let recallFastTrackError = null;
114
+ try {
115
+ let compactInputMessages = messages;
116
+ if (compactPolicy.prune) {
117
+ const pruned = pruneToolOutputs(messages, compactPolicy.boundaryTokens, {
118
+ reserveTokens: compactPolicy.reserveTokens,
119
+ });
120
+ pruneCount = countPrunedToolOutputs(messages, pruned);
121
+ compactInputMessages = pruned;
122
+ }
123
+ if (compactPolicy.recallFastTrack) {
124
+ try {
125
+ recallFastTrackResult = await runRecallFastTrackCompact({
126
+ sessionRef,
127
+ messages: compactInputMessages,
128
+ compactBudgetTokens,
129
+ compactPolicy,
130
+ sessionId,
131
+ signal,
132
+ });
133
+ const recallMessages = Array.isArray(recallFastTrackResult?.messages)
134
+ ? recallFastTrackResult.messages
135
+ : null;
136
+ if (!recallMessages) throw new Error('recall-fasttrack compact produced no messages');
137
+ compacted = recallMessages;
138
+ } catch (recallErr) {
139
+ recallFastTrackError = recallErr;
140
+ try {
141
+ process.stderr.write(
142
+ `[loop] recall-fasttrack compact failed (sess=${sessionId || 'unknown'}): ` +
143
+ `${recallErr?.message || recallErr}\n`,
144
+ );
145
+ } catch { /* best-effort */ }
146
+ throw recallErr;
147
+ }
148
+ } else if (compactPolicy.semantic) {
149
+ try {
150
+ semanticCompactResult = await semanticCompactMessages(
151
+ provider,
152
+ compactInputMessages,
153
+ model,
154
+ compactBudgetTokens,
155
+ {
156
+ reserveTokens: compactPolicy.reserveTokens,
157
+ providerName: sessionRef.provider || provider?.name || null,
158
+ sessionId,
159
+ signal,
160
+ sendOpts: opts,
161
+ promptCacheKey: opts.promptCacheKey || null,
162
+ providerCacheKey: opts.providerCacheKey || null,
163
+ timeoutMs: compactPolicy.semanticTimeoutMs,
164
+ tailTurns: compactPolicy.tailTurns,
165
+ keepTokens: compactPolicy.keepTokens,
166
+ preserveRecentTokens: compactPolicy.preserveRecentTokens,
167
+ force: true,
168
+ },
169
+ );
170
+ const semanticMessages = Array.isArray(semanticCompactResult?.messages)
171
+ ? semanticCompactResult.messages
172
+ : null;
173
+ if (!semanticMessages) throw new Error('semantic compact produced no messages');
174
+ compacted = semanticMessages;
175
+ if (semanticCompactResult?.usage) {
176
+ lastUsage = addUsage(lastUsage, semanticCompactResult.usage);
177
+ if (!firstTurnUsage) firstTurnUsage = normalizeUsage(semanticCompactResult.usage);
178
+ if (sessionId && opts.onUsageDelta) {
179
+ try {
180
+ opts.onUsageDelta({
181
+ sessionId,
182
+ iterationIndex: iterations + 1,
183
+ usageMetricsTurnId: loopUsageMetricsTurnId(),
184
+ usageMetricsEpoch: loopUsageMetricsEpoch(),
185
+ deltaInput: semanticCompactResult.usage.inputTokens || 0,
186
+ deltaOutput: semanticCompactResult.usage.outputTokens || 0,
187
+ deltaCachedRead: semanticCompactResult.usage.cachedTokens || 0,
188
+ deltaCacheWrite: semanticCompactResult.usage.cacheWriteTokens || 0,
189
+ source: 'semantic_compact',
190
+ ts: Date.now(),
191
+ });
192
+ } catch { /* best-effort */ }
193
+ }
194
+ }
195
+ } catch (semanticErr) {
196
+ semanticCompactError = semanticErr;
197
+ try {
198
+ process.stderr.write(
199
+ `[loop] semantic compact failed (sess=${sessionId || 'unknown'}): ` +
200
+ `${semanticErr?.message || semanticErr}\n`,
201
+ );
202
+ } catch { /* best-effort */ }
203
+ throw semanticErr;
204
+ }
205
+ } else {
206
+ throw new Error(`compact type ${compactPolicy.compactType || compactPolicy.type || DEFAULT_COMPACT_TYPE} is unavailable for auto compact`);
207
+ }
208
+ summaryChanged = messagesArrayChanged(compactInputMessages, compacted);
209
+ } catch (compactErr) {
210
+ // Anchor-independent prune safety net. When SEMANTIC compact
211
+ // throws (e.g. a degenerate single-turn transcript, or a
212
+ // summary that cannot fit), attempt one non-LLM prune that
213
+ // needs no user anchor: middle-truncate the oldest oversized
214
+ // tool_result bodies until the transcript fits the budget.
215
+ // If it shrinks the transcript we continue with that result
216
+ // instead of escalating to overflow. Structure/pairing is
217
+ // preserved (only string content shrinks) and the result is
218
+ // re-reconciled inside the helper.
219
+ //
220
+ // GATED to the non-recall path: a recall-fasttrack failure
221
+ // must NOT be silently recovered by this prune (that would
222
+ // change the type-2 path's contract by shipping a pruned
223
+ // transcript with no recall output). When recallFastTrackError
224
+ // is set the fallback is skipped and the original overflow
225
+ // escalation runs unchanged.
226
+ if (!recallFastTrackError) {
227
+ try {
228
+ // Accept only if the pruned transcript fits the SAME
229
+ // effective budget the prune targets (compactBudgetTokens
230
+ // minus the request reserve) — comparing against the raw
231
+ // compactBudgetTokens would accept a result with no
232
+ // reserve headroom and overflow on the very next send.
233
+ const acceptThreshold = compactEffectiveBudget(compactBudgetTokens, {
234
+ reserveTokens: compactPolicy.reserveTokens,
235
+ });
236
+ const salvaged = pruneToolOutputsUnanchored(messages, compactBudgetTokens, {
237
+ reserveTokens: compactPolicy.reserveTokens,
238
+ });
239
+ if (messagesArrayChanged(messages, salvaged)
240
+ && estimateMessagesTokensSafe(salvaged) <= acceptThreshold) {
241
+ compacted = salvaged;
242
+ pruneCount = countPrunedToolOutputs(messages, salvaged);
243
+ summaryChanged = true;
244
+ }
245
+ } catch { /* fall through to overflow escalation */ }
246
+ }
247
+ if (compacted !== undefined) {
248
+ try {
249
+ process.stderr.write(
250
+ `[loop] compact fallback prune recovered (sess=${sessionId || 'unknown'}): ` +
251
+ `${compactErr?.message || compactErr}\n`,
252
+ );
253
+ } catch { /* best-effort */ }
254
+ } else {
255
+ const compactFailMsg = compactErr && compactErr.message ? compactErr.message : String(compactErr);
256
+ const semanticFailMsg = semanticCompactError?.message || null;
257
+ const recallFailMsg = recallFastTrackError?.message || null;
258
+ const compactFailCode = compactErr?.code
259
+ || (compactErr?.name === 'AgentContextOverflowError' ? 'AGENT_CONTEXT_OVERFLOW' : null)
260
+ || 'compact_failed';
261
+ rememberCompactTelemetry(sessionRef, compactPolicy, {
262
+ stage: 'overflow_failed',
263
+ beforeTokens: messageTokensEst,
264
+ afterTokens: messageTokensEst,
265
+ pressureTokens,
266
+ trigger: compactTrigger,
267
+ semanticError: semanticFailMsg,
268
+ recallFastTrackError: recallFailMsg,
269
+ compactError: semanticFailMsg || recallFailMsg || compactFailMsg,
270
+ pruneCount,
271
+ durationMs: Date.now() - compactStartedAt,
272
+ });
273
+ traceAgentCompact({
274
+ sessionId,
275
+ iteration: iterations + 1,
276
+ stage: 'pre_send',
277
+ trigger: compactTrigger,
278
+ compact_type: compactPolicy.compactType || compactPolicy.type || DEFAULT_COMPACT_TYPE,
279
+ prune_count: pruneCount,
280
+ compact_changed: false,
281
+ input_prefix_hash: messagePrefixHash(messages),
282
+ before_count: beforeCount,
283
+ after_count: messages.length,
284
+ before_bytes: getBeforeBytes(),
285
+ after_bytes: getBeforeBytes(),
286
+ context_window: compactPolicy.contextWindow,
287
+ budget_tokens: compactPolicy.boundaryTokens,
288
+ boundary_tokens: compactPolicy.boundaryTokens,
289
+ target_budget_tokens: compactBudgetTokens,
290
+ reserve_tokens: compactPolicy.reserveTokens,
291
+ pressure_tokens: pressureTokens,
292
+ trigger_tokens: compactPolicy.triggerTokens,
293
+ message_tokens_est: messageTokensEst,
294
+ duration_ms: Date.now() - compactStartedAt,
295
+ provider: sessionRef.provider,
296
+ model: sessionRef.model || model,
297
+ error: compactFailMsg,
298
+ error_code: compactFailCode,
299
+ details: {
300
+ semantic: semanticCompactResult?.diagnostics || null,
301
+ recallFastTrack: recallFastTrackResult?.diagnostics || null,
302
+ semanticError: semanticFailMsg,
303
+ recallFastTrackError: recallFailMsg,
304
+ },
305
+ });
306
+ emitCompactEvent(opts, {
307
+ sessionId,
308
+ stage: 'pre_send',
309
+ trigger: compactTrigger,
310
+ status: 'failed',
311
+ compactType: compactEventType(compactPolicy),
312
+ beforeTokens: messageTokensEst,
313
+ afterTokens: messageTokensEst,
314
+ beforeMessages: beforeCount,
315
+ afterMessages: messages.length,
316
+ pressureTokens,
317
+ triggerTokens: compactPolicy.triggerTokens,
318
+ boundaryTokens: compactPolicy.boundaryTokens,
319
+ targetBudgetTokens: compactBudgetTokens,
320
+ reserveTokens: compactPolicy.reserveTokens,
321
+ semantic: compactPolicy.semantic === true,
322
+ recallFastTrack: compactPolicy.recallFastTrack === true,
323
+ pruneCount,
324
+ durationMs: Date.now() - compactStartedAt,
325
+ error: compactErr && compactErr.message ? compactErr.message : String(compactErr),
326
+ });
327
+ throw agentContextOverflowError({
328
+ stage: 'pre_send',
329
+ sessionId,
330
+ sessionRef,
331
+ model,
332
+ budgetTokens: compactBudgetTokens,
333
+ reserveTokens: compactPolicy.reserveTokens,
334
+ messageTokensEst,
335
+ }, compactErr);
336
+ }
337
+ }
338
+ try { await opts.onStageChange?.('requesting'); } catch { /* best-effort */ }
339
+ const compactChanged = messagesArrayChanged(messages, compacted);
340
+ if (compactChanged) {
341
+ messages.length = 0;
342
+ messages.push(...compacted);
343
+ // Compacting/pruning the transcript invalidates the
344
+ // server-side conversation anchor (xAI Responses / openai-oauth
345
+ // WS rely on previous_response_id which points at a
346
+ // now-mutated prefix). Drop providerState so the next send
347
+ // starts a fresh chain.
348
+ providerState = undefined;
349
+ // Compaction shrank the transcript, so prior turns no
350
+ // longer pressure the window — reset the iteration counter
351
+ // so a steadily-compacting long task isn't killed by the
352
+ // cap, while a non-compacting tight loop still hits it.
353
+ iterations = 0;
354
+ // New loop epoch so persistIterationMetrics idempotency keys do not
355
+ // collide when iteration indices restart at 1 (incl. iter 1 → iter 1).
356
+ if (sessionRef) bumpUsageMetricsEpoch(sessionRef);
357
+ }
358
+ const afterTokens = estimateMessagesTokensSafe(messages);
359
+ const compactDurationMs = Date.now() - compactStartedAt;
360
+ rememberCompactTelemetry(sessionRef, compactPolicy, {
361
+ stage: 'pre_send',
362
+ beforeTokens: messageTokensEst,
363
+ afterTokens,
364
+ pressureTokens,
365
+ compactChanged,
366
+ semanticCompact: semanticCompactResult?.semantic === true,
367
+ semanticError: semanticCompactError?.message || null,
368
+ recallFastTrack: recallFastTrackResult?.recallFastTrack === true,
369
+ recallFastTrackError: recallFastTrackError?.message || null,
370
+ compactError: null,
371
+ pruneCount,
372
+ durationMs: compactDurationMs,
373
+ });
374
+ let afterBytes = null;
375
+ try { afterBytes = Buffer.byteLength(JSON.stringify(messages), 'utf8'); } catch { afterBytes = null; }
376
+ traceAgentCompact({
377
+ sessionId,
378
+ iteration: iterations + 1,
379
+ stage: 'pre_send',
380
+ trigger: compactTrigger,
381
+ compact_type: compactPolicy.compactType || compactPolicy.type || DEFAULT_COMPACT_TYPE,
382
+ prune_count: pruneCount,
383
+ compact_changed: compactChanged || summaryChanged,
384
+ input_prefix_hash: messagePrefixHash(messages),
385
+ before_count: beforeCount,
386
+ after_count: messages.length,
387
+ before_bytes: getBeforeBytes(),
388
+ after_bytes: afterBytes,
389
+ context_window: compactPolicy.contextWindow,
390
+ budget_tokens: compactPolicy.boundaryTokens,
391
+ boundary_tokens: compactPolicy.boundaryTokens,
392
+ target_budget_tokens: compactBudgetTokens,
393
+ reserve_tokens: compactPolicy.reserveTokens,
394
+ pressure_tokens: pressureTokens,
395
+ trigger_tokens: compactPolicy.triggerTokens,
396
+ message_tokens_est: messageTokensEst,
397
+ duration_ms: compactDurationMs,
398
+ provider: sessionRef.provider,
399
+ model: sessionRef.model || model,
400
+ details: {
401
+ semantic: semanticCompactResult?.diagnostics || null,
402
+ recallFastTrack: recallFastTrackResult?.diagnostics || null,
403
+ },
404
+ });
405
+ emitCompactEvent(opts, {
406
+ sessionId,
407
+ stage: 'pre_send',
408
+ trigger: compactTrigger,
409
+ status: compactChanged || summaryChanged || pruneCount > 0 ? 'compacted' : 'no_change',
410
+ compactType: compactEventType(compactPolicy),
411
+ beforeTokens: messageTokensEst,
412
+ afterTokens,
413
+ beforeMessages: beforeCount,
414
+ afterMessages: messages.length,
415
+ pressureTokens,
416
+ triggerTokens: compactPolicy.triggerTokens,
417
+ boundaryTokens: compactPolicy.boundaryTokens,
418
+ targetBudgetTokens: compactBudgetTokens,
419
+ reserveTokens: compactPolicy.reserveTokens,
420
+ changed: compactChanged || summaryChanged,
421
+ semantic: semanticCompactResult?.semantic === true,
422
+ recallFastTrack: recallFastTrackResult?.recallFastTrack === true,
423
+ pruneCount,
424
+ durationMs: compactDurationMs,
425
+ });
426
+ }
427
+ // PostCompact: bridge to the standard hook bus after compaction
428
+ // completes. session-property hook; { trigger } 'auto'|'manual'.
429
+ {
430
+ const _postCompactHook = typeof opts.postCompactHook === 'function'
431
+ ? opts.postCompactHook
432
+ : sessionRef?.postCompactHook;
433
+ if (typeof _postCompactHook === 'function') {
434
+ try { await _postCompactHook({ sessionId, cwd, trigger: compactTrigger === 'manual' ? 'manual' : 'auto' }); }
435
+ catch { /* best-effort: PostCompact hook must never break the loop */ }
436
+ }
437
+ }
438
+ }
439
+ return { iterations, lastUsage, firstTurnUsage, providerState, reactiveOverflowRetryPending };
440
+ }
@@ -0,0 +1,153 @@
1
+ // provider.send wrapper with stall/overflow recovery, extracted from
2
+ // agent-loop.mjs. Returns { action } so the loop keeps control of the
3
+ // while-loop: proceed carries the response, retry signals a reactive
4
+ // context-overflow compact retry (caller re-enters the pre-send compact
5
+ // pass), and unrecoverable errors throw. Behavior identical to the inline
6
+ // try/catch it replaced.
7
+ import { appendAgentTrace } from '../agent-trace.mjs';
8
+ import { isContextOverflowError } from '../providers/retry-classifier.mjs';
9
+ import { resolveWorkerCompactPolicy } from './loop/compact-policy.mjs';
10
+ import { agentContextOverflowError } from './loop/context-overflow.mjs';
11
+ import { estimateMessagesTokensSafe } from './loop/compact-debug.mjs';
12
+
13
+ export async function sendWithRecovery(ctx) {
14
+ const {
15
+ provider, messages, model, sendTools, tools, opts,
16
+ sessionId, sessionRef, nextIteration, contextOverflowRetryUsed,
17
+ } = ctx;
18
+ let response;
19
+ try {
20
+ response = await provider.send(messages, model, sendTools.length ? sendTools : undefined, opts);
21
+ } catch (sendErr) {
22
+ // Partial-final recovery (owner-notify fix): the recurring "worker
23
+ // finished but the task hung / no result delivered" case is a FINAL,
24
+ // no-tool summary stream that wedges (ping-only) AFTER all real tool
25
+ // work completed in earlier iterations. The provider attaches its
26
+ // partial stream state to the StreamStalledError. When the stall
27
+ // carries streamed assistant text, has NO pending tool_use, and did
28
+ // NOT emit a tool call this iteration, accept the partial as a
29
+ // successful terminal response (deliver the summary we have) instead
30
+ // of throwing — which would strand/notify-as-failure a turn whose
31
+ // work actually succeeded. A stall WITH a pending/emitted tool call
32
+ // is NOT recoverable (a tool whose input never completed must never
33
+ // look done) and falls through to the normal error path.
34
+ if (
35
+ sendErr?.streamStalled === true
36
+ && sendErr.pendingToolUse !== true
37
+ // NOT gated on unsafeToRetry: live-text stalls stamp
38
+ // unsafeToRetry=true (replay would double-render), but
39
+ // ACCEPTING the already-streamed partial is exactly the safe
40
+ // move (CC rule). Only an emitted tool call blocks acceptance.
41
+ && sendErr.emittedToolCall !== true
42
+ && typeof sendErr.partialContent === 'string'
43
+ && sendErr.partialContent.trim().length > 0
44
+ && !(Array.isArray(sendErr.partialToolCalls) && sendErr.partialToolCalls.length > 0)
45
+ ) {
46
+ try {
47
+ process.stderr.write(
48
+ `[loop] final stream stalled with partial text (sess=${sessionId || 'unknown'} `
49
+ + `iter=${nextIteration} len=${sendErr.partialContent.length}); `
50
+ + `accepting as partial-final success\n`,
51
+ );
52
+ } catch { /* best-effort */ }
53
+ response = {
54
+ content: sendErr.partialContent,
55
+ model: sendErr.partialModel || model,
56
+ toolCalls: undefined,
57
+ usage: sendErr.partialUsage || undefined,
58
+ stopReason: sendErr.partialStopReason || 'end_turn',
59
+ hasThinkingContent: sendErr.partialHasThinking === true,
60
+ partialFinal: true,
61
+ };
62
+ } else
63
+ // Partial tool-call recovery (agent-hang fix): a stream that stalls
64
+ // AFTER fully-parsed tool calls were emitted used to lose the whole
65
+ // turn — unsafeToRetry blocks the mid-stream replay (correct: a
66
+ // replay would re-run side-effecting tools) and the old code threw,
67
+ // discarding tool work that had ALREADY completed via eager dispatch.
68
+ // But the parsed calls are complete (pendingToolUse false ⇒ no
69
+ // half-streamed tool input), so instead of replaying the request we
70
+ // accept the partial as a normal tool-call turn and fall through to
71
+ // the standard execution path: eager-dispatched (read-only) calls
72
+ // resolve from the pending map without re-running, side-effecting
73
+ // calls were never started during streaming and execute exactly
74
+ // once. providerState stays undefined so the next iteration resends
75
+ // a full frame on a fresh stream.
76
+ if (
77
+ sendErr?.streamStalled === true
78
+ && sendErr.pendingToolUse !== true
79
+ && Array.isArray(sendErr.partialToolCalls)
80
+ && sendErr.partialToolCalls.length > 0
81
+ ) {
82
+ try {
83
+ process.stderr.write(
84
+ `[loop] stream stalled after ${sendErr.partialToolCalls.length} complete tool call(s) `
85
+ + `(sess=${sessionId || 'unknown'} iter=${nextIteration}); `
86
+ + `recovering as tool-call turn instead of failing\n`,
87
+ );
88
+ } catch { /* best-effort */ }
89
+ try {
90
+ appendAgentTrace({
91
+ kind: 'stall_tool_recovery',
92
+ sessionId: sessionId || null,
93
+ iteration: nextIteration,
94
+ toolCalls: sendErr.partialToolCalls.length,
95
+ partialContentLen: typeof sendErr.partialContent === 'string' ? sendErr.partialContent.length : 0,
96
+ });
97
+ } catch { /* best-effort */ }
98
+ response = {
99
+ content: typeof sendErr.partialContent === 'string' ? sendErr.partialContent : '',
100
+ model: sendErr.partialModel || model,
101
+ toolCalls: sendErr.partialToolCalls.slice(),
102
+ usage: sendErr.partialUsage || undefined,
103
+ stopReason: 'tool_use',
104
+ hasThinkingContent: sendErr.partialHasThinking === true,
105
+ partialToolRecovery: true,
106
+ };
107
+ } else
108
+ // Context-window-exceeded is a deterministic refusal from the API.
109
+ // Recover context overflow reactively by compacting and retrying
110
+ // in the same active turn. MixDog's proactive estimator can miss a
111
+ // provider-specific overhead spike, so do one reactive retry by
112
+ // marking the live session over-threshold and looping back through
113
+ // the normal pre-send auto-compact path. If compaction/retry still
114
+ // fails, surface the overflow normally.
115
+ if (
116
+ !isContextOverflowError(sendErr)
117
+ || !(sessionRef && typeof sessionRef.contextWindow === 'number')
118
+ ) {
119
+ throw sendErr;
120
+ }
121
+ const compactPolicyForRetry = resolveWorkerCompactPolicy(sessionRef, sendTools.length ? sendTools : tools);
122
+ if (!contextOverflowRetryUsed && compactPolicyForRetry?.auto) {
123
+ // Mark the next pre-send compact as REACTIVE (driven by a
124
+ // provider overflow refusal) rather than the normal proactive
125
+ // pressure trigger, so the compact event/telemetry the loop
126
+ // emits on the retry is distinguishable downstream.
127
+ opts.onToolCall = undefined;
128
+ try {
129
+ process.stderr.write(
130
+ `[loop] context overflow on send (sess=${sessionId || 'unknown'} iter=${nextIteration}); ` +
131
+ `reactive compact retry messages=${messages.length}\n`,
132
+ );
133
+ } catch { /* best-effort */ }
134
+ return { action: 'retry' };
135
+ }
136
+ try {
137
+ process.stderr.write(
138
+ `[loop] context overflow on send (sess=${sessionId || 'unknown'} iter=${nextIteration}); ` +
139
+ `surfacing overflow after reactive compact retry messages=${messages.length}\n`,
140
+ );
141
+ } catch { /* best-effort */ }
142
+ throw agentContextOverflowError({
143
+ stage: 'send',
144
+ sessionId,
145
+ sessionRef,
146
+ model,
147
+ budgetTokens: sessionRef.contextWindow,
148
+ reserveTokens: compactPolicyForRetry?.reserveTokens,
149
+ messageTokensEst: estimateMessagesTokensSafe(messages),
150
+ }, sendErr);
151
+ }
152
+ return { action: 'proceed', response };
153
+ }
@@ -13,6 +13,7 @@ import { isAgentOwner } from '../agent-owner.mjs';
13
13
  import { renameWithRetrySync } from '../../../shared/atomic-file.mjs';
14
14
  import { sanitizeContentForStoredHistory } from '../providers/media-normalization.mjs';
15
15
  import { scanTopLevelLifecycle } from './lifecycle-scan.mjs';
16
+ import { rotateBoundedLog, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES } from '../../../../lib/mixdog-debug.cjs';
16
17
  import {
17
18
  summaryIndexPath,
18
19
  _sessionSummary,
@@ -517,8 +518,10 @@ export function markSessionClosed(id, reason = 'manual') {
517
518
  : 0;
518
519
  const _agent = existing.agent || '-';
519
520
  const _owner = existing.owner || '-';
521
+ const _toolEventsPath = join(_dataDir, 'tool-events.log');
522
+ rotateBoundedLog(_toolEventsPath, PLUGIN_LOG_MAX_BYTES, PLUGIN_LOG_KEEP_BYTES);
520
523
  void fsp.appendFile(
521
- join(_dataDir, 'tool-events.log'),
524
+ _toolEventsPath,
522
525
  `[${_ts}] [session-close] owner=${_owner} agent=${_agent} reason=${reason} lifeMs=${_lifeMs} id=${id}\n`,
523
526
  ).catch(() => {});
524
527
  }