@x-code-cli/core 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/api-errors.js +1 -1
- package/dist/agent/api-errors.js.map +1 -1
- package/dist/agent/compression.d.ts +25 -0
- package/dist/agent/compression.d.ts.map +1 -0
- package/dist/agent/compression.js +105 -0
- package/dist/agent/compression.js.map +1 -0
- package/dist/agent/diff.js.map +1 -1
- package/dist/agent/file-ingest.d.ts +14 -0
- package/dist/agent/file-ingest.d.ts.map +1 -1
- package/dist/agent/file-ingest.js +125 -34
- package/dist/agent/file-ingest.js.map +1 -1
- package/dist/agent/light-compact.d.ts.map +1 -1
- package/dist/agent/light-compact.js +0 -19
- package/dist/agent/light-compact.js.map +1 -1
- package/dist/agent/loop-guard.d.ts.map +1 -1
- package/dist/agent/loop-guard.js.map +1 -1
- package/dist/agent/loop-state.d.ts.map +1 -1
- package/dist/agent/loop-state.js +8 -1
- package/dist/agent/loop-state.js.map +1 -1
- package/dist/agent/loop.d.ts +2 -3
- package/dist/agent/loop.d.ts.map +1 -1
- package/dist/agent/loop.js +53 -93
- package/dist/agent/loop.js.map +1 -1
- package/dist/agent/memory-extractor.d.ts +22 -0
- package/dist/agent/memory-extractor.d.ts.map +1 -0
- package/dist/agent/memory-extractor.js +253 -0
- package/dist/agent/memory-extractor.js.map +1 -0
- package/dist/agent/messages.d.ts +9 -0
- package/dist/agent/messages.d.ts.map +1 -1
- package/dist/agent/messages.js +15 -0
- package/dist/agent/messages.js.map +1 -1
- package/dist/agent/plan-storage.d.ts.map +1 -1
- package/dist/agent/plan-storage.js +1 -1
- package/dist/agent/plan-storage.js.map +1 -1
- package/dist/agent/plan-tools.d.ts +8 -0
- package/dist/agent/plan-tools.d.ts.map +1 -0
- package/dist/agent/plan-tools.js +150 -0
- package/dist/agent/plan-tools.js.map +1 -0
- package/dist/agent/provider-compat.d.ts.map +1 -1
- package/dist/agent/provider-compat.js.map +1 -1
- package/dist/agent/sub-agents/built-in.d.ts.map +1 -1
- package/dist/agent/sub-agents/built-in.js +41 -15
- package/dist/agent/sub-agents/built-in.js.map +1 -1
- package/dist/agent/sub-agents/loader.d.ts.map +1 -1
- package/dist/agent/sub-agents/loader.js.map +1 -1
- package/dist/agent/sub-agents/runner.d.ts.map +1 -1
- package/dist/agent/sub-agents/runner.js +12 -8
- package/dist/agent/sub-agents/runner.js.map +1 -1
- package/dist/agent/system-prompt.d.ts.map +1 -1
- package/dist/agent/system-prompt.js +0 -31
- package/dist/agent/system-prompt.js.map +1 -1
- package/dist/agent/tool-execution.d.ts +34 -2
- package/dist/agent/tool-execution.d.ts.map +1 -1
- package/dist/agent/tool-execution.js +363 -360
- package/dist/agent/tool-execution.js.map +1 -1
- package/dist/agent/tool-result-sanitize.d.ts +21 -7
- package/dist/agent/tool-result-sanitize.d.ts.map +1 -1
- package/dist/agent/tool-result-sanitize.js +56 -30
- package/dist/agent/tool-result-sanitize.js.map +1 -1
- package/dist/agent/vision-fallback.d.ts.map +1 -1
- package/dist/agent/vision-fallback.js +3 -14
- package/dist/agent/vision-fallback.js.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/knowledge/loader.d.ts.map +1 -1
- package/dist/knowledge/loader.js +50 -23
- package/dist/knowledge/loader.js.map +1 -1
- package/dist/permissions/index.d.ts +9 -4
- package/dist/permissions/index.d.ts.map +1 -1
- package/dist/permissions/index.js +41 -7
- package/dist/permissions/index.js.map +1 -1
- package/dist/permissions/session-store.d.ts +34 -20
- package/dist/permissions/session-store.d.ts.map +1 -1
- package/dist/permissions/session-store.js +94 -34
- package/dist/permissions/session-store.js.map +1 -1
- package/dist/providers/cache-control.d.ts.map +1 -1
- package/dist/providers/cache-control.js +0 -29
- package/dist/providers/cache-control.js.map +1 -1
- package/dist/providers/thinking.d.ts.map +1 -1
- package/dist/providers/thinking.js +2 -6
- package/dist/providers/thinking.js.map +1 -1
- package/dist/tools/ask-user.d.ts.map +1 -1
- package/dist/tools/ask-user.js +17 -7
- package/dist/tools/ask-user.js.map +1 -1
- package/dist/tools/edit.d.ts.map +1 -1
- package/dist/tools/edit.js +8 -1
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/glob.d.ts.map +1 -1
- package/dist/tools/glob.js +9 -2
- package/dist/tools/glob.js.map +1 -1
- package/dist/tools/grep.d.ts +1 -1
- package/dist/tools/grep.d.ts.map +1 -1
- package/dist/tools/grep.js +29 -6
- package/dist/tools/grep.js.map +1 -1
- package/dist/tools/index.d.ts +2 -10
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +10 -3
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/list-dir.d.ts.map +1 -1
- package/dist/tools/list-dir.js.map +1 -1
- package/dist/tools/read-file.d.ts.map +1 -1
- package/dist/tools/read-file.js +78 -36
- package/dist/tools/read-file.js.map +1 -1
- package/dist/tools/shell-provider.d.ts +1 -0
- package/dist/tools/shell-provider.d.ts.map +1 -1
- package/dist/tools/shell-provider.js +7 -0
- package/dist/tools/shell-provider.js.map +1 -1
- package/dist/tools/shell-utils.d.ts.map +1 -1
- package/dist/tools/shell-utils.js +45 -2
- package/dist/tools/shell-utils.js.map +1 -1
- package/dist/tools/shell.d.ts.map +1 -1
- package/dist/tools/shell.js +15 -1
- package/dist/tools/shell.js.map +1 -1
- package/dist/tools/task.d.ts.map +1 -1
- package/dist/tools/task.js +4 -4
- package/dist/tools/task.js.map +1 -1
- package/dist/tools/todo-write.d.ts.map +1 -1
- package/dist/tools/todo-write.js.map +1 -1
- package/dist/tools/web-fetch.d.ts +2 -0
- package/dist/tools/web-fetch.d.ts.map +1 -1
- package/dist/tools/web-fetch.js +92 -27
- package/dist/tools/web-fetch.js.map +1 -1
- package/dist/tools/write-file.d.ts.map +1 -1
- package/dist/tools/write-file.js +7 -1
- package/dist/tools/write-file.js.map +1 -1
- package/dist/types/index.d.ts +17 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/dist/utils/lru-cache.d.ts +17 -0
- package/dist/utils/lru-cache.d.ts.map +1 -0
- package/dist/utils/lru-cache.js +40 -0
- package/dist/utils/lru-cache.js.map +1 -0
- package/dist/utils/media-type.d.ts +5 -0
- package/dist/utils/media-type.d.ts.map +1 -0
- package/dist/utils/media-type.js +19 -0
- package/dist/utils/media-type.js.map +1 -0
- package/dist/utils/message-helpers.d.ts +6 -0
- package/dist/utils/message-helpers.d.ts.map +1 -0
- package/dist/utils/message-helpers.js +14 -0
- package/dist/utils/message-helpers.js.map +1 -0
- package/package.json +1 -1
- package/dist/tools/save-knowledge.d.ts +0 -8
- package/dist/tools/save-knowledge.d.ts.map +0 -1
- package/dist/tools/save-knowledge.js +0 -61
- package/dist/tools/save-knowledge.js.map +0 -1
|
@@ -5,31 +5,13 @@ import { checkPermission } from '../permissions/index.js';
|
|
|
5
5
|
import { truncateToolResult } from '../tools/index.js';
|
|
6
6
|
import { clearProgressReporter, reportProgress } from '../tools/progress.js';
|
|
7
7
|
import { getShellProvider } from '../tools/shell-provider.js';
|
|
8
|
+
import { debugLog } from '../utils.js';
|
|
8
9
|
import { foldShellErrorNoise } from '../utils/shell-error.js';
|
|
9
10
|
import { computeEditDiff } from './diff.js';
|
|
10
11
|
import { checkForLoop, recordToolCall } from './loop-guard.js';
|
|
11
|
-
import { toolResultMessage } from './messages.js';
|
|
12
|
-
import {
|
|
12
|
+
import { isToolErrorString, toolErrorFromUnknown, toolErrorString, toolResultMessage } from './messages.js';
|
|
13
|
+
import { handleEnterPlanMode, handleExitPlanMode, handleTodoWrite } from './plan-tools.js';
|
|
13
14
|
import { runSubAgent } from './sub-agents/runner.js';
|
|
14
|
-
/** Walk back through state.messages and grab the most recent user
|
|
15
|
-
* message's text — used as the slug source for the plan filename. */
|
|
16
|
-
function lastUserMessageText(messages) {
|
|
17
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
18
|
-
const m = messages[i];
|
|
19
|
-
if (m && m.role === 'user') {
|
|
20
|
-
const content = m.content;
|
|
21
|
-
if (typeof content === 'string')
|
|
22
|
-
return content;
|
|
23
|
-
if (Array.isArray(content)) {
|
|
24
|
-
return content
|
|
25
|
-
.filter((p) => p?.type === 'text' && typeof p.text === 'string')
|
|
26
|
-
.map((p) => p.text)
|
|
27
|
-
.join(' ');
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
return '';
|
|
32
|
-
}
|
|
33
15
|
/** Count occurrences of a substring without creating intermediate arrays. */
|
|
34
16
|
function countOccurrences(content, search) {
|
|
35
17
|
let count = 0;
|
|
@@ -47,7 +29,7 @@ function countOccurrences(content, search) {
|
|
|
47
29
|
* UI can render a colored diff under the tool bullet. The diff payload is
|
|
48
30
|
* a UI-only side channel — it never lands in `state.messages` and the
|
|
49
31
|
* model only sees the short result string. */
|
|
50
|
-
async function executeWriteTool(toolName, input, toolCallId, callbacks) {
|
|
32
|
+
async function executeWriteTool(toolName, input, toolCallId, callbacks, signal) {
|
|
51
33
|
if (toolName === 'writeFile') {
|
|
52
34
|
const filePath = input.filePath;
|
|
53
35
|
const content = input.content;
|
|
@@ -58,12 +40,12 @@ async function executeWriteTool(toolName, input, toolCallId, callbacks) {
|
|
|
58
40
|
// plus permission / EISDIR edge cases (we'd error on write anyway).
|
|
59
41
|
let oldContent = null;
|
|
60
42
|
try {
|
|
61
|
-
oldContent = await fs.readFile(filePath, 'utf-8');
|
|
43
|
+
oldContent = await fs.readFile(filePath, { encoding: 'utf-8', signal });
|
|
62
44
|
}
|
|
63
45
|
catch {
|
|
64
46
|
oldContent = null;
|
|
65
47
|
}
|
|
66
|
-
await fs.writeFile(filePath, content, 'utf-8');
|
|
48
|
+
await fs.writeFile(filePath, content, { encoding: 'utf-8', signal });
|
|
67
49
|
const isNew = oldContent === null;
|
|
68
50
|
const parts = content.split('\n');
|
|
69
51
|
const lineCount = content.endsWith('\n') ? parts.length - 1 : parts.length;
|
|
@@ -81,22 +63,22 @@ async function executeWriteTool(toolName, input, toolCallId, callbacks) {
|
|
|
81
63
|
const newString = input.newString;
|
|
82
64
|
const replaceAll = input.replaceAll ?? false;
|
|
83
65
|
reportProgress(toolCallId, `Editing ${filePath}`);
|
|
84
|
-
const content = await fs.readFile(filePath, 'utf-8');
|
|
66
|
+
const content = await fs.readFile(filePath, { encoding: 'utf-8', signal });
|
|
85
67
|
if (!replaceAll) {
|
|
86
68
|
const count = countOccurrences(content, oldString);
|
|
87
69
|
if (count === 0)
|
|
88
|
-
return `
|
|
70
|
+
return toolErrorString(`old_string not found in ${filePath}`);
|
|
89
71
|
if (count > 1)
|
|
90
|
-
return `
|
|
72
|
+
return toolErrorString(`old_string is not unique in ${filePath} (found ${count} occurrences). Provide more context or set replaceAll: true.`);
|
|
91
73
|
}
|
|
92
74
|
const newContent = replaceAll ? content.replaceAll(oldString, newString) : content.replace(oldString, newString);
|
|
93
|
-
await fs.writeFile(filePath, newContent, 'utf-8');
|
|
75
|
+
await fs.writeFile(filePath, newContent, { encoding: 'utf-8', signal });
|
|
94
76
|
const payload = computeEditDiff(filePath, content, newContent);
|
|
95
77
|
if (payload && callbacks.onFileEdit)
|
|
96
78
|
callbacks.onFileEdit(toolCallId, payload);
|
|
97
79
|
return `File edited: ${filePath}`;
|
|
98
80
|
}
|
|
99
|
-
return '
|
|
81
|
+
return toolErrorString('unknown write tool');
|
|
100
82
|
}
|
|
101
83
|
/** Execute a shell command with streaming. */
|
|
102
84
|
async function executeShell(command, timeout, signal, callbacks, toolCallId) {
|
|
@@ -145,11 +127,23 @@ async function executeShell(command, timeout, signal, callbacks, toolCallId) {
|
|
|
145
127
|
// `string | unknown[] | Uint8Array` — we spawn with default string mode, so
|
|
146
128
|
// a cast is safe, but keep a defensive fallback for non-string just in case.
|
|
147
129
|
const toStr = (v) => (typeof v === 'string' ? v : '');
|
|
148
|
-
|
|
149
|
-
|
|
130
|
+
let stdout = foldShellErrorNoise(toStr(result.stdout));
|
|
131
|
+
let stderr = foldShellErrorNoise(toStr(result.stderr));
|
|
132
|
+
// When execa kills the child for exceeding maxBuffer, the partial
|
|
133
|
+
// output is still available in stdout/stderr. Surface a clear
|
|
134
|
+
// truncation notice so the model doesn't silently lose context.
|
|
135
|
+
const isMaxBuffer = result.isMaxBuffer ?? false;
|
|
136
|
+
if (isMaxBuffer) {
|
|
137
|
+
const INLINE_CAP = 30_000;
|
|
138
|
+
if (stdout.length > INLINE_CAP)
|
|
139
|
+
stdout = stdout.slice(0, INLINE_CAP) + '\n... [stdout truncated — exceeded buffer limit]';
|
|
140
|
+
if (stderr.length > INLINE_CAP)
|
|
141
|
+
stderr = stderr.slice(0, INLINE_CAP) + '\n... [stderr truncated — exceeded buffer limit]';
|
|
142
|
+
}
|
|
150
143
|
const output = [stdout, stderr].filter(Boolean).join('\n').trim();
|
|
151
|
-
if (result.exitCode !== 0) {
|
|
152
|
-
const
|
|
144
|
+
if (result.exitCode !== 0 || isMaxBuffer) {
|
|
145
|
+
const suffix = isMaxBuffer ? ' (output exceeded buffer limit)' : '';
|
|
146
|
+
const text = output ? `${output}\nExit code ${result.exitCode}${suffix}` : `Exit code ${result.exitCode}${suffix}`;
|
|
153
147
|
return { output: text, isError: true };
|
|
154
148
|
}
|
|
155
149
|
return { output: output || 'Done', isError: false };
|
|
@@ -164,360 +158,369 @@ function pushToolResult(state, callbacks, toolCallId, toolName, output, isError
|
|
|
164
158
|
clearProgressReporter(toolCallId);
|
|
165
159
|
callbacks.onToolResult(toolCallId, output, isError);
|
|
166
160
|
}
|
|
167
|
-
/**
|
|
168
|
-
* the
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
? ' Before wrapping up, verify your work — run tests, lint, or type-check as appropriate for this project.'
|
|
225
|
-
: '';
|
|
226
|
-
pushToolResult(state, callbacks, toolCallId, toolName, allDone
|
|
227
|
-
? `All todos completed. Checklist cleared.${verifyNote}${droppedNote}`
|
|
228
|
-
: `Todo list updated. Keep the checklist current — mark items completed immediately when finished, and ensure exactly one item is in_progress.${droppedNote}`);
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
// ── task tool (sub-agent dispatch) ──
|
|
232
|
-
if (toolName === 'task') {
|
|
233
|
-
const agentName = input.subagent_type;
|
|
234
|
-
const description = input.description;
|
|
235
|
-
const taskPrompt = input.prompt;
|
|
236
|
-
reportProgress(toolCallId, `Task: ${description} (${agentName})`);
|
|
237
|
-
const result = await runSubAgent({
|
|
238
|
-
parentState: state,
|
|
239
|
-
parentOptions: options,
|
|
240
|
-
callbacks,
|
|
241
|
-
toolCallId,
|
|
242
|
-
agentName,
|
|
243
|
-
description,
|
|
244
|
-
prompt: taskPrompt,
|
|
245
|
-
knowledgeContext: state.knowledgeContext ?? '',
|
|
246
|
-
isGitRepo: state.isGitRepo ?? false,
|
|
247
|
-
}, parentModel);
|
|
248
|
-
const statsLine = `<task_stats tool_calls="${result.toolCallCount}" tokens="${result.tokenUsage.totalTokens}" duration_ms="${result.durationMs}" />`;
|
|
249
|
-
pushToolResult(state, callbacks, toolCallId, toolName, `${result.resultText}\n${statsLine}`);
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
// ── enterPlanMode tool ──
|
|
253
|
-
// Flip state.permissionMode → 'plan', invalidate the system-prompt
|
|
254
|
-
// cache so the next turn rebuilds it with the overlay, and reserve a
|
|
255
|
-
// plan-file path on state.currentPlanPath WITHOUT actually creating
|
|
256
|
-
// the file (the path is just a string until the model decides it
|
|
257
|
-
// wants a scratchpad). Plan mode is a conversation state, not a
|
|
258
|
-
// forced "write to a file" workflow — for Q&A and discussion the
|
|
259
|
-
// model never touches the file. The path is created lazily, the
|
|
260
|
-
// first time the model calls writeFile/edit on it (or when
|
|
261
|
-
// exitPlanMode persists the approved plan).
|
|
262
|
-
if (toolName === 'enterPlanMode') {
|
|
263
|
-
if (state.permissionMode === 'plan') {
|
|
264
|
-
pushToolResult(state, callbacks, toolCallId, toolName, 'Already in plan mode. Continue the conversation; call exitPlanMode when the user has asked for an implementation and you have a plan ready.');
|
|
265
|
-
return;
|
|
266
|
-
}
|
|
267
|
-
// Approval gate. Mirrors Claude Code: model can recommend plan
|
|
268
|
-
// mode but cannot enter on its own — user has to consent so the
|
|
269
|
-
// mode flip never feels like the model unilaterally hijacking the
|
|
270
|
-
// session. The same dialog component the write-tool path uses
|
|
271
|
-
// renders a "X-Code wants to enter plan mode" prompt with Yes/No.
|
|
272
|
-
const approved = await callbacks.onAskPermission({ toolCallId, toolName, input });
|
|
273
|
-
if (options.abortSignal?.aborted) {
|
|
274
|
-
pushToolResult(state, callbacks, toolCallId, toolName, '[Tool execution interrupted by user]', true);
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
if (!approved) {
|
|
278
|
-
pushToolResult(state, callbacks, toolCallId, toolName, "User declined to enter plan mode. Continue with the user's request in default mode — make whatever edits or shell calls the task requires (subject to per-tool permission).", true);
|
|
279
|
-
return;
|
|
280
|
-
}
|
|
281
|
-
state.permissionMode = 'plan';
|
|
282
|
-
state.systemPromptCache = null;
|
|
283
|
-
// Derive the plan file path. Slug priority:
|
|
284
|
-
// 1. Model-supplied `topic` (3-5 English words specific to the
|
|
285
|
-
// current task — most accurate when the user is mid-session
|
|
286
|
-
// and the topic has shifted).
|
|
287
|
-
// 2. `state.taskSlug` (set once per session by agentLoop using
|
|
288
|
-
// either local slugify or a one-shot LLM summary — already
|
|
289
|
-
// handles CJK first messages).
|
|
290
|
-
// 3. Raw last-user-message text (final fallback; slugify will
|
|
291
|
-
// reduce CJK to empty → timestamp-only filename).
|
|
292
|
-
if (!state.currentPlanPath) {
|
|
293
|
-
const topic = input.topic?.trim();
|
|
294
|
-
const fallbackText = lastUserMessageText(state.messages);
|
|
295
|
-
const explicitSlug = topic && topic.length > 0 ? topic : state.taskSlug || undefined;
|
|
296
|
-
state.currentPlanPath = makePlanFilePath(fallbackText, { slug: explicitSlug });
|
|
297
|
-
}
|
|
298
|
-
callbacks.onPlanModeChange('plan');
|
|
299
|
-
pushToolResult(state, callbacks, toolCallId, toolName, [
|
|
300
|
-
'Entered plan mode (user approved).',
|
|
301
|
-
'',
|
|
302
|
-
'Read-only tools are unrestricted (readFile, glob, grep, listDir, webSearch, webFetch).',
|
|
303
|
-
`Plan file path for this session: ${state.currentPlanPath}`,
|
|
304
|
-
'Use writeFile/edit on the plan file to build your plan; do NOT edit any other files',
|
|
305
|
-
'or run state-changing shell commands until the user approves your plan via exitPlanMode.',
|
|
306
|
-
'',
|
|
307
|
-
'Workflow: explore → update plan file → askUser → repeat.',
|
|
308
|
-
'',
|
|
309
|
-
'CRITICAL: when the plan is ready, call **exitPlanMode** to request approval — NOT',
|
|
310
|
-
'askUser. askUser cannot leave plan mode no matter how the user answers; only',
|
|
311
|
-
'exitPlanMode flips the mode and unblocks your writeFile/edit/shell calls.',
|
|
312
|
-
].join('\n'));
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
// ── exitPlanMode tool ──
|
|
316
|
-
// Triggers the user-approval gate. The plan body comes from
|
|
317
|
-
// `input.plan` (passed verbatim by the model). We persist it to the
|
|
318
|
-
// session's plan file as a permanent record before showing the
|
|
319
|
-
// approval dialog — that way even rejected plans leave a trace, and
|
|
320
|
-
// approved plans live alongside the implementation that follows.
|
|
321
|
-
// Approval flips state back to 'default' and invalidates the
|
|
322
|
-
// system-prompt cache so the next turn drops the plan-mode overlay.
|
|
323
|
-
// Rejection keeps the model in plan mode and tells it to revise.
|
|
324
|
-
if (toolName === 'exitPlanMode') {
|
|
325
|
-
if (state.permissionMode !== 'plan') {
|
|
326
|
-
pushToolResult(state, callbacks, toolCallId, toolName, 'Error: not in plan mode. exitPlanMode is only valid when the session is in plan mode.', true);
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
// Source of truth for the plan body is the plan file the model has
|
|
330
|
-
// been writing to during planning (matches Claude Code: the model
|
|
331
|
-
// builds the plan incrementally via writeFile/edit, then calls
|
|
332
|
-
// exitPlanMode which reads the file). The optional `plan` override
|
|
333
|
-
// exists for rare cases where the model wants to substitute the
|
|
334
|
-
// file content with something different.
|
|
335
|
-
const planPath = state.currentPlanPath ??
|
|
336
|
-
makePlanFilePath(lastUserMessageText(state.messages), { slug: state.taskSlug || undefined });
|
|
337
|
-
state.currentPlanPath = planPath;
|
|
338
|
-
const planOverride = input.plan?.trim();
|
|
339
|
-
let planBody = planOverride ?? '';
|
|
340
|
-
if (!planBody) {
|
|
341
|
-
planBody = (await readPlan(planPath)).trim();
|
|
342
|
-
}
|
|
343
|
-
if (!planBody) {
|
|
344
|
-
pushToolResult(state, callbacks, toolCallId, toolName, `Error: the plan file at ${planPath} is empty. Write your plan to that file using writeFile or edit, then call exitPlanMode again.`, true);
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
// If the model passed an override, persist it back to the plan
|
|
348
|
-
// file so the on-disk record matches what the user sees / approves.
|
|
349
|
-
let savedPath = planPath;
|
|
350
|
-
if (planOverride) {
|
|
351
|
-
try {
|
|
352
|
-
savedPath = await writePlan(planPath, planBody);
|
|
353
|
-
state.currentPlanPath = savedPath;
|
|
354
|
-
}
|
|
355
|
-
catch {
|
|
356
|
-
// Disk failure (read-only fs, permissions) is non-fatal — fall
|
|
357
|
-
// through to the approval dialog with the in-memory body.
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
const approved = await callbacks.onPlanApprovalRequest(planBody);
|
|
361
|
-
if (approved) {
|
|
362
|
-
// Default post-approval mode is `acceptEdits` — the user just
|
|
363
|
-
// vetted the plan, so making them click "Yes" on every writeFile
|
|
364
|
-
// / edit during implementation is pure friction. Shell commands
|
|
365
|
-
// still go through normal classification (always-allow for read-
|
|
366
|
-
// only, ask for mixed, deny for destructive) so we don't blanket-
|
|
367
|
-
// approve `rm -rf` on plan approval. Matches Claude Code's
|
|
368
|
-
// default "Yes, auto-accept edits" behavior.
|
|
369
|
-
state.permissionMode = 'acceptEdits';
|
|
370
|
-
state.systemPromptCache = null;
|
|
371
|
-
const persisted = savedPath ?? state.currentPlanPath;
|
|
372
|
-
state.currentPlanPath = null;
|
|
373
|
-
callbacks.onPlanModeChange('acceptEdits');
|
|
374
|
-
pushToolResult(state, callbacks, toolCallId, toolName, [
|
|
375
|
-
'Plan approved by user. Plan mode has been exited.',
|
|
376
|
-
persisted ? `The approved plan is saved at: ${persisted}` : '',
|
|
377
|
-
'You can now edit files and run shell commands. Start implementing the plan.',
|
|
378
|
-
'',
|
|
379
|
-
'For multi-step plans, call **todoWrite** first to break the plan into a',
|
|
380
|
-
'tracked checklist — the user sees a live panel of your progress and you',
|
|
381
|
-
'avoid losing track of remaining steps mid-implementation.',
|
|
382
|
-
]
|
|
383
|
-
.filter(Boolean)
|
|
384
|
-
.join('\n'));
|
|
385
|
-
// Also inject a system-reminder-style user-role meta message so
|
|
386
|
-
// the model treats the mode flip as a fresh top-level instruction
|
|
387
|
-
// rather than just a tool result. Mirrors Claude Code's
|
|
388
|
-
// `## Exited Plan Mode` attachment (messages.ts:3847-3852) — gives
|
|
389
|
-
// the next turn a clear "the rules just changed" anchor.
|
|
390
|
-
state.messages.push({
|
|
391
|
-
role: 'user',
|
|
392
|
-
content: [
|
|
393
|
-
'## Exited Plan Mode',
|
|
394
|
-
'',
|
|
395
|
-
'You have exited plan mode. You can now make edits, run tools, and take actions.',
|
|
396
|
-
'Write tools (writeFile, edit) are now auto-approved (acceptEdits mode); shell commands',
|
|
397
|
-
'still go through normal permission classification.',
|
|
398
|
-
persisted ? `The plan file is located at ${persisted} if you need to reference it.` : '',
|
|
399
|
-
]
|
|
400
|
-
.filter(Boolean)
|
|
401
|
-
.join('\n'),
|
|
402
|
-
});
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
pushToolResult(state, callbacks, toolCallId, toolName, [
|
|
406
|
-
'Plan rejected by user. You are still in plan mode.',
|
|
407
|
-
"Read the user's next message for feedback, revise the plan accordingly,",
|
|
408
|
-
'and call exitPlanMode again with the revised body. Consider asking the user',
|
|
409
|
-
'a clarifying question via askUser if you are unsure what to change.',
|
|
410
|
-
].join('\n'), true);
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
// ── Doom-loop detection ──
|
|
414
|
-
// For manual tools we pre-block. For auto-executed tools the call has
|
|
415
|
-
// already run (result landed in state.messages via collectTurnResponse);
|
|
416
|
-
// we still record the hash and, on soft-block, push a supplemental notice
|
|
417
|
-
// so the next turn sees a clear stop signal. On hard-block, we additionally
|
|
418
|
-
// prompt the user before returning.
|
|
419
|
-
const isAutoExecuted = AUTO_EXECUTED_TOOLS.has(toolName);
|
|
161
|
+
/** ── askUser ──
|
|
162
|
+
* Bypasses the loop guard intentionally. The model asking the user the same
|
|
163
|
+
* clarifying question twice is almost always deliberate (e.g. the user
|
|
164
|
+
* answered ambiguously); blocking it would silently break the UX. */
|
|
165
|
+
async function handleAskUser(ctx) {
|
|
166
|
+
const { input, toolCallId, toolName, state, callbacks } = ctx;
|
|
167
|
+
const question = input.question;
|
|
168
|
+
const optionsList = input.options;
|
|
169
|
+
const answer = await callbacks.onAskUser(question, optionsList);
|
|
170
|
+
pushToolResult(state, callbacks, toolCallId, toolName, `User answered: ${answer}`);
|
|
171
|
+
}
|
|
172
|
+
/** ── task (sub-agent dispatch) ── */
|
|
173
|
+
async function handleTask(ctx) {
|
|
174
|
+
const { input, toolCallId, toolName, state, options, callbacks, parentModel } = ctx;
|
|
175
|
+
const agentName = input.subagent_type;
|
|
176
|
+
const description = input.description;
|
|
177
|
+
const taskPrompt = input.prompt;
|
|
178
|
+
reportProgress(toolCallId, `Task: ${description} (${agentName})`);
|
|
179
|
+
const result = await runSubAgent({
|
|
180
|
+
parentState: state,
|
|
181
|
+
parentOptions: options,
|
|
182
|
+
callbacks,
|
|
183
|
+
toolCallId,
|
|
184
|
+
agentName,
|
|
185
|
+
description,
|
|
186
|
+
prompt: taskPrompt,
|
|
187
|
+
knowledgeContext: state.knowledgeContext ?? '',
|
|
188
|
+
isGitRepo: state.isGitRepo ?? false,
|
|
189
|
+
}, parentModel);
|
|
190
|
+
const statsLine = `<task_stats tool_calls="${result.toolCallCount}" tokens="${result.tokenUsage.totalTokens}" duration_ms="${result.durationMs}" />`;
|
|
191
|
+
pushToolResult(state, callbacks, toolCallId, toolName, `${result.resultText}\n${statsLine}`);
|
|
192
|
+
}
|
|
193
|
+
/** Manual tools that bypass the loop guard and the writeFile/edit/shell
|
|
194
|
+
* permission + execution pipeline below. Each handler owns its own
|
|
195
|
+
* pushToolResult call. Adding a new bypass tool is a one-line entry here. */
|
|
196
|
+
const BYPASS_LOOP_GUARD_HANDLERS = {
|
|
197
|
+
askUser: handleAskUser,
|
|
198
|
+
task: handleTask,
|
|
199
|
+
todoWrite: ({ input, toolCallId, state, callbacks }) => handleTodoWrite(input, toolCallId, state, callbacks, pushToolResult),
|
|
200
|
+
enterPlanMode: ({ input, toolCallId, state, options, callbacks }) => handleEnterPlanMode(input, toolCallId, state, options, callbacks, pushToolResult),
|
|
201
|
+
exitPlanMode: ({ input, toolCallId, state, callbacks }) => handleExitPlanMode(input, toolCallId, state, callbacks, pushToolResult),
|
|
202
|
+
};
|
|
203
|
+
/** Run the loop-guard machinery for a non-bypass tool. Returns true if the
|
|
204
|
+
* tool was blocked (caller should stop dispatching).
|
|
205
|
+
*
|
|
206
|
+
* Auto-executed tools never reach this path — `processToolCalls` skips
|
|
207
|
+
* them earlier because their result is already in `state.messages` from
|
|
208
|
+
* the SDK's `response.messages`, and re-running the loop-guard here would
|
|
209
|
+
* push the synthesized result on top of that or inject a mid-iteration
|
|
210
|
+
* user message that breaks the assistant→tool ordering strict providers
|
|
211
|
+
* require.
|
|
212
|
+
*
|
|
213
|
+
* `deferred` collects messages that must land AFTER the iteration's tool
|
|
214
|
+
* results — pushing them mid-loop creates the
|
|
215
|
+
* `assistant → tool A → user → tool B` pattern that DeepSeek 400s on. */
|
|
216
|
+
async function applyLoopGuard(ctx, deferred) {
|
|
217
|
+
const { toolName, input, toolCallId, state, callbacks } = ctx;
|
|
420
218
|
const loopCheck = checkForLoop(state, toolName, input, toolCallId);
|
|
421
|
-
if (loopCheck.kind
|
|
219
|
+
if (loopCheck.kind === 'ok') {
|
|
422
220
|
recordToolCall(state, toolName, input, loopCheck.hash);
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
recordToolCall(state, toolName, input, loopCheck.hash);
|
|
224
|
+
const guardMessage = `[loop-guard] ${loopCheck.message}`;
|
|
225
|
+
// Manual tool — short-circuit by synthesising the result. The tool body
|
|
226
|
+
// never runs; no side effects, no permission prompt.
|
|
227
|
+
pushToolResult(state, callbacks, toolCallId, toolName, guardMessage, true);
|
|
228
|
+
if (loopCheck.kind === 'hard-block') {
|
|
229
|
+
const answer = await callbacks
|
|
230
|
+
.onAskUser(`The model keeps calling ${toolName} with identical arguments. How do you want to proceed?`, [
|
|
231
|
+
{ label: 'Pause', description: 'Pause the turn — you can type a new instruction.' },
|
|
232
|
+
{ label: 'Continue', description: 'Let the model keep trying; the loop guard stays armed.' },
|
|
233
|
+
])
|
|
234
|
+
.catch(() => 'Pause');
|
|
235
|
+
if (answer.toLowerCase().startsWith('pause')) {
|
|
236
|
+
// Clear the recent-calls window so the guard doesn't immediately
|
|
237
|
+
// re-trigger on the next turn if the model legitimately retries
|
|
238
|
+
// once with the same args under the user's guidance.
|
|
239
|
+
state.recentToolCalls = [];
|
|
240
|
+
// Defer until after the iteration so the user-role message lands at
|
|
241
|
+
// the END of this turn's messages, not between tool results.
|
|
242
|
+
deferred.push({
|
|
428
243
|
role: 'user',
|
|
429
|
-
content:
|
|
244
|
+
content: '[loop-guard] User paused the loop. Wait for further instructions rather than calling more tools.',
|
|
430
245
|
});
|
|
431
|
-
callbacks.onToolResult(toolCallId, `[loop-guard] ${loopCheck.message}`, true);
|
|
432
246
|
}
|
|
433
|
-
else {
|
|
434
|
-
// Manual tool — short-circuit by synthesising the result. The tool body
|
|
435
|
-
// never runs; no side effects, no permission prompt.
|
|
436
|
-
pushToolResult(state, callbacks, toolCallId, toolName, `[loop-guard] ${loopCheck.message}`, true);
|
|
437
|
-
}
|
|
438
|
-
if (loopCheck.kind === 'hard-block') {
|
|
439
|
-
const answer = await callbacks
|
|
440
|
-
.onAskUser(`The model keeps calling ${toolName} with identical arguments. How do you want to proceed?`, [
|
|
441
|
-
{ label: 'Pause', description: 'Pause the turn — you can type a new instruction.' },
|
|
442
|
-
{ label: 'Continue', description: 'Let the model keep trying; the loop guard stays armed.' },
|
|
443
|
-
])
|
|
444
|
-
.catch(() => 'Pause');
|
|
445
|
-
if (answer.toLowerCase().startsWith('pause')) {
|
|
446
|
-
// Clear the recent-calls window so the guard doesn't immediately
|
|
447
|
-
// re-trigger on the next turn if the model legitimately retries
|
|
448
|
-
// once with the same args under the user's guidance.
|
|
449
|
-
state.recentToolCalls = [];
|
|
450
|
-
state.messages.push({
|
|
451
|
-
role: 'user',
|
|
452
|
-
content: '[loop-guard] User paused the loop. Wait for further instructions rather than calling more tools.',
|
|
453
|
-
});
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
return;
|
|
457
247
|
}
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
/** Permission gate for writeFile/edit/shell. Returns true if execution
|
|
251
|
+
* should continue, false if it was blocked / denied / aborted. */
|
|
252
|
+
async function checkWriteOrShellPermission(ctx) {
|
|
253
|
+
const { toolName, input, toolCallId, state, options, callbacks } = ctx;
|
|
254
|
+
if (toolName !== 'writeFile' && toolName !== 'edit' && toolName !== 'shell')
|
|
255
|
+
return true;
|
|
256
|
+
const approved = await checkPermission({ toolCallId, toolName, input }, options.trustMode, callbacks.onAskPermission, state.permissionMode, process.cwd());
|
|
257
|
+
if (options.abortSignal?.aborted) {
|
|
258
|
+
pushToolResult(state, callbacks, toolCallId, toolName, '[Tool execution interrupted by user]', true);
|
|
259
|
+
return false;
|
|
470
260
|
}
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
261
|
+
if (!approved) {
|
|
262
|
+
pushToolResult(state, callbacks, toolCallId, toolName, 'Permission denied by user.');
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
/** Run the underlying side-effecting tool body for writeFile/edit/shell.
|
|
268
|
+
* Auto-executed tools return early because the AI SDK has already produced
|
|
269
|
+
* their result. Returns the post-execution { output, isError } pair, or
|
|
270
|
+
* null when there's nothing to push (auto-executed). */
|
|
271
|
+
async function executeWriteOrShell(ctx) {
|
|
272
|
+
const { toolName, input, toolCallId, state, options, callbacks } = ctx;
|
|
474
273
|
try {
|
|
475
274
|
if (toolName === 'writeFile' || toolName === 'edit') {
|
|
476
|
-
output = await executeWriteTool(toolName, input, toolCallId, callbacks);
|
|
275
|
+
const output = await executeWriteTool(toolName, input, toolCallId, callbacks, options.abortSignal);
|
|
477
276
|
// executeWriteTool returns "Error: ..." strings for in-band failures
|
|
478
277
|
// (missing match, non-unique match) rather than throwing — surface
|
|
479
278
|
// those as errored results so the scrollback line flips to red.
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
else
|
|
279
|
+
const isError = isToolErrorString(output);
|
|
280
|
+
if (!isError)
|
|
483
281
|
state.filesModified.add(input.filePath);
|
|
282
|
+
return { output, isError };
|
|
484
283
|
}
|
|
485
|
-
|
|
284
|
+
if (toolName === 'shell') {
|
|
486
285
|
const timeout = input.timeout ?? 30000;
|
|
487
286
|
const shellResult = await executeShell(input.command, timeout, options.abortSignal, callbacks, toolCallId);
|
|
488
|
-
output
|
|
489
|
-
isError = shellResult.isError;
|
|
490
|
-
}
|
|
491
|
-
else {
|
|
492
|
-
// Tools with execute (readFile, glob, grep, etc.) are auto-executed by AI SDK
|
|
493
|
-
return;
|
|
287
|
+
return { output: shellResult.output, isError: shellResult.isError };
|
|
494
288
|
}
|
|
289
|
+
// Tools with execute (readFile, glob, grep, etc.) are auto-executed by AI SDK
|
|
290
|
+
return null;
|
|
495
291
|
}
|
|
496
292
|
catch (err) {
|
|
497
|
-
|
|
498
|
-
|
|
293
|
+
return { output: toolErrorFromUnknown(err), isError: true };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** Handle a single tool call. Returns when the call has been fully dispatched.
|
|
297
|
+
* `parentModel` is the LanguageModel instance for the current loop — needed
|
|
298
|
+
* by the task tool to pass as fallback when the sub-agent doesn't override.
|
|
299
|
+
* `deferred` is the per-turn deferred-message queue threaded down to
|
|
300
|
+
* `applyLoopGuard`; messages collected here are flushed after the entire
|
|
301
|
+
* iteration in `processToolCalls`. */
|
|
302
|
+
async function handleToolCall(tc, state, options, callbacks, parentModel, deferred) {
|
|
303
|
+
const ctx = {
|
|
304
|
+
toolName: tc.toolName,
|
|
305
|
+
input: tc.input,
|
|
306
|
+
toolCallId: tc.toolCallId,
|
|
307
|
+
state,
|
|
308
|
+
options,
|
|
309
|
+
callbacks,
|
|
310
|
+
parentModel,
|
|
311
|
+
};
|
|
312
|
+
const bypassHandler = BYPASS_LOOP_GUARD_HANDLERS[ctx.toolName];
|
|
313
|
+
if (bypassHandler) {
|
|
314
|
+
await bypassHandler(ctx);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (await applyLoopGuard(ctx, deferred))
|
|
318
|
+
return;
|
|
319
|
+
if (!(await checkWriteOrShellPermission(ctx)))
|
|
320
|
+
return;
|
|
321
|
+
const result = await executeWriteOrShell(ctx);
|
|
322
|
+
if (result == null)
|
|
323
|
+
return;
|
|
324
|
+
pushToolResult(state, callbacks, ctx.toolCallId, ctx.toolName, truncateToolResult(result.output), result.isError);
|
|
325
|
+
}
|
|
326
|
+
/** Collect every toolCallId the AI SDK actually committed to the
|
|
327
|
+
* assistant message in this turn. The SDK's `result.toolCalls` promise
|
|
328
|
+
* is independent of `response.messages` — when zod validation rejects
|
|
329
|
+
* a malformed tool input mid-stream the SDK emits a `tool-error` chunk
|
|
330
|
+
* and excludes that tool_call from response.messages, but it can still
|
|
331
|
+
* surface in `toolCalls`. Running such a "ghost" call would have two
|
|
332
|
+
* bad outcomes:
|
|
333
|
+
* 1. write/edit/shell would fire a real side effect for a call the
|
|
334
|
+
* model never officially committed to.
|
|
335
|
+
* 2. The pushed tool_result would be an orphan in state.messages
|
|
336
|
+
* (no preceding assistant tool_call with that id) and the next
|
|
337
|
+
* API request would 400 with "tool must be a response to a
|
|
338
|
+
* preceding message with tool_calls".
|
|
339
|
+
* Returning the set lets `processToolCalls` filter the SDK's list
|
|
340
|
+
* before any handler runs.
|
|
341
|
+
*
|
|
342
|
+
* Walks from the END of state.messages backwards, collecting tool-call
|
|
343
|
+
* ids from EVERY assistant message we encounter until we hit a
|
|
344
|
+
* non-assistant/tool boundary — covers multi-assistant turn structures
|
|
345
|
+
* some providers produce while still cutting off at the previous user
|
|
346
|
+
* message so old turns' ids don't bleed in. */
|
|
347
|
+
function collectActiveAssistantToolCallIds(state) {
|
|
348
|
+
const ids = new Set();
|
|
349
|
+
for (let i = state.messages.length - 1; i >= 0; i--) {
|
|
350
|
+
const msg = state.messages[i];
|
|
351
|
+
if (!msg)
|
|
352
|
+
continue;
|
|
353
|
+
if (msg.role === 'user')
|
|
354
|
+
break;
|
|
355
|
+
if (msg.role !== 'assistant')
|
|
356
|
+
continue;
|
|
357
|
+
if (!Array.isArray(msg.content))
|
|
358
|
+
continue;
|
|
359
|
+
for (const part of msg.content) {
|
|
360
|
+
if (part?.type === 'tool-call' && typeof part.toolCallId === 'string') {
|
|
361
|
+
ids.add(part.toolCallId);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
return ids;
|
|
366
|
+
}
|
|
367
|
+
/** Collect tool_call_ids that ALREADY have a tool-result message in the
|
|
368
|
+
* current turn's window of state.messages. Two distinct upstream paths
|
|
369
|
+
* drop a result here before `processToolCalls` runs:
|
|
370
|
+
* 1. AI SDK auto-executed tools (readFile / glob / grep / listDir /
|
|
371
|
+
* webFetch / webSearch) — their result is in `response.messages`
|
|
372
|
+
* and gets pushed by `collectTurnResponse` before we iterate.
|
|
373
|
+
* 2. AI SDK auto-rejection of an unavailable tool — when a sub-agent's
|
|
374
|
+
* toolFilter excludes a tool the model still emits a tool-call for
|
|
375
|
+
* (e.g. `general-purpose` agent calling `writeFile`), the SDK
|
|
376
|
+
* synthesizes an `error-text` tool-result so the assistant message
|
|
377
|
+
* isn't left with an orphan tool-call.
|
|
378
|
+
* In both cases re-running the tool here is wrong:
|
|
379
|
+
* - For (1) the tool already executed; another run would duplicate
|
|
380
|
+
* side effects (re-fetch a webpage, re-trigger a saveKnowledge).
|
|
381
|
+
* - For (2) the tool isn't supposed to run at all in this agent's
|
|
382
|
+
* filter, but `executeWriteTool` dispatches by name and would
|
|
383
|
+
* happily fire writeFile, creating a real side effect AND pushing
|
|
384
|
+
* a duplicate tool-result that DeepSeek 400s on next turn.
|
|
385
|
+
* Same turn-boundary logic as collectActiveAssistantToolCallIds —
|
|
386
|
+
* walk back from end-of-messages, stop at the first user message. */
|
|
387
|
+
function collectFulfilledToolCallIds(state) {
|
|
388
|
+
const ids = new Set();
|
|
389
|
+
for (let i = state.messages.length - 1; i >= 0; i--) {
|
|
390
|
+
const msg = state.messages[i];
|
|
391
|
+
if (!msg)
|
|
392
|
+
continue;
|
|
393
|
+
if (msg.role === 'user')
|
|
394
|
+
break;
|
|
395
|
+
if (msg.role !== 'tool')
|
|
396
|
+
continue;
|
|
397
|
+
if (!Array.isArray(msg.content))
|
|
398
|
+
continue;
|
|
399
|
+
for (const part of msg.content) {
|
|
400
|
+
if (part?.type === 'tool-result' && typeof part.toolCallId === 'string') {
|
|
401
|
+
ids.add(part.toolCallId);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
499
404
|
}
|
|
500
|
-
|
|
405
|
+
return ids;
|
|
501
406
|
}
|
|
502
|
-
/**
|
|
503
|
-
*
|
|
407
|
+
/** Group consecutive `task` tool-calls into a single batch so they can be
|
|
408
|
+
* dispatched in parallel; everything else gets a singleton batch and
|
|
409
|
+
* dispatches one-at-a-time. Sub-agents launched by the `task` tool are
|
|
410
|
+
* the only manual tool we hand-execute in `processToolCalls` that's
|
|
411
|
+
* truly isolated:
|
|
412
|
+
* - each `runSubAgent` builds a fresh `LoopState` (own messages, own
|
|
413
|
+
* `recentToolCalls`, own todos, own permission mode)
|
|
414
|
+
* - `parentState.tokenUsage` is updated by additive accumulation only
|
|
415
|
+
* after the sub-agent completes, so concurrent updates can't get
|
|
416
|
+
* torn (single-threaded event loop + plain `+=` writes)
|
|
417
|
+
* - permission dialogs from concurrent sub-agents queue naturally on
|
|
418
|
+
* the parent UI's `permissionResolversRef`
|
|
419
|
+
* Every other manual tool mutates shared state and must stay serial:
|
|
420
|
+
* - `writeFile` / `edit` mutate the filesystem and `state.filesModified`
|
|
421
|
+
* - `shell` streams stdout/stderr to the parent UI as it arrives —
|
|
422
|
+
* interleaved bytes from concurrent shells would scramble the live
|
|
423
|
+
* indicator
|
|
424
|
+
* - `askUser` / permission dialogs hold the UI; running two at once
|
|
425
|
+
* would race the dialog state machine
|
|
426
|
+
* - `todoWrite` / `enterPlanMode` / `exitPlanMode` mutate `LoopState`
|
|
427
|
+
* fields that the next turn reads
|
|
428
|
+
* Auto-executed tools (readFile / glob / grep / listDir / webFetch /
|
|
429
|
+
* webSearch) don't appear here — by the time `processToolCalls` runs,
|
|
430
|
+
* the SDK has already executed them and the skip-fulfilled pre-pass
|
|
431
|
+
* short-circuits them out. */
|
|
432
|
+
export function partitionToolCalls(calls) {
|
|
433
|
+
const batches = [];
|
|
434
|
+
let i = 0;
|
|
435
|
+
while (i < calls.length) {
|
|
436
|
+
let end = i + 1;
|
|
437
|
+
if (calls[i].toolName === 'task') {
|
|
438
|
+
while (end < calls.length && calls[end].toolName === 'task') {
|
|
439
|
+
end++;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
batches.push(calls.slice(i, end));
|
|
443
|
+
i = end;
|
|
444
|
+
}
|
|
445
|
+
return batches;
|
|
446
|
+
}
|
|
447
|
+
/** Handle all tool calls from a single model turn.
|
|
448
|
+
*
|
|
449
|
+
* Consecutive `task` tool-calls dispatch in parallel via Promise.all;
|
|
450
|
+
* every other tool runs one at a time. See `partitionToolCalls` for the
|
|
451
|
+
* full rationale on why only sub-agents are safe to fan out.
|
|
452
|
+
*
|
|
453
|
+
* `parentModel` is threaded through so the task tool can pass it to
|
|
454
|
+
* `runSubAgent`. */
|
|
504
455
|
export async function processToolCalls(toolCalls, state, options, callbacks, parentModel) {
|
|
505
|
-
|
|
506
|
-
|
|
456
|
+
const activeIds = collectActiveAssistantToolCallIds(state);
|
|
457
|
+
const fulfilledIds = collectFulfilledToolCallIds(state);
|
|
458
|
+
// Per-turn queue for messages that must land AFTER every tool-result
|
|
459
|
+
// we push in this loop. Pushing a `role: 'user'` message between two
|
|
460
|
+
// tool-results creates the shape that DeepSeek's strict ordering
|
|
461
|
+
// rejects — we collect them here and flush at the end of the loop.
|
|
462
|
+
const deferred = [];
|
|
463
|
+
// Pre-pass: drop ghost calls and account for already-fulfilled calls.
|
|
464
|
+
// What survives goes into `liveCalls` which is what we actually
|
|
465
|
+
// dispatch. Doing this BEFORE partitioning keeps the parallel-batch
|
|
466
|
+
// dispatch simple — every entry in the batch is a real call we need
|
|
467
|
+
// to run.
|
|
468
|
+
const liveCalls = [];
|
|
469
|
+
for (const tc of toolCalls) {
|
|
470
|
+
// Skip ghost calls the SDK rejected mid-stream — see
|
|
471
|
+
// collectActiveAssistantToolCallIds for the full rationale. Don't
|
|
472
|
+
// pushToolResult either: the assistant message has no matching
|
|
473
|
+
// tool_call, so any result we emit would be an orphan that the
|
|
474
|
+
// sanitizer drops next turn anyway. Belt-and-suspenders: the
|
|
475
|
+
// sanitizer's reverse-orphan branch would still clean up if this
|
|
476
|
+
// check ever lets one through.
|
|
477
|
+
if (activeIds.size > 0 && !activeIds.has(tc.toolCallId)) {
|
|
478
|
+
debugLog('tool-exec.skip-ghost', `${tc.toolName} ${tc.toolCallId} — not in assistant tool_calls, likely SDK tool-error reject`);
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
// Skip already-fulfilled calls — see collectFulfilledToolCallIds.
|
|
482
|
+
// Still record the call in the loop-guard window so a runaway
|
|
483
|
+
// pattern on the same auto-executed tool can be circuit-broken on
|
|
484
|
+
// a future turn; if the guard fires, defer the user-role nudge
|
|
485
|
+
// until after iteration.
|
|
486
|
+
if (fulfilledIds.has(tc.toolCallId)) {
|
|
487
|
+
debugLog('tool-exec.skip-fulfilled', `${tc.toolName} ${tc.toolCallId} — tool-result already in state.messages`);
|
|
488
|
+
const loopCheck = checkForLoop(state, tc.toolName, tc.input, tc.toolCallId);
|
|
489
|
+
recordToolCall(state, tc.toolName, tc.input, loopCheck.hash);
|
|
490
|
+
if (loopCheck.kind !== 'ok') {
|
|
491
|
+
deferred.push({ role: 'user', content: `[loop-guard] ${loopCheck.message}` });
|
|
492
|
+
}
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
liveCalls.push(tc);
|
|
496
|
+
}
|
|
497
|
+
// Dispatch in batches. A batch of size 1 is functionally identical to
|
|
498
|
+
// a plain `await handleToolCall(...)` — Promise.all over a single
|
|
499
|
+
// promise resolves the same way — so the parallel path uniformly
|
|
500
|
+
// handles both cases.
|
|
501
|
+
const batches = partitionToolCalls(liveCalls);
|
|
502
|
+
let dispatched = 0;
|
|
503
|
+
for (const batch of batches) {
|
|
507
504
|
// User pressed Esc / Ctrl+C. The currently running tool (if any) has
|
|
508
505
|
// already been SIGKILL'd via the shell provider's cancelSignal. For
|
|
509
|
-
// every remaining tool_call
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
506
|
+
// every remaining tool_call we still need to push a synthetic
|
|
507
|
+
// tool_result — orphan tool_calls without a matching result would
|
|
508
|
+
// make the next API request fail with "tool_use without tool_result"
|
|
509
|
+
// the moment the user types another prompt.
|
|
513
510
|
if (options.abortSignal?.aborted) {
|
|
514
|
-
for (let j =
|
|
515
|
-
|
|
516
|
-
pushToolResult(state, callbacks, skipped.toolCallId, skipped.toolName, '[Tool execution interrupted by user]', true);
|
|
511
|
+
for (let j = dispatched; j < liveCalls.length; j++) {
|
|
512
|
+
pushToolResult(state, callbacks, liveCalls[j].toolCallId, liveCalls[j].toolName, '[Tool execution interrupted by user]', true);
|
|
517
513
|
}
|
|
518
|
-
|
|
514
|
+
break;
|
|
519
515
|
}
|
|
520
|
-
await handleToolCall(tc, state, options, callbacks, parentModel);
|
|
516
|
+
await Promise.all(batch.map((tc) => handleToolCall(tc, state, options, callbacks, parentModel, deferred)));
|
|
517
|
+
dispatched += batch.length;
|
|
521
518
|
}
|
|
519
|
+
// Flush deferred messages AFTER all tool_results in this turn — they
|
|
520
|
+
// sit at the very end of state.messages, where the next runTurn sees
|
|
521
|
+
// them as the most recent context but they don't break the
|
|
522
|
+
// assistant→tool ordering the SDK will replay to the provider.
|
|
523
|
+
if (deferred.length > 0)
|
|
524
|
+
state.messages.push(...deferred);
|
|
522
525
|
}
|
|
523
526
|
//# sourceMappingURL=tool-execution.js.map
|