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.
- package/README.md +58 -4
- package/README.zh-CN.md +58 -4
- package/dist/agent/core.js +86 -34
- 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/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 +59 -46
- 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/write-file.js +52 -17
- package/dist/tools/constants.js +3 -3
- package/dist/tools/registry.js +5 -2
- package/dist/ui/batch.js +56 -24
- package/dist/ui/layout.js +8 -3
- package/package.json +1 -1
package/dist/agent/spawn.js
CHANGED
|
@@ -5,28 +5,34 @@
|
|
|
5
5
|
// - 不写主屏(layout.contentWrite):中间过程(流式正文 / 工具头 / diff)缓冲到内部字符串,
|
|
6
6
|
// 结束返回给 task 工具(task 把它当 tool 结果回灌主 history,主 agent 据此继续)。
|
|
7
7
|
// - 独立 history:不共享主对话,避免子任务的工具噪声污染主上下文。
|
|
8
|
-
// -
|
|
9
|
-
// -
|
|
8
|
+
// - 紧凑系统提示:不复制主 agent 的 memory/skills/项目快照;由 context 传入已知事实。
|
|
9
|
+
// - 工具子集:写任务默认继承主 Agent 工具(仅禁止递归 task);只读模式按安全语义移除写工具。
|
|
10
10
|
// - 不调 beginTurn:子 agent 共享主 agent 当前轮次;其文件修改进入同一回滚事务。
|
|
11
|
-
// -
|
|
11
|
+
// - 步数默认与主 Agent 相同,只作为无限循环保险,不以 token 配额提前终止有效任务。
|
|
12
12
|
// - 中断透传:opts.signal(主 agent 的 abort signal)透传给 runAgentCore → chat/executeTool,
|
|
13
13
|
// 主 Ctrl+C 树杀子 agent(chat 流式 abort + run_command/web_fetch 即时取消)。
|
|
14
14
|
import { chatTools } from '../llm/index.js';
|
|
15
|
-
import {
|
|
15
|
+
import { buildMocodeCorePrompt, config, isSubAgentEnabled } from '../config/index.js';
|
|
16
16
|
import { effectiveSystemPrompt } from '../skills/index.js';
|
|
17
|
-
import { buildMemorySection, buildMemoryIndexSection } from '../memory/index.js';
|
|
18
17
|
import { ui } from '../ui/theme.js';
|
|
19
18
|
import { runAgentCore } from './core.js';
|
|
20
19
|
import { summarizeToolCall, summarizeToolResult, truncateDisplay } from '../ui/render.js';
|
|
21
20
|
import { createContextState } from '../session/compact.js';
|
|
21
|
+
import { inOverlay, mergeSubAgentChangeSet } from '../agents/coordinator.js';
|
|
22
22
|
/** 子 agent 系统提示后缀:角色与约束。 */
|
|
23
|
-
const SUBAGENT_SUFFIX = `
|
|
23
|
+
const SUBAGENT_SUFFIX = `
|
|
24
24
|
|
|
25
25
|
## ⛯ SUB-AGENT MODE (you are a sub-agent)
|
|
26
26
|
You are a sub-agent spawned by the main agent to handle an isolated sub-task. You have your own conversation history (independent of the main thread).
|
|
27
|
-
- Focus solely on the assigned sub-task. Do NOT attempt to call the "
|
|
27
|
+
- Focus solely on the assigned sub-task. Do NOT attempt to call the "sub-agent" tool (no recursive spawning).
|
|
28
28
|
- Use the tools available to you to complete the sub-task.
|
|
29
29
|
- When done, your final text reply will be returned to the main agent as a summary — make it concise and actionable: what you did, key findings, files changed, and any issues. The main agent will decide the next step based on your summary.`;
|
|
30
|
+
const SUBAGENT_ROLE = `## Sub-agent execution
|
|
31
|
+
You are executing one delegated sub-task with the same engineering standards and capabilities as mocode.
|
|
32
|
+
- Treat Task context as authoritative facts already established by the main agent; do not rediscover them without evidence they are stale.
|
|
33
|
+
- Focus on the delegated scope, but continue until it is genuinely complete. Do not stop to save tokens.
|
|
34
|
+
- Do not recursively call sub-agent. A write task runs in an isolated overlay; the coordinator merges and performs final unified verification.
|
|
35
|
+
- Return concise findings, changes, verification evidence, and blockers to the coordinator.`;
|
|
30
36
|
/**
|
|
31
37
|
* 派生一个子 agent 执行独立子任务。
|
|
32
38
|
*
|
|
@@ -45,28 +51,25 @@ export async function spawnAgent(opts) {
|
|
|
45
51
|
summary: null,
|
|
46
52
|
completed: false,
|
|
47
53
|
transcript: 'Sub-agent execution is disabled. Enable it with /subagent on.',
|
|
54
|
+
status: 'failed', findings: [], readSet: [], changeSet: null, verification: null,
|
|
55
|
+
usage: { promptTokens: 0, completionTokens: 0, totalTokens: 0, cachedTokens: 0, reasoningTokens: 0 },
|
|
48
56
|
};
|
|
49
57
|
}
|
|
50
|
-
const maxSteps = opts.maxSteps ?? config.subAgentMaxSteps
|
|
51
|
-
//
|
|
52
|
-
|
|
53
|
-
// 所以这里直接读 config.systemPrompt 即可;buildMemoryIndexSection 显式按 isMemoryEnabled() 传参,
|
|
54
|
-
// 关闭时该段不进。注意:不能从 spawn.ts 直接 import buildBasePrompt —— 这会
|
|
55
|
-
// 拉起 config → llm → registry → builtins → task → spawn 形成循环求值死锁。
|
|
56
|
-
const systemPrompt = effectiveSystemPrompt(config.systemPrompt +
|
|
57
|
-
buildMemorySection() +
|
|
58
|
-
buildMemoryIndexSection(isMemoryEnabled()) +
|
|
59
|
-
SUBAGENT_SUFFIX +
|
|
58
|
+
const maxSteps = opts.maxSteps ?? config.subAgentMaxSteps;
|
|
59
|
+
// 构造窄 worker prompt;主 Agent 已知事实只通过有界 context 注入,避免重复探索与重复计费。
|
|
60
|
+
const systemPrompt = effectiveSystemPrompt(buildMocodeCorePrompt() + '\n\n' + SUBAGENT_ROLE + SUBAGENT_SUFFIX +
|
|
60
61
|
(opts.systemPromptSuffix ? `\n\n${opts.systemPromptSuffix}` : ''));
|
|
61
|
-
|
|
62
|
+
const taskPrompt = opts.context?.trim()
|
|
63
|
+
? `Task context (authoritative; do not rediscover):\n${opts.context.slice(0, 4000)}\n\nSub-task:\n${opts.prompt}`
|
|
64
|
+
: opts.prompt;
|
|
65
|
+
// 写 worker 保留主 Agent 的完整能力;只读 mode 仅按调用契约移除副作用工具。
|
|
62
66
|
let toolsOverride;
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
}
|
|
67
|
+
const mode = opts.mode ?? 'read';
|
|
68
|
+
const requested = opts.tools?.length ? new Set(opts.tools) : null;
|
|
69
|
+
const readOnly = new Set(['read_file', 'glob', 'grep', 'codegraph', 'web_search', 'web_fetch', 'use_skill', 'memory_search', 'memory_list']);
|
|
70
|
+
toolsOverride = chatTools.filter((tool) => tool.function.name !== 'sub-agent' &&
|
|
71
|
+
(!requested || requested.has(tool.function.name)) &&
|
|
72
|
+
(mode === 'write' || readOnly.has(tool.function.name)));
|
|
70
73
|
// 独立 history(子 agent 自己持有,不共享主对话)。
|
|
71
74
|
// 只塞 system;user 消息由 runAgentCore 的 userInput 参数 push(与主 agent 一致)。
|
|
72
75
|
const history = [
|
|
@@ -123,19 +126,60 @@ export async function spawnAgent(opts) {
|
|
|
123
126
|
// 每个子 agent 独享统计/预算状态。不能保存再恢复模块级单例:多个 task 并发时
|
|
124
127
|
// save/restore 会竞态,且 lastEstimate / schedulerLog 仍会污染主 agent。
|
|
125
128
|
const localContextState = createContextState();
|
|
126
|
-
const
|
|
129
|
+
const readSet = new Set();
|
|
130
|
+
const run = () => runAgentCore({
|
|
127
131
|
history,
|
|
128
|
-
userInput:
|
|
129
|
-
signal: opts.signal,
|
|
132
|
+
userInput: taskPrompt,
|
|
133
|
+
signal: opts.signal,
|
|
130
134
|
hooks,
|
|
131
135
|
maxSteps,
|
|
132
136
|
toolsOverride,
|
|
133
137
|
contextState: localContextState,
|
|
134
|
-
autoValidate: false,
|
|
138
|
+
autoValidate: false,
|
|
139
|
+
onToolOutcome: (tool, args) => {
|
|
140
|
+
if (tool === 'read_file' && typeof args.path === 'string')
|
|
141
|
+
readSet.add(args.path);
|
|
142
|
+
else if (['glob', 'grep', 'codegraph'].includes(tool))
|
|
143
|
+
readSet.add('workspace');
|
|
144
|
+
},
|
|
135
145
|
});
|
|
146
|
+
let result;
|
|
147
|
+
let changeSet = null;
|
|
148
|
+
let mergeStatus = 'committed';
|
|
149
|
+
if (opts.mode === 'write') {
|
|
150
|
+
const isolated = await inOverlay(run);
|
|
151
|
+
result = isolated.value;
|
|
152
|
+
changeSet = isolated.changeSet;
|
|
153
|
+
const declared = new Set((opts.writeSet ?? []).map((item) => item.replaceAll('\\', '/').toLowerCase()));
|
|
154
|
+
const outsideDeclaration = declared.size > 0 && changeSet?.changes.some((change) => !declared.has(change.path.replaceAll('\\', '/').toLowerCase()));
|
|
155
|
+
if (!result.completed || outsideDeclaration)
|
|
156
|
+
mergeStatus = 'failed';
|
|
157
|
+
else
|
|
158
|
+
mergeStatus = await mergeSubAgentChangeSet(changeSet, opts.signal);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
result = await run();
|
|
162
|
+
}
|
|
163
|
+
const status = opts.signal?.aborted || result.terminationReason === 'aborted'
|
|
164
|
+
? 'aborted'
|
|
165
|
+
: mergeStatus === 'conflict' ? 'conflict'
|
|
166
|
+
: mergeStatus === 'failed' || !result.completed ? 'failed'
|
|
167
|
+
: 'completed';
|
|
136
168
|
return {
|
|
137
169
|
summary: result.finalText,
|
|
138
|
-
completed: result.completed,
|
|
170
|
+
completed: result.completed && status === 'completed',
|
|
139
171
|
transcript: truncateDisplay(transcript, 20000), // 防过大;调试用,回灌主 history 的是 summary 不是 transcript
|
|
172
|
+
status,
|
|
173
|
+
findings: result.finalText ? [result.finalText] : [],
|
|
174
|
+
readSet: [...readSet].sort(),
|
|
175
|
+
changeSet,
|
|
176
|
+
verification: null, // 主 Agent 在所有 coordinator merge 完成后统一验证
|
|
177
|
+
usage: {
|
|
178
|
+
promptTokens: result.usage?.promptTokens ?? 0,
|
|
179
|
+
completionTokens: result.usage?.completionTokens ?? 0,
|
|
180
|
+
totalTokens: result.usage?.totalTokens ?? 0,
|
|
181
|
+
cachedTokens: result.usage?.cachedTokens ?? 0,
|
|
182
|
+
reasoningTokens: result.usage?.reasoningTokens ?? 0,
|
|
183
|
+
},
|
|
140
184
|
};
|
|
141
185
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { cp, mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { commitChangeSet, contentHash, createChangeSet } from '../changeset/index.js';
|
|
5
|
+
import { getSandboxRoot, withSandboxRoot } from '../sandbox/index.js';
|
|
6
|
+
const EXCLUDED = new Set(['.git', 'node_modules', 'dist', '.mocode']);
|
|
7
|
+
async function filesBelow(root, dir = root, out = new Map()) {
|
|
8
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
9
|
+
if (EXCLUDED.has(entry.name))
|
|
10
|
+
continue;
|
|
11
|
+
const absolute = path.join(dir, entry.name);
|
|
12
|
+
if (entry.isDirectory())
|
|
13
|
+
await filesBelow(root, absolute, out);
|
|
14
|
+
else if (entry.isFile())
|
|
15
|
+
out.set(path.relative(root, absolute).replaceAll('\\', '/'), await readFile(absolute));
|
|
16
|
+
}
|
|
17
|
+
return out;
|
|
18
|
+
}
|
|
19
|
+
function text(buffer) {
|
|
20
|
+
if (buffer.includes(0))
|
|
21
|
+
throw new Error('子 Agent overlay 暂不支持合并二进制文件。');
|
|
22
|
+
return buffer.toString('utf8');
|
|
23
|
+
}
|
|
24
|
+
function diffToChangeSet(before, after) {
|
|
25
|
+
const changes = [];
|
|
26
|
+
for (const file of new Set([...before.keys(), ...after.keys()])) {
|
|
27
|
+
const oldValue = before.get(file);
|
|
28
|
+
const newValue = after.get(file);
|
|
29
|
+
if (oldValue && newValue && oldValue.equals(newValue))
|
|
30
|
+
continue;
|
|
31
|
+
if (!oldValue && newValue)
|
|
32
|
+
changes.push({ path: file, operation: 'create', expectedHash: null, replacement: text(newValue) });
|
|
33
|
+
else if (oldValue && !newValue)
|
|
34
|
+
changes.push({ path: file, operation: 'delete', expectedHash: contentHash(oldValue) });
|
|
35
|
+
else if (oldValue && newValue)
|
|
36
|
+
changes.push({ path: file, operation: 'update', expectedHash: contentHash(oldValue), replacement: text(newValue) });
|
|
37
|
+
}
|
|
38
|
+
return changes.length ? createChangeSet(changes) : null;
|
|
39
|
+
}
|
|
40
|
+
/** Execute a writer in a private filesystem overlay and return, but do not merge, its ChangeSet. */
|
|
41
|
+
export async function inOverlay(run) {
|
|
42
|
+
const base = path.resolve(getSandboxRoot() ?? process.cwd());
|
|
43
|
+
const overlay = await mkdtemp(path.join(os.tmpdir(), 'mocode-subagent-'));
|
|
44
|
+
try {
|
|
45
|
+
await cp(base, overlay, { recursive: true, filter: (source) => !EXCLUDED.has(path.basename(source)) });
|
|
46
|
+
const before = await filesBelow(base);
|
|
47
|
+
const value = await withSandboxRoot(overlay, run);
|
|
48
|
+
return { value, changeSet: diffToChangeSet(before, await filesBelow(overlay)) };
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
await rm(overlay, { recursive: true, force: true });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** The only merge point: ChangeSet preconditions and canonical resource locks prevent silent overwrite. */
|
|
55
|
+
export async function mergeSubAgentChangeSet(changeSet, signal) {
|
|
56
|
+
if (!changeSet)
|
|
57
|
+
return 'committed';
|
|
58
|
+
const result = await commitChangeSet(changeSet, signal);
|
|
59
|
+
return result.status;
|
|
60
|
+
}
|
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmod, copyFile, mkdir, readFile, rename, rm, rmdir, stat, writeFile, } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { jailResolve } from '../sandbox/index.js';
|
|
6
|
+
import { beginPathMutation, endPathMutation } from '../rollback/index.js';
|
|
7
|
+
import { canonicalFileResourceKey, toolResourceLockManager, } from '../tools/resource-lock.js';
|
|
8
|
+
export function contentHash(content) {
|
|
9
|
+
const data = typeof content === 'string' ? Buffer.from(content, 'utf8') : content;
|
|
10
|
+
return `sha256:${createHash('sha256').update(data).digest('hex')}`;
|
|
11
|
+
}
|
|
12
|
+
export function normalizeContentHash(value) {
|
|
13
|
+
const normalized = value.trim().toLowerCase();
|
|
14
|
+
const hex = normalized.startsWith('sha256:') ? normalized.slice(7) : normalized;
|
|
15
|
+
return /^[a-f0-9]{64}$/.test(hex) ? `sha256:${hex}` : null;
|
|
16
|
+
}
|
|
17
|
+
function applyTextEdits(source, edits) {
|
|
18
|
+
const ordered = [...edits].sort((left, right) => left.start - right.start || left.end - right.end);
|
|
19
|
+
let cursor = 0;
|
|
20
|
+
let output = '';
|
|
21
|
+
for (const edit of ordered) {
|
|
22
|
+
if (!Number.isInteger(edit.start) || !Number.isInteger(edit.end) ||
|
|
23
|
+
edit.start < cursor || edit.end < edit.start || edit.end > source.length) {
|
|
24
|
+
throw new Error(`无效或重叠的 TextEdit 范围: ${edit.start}..${edit.end}`);
|
|
25
|
+
}
|
|
26
|
+
output += source.slice(cursor, edit.start) + edit.newText;
|
|
27
|
+
cursor = edit.end;
|
|
28
|
+
}
|
|
29
|
+
return output + source.slice(cursor);
|
|
30
|
+
}
|
|
31
|
+
async function readCurrent(file) {
|
|
32
|
+
try {
|
|
33
|
+
const info = await stat(file);
|
|
34
|
+
if (!info.isFile())
|
|
35
|
+
throw new Error(`目标不是普通文件: ${file}`);
|
|
36
|
+
return await readFile(file);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error.code === 'ENOENT')
|
|
40
|
+
return null;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function actualHash(content) {
|
|
45
|
+
return content === null ? null : contentHash(content);
|
|
46
|
+
}
|
|
47
|
+
export function createChangeSet(changes) {
|
|
48
|
+
return { id: randomUUID(), createdAt: Date.now(), changes };
|
|
49
|
+
}
|
|
50
|
+
/** Validate every precondition and calculate every output without touching disk. */
|
|
51
|
+
export async function dryRunChangeSet(changeSet) {
|
|
52
|
+
const prepared = [];
|
|
53
|
+
const conflicts = [];
|
|
54
|
+
const seen = new Set();
|
|
55
|
+
for (const change of changeSet.changes) {
|
|
56
|
+
const absolutePath = jailResolve(change.path);
|
|
57
|
+
const identity = process.platform === 'win32' ? absolutePath.toLowerCase() : absolutePath;
|
|
58
|
+
if (seen.has(identity)) {
|
|
59
|
+
conflicts.push({
|
|
60
|
+
path: change.path,
|
|
61
|
+
expectedHash: change.expectedHash,
|
|
62
|
+
actualHash: null,
|
|
63
|
+
reason: '同一 ChangeSet 不能多次修改同一路径。',
|
|
64
|
+
});
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
seen.add(identity);
|
|
68
|
+
const before = await readCurrent(absolutePath);
|
|
69
|
+
const beforeHash = actualHash(before);
|
|
70
|
+
if (beforeHash !== change.expectedHash) {
|
|
71
|
+
conflicts.push({
|
|
72
|
+
path: change.path,
|
|
73
|
+
expectedHash: change.expectedHash,
|
|
74
|
+
actualHash: beforeHash,
|
|
75
|
+
reason: '文件内容已变化或存在状态与预期不一致。',
|
|
76
|
+
});
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
if (change.operation === 'create' && before !== null)
|
|
81
|
+
throw new Error('创建目标已经存在。');
|
|
82
|
+
if (change.operation !== 'create' && before === null)
|
|
83
|
+
throw new Error('更新或删除目标不存在。');
|
|
84
|
+
if (change.operation === 'delete' && (change.replacement !== undefined || change.edits?.length)) {
|
|
85
|
+
throw new Error('删除操作不能包含 replacement 或 edits。');
|
|
86
|
+
}
|
|
87
|
+
if (change.replacement !== undefined && change.edits?.length) {
|
|
88
|
+
throw new Error('FileChange 不能同时包含 replacement 和 edits。');
|
|
89
|
+
}
|
|
90
|
+
let after = null;
|
|
91
|
+
if (change.operation !== 'delete') {
|
|
92
|
+
const source = before?.toString('utf8') ?? '';
|
|
93
|
+
const next = change.replacement ?? applyTextEdits(source, change.edits ?? []);
|
|
94
|
+
after = Buffer.from(next, 'utf8');
|
|
95
|
+
}
|
|
96
|
+
prepared.push({
|
|
97
|
+
...change,
|
|
98
|
+
absolutePath,
|
|
99
|
+
before,
|
|
100
|
+
after,
|
|
101
|
+
beforeHash,
|
|
102
|
+
afterHash: actualHash(after),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
conflicts.push({
|
|
107
|
+
path: change.path,
|
|
108
|
+
expectedHash: change.expectedHash,
|
|
109
|
+
actualHash: beforeHash,
|
|
110
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return conflicts.length > 0
|
|
115
|
+
? { ok: false, conflicts }
|
|
116
|
+
: { ok: true, changeSet: { ...changeSet, prepared } };
|
|
117
|
+
}
|
|
118
|
+
async function removeIfPresent(target) {
|
|
119
|
+
await rm(target, { recursive: true, force: true });
|
|
120
|
+
}
|
|
121
|
+
async function verifyPreparedVersions(prepared) {
|
|
122
|
+
const conflicts = [];
|
|
123
|
+
for (const change of prepared) {
|
|
124
|
+
const current = await readCurrent(change.absolutePath);
|
|
125
|
+
const currentHash = actualHash(current);
|
|
126
|
+
if (currentHash !== change.beforeHash) {
|
|
127
|
+
conflicts.push({
|
|
128
|
+
path: change.path,
|
|
129
|
+
expectedHash: change.beforeHash,
|
|
130
|
+
actualHash: currentHash,
|
|
131
|
+
reason: 'dry-run 后文件又被外部修改。',
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return conflicts;
|
|
136
|
+
}
|
|
137
|
+
function lockRequests(changeSet) {
|
|
138
|
+
return changeSet.changes.map((change) => ({
|
|
139
|
+
key: canonicalFileResourceKey(change.path),
|
|
140
|
+
scope: 'resource',
|
|
141
|
+
mode: 'write',
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
async function missingParentDirectories(file) {
|
|
145
|
+
const result = [];
|
|
146
|
+
let cursor = path.dirname(file);
|
|
147
|
+
while (true) {
|
|
148
|
+
try {
|
|
149
|
+
await stat(cursor);
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error.code !== 'ENOENT')
|
|
154
|
+
throw error;
|
|
155
|
+
result.push(cursor);
|
|
156
|
+
const parent = path.dirname(cursor);
|
|
157
|
+
if (parent === cursor)
|
|
158
|
+
break;
|
|
159
|
+
cursor = parent;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
/** Commit is process-transactional: every target is prepared first and any failed swap is compensated. */
|
|
165
|
+
export async function commitChangeSet(changeSet, signal) {
|
|
166
|
+
try {
|
|
167
|
+
return await toolResourceLockManager.withLocks(lockRequests(changeSet), signal, async () => {
|
|
168
|
+
if (signal?.aborted) {
|
|
169
|
+
return { status: 'failed', changeSet, error: 'ChangeSet 在提交前被中断。', changedFiles: [] };
|
|
170
|
+
}
|
|
171
|
+
const dryRun = await dryRunChangeSet(changeSet);
|
|
172
|
+
if (!dryRun.ok) {
|
|
173
|
+
return { status: 'conflict', changeSet, conflicts: dryRun.conflicts, changedFiles: [] };
|
|
174
|
+
}
|
|
175
|
+
const effective = dryRun.changeSet.prepared.filter((change) => change.beforeHash !== change.afterHash);
|
|
176
|
+
if (effective.length === 0) {
|
|
177
|
+
return { status: 'committed', changeSet: dryRun.changeSet, changedFiles: [] };
|
|
178
|
+
}
|
|
179
|
+
const captures = effective.map((change) => ({
|
|
180
|
+
change,
|
|
181
|
+
capture: beginPathMutation(change.absolutePath),
|
|
182
|
+
}));
|
|
183
|
+
const createdDirectories = new Set();
|
|
184
|
+
const tempByPath = new Map();
|
|
185
|
+
const backupByPath = new Map();
|
|
186
|
+
const committed = [];
|
|
187
|
+
try {
|
|
188
|
+
// Prepare all parent directories and temp files before replacing any target.
|
|
189
|
+
for (const change of effective) {
|
|
190
|
+
for (const directory of await missingParentDirectories(change.absolutePath)) {
|
|
191
|
+
createdDirectories.add(directory);
|
|
192
|
+
}
|
|
193
|
+
await mkdir(path.dirname(change.absolutePath), { recursive: true });
|
|
194
|
+
if (change.after !== null) {
|
|
195
|
+
const temp = path.join(path.dirname(change.absolutePath), `.${path.basename(change.absolutePath)}.${changeSet.id}.tmp`);
|
|
196
|
+
await writeFile(temp, change.after, { flag: 'wx' });
|
|
197
|
+
if (change.before !== null) {
|
|
198
|
+
const currentMode = (await stat(change.absolutePath)).mode;
|
|
199
|
+
await chmod(temp, currentMode);
|
|
200
|
+
}
|
|
201
|
+
tempByPath.set(change.absolutePath, temp);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// Close the dry-run/commit gap before the first visible replacement.
|
|
205
|
+
const conflicts = await verifyPreparedVersions(effective);
|
|
206
|
+
if (conflicts.length > 0) {
|
|
207
|
+
return { status: 'conflict', changeSet, conflicts, changedFiles: [] };
|
|
208
|
+
}
|
|
209
|
+
// Once the first swap starts, finish or compensate even if the caller aborts.
|
|
210
|
+
for (const change of effective) {
|
|
211
|
+
const backup = path.join(path.dirname(change.absolutePath), `.${path.basename(change.absolutePath)}.${changeSet.id}.bak`);
|
|
212
|
+
if (change.before !== null) {
|
|
213
|
+
if (change.after === null)
|
|
214
|
+
await rename(change.absolutePath, backup);
|
|
215
|
+
else
|
|
216
|
+
await copyFile(change.absolutePath, backup);
|
|
217
|
+
backupByPath.set(change.absolutePath, backup);
|
|
218
|
+
}
|
|
219
|
+
committed.push(change);
|
|
220
|
+
const temp = tempByPath.get(change.absolutePath);
|
|
221
|
+
// Same-directory rename is the atomic visibility boundary for creates/updates.
|
|
222
|
+
if (temp)
|
|
223
|
+
await rename(temp, change.absolutePath);
|
|
224
|
+
}
|
|
225
|
+
for (const { change, capture } of captures)
|
|
226
|
+
endPathMutation(capture, `changeset:${changeSet.id}`);
|
|
227
|
+
for (const backup of backupByPath.values()) {
|
|
228
|
+
await removeIfPresent(backup).catch(() => undefined);
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
status: 'committed',
|
|
232
|
+
changeSet: dryRun.changeSet,
|
|
233
|
+
changedFiles: effective.map((change) => change.path),
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
// Reverse every visible replacement. Backups are kept until the full set succeeds.
|
|
238
|
+
for (const change of [...committed].reverse()) {
|
|
239
|
+
try {
|
|
240
|
+
await removeIfPresent(change.absolutePath);
|
|
241
|
+
const backup = backupByPath.get(change.absolutePath);
|
|
242
|
+
if (backup) {
|
|
243
|
+
await rename(backup, change.absolutePath);
|
|
244
|
+
backupByPath.delete(change.absolutePath);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
// Continue restoring the remaining files; report the original commit failure below.
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
status: 'failed',
|
|
253
|
+
changeSet,
|
|
254
|
+
error: error instanceof Error ? error.message : String(error),
|
|
255
|
+
changedFiles: [],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
for (const temp of tempByPath.values())
|
|
260
|
+
await removeIfPresent(temp).catch(() => undefined);
|
|
261
|
+
// A backup left after compensation failure is deliberately preserved for manual recovery.
|
|
262
|
+
for (const directory of [...createdDirectories].sort((a, b) => b.length - a.length)) {
|
|
263
|
+
await rmdir(directory).catch(() => undefined);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
catch (error) {
|
|
269
|
+
return {
|
|
270
|
+
status: 'failed',
|
|
271
|
+
changeSet,
|
|
272
|
+
error: error instanceof Error ? error.message : String(error),
|
|
273
|
+
changedFiles: [],
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
export function summarizeChangeSet(changeSet) {
|
|
278
|
+
const effective = changeSet.prepared.filter((change) => change.beforeHash !== change.afterHash);
|
|
279
|
+
return {
|
|
280
|
+
id: changeSet.id,
|
|
281
|
+
changedFiles: effective.map((change) => change.path),
|
|
282
|
+
changes: effective.map((change) => ({
|
|
283
|
+
path: change.path,
|
|
284
|
+
operation: change.operation,
|
|
285
|
+
beforeHash: change.beforeHash,
|
|
286
|
+
afterHash: change.afterHash,
|
|
287
|
+
})),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/config/index.js
CHANGED
|
@@ -113,8 +113,7 @@ export function isProjectSnapshotEnabled() {
|
|
|
113
113
|
* Agent 用 write_file/edit_file/read_file 维护此文件,抗 compact(在 context window 之外)。
|
|
114
114
|
* 文件不存在或为空时返空串(零开销)。
|
|
115
115
|
*/
|
|
116
|
-
function buildNotepadSection() {
|
|
117
|
-
const sessionId = getCurrentSessionId();
|
|
116
|
+
function buildNotepadSection(sessionId = getCurrentSessionId()) {
|
|
118
117
|
if (!sessionId)
|
|
119
118
|
return '';
|
|
120
119
|
const root = getSandboxRoot() ?? process.cwd();
|
|
@@ -193,7 +192,7 @@ You are in PLAN mode: investigate and design only — do NOT execute or change a
|
|
|
193
192
|
${PLAN_RESEARCH_RULES}`;
|
|
194
193
|
}
|
|
195
194
|
/** 兼容旧名字:repl 的 buildSystemMessage 仍引 PLAN_MODE_SUFFIX(变量)。运行时按需现拼。 */
|
|
196
|
-
export function buildBasePrompt() {
|
|
195
|
+
export function buildBasePrompt(sessionId = getCurrentSessionId()) {
|
|
197
196
|
const autoAllToolsLine = isMemoryEnabled()
|
|
198
197
|
? '- Default is AUTO mode: you research and execute with all tools (read/edit/run_command/memory/web/skills).'
|
|
199
198
|
: '- Default is AUTO mode: you research and execute with all tools (read/edit/run_command/web/skills).';
|
|
@@ -219,7 +218,7 @@ ${PLATFORM_NOTE}
|
|
|
219
218
|
- Batch only independent read-only calls. After their results arrive, make the dependent edit in the next turn; then batch independent edits and one final verification when their exact inputs are already known.
|
|
220
219
|
- Read only what supports the next decision; verify once after a related edit set, not after every edit.
|
|
221
220
|
- Do not repeat an unchanged failing call; after three unproductive attempts, change tools or ask for the missing decision.
|
|
222
|
-
- For \`edit_file\`, derive \`old_string\` by copying the exact relevant lines from the latest successful \`read_file\` of that same path
|
|
221
|
+
- For \`edit_file\`, derive \`old_string\` by copying the exact relevant lines from the latest successful \`read_file\` of that same path and pass that read's \`expected_hash\`; never reconstruct either from memory, a summary, grep output, or a previous diff. That read becomes stale after any edit/write to the path, compaction/resume, or a possible external change. On a conflict, re-read the exact region and retry once with the new text and hash; never retry identical arguments.
|
|
223
222
|
|
|
224
223
|
## Workflow
|
|
225
224
|
- Understand requirements and current code before acting; do not guess.
|
|
@@ -229,7 +228,7 @@ ${PLATFORM_NOTE}
|
|
|
229
228
|
|
|
230
229
|
## Tool rules
|
|
231
230
|
- Precise path/symbol → go directly to \`read_file\` or \`codegraph node\`; use \`glob\`/\`grep\` only for discovery.
|
|
232
|
-
- Before editing, read the exact target region and
|
|
231
|
+
- Before editing, read the exact target region and copy both its artifact \`hash\` and verbatim text. Use \`edit_file\` with \`expected_hash\` for unique local replacements, and \`write_file\` with the latest hash for replacement (or null only for creation).
|
|
233
232
|
- Local edits require an exact unique match; use \`write_file\` for new/full files.
|
|
234
233
|
- Use \`glob\`/\`grep\` for discovery and \`run_command\` for execution or verification, not file existence checks. State intent before side effects.
|
|
235
234
|
- Call \`ask_human\` only when a real user decision is required; otherwise decide and proceed.
|
|
@@ -252,11 +251,11 @@ ${PLATFORM_NOTE}
|
|
|
252
251
|
- Operate only within authorized scope; when unsure, ask — don't guess.
|
|
253
252
|
|
|
254
253
|
## Project context (dynamic reference)
|
|
255
|
-
${buildSnapshotSection()}${config.projectSkillEnabled ? buildProjectSkillSection() : ''}${memorySection}${buildNotepadSection()}
|
|
254
|
+
${buildSnapshotSection()}${config.projectSkillEnabled ? buildProjectSkillSection() : ''}${memorySection}${buildNotepadSection(sessionId)}
|
|
256
255
|
|
|
257
256
|
## Session Notepad — working notes file
|
|
258
|
-
${
|
|
259
|
-
? `You maintain a working notepad at \`.mocode/sessions/${
|
|
257
|
+
${sessionId
|
|
258
|
+
? `You maintain a working notepad at \`.mocode/sessions/${sessionId}/notes.md\` using write_file / edit_file / read_file.`
|
|
260
259
|
: 'You maintain a working notepad (path will be shown after the session starts).'}
|
|
261
260
|
This is your private working surface — write intermediate findings, decisions, open questions,
|
|
262
261
|
and anything you might need to recall later. The file survives context compaction.
|
|
@@ -296,8 +295,8 @@ Example:
|
|
|
296
295
|
- [ ] Check if rate limiter interacts with auth middleware
|
|
297
296
|
|
|
298
297
|
### RULES
|
|
299
|
-
${
|
|
300
|
-
? `- Your notepad file path is: \`.mocode/sessions/${
|
|
298
|
+
${sessionId
|
|
299
|
+
? `- Your notepad file path is: \`.mocode/sessions/${sessionId}/notes.md\`. Use this exact path for all read_file/write_file/edit_file operations on your notes.`
|
|
301
300
|
: '- Your notepad file path will be available after the session starts.'}
|
|
302
301
|
- Use write_file to create/overwrite; use edit_file to append or modify sections
|
|
303
302
|
- Keep the file concise — summarize, don't dump raw tool output
|
|
@@ -331,6 +330,19 @@ Rules:
|
|
|
331
330
|
- **No flattery / no preamble in conclusions**: skip "Sure", "好的", "我已经完成了" and similar no-information prefixes — jump straight to substance.
|
|
332
331
|
- Report honestly: say success when successful, say where you're stuck when failing, and mention anything skipped. Reference code in "path:line" format (e.g., src/index.ts:42). Keep it concise.`;
|
|
333
332
|
}
|
|
333
|
+
/**
|
|
334
|
+
* Stable, production-grade behavior shared by main and sub agents.
|
|
335
|
+
* It intentionally excludes session/project payload (snapshot, memory index, notepad), while
|
|
336
|
+
* retaining the exact editing, verification, recovery, safety, and reporting rules.
|
|
337
|
+
*/
|
|
338
|
+
export function buildMocodeCorePrompt() {
|
|
339
|
+
const full = buildBasePrompt();
|
|
340
|
+
const dynamicStart = full.indexOf('## Project context (dynamic reference)');
|
|
341
|
+
const reportingStart = full.indexOf('## Termination & Reporting');
|
|
342
|
+
if (dynamicStart < 0 || reportingStart < dynamicStart)
|
|
343
|
+
return full;
|
|
344
|
+
return `${full.slice(0, dynamicStart).trimEnd()}\n\n${full.slice(reportingStart)}`;
|
|
345
|
+
}
|
|
334
346
|
/**
|
|
335
347
|
* plan 模式追加到系统提示末尾的指令。
|
|
336
348
|
* 历史曾是 `export const PLAN_MODE_SUFFIX`(顶层字面量);现改为按 isMemoryEnabled()
|
|
@@ -366,9 +378,9 @@ export const config = {
|
|
|
366
378
|
autoReflect: process.env.AUTO_REFLECT !== 'false',
|
|
367
379
|
memoryEnabled: process.env.MEMORY_ENABLED === 'true',
|
|
368
380
|
reflectEveryN: Number(process.env.REFLECT_EVERY_N) || 5,
|
|
369
|
-
maxSteps: Number(process.env.MAX_STEPS) ||
|
|
381
|
+
maxSteps: Number(process.env.MAX_STEPS) || 1000,
|
|
370
382
|
subAgentEnabled: process.env.MOCODE_SUBAGENT_ENABLED === 'true',
|
|
371
|
-
subAgentMaxSteps: Number(process.env.SUB_AGENT_MAX_STEPS) ||
|
|
383
|
+
subAgentMaxSteps: Number(process.env.SUB_AGENT_MAX_STEPS) || Number(process.env.MAX_STEPS) || 1000,
|
|
372
384
|
sessionDir: path.join(process.cwd(), '.mocode', 'sessions'),
|
|
373
385
|
searchApiKey: process.env.ANYSEARCH_API_KEY,
|
|
374
386
|
sandboxRoot: process.env.SANDBOX_ROOT || undefined,
|
|
@@ -410,7 +422,7 @@ export function updateModelConfig(opts) {
|
|
|
410
422
|
process.env.CONTEXT_WINDOW_TOKENS = String(opts.contextWindowTokens);
|
|
411
423
|
}
|
|
412
424
|
}
|
|
413
|
-
/** 子 Agent 总开关;默认 false,关闭时
|
|
425
|
+
/** 子 Agent 总开关;默认 false,关闭时 sub-agent 不进入模型工具表。 */
|
|
414
426
|
export function isSubAgentEnabled() {
|
|
415
427
|
return config.subAgentEnabled;
|
|
416
428
|
}
|