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