min-agent 0.2.1 → 0.4.0

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 (137) hide show
  1. package/README.md +242 -31
  2. package/dist/agent.js +1233 -485
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli/commands/chat.js +10 -0
  5. package/dist/cli/commands/exec.js +32 -0
  6. package/dist/cli/commands/history.js +58 -0
  7. package/dist/cli/commands/index.js +224 -0
  8. package/dist/cli/commands/init.js +18 -0
  9. package/dist/cli/commands/mcp.js +173 -0
  10. package/dist/cli/commands/memory.js +69 -0
  11. package/dist/cli/commands/models.js +21 -0
  12. package/dist/cli/commands/permission.js +12 -0
  13. package/dist/cli/commands/rules.js +33 -0
  14. package/dist/cli/commands/sandbox.js +13 -0
  15. package/dist/cli/commands/serve.js +9 -0
  16. package/dist/cli/commands/setup.js +4 -0
  17. package/dist/cli/commands/shared.js +16 -0
  18. package/dist/cli/commands/skills.js +119 -0
  19. package/dist/cli/commands/update.js +7 -0
  20. package/dist/cli/commands/write-config.js +30 -0
  21. package/dist/cli/errors.js +36 -0
  22. package/dist/cli/exec-prompt.js +26 -0
  23. package/dist/cli/option-helpers.js +53 -0
  24. package/dist/cli/program.js +180 -0
  25. package/dist/cli.js +7 -632
  26. package/dist/clipboard.js +59 -23
  27. package/dist/code-mode.js +35 -17
  28. package/dist/compaction.js +457 -169
  29. package/dist/config.js +298 -38
  30. package/dist/confirm.js +105 -9
  31. package/dist/context-window.js +156 -75
  32. package/dist/doom-loop.js +268 -26
  33. package/dist/fetch-timeout.js +152 -0
  34. package/dist/http-approvals.js +60 -0
  35. package/dist/http.js +119 -0
  36. package/dist/instructions.js +72 -33
  37. package/dist/logger.js +95 -0
  38. package/dist/markdown.js +35 -50
  39. package/dist/mcp.js +847 -102
  40. package/dist/memory.js +128 -45
  41. package/dist/output.js +42 -31
  42. package/dist/paste-handler.js +3 -3
  43. package/dist/permission-cli.js +43 -0
  44. package/dist/plugins.js +76 -11
  45. package/dist/pricing.js +119 -0
  46. package/dist/provider.js +34 -15
  47. package/dist/question-format.js +60 -0
  48. package/dist/sandbox-cli.js +82 -0
  49. package/dist/sandbox.js +403 -0
  50. package/dist/save-throttle.js +45 -0
  51. package/dist/serve/common.js +404 -0
  52. package/dist/serve/routes-chat.js +347 -0
  53. package/dist/serve/routes-mcp.js +212 -0
  54. package/dist/serve/routes-memory.js +66 -0
  55. package/dist/serve/routes-meta.js +205 -0
  56. package/dist/serve/routes-sessions.js +61 -0
  57. package/dist/serve/routes-skills.js +70 -0
  58. package/dist/serve.js +74 -635
  59. package/dist/sessions.js +197 -15
  60. package/dist/skills.js +531 -77
  61. package/dist/synthetic.js +7 -0
  62. package/dist/title-gen.js +9 -2
  63. package/dist/token-display.js +36 -0
  64. package/dist/tool-display.js +178 -0
  65. package/dist/tool-output.js +53 -46
  66. package/dist/tools/apply_patch.js +265 -0
  67. package/dist/tools/atomic-file.js +35 -0
  68. package/dist/tools/backend.js +61 -0
  69. package/dist/tools/bash.js +186 -71
  70. package/dist/tools/code_search.js +13 -6
  71. package/dist/tools/edit.js +26 -9
  72. package/dist/tools/explore.js +144 -16
  73. package/dist/tools/glob.js +7 -3
  74. package/dist/tools/grep.js +153 -14
  75. package/dist/tools/index.js +9 -24
  76. package/dist/tools/question.js +31 -30
  77. package/dist/tools/read.js +77 -15
  78. package/dist/tools/search-searxng.js +223 -0
  79. package/dist/tools/search-serper.js +189 -0
  80. package/dist/tools/task.js +100 -33
  81. package/dist/tools/todo.js +178 -67
  82. package/dist/tools/web_fetch.js +158 -46
  83. package/dist/tools/web_search.js +217 -29
  84. package/dist/tools/write.js +34 -11
  85. package/dist/tui/App.js +89 -6
  86. package/dist/tui/ConfirmBar.js +57 -4
  87. package/dist/tui/InputBar.js +504 -44
  88. package/dist/tui/MessageList.js +674 -20
  89. package/dist/tui/ModelPicker.js +113 -0
  90. package/dist/tui/QuestionBar.js +136 -0
  91. package/dist/tui/SessionPicker.js +79 -0
  92. package/dist/tui/StatusBar.js +14 -12
  93. package/dist/tui/agent-runner.js +223 -0
  94. package/dist/tui/caret-pos.js +177 -0
  95. package/dist/tui/caret.js +69 -0
  96. package/dist/tui/click-count.js +13 -0
  97. package/dist/tui/diff-view.js +61 -0
  98. package/dist/tui/drag-state.js +49 -0
  99. package/dist/tui/hydrate.js +129 -0
  100. package/dist/tui/index.js +189 -31
  101. package/dist/tui/input-history.js +125 -0
  102. package/dist/tui/layout.js +88 -0
  103. package/dist/tui/mouse.js +46 -0
  104. package/dist/tui/prompt-queue.js +24 -0
  105. package/dist/tui/selection.js +226 -0
  106. package/dist/tui/session-switch.js +28 -0
  107. package/dist/tui/slash-commands.js +106 -0
  108. package/dist/tui/slash-handler.js +545 -0
  109. package/dist/tui/text-width.js +113 -0
  110. package/dist/tui/theme.js +12 -0
  111. package/dist/tui/token-info.js +7 -0
  112. package/dist/tui/tool-children.js +19 -0
  113. package/dist/tui/undo-stack.js +14 -0
  114. package/dist/tui/use-sgr-mouse.js +29 -0
  115. package/dist/tui-chat.js +346 -330
  116. package/dist/updater.js +116 -0
  117. package/dist/xml-search.js +194 -0
  118. package/docs/API.md +410 -32
  119. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  120. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  121. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  122. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  123. package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
  124. package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
  125. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  126. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  127. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  128. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  129. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  130. package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
  131. package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
  132. package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
  133. package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
  134. package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
  135. package/package.json +12 -8
  136. package/skills/self-config/SKILL.md +90 -0
  137. package/skills/self-config/reference.md +149 -0
package/dist/agent.js CHANGED
@@ -1,31 +1,93 @@
1
- import { streamText, stepCountIs } from "ai";
2
- import { readFileSync, existsSync } from "fs";
1
+ import { streamText, stepCountIs, } from "ai";
2
+ import { readFileSync, existsSync, statSync } from "fs";
3
3
  import path from "path";
4
4
  import { resolveModel } from "./provider.js";
5
- import { createChatTools, createCodeTools } from "./tools/index.js";
6
- import { initMcp, shutdownMcp, getMcpTools } from "./mcp.js";
7
- import { discoverSkills, getSkillsTool, getSkillsSystemPrompt, getSkills } from "./skills.js";
5
+ import { isProviderStallError, describeError } from "./fetch-timeout.js";
6
+ import { getEffectiveConfig, getActiveProvider } from "./config.js";
7
+ import { getModelPrice, estimateCost } from "./pricing.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";
11
+ import { discoverSkills, attachSkills, collectLoadedSkillNames } from "./skills.js";
8
12
  import { loadInstructions } from "./instructions.js";
9
13
  import { getMemorySystemPrompt, getMemoryTools } from "./memory.js";
10
- import { needsCompaction, compactMessages, estimateTokens, TokenTracker } from "./compaction.js";
11
- 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";
12
16
  import { MarkdownRenderer } from "./markdown.js";
13
- 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";
14
18
  import { ThinkingBodySplitter, stripThinkingFromAssistantText } from "./assistant-stream.js";
19
+ import { XmlSearchSplitter } from "./xml-search.js";
15
20
  import { printHeader, printDivider, printToolCall, printToolResult, printDone } from "./output.js";
16
- const MAX_STEPS = 30;
17
- function dimStyle() {
18
- if ("NO_COLOR" in process.env)
19
- return { dim: "", reset: "" };
20
- return { dim: "\x1b[90m", reset: "\x1b[0m" };
21
- }
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;
41
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
42
+ const IMAGE_MIME_TYPES = {
43
+ ".png": "image/png",
44
+ ".jpg": "image/jpeg",
45
+ ".jpeg": "image/jpeg",
46
+ ".gif": "image/gif",
47
+ ".webp": "image/webp",
48
+ };
49
+ const DIM_STYLE = "NO_COLOR" in process.env ? { dim: "", reset: "" } : { dim: "\x1b[90m", reset: "\x1b[0m" };
22
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
+ }
23
85
  function printInitLoading() {
24
- const { dim, reset } = dimStyle();
86
+ const { dim, reset } = DIM_STYLE;
25
87
  console.log(`${dim}⟳ 正在初始化(MCP、技能、规则)…${reset}`);
26
88
  }
27
89
  function printInitReady() {
28
- const { dim, reset } = dimStyle();
90
+ const { dim, reset } = DIM_STYLE;
29
91
  console.log(`${dim}✓ 就绪${reset}`);
30
92
  }
31
93
  /** Stream thinking to stderr (dim). Set MIN_AGENT_SHOW_THINKING=0 to hide. */
@@ -34,547 +96,1233 @@ function writeThinkingDelta(text) {
34
96
  return;
35
97
  if (process.env.MIN_AGENT_SHOW_THINKING === "0" || process.env.MIN_AGENT_SHOW_THINKING === "false")
36
98
  return;
37
- const noColor = "NO_COLOR" in process.env;
38
- const dim = noColor ? "" : "\x1b[2m";
39
- const reset = noColor ? "" : "\x1b[0m";
40
- process.stderr.write(`${dim}${text}${reset}`);
41
- }
42
- function buildSystemPrompt(instructions) {
43
- const parts = [
44
- `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.`,
45
- "",
46
- "Be concise and direct. When you run a command, briefly explain why.",
47
- "Use the available tools to complete tasks. When multiple independent operations are needed, call tools in parallel.",
48
- "When the user asks about current events, news, or anything requiring up-to-date information, use the web_search tool.",
49
- "",
50
- `Working directory: ${process.cwd()}`,
51
- `Platform: ${process.platform}`,
52
- `Date: ${new Date().toDateString()}`,
53
- "",
54
- `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.`,
55
- ];
56
- const skillsPrompt = getSkillsSystemPrompt();
57
- if (skillsPrompt) {
58
- parts.push("", skillsPrompt);
59
- }
60
- const memoryPrompt = getMemorySystemPrompt();
61
- if (memoryPrompt) {
62
- parts.push("", memoryPrompt);
63
- }
64
- if (instructions.length > 0) {
65
- parts.push("", "# User Instructions", "");
66
- parts.push(...instructions);
67
- }
68
- return parts.join("\n");
99
+ process.stderr.write(`${DIM_STYLE.dim}${text}${DIM_STYLE.reset}`);
69
100
  }
70
- /** Build user message content, optionally with images */
71
- export async function buildUserContent(message, imagePaths) {
72
- if (!imagePaths || imagePaths.length === 0)
73
- return message;
74
- const parts = [{ type: "text", text: message }];
101
+ export function loadImageParts(imagePaths, notify) {
102
+ const parts = [];
75
103
  for (const imgPath of imagePaths) {
76
104
  const resolved = path.resolve(process.cwd(), imgPath);
77
105
  if (!existsSync(resolved)) {
78
- console.error(`\x1b[33m Warning: Image not found: ${imgPath}\x1b[0m`);
106
+ notify?.("not_found", imgPath);
107
+ continue;
108
+ }
109
+ const size = statSync(resolved).size;
110
+ if (size > MAX_IMAGE_BYTES) {
111
+ notify?.("too_large", imgPath, size);
79
112
  continue;
80
113
  }
81
114
  const data = readFileSync(resolved);
82
115
  const ext = path.extname(resolved).toLowerCase();
83
- const mimeMap = {
84
- ".png": "image/png",
85
- ".jpg": "image/jpeg",
86
- ".jpeg": "image/jpeg",
87
- ".gif": "image/gif",
88
- ".webp": "image/webp",
89
- };
90
- const mimeType = mimeMap[ext] ?? "image/png";
91
- parts.push({
92
- type: "image",
93
- image: data,
94
- mimeType,
95
- });
96
- console.log(`\x1b[90m 📎 ${imgPath}\x1b[0m`);
116
+ parts.push({ type: "image", image: data, mimeType: IMAGE_MIME_TYPES[ext] ?? "image/png" });
117
+ notify?.("attached", imgPath);
97
118
  }
98
119
  return parts;
99
120
  }
121
+ /** Build user message content, optionally with images */
122
+ export async function buildUserContent(message, imagePaths) {
123
+ if (!imagePaths || imagePaths.length === 0)
124
+ return message;
125
+ const images = loadImageParts(imagePaths, (kind, imgPath, sizeBytes) => {
126
+ if (kind === "not_found") {
127
+ console.error(`\x1b[33m Warning: Image not found: ${imgPath}\x1b[0m`);
128
+ }
129
+ else if (kind === "too_large") {
130
+ console.error(`\x1b[33m Warning: Image skipped (${((sizeBytes ?? 0) / 1024 / 1024).toFixed(1)} MB exceeds ${MAX_IMAGE_BYTES / 1024 / 1024} MB limit): ${imgPath}\x1b[0m`);
131
+ }
132
+ else {
133
+ console.log(`\x1b[90m 📎 ${imgPath}\x1b[0m`);
134
+ }
135
+ });
136
+ return [{ type: "text", text: message }, ...images];
137
+ }
100
138
  /** Single-shot: send one message, get response, exit */
101
- export async function runAgent(message, modelId, imagePaths) {
139
+ export async function runAgent(message, modelId, imagePaths, providerName, resumeSessionId) {
102
140
  printHeader(modelId);
103
141
  printDivider();
104
142
  printInitLoading();
105
143
  await initMcp();
106
- discoverSkills();
107
- const instructions = await loadInstructions();
108
- printInitReady();
109
- console.log(`\x1b[36m> ${message}\x1b[0m\n`);
110
- const content = await buildUserContent(message, imagePaths);
111
- const messages = [{ role: "user", content }];
112
- const tracker = new TokenTracker();
113
- await runOnce(messages, instructions, modelId, undefined, undefined, tracker);
114
- await shutdownMcp();
115
- }
116
- /** runOnce variant that accepts a pre-built system prompt (for code mode) */
117
- export async function runOnceWithSystem(messages, systemPrompt, modelId, abortSignal, callbacks, tracker) {
118
- const model = resolveModel(modelId);
119
- const api = !!callbacks;
120
- if (needsCompaction(messages, tracker)) {
121
- console.log("\x1b[90m⟳ Compacting context...\x1b[0m");
122
- const result = await compactMessages(messages, model);
123
- if (result.compacted) {
124
- messages.length = 0;
125
- messages.push(...result.messages);
126
- if (result.shouldContinue) {
127
- const continueText = result.replayText || "Continue with your task.";
128
- messages.push({ role: "user", content: continueText });
129
- }
130
- if (tracker)
131
- tracker.resetContext();
132
- console.log(`\x1b[90m ✓ Compacted (${estimateTokens(messages)} tokens estimated)\x1b[0m`);
144
+ try {
145
+ discoverSkills();
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;
133
152
  }
153
+ printInitReady();
154
+ console.log(`\x1b[36m> ${message}\x1b[0m\n`);
155
+ const content = await buildUserContent(message, imagePaths);
156
+ const messages = [...history.messages, { role: "user", content }];
157
+ const tracker = new TokenTracker();
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;
181
+ }
182
+ finally {
183
+ await shutdownMcp();
184
+ }
185
+ }
186
+ /**
187
+ * Push assistant text + tool call/result history into `messages` so the model
188
+ * remembers its tool activity across turns and resumed sessions.
189
+ */
190
+ export function pushTurn(messages, assistantText, toolCalls, toolResults) {
191
+ const cleaned = stripThinkingFromAssistantText(assistantText);
192
+ const resultIds = new Set(toolResults.map((r) => r.toolCallId));
193
+ const parts = [];
194
+ if (cleaned.trim())
195
+ parts.push({ type: "text", text: cleaned });
196
+ for (const c of toolCalls) {
197
+ if (!resultIds.has(c.toolCallId))
198
+ continue;
199
+ parts.push({ type: "tool-call", toolCallId: c.toolCallId, toolName: c.toolName, input: c.input });
200
+ }
201
+ if (parts.length > 0) {
202
+ messages.push({ role: "assistant", content: parts });
203
+ }
204
+ for (const r of toolResults) {
205
+ const text = safeText(r.output);
206
+ messages.push({
207
+ role: "tool",
208
+ content: [
209
+ {
210
+ type: "tool-result",
211
+ toolCallId: r.toolCallId,
212
+ toolName: r.toolName,
213
+ output: { type: "text", value: text },
214
+ },
215
+ ],
216
+ });
134
217
  }
135
- const builtinTools = createCodeTools();
218
+ }
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 });
136
228
  const mcpTools = getMcpTools();
229
+ const catalogTools = getMcpCatalogTools();
137
230
  const memoryTools = getMemoryTools();
138
231
  const pluginTools = await loadPluginTools();
139
- const { createTaskTool } = await import("./tools/task.js");
232
+ const allTools = {
233
+ ...builtinTools,
234
+ ...memoryTools,
235
+ ...pluginTools,
236
+ ...mcpTools,
237
+ ...catalogTools,
238
+ };
239
+ const onUsage = tracker ? (usage) => tracker.add(usage) : undefined;
140
240
  const { createExploreTool } = await import("./tools/explore.js");
141
- const skills = getSkills();
142
- const allTools = { ...builtinTools, ...memoryTools, ...pluginTools };
143
- for (const [id, t] of Object.entries(mcpTools))
144
- allTools[id] = t;
145
- if (skills.length > 0)
146
- allTools["skill"] = getSkillsTool();
147
- allTools["task"] = createTaskTool(modelId);
148
- allTools["explore"] = createExploreTool(modelId);
149
- let stepCount = 0;
150
- let hasError = false;
151
- const doomLoop = new DoomLoopDetector();
152
- const result = streamText({
153
- model,
154
- system: systemPrompt,
155
- messages,
156
- tools: allTools,
157
- stopWhen: stepCountIs(MAX_STEPS),
158
- maxRetries: 3,
159
- abortSignal,
160
- onStepFinish() { stepCount++; },
161
- onError() { },
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,
162
248
  });
163
- Promise.resolve(result.usage).catch(() => { });
164
- let rawText = "";
165
- let assistantText = "";
166
- const md = new MarkdownRenderer();
167
- const thinkingSplit = new ThinkingBodySplitter();
249
+ if (planMode) {
250
+ for (const name of ["bash", "write", "edit", "apply_patch"])
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
+ }
262
+ }
263
+ return allTools;
264
+ }
265
+ function buildIterationPrompts(allTools, messages, taskState, loopGuard) {
266
+ const skillsPrompt = attachSkills(allTools, collectLoadedSkillNames(messages));
267
+ return {
268
+ stable: [skillsPrompt].filter((s) => s.length > 0),
269
+ volatile: [getMemorySystemPrompt(), formatTaskStatePrompt(taskState), loopGuard.promptHint()].filter((s) => s.length > 0),
270
+ };
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
+ }
279
+ function formatErrorMessage(msg) {
280
+ if (msg.includes("API key") || msg.includes("Unauthorized") || msg.includes("Forbidden")) {
281
+ return "Authentication error: Check your API key.";
282
+ }
283
+ if (msg.includes("429") || msg.includes("rate limit") || msg.includes("Rate limit")) {
284
+ return "Rate limited after retries. Please wait and try again.";
285
+ }
286
+ if (msg.includes("timeout") || msg.includes("ETIMEDOUT") || msg.includes("ECONNRESET")) {
287
+ return `Network error (retries exhausted): ${msg}`;
288
+ }
289
+ return msg;
290
+ }
291
+ function safeText(value) {
292
+ if (typeof value === "string")
293
+ return value;
168
294
  try {
169
- for await (const event of result.fullStream) {
170
- switch (event.type) {
171
- case "text-delta": {
172
- const { display, thinking } = thinkingSplit.feed(event.text);
173
- if (thinking) {
174
- if (callbacks?.onThinkingDelta)
175
- callbacks.onThinkingDelta(thinking);
176
- else
177
- writeThinkingDelta(thinking);
178
- }
179
- if (display) {
180
- rawText += display;
181
- assistantText += display;
182
- if (callbacks?.onAssistantDisplayDelta)
183
- callbacks.onAssistantDisplayDelta(display);
184
- else {
185
- const formatted = md.write(display);
186
- if (formatted)
187
- process.stdout.write(formatted);
188
- }
189
- }
190
- break;
191
- }
192
- case "tool-call": {
193
- if (doomLoop.record(event.toolName, event.input)) {
194
- const warn = `Doom loop detected: "${event.toolName}" — breaking.`;
195
- if (callbacks?.onStreamError)
196
- callbacks.onStreamError(warn);
197
- else
198
- console.log(`\n\x1b[33m⚠ ${warn}\x1b[0m`);
199
- hasError = true;
200
- break;
201
- }
202
- const flush = thinkingSplit.flush();
203
- if (flush.thinking) {
204
- if (callbacks?.onThinkingDelta)
205
- callbacks.onThinkingDelta(flush.thinking);
206
- else
207
- writeThinkingDelta(flush.thinking);
208
- }
209
- if (flush.display) {
210
- rawText += flush.display;
211
- assistantText += flush.display;
212
- if (callbacks?.onAssistantDisplayDelta)
213
- callbacks.onAssistantDisplayDelta(flush.display);
214
- else {
215
- const extra = md.write(flush.display);
216
- if (extra)
217
- process.stdout.write(extra);
218
- }
219
- }
220
- if (!api) {
221
- const flushed = md.flush();
222
- if (flushed)
223
- process.stdout.write(flushed);
224
- if (rawText.trim())
225
- console.log();
226
- }
227
- rawText = "";
228
- if (callbacks?.onToolCall)
229
- callbacks.onToolCall(event.toolName, event.input);
230
- else
231
- printToolCall(event.toolName, event.input);
232
- break;
233
- }
234
- case "tool-result":
235
- if (callbacks?.onToolResult)
236
- callbacks.onToolResult(event.toolName, event.output);
237
- else
238
- printToolResult(event.toolName, event.output);
239
- break;
240
- case "error":
241
- hasError = true;
242
- if (callbacks?.onStreamError)
243
- callbacks.onStreamError(String(event.error));
244
- else
245
- console.error(`\x1b[31mError: ${event.error}\x1b[0m`);
246
- break;
247
- case "finish":
248
- break;
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
+ }
328
+ async function safeUsage(result) {
329
+ try {
330
+ return await result.usage;
331
+ }
332
+ catch {
333
+ return undefined;
334
+ }
335
+ }
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) {
445
+ const model = resolveModel(modelId, options?.providerName);
446
+ startRunLog();
447
+ log("info", `run start messages=${messages.length} model=${typeof model === "string" ? model : model.modelId}`);
448
+ const cbs = callbacks ?? {};
449
+ const cfg = getEffectiveConfig();
450
+ const sampling = cfg.sampling ?? {};
451
+ const temperature = options?.temperature ?? sampling.temperature;
452
+ const maxTokens = options?.maxTokens ?? sampling.maxTokens;
453
+ const topP = options?.topP ?? sampling.topP;
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
+ });
491
+ const budgetLimit = cfg.budget?.maxCostUSD;
492
+ let budgetPrice = null;
493
+ if (budgetLimit != null && budgetLimit > 0) {
494
+ const provider = options?.providerName
495
+ ? cfg.providers?.find((p) => p.name === options.providerName)
496
+ : getActiveProvider(cfg);
497
+ budgetPrice = await getModelPrice(modelId ?? provider?.defaultModel ?? "");
498
+ }
499
+ let budgetExceeded = false;
500
+ const checkBudget = () => {
501
+ if (!tracker || budgetLimit == null || budgetLimit <= 0)
502
+ return false;
503
+ const cost = estimateCost({ inputTokens: tracker.totalInputTokens, outputTokens: tracker.totalOutputTokens }, budgetPrice);
504
+ if (cost != null && cost > budgetLimit) {
505
+ budgetExceeded = true;
506
+ return true;
507
+ }
508
+ return false;
509
+ };
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),
521
+ });
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)}`);
249
545
  }
546
+ return { hasError };
250
547
  }
251
- const end = thinkingSplit.flush();
252
- if (end.thinking) {
253
- if (callbacks?.onThinkingDelta)
254
- callbacks.onThinkingDelta(end.thinking);
255
- else
256
- writeThinkingDelta(end.thinking);
548
+ if (hasError) {
549
+ printDivider();
550
+ return { hasError };
257
551
  }
258
- if (end.display) {
259
- assistantText += end.display;
260
- if (callbacks?.onAssistantDisplayDelta)
261
- callbacks.onAssistantDisplayDelta(end.display);
262
- else {
263
- const tail = md.write(end.display);
264
- if (tail)
265
- process.stdout.write(tail);
266
- }
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`);
267
560
  }
268
- if (!api) {
269
- const remaining = md.flush();
270
- if (remaining)
271
- process.stdout.write(remaining);
272
- if (rawText.trim())
273
- console.log();
561
+ else if (opts.wrapUp) {
562
+ console.log(`\x1b[33m⚠ ${WRAP_UP_MESSAGE}\x1b[0m`);
274
563
  }
275
- const cleaned = stripThinkingFromAssistantText(assistantText);
276
- if (cleaned.trim())
277
- messages.push({ role: "assistant", content: cleaned });
278
- let usage;
279
- try {
280
- usage = await result.usage;
564
+ else if (opts.maxStepsReached) {
565
+ console.log(`\x1b[33m⚠ 已达到自动续跑上限,发送消息继续\x1b[0m`);
281
566
  }
282
- catch {
283
- usage = undefined;
567
+ else if (opts.incomplete) {
568
+ console.log(`\x1b[33m⚠ 还没有完整回复,发送消息可继续\x1b[0m`);
284
569
  }
285
- if (usage && tracker)
286
- tracker.update(usage);
287
- if (callbacks?.onRunFinish) {
288
- callbacks.onRunFinish({ stepCount, usage, hasError, aborted: false });
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
+ });
289
604
  }
290
- else {
291
- if (!hasError && usage) {
292
- printDivider();
293
- const { getContextWindow } = await import("./context-window.js");
294
- const ctxWindow = await getContextWindow(modelId);
295
- printDone(stepCount, usage, ctxWindow);
296
- }
297
- else {
298
- printDivider();
299
- }
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 });
300
614
  }
301
- }
302
- catch (err) {
303
- if (err.name === "AbortError" || abortSignal?.aborted) {
304
- const cleaned = stripThinkingFromAssistantText(assistantText);
305
- if (cleaned.trim())
306
- messages.push({ role: "assistant", content: cleaned });
307
- if (callbacks?.onRunFinish)
308
- callbacks.onRunFinish({ stepCount, usage: undefined, hasError: false, aborted: true });
309
- return;
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 });
310
627
  }
311
- if (callbacks?.onStreamError)
312
- callbacks.onStreamError(err.message);
313
- else {
314
- printDivider();
315
- console.error(`\x1b[31mError: ${err.message}\x1b[0m`);
628
+ if (budgetExceeded)
629
+ return finish({ aborted: true, maxStepsReached: false });
630
+ if (inner.toolPressure) {
631
+ applyToolPrune(messages, pruneBudget());
632
+ continue;
316
633
  }
317
- }
318
- }
319
- export async function runOnce(messages, instructions, modelId, abortSignal, callbacks, tracker) {
320
- const model = resolveModel(modelId);
321
- const api = !!callbacks;
322
- // Auto-compact if context is getting too large
323
- if (needsCompaction(messages, tracker)) {
324
- if (api) {
325
- callbacks.onCompaction?.("compacting_start");
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;
326
660
  }
327
- else {
328
- console.log("\x1b[90m⟳ Compacting context...\x1b[0m");
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 });
329
675
  }
330
- const result = await compactMessages(messages, model);
331
- if (result.compacted) {
332
- messages.length = 0;
333
- messages.push(...result.messages);
334
- // Auto-continue: inject a message so the agent keeps working
335
- if (result.shouldContinue) {
336
- const continueText = result.replayText ||
337
- "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed.";
338
- messages.push({ role: "user", content: continueText });
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 });
339
681
  }
340
- if (tracker)
341
- tracker.resetContext();
342
- if (api) {
343
- callbacks.onCompaction?.(`compacted_ok estimated_tokens=${estimateTokens(messages)}`);
682
+ wrapUpAsked = true;
683
+ continues++;
684
+ if (continues > maxContinues) {
685
+ return finish({ aborted: false, maxStepsReached: true, incomplete: true });
344
686
  }
345
- else {
346
- console.log(`\x1b[90m ✓ Compacted (${estimateTokens(messages)} tokens estimated)\x1b[0m`);
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 });
347
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 });
348
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));
349
722
  }
350
- // Merge tools: chat builtin + MCP + skill + memory + plugins (no task/explore in chat mode)
351
- const builtinTools = createChatTools();
352
- const mcpTools = getMcpTools();
353
- const memoryTools = getMemoryTools();
354
- const pluginTools = await loadPluginTools();
355
- const skills = getSkills();
356
- const allTools = { ...builtinTools, ...memoryTools, ...pluginTools };
357
- for (const [id, t] of Object.entries(mcpTools)) {
358
- allTools[id] = t;
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;
359
743
  }
360
- if (skills.length > 0) {
361
- allTools["skill"] = getSkillsTool();
744
+ get pendingResults() {
745
+ return this.toolResults.length;
362
746
  }
363
- let stepCount = 0;
364
- let hasError = false;
365
- const doomLoop = new DoomLoopDetector();
366
- const result = streamText({
367
- model,
368
- system: buildSystemPrompt(instructions),
369
- messages,
370
- tools: allTools,
371
- stopWhen: stepCountIs(MAX_STEPS),
372
- maxRetries: 3,
373
- abortSignal,
374
- onStepFinish() {
375
- stepCount++;
376
- },
377
- onError() { },
378
- });
379
- Promise.resolve(result.usage).catch(() => { });
380
- let rawText = "";
381
- let assistantText = "";
382
- const md = new MarkdownRenderer();
383
- const thinkingSplit = new ThinkingBodySplitter();
384
- const emitThinking = (t) => {
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) {
385
803
  if (!t)
386
804
  return;
387
- if (callbacks?.onThinkingDelta)
388
- callbacks.onThinkingDelta(t);
805
+ if (this.cbs.onThinkingDelta)
806
+ invokeCallback(this.cbs.onThinkingDelta, t);
389
807
  else
390
808
  writeThinkingDelta(t);
391
- };
392
- try {
393
- for await (const event of result.fullStream) {
394
- switch (event.type) {
395
- case "text-delta": {
396
- const { display, thinking } = thinkingSplit.feed(event.text);
397
- emitThinking(thinking);
398
- if (display) {
399
- rawText += display;
400
- assistantText += display;
401
- if (callbacks?.onAssistantDisplayDelta)
402
- callbacks.onAssistantDisplayDelta(display);
403
- else {
404
- const formatted = md.write(display);
405
- if (formatted)
406
- process.stdout.write(formatted);
407
- }
408
- }
409
- break;
410
- }
411
- case "tool-call": {
412
- // Doom loop detection
413
- if (doomLoop.record(event.toolName, event.input)) {
414
- const warning = `\x1b[33m⚠ Doom loop detected: "${event.toolName}" called ${3} times with same args. Breaking loop.\x1b[0m`;
415
- if (callbacks?.onStreamError)
416
- callbacks.onStreamError(warning);
417
- else
418
- console.log(`\n${warning}`);
419
- hasError = true;
420
- break;
421
- }
422
- const splitFlush = thinkingSplit.flush();
423
- emitThinking(splitFlush.thinking);
424
- if (splitFlush.display) {
425
- rawText += splitFlush.display;
426
- assistantText += splitFlush.display;
427
- if (callbacks?.onAssistantDisplayDelta)
428
- callbacks.onAssistantDisplayDelta(splitFlush.display);
429
- else {
430
- const extra = md.write(splitFlush.display);
431
- if (extra)
432
- process.stdout.write(extra);
433
- }
434
- }
435
- if (!callbacks) {
436
- const flushed = md.flush();
437
- if (flushed)
438
- process.stdout.write(flushed);
439
- if (rawText.trim())
440
- console.log();
441
- }
442
- rawText = "";
443
- if (callbacks?.onToolCall)
444
- callbacks.onToolCall(event.toolName, event.input);
445
- else
446
- printToolCall(event.toolName, event.input);
447
- break;
448
- }
449
- case "tool-result":
450
- if (callbacks?.onToolResult)
451
- callbacks.onToolResult(event.toolName, event.output);
452
- else
453
- printToolResult(event.toolName, event.output);
454
- break;
455
- case "error":
456
- hasError = true;
457
- const errorMsg = String(event.error);
458
- if (callbacks?.onStreamError) {
459
- callbacks.onStreamError(errorMsg);
460
- }
461
- else if (errorMsg.includes("Forbidden") || errorMsg.includes("Unauthorized") || errorMsg.includes("API key")) {
462
- console.error(`\x1b[31mAuthentication error: Check your API key.\x1b[0m`);
463
- }
464
- else {
465
- console.error(`\x1b[31mError: ${errorMsg}\x1b[0m`);
466
- }
467
- break;
468
- case "finish":
469
- break;
809
+ }
810
+ emitDisplay(delta) {
811
+ try {
812
+ if (this.cbs.onAssistantDisplayDelta) {
813
+ this.cbs.onAssistantDisplayDelta(delta);
814
+ return;
470
815
  }
816
+ const formatted = this.md.write(delta);
817
+ if (formatted)
818
+ process.stdout.write(formatted);
471
819
  }
472
- const splitEnd = thinkingSplit.flush();
473
- emitThinking(splitEnd.thinking);
474
- if (splitEnd.display) {
475
- rawText += splitEnd.display;
476
- assistantText += splitEnd.display;
477
- if (callbacks?.onAssistantDisplayDelta)
478
- callbacks.onAssistantDisplayDelta(splitEnd.display);
820
+ catch (err) {
821
+ log("error", `display callback failed: ${err instanceof Error ? err.message : String(err)}`);
822
+ }
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
+ }
479
845
  else {
480
- const tail = md.write(splitEnd.display);
481
- if (tail)
482
- process.stdout.write(tail);
846
+ out.push(sl);
483
847
  }
484
848
  }
485
- if (!callbacks) {
486
- const remaining = md.flush();
487
- if (remaining)
488
- process.stdout.write(remaining);
489
- if (rawText.trim())
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())
490
870
  console.log();
491
871
  }
492
- const cleanedAssistant = stripThinkingFromAssistantText(assistantText);
493
- if (cleanedAssistant.trim()) {
494
- messages.push({ role: "assistant", content: cleanedAssistant });
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 { }
495
888
  }
496
- let usage;
497
- try {
498
- usage = await result.usage;
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,
909
+ };
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();
499
948
  }
500
- catch {
501
- usage = undefined;
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();
502
954
  }
503
- // Update token tracker with real usage from API
504
- if (usage && tracker) {
505
- tracker.update(usage);
955
+ else if (allowContextAbort && this.usageSteps >= 2 && tracker && tracker.lastInputTokens > compactThreshold) {
956
+ this.stop.context = true;
957
+ this.innerController.abort();
506
958
  }
507
- if (callbacks?.onRunFinish) {
508
- callbacks.onRunFinish({ stepCount, usage, hasError, aborted: false });
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();
509
963
  }
510
- else {
511
- if (hasError) {
512
- printDivider();
513
- return;
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;
514
1017
  }
515
- printDivider();
516
- const { getContextWindow } = await import("./context-window.js");
517
- const ctxWindow = await getContextWindow(modelId);
518
- printDone(stepCount, usage, ctxWindow);
519
1018
  }
1019
+ return false;
520
1020
  }
521
- catch (err) {
522
- if (!callbacks && rawText.trim())
523
- console.log();
524
- if (err.name === "AbortError" || abortSignal?.aborted) {
525
- const splitAbort = thinkingSplit.flush();
526
- emitThinking(splitAbort.thinking);
527
- if (splitAbort.display) {
528
- assistantText += splitAbort.display;
529
- if (callbacks?.onAssistantDisplayDelta)
530
- callbacks.onAssistantDisplayDelta(splitAbort.display);
531
- else {
532
- const w = md.write(splitAbort.display);
533
- if (w)
534
- process.stdout.write(w);
535
- }
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;
1028
+ }
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;
1075
+ }
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;
536
1138
  }
537
- if (!callbacks)
538
- process.stdout.write(md.flush());
539
- const cleaned = stripThinkingFromAssistantText(assistantText);
540
- if (cleaned.trim()) {
541
- messages.push({ role: "assistant", content: cleaned });
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));
542
1143
  }
543
- let usage;
544
- try {
545
- usage = await result.usage;
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;
546
1162
  }
547
- catch {
548
- usage = undefined;
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;
549
1207
  }
550
- callbacks?.onRunFinish?.({ stepCount, usage, hasError: false, aborted: true });
551
- return;
552
- }
553
- if (!callbacks)
554
- printDivider();
555
- const msg = err.message ?? String(err);
556
- if (callbacks?.onStreamError) {
557
- callbacks.onStreamError(msg);
558
1208
  }
559
- else if (msg.includes("API key") || msg.includes("Unauthorized") || msg.includes("Forbidden")) {
560
- console.error(`\x1b[31mAuthentication error: Check your API key.\x1b[0m`);
1209
+ return false;
1210
+ }
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 });
561
1254
  }
562
- else if (msg.includes("429") || msg.includes("rate limit") || msg.includes("Rate limit")) {
563
- console.error(`\x1b[31mRate limited after retries. Please wait and try again.\x1b[0m`);
1255
+ if (err instanceof Error && err.name === "AbortError") {
1256
+ return this.snapshot(usage, { userAborted: false });
564
1257
  }
565
- else if (msg.includes("timeout") || msg.includes("ETIMEDOUT") || msg.includes("ECONNRESET")) {
566
- console.error(`\x1b[31mNetwork error (retries exhausted): ${msg}\x1b[0m`);
1258
+ const msg = err instanceof Error ? err.message : String(err);
1259
+ log("error", msg);
1260
+ if (isContextOverflowError(msg)) {
1261
+ this.stop.context = true;
1262
+ return this.snapshot(usage, { hasError: false, contextPressure: true, userAborted: false });
567
1263
  }
568
- else {
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)
1269
+ console.error(`\x1b[31m${display}\x1b[0m`);
1270
+ else
569
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;
570
1311
  }
571
- let usage;
572
- try {
573
- usage = await result.usage;
574
- }
575
- catch {
576
- usage = undefined;
577
- }
578
- callbacks?.onRunFinish?.({ stepCount, usage, hasError: true, aborted: false });
1312
+ return await machine.finalize(result);
1313
+ }
1314
+ catch (err) {
1315
+ return machine.handleException(err, result);
1316
+ }
1317
+ finally {
1318
+ machine.detachAbort();
579
1319
  }
580
1320
  }
1321
+ export async function runOnce(messages, instructions, modelId, abortSignal, callbacks, tracker, options) {
1322
+ const project = scanProject();
1323
+ const systemPrompt = buildCodeSystemPrompt(project, instructions);
1324
+ return runOnceCore(messages, systemPrompt, modelId, abortSignal, callbacks, tracker, options);
1325
+ }
1326
+ export async function runOnceWithSystem(messages, systemPrompt, modelId, abortSignal, callbacks, tracker, options) {
1327
+ return runOnceCore(messages, systemPrompt, modelId, abortSignal, callbacks, tracker, options);
1328
+ }