mocode-ai 0.7.3 → 1.0.2

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.
@@ -7,6 +7,7 @@ import * as layout from '../ui/layout.js';
7
7
  import { pruneAfterCompaction } from '../rollback/index.js';
8
8
  import { toText } from '../context/utils.js';
9
9
  import { DEFAULT_BUDGET_POLICY } from '../context/budget.js';
10
+ import { collectArtifactRefs } from '../context/artifacts.js';
10
11
  export function createContextState() {
11
12
  return { lastEstimate: 0, correction: 1, calibrationSamples: 0 };
12
13
  }
@@ -210,7 +211,7 @@ async function defaultSummarize(older, focus) {
210
211
  }
211
212
  const sysMsg = {
212
213
  role: 'system',
213
- content: 'You are a session summarizer. Output only the summary body, max 300 words, preserving: the user\'s core request; files read/written/modified and key changes; key commands run and their result highlights; decisions made; current task progress and next step; open questions. Do not recap every detail.',
214
+ content: 'You are a session summarizer. Output only the summary body, max 300 words, preserving: the user\'s core request; files read/written/modified and key changes; source artifact IDs/hashes; key commands run and their result highlights; decisions made; current task progress and next step; open questions. Do not recap every detail.',
214
215
  };
215
216
  const userMsg = {
216
217
  role: 'user',
@@ -302,10 +303,11 @@ export async function compactHistory(history, opts) {
302
303
  }
303
304
  }
304
305
  }
306
+ const refs = collectArtifactRefs(older);
305
307
  const summaryMsg = {
306
308
  role: 'system',
307
309
  content: older.length > 0
308
- ? `# 会话摘要(force)\n被跳过的早期对话 ${older.length} 条已微压缩(token 数减少)。`
310
+ ? `# 会话摘要(force)\n被跳过的早期对话 ${older.length} 条已微压缩(token 数减少)。${refs.length > 0 ? `\n[artifact refs: ${refs.join(', ')}]` : ''}`
309
311
  : `# 会话摘要(force)\n无内容。`,
310
312
  };
311
313
  const rebuilt = [history[0], summaryMsg, ...keptAfter];
@@ -389,9 +391,13 @@ export async function compactHistory(history, opts) {
389
391
  summary = null; // 摘要失败 → 回退仅微压缩,不崩
390
392
  }
391
393
  if (summary) {
394
+ const artifactRefs = collectArtifactRefs(older);
395
+ const provenance = artifactRefs.length > 0
396
+ ? `\n\n[artifact refs: ${artifactRefs.join(', ')}]`
397
+ : '';
392
398
  const summaryMsg = {
393
399
  role: 'system',
394
- content: `# 会话摘要\n${summary}`,
400
+ content: `# 会话摘要\n${summary}${provenance}`,
395
401
  };
396
402
  // 原地重建:[systemPrompt, summaryMsg, ...kept]
397
403
  const systemMsg = history[0];
@@ -23,11 +23,15 @@ import { config } from '../config/index.js';
23
23
  import { maybeCompact, contextState } from './compact.js';
24
24
  import * as layout from '../ui/layout.js';
25
25
  import { ui } from '../ui/theme.js';
26
+ import { pruneStaleArtifacts, refreshArtifactFreshness } from '../context/artifacts.js';
26
27
  /** 创建 runAgentCore 闭包持有的 scheduler(每次 agent 启动一个新实例)。 */
27
28
  export function createBudgetScheduler(state = contextState) {
28
29
  const obs = {
29
30
  lastRunLog: null,
30
31
  async runStep(history, step, activeTools = chatTools) {
32
+ // Re-check file-backed hashes first, then discard stale/rebuildable facts before budgeting.
33
+ refreshArtifactFreshness(state, history);
34
+ pruneStaleArtifacts(state, history);
31
35
  const report = evaluateBudget(history, config.contextWindowTokens, step, state.correction, activeTools);
32
36
  const actions = scheduleActions(report);
33
37
  let compactHistoryCalled = false;
@@ -1,33 +1,17 @@
1
1
  // session/state.ts - 会话状态跟踪模块
2
2
  // 提供当前活跃会话 ID 的全局访问点,供 config/buildNotepadSection 等读取会话级 notes.md。
3
3
  // 避免 repl/index.ts ↔ config/index.ts 循环依赖。
4
- import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
5
- import path from 'node:path';
6
4
  let currentSessionId;
7
5
  /** 获取当前活跃会话 ID(供 buildNotepadSection 等使用)。 */
8
6
  export function getCurrentSessionId() {
9
7
  return currentSessionId;
10
8
  }
11
9
  /**
12
- * 设置当前活跃会话 ID,并确保该会话的 notes.md 文件存在(不存在则创建空文件)。
10
+ * 设置当前活跃会话 IDnotes.md 由 agent 按需创建;这里不能预建空文件,
11
+ * 否则 write_file(expected_hash=null) 的首次创建会必然冲突。
13
12
  * 由 repl/index.ts 在会话启动 / /resume 切换时调用。
14
13
  */
15
14
  export function setCurrentSessionId(id, cwd) {
16
15
  currentSessionId = id;
17
- if (id)
18
- ensureSessionNotes(id, cwd);
19
- }
20
- /** 确保 .mocode/sessions/<id>/notes.md 存在,不存在则创建空文件。 */
21
- function ensureSessionNotes(id, cwd) {
22
- const dir = path.join(cwd, '.mocode', 'sessions', id);
23
- const file = path.join(dir, 'notes.md');
24
- if (existsSync(file))
25
- return;
26
- try {
27
- mkdirSync(dir, { recursive: true });
28
- writeFileSync(file, '', 'utf8');
29
- }
30
- catch {
31
- // 创建失败不影响 REPL 主流程
32
- }
16
+ void cwd;
33
17
  }
@@ -0,0 +1,174 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { jailResolve } from '../../sandbox/index.js';
3
+ import { commitChangeSet, contentHash, createChangeSet, summarizeChangeSet, } from '../../changeset/index.js';
4
+ function invalid(message) {
5
+ const error = new Error(message);
6
+ error.name = 'PatchError';
7
+ return error;
8
+ }
9
+ function parsePatch(input) {
10
+ const lines = input.replace(/\r\n/g, '\n').split('\n');
11
+ if (lines[0] !== '*** Begin Patch')
12
+ throw invalid('patch 必须以 *** Begin Patch 开始。');
13
+ const operations = [];
14
+ let i = 1;
15
+ while (i < lines.length && lines[i] !== '*** End Patch') {
16
+ const header = lines[i++];
17
+ let match = /^\*\*\* Add File: (.+)$/.exec(header);
18
+ if (match) {
19
+ const body = [];
20
+ while (i < lines.length && !lines[i].startsWith('*** ')) {
21
+ const line = lines[i++];
22
+ if (!line.startsWith('+'))
23
+ throw invalid(`Add File ${match[1]} 的内容行必须以 + 开头。`);
24
+ body.push(line.slice(1));
25
+ }
26
+ operations.push({ kind: 'add', path: match[1], lines: body });
27
+ continue;
28
+ }
29
+ match = /^\*\*\* Delete File: (.+)$/.exec(header);
30
+ if (match) {
31
+ operations.push({ kind: 'delete', path: match[1] });
32
+ continue;
33
+ }
34
+ match = /^\*\*\* Update File: (.+)$/.exec(header);
35
+ if (!match)
36
+ throw invalid(`未知 patch 指令: ${header}`);
37
+ const hunks = [];
38
+ while (i < lines.length && !lines[i].startsWith('*** ')) {
39
+ if (!lines[i].startsWith('@@'))
40
+ throw invalid(`Update File ${match[1]} 缺少 @@ hunk。`);
41
+ i++;
42
+ const hunk = [];
43
+ while (i < lines.length && !lines[i].startsWith('@@') && !lines[i].startsWith('*** ')) {
44
+ hunk.push(lines[i++]);
45
+ }
46
+ hunks.push(hunk);
47
+ }
48
+ operations.push({ kind: 'update', path: match[1], hunks });
49
+ }
50
+ if (lines[i] !== '*** End Patch')
51
+ throw invalid('patch 缺少 *** End Patch。');
52
+ if (operations.length === 0)
53
+ throw invalid('patch 不包含文件操作。');
54
+ return operations;
55
+ }
56
+ function applyHunks(source, hunks, file) {
57
+ let current = source.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
58
+ for (const hunk of hunks) {
59
+ const before = [];
60
+ const after = [];
61
+ for (const line of hunk) {
62
+ if (line.startsWith(' ')) {
63
+ before.push(line.slice(1));
64
+ after.push(line.slice(1));
65
+ }
66
+ else if (line.startsWith('-')) {
67
+ before.push(line.slice(1));
68
+ }
69
+ else if (line.startsWith('+')) {
70
+ after.push(line.slice(1));
71
+ }
72
+ else if (line === '\') {
73
+ continue;
74
+ }
75
+ else {
76
+ throw invalid(`Update File ${file} 的 hunk 行必须以空格、+ 或 - 开头。`);
77
+ }
78
+ }
79
+ const oldText = before.join('\n');
80
+ const newText = after.join('\n');
81
+ if (!oldText)
82
+ throw invalid(`Update File ${file} 的 hunk 缺少上下文或删除行。`);
83
+ const first = current.indexOf(oldText);
84
+ if (first < 0)
85
+ throw invalid(`Update File ${file} 的 hunk 与当前文件不匹配。`);
86
+ if (current.indexOf(oldText, first + 1) >= 0) {
87
+ throw invalid(`Update File ${file} 的 hunk 上下文不唯一,请增加上下文。`);
88
+ }
89
+ current = current.slice(0, first) + newText + current.slice(first + oldText.length);
90
+ }
91
+ return source.includes('\r\n') ? current.replace(/\n/g, '\r\n') : current;
92
+ }
93
+ async function buildChanges(operations) {
94
+ const changes = [];
95
+ for (const operation of operations) {
96
+ if (operation.kind === 'add') {
97
+ changes.push({
98
+ path: operation.path,
99
+ operation: 'create',
100
+ expectedHash: null,
101
+ replacement: operation.lines.join('\n'),
102
+ });
103
+ continue;
104
+ }
105
+ const raw = await readFile(jailResolve(operation.path));
106
+ const expectedHash = contentHash(raw);
107
+ if (operation.kind === 'delete') {
108
+ changes.push({ path: operation.path, operation: 'delete', expectedHash });
109
+ continue;
110
+ }
111
+ const source = raw.toString('utf8');
112
+ changes.push({
113
+ path: operation.path,
114
+ operation: 'update',
115
+ expectedHash,
116
+ replacement: applyHunks(source, operation.hunks, operation.path),
117
+ });
118
+ }
119
+ return changes;
120
+ }
121
+ function conflictOutcome(result) {
122
+ return {
123
+ status: 'error',
124
+ code: 'CHANGE_CONFLICT',
125
+ retryable: false,
126
+ changedFiles: [],
127
+ staleFiles: result.conflicts.map((item) => item.path),
128
+ output: [
129
+ '错误:apply_patch 检测到内容冲突,磁盘未发生变化。',
130
+ ...result.conflicts.map((item) => `- ${item.path}: expected=${item.expectedHash ?? 'missing'}, actual=${item.actualHash ?? 'missing'} (${item.reason})`),
131
+ ].join('\n'),
132
+ };
133
+ }
134
+ export const applyPatchTool = {
135
+ name: 'apply_patch',
136
+ description: 'Apply a multi-file patch transactionally. Format: *** Begin Patch, then *** Add/Update/Delete File sections, then *** End Patch. All files are dry-run and hash-checked before any file is committed; failure leaves disk unchanged.',
137
+ risk: 'confirm',
138
+ parameters: {
139
+ type: 'object',
140
+ properties: {
141
+ patch: { type: 'string', description: 'The complete *** Begin Patch ... *** End Patch document.' },
142
+ },
143
+ required: ['patch'],
144
+ },
145
+ async execute(args, ctx) {
146
+ try {
147
+ const changes = await buildChanges(parsePatch(String(args.patch)));
148
+ const result = await commitChangeSet(createChangeSet(changes), ctx?.signal);
149
+ if (result.status === 'conflict')
150
+ return conflictOutcome(result);
151
+ if (result.status === 'failed') {
152
+ return { status: 'error', code: 'PATCH_INVALID', retryable: false, changedFiles: [], output: `错误:apply_patch 提交失败,已恢复磁盘: ${result.error}` };
153
+ }
154
+ const summary = summarizeChangeSet(result.changeSet);
155
+ return {
156
+ status: 'success',
157
+ code: 'OK',
158
+ retryable: false,
159
+ changedFiles: result.changedFiles,
160
+ changeSet: summary,
161
+ output: `已事务化应用 ChangeSet ${summary.id}:\n${summary.changes.map((item) => `- ${item.operation} ${item.path} (${item.beforeHash ?? 'missing'} -> ${item.afterHash ?? 'missing'})`).join('\n')}`,
162
+ };
163
+ }
164
+ catch (error) {
165
+ return {
166
+ status: 'error',
167
+ code: 'PATCH_INVALID',
168
+ retryable: false,
169
+ changedFiles: [],
170
+ output: `错误:apply_patch 无效,磁盘未发生变化: ${error instanceof Error ? error.message : String(error)}`,
171
+ };
172
+ }
173
+ },
174
+ };
@@ -1,66 +1,79 @@
1
- import { readFile, writeFile } from 'node:fs/promises';
2
- import { resolve } from 'node:path';
3
- import { verifyWrittenFile } from '../../verification/postconditions.js';
4
- // ---------- edit_file ----------
1
+ import { readFile } from 'node:fs/promises';
2
+ import { jailResolve } from '../../sandbox/index.js';
3
+ import { commitChangeSet, createChangeSet, normalizeContentHash, summarizeChangeSet, } from '../../changeset/index.js';
4
+ function conflict(path, details) {
5
+ return {
6
+ status: 'error',
7
+ code: 'CHANGE_CONFLICT',
8
+ retryable: false,
9
+ changedFiles: [],
10
+ staleFiles: [path],
11
+ output: `CHANGE_CONFLICT: ${path} was not changed. ${details} Do not retry these arguments. Call read_file on this exact path and copy both its latest hash and exact target text before editing again.`,
12
+ };
13
+ }
5
14
  export const editFileTool = {
6
15
  name: 'edit_file',
7
- description: 'Replace a string in a file. old_string must occur exactly once and match exactly (including indentation/newlines). Copy old_string verbatim from a fresh read_file result for this path; do not reconstruct it from memory or summaries. Use write_file for new files.',
16
+ description: 'Replace one exact string in a file transactionally. expected_hash is required and must be copied from a fresh read_file artifact header. If the file changes after that read, the edit is rejected without writing.',
8
17
  risk: 'confirm',
9
18
  parameters: {
10
19
  type: 'object',
11
20
  properties: {
12
21
  path: { type: 'string' },
13
- old_string: { type: 'string', description: 'The original text to be replaced; must match exactly' },
14
- new_string: { type: 'string', description: 'The new text to replace it with' },
22
+ old_string: { type: 'string', description: 'The original text; must occur exactly once.' },
23
+ new_string: { type: 'string', description: 'Replacement text.' },
24
+ expected_hash: { type: 'string', description: 'sha256 hash from the latest read_file artifact header.' },
15
25
  },
16
- required: ['path', 'old_string', 'new_string'],
26
+ required: ['path', 'old_string', 'new_string', 'expected_hash'],
17
27
  },
18
- async execute(args) {
19
- const path = String(args.path);
20
- const oldStr = String(args.old_string);
21
- const newStr = String(args.new_string);
22
- const full = resolve(path);
23
- const data = await readFile(full, 'utf8');
24
- // 行尾归一化:read_file 用 split(/\r?\n/) 输出纯 LF,LLM 据此构造的 old_string/new_string
25
- // 也是 LF;但本工具原样读文件(CRLF 保留),直接精确匹配会在 CRLF 文件上必败(文件 \r\n 对不上
26
- // old_string \n)。故匹配/计数在归一化(LF)文本上做,写回时按文件原始行尾风格还原,
27
- // 不把 CRLF 文件悄悄换成 LF(只含 LF 的纯 LF 文件 norm===data,行为完全不变)
28
- const norm = data.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
29
- const normOld = oldStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
30
- const normNew = newStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
31
- const count = norm.split(normOld).length - 1;
28
+ async execute(args, ctx) {
29
+ const file = String(args.path);
30
+ const oldString = String(args.old_string);
31
+ const newString = String(args.new_string);
32
+ const expectedHash = normalizeContentHash(String(args.expected_hash));
33
+ if (!expectedHash)
34
+ return conflict(file, 'expected_hash 必须是 sha256:<64 hex>。');
35
+ const data = await readFile(jailResolve(file), 'utf8');
36
+ const normalized = data.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
37
+ const oldNormalized = oldString.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
38
+ const newNormalized = newString.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
39
+ const count = normalized.split(oldNormalized).length - 1;
32
40
  if (count === 0) {
33
- return {
34
- status: 'error',
35
- code: 'EDIT_CONFLICT',
36
- retryable: false,
37
- output: `错误:在 ${path} 中未找到 old_string。不要重试相同参数;请先 read_file 读取目标区域,再从返回内容逐字复制新的 old_string 后重试。`,
38
- };
41
+ return conflict(file, 'old_string 未找到;请重新 read_file 并复制最新内容。');
39
42
  }
40
43
  if (count > 1) {
41
- return {
42
- status: 'error',
43
- code: 'EDIT_CONFLICT',
44
- retryable: false,
45
- output: `错误:old_string ${path} 中出现 ${count} 次,不唯一。请加入更多上下文使其唯一。`,
46
- };
44
+ return conflict(file, `old_string 出现 ${count} 次;请增加上下文使其唯一。`);
45
+ }
46
+ const updated = normalized.replace(oldNormalized, () => newNormalized);
47
+ const replacement = data.includes('\r\n') ? updated.replace(/\n/g, '\r\n') : updated;
48
+ const result = await commitChangeSet(createChangeSet([{
49
+ path: file,
50
+ operation: 'update',
51
+ expectedHash,
52
+ replacement,
53
+ }]), ctx?.signal);
54
+ if (result.status === 'conflict') {
55
+ const item = result.conflicts[0];
56
+ return conflict(file, `expected=${item?.expectedHash ?? 'missing'}, actual=${item?.actualHash ?? 'missing'}。请重新读取后再编辑。`);
47
57
  }
48
- // 用函数形式替换,避免 new_string 里的 $ 被当特殊模式
49
- const updated = norm.replace(normOld, () => normNew);
50
- // 检测原始行尾风格,写回时还原(存在 \r\n 即视为 CRLF 文件;纯 LF 文件保持 LF)
51
- const out = data.includes('\r\n') ? updated.replace(/\n/g, '\r\n') : updated;
52
- await writeFile(full, out, 'utf8');
53
- const postcondition = await verifyWrittenFile(full, out);
54
- if (postcondition.status === 'failed') {
58
+ if (result.status === 'failed') {
55
59
  return {
56
60
  status: 'error',
57
- code: 'POSTCONDITION_FAILED',
61
+ code: 'EXECUTION_ERROR',
58
62
  retryable: false,
59
- output: postcondition.diagnostics
60
- .map((item) => `[${item.code ?? 'V0_FAILED'}] ${item.file ?? path}: ${item.message}`)
61
- .join('\n'),
63
+ changedFiles: [],
64
+ output: `错误:ChangeSet 提交失败并已执行恢复: ${result.error}`,
62
65
  };
63
66
  }
64
- return `已在 ${path} 中完成 1 处替换 (sha256=${postcondition.actualHash})。`;
67
+ const summary = summarizeChangeSet(result.changeSet);
68
+ return {
69
+ status: 'success',
70
+ code: 'OK',
71
+ retryable: false,
72
+ changedFiles: result.changedFiles,
73
+ changeSet: summary,
74
+ output: result.changedFiles.length === 0
75
+ ? `文件 ${file} 内容未变化 (ChangeSet ${summary.id})。`
76
+ : `已事务化编辑 ${file} (ChangeSet ${summary.id}, sha256=${summary.changes[0]?.afterHash})。`,
77
+ };
65
78
  },
66
79
  };
@@ -16,7 +16,7 @@ import { memorySearchTool } from './memory-search.js';
16
16
  import { memoryListTool } from './memory-list.js';
17
17
  import { memoryUpdateTool } from './memory-update.js';
18
18
  import { memoryForgetTool } from './memory-forget.js';
19
- import { taskTool } from './task.js';
19
+ import { subAgentTool } from './task.js';
20
20
  import { projectSkillUpdateTool } from './project-skill-update.js';
21
21
  /**
22
22
  * 所有内置工具,按注册顺序排列。
@@ -51,8 +51,8 @@ const workspaceResource = () => ['workspace'];
51
51
  const memoryResource = () => ['memory-store'];
52
52
  const CAPABILITIES = {
53
53
  read_file: { effect: 'read', concurrency: 'parallel', retry: 'safe', resources: pathResource },
54
- write_file: { effect: 'write', concurrency: 'resource-locked', retry: 'never', resources: pathResource },
55
- edit_file: { effect: 'write', concurrency: 'resource-locked', retry: 'never', resources: pathResource },
54
+ write_file: { effect: 'write', concurrency: 'resource-locked', retry: 'never', resources: pathResource, delegatesResourceLocks: true },
55
+ edit_file: { effect: 'write', concurrency: 'resource-locked', retry: 'never', resources: pathResource, delegatesResourceLocks: true },
56
56
  run_command: { effect: 'process', concurrency: 'serial', retry: 'never', resources: workspaceResource, supportsAbort: true },
57
57
  glob: { effect: 'read', concurrency: 'parallel', retry: 'safe', resources: workspaceResource },
58
58
  grep: { effect: 'read', concurrency: 'parallel', retry: 'safe', resources: workspaceResource },
@@ -69,12 +69,14 @@ const CAPABILITIES = {
69
69
  memory_update: { effect: 'write', concurrency: 'serial', retry: 'never', resources: memoryResource },
70
70
  memory_forget: { effect: 'write', concurrency: 'serial', retry: 'never', resources: memoryResource },
71
71
  project_skill_update: { effect: 'write', concurrency: 'serial', retry: 'never', resources: workspaceResource },
72
- // task 只编排子 Agent;真实读写由子调用自行持锁,父调用不得包 workspace 锁。
73
- task: {
72
+ // sub-agent 动态协调:只读任务无锁并行;写任务在 overlay 中执行,merge 时由 ChangeSet 持 canonical lock。
73
+ 'sub-agent': {
74
74
  effect: 'write',
75
- concurrency: 'serial',
75
+ concurrency: 'resource-locked',
76
76
  retry: 'never',
77
- resources: workspaceResource,
77
+ resources: (args) => args.mode === 'write' && Array.isArray(args.writeSet) && args.writeSet.length
78
+ ? args.writeSet.map((item) => `file:${String(item)}`)
79
+ : args.mode === 'write' ? ['workspace'] : [],
78
80
  delegatesResourceLocks: true,
79
81
  supportsAbort: true,
80
82
  },
@@ -95,7 +97,7 @@ const rawBuiltinTools = [
95
97
  dropContextTool,
96
98
  ..._memoryTools,
97
99
  ..._projectSkillTools,
98
- taskTool,
100
+ subAgentTool,
99
101
  ];
100
102
  /** 所有内置工具均携带显式能力;新增工具遗漏声明时 registry 会保守串行。 */
101
103
  export const builtinTools = rawBuiltinTools.map((tool) => ({
@@ -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
  };