mocode-ai 1.3.9 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/README.md +78 -70
  2. package/README.zh-CN.md +81 -73
  3. package/bin/mocode-agent-host.js +1 -1
  4. package/dist/agent/core.js +226 -293
  5. package/dist/agent/index.js +14 -10
  6. package/dist/agent/mode.js +3 -3
  7. package/dist/agent/runtime-context.js +45 -0
  8. package/dist/agent/spawn.js +101 -86
  9. package/dist/agent/tool-helpers.js +134 -0
  10. package/dist/agent/trace-state.js +118 -0
  11. package/dist/agents/coordinator.js +6 -1
  12. package/dist/changeset/index.js +9 -8
  13. package/dist/config/index.js +109 -77
  14. package/dist/config/presets.js +3 -1
  15. package/dist/config/profiles.js +125 -0
  16. package/dist/context/age-aware.js +2 -4
  17. package/dist/context/artifacts.js +6 -13
  18. package/dist/context/budget.js +6 -8
  19. package/dist/context/classifier.js +1 -2
  20. package/dist/context/encoders/code.js +2 -6
  21. package/dist/context/encoders/command.js +1 -3
  22. package/dist/context/encoders/search.js +1 -3
  23. package/dist/context/index.js +1 -1
  24. package/dist/context/lifecycle.js +5 -3
  25. package/dist/context/pipeline.js +1 -1
  26. package/dist/context/relevance.js +2 -4
  27. package/dist/context/token-calibration.js +13 -18
  28. package/dist/host/stdio.js +73 -12
  29. package/dist/i18n/index.js +96 -44
  30. package/dist/index.js +1 -1
  31. package/dist/llm/index.js +39 -23
  32. package/dist/llm/provider.js +23 -0
  33. package/dist/llm/providers/anthropic.js +12 -18
  34. package/dist/llm/tool-schema.js +48 -0
  35. package/dist/mcp/client.js +70 -25
  36. package/dist/mcp/config.js +1 -3
  37. package/dist/mcp/index.js +3 -1
  38. package/dist/memory/reflect.js +3 -6
  39. package/dist/memory/store.js +10 -12
  40. package/dist/permissions/index.js +66 -22
  41. package/dist/pet/bridge.js +2 -2
  42. package/dist/repl/commands/appearance.js +72 -0
  43. package/dist/repl/commands/compact.js +114 -0
  44. package/dist/repl/commands/context.js +17 -0
  45. package/dist/repl/commands/image.js +71 -0
  46. package/dist/repl/commands/memory.js +51 -0
  47. package/dist/repl/commands/mode.js +47 -0
  48. package/dist/repl/commands/model.js +435 -0
  49. package/dist/repl/commands/pet.js +70 -0
  50. package/dist/repl/commands/registry.js +46 -0
  51. package/dist/repl/commands/session.js +102 -0
  52. package/dist/repl/commands/skill.js +95 -0
  53. package/dist/repl/commands/system.js +202 -0
  54. package/dist/repl/commands/tool-group.js +233 -0
  55. package/dist/repl/commands/types.js +4 -0
  56. package/dist/repl/commands.js +324 -0
  57. package/dist/repl/index.js +5 -2577
  58. package/dist/repl/message-format.js +151 -0
  59. package/dist/repl/running-input.js +147 -0
  60. package/dist/repl/runtime.js +816 -0
  61. package/dist/repl/status-bar.js +161 -0
  62. package/dist/rollback/index.js +44 -14
  63. package/dist/runtime/browser-manager.js +1 -1
  64. package/dist/runtime/dev-server-manager.js +1 -1
  65. package/dist/runtime/input-injector.js +313 -0
  66. package/dist/runtime/screen-capture.js +89 -0
  67. package/dist/runtime/screen-pipeline.js +223 -0
  68. package/dist/sandbox/index.js +1 -1
  69. package/dist/sandbox/policy.js +8 -2
  70. package/dist/session/compact.js +22 -34
  71. package/dist/session/index.js +2 -2
  72. package/dist/session/notes.js +39 -5
  73. package/dist/session/persist.js +14 -8
  74. package/dist/session/scheduler.js +3 -4
  75. package/dist/session/trace-metrics.js +6 -3
  76. package/dist/skills/activation.js +10 -5
  77. package/dist/skills/discover.js +5 -17
  78. package/dist/skills/index.js +1 -1
  79. package/dist/skills/runner.js +28 -44
  80. package/dist/skills/toolmap.js +5 -2
  81. package/dist/tools/builtins/browser.js +17 -3
  82. package/dist/tools/builtins/computer.js +322 -0
  83. package/dist/tools/builtins/dev-server.js +1 -3
  84. package/dist/tools/builtins/edit-file.js +21 -7
  85. package/dist/tools/builtins/grep.js +1 -1
  86. package/dist/tools/builtins/index.js +28 -25
  87. package/dist/tools/builtins/memory-forget.js +1 -1
  88. package/dist/tools/builtins/memory-graph.js +4 -7
  89. package/dist/tools/builtins/memory-list.js +2 -6
  90. package/dist/tools/builtins/memory-save.js +8 -2
  91. package/dist/tools/builtins/memory-search.js +4 -4
  92. package/dist/tools/builtins/note-append.js +14 -3
  93. package/dist/tools/builtins/plan-update.js +40 -9
  94. package/dist/tools/builtins/run-command.js +8 -8
  95. package/dist/tools/builtins/screenshot.js +11 -87
  96. package/dist/tools/builtins/task.js +29 -40
  97. package/dist/tools/builtins/use-skill.js +1 -1
  98. package/dist/tools/builtins/view-image.js +5 -3
  99. package/dist/tools/builtins/web-fetch.js +1 -3
  100. package/dist/tools/builtins/web-search.js +3 -7
  101. package/dist/tools/builtins/write-file.js +4 -2
  102. package/dist/tools/constants.js +50 -37
  103. package/dist/tools/policy.js +221 -0
  104. package/dist/tools/registry.js +40 -19
  105. package/dist/tools/resource-lock.js +4 -2
  106. package/dist/tools/router.js +143 -0
  107. package/dist/tools/validation.js +6 -5
  108. package/dist/ui/batch.js +63 -23
  109. package/dist/ui/clipboard.js +17 -5
  110. package/dist/ui/content.js +166 -13
  111. package/dist/ui/diff.js +61 -19
  112. package/dist/ui/fuzzy.js +35 -2
  113. package/dist/ui/intervention.js +2 -4
  114. package/dist/ui/layout-internal/content-write.js +587 -0
  115. package/dist/ui/layout-internal/core.js +590 -0
  116. package/dist/ui/layout-internal/input-paint.js +365 -0
  117. package/dist/ui/layout-internal/screen.js +128 -0
  118. package/dist/ui/layout-internal/scroll.js +152 -0
  119. package/dist/ui/layout-internal/selection.js +229 -0
  120. package/dist/ui/layout-internal/state.js +82 -0
  121. package/dist/ui/layout-internal/statusbar.js +351 -0
  122. package/dist/ui/layout-types.js +1 -0
  123. package/dist/ui/layout.js +3 -2288
  124. package/dist/ui/markdown.js +10 -2
  125. package/dist/ui/prompt-internal/editor.js +945 -0
  126. package/dist/ui/prompt-internal/paste.js +52 -0
  127. package/dist/ui/prompt-internal/pickers.js +519 -0
  128. package/dist/ui/prompt-internal/types.js +1 -0
  129. package/dist/ui/prompt.js +4 -1506
  130. package/dist/ui/render.js +87 -2
  131. package/dist/verification/affected.js +11 -5
  132. package/dist/verification/discovery.js +1 -3
  133. package/dist/verification/profile.js +11 -3
  134. package/package.json +11 -2
@@ -4,154 +4,32 @@
4
4
  //
5
5
  // 与 index.ts 的关系:index.ts 的 runAgent = runAgentCore + TUI hooks 薄封装(行为不变)。
6
6
  // spawn.ts 的 spawnAgent = runAgentCore + 静默 hooks(子 agent)。
7
- import { readFileSync } from 'node:fs';
8
- import { getNotesMtime } from '../session/notes.js';
9
7
  import { chat, estimatePromptTokens, estimateTokens, isContextLengthError, planChatTools, chatTools, } from '../llm/index.js';
10
- import { executeToolOutcome, findTool, getToolCapabilities, isFileMutationTool, } from '../tools/registry.js';
8
+ import { executeToolOutcome, findTool, isFileMutationTool } from '../tools/registry.js';
11
9
  import { checkPermission } from '../permissions/index.js';
12
10
  import { validateToolArguments } from '../tools/validation.js';
13
- import { getPlanDisabledTools, getRuntimeDisabledTools } from '../tools/constants.js';
14
- import { getAgentMode, setAgentMode } from './mode.js';
15
- import { maybeCompact, contextState, createTraceEvent, summarizeToolArguments, safeProviderId, } from '../session/index.js';
16
- import { capToolResultForHistory } from '../session/compact.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
17
  import { createBudgetScheduler } from '../session/scheduler.js';
18
- import { recordArtifact, invalidateArtifacts, rehydrateArtifacts, knownEditTargets, } from '../context/index.js';
18
+ import { invalidateArtifacts, rehydrateArtifacts } from '../context/index.js';
19
19
  import { createRelevancePruner } from '../context/relevance.js';
20
- import { isToolResultSuccess } from '../context/utils.js';
21
- import { config, getActiveModel, extractActivePlanSection, buildSessionStateReminder } from '../config/index.js';
22
20
  import { t } from '../i18n/index.js';
23
- import { jailResolve } from '../sandbox/index.js';
24
21
  import { createLifecycleEngine } from '../context/lifecycle.js';
25
- import { getTokenCalibration, updateTokenCalibration, } from '../context/token-calibration.js';
26
- import { getCurrentTurnId, getCurrentTurnMutationState } from '../rollback/index.js';
27
- import { getCurrentSessionId } from '../session/state.js';
28
22
  /** nag 提醒阈值:连续 N 个"执行了工具但没更新 notes.md"的步后提醒一次(对齐 Claude Code TodoWrite 的 3 轮)。 */
29
23
  const PLAN_NAG_THRESHOLD = 3;
30
24
  /** nag 提醒文本:注入到当前步第一条 tool_result 内容前(与最新工具输出同批被模型看到,而非单独一条易被冲淡)。 */
31
25
  const PLAN_NAG_TEXT = '[mocode] Reminder: you have an active plan in notes.md but have not updated it recently. ' +
32
26
  'If you finished a step, call plan_update to check it off (keep at most one in_progress); ' +
33
27
  'if the whole plan is done, let plan_update settle it to ## Done:. If the plan changed scope, update it to match reality.';
34
- /** 解析工具 arguments JSON;非法或空返 null(调用方据此降级到普通 preview)。 */
35
- function parseArgs(raw) {
36
- try {
37
- return raw.trim() ? JSON.parse(raw) : {};
38
- }
39
- catch {
40
- return null;
41
- }
42
- }
43
- /** 需要「已知编辑目标」恢复提示的文件编辑工具;其余工具的参数报错不注入该提示。 */
44
- const EDIT_HINT_TOOLS = new Set(['edit_file', 'write_file']);
45
- /** 文件编辑工具参数校验失败时的恢复提示:把系统已知仍新鲜的「最近 read_file 的
46
- * path + hash」直接递给模型照抄,替代其在长上下文里凭记忆复述。
47
- * 只展示事实、不替模型填值;没有候选(从未 read_file / 全部已失效)返 undefined。 */
48
- function argumentErrorHint(name, state) {
49
- if (!EDIT_HINT_TOOLS.has(name))
50
- return undefined;
51
- const targets = knownEditTargets(state);
52
- if (targets.length === 0)
53
- return undefined;
54
- const lines = targets.map((target) => ` path=${target.path} expected_hash=${target.hash}`).join('\n');
55
- return ('系统已知最近 read_file 且尚未被修改的文件(直接复制下面的 path / expected_hash,勿凭记忆复述):\n' +
56
- `${lines}\n如目标文件不在其中,先 read_file 该文件再发起编辑。`);
57
- }
58
- /** 判定 assistant content 是否只是 "Tool results:" 这类工具结果前缀噪声。
59
- * 部分模型(如 Claude)会在 tool_calls 前输出此种无意义过渡文本,写入 history
60
- * 会污染后续轮次上下文并在 TUI 上泄露为孤立行。 */
61
- function isToolResultsNoise(content) {
62
- return /^(?:\s*Tool results:\s*)+$/i.test(content.trim());
63
- }
64
- /** 只有显式声明 parallel 且无需权限确认的工具才进入普通并发组。 */
65
- function isParallelTool(name) {
66
- const tool = findTool(name);
67
- return !!tool && (tool.risk ?? 'safe') === 'safe' &&
68
- getToolCapabilities(tool).concurrency === 'parallel';
69
- }
70
- /** resource-locked 工具先顺序完成权限预检,再依赖 canonical resource lock 并发执行。 */
71
- function isResourceLockedTool(name) {
72
- const tool = findTool(name);
73
- return !!tool && getToolCapabilities(tool).concurrency === 'resource-locked';
74
- }
75
- function isResourceLockedCall(call) {
76
- if (!isResourceLockedTool(call.name))
77
- return false;
78
- if (call.name !== 'sub-agent')
79
- return true;
80
- const args = parseArgs(call.arguments);
81
- // Unknown write sets stay on the serial path. Read tasks and known disjoint write sets may batch.
82
- return args?.mode !== 'write' || (Array.isArray(args.writeSet) && args.writeSet.length > 0);
83
- }
28
+ // 工具辅助纯函数(parseArgs / argumentErrorHint / isToolResultsNoise / isParallelTool /
29
+ // isResourceLockedTool / isResourceLockedCall / deniedOutcome / readDiffContext / pushToolResult)
30
+ // 已提取至 ./tool-helpers.ts——它们不依赖本循环的局部状态,只接受显式参数,故可安全模块化。
84
31
  /** 文件 mutation 由 capability metadata 判定,供 diff、回滚与上下文失效共用。 */
85
32
  const isMutationTool = (name) => isFileMutationTool(name);
86
- function deniedOutcome(name) {
87
- return {
88
- status: 'denied',
89
- code: 'PERMISSION_DENIED',
90
- retryable: false,
91
- output: `错误:用户拒绝了工具 ${name} 的执行。`,
92
- };
93
- }
94
- /** mutation 执行前读旧内容供 diff:write_file 取整文件旧内容(不存在→null=新建),
95
- * edit_file 取 old_string 起始行号(供 diff 显示真实文件行号)。读不到则 diff 退化为相对行号。
96
- * 非 mutation 或参数非法返 { preWriteOld: null, editStartLine: 1 }。失败不阻断。 */
97
- function readDiffContext(tc, parsed) {
98
- if (!parsed)
99
- return { preWriteOld: null, editStartLine: 1 };
100
- const p = String(parsed.path ?? '');
101
- if (!p)
102
- return { preWriteOld: null, editStartLine: 1 };
103
- if (tc.name === 'write_file') {
104
- try {
105
- // jailResolve:沙箱越界(../../、绝对外圈、软链出圈)抛错 → catch 兜底返 null,不泄露牢外内容(TOCTOU)
106
- return { preWriteOld: readFileSync(jailResolve(p), 'utf8'), editStartLine: 1 };
107
- }
108
- catch {
109
- return { preWriteOld: null, editStartLine: 1 }; // 文件不存在(新建)、不可读 或 沙箱越界(不泄露)
110
- }
111
- }
112
- if (tc.name === 'edit_file') {
113
- // 行尾归一化:LLM 生成的 old_string 用 LF(\n),但 Windows 文件可能是 CRLF(\r\n),
114
- // 不统一则 indexOf 必败、editStartLine 恒为 1。与 edit-file.ts 保持一致归一化为 LF。
115
- const oldStr = String(parsed.old_string ?? '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
116
- try {
117
- // jailResolve:同上,沙箱越界抛错 → catch 兜底,不泄露牢外内容
118
- const raw = readFileSync(jailResolve(p), 'utf8');
119
- const data = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
120
- const idx = oldStr ? data.indexOf(oldStr) : -1;
121
- return {
122
- preWriteOld: null,
123
- editStartLine: idx >= 0 ? data.slice(0, idx).split('\n').length : 1,
124
- };
125
- }
126
- catch {
127
- return { preWriteOld: null, editStartLine: 1 }; // 读不到:diff 退化为相对行号(含沙箱越界)
128
- }
129
- }
130
- return { preWriteOld: null, editStartLine: 1 };
131
- }
132
- /** 回灌 tool 结果到 history。
133
- * 正常路径只做单条 hard cap;原始 output 同时供 TUI 展示,因此用户与模型
134
- * 看到同一事实。Artifact/Relevance/Lifecycle 仅登记 metadata/provenance,
135
- * 不在这里改写旧正文;所有自动清理与压缩统一由 80% pressure scheduler 决定。 */
136
- function pushToolResult(history, tc, output, pruner, lifecycle, _scheduler, runtimeContextState = contextState, succeededOverride) {
137
- const succeeded = succeededOverride ?? isToolResultSuccess(output);
138
- const msg = {
139
- role: 'tool',
140
- tool_call_id: tc.id,
141
- // Preserve evidence verbatim in normal operation; the hard per-result cap
142
- // remains solely as a request-size safety rail.
143
- content: capToolResultForHistory(tc.name, output),
144
- };
145
- history.push(msg);
146
- const messageIndex = history.length - 1;
147
- recordArtifact(runtimeContextState, history, messageIndex, output, succeeded);
148
- // 失败 read 不得淘汰旧 read;失败 consumer 也不能改变 lifecycle 上游状态。
149
- if (pruner)
150
- pruner.observePush(history, msg, succeeded);
151
- if (lifecycle)
152
- lifecycle.pushTool(history, messageIndex, succeeded);
153
- runtimeContextState.lifecycleStats = lifecycle?.stats();
154
- }
155
33
  /**
156
34
  * agent 核心循环(纯逻辑):
157
35
  * 流式调 LLM(经 hooks.onText 实时渲染)→ 有 tool_calls 就分组执行并回灌
@@ -169,67 +47,41 @@ function pushToolResult(history, tc, output, pruner, lifecycle, _scheduler, runt
169
47
  */
170
48
  export async function runAgentCore(opts) {
171
49
  const { history, userInput, signal, onContextUpdate, hooks } = opts;
50
+ const ctx = opts.runtimeContext ?? defaultAgentRuntimeContext;
172
51
  const runtimeContextState = opts.contextState ?? contextState;
173
52
  /** 本轮 ask_human 成功调用次数,仅用于 trace 观测,不影响工具执行或模型上下文。 */
174
53
  let askHumanCountThisTurn = 0;
175
- const maxSteps = opts.maxSteps ?? config.maxSteps;
54
+ const maxSteps = opts.maxSteps ?? ctx.config.maxSteps;
176
55
  // 中断还原:repl 的 /plan / /auto / Shift+Tab 等用户面触发 setAgentMode 中途切了模式,
177
56
  // abort 时连同模式一起还原回轮首。模型不再持有 switch_mode 工具,无法自切。
178
- const savedMode = getAgentMode();
57
+ const savedMode = ctx.getAgentMode();
179
58
  // 本轮计时:从入口到完毕(正常 return / 达上限),供 finally 打 ✻ Worked for 摘要行。
180
59
  const t0 = Date.now();
181
- const traceSessionId = opts.traceContext?.sessionId ?? getCurrentSessionId() ?? `ephemeral-${process.pid}`;
182
- const traceTurnId = opts.traceContext?.turnId ?? getCurrentTurnId();
183
- let currentTraceStep;
184
- let abortTraced = false;
185
- const emitTrace = (type, data = {}, ids = {}) => {
186
- try {
187
- opts.onTraceEvent?.(createTraceEvent({
188
- sessionId: traceSessionId,
189
- turnId: traceTurnId,
190
- type,
191
- ...(currentTraceStep === undefined ? {} : {
192
- step: currentTraceStep,
193
- stepId: `${traceTurnId}:step:${currentTraceStep}`,
194
- }),
195
- ...ids,
196
- data,
197
- }));
198
- }
199
- catch {
200
- // Trace is best-effort and must never alter execution.
201
- }
202
- };
203
- emitTrace('turn_start', { mode: getAgentMode() });
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,
66
+ });
67
+ const traceSessionId = traceState.sessionId;
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);
204
73
  let done = false; // 正常完毕 / 达上限 true;中断 false(不显摘要)
205
74
  let traceStatus = 'error';
206
- let toolCallCount = 0;
207
75
  // A+B(plan 可靠性):跨步计数"执行了工具但没改动 notes.md"的连续步数。
208
76
  // 本步写了 notes.md(plan_update 或直接 write/edit)→ 清零并重同步 history[0];
209
77
  // 否则累计,达阈值则在当前步 tool_result 前注入 nag 提醒。
210
78
  let stepsSincePlanTouch = 0;
211
- // 本轮 token 累计:每步 chat() 返回后把 result.usage 累加,供 onDone 摘要行 + AgentRunResult.usage
79
+ // 本轮 token 累计在 traceState.turnUsage(每步 chat() 返回后 addUsage),供 onDone 摘要行 + AgentRunResult.usage
212
80
  // 透传给 repl(显示在底栏模式 chip 右边)。未开启 include_usage 或全失败时为 undefined。
213
- let turnUsage;
214
81
  // 实时 chip ↻ 估算用:上一步 chat 实测 prompt(前缀缓存下当前步命中 ≈ 它)+ 后端是否报过 cache 命中
215
82
  // (从不报 cache 的后端不估算,避免虚显 ↻)。
216
83
  let lastStepPromptTokens = 0;
217
84
  let providerCacheSeen = false;
218
- const addUsage = (u) => {
219
- if (!u)
220
- return;
221
- turnUsage = turnUsage
222
- ? {
223
- promptTokens: turnUsage.promptTokens + u.promptTokens,
224
- completionTokens: turnUsage.completionTokens + u.completionTokens,
225
- totalTokens: turnUsage.totalTokens + u.totalTokens,
226
- cachedTokens: turnUsage.cachedTokens + u.cachedTokens,
227
- cacheCreationTokens: (turnUsage.cacheCreationTokens ?? 0) + (u.cacheCreationTokens ?? 0),
228
- reasoningTokens: turnUsage.reasoningTokens + u.reasoningTokens,
229
- }
230
- : u;
231
- };
232
- const addToolUsage = (outcome) => addUsage(outcome.usage);
233
85
  history.push({ role: 'user', content: userInput });
234
86
  // 中断回滚快照:push 用户消息后整段浅拷贝。abort 时 length=0;push(...saved) 还原。
235
87
  // 这样中断时至少保留用户消息(及之前的历史);每步工具全部执行完毕后刷新快照,
@@ -238,18 +90,14 @@ export async function runAgentCore(opts) {
238
90
  let savedHistory = history.slice();
239
91
  // Relevance and lifecycle collect provenance during normal work. Neither path
240
92
  // rewrites history; exact supersession is applied only by the pressure scheduler.
241
- const relprune = config.contextRelprune ? createRelevancePruner() : null;
242
- let lifecycle = config.contextLifecycle
243
- ? createLifecycleEngine(history)
244
- : null;
93
+ const relprune = ctx.config.contextRelprune ? createRelevancePruner() : null;
94
+ let lifecycle = ctx.config.contextLifecycle ? createLifecycleEngine(history) : null;
245
95
  runtimeContextState.lifecycleStats = lifecycle?.stats();
246
96
  rehydrateArtifacts(runtimeContextState, history);
247
97
  // The scheduler is the sole automatic history-rewrite entry point. It runs
248
98
  // superseded → stale artifact → old logs/search → compact at real pressure.
249
99
  // contextBudget=false keeps only the infrastructure compact fallback.
250
- const scheduler = config.contextBudget !== false
251
- ? createBudgetScheduler(runtimeContextState)
252
- : null;
100
+ const scheduler = ctx.config.contextBudget !== false ? createBudgetScheduler(runtimeContextState) : null;
253
101
  // 本轮流式状态:首个正文 token 到达即停 spinner(思考期间 spinner 持续转「思考中…」,不写思考内容)。
254
102
  let mode = 'idle';
255
103
  let gotText = false;
@@ -272,19 +120,11 @@ export async function runAgentCore(opts) {
272
120
  };
273
121
  // 中断还原:停 spinner + 补换行 + (已中断)提示 + history 还原到本 turn 前 + 模式还原。
274
122
  // 两处共用:① await chat() 抛 AbortError 的 catch;② 工具被 abort 杀后循环顶检查。
275
- const abortRestore = () => {
276
- if (!abortTraced) {
277
- emitTrace('abort', { phase: 'observed', reason: 'signal' });
278
- abortTraced = true;
279
- }
280
- hooks.onAbort?.();
281
- history.length = 0;
282
- history.push(...savedHistory);
283
- setAgentMode(savedMode);
284
- };
123
+ // 实现已收敛进 TurnTraceState.abortRestore;savedHistory/savedMode 是函数级 let,此处闭包现读。
124
+ const abortRestore = () => traceState.abortRestore({ hooks, history, savedHistory, ctx, savedMode });
285
125
  try {
286
126
  for (let step = 0; step < maxSteps; step++) {
287
- currentTraceStep = step;
127
+ traceState.currentTraceStep = step;
288
128
  const stepStartedAt = Date.now();
289
129
  emitTrace('step_start', { ordinal: step });
290
130
  try {
@@ -292,21 +132,45 @@ export async function runAgentCore(opts) {
292
132
  if (signal?.aborted) {
293
133
  abortRestore();
294
134
  traceStatus = 'aborted';
295
- const mutation = getCurrentTurnMutationState();
296
- return {
297
- completed: false,
298
- terminationReason: 'aborted',
299
- finalText: null,
300
- usage: turnUsage,
301
- changedFiles: mutation.changedFiles.map((item) => item.path),
302
- };
135
+ return traceState.buildAbortedResult(ctx.getCurrentTurnMutationState());
303
136
  }
304
- // 本步只计算一次实际工具集合,调度、请求和 usage 校准必须使用完全相同的 schema。
305
- const activeTools = opts.toolsOverride
306
- ?? (getAgentMode() === 'plan' ? planChatTools : chatTools);
307
- const requestBaseURL = config.baseURL;
308
- const requestModel = getActiveModel();
309
- const storedCalibration = getTokenCalibration(requestBaseURL, requestModel, activeTools);
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);
310
174
  runtimeContextState.correction = storedCalibration.correction;
311
175
  runtimeContextState.calibrationSamples = storedCalibration.samples;
312
176
  // 会话状态(活跃 plan + 笔记正文)在调度器**之前**取一次:
@@ -314,7 +178,7 @@ export async function runAgentCore(opts) {
314
178
  // 必须计入压力线——它不在 history 里,调度器只能由此入参看见(否则最多 5k
315
179
  // 的笔记 + plan 段对 80% 触发线完全不可见,小窗口模型会压不住);
316
180
  // ② 压缩步在压缩成功后重取(P2 固结的 Compaction Snapshot 当步即可见)。
317
- let sessionStateText = opts.suppressSessionState ? '' : buildSessionStateReminder();
181
+ let sessionStateText = opts.suppressSessionState ? '' : ctx.buildSessionStateReminder();
318
182
  // The scheduler is the only automatic path that may compress old evidence.
319
183
  // Normal tool pushes and lifecycle tracking remain metadata-only.
320
184
  // 压缩**之前**先刷一次状态栏:bar 要显示「本步真实 prompt 撞线」那一刻。
@@ -368,7 +232,7 @@ export async function runAgentCore(opts) {
368
232
  // 末尾就带上最新 Compaction Snapshot,不必等下一步。bar 口径对应的
369
233
  // ephemeralText 仍用触发时旧值(见 buildRequestHistory 注释),仅差这一段。
370
234
  if (!opts.suppressSessionState)
371
- sessionStateText = buildSessionStateReminder();
235
+ sessionStateText = ctx.buildSessionStateReminder();
372
236
  // 会话状态(活跃 plan + 笔记段)不再回写 history[0]:每步都会在 requestHistory
373
237
  // 末尾注入最新副本(见下方 ephemeralReminder),compact 后自然恢复。
374
238
  }
@@ -378,7 +242,7 @@ export async function runAgentCore(opts) {
378
242
  lastChar = '';
379
243
  let result;
380
244
  const modelStartedAt = Date.now();
381
- const provider = safeProviderId(requestBaseURL);
245
+ const provider = ctx.safeProviderId(requestBaseURL);
382
246
  emitTrace('model_start', { model: requestModel, provider });
383
247
  // 动态注入(prompt 缓存关键):所有随步/随文件变化的提示统一拼成**历史末尾**一条
384
248
  // ephemeral system 消息,不再改写 history[0]。这样系统提示 + 已有对话逐字节稳定,
@@ -398,7 +262,8 @@ export async function runAgentCore(opts) {
398
262
  // 不计,让 bar 用会让两条线再错开几百 token。
399
263
  const buildRequestHistory = () => {
400
264
  const ephemeralReminder = [
401
- (!opts.suppressOpeningAnalysis && step === 0)
265
+ opts.toolPolicy?.reminder(planMode) ?? '',
266
+ !opts.suppressOpeningAnalysis && step === 0
402
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.'
403
268
  : '',
404
269
  historyRebuilt
@@ -413,7 +278,9 @@ export async function runAgentCore(opts) {
413
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.'
414
279
  : '',
415
280
  sessionStateText, // 调度器之前已取(并计入压力线),此处复用同一份,不重复读文件
416
- ].filter(Boolean).join('\n\n');
281
+ ]
282
+ .filter(Boolean)
283
+ .join('\n\n');
417
284
  return ephemeralReminder
418
285
  ? [...history, { role: 'system', content: ephemeralReminder }]
419
286
  : history;
@@ -424,25 +291,9 @@ export async function runAgentCore(opts) {
424
291
  // 那一次才是解释「为什么要压」的。
425
292
  onContextUpdate?.();
426
293
  // 实时用量:当前步 prompt 估算(含校准系数)+ 流式累计 completion 估算,
427
- // 叠上已完成步的实测 turnUsage,经 onLiveUsage 推给底栏实时 chip。
428
- // turnUsage 在闭包里被 addUsage 原地更新,reportLive 每次调用读最新值。
294
+ // 叠上已完成步的实测 turnUsage(traceState 内累加,每次调用读最新值),经 onLiveUsage 推给底栏实时 chip。
429
295
  let stepPromptEst = estimatePromptTokens(requestHistory, activeTools, runtimeContextState.correction);
430
- const reportLive = (p) => {
431
- // 当前步 prompt:末尾 usage chunk 到达后用实测,流式期间用估算(含校准)。
432
- // 当前步 cache 命中同理:上报即用实测;流式期间按前缀缓存估算 ≈ 上一步实测 prompt
433
- // (当前 prompt 总含其为前缀),不超过当前步 prompt;后端从不报 cache 时不估算。
434
- // 口径与轮末摘要一致:chip ↑ 显计费 prompt(裸 - cached),↓/↻ 同。
435
- const curPrompt = p.promptTokens ?? stepPromptEst;
436
- const curCached = p.cachedTokens ?? (providerCacheSeen
437
- ? Math.min(lastStepPromptTokens, curPrompt)
438
- : 0);
439
- hooks.onLiveUsage?.({
440
- promptTokens: (turnUsage?.promptTokens ?? 0) + curPrompt,
441
- completionTokens: (turnUsage?.completionTokens ?? 0) + p.completionTokens,
442
- totalTokens: (turnUsage?.totalTokens ?? 0) + curPrompt + p.completionTokens,
443
- cachedTokens: (turnUsage?.cachedTokens ?? 0) + curCached,
444
- });
445
- };
296
+ const reportLive = (p) => traceState.reportLive(hooks, stepPromptEst, lastStepPromptTokens, providerCacheSeen, p);
446
297
  reportLive({ completionTokens: 0 }); // 思考阶段先显 ↑ prompt 估算,首 token 到达后 ↓ 开始涨
447
298
  // 单一 chat 入口:错误侧的 model_end 埋点只写一处(重试也会记,不丢失败轨迹)。
448
299
  const chatHandlers = {
@@ -463,16 +314,14 @@ export async function runAgentCore(opts) {
463
314
  return await chat(requestHistory, chatHandlers, signal, activeTools);
464
315
  }
465
316
  catch (err) {
466
- const errorValue = err && typeof err === 'object'
467
- ? err
468
- : undefined;
317
+ const errorValue = err && typeof err === 'object' ? err : undefined;
469
318
  emitTrace('model_end', {
470
319
  model: requestModel,
471
320
  provider,
472
321
  status: signal?.aborted ? 'aborted' : 'error',
473
322
  code: typeof errorValue?.status === 'number'
474
323
  ? `HTTP_${errorValue.status}`
475
- : errorValue?.code ?? errorValue?.name ?? 'MODEL_ERROR',
324
+ : (errorValue?.code ?? errorValue?.name ?? 'MODEL_ERROR'),
476
325
  durationMs: Date.now() - modelStartedAt,
477
326
  });
478
327
  throw err;
@@ -484,19 +333,10 @@ export async function runAgentCore(opts) {
484
333
  catch (e) {
485
334
  // 中断(用户运行中 Ctrl+C):chat() 抛 AbortError(signal.aborted)→ 还原 history + 模式 + return(不抛)。
486
335
  // 工具执行现已串 signal:run_command/web_fetch 被 abort 即时杀,循环顶检查兜底(不会留未配对 tool_call_id)。
487
- if (signal?.aborted ||
488
- (e instanceof Error &&
489
- (e.name === 'AbortError' || e.name === 'APIUserAbortError'))) {
336
+ if (signal?.aborted || (e instanceof Error && (e.name === 'AbortError' || e.name === 'APIUserAbortError'))) {
490
337
  abortRestore();
491
338
  traceStatus = 'aborted';
492
- const mutation = getCurrentTurnMutationState();
493
- return {
494
- completed: false,
495
- terminationReason: 'aborted',
496
- finalText: null,
497
- usage: turnUsage,
498
- changedFiles: mutation.changedFiles.map((item) => item.path),
499
- };
339
+ return traceState.buildAbortedResult(ctx.getCurrentTurnMutationState());
500
340
  }
501
341
  // 后端实测拒绝了 prompt(上下文超长):本地估算对该 provider 系统性偏低时,
502
342
  // 压力线压不住,这是唯一可信的触发。强压一轮后重试一次;仍失败才抛(限一次,防循环)。
@@ -508,7 +348,7 @@ export async function runAgentCore(opts) {
508
348
  // 偶发误判会被后续真实 usage 样本拉回。
509
349
  const rawEstimate = estimatePromptTokens(requestHistory, activeTools);
510
350
  if (rawEstimate > 1_000) {
511
- const cal = updateTokenCalibration(requestBaseURL, requestModel, activeTools, rawEstimate, config.contextWindowTokens);
351
+ const cal = ctx.updateTokenCalibration(requestBaseURL, requestModel, activeTools, rawEstimate, ctx.config.contextWindowTokens);
512
352
  runtimeContextState.correction = cal.correction;
513
353
  runtimeContextState.calibrationSamples = cal.samples;
514
354
  }
@@ -551,7 +391,7 @@ export async function runAgentCore(opts) {
551
391
  reasoningTokens: result.usage?.reasoningTokens,
552
392
  });
553
393
  runtimeContextState.lastUsage = result.usage; // 供 /context 与状态行显示实测 token
554
- addUsage(result.usage); // 本轮累计:onDone 摘要行 + AgentRunResult.usage 透传
394
+ traceState.addUsage(result.usage); // 本轮累计:onDone 摘要行 + AgentRunResult.usage 透传
555
395
  if (result.usage) {
556
396
  lastStepPromptTokens = result.usage.promptTokens; // 下一步流式期 ↻ 估算的前缀基准
557
397
  if (result.usage.cachedTokens > 0)
@@ -561,7 +401,7 @@ export async function runAgentCore(opts) {
561
401
  // 只持久化比例与样本数;无 usage 或短 prompt 时保持既有值。
562
402
  if (result.usage?.promptTokens && result.usage.promptTokens > 100) {
563
403
  const estimated = estimatePromptTokens(requestHistory, activeTools);
564
- const updated = updateTokenCalibration(requestBaseURL, requestModel, activeTools, estimated, result.usage.promptTokens);
404
+ const updated = ctx.updateTokenCalibration(requestBaseURL, requestModel, activeTools, estimated, result.usage.promptTokens);
565
405
  runtimeContextState.correction = updated.correction;
566
406
  runtimeContextState.calibrationSamples = updated.samples;
567
407
  }
@@ -569,7 +409,7 @@ export async function runAgentCore(opts) {
569
409
  // lastUsage 已更新:触发状态行 context 用量条重算+重画,运行中不再冻结在轮首。
570
410
  onContextUpdate?.();
571
411
  if (result.toolCalls.length > 0) {
572
- toolCallCount += result.toolCalls.length;
412
+ traceState.toolCallCount += result.toolCalls.length;
573
413
  // 若 content 只是 Claude 式 "Tool results:" 噪声,清空它:不补换行、不写入 history,
574
414
  // 避免污染后续轮次上下文并在 TUI 泄露为孤立行。
575
415
  if (result.content && isToolResultsNoise(result.content)) {
@@ -594,7 +434,7 @@ export async function runAgentCore(opts) {
594
434
  // A+B:记录本步第一条 tool_result 的下标 + 执行前 notes.md 的 mtime,
595
435
  // 工具全部执行完后据此判断"本步是否改动了 notes.md"(重同步 / nag)。
596
436
  const toolResultStartIdx = history.length;
597
- const notesMtimeBefore = getNotesMtime();
437
+ const notesMtimeBefore = ctx.getNotesMtime();
598
438
  // Record interstitial narration for observability only. It never changes tool output
599
439
  // or injects instructions back into the model context.
600
440
  const narration = result.content?.trim() ?? '';
@@ -649,10 +489,104 @@ export async function runAgentCore(opts) {
649
489
  ...(tc.id ? { providerToolCallId: tc.id } : {}),
650
490
  });
651
491
  };
652
- let i = 0;
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;
653
587
  while (i < calls.length) {
654
588
  const currentCall = calls[i];
655
- if (getRuntimeDisabledTools().has(currentCall.name)) {
589
+ if (isToolDeniedForStep(currentCall.name)) {
656
590
  hooks.onToolHeader?.(currentCall);
657
591
  const error = t('task.disabled');
658
592
  const outcome = {
@@ -676,17 +610,21 @@ export async function runAgentCore(opts) {
676
610
  // 已经完成;若先 started.map,用户只能在工具完成后才看到摘要与其前面的换行。
677
611
  // 异步工具(web_fetch 等)并发跑、总耗时 ≈ 最慢一个;同步工具(glob/grep)map 时已顺序跑完,await 即返。
678
612
  let j = i;
679
- while (j < calls.length && isParallelTool(calls[j].name))
613
+ while (j < calls.length && isParallelTool(calls[j].name) && !isToolDeniedForStep(calls[j].name))
680
614
  j++;
681
615
  const batch = calls.slice(i, j);
682
616
  for (const tc of batch)
683
617
  hooks.onToolHeader?.(tc);
684
618
  hooks.onToolStart?.(batch[0].name);
685
- const started = batch.map((tc) => executeToolOutcome(tc.name, tc.arguments, signal, { callId: tc.id }));
619
+ const started = batch.map((tc) => executeToolOutcome(tc.name, tc.arguments, signal, {
620
+ callId: tc.id,
621
+ allowedToolNames: currentAllowedToolNames(),
622
+ delegation: delegationForOrchestrator(),
623
+ }));
686
624
  for (let k = 0; k < batch.length; k++) {
687
625
  const tc = batch[k];
688
626
  const outcome = await started[k];
689
- addToolUsage(outcome);
627
+ traceState.addUsage(outcome.usage);
690
628
  opts.onToolOutcome?.(tc.name, parseArgs(tc.arguments) ?? {}, outcome);
691
629
  traceToolEnd(tc, i + k, outcome);
692
630
  const output = outcome.output;
@@ -705,14 +643,14 @@ export async function runAgentCore(opts) {
705
643
  i = j;
706
644
  }
707
645
  else if (isResourceLockedCall(currentCall) &&
708
- !(getAgentMode() === 'plan' && getPlanDisabledTools().has(currentCall.name))) {
646
+ !(ctx.getAgentMode() === 'plan' && getPlanDisabledTools().has(currentCall.name))) {
709
647
  // 连续文件 mutation:权限确认仍严格按原序进行;全部 preflight 完成后再启动。
710
648
  // 每个执行在 registry 内按 canonical path 获取锁,不同文件可并发,同文件别名会排队。
711
649
  let j = i;
712
650
  while (j < calls.length &&
713
651
  isResourceLockedCall(calls[j]) &&
714
- !getRuntimeDisabledTools().has(calls[j].name) &&
715
- !(getAgentMode() === 'plan' && getPlanDisabledTools().has(calls[j].name)))
652
+ !isToolDeniedForStep(calls[j].name) &&
653
+ !(ctx.getAgentMode() === 'plan' && getPlanDisabledTools().has(calls[j].name)))
716
654
  j++;
717
655
  const batch = calls.slice(i, j);
718
656
  const entries = [];
@@ -720,9 +658,7 @@ export async function runAgentCore(opts) {
720
658
  const tc = batch[k];
721
659
  const parsed = parseArgs(tc.arguments);
722
660
  const tool = findTool(tc.name);
723
- const argumentsValid = tool && parsed !== null
724
- ? validateToolArguments(tool, parsed).valid
725
- : false;
661
+ const argumentsValid = tool && parsed !== null ? validateToolArguments(tool, parsed).valid : false;
726
662
  let denied;
727
663
  if (tool && argumentsValid) {
728
664
  const perm = await checkPermission(tool, parsed ?? {}, signal, {
@@ -758,6 +694,8 @@ export async function runAgentCore(opts) {
758
694
  const hint = argumentErrorHint(entry.tc.name, runtimeContextState);
759
695
  return executeToolOutcome(entry.tc.name, entry.tc.arguments, signal, {
760
696
  callId: entry.tc.id,
697
+ allowedToolNames: currentAllowedToolNames(),
698
+ delegation: delegationForOrchestrator(),
761
699
  ...(hint ? { argumentErrorHint: hint } : {}),
762
700
  onLockAcquired: (lockedArgs) => {
763
701
  entry.diff = readDiffContext(entry.tc, lockedArgs);
@@ -767,15 +705,12 @@ export async function runAgentCore(opts) {
767
705
  for (let k = 0; k < entries.length; k++) {
768
706
  const entry = entries[k];
769
707
  const outcome = await started[k];
770
- addToolUsage(outcome);
708
+ traceState.addUsage(outcome.usage);
771
709
  opts.onToolOutcome?.(entry.tc.name, entry.parsed ?? {}, outcome);
772
710
  traceToolEnd(entry.tc, i + k, outcome);
773
711
  hooks.onToolResult?.(entry.tc, outcome.output, entry.denied ? null : entry.parsed, entry.diff.preWriteOld, entry.diff.editStartLine);
774
712
  pushToolResult(history, entry.tc, outcome.output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
775
- const invalidatedFiles = [...new Set([
776
- ...(outcome.changedFiles ?? []),
777
- ...(outcome.staleFiles ?? []),
778
- ])];
713
+ const invalidatedFiles = [...new Set([...(outcome.changedFiles ?? []), ...(outcome.staleFiles ?? [])])];
779
714
  if (invalidatedFiles.length > 0) {
780
715
  for (const changedFile of invalidatedFiles) {
781
716
  relprune?.observeMutation(history, changedFile);
@@ -794,7 +729,7 @@ export async function runAgentCore(opts) {
794
729
  const tc = calls[i];
795
730
  // plan 模式防御 backstop:schema 已剔除这些工具,正常不会进这里;防后端幻觉调用——
796
731
  // 不执行,直接返错回灌(让模型看到「plan 模式禁用」并停止),绝不写盘 / 跑命令。
797
- if (getAgentMode() === 'plan' && getPlanDisabledTools().has(tc.name)) {
732
+ if (ctx.getAgentMode() === 'plan' && getPlanDisabledTools().has(tc.name)) {
798
733
  hooks.onToolHeader?.(tc);
799
734
  const err = `错误:计划模式下禁用工具 ${tc.name}(仅读探查,不改动文件 / 不跑命令)`;
800
735
  const outcome = {
@@ -815,9 +750,7 @@ export async function runAgentCore(opts) {
815
750
  // 拒绝时只渲染拒绝结果,不渲染执行头;放行则继续走 header → start → executeTool 流程。
816
751
  const parsed = parseArgs(tc.arguments);
817
752
  const tool = findTool(tc.name);
818
- const argumentsValid = tool && parsed !== null
819
- ? validateToolArguments(tool, parsed).valid
820
- : false;
753
+ const argumentsValid = tool && parsed !== null ? validateToolArguments(tool, parsed).valid : false;
821
754
  if (tool && argumentsValid) {
822
755
  const perm = await checkPermission(tool, parsed ?? {}, signal, {
823
756
  prompt: opts.permissionPrompt,
@@ -842,21 +775,21 @@ export async function runAgentCore(opts) {
842
775
  }
843
776
  }
844
777
  hooks.onToolHeader?.(tc);
845
- const mutationParsed = isMutationTool(tc.name)
846
- ? parsed
847
- : null;
778
+ const mutationParsed = isMutationTool(tc.name) ? parsed : null;
848
779
  let diff = readDiffContext(tc, mutationParsed);
849
780
  hooks.onToolStart?.(tc.name);
850
781
  const serialHint = argumentErrorHint(tc.name, runtimeContextState);
851
782
  const outcome = await executeToolOutcome(tc.name, tc.arguments, signal, {
852
783
  callId: tc.id,
784
+ allowedToolNames: currentAllowedToolNames(),
785
+ delegation: delegationForOrchestrator(),
853
786
  ...(serialHint ? { argumentErrorHint: serialHint } : {}),
854
787
  onLockAcquired: (lockedArgs) => {
855
788
  if (mutationParsed)
856
789
  diff = readDiffContext(tc, lockedArgs);
857
790
  },
858
791
  });
859
- addToolUsage(outcome);
792
+ traceState.addUsage(outcome.usage);
860
793
  opts.onToolOutcome?.(tc.name, parsed ?? {}, outcome);
861
794
  traceToolEnd(tc, i, outcome);
862
795
  const output = outcome.output;
@@ -871,10 +804,7 @@ export async function runAgentCore(opts) {
871
804
  }, tc.id ? { providerToolCallId: tc.id } : {});
872
805
  }
873
806
  pushToolResult(history, tc, output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
874
- const invalidatedFiles = [...new Set([
875
- ...(outcome.changedFiles ?? []),
876
- ...(outcome.staleFiles ?? []),
877
- ])];
807
+ const invalidatedFiles = [...new Set([...(outcome.changedFiles ?? []), ...(outcome.staleFiles ?? [])])];
878
808
  if (invalidatedFiles.length > 0) {
879
809
  for (const changedFile of invalidatedFiles) {
880
810
  relprune?.observeMutation(history, changedFile);
@@ -890,16 +820,19 @@ export async function runAgentCore(opts) {
890
820
  // 每步都会由 buildSessionStateReminder() 在 requestHistory 末尾重建最新副本(见上方注入点),
891
821
  // 所以模型下一步看到的必然是当前勾选态。只保留计数,避免多余的 history 改写(prompt 缓存)。
892
822
  // B(nag 提醒):连续 N 步有工具活动但没更新 plan,在当前步第一条 tool_result 前注入提醒。
893
- const notesMtimeAfter = getNotesMtime();
823
+ const notesMtimeAfter = ctx.getNotesMtime();
894
824
  if (notesMtimeAfter !== notesMtimeBefore) {
895
825
  stepsSincePlanTouch = 0;
896
826
  }
897
827
  else {
898
828
  stepsSincePlanTouch += 1;
899
829
  if (stepsSincePlanTouch >= PLAN_NAG_THRESHOLD) {
900
- const activePlan = extractActivePlanSection();
830
+ const activePlan = ctx.extractActivePlanSection();
901
831
  const firstToolMsg = history[toolResultStartIdx];
902
- if (activePlan && firstToolMsg && firstToolMsg.role === 'tool' && typeof firstToolMsg.content === 'string') {
832
+ if (activePlan &&
833
+ firstToolMsg &&
834
+ firstToolMsg.role === 'tool' &&
835
+ typeof firstToolMsg.content === 'string') {
903
836
  firstToolMsg.content = `${PLAN_NAG_TEXT}\n\n${firstToolMsg.content}`;
904
837
  }
905
838
  stepsSincePlanTouch = 0;
@@ -943,14 +876,14 @@ export async function runAgentCore(opts) {
943
876
  if (!gotText)
944
877
  hooks.onNoReply?.();
945
878
  history.push({ role: 'assistant', content: result.content });
946
- const finalMutation = getCurrentTurnMutationState();
879
+ const finalMutation = ctx.getCurrentTurnMutationState();
947
880
  done = true;
948
881
  traceStatus = 'completed';
949
882
  return {
950
883
  completed: true,
951
884
  terminationReason: 'completed',
952
885
  finalText: result.content,
953
- usage: turnUsage,
886
+ usage: traceState.turnUsage,
954
887
  changedFiles: finalMutation.changedFiles.map((item) => item.path),
955
888
  };
956
889
  }
@@ -964,24 +897,24 @@ export async function runAgentCore(opts) {
964
897
  hooks.onMaxSteps?.();
965
898
  done = true;
966
899
  traceStatus = 'max_steps';
967
- const finalMutation = getCurrentTurnMutationState();
900
+ const finalMutation = ctx.getCurrentTurnMutationState();
968
901
  return {
969
902
  completed: false,
970
903
  terminationReason: 'max_steps',
971
904
  finalText: null,
972
- usage: turnUsage,
905
+ usage: traceState.turnUsage,
973
906
  changedFiles: finalMutation.changedFiles.map((item) => item.path),
974
907
  };
975
908
  }
976
909
  finally {
977
- const finalMutation = getCurrentTurnMutationState();
978
- currentTraceStep = undefined;
910
+ const finalMutation = ctx.getCurrentTurnMutationState();
911
+ traceState.currentTraceStep = undefined;
979
912
  emitTrace('turn_end', {
980
913
  status: traceStatus,
981
914
  durationMs: Date.now() - t0,
982
- toolCalls: toolCallCount,
915
+ toolCalls: traceState.toolCallCount,
983
916
  changedFiles: finalMutation.changedFiles.map((item) => item.path),
984
- totalTokens: turnUsage?.totalTokens,
917
+ totalTokens: traceState.turnUsage?.totalTokens,
985
918
  });
986
919
  try {
987
920
  opts.onTrace?.({
@@ -990,9 +923,9 @@ export async function runAgentCore(opts) {
990
923
  turnId: traceTurnId,
991
924
  status: traceStatus,
992
925
  durationMs: Date.now() - t0,
993
- toolCalls: toolCallCount,
926
+ toolCalls: traceState.toolCallCount,
994
927
  changedFiles: finalMutation.changedFiles.map((item) => item.path),
995
- usage: turnUsage,
928
+ usage: traceState.turnUsage,
996
929
  });
997
930
  }
998
931
  catch {
@@ -1000,7 +933,7 @@ export async function runAgentCore(opts) {
1000
933
  }
1001
934
  // 跑完(正常 / 达上限)在回复末尾打耗时摘要行;中断 done=false 不打。
1002
935
  if (done) {
1003
- hooks.onDone?.(Date.now() - t0, turnUsage);
936
+ hooks.onDone?.(Date.now() - t0, traceState.turnUsage);
1004
937
  }
1005
938
  }
1006
939
  }