mocode-ai 0.7.2 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
+ import { contentHash } from '../../changeset/index.js';
3
4
  import { MAX_FILE_LINES } from '../constants.js';
4
5
  /** 默认单次 read_file 拉取的行数。刻意压低,逼 LLM 分块读大文件,
5
6
  * 配合 description 中的 PAGINATION IS MANDATORY 引导。
@@ -30,6 +31,7 @@ export const readFileTool = {
30
31
  // 无论 LLM 传多大,单次硬钳到 MAX_FILE_LINES,杜绝「绕过分页引导一把全拿」。
31
32
  const limit = Math.min(Number(args.limit ?? DEFAULT_READ_LIMIT), MAX_FILE_LINES);
32
33
  const data = await readFile(resolve(path), 'utf8');
34
+ const artifactHeader = `[artifact source=read_file path=${path} hash=${contentHash(data)}]`;
33
35
  const lines = data.split(/\r?\n/);
34
36
  const start = Math.max(0, offset - 1);
35
37
  const end = Math.min(lines.length, start + limit);
@@ -38,8 +40,8 @@ export const readFileTool = {
38
40
  .map((l, i) => `${String(start + i + 1).padStart(6, ' ')}\t${l}`)
39
41
  .join('\n');
40
42
  if (end < lines.length) {
41
- return body + `\n\n... (${lines.length - end} 行未显示,共 ${lines.length} 行)`;
43
+ return artifactHeader + '\n' + body + `\n\n... (${lines.length - end} 行未显示,共 ${lines.length} 行)`;
42
44
  }
43
- return body || '(空文件)';
45
+ return artifactHeader + '\n' + (body || '(空文件)');
44
46
  },
45
47
  };
@@ -8,12 +8,12 @@ import { isSubAgentEnabled } from '../../config/index.js';
8
8
  //
9
9
  // 适用:分而治之的复杂任务 / 隔离上下文避免子任务工具噪声撑爆主窗口。
10
10
  // 多个 task 在共享工作区期间由 capability scheduler 串行执行;隔离 workspace 落地后再开放并行写。
11
- export const taskTool = {
12
- name: 'task',
11
+ export const subAgentTool = {
12
+ name: 'sub-agent',
13
13
  risk: 'dangerous',
14
14
  description: [
15
- 'Spawn a sub-agent for an isolated sub-task (independent history; only its final summary returns to you).',
16
- 'Use when a task splits into independent parts or its many tool calls would bloat your context.',
15
+ 'Spawn a capable worker for an isolated sub-task; only its structured result returns.',
16
+ 'Pass context with facts the main agent already knows to prevent duplicate exploration and token waste.',
17
17
  'Cannot recursively spawn sub-agents.',
18
18
  ].join(''),
19
19
  parameters: {
@@ -26,11 +26,23 @@ export const taskTool = {
26
26
  tools: {
27
27
  type: 'array',
28
28
  items: { type: 'string' },
29
- description: 'Optional whitelist of tool names the sub-agent is allowed to use (e.g. ["read_file","glob","grep","codegraph"] for read-only investigation). Omit to allow all tools. If the sub-task needs verification/build/test (running scripts, typecheck, etc.), remember to include "run_command".',
29
+ description: 'Optional whitelist for deliberate specialization. Omit it to preserve the worker full capability for its mode.',
30
30
  },
31
31
  maxSteps: {
32
32
  type: 'number',
33
- description: 'Optional step limit for the sub-agent (default 50). Lower if the sub-task should be quick.',
33
+ description: 'Optional loop-safety override. By default the worker uses the same step ceiling as the main agent; this is not a token budget.',
34
+ },
35
+ mode: {
36
+ type: 'string', enum: ['read', 'write'],
37
+ description: 'read tasks may run in parallel; write tasks use an isolated overlay and are merged by the coordinator.',
38
+ },
39
+ writeSet: {
40
+ type: 'array', items: { type: 'string' },
41
+ description: 'Known workspace-relative paths this task may write. Unknown write sets conservatively use the workspace lock.',
42
+ },
43
+ context: {
44
+ type: 'string',
45
+ description: 'Concise facts already learned by the main agent. Passing this avoids duplicate repository exploration.',
34
46
  },
35
47
  },
36
48
  required: ['prompt'],
@@ -47,20 +59,39 @@ export const taskTool = {
47
59
  const maxSteps = typeof args.maxSteps === 'number' && args.maxSteps > 0
48
60
  ? Math.floor(args.maxSteps)
49
61
  : undefined;
62
+ const mode = args.mode === 'write' ? 'write' : 'read';
63
+ const writeSet = Array.isArray(args.writeSet) ? args.writeSet.map(String) : undefined;
64
+ const context = typeof args.context === 'string' ? args.context.slice(0, 4000) : undefined;
50
65
  // 透传主 agent 的 abort signal:主 Ctrl+C 树杀子 agent(chat abort + 工具 abort)。
51
- const result = await spawnAgent({ prompt, tools, maxSteps, signal: ctx?.signal });
66
+ const result = await spawnAgent({ prompt, tools, maxSteps, signal: ctx?.signal, mode, writeSet, context });
67
+ let output;
52
68
  if (!result.completed) {
53
- return t('task.interrupted');
69
+ output = `[SubAgentResult status=${result.status} tokens=${result.usage.totalTokens} readSet=${JSON.stringify(result.readSet)} changeSet=${result.changeSet?.id ?? 'none'} verification=not-run]\n${result.summary ?? t('task.interrupted')}`;
54
70
  }
55
- if (!result.summary) {
56
- return t('task.noSummary');
71
+ else if (!result.summary) {
72
+ output = t('task.noSummary');
57
73
  }
58
- // 摘要可能很长,截到 MAX_OUTPUT 保主 history 不爆。
59
- const summary = result.summary;
60
- if (summary.length > MAX_OUTPUT) {
61
- return (summary.slice(0, MAX_OUTPUT) +
62
- `\n\n${t('task.summaryTruncated', { count: summary.length - MAX_OUTPUT })}`);
74
+ else {
75
+ // 摘要可能很长,截到 MAX_OUTPUT 保主 history 不爆。
76
+ const summary = [
77
+ result.summary,
78
+ `\n[SubAgentResult status=${result.status} tokens=${result.usage.totalTokens} prompt=${result.usage.promptTokens} completion=${result.usage.completionTokens} cached=${result.usage.cachedTokens} readSet=${JSON.stringify(result.readSet)} changeSet=${result.changeSet?.id ?? 'none'} verification=deferred-to-coordinator]`,
79
+ ].filter(Boolean).join('\n');
80
+ output = summary.length > MAX_OUTPUT ? (summary.slice(0, MAX_OUTPUT) +
81
+ `\n\n${t('task.summaryTruncated', { count: summary.length - MAX_OUTPUT })}`) : summary;
63
82
  }
64
- return summary;
83
+ const code = result.status === 'aborted'
84
+ ? 'ABORTED'
85
+ : result.status === 'conflict'
86
+ ? 'CHANGE_CONFLICT'
87
+ : result.completed ? 'OK' : 'EXECUTION_ERROR';
88
+ const outcome = {
89
+ status: result.status === 'aborted' ? 'aborted' : result.completed ? 'success' : 'error',
90
+ code,
91
+ retryable: false,
92
+ output,
93
+ usage: result.usage,
94
+ };
95
+ return outcome;
65
96
  },
66
97
  };
@@ -49,7 +49,12 @@ export const webFetchTool = {
49
49
  const contentType = resp.headers.get('content-type') ?? '';
50
50
  const text = await resp.text();
51
51
  if (!resp.ok) {
52
- return `错误:抓取失败 HTTP ${resp.status} ${resp.statusText}\n${text.slice(0, 500)}`;
52
+ return {
53
+ status: 'error',
54
+ code: 'HTTP_ERROR',
55
+ retryable: resp.status === 408 || resp.status === 429 || resp.status >= 500,
56
+ output: `错误:抓取失败 HTTP ${resp.status} ${resp.statusText}\n${text.slice(0, 500)}`,
57
+ };
53
58
  }
54
59
  const isHtml = /html/i.test(contentType) ||
55
60
  /^\s*<!doctype html/i.test(text) ||
@@ -65,12 +70,23 @@ export const webFetchTool = {
65
70
  }
66
71
  catch (e) {
67
72
  if (ctrl.signal.aborted) {
68
- if (externalSignal?.aborted)
69
- return `错误:已中断: ${url.href}`;
70
- return `错误:抓取超时(${FETCH_TIMEOUT_MS}ms): ${url.href}`;
73
+ if (externalSignal?.aborted) {
74
+ return { status: 'aborted', code: 'ABORTED', retryable: false, output: `错误:已中断: ${url.href}` };
75
+ }
76
+ return {
77
+ status: 'error',
78
+ code: 'TIMEOUT',
79
+ retryable: true,
80
+ output: `错误:抓取超时(${FETCH_TIMEOUT_MS}ms): ${url.href}`,
81
+ };
71
82
  }
72
83
  const msg = e instanceof Error ? e.message : String(e);
73
- return `错误:抓取失败: ${msg}`;
84
+ return {
85
+ status: 'error',
86
+ code: 'NETWORK_ERROR',
87
+ retryable: true,
88
+ output: `错误:抓取失败: ${msg}`,
89
+ };
74
90
  }
75
91
  finally {
76
92
  clearTimeout(timer);
@@ -75,7 +75,12 @@ export const webSearchTool = {
75
75
  data = JSON.parse(text);
76
76
  }
77
77
  catch {
78
- return `错误:搜索返回非 JSON(HTTP ${resp.status}): ${text.slice(0, 500)}`;
78
+ return {
79
+ status: 'error',
80
+ code: 'HTTP_ERROR',
81
+ retryable: resp.status === 408 || resp.status === 429 || resp.status >= 500,
82
+ output: `错误:搜索返回非 JSON(HTTP ${resp.status}): ${text.slice(0, 500)}`,
83
+ };
79
84
  }
80
85
  // AnySearch 成功返回 code===0;否则把 message/request_id 喂回 LLM。
81
86
  if (!resp.ok || data?.code !== 0) {
@@ -92,7 +97,13 @@ export const webSearchTool = {
92
97
  else if (resp.status === 429) {
93
98
  hint = ' 触发限流,请稍后重试。';
94
99
  }
95
- return `错误:搜索失败 [${code}] ${message}${rid}${hint}`;
100
+ return {
101
+ status: 'error',
102
+ code: !resp.ok || Number(code) === 429 ? 'HTTP_ERROR' : 'EXECUTION_ERROR',
103
+ retryable: resp.status === 408 || resp.status === 429 ||
104
+ resp.status >= 500 || Number(code) === 429,
105
+ output: `错误:搜索失败 [${code}] ${message}${rid}${hint}`,
106
+ };
96
107
  }
97
108
  const results = data?.data?.results;
98
109
  if (!Array.isArray(results) || results.length === 0) {
@@ -129,10 +140,23 @@ export const webSearchTool = {
129
140
  }
130
141
  catch (e) {
131
142
  if (ctrl.signal.aborted) {
132
- return `错误:搜索超时(${SEARCH_TIMEOUT_MS}ms)。`;
143
+ if (externalSignal?.aborted) {
144
+ return { status: 'aborted', code: 'ABORTED', retryable: false, output: '错误:搜索已中断。' };
145
+ }
146
+ return {
147
+ status: 'error',
148
+ code: 'TIMEOUT',
149
+ retryable: true,
150
+ output: `错误:搜索超时(${SEARCH_TIMEOUT_MS}ms)。`,
151
+ };
133
152
  }
134
153
  const msg = e instanceof Error ? e.message : String(e);
135
- return `错误:联网搜索请求失败: ${msg}`;
154
+ return {
155
+ status: 'error',
156
+ code: 'NETWORK_ERROR',
157
+ retryable: true,
158
+ output: `错误:联网搜索请求失败: ${msg}`,
159
+ };
136
160
  }
137
161
  finally {
138
162
  clearTimeout(timer);
@@ -1,36 +1,71 @@
1
- import { writeFile, mkdir } from 'node:fs/promises';
2
- import { resolve, dirname } from 'node:path';
3
- import { verifyWrittenFile } from '../../verification/postconditions.js';
4
- // ---------- write_file ----------
1
+ import { commitChangeSet, createChangeSet, normalizeContentHash, summarizeChangeSet, } from '../../changeset/index.js';
2
+ function conflict(path, details) {
3
+ return {
4
+ status: 'error',
5
+ code: 'CHANGE_CONFLICT',
6
+ retryable: false,
7
+ changedFiles: [],
8
+ staleFiles: [path],
9
+ output: `CHANGE_CONFLICT: ${path} was not changed. ${details} Do not retry these arguments. Call read_file on this exact path, then use the returned hash; use expected_hash=null only if read_file reports that the path is missing.`,
10
+ };
11
+ }
5
12
  export const writeFileTool = {
6
13
  name: 'write_file',
7
- description: 'Create or overwrite a file; parent dirs created.',
14
+ description: 'Create or replace one file transactionally. expected_hash may be omitted (or null) only for create-only writes to a path that must not exist; overwriting requires the hash from a fresh read_file artifact header.',
8
15
  risk: 'confirm',
9
16
  parameters: {
10
17
  type: 'object',
11
18
  properties: {
12
19
  path: { type: 'string', description: 'File path' },
13
20
  content: { type: 'string', description: 'Full file content' },
21
+ expected_hash: {
22
+ type: ['string', 'null'],
23
+ description: 'Optional sha256 hash from read_file. Omit or pass null only when the path must not exist.',
24
+ },
14
25
  },
15
26
  required: ['path', 'content'],
16
27
  },
17
- async execute(args) {
18
- const path = String(args.path);
28
+ async execute(args, ctx) {
29
+ const file = String(args.path);
19
30
  const content = String(args.content);
20
- const full = resolve(path);
21
- await mkdir(dirname(full), { recursive: true });
22
- await writeFile(full, content, 'utf8');
23
- const postcondition = await verifyWrittenFile(full, content);
24
- if (postcondition.status === 'failed') {
31
+ let expectedHash = null;
32
+ // Missing and explicit null are both safe create-only requests. They never
33
+ // overwrite: ChangeSet compares expectedHash=null against the current path.
34
+ if (args.expected_hash != null) {
35
+ expectedHash = normalizeContentHash(String(args.expected_hash));
36
+ if (!expectedHash)
37
+ return conflict(file, 'expected_hash 必须是 null 或 sha256:<64 hex>。');
38
+ }
39
+ const operation = expectedHash === null ? 'create' : 'update';
40
+ const result = await commitChangeSet(createChangeSet([{
41
+ path: file,
42
+ operation,
43
+ expectedHash,
44
+ replacement: content,
45
+ }]), ctx?.signal);
46
+ if (result.status === 'conflict') {
47
+ const item = result.conflicts[0];
48
+ return conflict(file, `expected=${item?.expectedHash ?? 'missing'}, actual=${item?.actualHash ?? 'missing'}。请重新读取后再写入。`);
49
+ }
50
+ if (result.status === 'failed') {
25
51
  return {
26
52
  status: 'error',
27
- code: 'POSTCONDITION_FAILED',
28
- retryable: true,
29
- output: postcondition.diagnostics
30
- .map((item) => `[${item.code ?? 'V0_FAILED'}] ${item.file ?? path}: ${item.message}`)
31
- .join('\n'),
53
+ code: 'EXECUTION_ERROR',
54
+ retryable: false,
55
+ changedFiles: [],
56
+ output: `错误:ChangeSet 提交失败并已执行恢复: ${result.error}`,
32
57
  };
33
58
  }
34
- return `已写入 ${path} (${content.length} 字符, sha256=${postcondition.actualHash})`;
59
+ const summary = summarizeChangeSet(result.changeSet);
60
+ return {
61
+ status: 'success',
62
+ code: 'OK',
63
+ retryable: false,
64
+ changedFiles: result.changedFiles,
65
+ changeSet: summary,
66
+ output: result.changedFiles.length === 0
67
+ ? `文件 ${file} 内容未变化 (ChangeSet ${summary.id})。`
68
+ : `已事务化写入 ${file} (${content.length} 字符, ChangeSet ${summary.id}, sha256=${summary.changes[0]?.afterHash})。`,
69
+ };
35
70
  },
36
71
  };
@@ -26,7 +26,7 @@ export const IGNORE = ['**/node_modules/**', '**/.git/**'];
26
26
  // ── plan 模式(只读规划,不执行)──────────────────────────────────────────────
27
27
  /**
28
28
  * plan 模式下从工具 schema 里剔除的工具(模型根本看不到 → 调不到):
29
- * 写盘 / 命令 / 记忆写入类 + task(派生子 agent,plan 模式只读不可有副作用)。单一事实源,
29
+ * 写盘 / 命令 / 记忆写入类 + sub-agent(派生子 agentplan 模式只读不可有副作用)。单一事实源,
30
30
  * 被 llm(planChatTools)与 agent(防御 backstop)共用。
31
31
  * 只读工具(read_file/glob/grep/codegraph/web_search/web_fetch/use_skill/ask_human/memory_search/memory_list)保留。
32
32
  */
@@ -37,7 +37,7 @@ export const PLAN_DISABLED_TOOLS = new Set([
37
37
  'memory_save',
38
38
  'memory_update',
39
39
  'memory_forget',
40
- 'task',
40
+ 'sub-agent',
41
41
  ]);
42
42
  /**
43
43
  * 按当前 isMemoryEnabled() 现算 plan 模式应屏蔽的工具。
@@ -56,5 +56,5 @@ export function getPlanDisabledTools() {
56
56
  }
57
57
  /** auto/plan 共用的运行时功能开关防线;关闭时即使模型幻觉调用也不得执行。 */
58
58
  export function getRuntimeDisabledTools() {
59
- return isSubAgentEnabled() ? new Set() : new Set(['task']);
59
+ return isSubAgentEnabled() ? new Set() : new Set(['sub-agent']);
60
60
  }
@@ -2,6 +2,8 @@ import { builtinTools } from './builtins/index.js';
2
2
  import { beginPathMutation, beginWorkspaceMutation, endPathMutation, endWorkspaceMutation, getCurrentTurnMutationState, } from '../rollback/index.js';
3
3
  import { enforceSandbox } from '../sandbox/index.js';
4
4
  import { resolveResourceLockRequests, toolResourceLockManager } from './resource-lock.js';
5
+ import { executeWithToolRetry } from './retry.js';
6
+ import { validateToolArguments } from './validation.js';
5
7
  import { t } from '../i18n/index.js';
6
8
  import { isToolErrorOutput } from './result.js';
7
9
  /**
@@ -67,7 +69,7 @@ function isStructuredOutcome(value) {
67
69
  typeof value.status === 'string' && typeof value.code === 'string' &&
68
70
  typeof value.retryable === 'boolean' && typeof value.output === 'string';
69
71
  }
70
- function normalizeOutcome(value, capabilities, durationMs, changedFiles) {
72
+ function normalizeOutcome(value, durationMs, changedFiles) {
71
73
  if (isStructuredOutcome(value)) {
72
74
  return {
73
75
  ...value,
@@ -79,7 +81,8 @@ function normalizeOutcome(value, capabilities, durationMs, changedFiles) {
79
81
  return {
80
82
  status: failed ? 'error' : 'success',
81
83
  code: failed ? 'EXECUTION_ERROR' : 'OK',
82
- retryable: failed && capabilities.retry !== 'never',
84
+ // Legacy string errors carry no transient classification and are never retried blindly.
85
+ retryable: false,
83
86
  output: value,
84
87
  changedFiles,
85
88
  durationMs,
@@ -95,92 +98,148 @@ function terminalOutcome(status, code, output, startedAt, changedFiles = []) {
95
98
  durationMs: Date.now() - startedAt,
96
99
  };
97
100
  }
98
- /**
99
- * 结构化工具调度入口。永不抛错;旧字符串工具在此归一化为 ToolOutcome。
100
- * 权限仍由 Agent 在展示工具头之前预检,保持现有交互时序。
101
- */
102
- export async function executeToolOutcome(name, argsRaw, signal, opts) {
101
+ function isTransientExecutionError(error) {
102
+ if (!error || typeof error !== 'object')
103
+ return false;
104
+ const value = error;
105
+ if (value.name === 'AbortError' || value.name === 'APIUserAbortError')
106
+ return false;
107
+ if (value.status === 408 || value.status === 429 ||
108
+ (typeof value.status === 'number' && value.status >= 500))
109
+ return true;
110
+ if (['ETIMEDOUT', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EPIPE']
111
+ .includes(value.code ?? ''))
112
+ return true;
113
+ return value.name === 'APIConnectionError' ||
114
+ value.name === 'APIConnectionTimeoutError' ||
115
+ (typeof value.message === 'string' && /\btime(?:d)?\s*out\b|ETIMEDOUT/i.test(value.message));
116
+ }
117
+ function executionErrorOutcome(name, error, startedAt, changedFiles) {
118
+ const transient = isTransientExecutionError(error);
119
+ const value = error;
120
+ const timeout = transient && (value?.code === 'ETIMEDOUT' ||
121
+ value?.name === 'APIConnectionTimeoutError' ||
122
+ (error instanceof Error && /\btime(?:d)?\s*out\b|ETIMEDOUT/i.test(error.message)));
123
+ return {
124
+ status: 'error',
125
+ code: timeout ? 'TIMEOUT' : transient ? 'NETWORK_ERROR' : 'EXECUTION_ERROR',
126
+ retryable: transient,
127
+ output: t('toolError.execution', {
128
+ name,
129
+ message: error instanceof Error ? error.message : String(error),
130
+ }),
131
+ changedFiles,
132
+ durationMs: Date.now() - startedAt,
133
+ };
134
+ }
135
+ /** One complete attempt: acquire/release locks and capture rollback independently. */
136
+ async function executeToolAttempt(tool, args, signal, opts, notifyLockAcquired) {
103
137
  const startedAt = Date.now();
104
- if (signal?.aborted) {
105
- return terminalOutcome('aborted', 'ABORTED', t('command.interrupted'), startedAt);
106
- }
107
- const tool = findTool(name);
108
- if (!tool) {
109
- return terminalOutcome('error', 'UNKNOWN_TOOL', t('toolError.unknown', { name }), startedAt);
110
- }
111
- let args;
112
- try {
113
- args = argsRaw.trim() ? JSON.parse(argsRaw) : {};
114
- }
115
- catch {
116
- return terminalOutcome('error', 'INVALID_JSON', t('toolError.invalidJson', { name, arguments: argsRaw }), startedAt);
117
- }
118
138
  const capabilities = getToolCapabilities(tool);
119
139
  let mutationVersionBefore;
120
140
  let capturedPath;
121
141
  try {
122
- const sandboxError = enforceSandbox(name, args);
123
- if (sandboxError) {
124
- return terminalOutcome('denied', 'SANDBOX_DENIED', sandboxError, startedAt);
125
- }
126
142
  const requests = resolveResourceLockRequests(capabilities, args);
127
143
  return await toolResourceLockManager.withLocks(requests, signal, async () => {
128
- // Diff 等执行前观察必须发生在真正持锁之后;同路径排队调用才能看到前序写入结果。
129
- opts?.onLockAcquired?.(args);
144
+ if (notifyLockAcquired)
145
+ opts?.onLockAcquired?.(args);
130
146
  const mutationBefore = getCurrentTurnMutationState();
131
147
  mutationVersionBefore = mutationBefore.version;
132
- const pathCapture = isFileMutationTool(name) && typeof args.path === 'string' && args.path
148
+ // Transactional tools own their full write-set capture inside ChangeSet commit.
149
+ const pathCapture = !capabilities.delegatesResourceLocks &&
150
+ isFileMutationTool(tool.name) && typeof args.path === 'string' && args.path
133
151
  ? beginPathMutation(args.path)
134
152
  : null;
135
153
  capturedPath = pathCapture?.path;
136
- // 进程和未知扩展可能间接改动任意文件;其 workspace lock 同时隔离全盘捕获。
137
154
  const workspaceCapture = capabilities.effect === 'process' || capabilities.effect === 'unknown'
138
155
  ? beginWorkspaceMutation()
139
156
  : null;
140
157
  let raw;
141
158
  try {
142
- raw = await tool.execute(args, {
143
- signal,
144
- dropContext: opts?.dropContext,
145
- });
159
+ raw = await tool.execute(args, { signal, dropContext: opts?.dropContext });
146
160
  }
147
161
  finally {
148
162
  if (pathCapture)
149
- endPathMutation(pathCapture, name);
163
+ endPathMutation(pathCapture, tool.name);
150
164
  if (workspaceCapture)
151
- endWorkspaceMutation(workspaceCapture, name);
165
+ endWorkspaceMutation(workspaceCapture, tool.name);
152
166
  }
153
167
  const mutationAfter = getCurrentTurnMutationState();
154
168
  const changedFiles = mutationAfter.version !== mutationBefore.version
155
169
  ? pathCapture
156
- ? mutationAfter.changedFiles
157
- .filter((item) => item.path === pathCapture.path)
158
- .map((item) => item.path)
170
+ ? mutationAfter.changedFiles.filter((item) => item.path === pathCapture.path).map((item) => item.path)
159
171
  : mutationAfter.changedFiles.map((item) => item.path)
160
172
  : [];
161
173
  if (signal?.aborted) {
162
- return terminalOutcome('aborted', 'ABORTED', String(isStructuredOutcome(raw) ? raw.output : raw), startedAt, changedFiles);
174
+ const aborted = terminalOutcome('aborted', 'ABORTED', String(isStructuredOutcome(raw) ? raw.output : raw), startedAt, changedFiles);
175
+ return isStructuredOutcome(raw) ? { ...aborted, usage: raw.usage } : aborted;
163
176
  }
164
- return normalizeOutcome(raw, capabilities, Date.now() - startedAt, changedFiles);
177
+ return normalizeOutcome(raw, Date.now() - startedAt, changedFiles);
165
178
  });
166
179
  }
167
180
  catch (error) {
168
181
  const mutationAfter = getCurrentTurnMutationState();
169
- const changedFiles = mutationVersionBefore !== undefined &&
170
- mutationAfter.version !== mutationVersionBefore
182
+ const changedFiles = mutationVersionBefore !== undefined && mutationAfter.version !== mutationVersionBefore
171
183
  ? capturedPath
172
- ? mutationAfter.changedFiles
173
- .filter((item) => item.path === capturedPath)
174
- .map((item) => item.path)
184
+ ? mutationAfter.changedFiles.filter((item) => item.path === capturedPath).map((item) => item.path)
175
185
  : mutationAfter.changedFiles.map((item) => item.path)
176
186
  : [];
177
187
  if (signal?.aborted || (error instanceof Error && error.name === 'AbortError')) {
178
188
  return terminalOutcome('aborted', 'ABORTED', t('command.interrupted'), startedAt, changedFiles);
179
189
  }
180
- return terminalOutcome('error', 'EXECUTION_ERROR', t('toolError.execution', {
181
- name,
182
- message: error instanceof Error ? error.message : String(error),
183
- }), startedAt, changedFiles);
190
+ return executionErrorOutcome(tool.name, error, startedAt, changedFiles);
191
+ }
192
+ }
193
+ function stableJson(value) {
194
+ if (Array.isArray(value))
195
+ return `[${value.map(stableJson).join(',')}]`;
196
+ if (value && typeof value === 'object') {
197
+ return `{${Object.entries(value)
198
+ .sort(([left], [right]) => left.localeCompare(right))
199
+ .map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`)
200
+ .join(',')}}`;
201
+ }
202
+ return JSON.stringify(value) ?? 'null';
203
+ }
204
+ /**
205
+ * 结构化工具调度入口。永不抛错;旧字符串工具在此归一化为 ToolOutcome。
206
+ * 权限仍由 Agent 在展示工具头之前预检,保持现有交互时序。
207
+ */
208
+ export async function executeToolOutcome(name, argsRaw, signal, opts) {
209
+ const startedAt = Date.now();
210
+ if (signal?.aborted) {
211
+ return terminalOutcome('aborted', 'ABORTED', t('command.interrupted'), startedAt);
212
+ }
213
+ const tool = findTool(name);
214
+ if (!tool) {
215
+ return terminalOutcome('error', 'UNKNOWN_TOOL', t('toolError.unknown', { name }), startedAt);
216
+ }
217
+ let parsed;
218
+ try {
219
+ parsed = argsRaw.trim() ? JSON.parse(argsRaw) : {};
220
+ }
221
+ catch {
222
+ return terminalOutcome('error', 'INVALID_JSON', t('toolError.invalidJson', { name, arguments: argsRaw }), startedAt);
223
+ }
224
+ const validation = validateToolArguments(tool, parsed);
225
+ if (!validation.valid) {
226
+ return terminalOutcome('error', validation.code, `错误:工具 ${name} 参数无效: ${validation.message}`, startedAt);
227
+ }
228
+ const args = parsed;
229
+ const fingerprint = `${name}\x00${stableJson(args)}`;
230
+ const sandboxError = enforceSandbox(name, args);
231
+ if (sandboxError) {
232
+ return terminalOutcome('denied', 'SANDBOX_DENIED', sandboxError, startedAt);
233
+ }
234
+ const capabilities = getToolCapabilities(tool);
235
+ try {
236
+ return await executeWithToolRetry(capabilities, fingerprint, signal, (attempt) => executeToolAttempt(tool, args, signal, opts, attempt === 1), opts?.onRetry);
237
+ }
238
+ catch (error) {
239
+ if (signal?.aborted || (error instanceof Error && error.name === 'AbortError')) {
240
+ return terminalOutcome('aborted', 'ABORTED', t('command.interrupted'), startedAt);
241
+ }
242
+ return executionErrorOutcome(name, error, startedAt, []);
184
243
  }
185
244
  }
186
245
  /** 字符串兼容入口:现有调用方、TUI 和 LLM history 无需同步迁移。 */