@x-code-cli/core 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/dist/agent/api-errors.js +1 -1
  2. package/dist/agent/api-errors.js.map +1 -1
  3. package/dist/agent/compression.d.ts +25 -0
  4. package/dist/agent/compression.d.ts.map +1 -0
  5. package/dist/agent/compression.js +105 -0
  6. package/dist/agent/compression.js.map +1 -0
  7. package/dist/agent/diff.js.map +1 -1
  8. package/dist/agent/file-ingest.d.ts +14 -0
  9. package/dist/agent/file-ingest.d.ts.map +1 -1
  10. package/dist/agent/file-ingest.js +125 -34
  11. package/dist/agent/file-ingest.js.map +1 -1
  12. package/dist/agent/light-compact.d.ts.map +1 -1
  13. package/dist/agent/light-compact.js +0 -19
  14. package/dist/agent/light-compact.js.map +1 -1
  15. package/dist/agent/loop-guard.d.ts.map +1 -1
  16. package/dist/agent/loop-guard.js.map +1 -1
  17. package/dist/agent/loop-state.d.ts.map +1 -1
  18. package/dist/agent/loop-state.js +8 -1
  19. package/dist/agent/loop-state.js.map +1 -1
  20. package/dist/agent/loop.d.ts +2 -3
  21. package/dist/agent/loop.d.ts.map +1 -1
  22. package/dist/agent/loop.js +16 -92
  23. package/dist/agent/loop.js.map +1 -1
  24. package/dist/agent/messages.d.ts +9 -0
  25. package/dist/agent/messages.d.ts.map +1 -1
  26. package/dist/agent/messages.js +15 -0
  27. package/dist/agent/messages.js.map +1 -1
  28. package/dist/agent/plan-storage.d.ts.map +1 -1
  29. package/dist/agent/plan-storage.js +1 -1
  30. package/dist/agent/plan-storage.js.map +1 -1
  31. package/dist/agent/plan-tools.d.ts +8 -0
  32. package/dist/agent/plan-tools.d.ts.map +1 -0
  33. package/dist/agent/plan-tools.js +150 -0
  34. package/dist/agent/plan-tools.js.map +1 -0
  35. package/dist/agent/provider-compat.d.ts.map +1 -1
  36. package/dist/agent/provider-compat.js.map +1 -1
  37. package/dist/agent/sub-agents/built-in.d.ts.map +1 -1
  38. package/dist/agent/sub-agents/built-in.js +41 -15
  39. package/dist/agent/sub-agents/built-in.js.map +1 -1
  40. package/dist/agent/sub-agents/loader.d.ts.map +1 -1
  41. package/dist/agent/sub-agents/loader.js.map +1 -1
  42. package/dist/agent/sub-agents/runner.d.ts.map +1 -1
  43. package/dist/agent/sub-agents/runner.js +12 -8
  44. package/dist/agent/sub-agents/runner.js.map +1 -1
  45. package/dist/agent/tool-execution.d.ts +34 -2
  46. package/dist/agent/tool-execution.d.ts.map +1 -1
  47. package/dist/agent/tool-execution.js +363 -360
  48. package/dist/agent/tool-execution.js.map +1 -1
  49. package/dist/agent/tool-result-sanitize.d.ts +21 -7
  50. package/dist/agent/tool-result-sanitize.d.ts.map +1 -1
  51. package/dist/agent/tool-result-sanitize.js +56 -30
  52. package/dist/agent/tool-result-sanitize.js.map +1 -1
  53. package/dist/agent/vision-fallback.d.ts.map +1 -1
  54. package/dist/agent/vision-fallback.js +3 -14
  55. package/dist/agent/vision-fallback.js.map +1 -1
  56. package/dist/index.d.ts +3 -0
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +3 -0
  59. package/dist/index.js.map +1 -1
  60. package/dist/permissions/index.d.ts +9 -4
  61. package/dist/permissions/index.d.ts.map +1 -1
  62. package/dist/permissions/index.js +41 -6
  63. package/dist/permissions/index.js.map +1 -1
  64. package/dist/permissions/session-store.d.ts +34 -20
  65. package/dist/permissions/session-store.d.ts.map +1 -1
  66. package/dist/permissions/session-store.js +94 -34
  67. package/dist/permissions/session-store.js.map +1 -1
  68. package/dist/providers/cache-control.d.ts.map +1 -1
  69. package/dist/providers/cache-control.js +0 -29
  70. package/dist/providers/cache-control.js.map +1 -1
  71. package/dist/providers/thinking.d.ts.map +1 -1
  72. package/dist/providers/thinking.js +2 -6
  73. package/dist/providers/thinking.js.map +1 -1
  74. package/dist/tools/ask-user.d.ts.map +1 -1
  75. package/dist/tools/ask-user.js +17 -7
  76. package/dist/tools/ask-user.js.map +1 -1
  77. package/dist/tools/edit.d.ts.map +1 -1
  78. package/dist/tools/edit.js +8 -1
  79. package/dist/tools/edit.js.map +1 -1
  80. package/dist/tools/glob.d.ts.map +1 -1
  81. package/dist/tools/glob.js +9 -2
  82. package/dist/tools/glob.js.map +1 -1
  83. package/dist/tools/grep.d.ts +1 -1
  84. package/dist/tools/grep.d.ts.map +1 -1
  85. package/dist/tools/grep.js +29 -6
  86. package/dist/tools/grep.js.map +1 -1
  87. package/dist/tools/index.d.ts +1 -1
  88. package/dist/tools/list-dir.d.ts.map +1 -1
  89. package/dist/tools/list-dir.js.map +1 -1
  90. package/dist/tools/read-file.d.ts.map +1 -1
  91. package/dist/tools/read-file.js +78 -36
  92. package/dist/tools/read-file.js.map +1 -1
  93. package/dist/tools/shell-provider.d.ts +1 -0
  94. package/dist/tools/shell-provider.d.ts.map +1 -1
  95. package/dist/tools/shell-provider.js +7 -0
  96. package/dist/tools/shell-provider.js.map +1 -1
  97. package/dist/tools/shell-utils.d.ts.map +1 -1
  98. package/dist/tools/shell-utils.js +45 -2
  99. package/dist/tools/shell-utils.js.map +1 -1
  100. package/dist/tools/shell.d.ts.map +1 -1
  101. package/dist/tools/shell.js +15 -1
  102. package/dist/tools/shell.js.map +1 -1
  103. package/dist/tools/task.d.ts.map +1 -1
  104. package/dist/tools/task.js +4 -4
  105. package/dist/tools/task.js.map +1 -1
  106. package/dist/tools/todo-write.d.ts.map +1 -1
  107. package/dist/tools/todo-write.js.map +1 -1
  108. package/dist/tools/web-fetch.d.ts +2 -0
  109. package/dist/tools/web-fetch.d.ts.map +1 -1
  110. package/dist/tools/web-fetch.js +92 -27
  111. package/dist/tools/web-fetch.js.map +1 -1
  112. package/dist/tools/write-file.d.ts.map +1 -1
  113. package/dist/tools/write-file.js +7 -1
  114. package/dist/tools/write-file.js.map +1 -1
  115. package/dist/types/index.d.ts +1 -1
  116. package/dist/types/index.d.ts.map +1 -1
  117. package/dist/utils/lru-cache.d.ts +17 -0
  118. package/dist/utils/lru-cache.d.ts.map +1 -0
  119. package/dist/utils/lru-cache.js +40 -0
  120. package/dist/utils/lru-cache.js.map +1 -0
  121. package/dist/utils/media-type.d.ts +5 -0
  122. package/dist/utils/media-type.d.ts.map +1 -0
  123. package/dist/utils/media-type.js +19 -0
  124. package/dist/utils/media-type.js.map +1 -0
  125. package/dist/utils/message-helpers.d.ts +6 -0
  126. package/dist/utils/message-helpers.d.ts.map +1 -0
  127. package/dist/utils/message-helpers.js +14 -0
  128. package/dist/utils/message-helpers.js.map +1 -0
  129. package/package.json +1 -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 { makePlanFilePath, readPlan, writePlan } from './plan-storage.js';
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 `Error: old_string not found in ${filePath}`;
70
+ return toolErrorString(`old_string not found in ${filePath}`);
89
71
  if (count > 1)
90
- return `Error: old_string is not unique in ${filePath} (found ${count} occurrences). Provide more context or set replaceAll: true.`;
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 'Error: unknown write tool';
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
- const stdout = foldShellErrorNoise(toStr(result.stdout));
149
- const stderr = foldShellErrorNoise(toStr(result.stderr));
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 text = output ? `${output}\nExit code ${result.exitCode}` : `Exit code ${result.exitCode}`;
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
- /** Tools whose execution is driven by the AI SDK (they have an `execute` on
168
- * the tool definition). By the time we see them in `processToolCalls`, the
169
- * tool has already run and its result is already in `state.messages`. We
170
- * can't pre-block these only record for loop detection and annotate. */
171
- const AUTO_EXECUTED_TOOLS = new Set(['readFile', 'glob', 'grep', 'listDir', 'webFetch', 'webSearch', 'saveKnowledge']);
172
- /** Handle a single tool call. Returns when the call has been fully dispatched.
173
- * `parentModel` is the LanguageModel instance for the current loop — needed
174
- * by the task tool to pass as fallback when the sub-agent doesn't override. */
175
- async function handleToolCall(tc, state, options, callbacks, parentModel) {
176
- const { toolName, input, toolCallId } = tc;
177
- // ── askUser tool ──
178
- // Skip the loop guard for askUser — the model asking the user the same
179
- // clarifying question twice is almost always intentional (e.g. the user
180
- // answered ambiguously) and blocking it would silently break the UX.
181
- if (toolName === 'askUser') {
182
- const question = input.question;
183
- const optionsList = input.options;
184
- const answer = await callbacks.onAskUser(question, optionsList);
185
- pushToolResult(state, callbacks, toolCallId, toolName, `User answered: ${answer}`);
186
- return;
187
- }
188
- // ── todoWrite tool ──
189
- // Full-replacement semantics: every call rewrites state.todos with
190
- // the model's payload. Auto-clears (drops to []) when every item is
191
- // completed, mirroring Claude Code's TodoWriteTool behavior — the
192
- // user's live UI panel goes back to "no checklist" once the work is
193
- // done, instead of showing a stale all-✓ list forever.
194
- if (toolName === 'todoWrite') {
195
- const raw = input.todos ?? [];
196
- const normalized = [];
197
- for (const t of raw) {
198
- const content = (t.content ?? '').trim();
199
- const activeForm = (t.activeForm ?? '').trim();
200
- // Need at least one identity field otherwise this is just an
201
- // empty entry and there's nothing useful to show or track.
202
- if (!content && !activeForm)
203
- continue;
204
- normalized.push({
205
- content: content || activeForm,
206
- activeForm: activeForm || content,
207
- status: t.status ?? 'pending',
208
- });
209
- }
210
- const allDone = normalized.length > 0 && normalized.every((t) => t.status === 'completed');
211
- state.todos = allDone ? [] : normalized;
212
- callbacks.onTodosUpdate(state.todos);
213
- const dropped = raw.length - normalized.length;
214
- const droppedNote = dropped > 0
215
- ? ` ${dropped} entr${dropped === 1 ? 'y was' : 'ies were'} dropped because they had neither content nor activeForm — please include both fields next time so the user sees clean labels.`
216
- : '';
217
- // Verification nudge: when completing a 3+ item list and none of
218
- // them look like a verification step, remind the model to verify.
219
- const VERIFY_RE = /\b(verif|test|check|lint|build|typecheck|tsc)\b/i;
220
- const needsVerifyNudge = allDone &&
221
- normalized.length >= 3 &&
222
- !normalized.some((t) => VERIFY_RE.test(t.content) || VERIFY_RE.test(t.activeForm));
223
- const verifyNote = needsVerifyNudge
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 !== 'ok') {
219
+ if (loopCheck.kind === 'ok') {
422
220
  recordToolCall(state, toolName, input, loopCheck.hash);
423
- if (isAutoExecuted) {
424
- // The tool result already exists in state.messages. Append a follow-up
425
- // user-role notice so the model's next step has explicit context that
426
- // this path is spinning — without this nudge, some models keep trying.
427
- state.messages.push({
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: `[loop-guard] ${loopCheck.message}`,
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
- recordToolCall(state, toolName, input, loopCheck.hash);
459
- // ── Permission check for write tools and shell ──
460
- if (toolName === 'writeFile' || toolName === 'edit' || toolName === 'shell') {
461
- const approved = await checkPermission({ toolCallId, toolName, input }, options.trustMode, callbacks.onAskPermission, state.permissionMode, process.cwd());
462
- if (options.abortSignal?.aborted) {
463
- pushToolResult(state, callbacks, toolCallId, toolName, '[Tool execution interrupted by user]', true);
464
- return;
465
- }
466
- if (!approved) {
467
- pushToolResult(state, callbacks, toolCallId, toolName, 'Permission denied by user.');
468
- return;
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
- // ── Execute tool ──
472
- let output;
473
- let isError = false;
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
- if (output.startsWith('Error:'))
481
- isError = true;
482
- else
279
+ const isError = isToolErrorString(output);
280
+ if (!isError)
483
281
  state.filesModified.add(input.filePath);
282
+ return { output, isError };
484
283
  }
485
- else if (toolName === 'shell') {
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 = shellResult.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
- output = `Error: ${err instanceof Error ? err.message : String(err)}`;
498
- isError = true;
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
- pushToolResult(state, callbacks, toolCallId, toolName, truncateToolResult(output), isError);
405
+ return ids;
501
406
  }
502
- /** Handle all tool calls from a single model turn, sequentially.
503
- * `parentModel` is threaded through so the task tool can pass it to runSubAgent. */
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
- for (let i = 0; i < toolCalls.length; i++) {
506
- const tc = toolCalls[i];
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 from this turn we still need to push a
510
- // synthetic tool_result — orphan tool_calls without a matching result
511
- // would make the next API request fail with "tool_use without
512
- // tool_result" the moment the user types another prompt.
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 = i; j < toolCalls.length; j++) {
515
- const skipped = toolCalls[j];
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
- return;
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