mocode-ai 1.4.2 → 1.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/dist/agent/core.js +14 -936
- package/dist/agent/index.js +37 -13
- package/dist/agent/model-turn.js +218 -0
- package/dist/agent/pipeline.js +18 -0
- package/dist/agent/run-contracts.js +1 -0
- package/dist/agent/run-coordinator.js +758 -0
- package/dist/agent/runtime-context.js +118 -24
- package/dist/agent/spawn.js +11 -7
- package/dist/agent/stages/context-trimmer.js +63 -0
- package/dist/agent/stages/contracts.js +12 -0
- package/dist/agent/stages/history-manager.js +178 -0
- package/dist/agent/stages/legacy-adapters.js +19 -0
- package/dist/agent/stages/model-runner.js +29 -0
- package/dist/agent/stages/run-policy.js +73 -0
- package/dist/agent/stages/tool-dispatcher.js +341 -0
- package/dist/agent/tool-helpers.js +12 -12
- package/dist/agent/tool-turn.js +87 -0
- package/dist/agent/trace-state.js +97 -101
- package/dist/agent/turn-lifecycle.js +110 -0
- package/dist/config/index.js +14 -0
- package/dist/host/stdio.js +101 -40
- package/dist/llm/index.js +51 -35
- package/dist/llm/providers/anthropic.js +16 -10
- package/dist/llm/runtime.js +1 -0
- package/dist/permissions/index.js +21 -5
- package/dist/repl/commands/compact.js +2 -2
- package/dist/repl/commands/session.js +3 -12
- package/dist/repl/message-format.js +5 -0
- package/dist/repl/runtime.js +95 -55
- package/dist/rollback/index.js +29 -624
- package/dist/rollback/store.js +593 -0
- package/dist/runtime/index.js +1 -0
- package/dist/runtime/runtime.js +307 -0
- package/dist/session/compact.js +22 -14
- package/dist/session/index.js +1 -0
- package/dist/session/persist.js +10 -146
- package/dist/session/scheduler.js +28 -16
- package/dist/session/state.js +16 -12
- package/dist/session/store.js +218 -0
- package/dist/session/trace.js +5 -15
- package/dist/tools/policy.js +19 -15
- package/dist/tools/registry.js +21 -229
- package/dist/tools/router.js +5 -3
- package/dist/tools/tool-runtime.js +267 -0
- package/dist/ui/layout-internal/content-write.js +4 -0
- package/package.json +7 -3
package/dist/agent/core.js
CHANGED
|
@@ -1,941 +1,19 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
// 与 index.ts 的关系:index.ts 的 runAgent = runAgentCore + TUI hooks 薄封装(行为不变)。
|
|
6
|
-
// spawn.ts 的 spawnAgent = runAgentCore + 静默 hooks(子 agent)。
|
|
7
|
-
import { chat, estimatePromptTokens, estimateTokens, isContextLengthError, planChatTools, chatTools, } from '../llm/index.js';
|
|
8
|
-
import { executeToolOutcome, findTool, isFileMutationTool } from '../tools/registry.js';
|
|
9
|
-
import { checkPermission } from '../permissions/index.js';
|
|
10
|
-
import { validateToolArguments } from '../tools/validation.js';
|
|
11
|
-
import { getPlanDisabledTools, getRuntimeDisabledTools, getSkillRuntimeDisabledTools } from '../tools/constants.js';
|
|
12
|
-
import { ADD_TOOL_GROUPS_TOOL_NAME } from '../config/profiles.js';
|
|
13
|
-
import { defaultAgentRuntimeContext } from './runtime-context.js';
|
|
14
|
-
import { parseArgs, argumentErrorHint, isToolResultsNoise, isParallelTool, isResourceLockedCall, deniedOutcome, readDiffContext, pushToolResult, } from './tool-helpers.js';
|
|
15
|
-
import { maybeCompact, contextState, summarizeToolArguments } from '../session/index.js';
|
|
16
|
-
import { TurnTraceState } from './trace-state.js';
|
|
17
|
-
import { createBudgetScheduler } from '../session/scheduler.js';
|
|
18
|
-
import { invalidateArtifacts, rehydrateArtifacts } from '../context/index.js';
|
|
19
|
-
import { createRelevancePruner } from '../context/relevance.js';
|
|
20
|
-
import { t } from '../i18n/index.js';
|
|
21
|
-
import { createLifecycleEngine } from '../context/lifecycle.js';
|
|
22
|
-
/** nag 提醒阈值:连续 N 个"执行了工具但没更新 notes.md"的步后提醒一次(对齐 Claude Code TodoWrite 的 3 轮)。 */
|
|
23
|
-
const PLAN_NAG_THRESHOLD = 3;
|
|
24
|
-
/** nag 提醒文本:注入到当前步第一条 tool_result 内容前(与最新工具输出同批被模型看到,而非单独一条易被冲淡)。 */
|
|
25
|
-
const PLAN_NAG_TEXT = '[mocode] Reminder: you have an active plan in notes.md but have not updated it recently. ' +
|
|
26
|
-
'If you finished a step, call plan_update to check it off (keep at most one in_progress); ' +
|
|
27
|
-
'if the whole plan is done, let plan_update settle it to ## Done:. If the plan changed scope, update it to match reality.';
|
|
28
|
-
// 工具辅助纯函数(parseArgs / argumentErrorHint / isToolResultsNoise / isParallelTool /
|
|
29
|
-
// isResourceLockedTool / isResourceLockedCall / deniedOutcome / readDiffContext / pushToolResult)
|
|
30
|
-
// 已提取至 ./tool-helpers.ts——它们不依赖本循环的局部状态,只接受显式参数,故可安全模块化。
|
|
31
|
-
/** 文件 mutation 由 capability metadata 判定,供 diff、回滚与上下文失效共用。 */
|
|
32
|
-
const isMutationTool = (name) => isFileMutationTool(name);
|
|
1
|
+
import { defaultToolRuntime } from '../tools/registry.js';
|
|
2
|
+
import { createAgentPipelineAssembly } from './pipeline.js';
|
|
3
|
+
import { runAgentCoreLegacy } from './run-coordinator.js';
|
|
4
|
+
import { defaultAgentRuntimeContext, withAgentRuntimeContext } from './runtime-context.js';
|
|
33
5
|
/**
|
|
34
|
-
*
|
|
35
|
-
* 流式调 LLM(经 hooks.onText 实时渲染)→ 有 tool_calls 就分组执行并回灌
|
|
36
|
-
* → 否则流式正文即最终回复。history 在调用间持久,由调用方持有。
|
|
37
|
-
* 步前由 session scheduler 检查真实 context pressure;达到 80% 时统一清理并压缩历史。
|
|
38
|
-
* 工具结果正常只经 capToolResultForHistory 的单条 hard safety cap。
|
|
39
|
-
*
|
|
40
|
-
* 中断语义:signal 经 executeTool(name, args, signal) 串进工具;run_command/web_fetch 等 abort 即时杀
|
|
41
|
-
* (树杀子进程 / 取消 fetch),循环顶 if(signal.aborted) 兜底还原。不会留下未配对的 tool_call_id。
|
|
42
|
-
* abort 时 history 还原到本 turn 前(savedHistory 浅拷贝),模式还原,调 hooks.onAbort。
|
|
43
|
-
*
|
|
44
|
-
* 所有展示副作用经 hooks 注入;core 自身不直接调 layout / spinner(不依赖 ui/layout.ts)。
|
|
45
|
-
* 但 core 仍依赖 ui/render.ts 的纯函数(summarizeToolCall / truncateDisplay / fmtElapsed)——
|
|
46
|
-
* 这些是纯字符串格式化,无副作用,共享安全。
|
|
6
|
+
* Public entry point. Per-run assembly selects stage implementations while preserving a single coordinator execution.
|
|
47
7
|
*/
|
|
48
8
|
export async function runAgentCore(opts) {
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const maxSteps = opts.maxSteps ?? ctx.config.maxSteps;
|
|
55
|
-
// 中断还原:repl 的 /plan / /auto / Shift+Tab 等用户面触发 setAgentMode 中途切了模式,
|
|
56
|
-
// abort 时连同模式一起还原回轮首。模型不再持有 switch_mode 工具,无法自切。
|
|
57
|
-
const savedMode = ctx.getAgentMode();
|
|
58
|
-
// 本轮计时:从入口到完毕(正常 return / 达上限),供 finally 打 ✻ Worked for 摘要行。
|
|
59
|
-
const t0 = Date.now();
|
|
60
|
-
// trace / token-usage 状态聚合(2.0 步骤2 深拆第一刀):emit/addUsage/turnUsage 收敛进
|
|
61
|
-
// TurnTraceState,事件 payload 与 hooks 序列保持字节级不变。
|
|
62
|
-
const traceState = new TurnTraceState({
|
|
63
|
-
sessionId: opts.traceContext?.sessionId ?? ctx.getCurrentSessionId() ?? `ephemeral-${process.pid}`,
|
|
64
|
-
turnId: opts.traceContext?.turnId ?? ctx.getCurrentTurnId(),
|
|
65
|
-
onTraceEvent: opts.onTraceEvent,
|
|
9
|
+
const runtimeContext = opts.runtimeContext ?? defaultAgentRuntimeContext;
|
|
10
|
+
const assembly = createAgentPipelineAssembly({
|
|
11
|
+
pipeline: opts.pipeline,
|
|
12
|
+
stageOverrides: opts.stageOverrides,
|
|
13
|
+
runLegacy: runAgentCoreLegacy,
|
|
66
14
|
});
|
|
67
|
-
|
|
68
|
-
const traceTurnId = traceState.turnId;
|
|
69
|
-
const emitTrace = (type, data = {}, ids = {}) => traceState.emit(type, data, ids);
|
|
70
|
-
emitTrace('turn_start', { mode: ctx.getAgentMode() });
|
|
71
|
-
if (opts.initialToolRoute)
|
|
72
|
-
emitTrace('tool_route', opts.initialToolRoute);
|
|
73
|
-
let done = false; // 正常完毕 / 达上限 true;中断 false(不显摘要)
|
|
74
|
-
let traceStatus = 'error';
|
|
75
|
-
// A+B(plan 可靠性):跨步计数"执行了工具但没改动 notes.md"的连续步数。
|
|
76
|
-
// 本步写了 notes.md(plan_update 或直接 write/edit)→ 清零并重同步 history[0];
|
|
77
|
-
// 否则累计,达阈值则在当前步 tool_result 前注入 nag 提醒。
|
|
78
|
-
let stepsSincePlanTouch = 0;
|
|
79
|
-
// 本轮 token 累计在 traceState.turnUsage(每步 chat() 返回后 addUsage),供 onDone 摘要行 + AgentRunResult.usage
|
|
80
|
-
// 透传给 repl(显示在底栏模式 chip 右边)。未开启 include_usage 或全失败时为 undefined。
|
|
81
|
-
// 实时 chip ↻ 估算用:上一步 chat 实测 prompt(前缀缓存下当前步命中 ≈ 它)+ 后端是否报过 cache 命中
|
|
82
|
-
// (从不报 cache 的后端不估算,避免虚显 ↻)。
|
|
83
|
-
let lastStepPromptTokens = 0;
|
|
84
|
-
let providerCacheSeen = false;
|
|
85
|
-
history.push({ role: 'user', content: userInput });
|
|
86
|
-
// 中断回滚快照:push 用户消息后整段浅拷贝。abort 时 length=0;push(...saved) 还原。
|
|
87
|
-
// 这样中断时至少保留用户消息(及之前的历史);每步工具全部执行完毕后刷新快照,
|
|
88
|
-
// 保留已完成的 assistant+tool_calls+tool 结果,只丢弃当前未完成步骤的消息。
|
|
89
|
-
// 用 slice() 而非 length:maybeCompact 会原地重建(length=0;push(...rebuilt)),savedLen 会失效。
|
|
90
|
-
let savedHistory = history.slice();
|
|
91
|
-
// Relevance and lifecycle collect provenance during normal work. Neither path
|
|
92
|
-
// rewrites history; exact supersession is applied only by the pressure scheduler.
|
|
93
|
-
const relprune = ctx.config.contextRelprune ? createRelevancePruner() : null;
|
|
94
|
-
let lifecycle = ctx.config.contextLifecycle ? createLifecycleEngine(history) : null;
|
|
95
|
-
runtimeContextState.lifecycleStats = lifecycle?.stats();
|
|
96
|
-
rehydrateArtifacts(runtimeContextState, history);
|
|
97
|
-
// The scheduler is the sole automatic history-rewrite entry point. It runs
|
|
98
|
-
// superseded → stale artifact → old logs/search → compact at real pressure.
|
|
99
|
-
// contextBudget=false keeps only the infrastructure compact fallback.
|
|
100
|
-
const scheduler = ctx.config.contextBudget !== false ? createBudgetScheduler(runtimeContextState) : null;
|
|
101
|
-
// 本轮流式状态:首个正文 token 到达即停 spinner(思考期间 spinner 持续转「思考中…」,不写思考内容)。
|
|
102
|
-
let mode = 'idle';
|
|
103
|
-
let gotText = false;
|
|
104
|
-
let lastChar = '';
|
|
105
|
-
const onText = (s) => {
|
|
106
|
-
hooks.onText?.(s); // 主 agent:走 markdown 渲染写内容区
|
|
107
|
-
mode = 'text';
|
|
108
|
-
gotText = true;
|
|
109
|
-
if (s)
|
|
110
|
-
lastChar = s[s.length - 1];
|
|
111
|
-
};
|
|
112
|
-
const onToolCall = (name) => {
|
|
113
|
-
// 文本/思考已流完,模型转而生成 tool_call 参数(可能很长,如 write_file 整篇内容):
|
|
114
|
-
// 补换行(让随后的 ● 行与 diff 不黏在正文末尾)+ 启「生成中」内联 spinner,内容区不再干等。
|
|
115
|
-
if (lastChar && lastChar !== '\n') {
|
|
116
|
-
hooks.onTextEnd?.(); // 主 agent:layout.contentWrite('\n')
|
|
117
|
-
lastChar = '\n';
|
|
118
|
-
}
|
|
119
|
-
hooks.onToolCall?.(name); // 主 agent:spinner.start(`生成 ${name}…`)
|
|
120
|
-
};
|
|
121
|
-
// 中断还原:停 spinner + 补换行 + (已中断)提示 + history 还原到本 turn 前 + 模式还原。
|
|
122
|
-
// 两处共用:① await chat() 抛 AbortError 的 catch;② 工具被 abort 杀后循环顶检查。
|
|
123
|
-
// 实现已收敛进 TurnTraceState.abortRestore;savedHistory/savedMode 是函数级 let,此处闭包现读。
|
|
124
|
-
const abortRestore = () => traceState.abortRestore({ hooks, history, savedHistory, ctx, savedMode });
|
|
125
|
-
try {
|
|
126
|
-
for (let step = 0; step < maxSteps; step++) {
|
|
127
|
-
traceState.currentTraceStep = step;
|
|
128
|
-
const stepStartedAt = Date.now();
|
|
129
|
-
emitTrace('step_start', { ordinal: step });
|
|
130
|
-
try {
|
|
131
|
-
// 上一步工具被 abort 杀(run_command/web_fetch 等)→ signal.aborted,直接还原退出,不等 maybeCompact + chat()
|
|
132
|
-
if (signal?.aborted) {
|
|
133
|
-
abortRestore();
|
|
134
|
-
traceStatus = 'aborted';
|
|
135
|
-
return traceState.buildAbortedResult(ctx.getCurrentTurnMutationState());
|
|
136
|
-
}
|
|
137
|
-
// 本步只捕获一次不可变 policy snapshot。即便 add_tool_groups 在执行阶段扩容,
|
|
138
|
-
// 本次模型响应仍必须按旧 snapshot 校验;新工具只在下一 step 的 schema 中出现。
|
|
139
|
-
const planMode = ctx.getAgentMode() === 'plan';
|
|
140
|
-
const policySnapshot = opts.toolPolicy?.snapshot(planMode);
|
|
141
|
-
const configuredTools = opts.toolsOverride ?? policySnapshot?.tools ?? (planMode ? planChatTools : chatTools);
|
|
142
|
-
const policyAllowedTools = opts.runtimeAllowedToolNames
|
|
143
|
-
? configuredTools.filter((tool) => opts.runtimeAllowedToolNames?.has(tool.function.name))
|
|
144
|
-
: configuredTools;
|
|
145
|
-
const skillDisabledTools = getSkillRuntimeDisabledTools();
|
|
146
|
-
const legacyDisabledTools = opts.toolPolicy || opts.runtimeAllowedToolNames ? new Set() : getRuntimeDisabledTools();
|
|
147
|
-
// schema、runtime backstop 与后代权限都从同一 effective allow-list 派生。
|
|
148
|
-
// policy snapshot 是本 step 的不可扩张上限;skill deny 可在同批 use_skill 后继续动态收窄。
|
|
149
|
-
const activeTools = policyAllowedTools.filter((tool) => !skillDisabledTools.has(tool.function.name) && !legacyDisabledTools.has(tool.function.name));
|
|
150
|
-
const stepAllowedNames = new Set(activeTools.map((tool) => tool.function.name));
|
|
151
|
-
const currentAllowedToolNames = () => {
|
|
152
|
-
const currentSkillDisabledTools = getSkillRuntimeDisabledTools();
|
|
153
|
-
return [...stepAllowedNames].filter((name) => !currentSkillDisabledTools.has(name));
|
|
154
|
-
};
|
|
155
|
-
const isToolDeniedForStep = (name) => !stepAllowedNames.has(name) || getSkillRuntimeDisabledTools().has(name);
|
|
156
|
-
// 委派给编排工具(sub-agent/run_skill)的父前缀快照:去掉历史末尾「产生本次调用的
|
|
157
|
-
// assistant tool_call 消息」(协议上它必须紧跟 tool_result,不能出现在子 history),
|
|
158
|
-
// 只保留其前的主前缀。子 agent 以它为前缀、尾部追加委派消息 → 与主 agent 已发送
|
|
159
|
-
// 前缀逐字节一致,命中前缀缓存。tools 直接用本步 activeTools:子 agent 与主 agent
|
|
160
|
-
// 同权同 schema,不做任何裁剪,也没有额外的执行层禁用集合。
|
|
161
|
-
const delegationForOrchestrator = () => {
|
|
162
|
-
let k = history.length - 1;
|
|
163
|
-
while (k > 0) {
|
|
164
|
-
const m = history[k];
|
|
165
|
-
if (m.role === 'assistant' && Array.isArray(m.tool_calls))
|
|
166
|
-
break;
|
|
167
|
-
k--;
|
|
168
|
-
}
|
|
169
|
-
return { history: history.slice(0, k > 0 ? k : history.length), tools: activeTools };
|
|
170
|
-
};
|
|
171
|
-
const requestBaseURL = ctx.config.baseURL;
|
|
172
|
-
const requestModel = ctx.getActiveModel();
|
|
173
|
-
const storedCalibration = ctx.getTokenCalibration(requestBaseURL, requestModel, activeTools);
|
|
174
|
-
runtimeContextState.correction = storedCalibration.correction;
|
|
175
|
-
runtimeContextState.calibrationSamples = storedCalibration.samples;
|
|
176
|
-
// 会话状态(活跃 plan + 笔记正文)在调度器**之前**取一次:
|
|
177
|
-
// ① 它会被追加到本次请求末尾(见下方 ephemeralReminder),属于本步固定开销,
|
|
178
|
-
// 必须计入压力线——它不在 history 里,调度器只能由此入参看见(否则最多 5k
|
|
179
|
-
// 的笔记 + plan 段对 80% 触发线完全不可见,小窗口模型会压不住);
|
|
180
|
-
// ② 压缩步在压缩成功后重取(P2 固结的 Compaction Snapshot 当步即可见)。
|
|
181
|
-
let sessionStateText = opts.suppressSessionState ? '' : ctx.buildSessionStateReminder();
|
|
182
|
-
// The scheduler is the only automatic path that may compress old evidence.
|
|
183
|
-
// Normal tool pushes and lifecycle tracking remain metadata-only.
|
|
184
|
-
// 压缩**之前**先刷一次状态栏:bar 要显示「本步真实 prompt 撞线」那一刻。
|
|
185
|
-
// 触发器算的就是 history + 工具 schema + 本段 sessionStateText,此刻三者都已就位,
|
|
186
|
-
// bar 与触发器完全同口径同一步。若等压缩跑完再刷,那 30+ 秒的 LLM 摘要调用里
|
|
187
|
-
// bar 一直冻在上一步的值,用户只看到「75% 怎么就压了」。
|
|
188
|
-
runtimeContextState.ephemeralText = sessionStateText || undefined;
|
|
189
|
-
onContextUpdate?.();
|
|
190
|
-
// 步前:五区 Budget Scheduler 在当前完整 history 上决策;开关关闭时退化回 maybeCompact 路径。
|
|
191
|
-
// 此时 spinner 已停,通知行干净。
|
|
192
|
-
let historyRebuilt = false;
|
|
193
|
-
/** 本步是否已因「后端报上下文超长」压过一轮(限一次,防压缩↔重试死循环)。 */
|
|
194
|
-
let overflowRetried = false;
|
|
195
|
-
const compactStartedAt = Date.now();
|
|
196
|
-
if (scheduler) {
|
|
197
|
-
historyRebuilt = await scheduler.runStep(history, step, activeTools, sessionStateText ? estimateTokens(sessionStateText) : 0,
|
|
198
|
-
// signal 透传:压缩的 LLM 摘要是几十秒的调用,不串进来 Ctrl+C 掐不断。
|
|
199
|
-
signal);
|
|
200
|
-
if (scheduler.lastRunLog?.compactHistoryCalled) {
|
|
201
|
-
emitTrace('compact', {
|
|
202
|
-
source: 'automatic',
|
|
203
|
-
reason: 'scheduled',
|
|
204
|
-
historyRebuilt,
|
|
205
|
-
durationMs: Date.now() - compactStartedAt,
|
|
206
|
-
});
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
else {
|
|
210
|
-
const compactResult = await maybeCompact(history, undefined, undefined, runtimeContextState, activeTools, signal);
|
|
211
|
-
historyRebuilt = compactResult?.historyRebuilt === true;
|
|
212
|
-
if (compactResult) {
|
|
213
|
-
emitTrace('compact', {
|
|
214
|
-
source: 'automatic_fallback',
|
|
215
|
-
reason: compactResult.reason,
|
|
216
|
-
compacted: compactResult.compacted,
|
|
217
|
-
historyRebuilt,
|
|
218
|
-
estimateBefore: compactResult.estimateBefore,
|
|
219
|
-
estimateAfter: compactResult.estimateAfter,
|
|
220
|
-
durationMs: Date.now() - compactStartedAt,
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
// compact 用新消息数组原地重建 history 后,所有按消息位置恢复的状态都需重建。
|
|
225
|
-
if (historyRebuilt) {
|
|
226
|
-
if (lifecycle) {
|
|
227
|
-
lifecycle = createLifecycleEngine(history);
|
|
228
|
-
runtimeContextState.lifecycleStats = lifecycle.stats();
|
|
229
|
-
}
|
|
230
|
-
rehydrateArtifacts(runtimeContextState, history);
|
|
231
|
-
// 压缩可能刚把进度快照固结进 notes.md(P2)→ 重取,让本步 requestHistory
|
|
232
|
-
// 末尾就带上最新 Compaction Snapshot,不必等下一步。bar 口径对应的
|
|
233
|
-
// ephemeralText 仍用触发时旧值(见 buildRequestHistory 注释),仅差这一段。
|
|
234
|
-
if (!opts.suppressSessionState)
|
|
235
|
-
sessionStateText = ctx.buildSessionStateReminder();
|
|
236
|
-
// 会话状态(活跃 plan + 笔记段)不再回写 history[0]:每步都会在 requestHistory
|
|
237
|
-
// 末尾注入最新副本(见下方 ephemeralReminder),compact 后自然恢复。
|
|
238
|
-
}
|
|
239
|
-
hooks.onStepStart?.(); // 主 agent:spinner.start('思考中')
|
|
240
|
-
mode = 'idle';
|
|
241
|
-
gotText = false;
|
|
242
|
-
lastChar = '';
|
|
243
|
-
let result;
|
|
244
|
-
const modelStartedAt = Date.now();
|
|
245
|
-
const provider = ctx.safeProviderId(requestBaseURL);
|
|
246
|
-
emitTrace('model_start', { model: requestModel, provider });
|
|
247
|
-
// 动态注入(prompt 缓存关键):所有随步/随文件变化的提示统一拼成**历史末尾**一条
|
|
248
|
-
// ephemeral system 消息,不再改写 history[0]。这样系统提示 + 已有对话逐字节稳定,
|
|
249
|
-
// 支持自动前缀缓存的后端(OpenAI / DeepSeek / GLM / Qwen)可从头命中,只有尾部这
|
|
250
|
-
// 一小条随内容变化;若改写 history[0],单次 plan_update 就会让 6-8k 的系统提示
|
|
251
|
-
// 在本轮后续每步全价重算。
|
|
252
|
-
// - 开场分析:仅主线(step===0 且 !suppressOpeningAnalysis)——用户发任务后 agent
|
|
253
|
-
// 的第一次模型调用。子代理经 spawn.ts 传 suppressOpeningAnalysis:true 排除。
|
|
254
|
-
// 放在尾部还有额外好处:step 0 与 step 1 的前缀不再因这段的出现/消失而错位。
|
|
255
|
-
// - historyRebuilt:compact 恢复步,要求重新锚定目标。
|
|
256
|
-
// - 会话状态:notes.md 的活跃 plan + 笔记正文(纯读,每步重取,始终最新)。
|
|
257
|
-
// .filter(Boolean) 保证空段不产生多余空行;三段全空时不追加任何消息(requestHistory === history)。
|
|
258
|
-
// 安全保证:只拼进 requestHistory(新建数组),绝不写回 history,故不会跨 step/跨 turn 残留。
|
|
259
|
-
// 抽成函数:后端报上下文超长时压缩后要按新 history 重建一次(见下方 catch)。
|
|
260
|
-
// 不写 contextState.ephemeralText:bar 用的是触发器同口径的 sessionStateText
|
|
261
|
-
// (步骤顶部已设),而本函数还会额外拼 opening / post-compact 段——那两段触发器
|
|
262
|
-
// 不计,让 bar 用会让两条线再错开几百 token。
|
|
263
|
-
const buildRequestHistory = () => {
|
|
264
|
-
const ephemeralReminder = [
|
|
265
|
-
opts.toolPolicy?.reminder(planMode) ?? '',
|
|
266
|
-
!opts.suppressOpeningAnalysis && step === 0
|
|
267
|
-
? '## Opening analysis\nBegin your FIRST response of this turn with a brief analysis of the request and your planned approach (1-3 sentences, no filler), THEN start tool calls. This opening is the only place where pre-tool prose is expected; after it, work quietly with no narration between tool calls.'
|
|
268
|
-
: '',
|
|
269
|
-
historyRebuilt
|
|
270
|
-
? '## Post-compaction recovery\n' +
|
|
271
|
-
'Context was compacted before this request. Recover before doing anything else, in this order:\n' +
|
|
272
|
-
'1. Read the session summary at the top of the history: `## Completed` is already done — do not redo or re-verify it. `## In Progress` / `## Next Steps` tell you exactly where work stopped and what is next.\n' +
|
|
273
|
-
'2. Read `## Session state` below (from notes.md, refreshed every step): the active plan is authoritative — `[x]` steps are finished, resume from the first `[ ]`. A `## Compaction Snapshot` section there is the progress checkpoint written at this compaction.\n' +
|
|
274
|
-
(sessionStateText
|
|
275
|
-
? ''
|
|
276
|
-
: '(No active plan or snapshot was found in notes.md — reconstruct what is done purely from the summary and treat its `## Completed` as ground truth.)\n') +
|
|
277
|
-
'3. Before any file edit, read_file the target fresh to get the current content hash — never edit from memory of pre-compaction content.\n' +
|
|
278
|
-
'4. Before re-running a search/read you think you already did, check the summary and notes first: only repeat it if the result is genuinely missing or the target has changed.'
|
|
279
|
-
: '',
|
|
280
|
-
sessionStateText, // 调度器之前已取(并计入压力线),此处复用同一份,不重复读文件
|
|
281
|
-
]
|
|
282
|
-
.filter(Boolean)
|
|
283
|
-
.join('\n\n');
|
|
284
|
-
return ephemeralReminder
|
|
285
|
-
? [...history, { role: 'system', content: ephemeralReminder }]
|
|
286
|
-
: history;
|
|
287
|
-
};
|
|
288
|
-
let requestHistory = buildRequestHistory();
|
|
289
|
-
// 再刷一次:压缩刚跑完(history 已重建),bar 从「撞线 82%」跳到「压后 62%」,
|
|
290
|
-
// 让用户看到压缩确实起了作用。撞线那一刻的刷新在步骤顶部(trigger 之前),
|
|
291
|
-
// 那一次才是解释「为什么要压」的。
|
|
292
|
-
onContextUpdate?.();
|
|
293
|
-
// 实时用量:当前步 prompt 估算(含校准系数)+ 流式累计 completion 估算,
|
|
294
|
-
// 叠上已完成步的实测 turnUsage(traceState 内累加,每次调用读最新值),经 onLiveUsage 推给底栏实时 chip。
|
|
295
|
-
let stepPromptEst = estimatePromptTokens(requestHistory, activeTools, runtimeContextState.correction);
|
|
296
|
-
const reportLive = (p) => traceState.reportLive(hooks, stepPromptEst, lastStepPromptTokens, providerCacheSeen, p);
|
|
297
|
-
reportLive({ completionTokens: 0 }); // 思考阶段先显 ↑ prompt 估算,首 token 到达后 ↓ 开始涨
|
|
298
|
-
// 单一 chat 入口:错误侧的 model_end 埋点只写一处(重试也会记,不丢失败轨迹)。
|
|
299
|
-
const chatHandlers = {
|
|
300
|
-
onText,
|
|
301
|
-
onToolCall,
|
|
302
|
-
onProgress: reportLive,
|
|
303
|
-
onRetry: (retry) => emitTrace('model_retry', {
|
|
304
|
-
model: requestModel,
|
|
305
|
-
provider,
|
|
306
|
-
attempt: retry.attempt,
|
|
307
|
-
nextAttempt: retry.nextAttempt,
|
|
308
|
-
waitMs: retry.waitMs,
|
|
309
|
-
code: retry.code,
|
|
310
|
-
}),
|
|
311
|
-
};
|
|
312
|
-
const runChatOnce = async () => {
|
|
313
|
-
try {
|
|
314
|
-
return await chat(requestHistory, chatHandlers, signal, activeTools);
|
|
315
|
-
}
|
|
316
|
-
catch (err) {
|
|
317
|
-
const errorValue = err && typeof err === 'object' ? err : undefined;
|
|
318
|
-
emitTrace('model_end', {
|
|
319
|
-
model: requestModel,
|
|
320
|
-
provider,
|
|
321
|
-
status: signal?.aborted ? 'aborted' : 'error',
|
|
322
|
-
code: typeof errorValue?.status === 'number'
|
|
323
|
-
? `HTTP_${errorValue.status}`
|
|
324
|
-
: (errorValue?.code ?? errorValue?.name ?? 'MODEL_ERROR'),
|
|
325
|
-
durationMs: Date.now() - modelStartedAt,
|
|
326
|
-
});
|
|
327
|
-
throw err;
|
|
328
|
-
}
|
|
329
|
-
};
|
|
330
|
-
try {
|
|
331
|
-
result = await runChatOnce();
|
|
332
|
-
}
|
|
333
|
-
catch (e) {
|
|
334
|
-
// 中断(用户运行中 Ctrl+C):chat() 抛 AbortError(signal.aborted)→ 还原 history + 模式 + return(不抛)。
|
|
335
|
-
// 工具执行现已串 signal:run_command/web_fetch 被 abort 即时杀,循环顶检查兜底(不会留未配对 tool_call_id)。
|
|
336
|
-
if (signal?.aborted || (e instanceof Error && (e.name === 'AbortError' || e.name === 'APIUserAbortError'))) {
|
|
337
|
-
abortRestore();
|
|
338
|
-
traceStatus = 'aborted';
|
|
339
|
-
return traceState.buildAbortedResult(ctx.getCurrentTurnMutationState());
|
|
340
|
-
}
|
|
341
|
-
// 后端实测拒绝了 prompt(上下文超长):本地估算对该 provider 系统性偏低时,
|
|
342
|
-
// 压力线压不住,这是唯一可信的触发。强压一轮后重试一次;仍失败才抛(限一次,防循环)。
|
|
343
|
-
if (!overflowRetried && isContextLengthError(e)) {
|
|
344
|
-
overflowRetried = true;
|
|
345
|
-
// 估算被后端证伪:raw×correction 明明在压力线以下,真实 prompt 却超了窗。
|
|
346
|
-
// 用「实测 = 窗口」这个下限样本喂校准,让压力线对这家 provider 立刻变严——
|
|
347
|
-
// 否则每一步都要等后端报错才压,而不是提前压。EWMA α=0.2 + [0.5,2] 夹逼,
|
|
348
|
-
// 偶发误判会被后续真实 usage 样本拉回。
|
|
349
|
-
const rawEstimate = estimatePromptTokens(requestHistory, activeTools);
|
|
350
|
-
if (rawEstimate > 1_000) {
|
|
351
|
-
const cal = ctx.updateTokenCalibration(requestBaseURL, requestModel, activeTools, rawEstimate, ctx.config.contextWindowTokens);
|
|
352
|
-
runtimeContextState.correction = cal.correction;
|
|
353
|
-
runtimeContextState.calibrationSamples = cal.samples;
|
|
354
|
-
}
|
|
355
|
-
const overflowResult = await maybeCompact(history, undefined, { manual: true, force: true }, runtimeContextState, activeTools, signal);
|
|
356
|
-
emitTrace('compact', {
|
|
357
|
-
source: 'overflow_retry',
|
|
358
|
-
reason: overflowResult?.reason ?? 'noop',
|
|
359
|
-
compacted: overflowResult?.compacted === true,
|
|
360
|
-
estimateBefore: overflowResult?.estimateBefore,
|
|
361
|
-
estimateAfter: overflowResult?.estimateAfter,
|
|
362
|
-
durationMs: Date.now() - modelStartedAt,
|
|
363
|
-
});
|
|
364
|
-
if (!overflowResult?.compacted)
|
|
365
|
-
throw e; // 压不动:没法救,原样抛
|
|
366
|
-
if (overflowResult.historyRebuilt) {
|
|
367
|
-
historyRebuilt = true;
|
|
368
|
-
if (lifecycle) {
|
|
369
|
-
lifecycle = createLifecycleEngine(history);
|
|
370
|
-
runtimeContextState.lifecycleStats = lifecycle.stats();
|
|
371
|
-
}
|
|
372
|
-
rehydrateArtifacts(runtimeContextState, history);
|
|
373
|
-
}
|
|
374
|
-
requestHistory = buildRequestHistory();
|
|
375
|
-
stepPromptEst = estimatePromptTokens(requestHistory, activeTools, runtimeContextState.correction);
|
|
376
|
-
result = await runChatOnce(); // 仍超长 → 抛出,交给上层报错
|
|
377
|
-
}
|
|
378
|
-
else {
|
|
379
|
-
throw e;
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
emitTrace('model_end', {
|
|
383
|
-
model: requestModel,
|
|
384
|
-
provider,
|
|
385
|
-
status: 'success',
|
|
386
|
-
durationMs: Date.now() - modelStartedAt,
|
|
387
|
-
promptTokens: result.usage?.promptTokens,
|
|
388
|
-
completionTokens: result.usage?.completionTokens,
|
|
389
|
-
totalTokens: result.usage?.totalTokens,
|
|
390
|
-
cachedTokens: result.usage?.cachedTokens,
|
|
391
|
-
reasoningTokens: result.usage?.reasoningTokens,
|
|
392
|
-
});
|
|
393
|
-
runtimeContextState.lastUsage = result.usage; // 供 /context 与状态行显示实测 token
|
|
394
|
-
traceState.addUsage(result.usage); // 本轮累计:onDone 摘要行 + AgentRunResult.usage 透传
|
|
395
|
-
if (result.usage) {
|
|
396
|
-
lastStepPromptTokens = result.usage.promptTokens; // 下一步流式期 ↻ 估算的前缀基准
|
|
397
|
-
if (result.usage.cachedTokens > 0)
|
|
398
|
-
providerCacheSeen = true;
|
|
399
|
-
}
|
|
400
|
-
// 用本次实际发送的 tools 计算分母,再以 EWMA 更新 provider/model/tool-set 校准。
|
|
401
|
-
// 只持久化比例与样本数;无 usage 或短 prompt 时保持既有值。
|
|
402
|
-
if (result.usage?.promptTokens && result.usage.promptTokens > 100) {
|
|
403
|
-
const estimated = estimatePromptTokens(requestHistory, activeTools);
|
|
404
|
-
const updated = ctx.updateTokenCalibration(requestBaseURL, requestModel, activeTools, estimated, result.usage.promptTokens);
|
|
405
|
-
runtimeContextState.correction = updated.correction;
|
|
406
|
-
runtimeContextState.calibrationSamples = updated.samples;
|
|
407
|
-
}
|
|
408
|
-
hooks.onChatDone?.(); // 主 agent:spinner.stop()
|
|
409
|
-
// lastUsage 已更新:触发状态行 context 用量条重算+重画,运行中不再冻结在轮首。
|
|
410
|
-
onContextUpdate?.();
|
|
411
|
-
if (result.toolCalls.length > 0) {
|
|
412
|
-
traceState.toolCallCount += result.toolCalls.length;
|
|
413
|
-
// 若 content 只是 Claude 式 "Tool results:" 噪声,清空它:不补换行、不写入 history,
|
|
414
|
-
// 避免污染后续轮次上下文并在 TUI 泄露为孤立行。
|
|
415
|
-
if (result.content && isToolResultsNoise(result.content)) {
|
|
416
|
-
result.content = null;
|
|
417
|
-
mode = 'idle';
|
|
418
|
-
gotText = false;
|
|
419
|
-
lastChar = '';
|
|
420
|
-
}
|
|
421
|
-
// 流式正文末尾补换行(若 onToolCall 已补则 lastChar='\n',此处 no-op);防 ● 行黏在正文行尾
|
|
422
|
-
if (mode !== 'idle' && lastChar !== '\n')
|
|
423
|
-
hooks.onTextEnd?.();
|
|
424
|
-
// 带工具调用的 assistant 消息原样回灌(OpenAI 格式要求)
|
|
425
|
-
history.push({
|
|
426
|
-
role: 'assistant',
|
|
427
|
-
content: result.content,
|
|
428
|
-
tool_calls: result.toolCalls.map((tc) => ({
|
|
429
|
-
id: tc.id,
|
|
430
|
-
type: 'function',
|
|
431
|
-
function: { name: tc.name, arguments: tc.arguments },
|
|
432
|
-
})),
|
|
433
|
-
});
|
|
434
|
-
// A+B:记录本步第一条 tool_result 的下标 + 执行前 notes.md 的 mtime,
|
|
435
|
-
// 工具全部执行完后据此判断"本步是否改动了 notes.md"(重同步 / nag)。
|
|
436
|
-
const toolResultStartIdx = history.length;
|
|
437
|
-
const notesMtimeBefore = ctx.getNotesMtime();
|
|
438
|
-
// Record interstitial narration for observability only. It never changes tool output
|
|
439
|
-
// or injects instructions back into the model context.
|
|
440
|
-
const narration = result.content?.trim() ?? '';
|
|
441
|
-
if (narration) {
|
|
442
|
-
emitTrace('narration', {
|
|
443
|
-
chars: [...narration].length,
|
|
444
|
-
toolCalls: result.toolCalls.length,
|
|
445
|
-
step,
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
|
-
// 工具分组执行(保 tool_calls 原顺序):safe parallel 工具照常并发;连续
|
|
449
|
-
// resource-locked mutation 先按序完成权限预检,再按 canonical resource lock 启动。
|
|
450
|
-
// registry 对所有真实资源访问统一持锁,所以不同 Agent 间的 read/write/process 也不会竞态。
|
|
451
|
-
// 串行工具仍是本调用列表内的屏障;渲染/history 回灌始终按原 tool_calls 顺序。
|
|
452
|
-
// executeToolOutcome 永不抛错,失败通过结构化 status/code 返回。
|
|
453
|
-
const calls = result.toolCalls;
|
|
454
|
-
const modelAttachments = [];
|
|
455
|
-
const tracedCalls = calls.map((tc, index) => ({
|
|
456
|
-
toolCallId: `${traceTurnId}:step:${step}:tool:${index}`,
|
|
457
|
-
args: summarizeToolArguments(tc.arguments),
|
|
458
|
-
}));
|
|
459
|
-
for (let index = 0; index < calls.length; index++) {
|
|
460
|
-
const tc = calls[index];
|
|
461
|
-
const traceCall = tracedCalls[index];
|
|
462
|
-
emitTrace('tool_call_start', {
|
|
463
|
-
tool: tc.name,
|
|
464
|
-
argumentHash: traceCall.args.sha256,
|
|
465
|
-
arguments: traceCall.args,
|
|
466
|
-
}, {
|
|
467
|
-
toolCallId: traceCall.toolCallId,
|
|
468
|
-
...(tc.id ? { providerToolCallId: tc.id } : {}),
|
|
469
|
-
});
|
|
470
|
-
}
|
|
471
|
-
const traceToolEnd = (tc, index, outcome) => {
|
|
472
|
-
if (outcome.status === 'success' && outcome.modelAttachments?.length) {
|
|
473
|
-
modelAttachments.push(...outcome.modelAttachments);
|
|
474
|
-
}
|
|
475
|
-
const traceCall = tracedCalls[index];
|
|
476
|
-
emitTrace('tool_call_end', {
|
|
477
|
-
tool: tc.name,
|
|
478
|
-
argumentHash: traceCall.args.sha256,
|
|
479
|
-
status: outcome.status,
|
|
480
|
-
code: outcome.code,
|
|
481
|
-
retryable: outcome.retryable,
|
|
482
|
-
durationMs: outcome.durationMs ?? 0,
|
|
483
|
-
changedFiles: outcome.changedFiles ?? [],
|
|
484
|
-
staleFiles: outcome.staleFiles ?? [],
|
|
485
|
-
...(outcome.changeSet ? { changeSet: outcome.changeSet } : {}),
|
|
486
|
-
...(outcome.usage ? { nestedUsage: outcome.usage } : {}),
|
|
487
|
-
}, {
|
|
488
|
-
toolCallId: traceCall.toolCallId,
|
|
489
|
-
...(tc.id ? { providerToolCallId: tc.id } : {}),
|
|
490
|
-
});
|
|
491
|
-
};
|
|
492
|
-
const hasToolRouteBarrier = calls.some((tc) => tc.name === ADD_TOOL_GROUPS_TOOL_NAME);
|
|
493
|
-
if (hasToolRouteBarrier) {
|
|
494
|
-
const mixedCall = calls.length !== 1;
|
|
495
|
-
for (let index = 0; index < calls.length; index++) {
|
|
496
|
-
const tc = calls[index];
|
|
497
|
-
hooks.onToolHeader?.(tc);
|
|
498
|
-
const parsed = parseArgs(tc.arguments);
|
|
499
|
-
let outcome;
|
|
500
|
-
if (mixedCall) {
|
|
501
|
-
const isControl = tc.name === ADD_TOOL_GROUPS_TOOL_NAME;
|
|
502
|
-
outcome = {
|
|
503
|
-
status: 'denied',
|
|
504
|
-
code: isControl ? 'INVALID_ARGUMENTS' : 'TOOL_DISABLED',
|
|
505
|
-
retryable: false,
|
|
506
|
-
output: isControl
|
|
507
|
-
? '错误:add_tool_groups 必须在一个独立的 model step 中单独调用;本次没有扩容。'
|
|
508
|
-
: `错误:同一响应包含 add_tool_groups,工具 ${tc.name} 未执行。请等待扩容结果后在下一 step 重试。`,
|
|
509
|
-
changedFiles: [],
|
|
510
|
-
durationMs: 0,
|
|
511
|
-
};
|
|
512
|
-
}
|
|
513
|
-
else if (isToolDeniedForStep(tc.name)) {
|
|
514
|
-
outcome = {
|
|
515
|
-
status: 'denied',
|
|
516
|
-
code: 'TOOL_DISABLED',
|
|
517
|
-
retryable: false,
|
|
518
|
-
output: `错误:当前 tool policy snapshot 不允许调用 ${tc.name}。`,
|
|
519
|
-
changedFiles: [],
|
|
520
|
-
durationMs: 0,
|
|
521
|
-
};
|
|
522
|
-
}
|
|
523
|
-
else if (!opts.toolPolicy) {
|
|
524
|
-
outcome = {
|
|
525
|
-
status: 'denied',
|
|
526
|
-
code: 'TOOL_DISABLED',
|
|
527
|
-
retryable: false,
|
|
528
|
-
output: '错误:当前 Agent 未启用动态工具策略,无法调用 add_tool_groups。',
|
|
529
|
-
changedFiles: [],
|
|
530
|
-
durationMs: 0,
|
|
531
|
-
};
|
|
532
|
-
}
|
|
533
|
-
else if (!parsed ||
|
|
534
|
-
!Array.isArray(parsed.groups) ||
|
|
535
|
-
parsed.groups.length === 0 ||
|
|
536
|
-
typeof parsed.reason !== 'string' ||
|
|
537
|
-
!parsed.reason.trim()) {
|
|
538
|
-
outcome = {
|
|
539
|
-
status: 'error',
|
|
540
|
-
code: 'INVALID_ARGUMENTS',
|
|
541
|
-
retryable: false,
|
|
542
|
-
output: '错误:add_tool_groups 需要非空 groups 数组和非空 reason。',
|
|
543
|
-
changedFiles: [],
|
|
544
|
-
durationMs: 0,
|
|
545
|
-
};
|
|
546
|
-
}
|
|
547
|
-
else {
|
|
548
|
-
const expansion = opts.toolPolicy.expand(parsed.groups, parsed.reason);
|
|
549
|
-
const succeeded = expansion.added.length > 0;
|
|
550
|
-
const details = [
|
|
551
|
-
succeeded
|
|
552
|
-
? `Tool policy expanded to v${expansion.snapshot.version}; added groups: ${expansion.added.join(', ')}.`
|
|
553
|
-
: `Tool policy was not expanded (still v${expansion.snapshot.version}).`,
|
|
554
|
-
expansion.rejected.length > 0 ? `Rejected: ${expansion.rejected.join('; ')}.` : '',
|
|
555
|
-
succeeded ? 'The added tool schemas become available on the next model step.' : '',
|
|
556
|
-
]
|
|
557
|
-
.filter(Boolean)
|
|
558
|
-
.join('\n');
|
|
559
|
-
outcome = {
|
|
560
|
-
status: succeeded ? 'success' : 'error',
|
|
561
|
-
code: succeeded ? 'OK' : 'INVALID_ARGUMENTS',
|
|
562
|
-
retryable: false,
|
|
563
|
-
output: details,
|
|
564
|
-
changedFiles: [],
|
|
565
|
-
durationMs: 0,
|
|
566
|
-
};
|
|
567
|
-
emitTrace('tool_route_expand', {
|
|
568
|
-
policyId: expansion.snapshot.id,
|
|
569
|
-
fromVersion: policySnapshot?.version,
|
|
570
|
-
toVersion: expansion.snapshot.version,
|
|
571
|
-
requestedGroups: parsed.groups.map(String),
|
|
572
|
-
addedGroups: expansion.added,
|
|
573
|
-
rejected: expansion.rejected,
|
|
574
|
-
reason: parsed.reason,
|
|
575
|
-
status: outcome.status,
|
|
576
|
-
});
|
|
577
|
-
}
|
|
578
|
-
opts.onToolOutcome?.(tc.name, parsed ?? {}, outcome);
|
|
579
|
-
hooks.onToolResult?.(tc, outcome.output, null, null, 1);
|
|
580
|
-
pushToolResult(history, tc, outcome.output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
|
|
581
|
-
traceToolEnd(tc, index, outcome);
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
// add_tool_groups 是 step 屏障:只要本响应出现该控制调用,本批所有普通工具都不执行。
|
|
585
|
-
// 但上面仍为每个 provider tool_call 写入了配对 tool_result,保持 OpenAI 协议完整。
|
|
586
|
-
let i = hasToolRouteBarrier ? calls.length : 0;
|
|
587
|
-
while (i < calls.length) {
|
|
588
|
-
const currentCall = calls[i];
|
|
589
|
-
if (isToolDeniedForStep(currentCall.name)) {
|
|
590
|
-
hooks.onToolHeader?.(currentCall);
|
|
591
|
-
const error = t('task.disabled');
|
|
592
|
-
const outcome = {
|
|
593
|
-
status: 'denied',
|
|
594
|
-
code: 'TOOL_DISABLED',
|
|
595
|
-
retryable: false,
|
|
596
|
-
output: error,
|
|
597
|
-
changedFiles: [],
|
|
598
|
-
durationMs: 0,
|
|
599
|
-
};
|
|
600
|
-
hooks.onToolResult?.(currentCall, error, null, null, 1);
|
|
601
|
-
pushToolResult(history, currentCall, error, relprune, lifecycle, scheduler, runtimeContextState, false);
|
|
602
|
-
traceToolEnd(currentCall, i, outcome);
|
|
603
|
-
i++;
|
|
604
|
-
continue;
|
|
605
|
-
}
|
|
606
|
-
if (isParallelTool(currentCall.name)) {
|
|
607
|
-
// 收集连续只读组(≥1),并发执行:先渲染所有 header,再一次性启动所有
|
|
608
|
-
// (executeTool 调用即开始 I/O),最后按原顺序逐个 await + 回灌。
|
|
609
|
-
// 必须先 header 后 execute:grep 等同步快速工具会在 executeTool 返回 Promise 前
|
|
610
|
-
// 已经完成;若先 started.map,用户只能在工具完成后才看到摘要与其前面的换行。
|
|
611
|
-
// 异步工具(web_fetch 等)并发跑、总耗时 ≈ 最慢一个;同步工具(glob/grep)map 时已顺序跑完,await 即返。
|
|
612
|
-
let j = i;
|
|
613
|
-
while (j < calls.length && isParallelTool(calls[j].name) && !isToolDeniedForStep(calls[j].name))
|
|
614
|
-
j++;
|
|
615
|
-
const batch = calls.slice(i, j);
|
|
616
|
-
for (const tc of batch)
|
|
617
|
-
hooks.onToolHeader?.(tc);
|
|
618
|
-
hooks.onToolStart?.(batch[0].name);
|
|
619
|
-
const started = batch.map((tc) => executeToolOutcome(tc.name, tc.arguments, signal, {
|
|
620
|
-
callId: tc.id,
|
|
621
|
-
allowedToolNames: currentAllowedToolNames(),
|
|
622
|
-
delegation: delegationForOrchestrator(),
|
|
623
|
-
}));
|
|
624
|
-
for (let k = 0; k < batch.length; k++) {
|
|
625
|
-
const tc = batch[k];
|
|
626
|
-
const outcome = await started[k];
|
|
627
|
-
traceState.addUsage(outcome.usage);
|
|
628
|
-
opts.onToolOutcome?.(tc.name, parseArgs(tc.arguments) ?? {}, outcome);
|
|
629
|
-
traceToolEnd(tc, i + k, outcome);
|
|
630
|
-
const output = outcome.output;
|
|
631
|
-
hooks.onToolResult?.(tc, output, null, null, 1); // 并行工具无 diff
|
|
632
|
-
if (tc.name === 'ask_human' && outcome.status === 'success') {
|
|
633
|
-
askHumanCountThisTurn += 1;
|
|
634
|
-
emitTrace('ask_human_call', {
|
|
635
|
-
tool: tc.name,
|
|
636
|
-
status: outcome.status,
|
|
637
|
-
perTurnCount: askHumanCountThisTurn,
|
|
638
|
-
}, tc.id ? { providerToolCallId: tc.id } : {});
|
|
639
|
-
}
|
|
640
|
-
pushToolResult(history, tc, output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
|
|
641
|
-
}
|
|
642
|
-
hooks.onToolDone?.();
|
|
643
|
-
i = j;
|
|
644
|
-
}
|
|
645
|
-
else if (isResourceLockedCall(currentCall) &&
|
|
646
|
-
!(ctx.getAgentMode() === 'plan' && getPlanDisabledTools().has(currentCall.name))) {
|
|
647
|
-
// 连续文件 mutation:权限确认仍严格按原序进行;全部 preflight 完成后再启动。
|
|
648
|
-
// 每个执行在 registry 内按 canonical path 获取锁,不同文件可并发,同文件别名会排队。
|
|
649
|
-
let j = i;
|
|
650
|
-
while (j < calls.length &&
|
|
651
|
-
isResourceLockedCall(calls[j]) &&
|
|
652
|
-
!isToolDeniedForStep(calls[j].name) &&
|
|
653
|
-
!(ctx.getAgentMode() === 'plan' && getPlanDisabledTools().has(calls[j].name)))
|
|
654
|
-
j++;
|
|
655
|
-
const batch = calls.slice(i, j);
|
|
656
|
-
const entries = [];
|
|
657
|
-
for (let k = 0; k < batch.length; k++) {
|
|
658
|
-
const tc = batch[k];
|
|
659
|
-
const parsed = parseArgs(tc.arguments);
|
|
660
|
-
const tool = findTool(tc.name);
|
|
661
|
-
const argumentsValid = tool && parsed !== null ? validateToolArguments(tool, parsed).valid : false;
|
|
662
|
-
let denied;
|
|
663
|
-
if (tool && argumentsValid) {
|
|
664
|
-
const perm = await checkPermission(tool, parsed ?? {}, signal, {
|
|
665
|
-
prompt: opts.permissionPrompt,
|
|
666
|
-
});
|
|
667
|
-
emitTrace('permission', {
|
|
668
|
-
source: 'agent_tool',
|
|
669
|
-
tool: tc.name,
|
|
670
|
-
decision: perm,
|
|
671
|
-
argumentHash: tracedCalls[i + k].args.sha256,
|
|
672
|
-
}, {
|
|
673
|
-
toolCallId: tracedCalls[i + k].toolCallId,
|
|
674
|
-
...(tc.id ? { providerToolCallId: tc.id } : {}),
|
|
675
|
-
});
|
|
676
|
-
if (perm === 'deny')
|
|
677
|
-
denied = deniedOutcome(tc.name);
|
|
678
|
-
}
|
|
679
|
-
entries.push({
|
|
680
|
-
tc,
|
|
681
|
-
parsed,
|
|
682
|
-
diff: { preWriteOld: null, editStartLine: 1 },
|
|
683
|
-
...(denied ? { denied } : {}),
|
|
684
|
-
});
|
|
685
|
-
}
|
|
686
|
-
for (const entry of entries)
|
|
687
|
-
hooks.onToolHeader?.(entry.tc);
|
|
688
|
-
const firstAllowed = entries.find((entry) => !entry.denied);
|
|
689
|
-
if (firstAllowed)
|
|
690
|
-
hooks.onToolStart?.(firstAllowed.tc.name);
|
|
691
|
-
const started = entries.map((entry) => {
|
|
692
|
-
if (entry.denied)
|
|
693
|
-
return Promise.resolve(entry.denied);
|
|
694
|
-
const hint = argumentErrorHint(entry.tc.name, runtimeContextState);
|
|
695
|
-
return executeToolOutcome(entry.tc.name, entry.tc.arguments, signal, {
|
|
696
|
-
callId: entry.tc.id,
|
|
697
|
-
allowedToolNames: currentAllowedToolNames(),
|
|
698
|
-
delegation: delegationForOrchestrator(),
|
|
699
|
-
...(hint ? { argumentErrorHint: hint } : {}),
|
|
700
|
-
onLockAcquired: (lockedArgs) => {
|
|
701
|
-
entry.diff = readDiffContext(entry.tc, lockedArgs);
|
|
702
|
-
},
|
|
703
|
-
});
|
|
704
|
-
});
|
|
705
|
-
for (let k = 0; k < entries.length; k++) {
|
|
706
|
-
const entry = entries[k];
|
|
707
|
-
const outcome = await started[k];
|
|
708
|
-
traceState.addUsage(outcome.usage);
|
|
709
|
-
opts.onToolOutcome?.(entry.tc.name, entry.parsed ?? {}, outcome);
|
|
710
|
-
traceToolEnd(entry.tc, i + k, outcome);
|
|
711
|
-
hooks.onToolResult?.(entry.tc, outcome.output, entry.denied ? null : entry.parsed, entry.diff.preWriteOld, entry.diff.editStartLine);
|
|
712
|
-
pushToolResult(history, entry.tc, outcome.output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
|
|
713
|
-
const invalidatedFiles = [...new Set([...(outcome.changedFiles ?? []), ...(outcome.staleFiles ?? [])])];
|
|
714
|
-
if (invalidatedFiles.length > 0) {
|
|
715
|
-
for (const changedFile of invalidatedFiles) {
|
|
716
|
-
relprune?.observeMutation(history, changedFile);
|
|
717
|
-
lifecycle?.pushMutation(history, history.length - 1, changedFile);
|
|
718
|
-
}
|
|
719
|
-
invalidateArtifacts(runtimeContextState, history, invalidatedFiles);
|
|
720
|
-
runtimeContextState.lifecycleStats = lifecycle?.stats();
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
if (firstAllowed)
|
|
724
|
-
hooks.onToolDone?.();
|
|
725
|
-
i = j;
|
|
726
|
-
}
|
|
727
|
-
else {
|
|
728
|
-
// 单步串行(mutation / run_command / use_skill)——逐个执行,保快照序
|
|
729
|
-
const tc = calls[i];
|
|
730
|
-
// plan 模式防御 backstop:schema 已剔除这些工具,正常不会进这里;防后端幻觉调用——
|
|
731
|
-
// 不执行,直接返错回灌(让模型看到「plan 模式禁用」并停止),绝不写盘 / 跑命令。
|
|
732
|
-
if (ctx.getAgentMode() === 'plan' && getPlanDisabledTools().has(tc.name)) {
|
|
733
|
-
hooks.onToolHeader?.(tc);
|
|
734
|
-
const err = `错误:计划模式下禁用工具 ${tc.name}(仅读探查,不改动文件 / 不跑命令)`;
|
|
735
|
-
const outcome = {
|
|
736
|
-
status: 'denied',
|
|
737
|
-
code: 'MODE_DENIED',
|
|
738
|
-
retryable: false,
|
|
739
|
-
output: err,
|
|
740
|
-
changedFiles: [],
|
|
741
|
-
durationMs: 0,
|
|
742
|
-
};
|
|
743
|
-
hooks.onToolResult?.(tc, err, null, null, 1);
|
|
744
|
-
pushToolResult(history, tc, err, relprune, lifecycle, scheduler);
|
|
745
|
-
traceToolEnd(tc, i, outcome);
|
|
746
|
-
i++;
|
|
747
|
-
continue;
|
|
748
|
-
}
|
|
749
|
-
// 权限预检查:在渲染 ● 头之前弹确认面板(体验:先问再执行,而非执行完再问)。
|
|
750
|
-
// 拒绝时只渲染拒绝结果,不渲染执行头;放行则继续走 header → start → executeTool 流程。
|
|
751
|
-
const parsed = parseArgs(tc.arguments);
|
|
752
|
-
const tool = findTool(tc.name);
|
|
753
|
-
const argumentsValid = tool && parsed !== null ? validateToolArguments(tool, parsed).valid : false;
|
|
754
|
-
if (tool && argumentsValid) {
|
|
755
|
-
const perm = await checkPermission(tool, parsed ?? {}, signal, {
|
|
756
|
-
prompt: opts.permissionPrompt,
|
|
757
|
-
});
|
|
758
|
-
emitTrace('permission', {
|
|
759
|
-
source: 'agent_tool',
|
|
760
|
-
tool: tc.name,
|
|
761
|
-
decision: perm,
|
|
762
|
-
argumentHash: tracedCalls[i].args.sha256,
|
|
763
|
-
}, {
|
|
764
|
-
toolCallId: tracedCalls[i].toolCallId,
|
|
765
|
-
...(tc.id ? { providerToolCallId: tc.id } : {}),
|
|
766
|
-
});
|
|
767
|
-
if (perm === 'deny') {
|
|
768
|
-
hooks.onToolHeader?.(tc);
|
|
769
|
-
const outcome = deniedOutcome(tc.name);
|
|
770
|
-
hooks.onToolResult?.(tc, outcome.output, null, null, 1);
|
|
771
|
-
pushToolResult(history, tc, outcome.output, relprune, lifecycle, scheduler, runtimeContextState, false);
|
|
772
|
-
traceToolEnd(tc, i, outcome);
|
|
773
|
-
i++;
|
|
774
|
-
continue;
|
|
775
|
-
}
|
|
776
|
-
}
|
|
777
|
-
hooks.onToolHeader?.(tc);
|
|
778
|
-
const mutationParsed = isMutationTool(tc.name) ? parsed : null;
|
|
779
|
-
let diff = readDiffContext(tc, mutationParsed);
|
|
780
|
-
hooks.onToolStart?.(tc.name);
|
|
781
|
-
const serialHint = argumentErrorHint(tc.name, runtimeContextState);
|
|
782
|
-
const outcome = await executeToolOutcome(tc.name, tc.arguments, signal, {
|
|
783
|
-
callId: tc.id,
|
|
784
|
-
allowedToolNames: currentAllowedToolNames(),
|
|
785
|
-
delegation: delegationForOrchestrator(),
|
|
786
|
-
...(serialHint ? { argumentErrorHint: serialHint } : {}),
|
|
787
|
-
onLockAcquired: (lockedArgs) => {
|
|
788
|
-
if (mutationParsed)
|
|
789
|
-
diff = readDiffContext(tc, lockedArgs);
|
|
790
|
-
},
|
|
791
|
-
});
|
|
792
|
-
traceState.addUsage(outcome.usage);
|
|
793
|
-
opts.onToolOutcome?.(tc.name, parsed ?? {}, outcome);
|
|
794
|
-
traceToolEnd(tc, i, outcome);
|
|
795
|
-
const output = outcome.output;
|
|
796
|
-
hooks.onToolDone?.();
|
|
797
|
-
hooks.onToolResult?.(tc, output, mutationParsed, diff.preWriteOld, diff.editStartLine);
|
|
798
|
-
if (tc.name === 'ask_human' && outcome.status === 'success') {
|
|
799
|
-
askHumanCountThisTurn += 1;
|
|
800
|
-
emitTrace('ask_human_call', {
|
|
801
|
-
tool: tc.name,
|
|
802
|
-
status: outcome.status,
|
|
803
|
-
perTurnCount: askHumanCountThisTurn,
|
|
804
|
-
}, tc.id ? { providerToolCallId: tc.id } : {});
|
|
805
|
-
}
|
|
806
|
-
pushToolResult(history, tc, output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
|
|
807
|
-
const invalidatedFiles = [...new Set([...(outcome.changedFiles ?? []), ...(outcome.staleFiles ?? [])])];
|
|
808
|
-
if (invalidatedFiles.length > 0) {
|
|
809
|
-
for (const changedFile of invalidatedFiles) {
|
|
810
|
-
relprune?.observeMutation(history, changedFile);
|
|
811
|
-
lifecycle?.pushMutation(history, history.length - 1, changedFile);
|
|
812
|
-
}
|
|
813
|
-
invalidateArtifacts(runtimeContextState, history, invalidatedFiles);
|
|
814
|
-
runtimeContextState.lifecycleStats = lifecycle?.stats();
|
|
815
|
-
}
|
|
816
|
-
i++;
|
|
817
|
-
}
|
|
818
|
-
}
|
|
819
|
-
// A(计划触碰计数):本步若改动了 notes.md 则清零计数。会话状态本身无需在此重注入——
|
|
820
|
-
// 每步都会由 buildSessionStateReminder() 在 requestHistory 末尾重建最新副本(见上方注入点),
|
|
821
|
-
// 所以模型下一步看到的必然是当前勾选态。只保留计数,避免多余的 history 改写(prompt 缓存)。
|
|
822
|
-
// B(nag 提醒):连续 N 步有工具活动但没更新 plan,在当前步第一条 tool_result 前注入提醒。
|
|
823
|
-
const notesMtimeAfter = ctx.getNotesMtime();
|
|
824
|
-
if (notesMtimeAfter !== notesMtimeBefore) {
|
|
825
|
-
stepsSincePlanTouch = 0;
|
|
826
|
-
}
|
|
827
|
-
else {
|
|
828
|
-
stepsSincePlanTouch += 1;
|
|
829
|
-
if (stepsSincePlanTouch >= PLAN_NAG_THRESHOLD) {
|
|
830
|
-
const activePlan = ctx.extractActivePlanSection();
|
|
831
|
-
const firstToolMsg = history[toolResultStartIdx];
|
|
832
|
-
if (activePlan &&
|
|
833
|
-
firstToolMsg &&
|
|
834
|
-
firstToolMsg.role === 'tool' &&
|
|
835
|
-
typeof firstToolMsg.content === 'string') {
|
|
836
|
-
firstToolMsg.content = `${PLAN_NAG_TEXT}\n\n${firstToolMsg.content}`;
|
|
837
|
-
}
|
|
838
|
-
stepsSincePlanTouch = 0;
|
|
839
|
-
}
|
|
840
|
-
}
|
|
841
|
-
if (modelAttachments.length > 0) {
|
|
842
|
-
const names = modelAttachments.map((attachment) => attachment.name).join(', ');
|
|
843
|
-
const content = [
|
|
844
|
-
{
|
|
845
|
-
type: 'text',
|
|
846
|
-
text: `The view_image tool loaded the following visual input: ${names}. Analyze the attached image content directly.`,
|
|
847
|
-
},
|
|
848
|
-
...modelAttachments.map((attachment) => ({
|
|
849
|
-
type: 'image_url',
|
|
850
|
-
image_url: {
|
|
851
|
-
url: attachment.dataUrl,
|
|
852
|
-
// 不默认补 auto: OpenAI 省略时等同 auto,但 MiniMax 仅接受 low/default/high。
|
|
853
|
-
// 让各 provider 采用默认枚举;仅保留工具明确请求的 low/high。
|
|
854
|
-
...(attachment.detail === 'low' || attachment.detail === 'high'
|
|
855
|
-
? { detail: attachment.detail }
|
|
856
|
-
: {}),
|
|
857
|
-
},
|
|
858
|
-
})),
|
|
859
|
-
];
|
|
860
|
-
// OpenAI tool-call protocol requires every tool result to immediately follow the
|
|
861
|
-
// assistant tool_calls message; append visual input only after the full batch.
|
|
862
|
-
history.push({ role: 'user', content });
|
|
863
|
-
}
|
|
864
|
-
// 工具步末尾补一空行:与下一轮的思考 / 正文分隔(否则 ↳ 后紧接 ▎ 思考,无空行不好看;
|
|
865
|
-
// 与正文→● 的 1 空行对称)。工具结果已以 \n 收尾,此处再补 \n 恰好 1 空行。
|
|
866
|
-
hooks.onToolBatchEnd?.();
|
|
867
|
-
// 刷新中断快照:工具全部执行完毕后,history 处于一致状态(assistant+tool_calls+tool 结果完整),
|
|
868
|
-
// 此时中断可安全保留这些已完成的消息,只丢弃下一轮未完成的 chat() 响应。
|
|
869
|
-
savedHistory = history.slice();
|
|
870
|
-
continue; // 带着工具结果再调一次 LLM
|
|
871
|
-
}
|
|
872
|
-
if (mode !== 'idle' && lastChar !== '\n')
|
|
873
|
-
hooks.onTextEnd?.(); // 流式末尾补换行
|
|
874
|
-
// 没有工具调用:接受 agent 的完成判断。框架不自动运行测试、构建或完成门,
|
|
875
|
-
// 也不因缺少验证证据强制追加模型轮次;agent 仍可自行调用工具验证。
|
|
876
|
-
if (!gotText)
|
|
877
|
-
hooks.onNoReply?.();
|
|
878
|
-
history.push({ role: 'assistant', content: result.content });
|
|
879
|
-
const finalMutation = ctx.getCurrentTurnMutationState();
|
|
880
|
-
done = true;
|
|
881
|
-
traceStatus = 'completed';
|
|
882
|
-
return {
|
|
883
|
-
completed: true,
|
|
884
|
-
terminationReason: 'completed',
|
|
885
|
-
finalText: result.content,
|
|
886
|
-
usage: traceState.turnUsage,
|
|
887
|
-
changedFiles: finalMutation.changedFiles.map((item) => item.path),
|
|
888
|
-
};
|
|
889
|
-
}
|
|
890
|
-
finally {
|
|
891
|
-
emitTrace('step_end', {
|
|
892
|
-
durationMs: Date.now() - stepStartedAt,
|
|
893
|
-
aborted: signal?.aborted === true,
|
|
894
|
-
});
|
|
895
|
-
}
|
|
896
|
-
}
|
|
897
|
-
hooks.onMaxSteps?.();
|
|
898
|
-
done = true;
|
|
899
|
-
traceStatus = 'max_steps';
|
|
900
|
-
const finalMutation = ctx.getCurrentTurnMutationState();
|
|
901
|
-
return {
|
|
902
|
-
completed: false,
|
|
903
|
-
terminationReason: 'max_steps',
|
|
904
|
-
finalText: null,
|
|
905
|
-
usage: traceState.turnUsage,
|
|
906
|
-
changedFiles: finalMutation.changedFiles.map((item) => item.path),
|
|
907
|
-
};
|
|
908
|
-
}
|
|
909
|
-
finally {
|
|
910
|
-
const finalMutation = ctx.getCurrentTurnMutationState();
|
|
911
|
-
traceState.currentTraceStep = undefined;
|
|
912
|
-
emitTrace('turn_end', {
|
|
913
|
-
status: traceStatus,
|
|
914
|
-
durationMs: Date.now() - t0,
|
|
915
|
-
toolCalls: traceState.toolCallCount,
|
|
916
|
-
changedFiles: finalMutation.changedFiles.map((item) => item.path),
|
|
917
|
-
totalTokens: traceState.turnUsage?.totalTokens,
|
|
918
|
-
});
|
|
919
|
-
try {
|
|
920
|
-
opts.onTrace?.({
|
|
921
|
-
ts: new Date().toISOString(),
|
|
922
|
-
sessionId: traceSessionId,
|
|
923
|
-
turnId: traceTurnId,
|
|
924
|
-
status: traceStatus,
|
|
925
|
-
durationMs: Date.now() - t0,
|
|
926
|
-
toolCalls: traceState.toolCallCount,
|
|
927
|
-
changedFiles: finalMutation.changedFiles.map((item) => item.path),
|
|
928
|
-
usage: traceState.turnUsage,
|
|
929
|
-
});
|
|
930
|
-
}
|
|
931
|
-
catch {
|
|
932
|
-
// Trace is best-effort and must not change the turn result.
|
|
933
|
-
}
|
|
934
|
-
// 跑完(正常 / 达上限)在回复末尾打耗时摘要行;中断 done=false 不打。
|
|
935
|
-
if (done) {
|
|
936
|
-
hooks.onDone?.(Date.now() - t0, traceState.turnUsage);
|
|
937
|
-
}
|
|
938
|
-
}
|
|
15
|
+
return withAgentRuntimeContext(runtimeContext, () => runtimeContext.runInScope(() => assembly.run({ ...opts, runtimeContext })));
|
|
939
16
|
}
|
|
940
|
-
|
|
941
|
-
export
|
|
17
|
+
/** 文件 mutation 由 capability metadata 判定,供 diff、回滚与上下文失效共用。 */
|
|
18
|
+
export const isMutationTool = (name, toolRuntime = defaultToolRuntime) => toolRuntime.isFileMutationTool(name);
|
|
19
|
+
export { parseArgs, readDiffContext, isParallelTool } from './tool-helpers.js';
|