min-agent 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +154 -216
  2. package/dist/agent.js +1119 -256
  3. package/dist/cli/commands/chat.js +10 -0
  4. package/dist/cli/commands/exec.js +32 -0
  5. package/dist/cli/commands/history.js +58 -0
  6. package/dist/cli/commands/index.js +224 -0
  7. package/dist/cli/commands/init.js +18 -0
  8. package/dist/cli/commands/mcp.js +173 -0
  9. package/dist/cli/commands/memory.js +69 -0
  10. package/dist/cli/commands/models.js +21 -0
  11. package/dist/cli/commands/permission.js +12 -0
  12. package/dist/cli/commands/rules.js +33 -0
  13. package/dist/cli/commands/sandbox.js +13 -0
  14. package/dist/cli/commands/serve.js +9 -0
  15. package/dist/cli/commands/setup.js +4 -0
  16. package/dist/cli/commands/shared.js +16 -0
  17. package/dist/cli/commands/skills.js +119 -0
  18. package/dist/cli/commands/update.js +7 -0
  19. package/dist/cli/commands/write-config.js +30 -0
  20. package/dist/cli/errors.js +36 -0
  21. package/dist/cli/exec-prompt.js +26 -0
  22. package/dist/cli/option-helpers.js +53 -0
  23. package/dist/cli/program.js +180 -0
  24. package/dist/cli.js +5 -888
  25. package/dist/code-mode.js +32 -14
  26. package/dist/compaction.js +347 -160
  27. package/dist/config.js +119 -10
  28. package/dist/confirm.js +56 -9
  29. package/dist/context-window.js +107 -39
  30. package/dist/doom-loop.js +264 -29
  31. package/dist/fetch-timeout.js +152 -0
  32. package/dist/http-approvals.js +60 -0
  33. package/dist/instructions.js +21 -0
  34. package/dist/logger.js +33 -4
  35. package/dist/markdown.js +37 -11
  36. package/dist/mcp.js +328 -30
  37. package/dist/memory.js +97 -56
  38. package/dist/output.js +7 -5
  39. package/dist/permission-cli.js +43 -0
  40. package/dist/plugins.js +46 -8
  41. package/dist/pricing.js +4 -4
  42. package/dist/provider.js +23 -6
  43. package/dist/question-format.js +60 -0
  44. package/dist/sandbox-cli.js +82 -0
  45. package/dist/sandbox.js +403 -0
  46. package/dist/save-throttle.js +45 -0
  47. package/dist/serve/common.js +404 -0
  48. package/dist/serve/routes-chat.js +347 -0
  49. package/dist/serve/routes-mcp.js +212 -0
  50. package/dist/serve/routes-memory.js +66 -0
  51. package/dist/serve/routes-meta.js +205 -0
  52. package/dist/serve/routes-sessions.js +61 -0
  53. package/dist/serve/routes-skills.js +70 -0
  54. package/dist/serve.js +33 -883
  55. package/dist/sessions.js +53 -9
  56. package/dist/skills.js +82 -18
  57. package/dist/title-gen.js +8 -2
  58. package/dist/token-display.js +36 -0
  59. package/dist/tool-display.js +5 -0
  60. package/dist/tool-output.js +1 -3
  61. package/dist/tools/apply_patch.js +85 -11
  62. package/dist/tools/atomic-file.js +35 -0
  63. package/dist/tools/backend.js +2 -2
  64. package/dist/tools/bash.js +57 -19
  65. package/dist/tools/code_search.js +7 -1
  66. package/dist/tools/edit.js +11 -10
  67. package/dist/tools/explore.js +74 -14
  68. package/dist/tools/glob.js +4 -0
  69. package/dist/tools/grep.js +17 -10
  70. package/dist/tools/index.js +6 -21
  71. package/dist/tools/question.js +28 -9
  72. package/dist/tools/read.js +6 -4
  73. package/dist/tools/search-searxng.js +223 -0
  74. package/dist/tools/search-serper.js +189 -0
  75. package/dist/tools/task.js +84 -30
  76. package/dist/tools/todo.js +120 -19
  77. package/dist/tools/web_fetch.js +11 -3
  78. package/dist/tools/web_search.js +66 -556
  79. package/dist/tools/write.js +23 -6
  80. package/dist/tui/App.js +63 -14
  81. package/dist/tui/ConfirmBar.js +45 -13
  82. package/dist/tui/InputBar.js +150 -35
  83. package/dist/tui/MessageList.js +266 -125
  84. package/dist/tui/ModelPicker.js +8 -3
  85. package/dist/tui/QuestionBar.js +51 -19
  86. package/dist/tui/SessionPicker.js +79 -0
  87. package/dist/tui/StatusBar.js +8 -14
  88. package/dist/tui/agent-runner.js +142 -22
  89. package/dist/tui/caret-pos.js +48 -5
  90. package/dist/tui/caret.js +1 -1
  91. package/dist/tui/click-count.js +13 -0
  92. package/dist/tui/drag-state.js +8 -3
  93. package/dist/tui/hydrate.js +129 -0
  94. package/dist/tui/index.js +42 -13
  95. package/dist/tui/input-history.js +92 -11
  96. package/dist/tui/layout.js +75 -4
  97. package/dist/tui/prompt-queue.js +24 -0
  98. package/dist/tui/selection.js +113 -21
  99. package/dist/tui/session-switch.js +28 -0
  100. package/dist/tui/slash-commands.js +22 -6
  101. package/dist/tui/slash-handler.js +233 -58
  102. package/dist/tui/text-width.js +38 -16
  103. package/dist/tui/token-info.js +7 -0
  104. package/dist/tui/tool-children.js +19 -0
  105. package/dist/tui/undo-stack.js +1 -1
  106. package/dist/tui/use-sgr-mouse.js +3 -1
  107. package/dist/tui-chat.js +276 -40
  108. package/dist/updater.js +88 -29
  109. package/dist/xml-search.js +194 -0
  110. package/docs/API.md +257 -25
  111. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  112. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  113. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  114. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  115. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  116. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  117. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  118. package/package.json +6 -1
  119. package/skills/self-config/SKILL.md +90 -0
  120. package/skills/self-config/reference.md +149 -0
package/dist/agent.js CHANGED
@@ -1,25 +1,43 @@
1
- import { streamText, stepCountIs } from "ai";
1
+ import { streamText, stepCountIs, } from "ai";
2
2
  import { readFileSync, existsSync, statSync } from "fs";
3
3
  import path from "path";
4
4
  import { resolveModel } from "./provider.js";
5
- import { loadConfig, getActiveProvider } from "./config.js";
5
+ import { isProviderStallError, describeError } from "./fetch-timeout.js";
6
+ import { getEffectiveConfig, getActiveProvider } from "./config.js";
6
7
  import { getModelPrice, estimateCost } from "./pricing.js";
7
- import { createChatTools, createCodeTools } from "./tools/index.js";
8
- import { initMcp, shutdownMcp, getMcpTools } from "./mcp.js";
8
+ import { createTools } from "./tools/index.js";
9
+ import { scanProject, buildCodeSystemPrompt } from "./code-mode.js";
10
+ import { initMcp, shutdownMcp, getMcpTools, getMcpCatalogTools, getMcpReadOnlyToolIds } from "./mcp.js";
9
11
  import { discoverSkills, attachSkills, collectLoadedSkillNames } from "./skills.js";
10
12
  import { loadInstructions } from "./instructions.js";
11
13
  import { getMemorySystemPrompt, getMemoryTools } from "./memory.js";
12
- import { needsCompaction, compactMessages, estimateTokens, TokenTracker } from "./compaction.js";
13
- import { loadPluginTools } from "./plugins.js";
14
+ import { needsCompaction, compactMessages, estimateTokens, estimateStringTokens, estimateOverheadTokens, TokenTracker, COMPACTION_RATIO, PRUNE_PRESSURE_RATIO, PRUNE_TARGET_RATIO, applyToolPrune, pruneToolOutputs, } from "./compaction.js";
15
+ import { loadPluginTools, getPluginReadOnlyIds } from "./plugins.js";
14
16
  import { MarkdownRenderer } from "./markdown.js";
15
- import { DoomLoopDetector } from "./doom-loop.js";
17
+ import { DoomLoopDetector, STEER_PROMPT, DELIVER_PROMPT, LOOP_HALT_MESSAGE, RESEARCH_STUB_RESULT, WEB_RESEARCH_TOOLS, } from "./doom-loop.js";
16
18
  import { ThinkingBodySplitter, stripThinkingFromAssistantText } from "./assistant-stream.js";
19
+ import { XmlSearchSplitter } from "./xml-search.js";
17
20
  import { printHeader, printDivider, printToolCall, printToolResult, printDone } from "./output.js";
18
- import { createTodoTool } from "./tools/todo.js";
19
- import { getContextWindow } from "./context-window.js";
20
- import { log, logToolCall, logToolResult } from "./logger.js";
21
- import { markSyntheticMessage } from "./synthetic.js";
22
- const MAX_STEPS = 30;
21
+ import { createTodoTool, captureGoal, copyTaskState, emptyTaskState, formatTaskStatePrompt, } from "./tools/todo.js";
22
+ import { getContextWindowInfo } from "./context-window.js";
23
+ import { log, logToolCall, logToolResult, startRunLog, nextRunPass, endRunLog } from "./logger.js";
24
+ import { markSyntheticMessage, isSyntheticMessage } from "./synthetic.js";
25
+ import { runWithInstructionTracker, resetActiveInstructionTracker } from "./instructions.js";
26
+ const UNBOUNDED_STEP_CAP = 100_000;
27
+ const DEFAULT_MAX_CONTINUES = 40;
28
+ /** Attempts (including the first) allowed when the provider answers with nothing at all. */
29
+ const DEFAULT_MAX_EMPTY_ATTEMPTS = 4;
30
+ const EMPTY_RETRY_BASE_DELAY_MS = 1000;
31
+ const EMPTY_RETRY_MAX_DELAY_MS = 8000;
32
+ /** Wall-clock budget for one turn before the model is asked to wrap up. */
33
+ const DEFAULT_TURN_TIME_LIMIT_MS = 20 * 60 * 1000;
34
+ /** Tool steps in one turn before the model is asked to wrap up. */
35
+ const DEFAULT_SOFT_STEP_LIMIT = 120;
36
+ const KEEP_RECENT_STEPS_BEFORE_PRUNE = 4;
37
+ const CONTINUE_PROMPT = "Continue the task from where you left off. Do not wait for another user message. If the work is complete, give a brief summary and stop.";
38
+ const WRAP_UP_PROMPT = "You are out of time budget for this turn. Stop starting new work: finish or save what is already in progress, then reply with a short summary of what is done, what is not, and the exact next step. Do not call more tools than needed to leave things in a consistent state.";
39
+ /** Exported for tests / callers that want to recognise the injected wrap-up turn. */
40
+ export const WRAP_UP_PROMPT_TEXT = WRAP_UP_PROMPT;
23
41
  const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
24
42
  const IMAGE_MIME_TYPES = {
25
43
  ".png": "image/png",
@@ -30,6 +48,40 @@ const IMAGE_MIME_TYPES = {
30
48
  };
31
49
  const DIM_STYLE = "NO_COLOR" in process.env ? { dim: "", reset: "" } : { dim: "\x1b[90m", reset: "\x1b[0m" };
32
50
  /** Shown immediately so the terminal does not look frozen while MCP / rules load. */
51
+ function resolveMaxSteps(raw) {
52
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw < 1) {
53
+ return { cap: UNBOUNDED_STEP_CAP, bounded: false };
54
+ }
55
+ return { cap: Math.floor(raw), bounded: true };
56
+ }
57
+ /**
58
+ * Backoff before re-sending a request the provider answered with an empty
59
+ * stream. The first retry is immediate (most empty replies are one-off), later
60
+ * ones back off so a stalling gateway is not hammered.
61
+ */
62
+ export function emptyRetryDelay(attempt, baseMs = EMPTY_RETRY_BASE_DELAY_MS) {
63
+ if (attempt <= 1 || baseMs <= 0)
64
+ return 0;
65
+ return Math.min(EMPTY_RETRY_MAX_DELAY_MS, baseMs * 2 ** (attempt - 2));
66
+ }
67
+ function sleep(ms) {
68
+ if (ms <= 0)
69
+ return Promise.resolve();
70
+ return new Promise((resolve) => setTimeout(resolve, ms));
71
+ }
72
+ /** User-facing note when the provider kept answering with an empty stream. */
73
+ export function emptyResponseMessage(attempts) {
74
+ return `模型连续 ${attempts} 次返回空响应(provider 可能超时或被限流),本轮已停止。发送消息可重试`;
75
+ }
76
+ /** User-facing note when a turn hit its time / step budget. */
77
+ export const WRAP_UP_MESSAGE = "本轮已达时间或步数预算,已让模型收尾并停止。发送消息可继续";
78
+ function resolveLimit(...values) {
79
+ for (const value of values) {
80
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0)
81
+ return value;
82
+ }
83
+ return undefined;
84
+ }
33
85
  function printInitLoading() {
34
86
  const { dim, reset } = DIM_STYLE;
35
87
  console.log(`${dim}⟳ 正在初始化(MCP、技能、规则)…${reset}`);
@@ -46,26 +98,6 @@ function writeThinkingDelta(text) {
46
98
  return;
47
99
  process.stderr.write(`${DIM_STYLE.dim}${text}${DIM_STYLE.reset}`);
48
100
  }
49
- function buildSystemPrompt(instructions) {
50
- const parts = [
51
- `You are a helpful coding agent. You can read files, write files, run shell commands, search the web, and search the codebase to help the user with software engineering tasks.`,
52
- "",
53
- "Be concise and direct. When you run a command, briefly explain why.",
54
- "Use the available tools to complete tasks. When multiple independent operations are needed, call tools in parallel.",
55
- "When the user asks about current events, news, or anything requiring up-to-date information, use the search_web tool.",
56
- "",
57
- `Working directory: ${process.cwd()}`,
58
- `Platform: ${process.platform}`,
59
- `Date: ${new Date().toDateString()}`,
60
- "",
61
- `When the user asks to configure, install, or manage MCP servers, skills, rules, memory, or other min-agent features, use the read tool on the file at ${path.resolve(path.dirname(new URL(import.meta.url).pathname), "../README.md")} first, then follow what it says.`,
62
- ];
63
- if (instructions.length > 0) {
64
- parts.push("", "# User Instructions", "");
65
- parts.push(...instructions);
66
- }
67
- return parts.join("\n");
68
- }
69
101
  export function loadImageParts(imagePaths, notify) {
70
102
  const parts = [];
71
103
  for (const imgPath of imagePaths) {
@@ -104,7 +136,7 @@ export async function buildUserContent(message, imagePaths) {
104
136
  return [{ type: "text", text: message }, ...images];
105
137
  }
106
138
  /** Single-shot: send one message, get response, exit */
107
- export async function runAgent(message, modelId, imagePaths, providerName) {
139
+ export async function runAgent(message, modelId, imagePaths, providerName, resumeSessionId) {
108
140
  printHeader(modelId);
109
141
  printDivider();
110
142
  printInitLoading();
@@ -112,12 +144,40 @@ export async function runAgent(message, modelId, imagePaths, providerName) {
112
144
  try {
113
145
  discoverSkills();
114
146
  const instructions = await loadInstructions();
147
+ const { loadExecHistory, saveSession } = await import("./sessions.js");
148
+ const history = loadExecHistory(resumeSessionId);
149
+ if (!history.ok) {
150
+ console.error(history.error);
151
+ return true;
152
+ }
115
153
  printInitReady();
116
154
  console.log(`\x1b[36m> ${message}\x1b[0m\n`);
117
155
  const content = await buildUserContent(message, imagePaths);
118
- const messages = [{ role: "user", content }];
156
+ const messages = [...history.messages, { role: "user", content }];
119
157
  const tracker = new TokenTracker();
120
- await runOnce(messages, instructions, modelId, undefined, undefined, tracker, { providerName });
158
+ const taskState = emptyTaskState();
159
+ if (history.taskState)
160
+ copyTaskState(history.taskState, taskState);
161
+ // Persist as the run progresses so a crash keeps the work done so far.
162
+ const { createSaveThrottle } = await import("./save-throttle.js");
163
+ let sessionId = history.sessionId;
164
+ const persist = () => {
165
+ try {
166
+ sessionId = saveSession(messages, sessionId, undefined, undefined, taskState);
167
+ }
168
+ catch { }
169
+ };
170
+ const saveThrottle = createSaveThrottle(persist);
171
+ const { hasError } = await runOnce(messages, instructions, modelId, undefined, undefined, tracker, {
172
+ providerName,
173
+ taskState,
174
+ onStepPersist: () => saveThrottle.request(),
175
+ });
176
+ saveThrottle.cancel();
177
+ persist();
178
+ if (sessionId)
179
+ console.log(`\nResume with: min-agent exec --resume ${sessionId}`);
180
+ return hasError;
121
181
  }
122
182
  finally {
123
183
  await shutdownMcp();
@@ -142,7 +202,7 @@ export function pushTurn(messages, assistantText, toolCalls, toolResults) {
142
202
  messages.push({ role: "assistant", content: parts });
143
203
  }
144
204
  for (const r of toolResults) {
145
- const text = typeof r.output === "string" ? r.output : JSON.stringify(r.output);
205
+ const text = safeText(r.output);
146
206
  messages.push({
147
207
  role: "tool",
148
208
  content: [
@@ -156,32 +216,66 @@ export function pushTurn(messages, assistantText, toolCalls, toolResults) {
156
216
  });
157
217
  }
158
218
  }
159
- async function buildTools(mode, modelId, abortSignal, planMode = false, messages = [], tracker) {
160
- const builtinTools = mode === "code" ? createCodeTools() : createChatTools();
161
- builtinTools["todo"] = createTodoTool();
219
+ /**
220
+ * Tools that stay constant for a whole run: everything except the skill tool,
221
+ * which is re-bound per iteration (see buildIterationPrompts). Building these
222
+ * once per run avoids re-reading plugin manifests and re-wrapping MCP tools on
223
+ * every continue.
224
+ */
225
+ async function buildRunTools(modelId, abortSignal, planMode, tracker, taskState, subAgent) {
226
+ const builtinTools = createTools();
227
+ builtinTools.todo = createTodoTool({ store: taskState });
162
228
  const mcpTools = getMcpTools();
229
+ const catalogTools = getMcpCatalogTools();
163
230
  const memoryTools = getMemoryTools();
164
231
  const pluginTools = await loadPluginTools();
165
- const allTools = { ...builtinTools, ...memoryTools, ...pluginTools };
166
- for (const [id, t] of Object.entries(mcpTools))
167
- allTools[id] = t;
168
- const skillsPrompt = attachSkills(allTools, collectLoadedSkillNames(messages));
232
+ const allTools = {
233
+ ...builtinTools,
234
+ ...memoryTools,
235
+ ...pluginTools,
236
+ ...mcpTools,
237
+ ...catalogTools,
238
+ };
169
239
  const onUsage = tracker ? (usage) => tracker.add(usage) : undefined;
170
240
  const { createExploreTool } = await import("./tools/explore.js");
171
- allTools["explore"] = createExploreTool(modelId, abortSignal, onUsage);
172
- if (mode === "code") {
173
- const { createTaskTool } = await import("./tools/task.js");
174
- allTools["task"] = createTaskTool(modelId, abortSignal, onUsage);
175
- }
241
+ allTools.explore = createExploreTool(modelId, abortSignal, onUsage, { shouldStop: subAgent.shouldStop });
242
+ const { createTaskTool } = await import("./tools/task.js");
243
+ // Sub-agents share the parent's loop guard and budget so they cannot restart
244
+ // the research allowance or run past the cost ceiling.
245
+ allTools.task = createTaskTool(modelId, abortSignal, onUsage, {
246
+ loopGuard: subAgent.loopGuard,
247
+ shouldStop: subAgent.shouldStop,
248
+ });
176
249
  if (planMode) {
177
250
  for (const name of ["bash", "write", "edit", "apply_patch"])
178
251
  delete allTools[name];
252
+ const mcpReadOnly = getMcpReadOnlyToolIds();
253
+ for (const id of Object.keys(mcpTools)) {
254
+ if (!mcpReadOnly.has(id))
255
+ delete allTools[id];
256
+ }
257
+ const pluginReadOnly = getPluginReadOnlyIds();
258
+ for (const id of Object.keys(pluginTools)) {
259
+ if (!pluginReadOnly.has(id))
260
+ delete allTools[id];
261
+ }
179
262
  }
263
+ return allTools;
264
+ }
265
+ function buildIterationPrompts(allTools, messages, taskState, loopGuard) {
266
+ const skillsPrompt = attachSkills(allTools, collectLoadedSkillNames(messages));
180
267
  return {
181
- tools: allTools,
182
- promptSections: [skillsPrompt, getMemorySystemPrompt()].filter((s) => s.length > 0),
268
+ stable: [skillsPrompt].filter((s) => s.length > 0),
269
+ volatile: [getMemorySystemPrompt(), formatTaskStatePrompt(taskState), loopGuard.promptHint()].filter((s) => s.length > 0),
183
270
  };
184
271
  }
272
+ /** Ephemeral per-request message carrying the volatile prompt sections. */
273
+ export const SESSION_STATE_HEADER = "## Session state (current, not part of the conversation)";
274
+ function sessionStateMessage(volatile) {
275
+ if (volatile.length === 0)
276
+ return null;
277
+ return { role: "system", content: [SESSION_STATE_HEADER, ...volatile].join("\n\n") };
278
+ }
185
279
  function formatErrorMessage(msg) {
186
280
  if (msg.includes("API key") || msg.includes("Unauthorized") || msg.includes("Forbidden")) {
187
281
  return "Authentication error: Check your API key.";
@@ -194,6 +288,43 @@ function formatErrorMessage(msg) {
194
288
  }
195
289
  return msg;
196
290
  }
291
+ function safeText(value) {
292
+ if (typeof value === "string")
293
+ return value;
294
+ try {
295
+ return JSON.stringify(value) ?? String(value);
296
+ }
297
+ catch {
298
+ return String(value);
299
+ }
300
+ }
301
+ function isContextOverflowError(msg) {
302
+ return /context[\s_-]*(length|window|size)|context_length_exceeded|maximum context|too many tokens|prompt (is )?too long|reduce the (prompt )?length|token limit exceeded/i.test(msg);
303
+ }
304
+ function invokeCallback(fn, ...args) {
305
+ if (!fn)
306
+ return;
307
+ try {
308
+ fn(...args);
309
+ }
310
+ catch (err) {
311
+ log("error", `callback failed: ${err instanceof Error ? err.message : String(err)}`);
312
+ }
313
+ }
314
+ const INTERRUPTED_TOOL_RESULT = "Error: tool call was interrupted before a result was returned.";
315
+ function pairToolTurn(calls, results) {
316
+ const resultIds = new Set(results.map((r) => r.toolCallId));
317
+ const callIds = new Set(calls.map((c) => c.toolCallId));
318
+ const keptResults = results.filter((r) => callIds.has(r.toolCallId));
319
+ const synthetics = calls
320
+ .filter((c) => !resultIds.has(c.toolCallId))
321
+ .map((c) => ({
322
+ toolCallId: c.toolCallId,
323
+ toolName: c.toolName,
324
+ output: INTERRUPTED_TOOL_RESULT,
325
+ }));
326
+ return { calls, results: [...keptResults, ...synthetics] };
327
+ }
197
328
  async function safeUsage(result) {
198
329
  try {
199
330
  return await result.usage;
@@ -202,53 +333,161 @@ async function safeUsage(result) {
202
333
  return undefined;
203
334
  }
204
335
  }
205
- async function runOnceCore(messages, systemPrompt, modelId, abortSignal, callbacks, tracker, mode, options) {
336
+ function usageNonZero(usage) {
337
+ return (usage?.inputTokens ?? 0) > 0 || (usage?.outputTokens ?? 0) > 0;
338
+ }
339
+ function mergeUsage(a, b) {
340
+ if (!usageNonZero(b))
341
+ return a ?? b;
342
+ if (!usageNonZero(a))
343
+ return b;
344
+ const inputTokens = (a.inputTokens ?? 0) + (b.inputTokens ?? 0);
345
+ const outputTokens = (a.outputTokens ?? 0) + (b.outputTokens ?? 0);
346
+ return {
347
+ ...a,
348
+ ...b,
349
+ inputTokens,
350
+ outputTokens,
351
+ totalTokens: (a.totalTokens ?? 0) + (b.totalTokens ?? 0) || inputTokens + outputTokens,
352
+ };
353
+ }
354
+ function pickUsage(...candidates) {
355
+ const present = candidates.filter((u) => u != null);
356
+ if (present.length === 0)
357
+ return undefined;
358
+ const scored = present.filter(usageNonZero);
359
+ const pool = scored.length > 0 ? scored : present;
360
+ return pool.reduce((best, u) => {
361
+ const outDiff = (u.outputTokens ?? 0) - (best.outputTokens ?? 0);
362
+ if (outDiff !== 0)
363
+ return outDiff > 0 ? u : best;
364
+ return (u.inputTokens ?? 0) > (best.inputTokens ?? 0) ? u : best;
365
+ });
366
+ }
367
+ function injectUser(messages, text) {
368
+ const last = messages[messages.length - 1];
369
+ if (last && isSyntheticMessage(last) && last.role === "user") {
370
+ last.content = text;
371
+ return;
372
+ }
373
+ const continueMsg = { role: "user", content: text };
374
+ messages.push(continueMsg);
375
+ markSyntheticMessage(continueMsg);
376
+ }
377
+ function continuePromptFor(guard) {
378
+ if (guard.researchCapped)
379
+ return DELIVER_PROMPT;
380
+ if (guard.webResearchCount >= 6)
381
+ return STEER_PROMPT;
382
+ return CONTINUE_PROMPT;
383
+ }
384
+ function stubWebResearchTools(tools) {
385
+ for (const name of WEB_RESEARCH_TOOLS) {
386
+ const current = tools[name];
387
+ if (!current)
388
+ continue;
389
+ tools[name] = {
390
+ ...current,
391
+ execute: async () => RESEARCH_STUB_RESULT,
392
+ };
393
+ }
394
+ }
395
+ /**
396
+ * Drop the research tools for the rest of the turn. Removing them from the
397
+ * schema (instead of leaving a stub the model keeps calling) is what actually
398
+ * stops the model from burning steps on searches that cannot return anything.
399
+ */
400
+ function removeWebResearchTools(tools) {
401
+ const removed = [];
402
+ for (const name of WEB_RESEARCH_TOOLS) {
403
+ if (!tools[name])
404
+ continue;
405
+ delete tools[name];
406
+ removed.push(name);
407
+ }
408
+ return removed;
409
+ }
410
+ async function applyCompaction(messages, model, tracker, cbs, callbacks, compactCfg, pruneOptions = {}) {
411
+ applyToolPrune(messages, pruneOptions);
412
+ try {
413
+ if (!(await needsCompaction(messages, tracker, compactCfg)))
414
+ return false;
415
+ if (cbs.onCompaction)
416
+ invokeCallback(cbs.onCompaction, "compacting_start");
417
+ else if (!callbacks)
418
+ console.log("\x1b[90m⟳ Compacting context...\x1b[0m");
419
+ const result = await compactMessages(messages, model, compactCfg);
420
+ if (!result.compacted)
421
+ return false;
422
+ if (result.usage && tracker)
423
+ tracker.add(result.usage);
424
+ messages.length = 0;
425
+ messages.push(...result.messages);
426
+ if (tracker)
427
+ tracker.resetContext();
428
+ resetActiveInstructionTracker();
429
+ if (cbs.onCompaction)
430
+ invokeCallback(cbs.onCompaction, `compacted_ok estimated_tokens=${estimateTokens(messages)}`);
431
+ else if (!callbacks)
432
+ console.log(`\x1b[90m ✓ Compacted (${estimateTokens(messages)} tokens estimated)\x1b[0m`);
433
+ return true;
434
+ }
435
+ catch (err) {
436
+ log("warn", `compaction failed: ${err instanceof Error ? err.message : String(err)}`);
437
+ invokeCallback(cbs.onCompaction, "compacted_failed");
438
+ return false;
439
+ }
440
+ }
441
+ async function runOnceCore(messages, systemPrompt, modelId, abortSignal, callbacks, tracker, options) {
442
+ return runWithInstructionTracker(() => runOnceCoreLoop(messages, systemPrompt, modelId, abortSignal, callbacks, tracker, options));
443
+ }
444
+ async function runOnceCoreLoop(messages, systemPrompt, modelId, abortSignal, callbacks, tracker, options) {
206
445
  const model = resolveModel(modelId, options?.providerName);
207
- log("info", `run start mode=${mode} messages=${messages.length} model=${typeof model === "string" ? model : model.modelId}`);
446
+ startRunLog();
447
+ log("info", `run start messages=${messages.length} model=${typeof model === "string" ? model : model.modelId}`);
208
448
  const cbs = callbacks ?? {};
209
- const sampling = loadConfig().sampling ?? {};
449
+ const cfg = getEffectiveConfig();
450
+ const sampling = cfg.sampling ?? {};
210
451
  const temperature = options?.temperature ?? sampling.temperature;
211
452
  const maxTokens = options?.maxTokens ?? sampling.maxTokens;
212
453
  const topP = options?.topP ?? sampling.topP;
213
- // Auto-compact if context is getting too large
214
- if (await needsCompaction(messages, tracker)) {
215
- if (cbs.onCompaction) {
216
- cbs.onCompaction("compacting_start");
217
- }
218
- else if (!callbacks) {
219
- console.log("\x1b[90m⟳ Compacting context...\x1b[0m");
220
- }
221
- const result = await compactMessages(messages, model, { abortSignal });
222
- if (result.compacted) {
223
- if (result.usage && tracker)
224
- tracker.add(result.usage);
225
- messages.length = 0;
226
- messages.push(...result.messages);
227
- // Auto-continue: inject a message so the agent keeps working
228
- if (result.shouldContinue) {
229
- const continueText = result.replayText ||
230
- "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed.";
231
- const continueMsg = { role: "user", content: continueText };
232
- messages.push(continueMsg);
233
- markSyntheticMessage(continueMsg);
234
- }
235
- if (tracker)
236
- tracker.resetContext();
237
- if (cbs.onCompaction) {
238
- cbs.onCompaction(`compacted_ok estimated_tokens=${estimateTokens(messages)}`);
239
- }
240
- else if (!callbacks) {
241
- console.log(`\x1b[90m ✓ Compacted (${estimateTokens(messages)} tokens estimated)\x1b[0m`);
242
- }
243
- }
244
- }
245
- const { tools: allTools, promptSections } = await buildTools(mode, modelId, abortSignal, options?.planMode, messages, tracker);
246
- const system = [systemPrompt, ...promptSections].join("\n\n");
247
- let stepCount = 0;
248
- let maxStepsReached = false;
249
- let hasError = false;
250
- let budgetExceeded = false;
251
- const cfg = loadConfig();
454
+ const taskState = options?.taskState ?? emptyTaskState();
455
+ captureGoal(taskState, messages);
456
+ const { cap: maxSteps, bounded: stepBounded } = resolveMaxSteps(options?.maxSteps ?? cfg.agent?.maxSteps);
457
+ const maxContinues = options?.maxContinues ?? cfg.agent?.maxContinues ?? DEFAULT_MAX_CONTINUES;
458
+ const autoContinue = options?.autoContinue ?? cfg.agent?.autoContinue ?? cfg.compaction?.autoContinue ?? true;
459
+ const maxEmptyAttempts = Math.max(1, options?.maxEmptyAttempts ?? cfg.agent?.maxEmptyAttempts ?? DEFAULT_MAX_EMPTY_ATTEMPTS);
460
+ const emptyRetryDelayMs = options?.emptyRetryDelayMs ?? cfg.agent?.emptyRetryDelayMs ?? EMPTY_RETRY_BASE_DELAY_MS;
461
+ const turnTimeLimitMs = resolveLimit(options?.turnTimeLimitMs, cfg.agent?.turnTimeLimitMs) ?? DEFAULT_TURN_TIME_LIMIT_MS;
462
+ const softStepLimit = resolveLimit(options?.softStepLimit, cfg.agent?.softStepLimit) ?? DEFAULT_SOFT_STEP_LIMIT;
463
+ const turnStartedAt = Date.now();
464
+ /** Time / step budget for the whole turn (0 disables either half). */
465
+ const overTurnBudget = (steps) => (turnTimeLimitMs > 0 && Date.now() - turnStartedAt >= turnTimeLimitMs) ||
466
+ (softStepLimit > 0 && steps >= softStepLimit);
467
+ const loopGuard = new DoomLoopDetector({
468
+ steerAfter: options?.researchSteerAfter ?? cfg.agent?.researchSteerAfter,
469
+ stopAfter: options?.researchStopAfter ?? cfg.agent?.researchStopAfter,
470
+ totalCap: options?.researchTotalCap ?? cfg.agent?.researchTotalCap,
471
+ });
472
+ /** Tokens spent on the system prompt + tool schemas of the current pass. */
473
+ const overhead = { tokens: 0 };
474
+ const compactCfg = {
475
+ abortSignal,
476
+ modelId,
477
+ taskGoal: taskState.goal || undefined,
478
+ get overheadTokens() {
479
+ return overhead.tokens;
480
+ },
481
+ };
482
+ const windowInfo = await getContextWindowInfo(modelId);
483
+ const ctxWindow = windowInfo.tokens;
484
+ const compactThreshold = ctxWindow * COMPACTION_RATIO;
485
+ /** Old tool payloads are only dropped once the context is actually filling up. */
486
+ const pruneBudget = () => ({
487
+ pressureTokens: ctxWindow * PRUNE_PRESSURE_RATIO,
488
+ targetTokens: ctxWindow * PRUNE_TARGET_RATIO,
489
+ overheadTokens: overhead.tokens,
490
+ });
252
491
  const budgetLimit = cfg.budget?.maxCostUSD;
253
492
  let budgetPrice = null;
254
493
  if (budgetLimit != null && budgetLimit > 0) {
@@ -257,6 +496,7 @@ async function runOnceCore(messages, systemPrompt, modelId, abortSignal, callbac
257
496
  : getActiveProvider(cfg);
258
497
  budgetPrice = await getModelPrice(modelId ?? provider?.defaultModel ?? "");
259
498
  }
499
+ let budgetExceeded = false;
260
500
  const checkBudget = () => {
261
501
  if (!tracker || budgetLimit == null || budgetLimit <= 0)
262
502
  return false;
@@ -267,199 +507,822 @@ async function runOnceCore(messages, systemPrompt, modelId, abortSignal, callbac
267
507
  }
268
508
  return false;
269
509
  };
270
- // Internal controller so the budget limit can abort mid-run; external
271
- // abort requests are forwarded to it.
272
- const budgetController = new AbortController();
273
- const forwardAbort = () => budgetController.abort();
274
- abortSignal?.addEventListener("abort", forwardAbort, { once: true });
275
- const doomLoop = new DoomLoopDetector();
276
- const toolCalls = [];
277
- const toolResults = [];
278
- const result = streamText({
279
- model,
280
- system,
281
- messages,
282
- tools: allTools,
283
- stopWhen: stepCountIs(MAX_STEPS),
284
- maxRetries: 3,
285
- abortSignal: budgetController.signal,
286
- ...(temperature != null ? { temperature } : {}),
287
- ...(maxTokens != null ? { maxTokens } : {}),
288
- ...(topP != null ? { topP } : {}),
289
- onStepFinish({ stepNumber, usage }) {
290
- stepCount = stepNumber;
291
- if (usage && tracker)
292
- tracker.update(usage);
293
- if (stepNumber >= MAX_STEPS)
294
- maxStepsReached = true;
295
- if (checkBudget())
296
- budgetController.abort();
297
- },
298
- onError({ error }) {
299
- log("warn", `step failed (will retry): ${String(error)}`);
300
- },
510
+ await applyCompaction(messages, model, tracker, cbs, callbacks, compactCfg, pruneBudget());
511
+ let totalSteps = 0;
512
+ let continues = 0;
513
+ let emptyAttempts = 0;
514
+ let wrapUpAsked = false;
515
+ let hasError = false;
516
+ let lastUsage;
517
+ let allowContextAbort = true;
518
+ const allTools = await buildRunTools(modelId, abortSignal, options?.planMode ?? false, tracker, taskState, {
519
+ loopGuard,
520
+ shouldStop: () => checkBudget() || overTurnBudget(totalSteps),
301
521
  });
302
- let rawText = "";
303
- let assistantText = "";
304
- const md = new MarkdownRenderer();
305
- const thinkingSplit = new ThinkingBodySplitter();
306
- const emitThinking = (t) => {
522
+ const finish = async (opts) => {
523
+ log("info", `run end steps=${totalSteps} continues=${continues} tokens_in=${lastUsage?.inputTokens ?? 0} tokens_out=${lastUsage?.outputTokens ?? 0}`);
524
+ endRunLog();
525
+ if (cbs.onRunFinish) {
526
+ try {
527
+ await cbs.onRunFinish({
528
+ stepCount: totalSteps,
529
+ usage: lastUsage,
530
+ contextTokens: tracker?.lastInputTokens ?? 0,
531
+ hasError,
532
+ aborted: opts.aborted,
533
+ maxStepsReached: opts.maxStepsReached,
534
+ budgetExceeded,
535
+ continues,
536
+ incomplete: opts.incomplete ?? false,
537
+ stopped: opts.stopped ?? false,
538
+ emptyResponse: opts.emptyResponse ?? false,
539
+ emptyAttempts,
540
+ wrapUp: opts.wrapUp ?? false,
541
+ });
542
+ }
543
+ catch (err) {
544
+ log("error", `onRunFinish failed: ${err instanceof Error ? err.message : String(err)}`);
545
+ }
546
+ return { hasError };
547
+ }
548
+ if (hasError) {
549
+ printDivider();
550
+ return { hasError };
551
+ }
552
+ printDivider();
553
+ printDone(totalSteps, lastUsage, ctxWindow, tracker?.lastInputTokens);
554
+ if (budgetExceeded)
555
+ console.log(`\x1b[33m⚠ 已达到预算上限 ($${budgetLimit}),运行已中断\x1b[0m`);
556
+ if (opts.stopped)
557
+ console.log(`\x1b[33m⚠ ${LOOP_HALT_MESSAGE}\x1b[0m`);
558
+ if (opts.emptyResponse) {
559
+ console.log(`\x1b[33m⚠ ${emptyResponseMessage(emptyAttempts)}\x1b[0m`);
560
+ }
561
+ else if (opts.wrapUp) {
562
+ console.log(`\x1b[33m⚠ ${WRAP_UP_MESSAGE}\x1b[0m`);
563
+ }
564
+ else if (opts.maxStepsReached) {
565
+ console.log(`\x1b[33m⚠ 已达到自动续跑上限,发送消息继续\x1b[0m`);
566
+ }
567
+ else if (opts.incomplete) {
568
+ console.log(`\x1b[33m⚠ 还没有完整回复,发送消息可继续\x1b[0m`);
569
+ }
570
+ return { hasError };
571
+ };
572
+ while (true) {
573
+ if (abortSignal?.aborted)
574
+ return finish({ aborted: true, maxStepsReached: false });
575
+ if (checkBudget())
576
+ return finish({ aborted: true, maxStepsReached: false });
577
+ let inner;
578
+ nextRunPass();
579
+ try {
580
+ inner = await runInnerStream({
581
+ messages,
582
+ systemPrompt,
583
+ model,
584
+ abortSignal,
585
+ callbacks,
586
+ cbs,
587
+ tracker,
588
+ taskState,
589
+ allTools,
590
+ maxSteps,
591
+ stepBounded,
592
+ temperature,
593
+ maxTokens,
594
+ topP,
595
+ checkBudget,
596
+ overTurnBudget: (stepsInPass) => overTurnBudget(totalSteps + stepsInPass),
597
+ pruneBudget,
598
+ overhead,
599
+ compactThreshold,
600
+ allowContextAbort,
601
+ loopGuard,
602
+ onStepPersist: options?.onStepPersist,
603
+ });
604
+ }
605
+ catch (err) {
606
+ const msg = err instanceof Error ? err.message : String(err);
607
+ log("error", msg);
608
+ hasError = true;
609
+ if (cbs.onStreamError)
610
+ invokeCallback(cbs.onStreamError, msg);
611
+ else
612
+ console.error(`\x1b[31m${formatErrorMessage(msg)}\x1b[0m`);
613
+ return finish({ aborted: Boolean(abortSignal?.aborted), maxStepsReached: false });
614
+ }
615
+ totalSteps += inner.stepCount;
616
+ lastUsage = mergeUsage(lastUsage, inner.usage);
617
+ if (inner.hasError)
618
+ hasError = true;
619
+ if (inner.budgetExceeded)
620
+ budgetExceeded = true;
621
+ if (inner.userAborted)
622
+ return finish({ aborted: true, maxStepsReached: false });
623
+ if (inner.doomLoop)
624
+ return finish({ aborted: false, maxStepsReached: false, stopped: true });
625
+ if (inner.hasError && !inner.contextPressure && !inner.toolPressure) {
626
+ return finish({ aborted: false, maxStepsReached: false });
627
+ }
628
+ if (budgetExceeded)
629
+ return finish({ aborted: true, maxStepsReached: false });
630
+ if (inner.toolPressure) {
631
+ applyToolPrune(messages, pruneBudget());
632
+ continue;
633
+ }
634
+ // The provider produced nothing at all (no text, no reasoning-backed reply,
635
+ // no tool call): a transport / gateway hiccup rather than model intent.
636
+ // Re-send the same request with backoff instead of nudging the model.
637
+ if (inner.emptyCompletion) {
638
+ emptyAttempts++;
639
+ if (emptyAttempts >= maxEmptyAttempts) {
640
+ log("warn", `empty response from provider ${emptyAttempts}x, ending run`);
641
+ return finish({ aborted: false, maxStepsReached: false, incomplete: true, emptyResponse: true });
642
+ }
643
+ continues++;
644
+ if (continues > maxContinues) {
645
+ return finish({ aborted: false, maxStepsReached: true, incomplete: true });
646
+ }
647
+ const delay = emptyRetryDelay(emptyAttempts, emptyRetryDelayMs);
648
+ log("warn", `empty response from provider, retry ${emptyAttempts}/${maxEmptyAttempts - 1} after ${delay}ms`);
649
+ if (cbs.onRetryNotice)
650
+ invokeCallback(cbs.onRetryNotice, {
651
+ kind: "empty_response",
652
+ attempt: emptyAttempts,
653
+ maxAttempts: maxEmptyAttempts - 1,
654
+ delayMs: delay,
655
+ });
656
+ else if (!callbacks)
657
+ console.log(`\x1b[90m⟳ 模型返回空响应,重试 ${emptyAttempts}/${maxEmptyAttempts - 1}…\x1b[0m`);
658
+ await sleep(delay);
659
+ continue;
660
+ }
661
+ const hitResearchLimit = inner.researchCap || inner.researchSteer;
662
+ const forceDeliver = !loopGuard.producedArtifact &&
663
+ (hitResearchLimit ||
664
+ (inner.lastStepHadTools && (loopGuard.researchCapped || (!autoContinue && loopGuard.webResearchCount > 0))));
665
+ const stalled = inner.lastStepHadTools || inner.xmlToolFollowUp || inner.providerStall;
666
+ const shouldKeepGoing = inner.xmlToolFollowUp ||
667
+ (autoContinue && (inner.contextPressure || inner.lastStepHadTools || inner.providerStall));
668
+ // A finished answer always wins: never override it with a budget notice.
669
+ if (!forceDeliver && !shouldKeepGoing) {
670
+ const capped = !autoContinue && inner.maxStepsReached && inner.lastStepHadTools;
671
+ if (capped && !callbacks) {
672
+ console.log(`\x1b[33m⚠ 本轮已达到步数上限(${maxSteps}),发送消息继续\x1b[0m`);
673
+ }
674
+ return finish({ aborted: false, maxStepsReached: capped, incomplete: stalled && !capped });
675
+ }
676
+ // Out of time / steps for this turn: ask for a wrap-up once, then stop.
677
+ if (inner.wrapUp || overTurnBudget(totalSteps)) {
678
+ if (wrapUpAsked) {
679
+ log("warn", `turn budget spent after wrap-up (steps=${totalSteps})`);
680
+ return finish({ aborted: false, maxStepsReached: false, incomplete: true, wrapUp: true });
681
+ }
682
+ wrapUpAsked = true;
683
+ continues++;
684
+ if (continues > maxContinues) {
685
+ return finish({ aborted: false, maxStepsReached: true, incomplete: true });
686
+ }
687
+ log("info", `auto-continue reason=wrap_up steps=${totalSteps} elapsed=${Date.now() - turnStartedAt}ms`);
688
+ if (cbs.onRetryNotice)
689
+ invokeCallback(cbs.onRetryNotice, {
690
+ kind: "wrap_up",
691
+ attempt: 1,
692
+ maxAttempts: 1,
693
+ delayMs: 0,
694
+ });
695
+ else if (!callbacks)
696
+ console.log(`\x1b[90m⟳ 本轮预算用尽,正在收尾…\x1b[0m`);
697
+ await applyCompaction(messages, model, tracker, cbs, callbacks, compactCfg, pruneBudget());
698
+ injectUser(messages, WRAP_UP_PROMPT);
699
+ continue;
700
+ }
701
+ if (forceDeliver) {
702
+ continues++;
703
+ if (continues > maxContinues) {
704
+ return finish({ aborted: false, maxStepsReached: true, incomplete: true });
705
+ }
706
+ removeWebResearchTools(allTools);
707
+ loopGuard.markCapped();
708
+ log("info", `auto-continue reason=${hitResearchLimit ? (inner.researchCap ? "research_cap" : "research_steer") : "awaiting_deliverable"}`);
709
+ await applyCompaction(messages, model, tracker, cbs, callbacks, compactCfg, pruneBudget());
710
+ injectUser(messages, DELIVER_PROMPT);
711
+ continue;
712
+ }
713
+ continues++;
714
+ if (continues > maxContinues) {
715
+ return finish({ aborted: false, maxStepsReached: true, incomplete: stalled });
716
+ }
717
+ log("info", `auto-continue reason=${inner.xmlToolFollowUp ? "xml_search" : inner.contextPressure ? "context" : inner.providerStall ? "provider_stall" : "tools_without_reply"}`);
718
+ const compacted = await applyCompaction(messages, model, tracker, cbs, callbacks, { ...compactCfg, force: inner.contextPressure }, pruneBudget());
719
+ if (inner.contextPressure && !compacted)
720
+ allowContextAbort = false;
721
+ injectUser(messages, continuePromptFor(loopGuard));
722
+ }
723
+ }
724
+ /**
725
+ * Accumulates the current assistant turn (text + tool call/result pairs) and
726
+ * flushes it into the shared message history at step boundaries.
727
+ */
728
+ class TurnAssembler {
729
+ messages;
730
+ onFlush;
731
+ stepText = "";
732
+ toolCalls = [];
733
+ toolResults = [];
734
+ innerSteps = 0;
735
+ lastStepHadTools = false;
736
+ hadTools = false;
737
+ hadAssistantText = false;
738
+ /** Estimated model output of this pass (assistant text + tool-call arguments). */
739
+ producedTokens = 0;
740
+ constructor(messages, onFlush) {
741
+ this.messages = messages;
742
+ this.onFlush = onFlush;
743
+ }
744
+ get pendingResults() {
745
+ return this.toolResults.length;
746
+ }
747
+ get hasUnpairedCalls() {
748
+ const resultIds = new Set(this.toolResults.map((r) => r.toolCallId));
749
+ return this.toolCalls.some((c) => !resultIds.has(c.toolCallId));
750
+ }
751
+ appendText(display) {
752
+ this.stepText += display;
753
+ }
754
+ recordCall(call) {
755
+ this.toolCalls.push(call);
756
+ }
757
+ recordResult(result) {
758
+ this.toolResults.push(result);
759
+ }
760
+ /** Placeholder id for tool-call events missing one (kept stable per step). */
761
+ nextMissingCallId() {
762
+ return `missing-${this.innerSteps}-${this.toolCalls.length}`;
763
+ }
764
+ flushStep() {
765
+ const hasTools = this.toolCalls.length > 0 || this.toolResults.length > 0;
766
+ const hasText = Boolean(this.stepText.trim());
767
+ if (!hasText && !hasTools)
768
+ return;
769
+ this.lastStepHadTools = hasTools;
770
+ if (hasTools)
771
+ this.hadTools = true;
772
+ if (hasText)
773
+ this.hadAssistantText = true;
774
+ this.producedTokens += estimateStringTokens(this.stepText);
775
+ for (const call of this.toolCalls) {
776
+ this.producedTokens += estimateStringTokens(safeText(call.input));
777
+ }
778
+ const paired = pairToolTurn(this.toolCalls, this.toolResults);
779
+ pushTurn(this.messages, this.stepText, paired.calls, paired.results);
780
+ this.innerSteps++;
781
+ this.stepText = "";
782
+ this.toolCalls = [];
783
+ this.toolResults = [];
784
+ this.onFlush?.({ steps: this.innerSteps });
785
+ }
786
+ }
787
+ /**
788
+ * Splits thinking and inline <web_search> dumps from displayable text, then
789
+ * fans both out to the callbacks (HTTP/TUI) or the TTY (single-shot).
790
+ */
791
+ class StreamRenderer {
792
+ cbs;
793
+ interactive;
794
+ md = new MarkdownRenderer();
795
+ thinkingSplit = new ThinkingBodySplitter();
796
+ xmlSplit = new XmlSearchSplitter();
797
+ rawText = "";
798
+ constructor(cbs, interactive) {
799
+ this.cbs = cbs;
800
+ this.interactive = interactive;
801
+ }
802
+ emitThinking(t) {
307
803
  if (!t)
308
804
  return;
309
- if (cbs.onThinkingDelta)
310
- cbs.onThinkingDelta(t);
805
+ if (this.cbs.onThinkingDelta)
806
+ invokeCallback(this.cbs.onThinkingDelta, t);
311
807
  else
312
808
  writeThinkingDelta(t);
313
- };
314
- const emitDisplay = (delta) => {
315
- if (cbs.onAssistantDisplayDelta) {
316
- cbs.onAssistantDisplayDelta(delta);
317
- return;
809
+ }
810
+ emitDisplay(delta) {
811
+ try {
812
+ if (this.cbs.onAssistantDisplayDelta) {
813
+ this.cbs.onAssistantDisplayDelta(delta);
814
+ return;
815
+ }
816
+ const formatted = this.md.write(delta);
817
+ if (formatted)
818
+ process.stdout.write(formatted);
318
819
  }
319
- const formatted = md.write(delta);
320
- if (formatted)
321
- process.stdout.write(formatted);
322
- };
323
- const flushSplit = () => {
324
- const out = thinkingSplit.flush();
325
- emitThinking(out.thinking);
326
- if (out.display) {
327
- rawText += out.display;
328
- assistantText += out.display;
329
- emitDisplay(out.display);
820
+ catch (err) {
821
+ log("error", `display callback failed: ${err instanceof Error ? err.message : String(err)}`);
330
822
  }
331
- };
332
- const flushOutput = () => {
333
- flushSplit();
334
- if (!callbacks) {
335
- const flushed = md.flush();
336
- if (flushed)
337
- process.stdout.write(flushed);
338
- if (rawText.trim())
823
+ }
824
+ feedReasoning(text) {
825
+ this.emitThinking(text);
826
+ }
827
+ mirror(display) {
828
+ if (!display)
829
+ return;
830
+ this.rawText += display;
831
+ this.emitDisplay(display);
832
+ }
833
+ pipeXml(display, flush) {
834
+ const slices = display ? this.xmlSplit.feed(display) : [];
835
+ if (flush)
836
+ slices.push(...this.xmlSplit.flush());
837
+ const out = [];
838
+ for (const sl of slices) {
839
+ if (sl.type === "display") {
840
+ if (!sl.text)
841
+ continue;
842
+ this.mirror(sl.text);
843
+ out.push(sl);
844
+ }
845
+ else {
846
+ out.push(sl);
847
+ }
848
+ }
849
+ return out;
850
+ }
851
+ feedText(text) {
852
+ const { display, thinking } = this.thinkingSplit.feed(text);
853
+ this.emitThinking(thinking);
854
+ return this.pipeXml(display, false);
855
+ }
856
+ flushSplitText() {
857
+ const out = this.thinkingSplit.flush();
858
+ this.emitThinking(out.thinking);
859
+ return this.pipeXml(out.display, true);
860
+ }
861
+ flushMarkdown() {
862
+ if (this.interactive) {
863
+ try {
864
+ const flushed = this.md.flush();
865
+ if (flushed)
866
+ process.stdout.write(flushed);
867
+ }
868
+ catch { }
869
+ if (this.rawText.trim())
339
870
  console.log();
340
871
  }
341
- rawText = "";
872
+ this.rawText = "";
873
+ }
874
+ flushOutput() {
875
+ const slices = this.flushSplitText();
876
+ this.flushMarkdown();
877
+ return slices;
878
+ }
879
+ flushOnError() {
880
+ if (this.interactive && this.rawText.trim())
881
+ console.log();
882
+ const slices = this.flushSplitText();
883
+ if (this.interactive) {
884
+ try {
885
+ process.stdout.write(this.md.flush());
886
+ }
887
+ catch { }
888
+ }
889
+ this.rawText = "";
890
+ return slices;
891
+ }
892
+ }
893
+ /**
894
+ * Explicit state machine for one streaming pass: owns the abort controller,
895
+ * stop flags, doom-loop detector, turn assembly and rendering, and reacts to
896
+ * each fullStream event via handleEvent (returns true to stop the loop).
897
+ */
898
+ class InnerStreamMachine {
899
+ params;
900
+ stop = {
901
+ budget: false,
902
+ doom: false,
903
+ steer: false,
904
+ cap: false,
905
+ context: false,
906
+ tools: false,
907
+ stall: false,
908
+ wrapUp: false,
342
909
  };
343
- try {
344
- streamLoop: for await (const event of result.fullStream) {
345
- switch (event.type) {
346
- case "text-delta": {
347
- const { display, thinking } = thinkingSplit.feed(event.text);
348
- emitThinking(thinking);
349
- if (display) {
350
- rawText += display;
351
- assistantText += display;
352
- emitDisplay(display);
353
- }
354
- break;
355
- }
356
- case "tool-call": {
357
- toolCalls.push({ toolCallId: event.toolCallId, toolName: event.toolName, input: event.input });
358
- if (doomLoop.record(event.toolName, event.input)) {
359
- hasError = true;
360
- const warning = `Doom loop detected: "${event.toolName}" called 3 times with same args. Breaking loop.`;
361
- if (cbs.onStreamError)
362
- cbs.onStreamError(warning);
363
- else
364
- console.log(`\n\x1b[33m⚠ ${warning}\x1b[0m`);
365
- flushOutput();
366
- break streamLoop;
367
- }
368
- logToolCall(event.toolName, event.input);
369
- flushOutput();
370
- if (cbs.onToolCall)
371
- cbs.onToolCall(event.toolName, event.input, event.toolCallId);
372
- else
373
- printToolCall(event.toolName, event.input);
374
- break;
375
- }
376
- case "tool-result":
377
- case "tool-error": {
378
- const isError = event.type === "tool-error";
379
- const output = isError ? event.error : event.output;
380
- const displayed = isError ? `Error: ${event.error}` : event.output;
381
- toolResults.push({ toolCallId: event.toolCallId, toolName: event.toolName, output });
382
- logToolResult(event.toolName, displayed);
383
- if (cbs.onToolResult)
384
- cbs.onToolResult(event.toolName, displayed, { toolCallId: event.toolCallId, isError });
385
- else
386
- printToolResult(event.toolName, displayed, isError);
387
- break;
388
- }
389
- case "error":
390
- hasError = true;
391
- const errorMsg = String(event.error);
392
- log("error", `stream error: ${errorMsg}`);
393
- if (cbs.onStreamError) {
394
- cbs.onStreamError(errorMsg);
395
- }
396
- else {
397
- console.error(`\x1b[31m${formatErrorMessage(errorMsg)}\x1b[0m`);
398
- }
399
- break;
910
+ hasError = false;
911
+ assembler;
912
+ renderer;
913
+ usageSteps = 0;
914
+ innerController = new AbortController();
915
+ forwardAbort = () => this.innerController.abort();
916
+ xmlSearchRecovered = false;
917
+ answerAfterXml = false;
918
+ pendingLoop = null;
919
+ stepFinishSeen = false;
920
+ stepUsage;
921
+ totalUsage;
922
+ constructor(params) {
923
+ this.params = params;
924
+ this.assembler = new TurnAssembler(params.messages, (info) => {
925
+ if (params.onStepPersist)
926
+ invokeCallback(params.onStepPersist, info);
927
+ });
928
+ this.renderer = new StreamRenderer(params.cbs, !params.callbacks);
929
+ }
930
+ get signal() {
931
+ return this.innerController.signal;
932
+ }
933
+ attachAbort() {
934
+ this.params.abortSignal?.addEventListener("abort", this.forwardAbort, { once: true });
935
+ }
936
+ detachAbort() {
937
+ this.params.abortSignal?.removeEventListener("abort", this.forwardAbort);
938
+ }
939
+ /** streamText onStepFinish: budget / context-pressure / tool-output pruning checks. */
940
+ onStepFinish(usage) {
941
+ const { tracker, checkBudget, allowContextAbort, compactThreshold, messages, overTurnBudget } = this.params;
942
+ if (usage && tracker)
943
+ tracker.update(usage);
944
+ this.usageSteps++;
945
+ if (checkBudget()) {
946
+ this.stop.budget = true;
947
+ this.innerController.abort();
948
+ }
949
+ else if (overTurnBudget(this.assembler.innerSteps)) {
950
+ // Do not let one pass run past the turn budget: stop here so the outer
951
+ // loop can ask for a wrap-up.
952
+ this.stop.wrapUp = true;
953
+ this.innerController.abort();
954
+ }
955
+ else if (allowContextAbort && this.usageSteps >= 2 && tracker && tracker.lastInputTokens > compactThreshold) {
956
+ this.stop.context = true;
957
+ this.innerController.abort();
958
+ }
959
+ else if (this.usageSteps >= KEEP_RECENT_STEPS_BEFORE_PRUNE &&
960
+ pruneToolOutputs(messages, this.params.pruneBudget()) !== messages) {
961
+ this.stop.tools = true;
962
+ this.innerController.abort();
963
+ }
964
+ }
965
+ noteStepUsage(usage) {
966
+ this.stepUsage = mergeUsage(this.stepUsage, usage);
967
+ }
968
+ resolvedUsage(fromResult) {
969
+ const reported = pickUsage(fromResult, this.totalUsage, this.stepUsage);
970
+ if (usageNonZero(reported))
971
+ return reported;
972
+ // We cut the stream ourselves (research steer, context pressure, stall…),
973
+ // so the provider never sent its usage chunk. Estimate instead of dropping
974
+ // the pass from cost / context accounting entirely.
975
+ const estimated = this.estimateUsage();
976
+ if (!estimated)
977
+ return reported;
978
+ if (this.params.tracker)
979
+ this.params.tracker.update(estimated);
980
+ log("info", `usage estimated for aborted pass in=${estimated.inputTokens} out=${estimated.outputTokens}`);
981
+ return estimated;
982
+ }
983
+ /** Rough usage for a pass whose provider usage never arrived. */
984
+ estimateUsage() {
985
+ const outputTokens = this.assembler.producedTokens;
986
+ if (outputTokens === 0)
987
+ return undefined;
988
+ const inputTokens = estimateTokens(this.params.messages) + this.params.overhead.tokens;
989
+ return {
990
+ inputTokens,
991
+ outputTokens,
992
+ totalTokens: inputTokens + outputTokens,
993
+ inputTokenDetails: { noCacheTokens: inputTokens, cacheReadTokens: undefined, cacheWriteTokens: undefined },
994
+ outputTokenDetails: { textTokens: outputTokens, reasoningTokens: undefined },
995
+ };
996
+ }
997
+ /** streamText onError callback (step failed, will retry). */
998
+ onStepError(error) {
999
+ const msg = String(error);
1000
+ if (isContextOverflowError(msg))
1001
+ this.stop.context = true;
1002
+ log("warn", `step failed (will retry): ${msg}`);
1003
+ }
1004
+ async applySlices(slices) {
1005
+ for (const sl of slices) {
1006
+ if (sl.type === "display") {
1007
+ if (!sl.text)
1008
+ continue;
1009
+ if (this.assembler.pendingResults > 0)
1010
+ this.assembler.flushStep();
1011
+ this.assembler.appendText(sl.text);
1012
+ if (this.xmlSearchRecovered && sl.text.trim())
1013
+ this.answerAfterXml = true;
1014
+ }
1015
+ else if (await this.materializeXmlSearch(sl.block)) {
1016
+ return true;
400
1017
  }
401
1018
  }
402
- flushOutput();
403
- pushTurn(messages, assistantText, toolCalls, toolResults);
404
- const usage = await safeUsage(result);
405
- if (maxStepsReached && !callbacks) {
406
- console.log(`\x1b[33m⚠ Reached the ${MAX_STEPS}-step limit for this turn. Send a follow-up message to continue.\x1b[0m`);
1019
+ return false;
1020
+ }
1021
+ applyLoopAction(action) {
1022
+ if (action === "ok")
1023
+ return false;
1024
+ if (action === "halt") {
1025
+ this.stop.doom = true;
1026
+ this.innerController.abort();
1027
+ return true;
407
1028
  }
408
- log("info", `run end steps=${stepCount} tokens_in=${usage?.inputTokens ?? 0} tokens_out=${usage?.outputTokens ?? 0}`);
409
- if (cbs.onRunFinish) {
410
- cbs.onRunFinish({ stepCount, usage, hasError, aborted: false, maxStepsReached, budgetExceeded });
1029
+ this.pendingLoop = action;
1030
+ stubWebResearchTools(this.params.allTools);
1031
+ return false;
1032
+ }
1033
+ /** End the inner stream after research steer/cap, but only once every tool call has a result. */
1034
+ settlePendingLoop() {
1035
+ if (!this.pendingLoop)
1036
+ return false;
1037
+ if (this.assembler.hasUnpairedCalls)
1038
+ return false;
1039
+ this.stop.steer = this.pendingLoop === "steer";
1040
+ this.stop.cap = this.pendingLoop === "cap";
1041
+ this.pendingLoop = null;
1042
+ this.innerController.abort();
1043
+ return true;
1044
+ }
1045
+ async flushAndSettleLoop() {
1046
+ if (this.assembler.hasUnpairedCalls)
1047
+ return false;
1048
+ if (await this.flushOutputAndStep())
1049
+ return true;
1050
+ return this.settlePendingLoop();
1051
+ }
1052
+ async materializeXmlSearch(block) {
1053
+ const { cbs, loopGuard } = this.params;
1054
+ const toolName = "search_web";
1055
+ const query = block.query.trim();
1056
+ const input = { query };
1057
+ const toolCallId = this.assembler.nextMissingCallId();
1058
+ this.renderer.flushMarkdown();
1059
+ this.assembler.recordCall({ toolCallId, toolName, input });
1060
+ logToolCall(toolName, input);
1061
+ if (cbs.onToolCall)
1062
+ invokeCallback(cbs.onToolCall, toolName, input, toolCallId);
1063
+ else
1064
+ printToolCall(toolName, input);
1065
+ if (loopGuard.researchCapped) {
1066
+ const output = RESEARCH_STUB_RESULT;
1067
+ this.assembler.recordResult({ toolCallId, toolName, output });
1068
+ logToolResult(toolName, output);
1069
+ if (cbs.onToolResult)
1070
+ invokeCallback(cbs.onToolResult, toolName, output, { toolCallId, isError: false });
1071
+ else
1072
+ printToolResult(toolName, output, false);
1073
+ this.xmlSearchRecovered = true;
1074
+ return false;
411
1075
  }
412
- else {
413
- if (hasError) {
414
- printDivider();
415
- return;
1076
+ if (this.applyLoopAction(loopGuard.observe(toolName, input))) {
1077
+ const output = LOOP_HALT_MESSAGE;
1078
+ this.assembler.recordResult({ toolCallId, toolName, output });
1079
+ logToolResult(toolName, output);
1080
+ if (cbs.onToolResult)
1081
+ invokeCallback(cbs.onToolResult, toolName, output, { toolCallId, isError: false });
1082
+ else
1083
+ printToolResult(toolName, output, false);
1084
+ return true;
1085
+ }
1086
+ const output = block.kind === "results" ? block.body : await this.executeSearchWeb(query, toolCallId);
1087
+ const isError = typeof output === "string" && /^(error|search error)\b/i.test(output);
1088
+ this.assembler.recordResult({ toolCallId, toolName, output });
1089
+ logToolResult(toolName, output);
1090
+ if (cbs.onToolResult)
1091
+ invokeCallback(cbs.onToolResult, toolName, output, { toolCallId, isError });
1092
+ else
1093
+ printToolResult(toolName, output, isError);
1094
+ this.xmlSearchRecovered = true;
1095
+ this.applyLoopAction(loopGuard.observeResult(toolName, isError ? "error" : output));
1096
+ return false;
1097
+ }
1098
+ async executeSearchWeb(query, toolCallId) {
1099
+ if (!query)
1100
+ return 'Search error: "query" must be a non-empty string.';
1101
+ const search = this.params.allTools.search_web;
1102
+ if (typeof search?.execute !== "function")
1103
+ return "Search error: search_web is unavailable.";
1104
+ try {
1105
+ const out = await search.execute({ query }, { toolCallId, messages: this.params.messages, abortSignal: this.signal });
1106
+ return typeof out === "string" ? out : safeText(out);
1107
+ }
1108
+ catch (err) {
1109
+ return `Search error: ${err instanceof Error ? err.message : String(err)}`;
1110
+ }
1111
+ }
1112
+ async flushOutputAndStep() {
1113
+ const stop = await this.applySlices(this.renderer.flushOutput());
1114
+ this.assembler.flushStep();
1115
+ return stop;
1116
+ }
1117
+ /** Handle one fullStream event; returns true to break out of the stream loop. */
1118
+ async handleEvent(event) {
1119
+ const { cbs, loopGuard } = this.params;
1120
+ switch (event.type) {
1121
+ case "start-step":
1122
+ this.stepFinishSeen = false;
1123
+ if (!this.assembler.hasUnpairedCalls)
1124
+ this.assembler.flushStep();
1125
+ return false;
1126
+ case "finish-step":
1127
+ this.stepFinishSeen = true;
1128
+ this.noteStepUsage(event.usage);
1129
+ return await this.flushAndSettleLoop();
1130
+ case "finish":
1131
+ if (usageNonZero(event.totalUsage))
1132
+ this.totalUsage = event.totalUsage;
1133
+ return false;
1134
+ case "reasoning-delta": {
1135
+ if (event.text)
1136
+ this.renderer.feedReasoning(event.text);
1137
+ return false;
1138
+ }
1139
+ case "text-delta": {
1140
+ if (this.assembler.pendingResults > 0 && (await this.flushOutputAndStep()))
1141
+ return true;
1142
+ return await this.applySlices(this.renderer.feedText(event.text));
1143
+ }
1144
+ case "tool-call": {
1145
+ if (this.assembler.pendingResults > 0 && (await this.flushOutputAndStep()))
1146
+ return true;
1147
+ if (await this.applySlices(this.renderer.flushOutput()))
1148
+ return true;
1149
+ const toolName = event.toolName || "unknown";
1150
+ const toolCallId = event.toolCallId || this.assembler.nextMissingCallId();
1151
+ this.assembler.recordCall({ toolCallId, toolName, input: event.input });
1152
+ logToolCall(toolName, event.input);
1153
+ if (cbs.onToolCall)
1154
+ invokeCallback(cbs.onToolCall, toolName, event.input, toolCallId);
1155
+ else
1156
+ printToolCall(toolName, event.input);
1157
+ if (this.applyLoopAction(loopGuard.observe(toolName, event.input))) {
1158
+ await this.flushOutputAndStep();
1159
+ return true;
1160
+ }
1161
+ return false;
1162
+ }
1163
+ case "tool-result":
1164
+ case "tool-error": {
1165
+ const isError = event.type === "tool-error";
1166
+ const output = isError ? event.error : event.output;
1167
+ const displayed = isError ? `Error: ${safeText(event.error)}` : event.output;
1168
+ this.assembler.recordResult({
1169
+ toolCallId: event.toolCallId,
1170
+ toolName: event.toolName || "unknown",
1171
+ output,
1172
+ });
1173
+ logToolResult(event.toolName, displayed);
1174
+ if (cbs.onToolResult)
1175
+ invokeCallback(cbs.onToolResult, event.toolName, displayed, { toolCallId: event.toolCallId, isError });
1176
+ else
1177
+ printToolResult(event.toolName, displayed, isError);
1178
+ // Research productivity is judged on the result, not the call.
1179
+ this.applyLoopAction(loopGuard.observeResult(event.toolName || "unknown", isError ? "error" : output));
1180
+ if (this.stepFinishSeen || this.pendingLoop)
1181
+ return await this.flushAndSettleLoop();
1182
+ return false;
1183
+ }
1184
+ case "error": {
1185
+ const errorMsg = String(event.error);
1186
+ if (isProviderStallError(event.error)) {
1187
+ this.stop.stall = true;
1188
+ log("warn", `provider stall: ${describeError(event.error)}`);
1189
+ await this.flushOutputAndStep();
1190
+ this.innerController.abort();
1191
+ return true;
1192
+ }
1193
+ if (isContextOverflowError(errorMsg)) {
1194
+ this.stop.context = true;
1195
+ log("warn", `context overflow: ${errorMsg}`);
1196
+ await this.flushOutputAndStep();
1197
+ this.innerController.abort();
1198
+ return true;
1199
+ }
1200
+ this.hasError = true;
1201
+ log("error", `stream error: ${errorMsg}`);
1202
+ if (cbs.onStreamError)
1203
+ invokeCallback(cbs.onStreamError, errorMsg);
1204
+ else
1205
+ console.error(`\x1b[31m${formatErrorMessage(errorMsg)}\x1b[0m`);
1206
+ return false;
416
1207
  }
417
- printDivider();
418
- const ctxWindow = await getContextWindow(modelId);
419
- printDone(stepCount, usage, ctxWindow);
420
- if (budgetExceeded)
421
- console.log(`\x1b[33m⚠ 已达到预算上限 ($${budgetLimit}),运行已中断\x1b[0m`);
422
1208
  }
1209
+ return false;
423
1210
  }
424
- catch (err) {
425
- if (!callbacks && rawText.trim())
426
- console.log();
427
- const aborted = abortSignal?.aborted || (err instanceof Error && err.name === "AbortError");
428
- flushSplit();
429
- if (!callbacks)
430
- process.stdout.write(md.flush());
431
- pushTurn(messages, assistantText, toolCalls, toolResults);
432
- const usage = await safeUsage(result);
433
- if (aborted) {
434
- if (budgetExceeded && !callbacks)
435
- console.log(`\x1b[33m⚠ 已达到预算上限 ($${budgetLimit}),运行已中断\x1b[0m`);
436
- cbs.onRunFinish?.({ stepCount, usage, hasError, aborted: true, maxStepsReached, budgetExceeded });
437
- return;
1211
+ snapshot(usage, extra = {}) {
1212
+ return {
1213
+ hasError: this.hasError,
1214
+ userAborted: Boolean(this.params.abortSignal?.aborted),
1215
+ budgetExceeded: this.stop.budget,
1216
+ doomLoop: this.stop.doom,
1217
+ researchSteer: this.stop.steer,
1218
+ researchCap: this.stop.cap,
1219
+ contextPressure: this.stop.context,
1220
+ toolPressure: this.stop.tools,
1221
+ maxStepsReached: this.params.stepBounded && this.assembler.innerSteps >= this.params.maxSteps && this.assembler.lastStepHadTools,
1222
+ wrapUp: this.stop.wrapUp,
1223
+ lastStepHadTools: this.assembler.lastStepHadTools,
1224
+ xmlToolFollowUp: this.xmlSearchRecovered && !this.answerAfterXml && !this.stop.doom,
1225
+ emptyCompletion: !this.assembler.hadAssistantText && !this.assembler.hadTools && !this.xmlSearchRecovered,
1226
+ providerStall: this.stop.stall,
1227
+ stepCount: this.assembler.innerSteps,
1228
+ usage,
1229
+ ...extra,
1230
+ };
1231
+ }
1232
+ /** Normal end-of-stream: flush everything and snapshot usage. */
1233
+ async finalize(result) {
1234
+ await this.flushOutputAndStep();
1235
+ this.settlePendingLoop();
1236
+ return this.snapshot(this.resolvedUsage(result ? await safeUsage(result) : undefined));
1237
+ }
1238
+ /** Exception path: flush, classify the error, snapshot. */
1239
+ async handleException(err, result) {
1240
+ await this.applySlices(this.renderer.flushOnError());
1241
+ this.assembler.flushStep();
1242
+ this.settlePendingLoop();
1243
+ const usage = this.resolvedUsage(result ? await safeUsage(result) : undefined);
1244
+ if (this.params.abortSignal?.aborted)
1245
+ return this.snapshot(usage, { userAborted: true });
1246
+ if (isProviderStallError(err)) {
1247
+ // The provider went quiet and we cut the request. Nothing was produced →
1248
+ // let the empty-reply retry path re-send it; otherwise keep the partial
1249
+ // turn and continue like any other tool-only step.
1250
+ const produced = this.assembler.hadAssistantText || this.assembler.hadTools;
1251
+ log("warn", `provider stall: ${describeError(err)}`);
1252
+ this.stop.stall = true;
1253
+ return this.snapshot(usage, { hasError: false, userAborted: false, emptyCompletion: !produced });
1254
+ }
1255
+ if (err instanceof Error && err.name === "AbortError") {
1256
+ return this.snapshot(usage, { userAborted: false });
438
1257
  }
439
- if (!callbacks)
440
- printDivider();
441
1258
  const msg = err instanceof Error ? err.message : String(err);
442
1259
  log("error", msg);
443
- const display = formatErrorMessage(msg);
444
- if (cbs.onStreamError) {
445
- cbs.onStreamError(msg);
1260
+ if (isContextOverflowError(msg)) {
1261
+ this.stop.context = true;
1262
+ return this.snapshot(usage, { hasError: false, contextPressure: true, userAborted: false });
446
1263
  }
447
- else if (display !== msg) {
1264
+ this.hasError = true;
1265
+ const display = formatErrorMessage(msg);
1266
+ if (this.params.cbs.onStreamError)
1267
+ invokeCallback(this.params.cbs.onStreamError, msg);
1268
+ else if (display !== msg)
448
1269
  console.error(`\x1b[31m${display}\x1b[0m`);
449
- }
450
- else {
1270
+ else
451
1271
  console.error(`\x1b[31mError: ${msg}\x1b[0m`);
1272
+ return this.snapshot(usage, { hasError: true, userAborted: false, contextPressure: false });
1273
+ }
1274
+ }
1275
+ async function runInnerStream(params) {
1276
+ const machine = new InnerStreamMachine(params);
1277
+ let result;
1278
+ try {
1279
+ machine.attachAbort();
1280
+ const prompts = buildIterationPrompts(params.allTools, params.messages, params.taskState, params.loopGuard);
1281
+ const system = [params.systemPrompt, ...prompts.stable].join("\n\n");
1282
+ applyToolPrune(params.messages, params.pruneBudget());
1283
+ // Volatile state rides along as a trailing message so the cached prefix
1284
+ // (system + history) stays stable across passes.
1285
+ const stateMessage = sessionStateMessage(prompts.volatile);
1286
+ const requestMessages = stateMessage ? [...params.messages, stateMessage] : params.messages;
1287
+ params.overhead.tokens =
1288
+ estimateOverheadTokens(system, params.allTools) +
1289
+ (stateMessage ? estimateOverheadTokens(String(stateMessage.content)) : 0);
1290
+ result = streamText({
1291
+ model: params.model,
1292
+ system,
1293
+ messages: requestMessages,
1294
+ tools: params.allTools,
1295
+ stopWhen: stepCountIs(params.maxSteps),
1296
+ maxRetries: 3,
1297
+ abortSignal: machine.signal,
1298
+ ...(params.temperature != null ? { temperature: params.temperature } : {}),
1299
+ ...(params.maxTokens != null ? { maxOutputTokens: params.maxTokens } : {}),
1300
+ ...(params.topP != null ? { topP: params.topP } : {}),
1301
+ onStepFinish({ usage }) {
1302
+ machine.onStepFinish(usage);
1303
+ },
1304
+ onError({ error }) {
1305
+ machine.onStepError(error);
1306
+ },
1307
+ });
1308
+ for await (const event of result.fullStream) {
1309
+ if (await machine.handleEvent(event))
1310
+ break;
452
1311
  }
453
- cbs.onRunFinish?.({ stepCount, usage, hasError: true, aborted: false, maxStepsReached, budgetExceeded });
1312
+ return await machine.finalize(result);
1313
+ }
1314
+ catch (err) {
1315
+ return machine.handleException(err, result);
454
1316
  }
455
1317
  finally {
456
- abortSignal?.removeEventListener("abort", forwardAbort);
1318
+ machine.detachAbort();
457
1319
  }
458
1320
  }
459
1321
  export async function runOnce(messages, instructions, modelId, abortSignal, callbacks, tracker, options) {
460
- await runOnceCore(messages, buildSystemPrompt(instructions), modelId, abortSignal, callbacks, tracker, "chat", options);
1322
+ const project = scanProject();
1323
+ const systemPrompt = buildCodeSystemPrompt(project, instructions);
1324
+ return runOnceCore(messages, systemPrompt, modelId, abortSignal, callbacks, tracker, options);
461
1325
  }
462
- /** runOnce variant that accepts a pre-built system prompt (for code mode) */
463
1326
  export async function runOnceWithSystem(messages, systemPrompt, modelId, abortSignal, callbacks, tracker, options) {
464
- await runOnceCore(messages, systemPrompt, modelId, abortSignal, callbacks, tracker, "code", options);
1327
+ return runOnceCore(messages, systemPrompt, modelId, abortSignal, callbacks, tracker, options);
465
1328
  }