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.
- package/README.md +58 -4
- package/README.zh-CN.md +58 -4
- package/dist/agent/core.js +119 -39
- package/dist/agent/index.js +57 -6
- package/dist/agent/spawn.js +74 -30
- package/dist/agents/coordinator.js +60 -0
- package/dist/changeset/index.js +289 -0
- package/dist/changeset/types.js +1 -0
- package/dist/config/index.js +25 -13
- package/dist/context/artifacts.js +254 -0
- package/dist/context/classifier.js +1 -1
- package/dist/context/index.js +1 -0
- package/dist/i18n/index.js +16 -4
- package/dist/llm/index.js +2 -2
- package/dist/permissions/index.js +7 -1
- package/dist/repl/index.js +31 -5
- package/dist/rollback/index.js +43 -8
- package/dist/sandbox/index.js +1 -1
- package/dist/sandbox/policy.js +2 -2
- package/dist/sandbox/root.js +9 -3
- package/dist/session/compact.js +9 -3
- package/dist/session/scheduler.js +4 -0
- package/dist/session/state.js +3 -19
- package/dist/tools/builtins/apply-patch.js +174 -0
- package/dist/tools/builtins/edit-file.js +60 -37
- package/dist/tools/builtins/index.js +10 -8
- package/dist/tools/builtins/read-file.js +4 -2
- package/dist/tools/builtins/task.js +47 -16
- package/dist/tools/builtins/web-fetch.js +21 -5
- package/dist/tools/builtins/web-search.js +28 -4
- package/dist/tools/builtins/write-file.js +53 -18
- package/dist/tools/constants.js +3 -3
- package/dist/tools/registry.js +108 -49
- package/dist/tools/retry.js +105 -0
- package/dist/tools/validation.js +80 -0
- package/dist/ui/batch.js +56 -24
- package/dist/ui/layout.js +8 -3
- package/package.json +2 -1
|
@@ -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
|
|
12
|
-
name: '
|
|
11
|
+
export const subAgentTool = {
|
|
12
|
+
name: 'sub-agent',
|
|
13
13
|
risk: 'dangerous',
|
|
14
14
|
description: [
|
|
15
|
-
'Spawn a
|
|
16
|
-
'
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
71
|
+
else if (!result.summary) {
|
|
72
|
+
output = t('task.noSummary');
|
|
57
73
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
`\n
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
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
|
|
28
|
+
async execute(args, ctx) {
|
|
29
|
+
const file = String(args.path);
|
|
19
30
|
const content = String(args.content);
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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: '
|
|
28
|
-
retryable:
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
.join('\n'),
|
|
53
|
+
code: 'EXECUTION_ERROR',
|
|
54
|
+
retryable: false,
|
|
55
|
+
changedFiles: [],
|
|
56
|
+
output: `错误:ChangeSet 提交失败并已执行恢复: ${result.error}`,
|
|
32
57
|
};
|
|
33
58
|
}
|
|
34
|
-
|
|
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
|
};
|
package/dist/tools/constants.js
CHANGED
|
@@ -26,7 +26,7 @@ export const IGNORE = ['**/node_modules/**', '**/.git/**'];
|
|
|
26
26
|
// ── plan 模式(只读规划,不执行)──────────────────────────────────────────────
|
|
27
27
|
/**
|
|
28
28
|
* plan 模式下从工具 schema 里剔除的工具(模型根本看不到 → 调不到):
|
|
29
|
-
* 写盘 / 命令 / 记忆写入类 +
|
|
29
|
+
* 写盘 / 命令 / 记忆写入类 + sub-agent(派生子 agent,plan 模式只读不可有副作用)。单一事实源,
|
|
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
|
-
'
|
|
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(['
|
|
59
|
+
return isSubAgentEnabled() ? new Set() : new Set(['sub-agent']);
|
|
60
60
|
}
|
package/dist/tools/registry.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
129
|
-
|
|
144
|
+
if (notifyLockAcquired)
|
|
145
|
+
opts?.onLockAcquired?.(args);
|
|
130
146
|
const mutationBefore = getCurrentTurnMutationState();
|
|
131
147
|
mutationVersionBefore = mutationBefore.version;
|
|
132
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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 无需同步迁移。 */
|