mocode-ai 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,7 +14,7 @@ import { runAgentCore, isMutationTool, } from './core.js';
14
14
  import { createPetHooks } from '../pet/state.js';
15
15
  import { t } from '../i18n/index.js';
16
16
  import { isToolErrorOutput } from '../tools/result.js';
17
- import { appendCurrentSessionTrace } from '../session/index.js';
17
+ import { appendCurrentSessionTraceEvent } from '../session/index.js';
18
18
  /** 当前 turn 的 batch id(runAgent 内闭包变量;一条 turn 一轮 tool batch 结束即清空)。 */
19
19
  let currentBatchId = null;
20
20
  /** 取 userInput 的首行:字符串直接 split;多模态 parts 找首个 text part 再 split。 */
@@ -229,7 +229,7 @@ onContextUpdate) {
229
229
  onContextUpdate,
230
230
  hooks: combinedHooks,
231
231
  autoValidate: config.autoValidate,
232
- onTrace: appendCurrentSessionTrace,
232
+ onTraceEvent: appendCurrentSessionTraceEvent,
233
233
  });
234
234
  }
235
235
  finally {
package/dist/llm/index.js CHANGED
@@ -229,6 +229,14 @@ function firstNumber(arr) {
229
229
  }
230
230
  return undefined;
231
231
  }
232
+ function retryErrorCode(error) {
233
+ if (!error || typeof error !== 'object')
234
+ return 'RETRYABLE_ERROR';
235
+ const value = error;
236
+ if (typeof value.status === 'number')
237
+ return `HTTP_${value.status}`;
238
+ return value.code ?? value.name ?? 'RETRYABLE_ERROR';
239
+ }
232
240
  /**
233
241
  * 流式调一次 LLM:增量回调文本,内部累加 tool_calls 片段。
234
242
  * tool_calls 跨 chunk 按 index 累加(id / name / arguments 拼接)。
@@ -254,6 +262,12 @@ toolsOverride) {
254
262
  throw err;
255
263
  }
256
264
  const wait = computeBackoff(attempt, getRetryAfterMs(err));
265
+ handlers.onRetry?.({
266
+ attempt,
267
+ nextAttempt: attempt + 1,
268
+ waitMs: wait,
269
+ code: retryErrorCode(err),
270
+ });
257
271
  logRetry(attempt, err, wait);
258
272
  // sleep 自己会在 signal abort 时抛 AbortError——透传,让 runAgentCore 的 catch 按中断处理。
259
273
  await sleep(wait, signal);
@@ -7,7 +7,9 @@ import { config } from '../config/index.js';
7
7
  import { getSandboxRoot } from '../sandbox/index.js';
8
8
  import { t } from '../i18n/index.js';
9
9
  const PERMISSIONS_PATH = path.join(os.homedir(), '.mocode', 'permissions.json');
10
+ const PERMISSIONS_VERSION = 3;
10
11
  let permanentGrants = [];
12
+ let permanentToolAllows = new Set();
11
13
  let permanentLoaded = false;
12
14
  const sessionGrants = [];
13
15
  function stable(value) {
@@ -31,7 +33,13 @@ export function permissionFingerprint(tool, args) {
31
33
  subject = { command: args.command.trim() };
32
34
  }
33
35
  else {
34
- const resources = tool.capabilities?.resources?.(args).filter(Boolean).sort();
36
+ let resources;
37
+ try {
38
+ resources = tool.capabilities?.resources?.(args).filter(Boolean).sort();
39
+ }
40
+ catch {
41
+ resources = undefined;
42
+ }
35
43
  // File mutations may be granted by their concrete resource. Coarse resources such as
36
44
  // "workspace" must retain arguments so task/process-like calls cannot become tool-wide.
37
45
  subject = resources?.length && typeof args.path === 'string'
@@ -58,16 +66,27 @@ function loadPermanent() {
58
66
  permanentGrants = Array.isArray(parsed.grants)
59
67
  ? parsed.grants.filter(validGrant).filter((grant) => grant.scope === 'project')
60
68
  : [];
69
+ // Only the explicit v3 field enables broad grants. The retired legacy allowForever
70
+ // field remains ignored so upgrades cannot silently restore old authorization.
71
+ permanentToolAllows = parsed.version === PERMISSIONS_VERSION && Array.isArray(parsed.alwaysAllowTools)
72
+ ? new Set(parsed.alwaysAllowTools.filter((tool) => typeof tool === 'string' && tool.length > 0))
73
+ : new Set();
61
74
  }
62
75
  catch {
63
76
  permanentGrants = [];
77
+ permanentToolAllows = new Set();
64
78
  }
65
79
  return permanentGrants;
66
80
  }
67
81
  function savePermanent() {
68
82
  try {
69
83
  fs.mkdirSync(path.dirname(PERMISSIONS_PATH), { recursive: true });
70
- fs.writeFileSync(PERMISSIONS_PATH, `${JSON.stringify({ version: 2, grants: permanentGrants }, null, 2)}\n`, 'utf8');
84
+ const data = {
85
+ version: PERMISSIONS_VERSION,
86
+ grants: permanentGrants,
87
+ alwaysAllowTools: [...permanentToolAllows].sort(),
88
+ };
89
+ fs.writeFileSync(PERMISSIONS_PATH, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
71
90
  }
72
91
  catch {
73
92
  // A failed persistence write must never turn into broader authorization.
@@ -98,11 +117,14 @@ export async function checkPermission(tool, args, signal, options = {}) {
98
117
  return 'allow';
99
118
  if (signal?.aborted)
100
119
  return 'deny';
120
+ loadPermanent();
121
+ if (permanentToolAllows.has(tool.name))
122
+ return 'allow';
101
123
  const fingerprint = permissionFingerprint(tool, args);
102
124
  const projectRoot = canonicalProjectRoot(options.projectRoot ?? getSandboxRoot() ?? process.cwd());
103
125
  if (sessionGrants.some((grant) => matches(grant, tool.name, fingerprint, projectRoot)))
104
126
  return 'allow';
105
- if (loadPermanent().some((grant) => matches(grant, tool.name, fingerprint, projectRoot)))
127
+ if (permanentGrants.some((grant) => matches(grant, tool.name, fingerprint, projectRoot)))
106
128
  return 'allow';
107
129
  // CI/pipes must fail closed. Operators can deliberately restore unattended behavior.
108
130
  if (!process.stdin.isTTY && !config.permissionNonInteractiveAllow && !options.prompt)
@@ -112,9 +134,10 @@ export async function checkPermission(tool, args, signal, options = {}) {
112
134
  const onceOption = t('permission.allow');
113
135
  const sessionOption = t('permission.allowSessionResource');
114
136
  const projectOption = t('permission.allowProjectResource');
137
+ const alwaysOption = t('permission.allowForever');
115
138
  const denyOption = t('permission.deny');
116
139
  const dangerous = getToolRisk(tool) === 'dangerous';
117
- const choices = [onceOption, sessionOption, projectOption, denyOption];
140
+ const choices = [onceOption, sessionOption, projectOption, alwaysOption, denyOption];
118
141
  const result = await (options.prompt ?? promptIntervention)({
119
142
  type: 'choice',
120
143
  title: dangerous
@@ -131,23 +154,44 @@ export async function checkPermission(tool, args, signal, options = {}) {
131
154
  }
132
155
  else if (result.value === projectOption) {
133
156
  const grant = { tool: tool.name, fingerprint, scope: 'project', projectRoot };
134
- permanentGrants = loadPermanent().filter((item) => !matches(item, tool.name, fingerprint, projectRoot));
157
+ permanentGrants = permanentGrants.filter((item) => !matches(item, tool.name, fingerprint, projectRoot));
135
158
  permanentGrants.push(grant);
136
159
  if (options.persistProjectGrant !== false)
137
160
  savePermanent();
138
161
  }
162
+ else if (result.value === alwaysOption) {
163
+ permanentToolAllows.add(tool.name);
164
+ if (options.persistProjectGrant !== false)
165
+ savePermanent();
166
+ }
139
167
  return 'allow';
140
168
  }
141
169
  export function revokePermanentAllow(toolName, fingerprint) {
142
- permanentGrants = loadPermanent().filter((grant) => grant.tool !== toolName || (fingerprint !== undefined && grant.fingerprint !== fingerprint));
170
+ loadPermanent();
171
+ permanentGrants = permanentGrants.filter((grant) => grant.tool !== toolName || (fingerprint !== undefined && grant.fingerprint !== fingerprint));
172
+ if (fingerprint === undefined)
173
+ permanentToolAllows.delete(toolName);
174
+ savePermanent();
175
+ }
176
+ export function revokePermanentToolAllow(toolName) {
177
+ loadPermanent();
178
+ permanentToolAllows.delete(toolName);
143
179
  savePermanent();
144
180
  }
145
181
  export function listPermanentGrants() {
146
182
  return loadPermanent().map((grant) => ({ ...grant }));
147
183
  }
148
- /** Compatibility API: returns tools having at least one project-scoped grant. */
184
+ export function listPermanentToolAllows() {
185
+ loadPermanent();
186
+ return [...permanentToolAllows].sort();
187
+ }
188
+ /** Compatibility API: returns tools having any persistent resource or tool-wide grant. */
149
189
  export function listPermanentAllow() {
150
- return [...new Set(loadPermanent().map((grant) => grant.tool))];
190
+ loadPermanent();
191
+ return [...new Set([
192
+ ...permanentGrants.map((grant) => grant.tool),
193
+ ...permanentToolAllows,
194
+ ])].sort();
151
195
  }
152
196
  export function clearSessionPermissionGrants() {
153
197
  sessionGrants.length = 0;
@@ -155,5 +199,6 @@ export function clearSessionPermissionGrants() {
155
199
  export function resetPermissionGrantsForTests() {
156
200
  sessionGrants.length = 0;
157
201
  permanentGrants = [];
202
+ permanentToolAllows = new Set();
158
203
  permanentLoaded = true;
159
204
  }
@@ -22,8 +22,8 @@ import { estimateMessagesTokens, reconfigureClient, refreshChatTools, chatTools,
22
22
  import { loadImageAttachment, renderChip, MAX_INLINE_BYTES_DEFAULT, } from '../attachments/image.js';
23
23
  import { modelSupportsVision } from '../llm/capabilities.js';
24
24
  import { computePruneStats } from '../context/relevance.js';
25
- import { manualCompact, contextState, newSessionId, saveSession, loadSession, listSessions, } from '../session/index.js';
26
- import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, } from '../rollback/index.js';
25
+ import { manualCompact, contextState, newSessionId, saveSession, loadSession, listSessions, appendCurrentSessionRuntimeEvent, hashTraceValue, } from '../session/index.js';
26
+ import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, getCurrentTurnId, } from '../rollback/index.js';
27
27
  import { listSkills, effectiveSystemPrompt, } from '../skills/index.js';
28
28
  import { buildMemorySection, buildMemoryIndexSection, kickoffReflection, drainMemoryBackground, getLastReflectResult, clearLastReflectResult, snapshotTranscript, formatReflectResult, loadAll, } from '../memory/index.js';
29
29
  import { buildSnapshot, clearSnapshotCache } from '../project-snapshot/index.js';
@@ -396,8 +396,9 @@ function onRunningKey(_str, key) {
396
396
  runningInput = '';
397
397
  layout.paintRunningInputEcho(runningInput, runningPlaceholder);
398
398
  }
399
- else {
400
- currentAbort?.abort();
399
+ else if (currentAbort && !currentAbort.signal.aborted) {
400
+ appendCurrentSessionRuntimeEvent('abort', { phase: 'requested', source: 'keyboard' });
401
+ currentAbort.abort();
401
402
  }
402
403
  return;
403
404
  }
@@ -876,10 +877,22 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
876
877
  for (const c of revertable)
877
878
  revertPaths.add(c.path);
878
879
  }
879
- applyRollback(plan, history, revertPaths);
880
+ const rolledBackFromTurnId = getCurrentTurnId();
881
+ const turnCountBeforeRollback = listTurns().length;
882
+ const rollbackResult = applyRollback(plan, history, revertPaths);
880
883
  if (!currentSessionId)
881
884
  currentSessionId = newSessionId();
882
885
  setCurrentSessionId(currentSessionId, process.cwd()); // 同步到 session/state,确保 notes.md 存在
886
+ appendCurrentSessionRuntimeEvent('rollback', {
887
+ status: 'applied',
888
+ rolledBackFromTurnId,
889
+ cutoffTurnId: plan.cutoffTurnId,
890
+ retainedTurns: plan.n,
891
+ rolledBackTurns: Math.max(0, turnCountBeforeRollback - plan.n),
892
+ deletedMessages: rollbackResult.deletedMsgs,
893
+ revertedFiles: rollbackResult.revertedFiles,
894
+ requestedFileCount: revertPaths.size,
895
+ }, plan.cutoffTurnId);
883
896
  try {
884
897
  saveSession(history, currentSessionId, queryHistory);
885
898
  }
@@ -1321,6 +1334,15 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1321
1334
  // 返回 SchedulerRunLog 给 UI 显示决策;退化路径(开关关时)在 manualCompact 内部走 compactHistory。
1322
1335
  const log = await manualCompact(history, focus, { force });
1323
1336
  const d = log.compactDetail;
1337
+ appendCurrentSessionRuntimeEvent('compact', {
1338
+ source: 'manual',
1339
+ force,
1340
+ called: log.compactHistoryCalled,
1341
+ reason: d?.reason ?? 'unknown',
1342
+ estimateBefore: d?.estimateBefore,
1343
+ estimateAfter: d?.estimateAfter,
1344
+ focusHash: focus ? hashTraceValue(focus) : undefined,
1345
+ });
1324
1346
  if (!d) {
1325
1347
  // 兜底(旧调用):只显示 old 文案
1326
1348
  if (!log.compactHistoryCalled) {
@@ -114,6 +114,11 @@ export function beginTurn(firstLine) {
114
114
  turnIdCounter += 1;
115
115
  currentTurnId = turnIdCounter;
116
116
  turns.push({ turnId: currentTurnId, firstLine });
117
+ return currentTurnId;
118
+ }
119
+ /** Stable identity shared by tracing, validation, and rollback for the active main turn. */
120
+ export function getCurrentTurnId() {
121
+ return currentTurnId;
117
122
  }
118
123
  /** 单路径工具执行前捕获,不立即记账;失败/no-op 不应出现在 rollback 中。 */
119
124
  export function beginPathMutation(p) {
@@ -12,4 +12,6 @@ export { compactHistory, maybeCompact, capToolResultForHistory, truncateMid, con
12
12
  export { runScheduler, manualCompact, createBudgetScheduler, } from './scheduler.js';
13
13
  export { dropContextFromHistory, formatDropResult, } from './drop.js';
14
14
  export { newSessionId, saveSession, loadSession, listSessions, sessionDir, } from './persist.js';
15
- export { appendCurrentSessionTrace } from './trace.js';
15
+ export { appendCurrentSessionTrace, appendCurrentSessionTraceEvent, appendCurrentSessionRuntimeEvent, createTraceEvent, } from './trace.js';
16
+ export { reduceTraceMetrics, readTraceEvents, readTraceMetrics } from './trace-metrics.js';
17
+ export { summarizeToolArguments, hashTraceValue, safeProviderId } from './trace-sanitize.js';
@@ -0,0 +1,70 @@
1
+ import { readFileSync } from 'node:fs';
2
+ export function reduceTraceMetrics(events) {
3
+ const ends = events.filter((event) => event.type === 'tool_call_end');
4
+ let recovered = false;
5
+ let hadFailure = false;
6
+ let successes = 0;
7
+ let toolRetries = 0;
8
+ let tokens = 0;
9
+ let hasTokens = false;
10
+ for (const event of ends) {
11
+ const status = String(event.data.status ?? 'error');
12
+ const retry = Number(event.data.retry ?? 0);
13
+ toolRetries += Number.isFinite(retry) ? retry : 0;
14
+ if (status === 'success') {
15
+ successes++;
16
+ if (hadFailure)
17
+ recovered = true;
18
+ }
19
+ else {
20
+ hadFailure = true;
21
+ }
22
+ }
23
+ for (const event of events) {
24
+ if (event.type !== 'model_end')
25
+ continue;
26
+ const value = event.data.totalTokens;
27
+ if (typeof value === 'number' && Number.isFinite(value)) {
28
+ tokens += value;
29
+ hasTokens = true;
30
+ }
31
+ }
32
+ const modelRetries = events.filter((event) => event.type === 'model_retry').length;
33
+ const firstValidation = events.find((event) => event.type === 'validation_end');
34
+ const turnEnd = [...events].reverse().find((event) => event.type === 'turn_end');
35
+ return {
36
+ toolCalls: events.filter((event) => event.type === 'tool_call_start').length,
37
+ toolFailures: ends.length - successes,
38
+ toolRecovery: recovered,
39
+ firstSuccessRate: ends.length ? successes / ends.length : 1,
40
+ modelRetries,
41
+ toolRetries,
42
+ retries: modelRetries + toolRetries,
43
+ tokens: hasTokens ? tokens : null,
44
+ durationMs: Number(turnEnd?.data.durationMs ?? 0),
45
+ firstValidationPassed: firstValidation?.data.status === 'passed',
46
+ };
47
+ }
48
+ /** Reads event JSONL; malformed/legacy summary lines are ignored. */
49
+ export function readTraceEvents(file) {
50
+ const events = [];
51
+ for (const line of readFileSync(file, 'utf8').split(/\r?\n/)) {
52
+ if (!line.trim())
53
+ continue;
54
+ try {
55
+ const value = JSON.parse(line);
56
+ if (value.schemaVersion === 1 && typeof value.type === 'string' &&
57
+ typeof value.sessionId === 'string' && typeof value.turnId === 'number' &&
58
+ value.data && typeof value.data === 'object') {
59
+ events.push(value);
60
+ }
61
+ }
62
+ catch {
63
+ // One corrupt best-effort trace line must not hide the remaining run.
64
+ }
65
+ }
66
+ return events;
67
+ }
68
+ export function readTraceMetrics(file) {
69
+ return reduceTraceMetrics(readTraceEvents(file));
70
+ }
@@ -0,0 +1,34 @@
1
+ import { createHash } from 'node:crypto';
2
+ export function hashTraceValue(value) {
3
+ return createHash('sha256').update(value).digest('hex');
4
+ }
5
+ /** Never persists argument values: only shape, size, and a one-way fingerprint. */
6
+ export function summarizeToolArguments(raw) {
7
+ let keys = [];
8
+ let parseable = false;
9
+ try {
10
+ const parsed = raw.trim() ? JSON.parse(raw) : {};
11
+ parseable = true;
12
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
13
+ keys = Object.keys(parsed).sort();
14
+ }
15
+ }
16
+ catch {
17
+ // Invalid arguments are still fingerprinted without retaining their contents.
18
+ }
19
+ return {
20
+ sha256: hashTraceValue(raw),
21
+ byteLength: Buffer.byteLength(raw, 'utf8'),
22
+ keys,
23
+ parseable,
24
+ };
25
+ }
26
+ /** Provider identity is deliberately reduced to a hostname; credentials/path/query are discarded. */
27
+ export function safeProviderId(baseURL) {
28
+ try {
29
+ return new URL(baseURL).hostname.toLowerCase() || 'custom';
30
+ }
31
+ catch {
32
+ return 'custom';
33
+ }
34
+ }
@@ -1,26 +1,54 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { appendFileSync, mkdirSync } from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import { config } from '../config/index.js';
5
+ import { getCurrentTurnId } from '../rollback/index.js';
4
6
  import { getCurrentSessionId } from './state.js';
5
- function boundedValidation(result) {
6
- if (!result)
7
- return undefined;
8
- const max = 4000;
9
- return result.output.length <= max
10
- ? result
11
- : { ...result, output: `${result.output.slice(0, 2000)}\n…[trace output truncated]…\n${result.output.slice(-1900)}` };
7
+ export function createTraceEvent(input) {
8
+ return {
9
+ schemaVersion: 1,
10
+ eventId: randomUUID(),
11
+ ts: new Date().toISOString(),
12
+ ...input,
13
+ };
12
14
  }
13
- /** Best-effort JSONL trace. Failure must never affect the agent turn. */
14
- export function appendCurrentSessionTrace(trace) {
15
- const sessionId = getCurrentSessionId();
16
- if (!sessionId)
17
- return;
15
+ function appendTraceLine(sessionId, value) {
18
16
  try {
19
17
  const dir = path.join(config.sessionDir, sessionId);
20
18
  mkdirSync(dir, { recursive: true });
21
- appendFileSync(path.join(dir, 'trace.jsonl'), `${JSON.stringify({ ...trace, validation: boundedValidation(trace.validation) })}\n`, 'utf8');
19
+ appendFileSync(path.join(dir, 'trace.jsonl'), `${JSON.stringify(value)}\n`, 'utf8');
22
20
  }
23
21
  catch {
24
22
  // Observability is best-effort and cannot block coding work.
25
23
  }
26
24
  }
25
+ /** Persists a typed event in the current session's append-only black-box log. */
26
+ export function appendCurrentSessionTraceEvent(event) {
27
+ const sessionId = getCurrentSessionId();
28
+ if (!sessionId)
29
+ return;
30
+ appendTraceLine(sessionId, { ...event, sessionId });
31
+ }
32
+ /** Records events initiated outside runAgentCore, such as Ctrl+C, /compact, and /rollback. */
33
+ export function appendCurrentSessionRuntimeEvent(type, data, turnId = getCurrentTurnId()) {
34
+ const sessionId = getCurrentSessionId();
35
+ if (!sessionId)
36
+ return;
37
+ appendTraceLine(sessionId, createTraceEvent({ sessionId, turnId, type, data }));
38
+ }
39
+ /** Legacy turn-summary sink retained for API compatibility. New production code writes events. */
40
+ export function appendCurrentSessionTrace(trace) {
41
+ const sessionId = getCurrentSessionId();
42
+ if (!sessionId)
43
+ return;
44
+ const validation = trace.validation
45
+ ? {
46
+ status: trace.validation.status,
47
+ level: trace.validation.level,
48
+ durationMs: trace.validation.durationMs,
49
+ verificationComplete: trace.validation.verificationComplete,
50
+ fingerprint: trace.validation.fingerprint,
51
+ }
52
+ : undefined;
53
+ appendTraceLine(sessionId, { ...trace, sessionId, validation });
54
+ }
@@ -1,5 +1,6 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
+ import { verifyWrittenFile } from '../../verification/postconditions.js';
3
4
  // ---------- edit_file ----------
4
5
  export const editFileTool = {
5
6
  name: 'edit_file',
@@ -29,16 +30,37 @@ export const editFileTool = {
29
30
  const normNew = newStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
30
31
  const count = norm.split(normOld).length - 1;
31
32
  if (count === 0) {
32
- return `错误:在 ${path} 中未找到 old_string。不要重试相同参数;请先 read_file 读取目标区域,再从返回内容逐字复制新的 old_string 后重试。`;
33
+ return {
34
+ status: 'error',
35
+ code: 'EDIT_CONFLICT',
36
+ retryable: false,
37
+ output: `错误:在 ${path} 中未找到 old_string。不要重试相同参数;请先 read_file 读取目标区域,再从返回内容逐字复制新的 old_string 后重试。`,
38
+ };
33
39
  }
34
40
  if (count > 1) {
35
- return `错误:old_string 在 ${path} 中出现 ${count} 次,不唯一。请加入更多上下文使其唯一。`;
41
+ return {
42
+ status: 'error',
43
+ code: 'EDIT_CONFLICT',
44
+ retryable: false,
45
+ output: `错误:old_string 在 ${path} 中出现 ${count} 次,不唯一。请加入更多上下文使其唯一。`,
46
+ };
36
47
  }
37
48
  // 用函数形式替换,避免 new_string 里的 $ 被当特殊模式
38
49
  const updated = norm.replace(normOld, () => normNew);
39
50
  // 检测原始行尾风格,写回时还原(存在 \r\n 即视为 CRLF 文件;纯 LF 文件保持 LF)
40
51
  const out = data.includes('\r\n') ? updated.replace(/\n/g, '\r\n') : updated;
41
52
  await writeFile(full, out, 'utf8');
42
- return `已在 ${path} 中完成 1 处替换。`;
53
+ const postcondition = await verifyWrittenFile(full, out);
54
+ if (postcondition.status === 'failed') {
55
+ return {
56
+ status: 'error',
57
+ code: 'POSTCONDITION_FAILED',
58
+ retryable: false,
59
+ output: postcondition.diagnostics
60
+ .map((item) => `[${item.code ?? 'V0_FAILED'}] ${item.file ?? path}: ${item.message}`)
61
+ .join('\n'),
62
+ };
63
+ }
64
+ return `已在 ${path} 中完成 1 处替换 (sha256=${postcondition.actualHash})。`;
43
65
  },
44
66
  };
@@ -69,8 +69,15 @@ 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
- // Agent 共享主工作区;在隔离 workspace / write-set 锁实现前必须串行。
73
- task: { effect: 'write', concurrency: 'serial', retry: 'never', resources: workspaceResource, supportsAbort: true },
72
+ // task 只编排子 Agent;真实读写由子调用自行持锁,父调用不得包 workspace 锁。
73
+ task: {
74
+ effect: 'write',
75
+ concurrency: 'serial',
76
+ retry: 'never',
77
+ resources: workspaceResource,
78
+ delegatesResourceLocks: true,
79
+ supportsAbort: true,
80
+ },
74
81
  };
75
82
  const rawBuiltinTools = [
76
83
  readFileTool,
@@ -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,5 +1,6 @@
1
1
  import { writeFile, mkdir } from 'node:fs/promises';
2
2
  import { resolve, dirname } from 'node:path';
3
+ import { verifyWrittenFile } from '../../verification/postconditions.js';
3
4
  // ---------- write_file ----------
4
5
  export const writeFileTool = {
5
6
  name: 'write_file',
@@ -19,6 +20,17 @@ export const writeFileTool = {
19
20
  const full = resolve(path);
20
21
  await mkdir(dirname(full), { recursive: true });
21
22
  await writeFile(full, content, 'utf8');
22
- return `已写入 ${path} (${content.length} 字符)`;
23
+ const postcondition = await verifyWrittenFile(full, content);
24
+ if (postcondition.status === 'failed') {
25
+ return {
26
+ status: 'error',
27
+ code: 'POSTCONDITION_FAILED',
28
+ retryable: false,
29
+ output: postcondition.diagnostics
30
+ .map((item) => `[${item.code ?? 'V0_FAILED'}] ${item.file ?? path}: ${item.message}`)
31
+ .join('\n'),
32
+ };
33
+ }
34
+ return `已写入 ${path} (${content.length} 字符, sha256=${postcondition.actualHash})`;
23
35
  },
24
36
  };