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.
- package/README.md +154 -216
- package/dist/agent.js +1119 -256
- package/dist/cli/commands/chat.js +10 -0
- package/dist/cli/commands/exec.js +32 -0
- package/dist/cli/commands/history.js +58 -0
- package/dist/cli/commands/index.js +224 -0
- package/dist/cli/commands/init.js +18 -0
- package/dist/cli/commands/mcp.js +173 -0
- package/dist/cli/commands/memory.js +69 -0
- package/dist/cli/commands/models.js +21 -0
- package/dist/cli/commands/permission.js +12 -0
- package/dist/cli/commands/rules.js +33 -0
- package/dist/cli/commands/sandbox.js +13 -0
- package/dist/cli/commands/serve.js +9 -0
- package/dist/cli/commands/setup.js +4 -0
- package/dist/cli/commands/shared.js +16 -0
- package/dist/cli/commands/skills.js +119 -0
- package/dist/cli/commands/update.js +7 -0
- package/dist/cli/commands/write-config.js +30 -0
- package/dist/cli/errors.js +36 -0
- package/dist/cli/exec-prompt.js +26 -0
- package/dist/cli/option-helpers.js +53 -0
- package/dist/cli/program.js +180 -0
- package/dist/cli.js +5 -888
- package/dist/code-mode.js +32 -14
- package/dist/compaction.js +347 -160
- package/dist/config.js +119 -10
- package/dist/confirm.js +56 -9
- package/dist/context-window.js +107 -39
- package/dist/doom-loop.js +264 -29
- package/dist/fetch-timeout.js +152 -0
- package/dist/http-approvals.js +60 -0
- package/dist/instructions.js +21 -0
- package/dist/logger.js +33 -4
- package/dist/markdown.js +37 -11
- package/dist/mcp.js +328 -30
- package/dist/memory.js +97 -56
- package/dist/output.js +7 -5
- package/dist/permission-cli.js +43 -0
- package/dist/plugins.js +46 -8
- package/dist/pricing.js +4 -4
- package/dist/provider.js +23 -6
- package/dist/question-format.js +60 -0
- package/dist/sandbox-cli.js +82 -0
- package/dist/sandbox.js +403 -0
- package/dist/save-throttle.js +45 -0
- package/dist/serve/common.js +404 -0
- package/dist/serve/routes-chat.js +347 -0
- package/dist/serve/routes-mcp.js +212 -0
- package/dist/serve/routes-memory.js +66 -0
- package/dist/serve/routes-meta.js +205 -0
- package/dist/serve/routes-sessions.js +61 -0
- package/dist/serve/routes-skills.js +70 -0
- package/dist/serve.js +33 -883
- package/dist/sessions.js +53 -9
- package/dist/skills.js +82 -18
- package/dist/title-gen.js +8 -2
- package/dist/token-display.js +36 -0
- package/dist/tool-display.js +5 -0
- package/dist/tool-output.js +1 -3
- package/dist/tools/apply_patch.js +85 -11
- package/dist/tools/atomic-file.js +35 -0
- package/dist/tools/backend.js +2 -2
- package/dist/tools/bash.js +57 -19
- package/dist/tools/code_search.js +7 -1
- package/dist/tools/edit.js +11 -10
- package/dist/tools/explore.js +74 -14
- package/dist/tools/glob.js +4 -0
- package/dist/tools/grep.js +17 -10
- package/dist/tools/index.js +6 -21
- package/dist/tools/question.js +28 -9
- package/dist/tools/read.js +6 -4
- package/dist/tools/search-searxng.js +223 -0
- package/dist/tools/search-serper.js +189 -0
- package/dist/tools/task.js +84 -30
- package/dist/tools/todo.js +120 -19
- package/dist/tools/web_fetch.js +11 -3
- package/dist/tools/web_search.js +66 -556
- package/dist/tools/write.js +23 -6
- package/dist/tui/App.js +63 -14
- package/dist/tui/ConfirmBar.js +45 -13
- package/dist/tui/InputBar.js +150 -35
- package/dist/tui/MessageList.js +266 -125
- package/dist/tui/ModelPicker.js +8 -3
- package/dist/tui/QuestionBar.js +51 -19
- package/dist/tui/SessionPicker.js +79 -0
- package/dist/tui/StatusBar.js +8 -14
- package/dist/tui/agent-runner.js +142 -22
- package/dist/tui/caret-pos.js +48 -5
- package/dist/tui/caret.js +1 -1
- package/dist/tui/click-count.js +13 -0
- package/dist/tui/drag-state.js +8 -3
- package/dist/tui/hydrate.js +129 -0
- package/dist/tui/index.js +42 -13
- package/dist/tui/input-history.js +92 -11
- package/dist/tui/layout.js +75 -4
- package/dist/tui/prompt-queue.js +24 -0
- package/dist/tui/selection.js +113 -21
- package/dist/tui/session-switch.js +28 -0
- package/dist/tui/slash-commands.js +22 -6
- package/dist/tui/slash-handler.js +233 -58
- package/dist/tui/text-width.js +38 -16
- package/dist/tui/token-info.js +7 -0
- package/dist/tui/tool-children.js +19 -0
- package/dist/tui/undo-stack.js +1 -1
- package/dist/tui/use-sgr-mouse.js +3 -1
- package/dist/tui-chat.js +276 -40
- package/dist/updater.js +88 -29
- package/dist/xml-search.js +194 -0
- package/docs/API.md +257 -25
- package/docs/superpowers/plans/2026-08-20-tui-completeness.md +873 -0
- package/docs/superpowers/plans/2026-08-20-unified-tui-default.md +631 -0
- package/docs/superpowers/specs/2026-08-20-config-http-alignment-design.md +47 -0
- package/docs/superpowers/specs/2026-08-20-mcp-plugins-alignment-design.md +37 -0
- package/docs/superpowers/specs/2026-08-20-sandbox-permissions-design.md +68 -0
- package/docs/superpowers/specs/2026-08-20-tui-completeness-design.md +273 -0
- package/docs/superpowers/specs/2026-08-20-unified-tui-default-design.md +165 -0
- package/package.json +6 -1
- package/skills/self-config/SKILL.md +90 -0
- 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 {
|
|
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 {
|
|
8
|
-
import {
|
|
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 {
|
|
20
|
-
import { log, logToolCall, logToolResult } from "./logger.js";
|
|
21
|
-
import { markSyntheticMessage } from "./synthetic.js";
|
|
22
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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 = {
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
446
|
+
startRunLog();
|
|
447
|
+
log("info", `run start messages=${messages.length} model=${typeof model === "string" ? model : model.modelId}`);
|
|
208
448
|
const cbs = callbacks ?? {};
|
|
209
|
-
const
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
|
805
|
+
if (this.cbs.onThinkingDelta)
|
|
806
|
+
invokeCallback(this.cbs.onThinkingDelta, t);
|
|
311
807
|
else
|
|
312
808
|
writeThinkingDelta(t);
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
cbs.onAssistantDisplayDelta
|
|
317
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
if (
|
|
406
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
1260
|
+
if (isContextOverflowError(msg)) {
|
|
1261
|
+
this.stop.context = true;
|
|
1262
|
+
return this.snapshot(usage, { hasError: false, contextPressure: true, userAborted: false });
|
|
446
1263
|
}
|
|
447
|
-
|
|
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
|
-
|
|
1312
|
+
return await machine.finalize(result);
|
|
1313
|
+
}
|
|
1314
|
+
catch (err) {
|
|
1315
|
+
return machine.handleException(err, result);
|
|
454
1316
|
}
|
|
455
1317
|
finally {
|
|
456
|
-
|
|
1318
|
+
machine.detachAbort();
|
|
457
1319
|
}
|
|
458
1320
|
}
|
|
459
1321
|
export async function runOnce(messages, instructions, modelId, abortSignal, callbacks, tracker, options) {
|
|
460
|
-
|
|
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
|
-
|
|
1327
|
+
return runOnceCore(messages, systemPrompt, modelId, abortSignal, callbacks, tracker, options);
|
|
465
1328
|
}
|