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