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.
Files changed (47) hide show
  1. package/README.md +13 -1
  2. package/dist/agent/core.js +14 -936
  3. package/dist/agent/index.js +37 -13
  4. package/dist/agent/model-turn.js +218 -0
  5. package/dist/agent/pipeline.js +18 -0
  6. package/dist/agent/run-contracts.js +1 -0
  7. package/dist/agent/run-coordinator.js +758 -0
  8. package/dist/agent/runtime-context.js +118 -24
  9. package/dist/agent/spawn.js +11 -7
  10. package/dist/agent/stages/context-trimmer.js +63 -0
  11. package/dist/agent/stages/contracts.js +12 -0
  12. package/dist/agent/stages/history-manager.js +178 -0
  13. package/dist/agent/stages/legacy-adapters.js +19 -0
  14. package/dist/agent/stages/model-runner.js +29 -0
  15. package/dist/agent/stages/run-policy.js +73 -0
  16. package/dist/agent/stages/tool-dispatcher.js +341 -0
  17. package/dist/agent/tool-helpers.js +12 -12
  18. package/dist/agent/tool-turn.js +87 -0
  19. package/dist/agent/trace-state.js +97 -101
  20. package/dist/agent/turn-lifecycle.js +110 -0
  21. package/dist/config/index.js +14 -0
  22. package/dist/host/stdio.js +101 -40
  23. package/dist/llm/index.js +51 -35
  24. package/dist/llm/providers/anthropic.js +16 -10
  25. package/dist/llm/runtime.js +1 -0
  26. package/dist/permissions/index.js +21 -5
  27. package/dist/repl/commands/compact.js +2 -2
  28. package/dist/repl/commands/session.js +3 -12
  29. package/dist/repl/message-format.js +5 -0
  30. package/dist/repl/runtime.js +95 -55
  31. package/dist/rollback/index.js +29 -624
  32. package/dist/rollback/store.js +593 -0
  33. package/dist/runtime/index.js +1 -0
  34. package/dist/runtime/runtime.js +307 -0
  35. package/dist/session/compact.js +22 -14
  36. package/dist/session/index.js +1 -0
  37. package/dist/session/persist.js +10 -146
  38. package/dist/session/scheduler.js +28 -16
  39. package/dist/session/state.js +16 -12
  40. package/dist/session/store.js +218 -0
  41. package/dist/session/trace.js +5 -15
  42. package/dist/tools/policy.js +19 -15
  43. package/dist/tools/registry.js +21 -229
  44. package/dist/tools/router.js +5 -3
  45. package/dist/tools/tool-runtime.js +267 -0
  46. package/dist/ui/layout-internal/content-write.js +4 -0
  47. package/package.json +7 -3
@@ -0,0 +1,758 @@
1
+ // agent 核心循环(纯逻辑,无 TUI 依赖):流式 chat → 工具执行 → 回灌。
2
+ // 所有展示副作用经 AgentHooks 注入——主 agent 注入 TUI 渲染(layout + spinner + diff),
3
+ // 子 agent 注入静默/摘要 hooks(不写屏)。逻辑层共享,避免重复实现循环 / 分组 / abort 还原。
4
+ //
5
+ // 与 index.ts 的关系:index.ts 的 runAgent = runAgentCore + TUI hooks 薄封装(行为不变)。
6
+ // spawn.ts 的 spawnAgent = runAgentCore + 静默 hooks(子 agent)。
7
+ import { validateToolArguments } from '../tools/validation.js';
8
+ import { getPlanDisabledTools, getRuntimeDisabledTools, getSkillRuntimeDisabledTools } from '../tools/constants.js';
9
+ import { ADD_TOOL_GROUPS_TOOL_NAME } from '../config/profiles.js';
10
+ import { defaultAgentRuntimeContext } from './runtime-context.js';
11
+ import { runModelTurn } from './model-turn.js';
12
+ import { runToolTurn } from './tool-turn.js';
13
+ import { createTurnLifecycle } from './turn-lifecycle.js';
14
+ import { parseArgs, argumentErrorHint, isParallelTool, isResourceLockedCall, deniedOutcome, readDiffContext, pushToolResult, } from './tool-helpers.js';
15
+ import { contextState, summarizeToolArguments } from '../session/index.js';
16
+ import { createBudgetScheduler } from '../session/scheduler.js';
17
+ import { invalidateArtifacts, rehydrateArtifacts } from '../context/index.js';
18
+ import { createRelevancePruner } from '../context/relevance.js';
19
+ import { t } from '../i18n/index.js';
20
+ import { createLifecycleEngine } from '../context/lifecycle.js';
21
+ import { createLegacyModelRunner, createStagedModelRunner } from './stages/model-runner.js';
22
+ import { createLegacyContextTrimmer, createStagedContextTrimmer } from './stages/context-trimmer.js';
23
+ import { createLegacyToolDispatcher, createStagedToolDispatcher } from './stages/tool-dispatcher.js';
24
+ import { createLegacyCapabilityResolver, createLegacyTerminationPolicy, createStagedCapabilityResolver, createStagedTerminationPolicy, } from './stages/run-policy.js';
25
+ // 工具辅助纯函数(parseArgs / argumentErrorHint / isToolResultsNoise / isParallelTool /
26
+ // isResourceLockedTool / isResourceLockedCall / deniedOutcome / readDiffContext / pushToolResult)
27
+ // 已提取至 ./tool-helpers.ts——它们不依赖本循环的局部状态,只接受显式参数,故可安全模块化。
28
+ /**
29
+ * agent 核心循环(纯逻辑):
30
+ * 流式调 LLM(经 hooks.onText 实时渲染)→ 有 tool_calls 就分组执行并回灌
31
+ * → 否则流式正文即最终回复。history 在调用间持久,由调用方持有。
32
+ * 步前由 session scheduler 检查真实 context pressure;达到 80% 时统一清理并压缩历史。
33
+ * 工具结果正常只经 capToolResultForHistory 的单条 hard safety cap。
34
+ *
35
+ * 中断语义:signal 经 executeTool(name, args, signal) 串进工具;run_command/web_fetch 等 abort 即时杀
36
+ * (树杀子进程 / 取消 fetch),循环顶 if(signal.aborted) 兜底还原。不会留下未配对的 tool_call_id。
37
+ * abort 时 history 还原到本 turn 前(savedHistory 浅拷贝),模式还原,调 hooks.onAbort。
38
+ *
39
+ * 所有展示副作用经 hooks 注入;core 自身不直接调 layout / spinner(不依赖 ui/layout.ts)。
40
+ * 但 core 仍依赖 ui/render.ts 的纯函数(summarizeToolCall / truncateDisplay / fmtElapsed)——
41
+ * 这些是纯字符串格式化,无副作用,共享安全。
42
+ */
43
+ export async function runAgentCoreLegacy(opts, historyManager, stages) {
44
+ const { history, userInput, signal, hooks } = opts;
45
+ const ctx = opts.runtimeContext ?? defaultAgentRuntimeContext;
46
+ const runtimeToolSchemas = ctx.toolRuntime.tools.map((tool) => ({
47
+ type: 'function',
48
+ function: {
49
+ name: tool.name,
50
+ description: tool.description,
51
+ parameters: tool.parameters,
52
+ },
53
+ }));
54
+ const planDisabledTools = getPlanDisabledTools();
55
+ const runtimePlanToolSchemas = runtimeToolSchemas.filter((tool) => !tool.function.name.startsWith('mcp__') && !planDisabledTools.has(tool.function.name));
56
+ const runtimeContextState = opts.contextState ?? contextState;
57
+ /** 本轮 ask_human 成功调用次数,仅用于 trace 观测,不影响工具执行或模型上下文。 */
58
+ let askHumanCountThisTurn = 0;
59
+ const maxSteps = opts.maxSteps ?? ctx.config.maxSteps;
60
+ // 中断还原:repl 的 /plan / /auto / Shift+Tab 等用户面触发 setAgentMode 中途切了模式,
61
+ // abort 时连同模式一起还原回轮首。模型不再持有 switch_mode 工具,无法自切。
62
+ const savedMode = ctx.getAgentMode();
63
+ const turnLifecycle = createTurnLifecycle(opts, ctx, stages, savedMode);
64
+ const { usageMeter, emitTrace, traceTurnId } = turnLifecycle;
65
+ const toolTurnPlanState = { stepsSincePlanTouch: 0 };
66
+ historyManager.appendUserTurn(userInput);
67
+ // The initial cancellation checkpoint is captured after the user turn and before any model/tool work.
68
+ // Relevance and lifecycle collect provenance during normal work. Neither path
69
+ // rewrites history; exact supersession is applied only by the pressure scheduler.
70
+ const relprune = ctx.config.contextRelprune ? createRelevancePruner() : null;
71
+ let lifecycle = ctx.config.contextLifecycle ? createLifecycleEngine(history) : null;
72
+ runtimeContextState.lifecycleStats = lifecycle?.stats();
73
+ rehydrateArtifacts(runtimeContextState, history);
74
+ // The scheduler is the sole automatic history-rewrite entry point. It runs
75
+ // superseded → stale artifact → old logs/search → compact at real pressure.
76
+ // contextBudget=false keeps only the infrastructure compact fallback.
77
+ const scheduler = ctx.config.contextBudget !== false ? createBudgetScheduler(runtimeContextState, ctx) : null;
78
+ const modelRunner = stages.model.implementation === 'staged'
79
+ ? createStagedModelRunner(ctx.modelTransport)
80
+ : createLegacyModelRunner(ctx.modelTransport);
81
+ const dispatcherDependencies = {
82
+ toolRuntime: ctx.toolRuntime,
83
+ checkPermission: ctx.checkPermission,
84
+ jailResolve: ctx.jailResolve,
85
+ };
86
+ const toolDispatcher = stages.tools.implementation === 'staged'
87
+ ? createStagedToolDispatcher(dispatcherDependencies)
88
+ : createLegacyToolDispatcher(dispatcherDependencies);
89
+ const capabilityResolver = stages.capabilities.implementation === 'staged'
90
+ ? createStagedCapabilityResolver()
91
+ : createLegacyCapabilityResolver();
92
+ const terminationPolicy = stages.termination.implementation === 'staged' ? createStagedTerminationPolicy() : createLegacyTerminationPolicy();
93
+ const trimmerInit = { historyManager, scheduler, contextState: runtimeContextState, runtime: ctx };
94
+ const contextTrimmer = stages.context.implementation === 'staged'
95
+ ? createStagedContextTrimmer(trimmerInit)
96
+ : createLegacyContextTrimmer(trimmerInit);
97
+ const rebuildHistoryIndexes = () => {
98
+ if (lifecycle) {
99
+ lifecycle = createLifecycleEngine(history);
100
+ runtimeContextState.lifecycleStats = lifecycle.stats();
101
+ }
102
+ rehydrateArtifacts(runtimeContextState, history);
103
+ };
104
+ const modelCacheState = { lastStepPromptTokens: 0, providerCacheSeen: false };
105
+ const cancellationLifecycle = turnLifecycle.createCancellation(historyManager, rebuildHistoryIndexes);
106
+ cancellationLifecycle.checkpoint();
107
+ try {
108
+ for (let step = 0; step < maxSteps; step++) {
109
+ turnLifecycle.setCurrentStep(step);
110
+ const stepStartedAt = Date.now();
111
+ emitTrace('step_start', { ordinal: step });
112
+ try {
113
+ // 上一步工具被 abort 杀(run_command/web_fetch 等)→ signal.aborted,直接还原退出,不等 maybeCompact + chat()
114
+ const startDecision = terminationPolicy.decide({
115
+ phase: 'step_start',
116
+ step,
117
+ maxSteps,
118
+ aborted: signal?.aborted === true,
119
+ });
120
+ if (startDecision.kind === 'aborted') {
121
+ cancellationLifecycle.restore();
122
+ turnLifecycle.markAborted();
123
+ return turnLifecycle.buildAbortedResult();
124
+ }
125
+ // 本步只捕获一次不可变 policy snapshot。即便 add_tool_groups 在执行阶段扩容,
126
+ // 本次模型响应仍必须按旧 snapshot 校验;新工具只在下一 step 的 schema 中出现。
127
+ const planMode = ctx.getAgentMode() === 'plan';
128
+ const policySnapshot = opts.toolPolicy?.snapshot(planMode);
129
+ const runPolicy = capabilityResolver.resolve({
130
+ mode: ctx.getAgentMode(),
131
+ toolsOverride: opts.toolsOverride,
132
+ toolPolicy: policySnapshot,
133
+ defaultTools: planMode ? runtimePlanToolSchemas : runtimeToolSchemas,
134
+ runtimeAllowedToolNames: opts.runtimeAllowedToolNames,
135
+ skillDisabledToolNames: getSkillRuntimeDisabledTools(),
136
+ legacyDisabledToolNames: getRuntimeDisabledTools(),
137
+ useLegacyDisabledFallback: !opts.toolPolicy && !opts.runtimeAllowedToolNames,
138
+ reminder: opts.toolPolicy?.reminder(planMode) ?? '',
139
+ });
140
+ // schema、runtime backstop 与后代权限都从同一 effective allow-list 派生。
141
+ // policy snapshot 是本 step 的不可扩张上限;skill deny 可在同批 use_skill 后继续动态收窄。
142
+ const activeTools = runPolicy.tools.slice();
143
+ const stepAllowedNames = runPolicy.allowedToolNames;
144
+ const currentAllowedToolNames = () => {
145
+ const currentSkillDisabledTools = getSkillRuntimeDisabledTools();
146
+ return [...stepAllowedNames].filter((name) => !currentSkillDisabledTools.has(name));
147
+ };
148
+ const isToolDeniedForStep = (name) => !stepAllowedNames.has(name) || getSkillRuntimeDisabledTools().has(name);
149
+ // 委派给编排工具(sub-agent/run_skill)的父前缀快照:去掉历史末尾「产生本次调用的
150
+ // assistant tool_call 消息」(协议上它必须紧跟 tool_result,不能出现在子 history),
151
+ // 只保留其前的主前缀。子 agent 以它为前缀、尾部追加委派消息 → 与主 agent 已发送
152
+ // 前缀逐字节一致,命中前缀缓存。tools 直接用本步 activeTools:子 agent 与主 agent
153
+ // 同权同 schema,不做任何裁剪,也没有额外的执行层禁用集合。
154
+ const delegationForOrchestrator = () => {
155
+ let k = history.length - 1;
156
+ while (k > 0) {
157
+ const m = history[k];
158
+ if (m.role === 'assistant' && Array.isArray(m.tool_calls))
159
+ break;
160
+ k--;
161
+ }
162
+ return { history: history.slice(0, k > 0 ? k : history.length), tools: activeTools };
163
+ };
164
+ const modelTurn = await runModelTurn({
165
+ opts,
166
+ ctx,
167
+ history,
168
+ historyManager,
169
+ runtimeContextState,
170
+ scheduler,
171
+ contextTrimmer,
172
+ modelRunner,
173
+ activeTools,
174
+ runPolicy,
175
+ step,
176
+ cacheState: modelCacheState,
177
+ turnLifecycle,
178
+ cancellationLifecycle,
179
+ rebuildHistoryIndexes,
180
+ });
181
+ if (modelTurn.kind === 'aborted')
182
+ return modelTurn.result;
183
+ const { result, stream } = modelTurn;
184
+ const { mode, gotText, lastChar } = stream;
185
+ const modelDecision = terminationPolicy.decide({
186
+ phase: 'model_result',
187
+ step,
188
+ maxSteps,
189
+ aborted: signal?.aborted === true,
190
+ modelResult: result,
191
+ });
192
+ if (modelDecision.kind === 'continue') {
193
+ await runToolTurn({
194
+ opts,
195
+ ctx,
196
+ historyManager,
197
+ result,
198
+ stream,
199
+ step,
200
+ maxSteps,
201
+ planState: toolTurnPlanState,
202
+ turnLifecycle,
203
+ cancellationLifecycle,
204
+ terminationPolicy,
205
+ rebuildHistoryIndexes,
206
+ dispatch: async (history, modelAttachments) => {
207
+ // 工具分组执行(保 tool_calls 原顺序):safe parallel 工具照常并发;连续
208
+ // resource-locked mutation 先按序完成权限预检,再按 canonical resource lock 启动。
209
+ // registry 对所有真实资源访问统一持锁,所以不同 Agent 间的 read/write/process 也不会竞态。
210
+ // 串行工具仍是本调用列表内的屏障;渲染/history 回灌始终按原 tool_calls 顺序。
211
+ // executeToolOutcome 永不抛错,失败通过结构化 status/code 返回。
212
+ if (stages.tools.implementation === 'staged') {
213
+ const handleDispatchEvent = (event) => {
214
+ switch (event.type) {
215
+ case 'call_start': {
216
+ const toolCallId = `${traceTurnId}:step:${step}:tool:${event.callIndex}`;
217
+ emitTrace('tool_call_start', {
218
+ tool: event.call.name,
219
+ argumentHash: event.argumentSummary.sha256,
220
+ arguments: event.argumentSummary,
221
+ }, {
222
+ toolCallId,
223
+ ...(event.call.id ? { providerToolCallId: event.call.id } : {}),
224
+ });
225
+ break;
226
+ }
227
+ case 'permission': {
228
+ const args = summarizeToolArguments(event.call.arguments);
229
+ emitTrace('permission', {
230
+ source: 'agent_tool',
231
+ tool: event.call.name,
232
+ decision: event.decision,
233
+ argumentHash: args.sha256,
234
+ }, {
235
+ toolCallId: `${traceTurnId}:step:${step}:tool:${event.callIndex}`,
236
+ ...(event.call.id ? { providerToolCallId: event.call.id } : {}),
237
+ });
238
+ break;
239
+ }
240
+ case 'route_expand':
241
+ emitTrace('tool_route_expand', {
242
+ policyId: event.expansion.snapshot.id,
243
+ fromVersion: event.fromVersion,
244
+ toVersion: event.expansion.snapshot.version,
245
+ requestedGroups: event.requestedGroups.map(String),
246
+ addedGroups: event.expansion.added,
247
+ rejected: event.expansion.rejected,
248
+ reason: event.reason,
249
+ status: event.status,
250
+ });
251
+ break;
252
+ case 'header':
253
+ hooks.onToolHeader?.(event.call);
254
+ break;
255
+ case 'start':
256
+ hooks.onToolStart?.(event.tool);
257
+ break;
258
+ case 'done':
259
+ hooks.onToolDone?.();
260
+ break;
261
+ case 'usage':
262
+ usageMeter.add(event.usage);
263
+ break;
264
+ case 'host_outcome':
265
+ opts.onToolOutcome?.(event.call.name, event.parsed, event.outcome);
266
+ break;
267
+ case 'trace_end': {
268
+ const { outcome, call } = event;
269
+ emitTrace('tool_call_end', {
270
+ tool: call.name,
271
+ argumentHash: event.argumentSummary.sha256,
272
+ status: outcome.status,
273
+ code: outcome.code,
274
+ retryable: outcome.retryable,
275
+ durationMs: outcome.durationMs ?? 0,
276
+ changedFiles: outcome.changedFiles ?? [],
277
+ staleFiles: outcome.staleFiles ?? [],
278
+ ...(outcome.changeSet ? { changeSet: outcome.changeSet } : {}),
279
+ ...(outcome.usage ? { nestedUsage: outcome.usage } : {}),
280
+ }, {
281
+ toolCallId: `${traceTurnId}:step:${step}:tool:${event.callIndex}`,
282
+ ...(call.id ? { providerToolCallId: call.id } : {}),
283
+ });
284
+ break;
285
+ }
286
+ case 'result':
287
+ hooks.onToolResult?.(event.call, event.outcome.output, event.parsed, event.diff.preWriteOld, event.diff.editStartLine);
288
+ if (event.call.name === 'ask_human' && event.outcome.status === 'success') {
289
+ askHumanCountThisTurn += 1;
290
+ emitTrace('ask_human_call', {
291
+ tool: event.call.name,
292
+ status: event.outcome.status,
293
+ perTurnCount: askHumanCountThisTurn,
294
+ }, event.call.id ? { providerToolCallId: event.call.id } : {});
295
+ }
296
+ if (event.includeContextState) {
297
+ pushToolResult(history, event.call, event.outcome.output, relprune, lifecycle, scheduler, runtimeContextState, event.succeeded);
298
+ }
299
+ else {
300
+ pushToolResult(history, event.call, event.outcome.output, relprune, lifecycle, scheduler);
301
+ }
302
+ break;
303
+ case 'invalidate':
304
+ for (const changedFile of event.files) {
305
+ relprune?.observeMutation(history, changedFile);
306
+ lifecycle?.pushMutation(history, history.length - 1, changedFile);
307
+ }
308
+ invalidateArtifacts(runtimeContextState, history, event.files);
309
+ runtimeContextState.lifecycleStats = lifecycle?.stats();
310
+ break;
311
+ }
312
+ };
313
+ const dispatchResult = await toolDispatcher.dispatch({
314
+ calls: result.toolCalls,
315
+ policy: runPolicy,
316
+ signal,
317
+ permissionPrompt: opts.permissionPrompt,
318
+ isDenied: isToolDeniedForStep,
319
+ currentAllowedToolNames,
320
+ delegation: delegationForOrchestrator,
321
+ argumentErrorHint: (name) => argumentErrorHint(name, runtimeContextState),
322
+ ...(opts.toolPolicy
323
+ ? {
324
+ expandToolGroups: (groups, reason) => opts.toolPolicy.expand(groups, reason),
325
+ }
326
+ : {}),
327
+ onEvent: handleDispatchEvent,
328
+ });
329
+ modelAttachments.push(...dispatchResult.modelAttachments);
330
+ }
331
+ else {
332
+ const calls = result.toolCalls;
333
+ const tracedCalls = calls.map((tc, index) => ({
334
+ toolCallId: `${traceTurnId}:step:${step}:tool:${index}`,
335
+ args: summarizeToolArguments(tc.arguments),
336
+ }));
337
+ for (let index = 0; index < calls.length; index++) {
338
+ const tc = calls[index];
339
+ const traceCall = tracedCalls[index];
340
+ emitTrace('tool_call_start', {
341
+ tool: tc.name,
342
+ argumentHash: traceCall.args.sha256,
343
+ arguments: traceCall.args,
344
+ }, {
345
+ toolCallId: traceCall.toolCallId,
346
+ ...(tc.id ? { providerToolCallId: tc.id } : {}),
347
+ });
348
+ }
349
+ const traceToolEnd = (tc, index, outcome) => {
350
+ if (outcome.status === 'success' && outcome.modelAttachments?.length) {
351
+ modelAttachments.push(...outcome.modelAttachments);
352
+ }
353
+ const traceCall = tracedCalls[index];
354
+ emitTrace('tool_call_end', {
355
+ tool: tc.name,
356
+ argumentHash: traceCall.args.sha256,
357
+ status: outcome.status,
358
+ code: outcome.code,
359
+ retryable: outcome.retryable,
360
+ durationMs: outcome.durationMs ?? 0,
361
+ changedFiles: outcome.changedFiles ?? [],
362
+ staleFiles: outcome.staleFiles ?? [],
363
+ ...(outcome.changeSet ? { changeSet: outcome.changeSet } : {}),
364
+ ...(outcome.usage ? { nestedUsage: outcome.usage } : {}),
365
+ }, {
366
+ toolCallId: traceCall.toolCallId,
367
+ ...(tc.id ? { providerToolCallId: tc.id } : {}),
368
+ });
369
+ };
370
+ const hasToolRouteBarrier = calls.some((tc) => tc.name === ADD_TOOL_GROUPS_TOOL_NAME);
371
+ if (hasToolRouteBarrier) {
372
+ const mixedCall = calls.length !== 1;
373
+ for (let index = 0; index < calls.length; index++) {
374
+ const tc = calls[index];
375
+ hooks.onToolHeader?.(tc);
376
+ const parsed = parseArgs(tc.arguments);
377
+ let outcome;
378
+ if (mixedCall) {
379
+ const isControl = tc.name === ADD_TOOL_GROUPS_TOOL_NAME;
380
+ outcome = {
381
+ status: 'denied',
382
+ code: isControl ? 'INVALID_ARGUMENTS' : 'TOOL_DISABLED',
383
+ retryable: false,
384
+ output: isControl
385
+ ? '错误:add_tool_groups 必须在一个独立的 model step 中单独调用;本次没有扩容。'
386
+ : `错误:同一响应包含 add_tool_groups,工具 ${tc.name} 未执行。请等待扩容结果后在下一 step 重试。`,
387
+ changedFiles: [],
388
+ durationMs: 0,
389
+ };
390
+ }
391
+ else if (isToolDeniedForStep(tc.name)) {
392
+ outcome = {
393
+ status: 'denied',
394
+ code: 'TOOL_DISABLED',
395
+ retryable: false,
396
+ output: `错误:当前 tool policy snapshot 不允许调用 ${tc.name}。`,
397
+ changedFiles: [],
398
+ durationMs: 0,
399
+ };
400
+ }
401
+ else if (!opts.toolPolicy) {
402
+ outcome = {
403
+ status: 'denied',
404
+ code: 'TOOL_DISABLED',
405
+ retryable: false,
406
+ output: '错误:当前 Agent 未启用动态工具策略,无法调用 add_tool_groups。',
407
+ changedFiles: [],
408
+ durationMs: 0,
409
+ };
410
+ }
411
+ else if (!parsed ||
412
+ !Array.isArray(parsed.groups) ||
413
+ parsed.groups.length === 0 ||
414
+ typeof parsed.reason !== 'string' ||
415
+ !parsed.reason.trim()) {
416
+ outcome = {
417
+ status: 'error',
418
+ code: 'INVALID_ARGUMENTS',
419
+ retryable: false,
420
+ output: '错误:add_tool_groups 需要非空 groups 数组和非空 reason。',
421
+ changedFiles: [],
422
+ durationMs: 0,
423
+ };
424
+ }
425
+ else {
426
+ const expansion = opts.toolPolicy.expand(parsed.groups, parsed.reason);
427
+ const succeeded = expansion.added.length > 0;
428
+ const details = [
429
+ succeeded
430
+ ? `Tool policy expanded to v${expansion.snapshot.version}; added groups: ${expansion.added.join(', ')}.`
431
+ : `Tool policy was not expanded (still v${expansion.snapshot.version}).`,
432
+ expansion.rejected.length > 0 ? `Rejected: ${expansion.rejected.join('; ')}.` : '',
433
+ succeeded ? 'The added tool schemas become available on the next model step.' : '',
434
+ ]
435
+ .filter(Boolean)
436
+ .join('\n');
437
+ outcome = {
438
+ status: succeeded ? 'success' : 'error',
439
+ code: succeeded ? 'OK' : 'INVALID_ARGUMENTS',
440
+ retryable: false,
441
+ output: details,
442
+ changedFiles: [],
443
+ durationMs: 0,
444
+ };
445
+ emitTrace('tool_route_expand', {
446
+ policyId: expansion.snapshot.id,
447
+ fromVersion: policySnapshot?.version,
448
+ toVersion: expansion.snapshot.version,
449
+ requestedGroups: parsed.groups.map(String),
450
+ addedGroups: expansion.added,
451
+ rejected: expansion.rejected,
452
+ reason: parsed.reason,
453
+ status: outcome.status,
454
+ });
455
+ }
456
+ opts.onToolOutcome?.(tc.name, parsed ?? {}, outcome);
457
+ hooks.onToolResult?.(tc, outcome.output, null, null, 1);
458
+ pushToolResult(history, tc, outcome.output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
459
+ traceToolEnd(tc, index, outcome);
460
+ }
461
+ }
462
+ // add_tool_groups 是 step 屏障:只要本响应出现该控制调用,本批所有普通工具都不执行。
463
+ // 但上面仍为每个 provider tool_call 写入了配对 tool_result,保持 OpenAI 协议完整。
464
+ let i = hasToolRouteBarrier ? calls.length : 0;
465
+ while (i < calls.length) {
466
+ const currentCall = calls[i];
467
+ if (isToolDeniedForStep(currentCall.name)) {
468
+ hooks.onToolHeader?.(currentCall);
469
+ const error = t('task.disabled');
470
+ const outcome = {
471
+ status: 'denied',
472
+ code: 'TOOL_DISABLED',
473
+ retryable: false,
474
+ output: error,
475
+ changedFiles: [],
476
+ durationMs: 0,
477
+ };
478
+ hooks.onToolResult?.(currentCall, error, null, null, 1);
479
+ pushToolResult(history, currentCall, error, relprune, lifecycle, scheduler, runtimeContextState, false);
480
+ traceToolEnd(currentCall, i, outcome);
481
+ i++;
482
+ continue;
483
+ }
484
+ if (isParallelTool(currentCall.name, ctx.toolRuntime)) {
485
+ // 收集连续只读组(≥1),并发执行:先渲染所有 header,再一次性启动所有
486
+ // (executeTool 调用即开始 I/O),最后按原顺序逐个 await + 回灌。
487
+ // 必须先 header 后 execute:grep 等同步快速工具会在 executeTool 返回 Promise 前
488
+ // 已经完成;若先 started.map,用户只能在工具完成后才看到摘要与其前面的换行。
489
+ // 异步工具(web_fetch 等)并发跑、总耗时 ≈ 最慢一个;同步工具(glob/grep)map 时已顺序跑完,await 即返。
490
+ let j = i;
491
+ while (j < calls.length &&
492
+ isParallelTool(calls[j].name, ctx.toolRuntime) &&
493
+ !isToolDeniedForStep(calls[j].name))
494
+ j++;
495
+ const batch = calls.slice(i, j);
496
+ for (const tc of batch)
497
+ hooks.onToolHeader?.(tc);
498
+ hooks.onToolStart?.(batch[0].name);
499
+ const started = batch.map((tc) => ctx.toolRuntime.executeToolOutcome(tc.name, tc.arguments, signal, {
500
+ callId: tc.id,
501
+ allowedToolNames: currentAllowedToolNames(),
502
+ delegation: delegationForOrchestrator(),
503
+ }));
504
+ for (let k = 0; k < batch.length; k++) {
505
+ const tc = batch[k];
506
+ const outcome = await started[k];
507
+ usageMeter.add(outcome.usage);
508
+ opts.onToolOutcome?.(tc.name, parseArgs(tc.arguments) ?? {}, outcome);
509
+ traceToolEnd(tc, i + k, outcome);
510
+ const output = outcome.output;
511
+ hooks.onToolResult?.(tc, output, null, null, 1); // 并行工具无 diff
512
+ if (tc.name === 'ask_human' && outcome.status === 'success') {
513
+ askHumanCountThisTurn += 1;
514
+ emitTrace('ask_human_call', {
515
+ tool: tc.name,
516
+ status: outcome.status,
517
+ perTurnCount: askHumanCountThisTurn,
518
+ }, tc.id ? { providerToolCallId: tc.id } : {});
519
+ }
520
+ pushToolResult(history, tc, output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
521
+ }
522
+ hooks.onToolDone?.();
523
+ i = j;
524
+ }
525
+ else if (isResourceLockedCall(currentCall, ctx.toolRuntime) &&
526
+ !(ctx.getAgentMode() === 'plan' && planDisabledTools.has(currentCall.name))) {
527
+ // 连续文件 mutation:权限确认仍严格按原序进行;全部 preflight 完成后再启动。
528
+ // 每个执行在 registry 内按 canonical path 获取锁,不同文件可并发,同文件别名会排队。
529
+ let j = i;
530
+ while (j < calls.length &&
531
+ isResourceLockedCall(calls[j], ctx.toolRuntime) &&
532
+ !isToolDeniedForStep(calls[j].name) &&
533
+ !(ctx.getAgentMode() === 'plan' && planDisabledTools.has(calls[j].name)))
534
+ j++;
535
+ const batch = calls.slice(i, j);
536
+ const entries = [];
537
+ for (let k = 0; k < batch.length; k++) {
538
+ const tc = batch[k];
539
+ const parsed = parseArgs(tc.arguments);
540
+ const tool = ctx.toolRuntime.findTool(tc.name);
541
+ const argumentsValid = tool && parsed !== null ? validateToolArguments(tool, parsed).valid : false;
542
+ let denied;
543
+ if (tool && argumentsValid) {
544
+ const perm = await ctx.checkPermission(tool, parsed ?? {}, signal, {
545
+ prompt: opts.permissionPrompt,
546
+ });
547
+ emitTrace('permission', {
548
+ source: 'agent_tool',
549
+ tool: tc.name,
550
+ decision: perm,
551
+ argumentHash: tracedCalls[i + k].args.sha256,
552
+ }, {
553
+ toolCallId: tracedCalls[i + k].toolCallId,
554
+ ...(tc.id ? { providerToolCallId: tc.id } : {}),
555
+ });
556
+ if (perm === 'deny')
557
+ denied = deniedOutcome(tc.name);
558
+ }
559
+ entries.push({
560
+ tc,
561
+ parsed,
562
+ diff: { preWriteOld: null, editStartLine: 1 },
563
+ ...(denied ? { denied } : {}),
564
+ });
565
+ }
566
+ for (const entry of entries)
567
+ hooks.onToolHeader?.(entry.tc);
568
+ const firstAllowed = entries.find((entry) => !entry.denied);
569
+ if (firstAllowed)
570
+ hooks.onToolStart?.(firstAllowed.tc.name);
571
+ const started = entries.map((entry) => {
572
+ if (entry.denied)
573
+ return Promise.resolve(entry.denied);
574
+ const hint = argumentErrorHint(entry.tc.name, runtimeContextState);
575
+ return ctx.toolRuntime.executeToolOutcome(entry.tc.name, entry.tc.arguments, signal, {
576
+ callId: entry.tc.id,
577
+ allowedToolNames: currentAllowedToolNames(),
578
+ delegation: delegationForOrchestrator(),
579
+ ...(hint ? { argumentErrorHint: hint } : {}),
580
+ onLockAcquired: (lockedArgs) => {
581
+ entry.diff = readDiffContext(entry.tc, lockedArgs, ctx.jailResolve);
582
+ },
583
+ });
584
+ });
585
+ for (let k = 0; k < entries.length; k++) {
586
+ const entry = entries[k];
587
+ const outcome = await started[k];
588
+ usageMeter.add(outcome.usage);
589
+ opts.onToolOutcome?.(entry.tc.name, entry.parsed ?? {}, outcome);
590
+ traceToolEnd(entry.tc, i + k, outcome);
591
+ hooks.onToolResult?.(entry.tc, outcome.output, entry.denied ? null : entry.parsed, entry.diff.preWriteOld, entry.diff.editStartLine);
592
+ pushToolResult(history, entry.tc, outcome.output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
593
+ const invalidatedFiles = [
594
+ ...new Set([...(outcome.changedFiles ?? []), ...(outcome.staleFiles ?? [])]),
595
+ ];
596
+ if (invalidatedFiles.length > 0) {
597
+ for (const changedFile of invalidatedFiles) {
598
+ relprune?.observeMutation(history, changedFile);
599
+ lifecycle?.pushMutation(history, history.length - 1, changedFile);
600
+ }
601
+ invalidateArtifacts(runtimeContextState, history, invalidatedFiles);
602
+ runtimeContextState.lifecycleStats = lifecycle?.stats();
603
+ }
604
+ }
605
+ if (firstAllowed)
606
+ hooks.onToolDone?.();
607
+ i = j;
608
+ }
609
+ else {
610
+ // 单步串行(mutation / run_command / use_skill)——逐个执行,保快照序
611
+ const tc = calls[i];
612
+ // plan 模式防御 backstop:schema 已剔除这些工具,正常不会进这里;防后端幻觉调用——
613
+ // 不执行,直接返错回灌(让模型看到「plan 模式禁用」并停止),绝不写盘 / 跑命令。
614
+ if (ctx.getAgentMode() === 'plan' && planDisabledTools.has(tc.name)) {
615
+ hooks.onToolHeader?.(tc);
616
+ const err = `错误:计划模式下禁用工具 ${tc.name}(仅读探查,不改动文件 / 不跑命令)`;
617
+ const outcome = {
618
+ status: 'denied',
619
+ code: 'MODE_DENIED',
620
+ retryable: false,
621
+ output: err,
622
+ changedFiles: [],
623
+ durationMs: 0,
624
+ };
625
+ hooks.onToolResult?.(tc, err, null, null, 1);
626
+ pushToolResult(history, tc, err, relprune, lifecycle, scheduler);
627
+ traceToolEnd(tc, i, outcome);
628
+ i++;
629
+ continue;
630
+ }
631
+ // 权限预检查:在渲染 ● 头之前弹确认面板(体验:先问再执行,而非执行完再问)。
632
+ // 拒绝时只渲染拒绝结果,不渲染执行头;放行则继续走 header → start → executeTool 流程。
633
+ const parsed = parseArgs(tc.arguments);
634
+ const tool = ctx.toolRuntime.findTool(tc.name);
635
+ const argumentsValid = tool && parsed !== null ? validateToolArguments(tool, parsed).valid : false;
636
+ if (tool && argumentsValid) {
637
+ const perm = await ctx.checkPermission(tool, parsed ?? {}, signal, {
638
+ prompt: opts.permissionPrompt,
639
+ });
640
+ emitTrace('permission', {
641
+ source: 'agent_tool',
642
+ tool: tc.name,
643
+ decision: perm,
644
+ argumentHash: tracedCalls[i].args.sha256,
645
+ }, {
646
+ toolCallId: tracedCalls[i].toolCallId,
647
+ ...(tc.id ? { providerToolCallId: tc.id } : {}),
648
+ });
649
+ if (perm === 'deny') {
650
+ hooks.onToolHeader?.(tc);
651
+ const outcome = deniedOutcome(tc.name);
652
+ hooks.onToolResult?.(tc, outcome.output, null, null, 1);
653
+ pushToolResult(history, tc, outcome.output, relprune, lifecycle, scheduler, runtimeContextState, false);
654
+ traceToolEnd(tc, i, outcome);
655
+ i++;
656
+ continue;
657
+ }
658
+ }
659
+ hooks.onToolHeader?.(tc);
660
+ const mutationParsed = ctx.toolRuntime.isFileMutationTool(tc.name) ? parsed : null;
661
+ let diff = readDiffContext(tc, mutationParsed, ctx.jailResolve);
662
+ hooks.onToolStart?.(tc.name);
663
+ const serialHint = argumentErrorHint(tc.name, runtimeContextState);
664
+ const outcome = await ctx.toolRuntime.executeToolOutcome(tc.name, tc.arguments, signal, {
665
+ callId: tc.id,
666
+ allowedToolNames: currentAllowedToolNames(),
667
+ delegation: delegationForOrchestrator(),
668
+ ...(serialHint ? { argumentErrorHint: serialHint } : {}),
669
+ onLockAcquired: (lockedArgs) => {
670
+ if (mutationParsed)
671
+ diff = readDiffContext(tc, lockedArgs, ctx.jailResolve);
672
+ },
673
+ });
674
+ usageMeter.add(outcome.usage);
675
+ opts.onToolOutcome?.(tc.name, parsed ?? {}, outcome);
676
+ traceToolEnd(tc, i, outcome);
677
+ const output = outcome.output;
678
+ hooks.onToolDone?.();
679
+ hooks.onToolResult?.(tc, output, mutationParsed, diff.preWriteOld, diff.editStartLine);
680
+ if (tc.name === 'ask_human' && outcome.status === 'success') {
681
+ askHumanCountThisTurn += 1;
682
+ emitTrace('ask_human_call', {
683
+ tool: tc.name,
684
+ status: outcome.status,
685
+ perTurnCount: askHumanCountThisTurn,
686
+ }, tc.id ? { providerToolCallId: tc.id } : {});
687
+ }
688
+ pushToolResult(history, tc, output, relprune, lifecycle, scheduler, runtimeContextState, outcome.status === 'success');
689
+ const invalidatedFiles = [
690
+ ...new Set([...(outcome.changedFiles ?? []), ...(outcome.staleFiles ?? [])]),
691
+ ];
692
+ if (invalidatedFiles.length > 0) {
693
+ for (const changedFile of invalidatedFiles) {
694
+ relprune?.observeMutation(history, changedFile);
695
+ lifecycle?.pushMutation(history, history.length - 1, changedFile);
696
+ }
697
+ invalidateArtifacts(runtimeContextState, history, invalidatedFiles);
698
+ runtimeContextState.lifecycleStats = lifecycle?.stats();
699
+ }
700
+ i++;
701
+ }
702
+ }
703
+ }
704
+ },
705
+ });
706
+ continue; // 带着工具结果再调一次 LLM
707
+ }
708
+ if (modelDecision.kind !== 'completed') {
709
+ throw new Error(`Unexpected model termination decision: ${modelDecision.kind}.`);
710
+ }
711
+ if (mode !== 'idle' && lastChar !== '\n')
712
+ hooks.onTextEnd?.(); // 流式末尾补换行
713
+ // 没有工具调用:接受 agent 的完成判断。框架不自动运行测试、构建或完成门,
714
+ // 也不因缺少验证证据强制追加模型轮次;agent 仍可自行调用工具验证。
715
+ if (!gotText)
716
+ hooks.onNoReply?.();
717
+ historyManager.appendAssistantTurn({ content: result.content, toolCalls: [] });
718
+ const finalMutation = ctx.getCurrentTurnMutationState();
719
+ turnLifecycle.markCompleted();
720
+ return {
721
+ completed: true,
722
+ terminationReason: 'completed',
723
+ finalText: modelDecision.finalText,
724
+ usage: usageMeter.snapshot(),
725
+ changedFiles: finalMutation.changedFiles.map((item) => item.path),
726
+ };
727
+ }
728
+ finally {
729
+ emitTrace('step_end', {
730
+ durationMs: Date.now() - stepStartedAt,
731
+ aborted: signal?.aborted === true,
732
+ });
733
+ }
734
+ }
735
+ const exhaustedDecision = terminationPolicy.decide({
736
+ phase: 'loop_exhausted',
737
+ step: maxSteps,
738
+ maxSteps,
739
+ aborted: signal?.aborted === true,
740
+ });
741
+ if (exhaustedDecision.kind !== 'max_steps') {
742
+ throw new Error(`Unexpected loop exhaustion decision: ${exhaustedDecision.kind}.`);
743
+ }
744
+ hooks.onMaxSteps?.();
745
+ turnLifecycle.markMaxSteps();
746
+ const finalMutation = ctx.getCurrentTurnMutationState();
747
+ return {
748
+ completed: false,
749
+ terminationReason: 'max_steps',
750
+ finalText: null,
751
+ usage: usageMeter.snapshot(),
752
+ changedFiles: finalMutation.changedFiles.map((item) => item.path),
753
+ };
754
+ }
755
+ finally {
756
+ turnLifecycle.finalize();
757
+ }
758
+ }