mocode-ai 0.7.1 → 0.7.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/dist/__trace_manual_test__.js +1 -0
- package/dist/agent/core.js +570 -263
- package/dist/agent/index.js +2 -2
- package/dist/llm/index.js +14 -0
- package/dist/permissions/index.js +46 -7
- package/dist/repl/index.js +27 -5
- package/dist/rollback/index.js +5 -0
- package/dist/session/index.js +3 -1
- package/dist/session/trace-metrics.js +70 -0
- package/dist/session/trace-sanitize.js +34 -0
- package/dist/session/trace.js +41 -13
- package/dist/tools/builtins/edit-file.js +13 -1
- package/dist/tools/builtins/index.js +9 -2
- package/dist/tools/builtins/write-file.js +13 -1
- package/dist/tools/registry.js +50 -31
- package/dist/tools/resource-lock.js +148 -0
- package/dist/verification/diagnostics.js +108 -0
- package/dist/verification/discovery.js +14 -6
- package/dist/verification/fingerprint.js +54 -0
- package/dist/verification/index.js +275 -105
- package/dist/verification/postconditions.js +98 -0
- package/dist/verification/targeted-tests.js +96 -0
- package/package.json +1 -1
package/dist/agent/index.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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) {
|
|
@@ -58,16 +60,27 @@ function loadPermanent() {
|
|
|
58
60
|
permanentGrants = Array.isArray(parsed.grants)
|
|
59
61
|
? parsed.grants.filter(validGrant).filter((grant) => grant.scope === 'project')
|
|
60
62
|
: [];
|
|
63
|
+
// Only the explicit v3 field enables broad grants. The retired legacy allowForever
|
|
64
|
+
// field remains ignored so upgrades cannot silently restore old authorization.
|
|
65
|
+
permanentToolAllows = parsed.version === PERMISSIONS_VERSION && Array.isArray(parsed.alwaysAllowTools)
|
|
66
|
+
? new Set(parsed.alwaysAllowTools.filter((tool) => typeof tool === 'string' && tool.length > 0))
|
|
67
|
+
: new Set();
|
|
61
68
|
}
|
|
62
69
|
catch {
|
|
63
70
|
permanentGrants = [];
|
|
71
|
+
permanentToolAllows = new Set();
|
|
64
72
|
}
|
|
65
73
|
return permanentGrants;
|
|
66
74
|
}
|
|
67
75
|
function savePermanent() {
|
|
68
76
|
try {
|
|
69
77
|
fs.mkdirSync(path.dirname(PERMISSIONS_PATH), { recursive: true });
|
|
70
|
-
|
|
78
|
+
const data = {
|
|
79
|
+
version: PERMISSIONS_VERSION,
|
|
80
|
+
grants: permanentGrants,
|
|
81
|
+
alwaysAllowTools: [...permanentToolAllows].sort(),
|
|
82
|
+
};
|
|
83
|
+
fs.writeFileSync(PERMISSIONS_PATH, `${JSON.stringify(data, null, 2)}\n`, 'utf8');
|
|
71
84
|
}
|
|
72
85
|
catch {
|
|
73
86
|
// A failed persistence write must never turn into broader authorization.
|
|
@@ -98,11 +111,14 @@ export async function checkPermission(tool, args, signal, options = {}) {
|
|
|
98
111
|
return 'allow';
|
|
99
112
|
if (signal?.aborted)
|
|
100
113
|
return 'deny';
|
|
114
|
+
loadPermanent();
|
|
115
|
+
if (permanentToolAllows.has(tool.name))
|
|
116
|
+
return 'allow';
|
|
101
117
|
const fingerprint = permissionFingerprint(tool, args);
|
|
102
118
|
const projectRoot = canonicalProjectRoot(options.projectRoot ?? getSandboxRoot() ?? process.cwd());
|
|
103
119
|
if (sessionGrants.some((grant) => matches(grant, tool.name, fingerprint, projectRoot)))
|
|
104
120
|
return 'allow';
|
|
105
|
-
if (
|
|
121
|
+
if (permanentGrants.some((grant) => matches(grant, tool.name, fingerprint, projectRoot)))
|
|
106
122
|
return 'allow';
|
|
107
123
|
// CI/pipes must fail closed. Operators can deliberately restore unattended behavior.
|
|
108
124
|
if (!process.stdin.isTTY && !config.permissionNonInteractiveAllow && !options.prompt)
|
|
@@ -112,9 +128,10 @@ export async function checkPermission(tool, args, signal, options = {}) {
|
|
|
112
128
|
const onceOption = t('permission.allow');
|
|
113
129
|
const sessionOption = t('permission.allowSessionResource');
|
|
114
130
|
const projectOption = t('permission.allowProjectResource');
|
|
131
|
+
const alwaysOption = t('permission.allowForever');
|
|
115
132
|
const denyOption = t('permission.deny');
|
|
116
133
|
const dangerous = getToolRisk(tool) === 'dangerous';
|
|
117
|
-
const choices = [onceOption, sessionOption, projectOption, denyOption];
|
|
134
|
+
const choices = [onceOption, sessionOption, projectOption, alwaysOption, denyOption];
|
|
118
135
|
const result = await (options.prompt ?? promptIntervention)({
|
|
119
136
|
type: 'choice',
|
|
120
137
|
title: dangerous
|
|
@@ -131,23 +148,44 @@ export async function checkPermission(tool, args, signal, options = {}) {
|
|
|
131
148
|
}
|
|
132
149
|
else if (result.value === projectOption) {
|
|
133
150
|
const grant = { tool: tool.name, fingerprint, scope: 'project', projectRoot };
|
|
134
|
-
permanentGrants =
|
|
151
|
+
permanentGrants = permanentGrants.filter((item) => !matches(item, tool.name, fingerprint, projectRoot));
|
|
135
152
|
permanentGrants.push(grant);
|
|
136
153
|
if (options.persistProjectGrant !== false)
|
|
137
154
|
savePermanent();
|
|
138
155
|
}
|
|
156
|
+
else if (result.value === alwaysOption) {
|
|
157
|
+
permanentToolAllows.add(tool.name);
|
|
158
|
+
if (options.persistProjectGrant !== false)
|
|
159
|
+
savePermanent();
|
|
160
|
+
}
|
|
139
161
|
return 'allow';
|
|
140
162
|
}
|
|
141
163
|
export function revokePermanentAllow(toolName, fingerprint) {
|
|
142
|
-
|
|
164
|
+
loadPermanent();
|
|
165
|
+
permanentGrants = permanentGrants.filter((grant) => grant.tool !== toolName || (fingerprint !== undefined && grant.fingerprint !== fingerprint));
|
|
166
|
+
if (fingerprint === undefined)
|
|
167
|
+
permanentToolAllows.delete(toolName);
|
|
168
|
+
savePermanent();
|
|
169
|
+
}
|
|
170
|
+
export function revokePermanentToolAllow(toolName) {
|
|
171
|
+
loadPermanent();
|
|
172
|
+
permanentToolAllows.delete(toolName);
|
|
143
173
|
savePermanent();
|
|
144
174
|
}
|
|
145
175
|
export function listPermanentGrants() {
|
|
146
176
|
return loadPermanent().map((grant) => ({ ...grant }));
|
|
147
177
|
}
|
|
148
|
-
|
|
178
|
+
export function listPermanentToolAllows() {
|
|
179
|
+
loadPermanent();
|
|
180
|
+
return [...permanentToolAllows].sort();
|
|
181
|
+
}
|
|
182
|
+
/** Compatibility API: returns tools having any persistent resource or tool-wide grant. */
|
|
149
183
|
export function listPermanentAllow() {
|
|
150
|
-
|
|
184
|
+
loadPermanent();
|
|
185
|
+
return [...new Set([
|
|
186
|
+
...permanentGrants.map((grant) => grant.tool),
|
|
187
|
+
...permanentToolAllows,
|
|
188
|
+
])].sort();
|
|
151
189
|
}
|
|
152
190
|
export function clearSessionPermissionGrants() {
|
|
153
191
|
sessionGrants.length = 0;
|
|
@@ -155,5 +193,6 @@ export function clearSessionPermissionGrants() {
|
|
|
155
193
|
export function resetPermissionGrantsForTests() {
|
|
156
194
|
sessionGrants.length = 0;
|
|
157
195
|
permanentGrants = [];
|
|
196
|
+
permanentToolAllows = new Set();
|
|
158
197
|
permanentLoaded = true;
|
|
159
198
|
}
|
package/dist/repl/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/rollback/index.js
CHANGED
|
@@ -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) {
|
package/dist/session/index.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/session/trace.js
CHANGED
|
@@ -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
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
export function createTraceEvent(input) {
|
|
8
|
+
return {
|
|
9
|
+
schemaVersion: 1,
|
|
10
|
+
eventId: randomUUID(),
|
|
11
|
+
ts: new Date().toISOString(),
|
|
12
|
+
...input,
|
|
13
|
+
};
|
|
12
14
|
}
|
|
13
|
-
|
|
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(
|
|
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',
|
|
@@ -39,6 +40,17 @@ export const editFileTool = {
|
|
|
39
40
|
// 检测原始行尾风格,写回时还原(存在 \r\n 即视为 CRLF 文件;纯 LF 文件保持 LF)
|
|
40
41
|
const out = data.includes('\r\n') ? updated.replace(/\n/g, '\r\n') : updated;
|
|
41
42
|
await writeFile(full, out, 'utf8');
|
|
42
|
-
|
|
43
|
+
const postcondition = await verifyWrittenFile(full, out);
|
|
44
|
+
if (postcondition.status === 'failed') {
|
|
45
|
+
return {
|
|
46
|
+
status: 'error',
|
|
47
|
+
code: 'POSTCONDITION_FAILED',
|
|
48
|
+
retryable: true,
|
|
49
|
+
output: postcondition.diagnostics
|
|
50
|
+
.map((item) => `[${item.code ?? 'V0_FAILED'}] ${item.file ?? path}: ${item.message}`)
|
|
51
|
+
.join('\n'),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return `已在 ${path} 中完成 1 处替换 (sha256=${postcondition.actualHash})。`;
|
|
43
55
|
},
|
|
44
56
|
};
|
|
@@ -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
|
-
//
|
|
73
|
-
task: {
|
|
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,
|
|
@@ -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
|
-
|
|
23
|
+
const postcondition = await verifyWrittenFile(full, content);
|
|
24
|
+
if (postcondition.status === 'failed') {
|
|
25
|
+
return {
|
|
26
|
+
status: 'error',
|
|
27
|
+
code: 'POSTCONDITION_FAILED',
|
|
28
|
+
retryable: true,
|
|
29
|
+
output: postcondition.diagnostics
|
|
30
|
+
.map((item) => `[${item.code ?? 'V0_FAILED'}] ${item.file ?? path}: ${item.message}`)
|
|
31
|
+
.join('\n'),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return `已写入 ${path} (${content.length} 字符, sha256=${postcondition.actualHash})`;
|
|
23
35
|
},
|
|
24
36
|
};
|
package/dist/tools/registry.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
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
|
+
import { resolveResourceLockRequests, toolResourceLockManager } from './resource-lock.js';
|
|
4
5
|
import { t } from '../i18n/index.js';
|
|
5
6
|
import { isToolErrorOutput } from './result.js';
|
|
6
7
|
/**
|
|
@@ -115,45 +116,63 @@ export async function executeToolOutcome(name, argsRaw, signal, opts) {
|
|
|
115
116
|
return terminalOutcome('error', 'INVALID_JSON', t('toolError.invalidJson', { name, arguments: argsRaw }), startedAt);
|
|
116
117
|
}
|
|
117
118
|
const capabilities = getToolCapabilities(tool);
|
|
118
|
-
|
|
119
|
+
let mutationVersionBefore;
|
|
120
|
+
let capturedPath;
|
|
119
121
|
try {
|
|
120
122
|
const sandboxError = enforceSandbox(name, args);
|
|
121
123
|
if (sandboxError) {
|
|
122
124
|
return terminalOutcome('denied', 'SANDBOX_DENIED', sandboxError, startedAt);
|
|
123
125
|
}
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
126
|
+
const requests = resolveResourceLockRequests(capabilities, args);
|
|
127
|
+
return await toolResourceLockManager.withLocks(requests, signal, async () => {
|
|
128
|
+
// Diff 等执行前观察必须发生在真正持锁之后;同路径排队调用才能看到前序写入结果。
|
|
129
|
+
opts?.onLockAcquired?.(args);
|
|
130
|
+
const mutationBefore = getCurrentTurnMutationState();
|
|
131
|
+
mutationVersionBefore = mutationBefore.version;
|
|
132
|
+
const pathCapture = isFileMutationTool(name) && typeof args.path === 'string' && args.path
|
|
133
|
+
? beginPathMutation(args.path)
|
|
134
|
+
: null;
|
|
135
|
+
capturedPath = pathCapture?.path;
|
|
136
|
+
// 进程和未知扩展可能间接改动任意文件;其 workspace lock 同时隔离全盘捕获。
|
|
137
|
+
const workspaceCapture = capabilities.effect === 'process' || capabilities.effect === 'unknown'
|
|
138
|
+
? beginWorkspaceMutation()
|
|
139
|
+
: null;
|
|
140
|
+
let raw;
|
|
141
|
+
try {
|
|
142
|
+
raw = await tool.execute(args, {
|
|
143
|
+
signal,
|
|
144
|
+
dropContext: opts?.dropContext,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
if (pathCapture)
|
|
149
|
+
endPathMutation(pathCapture, name);
|
|
150
|
+
if (workspaceCapture)
|
|
151
|
+
endWorkspaceMutation(workspaceCapture, name);
|
|
152
|
+
}
|
|
153
|
+
const mutationAfter = getCurrentTurnMutationState();
|
|
154
|
+
const changedFiles = mutationAfter.version !== mutationBefore.version
|
|
155
|
+
? pathCapture
|
|
156
|
+
? mutationAfter.changedFiles
|
|
157
|
+
.filter((item) => item.path === pathCapture.path)
|
|
158
|
+
.map((item) => item.path)
|
|
159
|
+
: mutationAfter.changedFiles.map((item) => item.path)
|
|
160
|
+
: [];
|
|
161
|
+
if (signal?.aborted) {
|
|
162
|
+
return terminalOutcome('aborted', 'ABORTED', String(isStructuredOutcome(raw) ? raw.output : raw), startedAt, changedFiles);
|
|
163
|
+
}
|
|
164
|
+
return normalizeOutcome(raw, capabilities, Date.now() - startedAt, changedFiles);
|
|
165
|
+
});
|
|
152
166
|
}
|
|
153
167
|
catch (error) {
|
|
154
168
|
const mutationAfter = getCurrentTurnMutationState();
|
|
155
|
-
const changedFiles =
|
|
156
|
-
|
|
169
|
+
const changedFiles = mutationVersionBefore !== undefined &&
|
|
170
|
+
mutationAfter.version !== mutationVersionBefore
|
|
171
|
+
? capturedPath
|
|
172
|
+
? mutationAfter.changedFiles
|
|
173
|
+
.filter((item) => item.path === capturedPath)
|
|
174
|
+
.map((item) => item.path)
|
|
175
|
+
: mutationAfter.changedFiles.map((item) => item.path)
|
|
157
176
|
: [];
|
|
158
177
|
if (signal?.aborted || (error instanceof Error && error.name === 'AbortError')) {
|
|
159
178
|
return terminalOutcome('aborted', 'ABORTED', t('command.interrupted'), startedAt, changedFiles);
|