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.
- package/dist/__trace_manual_test__.js +1 -0
- package/dist/agent/core.js +598 -263
- package/dist/agent/index.js +2 -2
- package/dist/llm/index.js +14 -0
- package/dist/permissions/index.js +53 -8
- 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 +25 -3
- package/dist/tools/builtins/index.js +9 -2
- package/dist/tools/builtins/web-fetch.js +21 -5
- package/dist/tools/builtins/web-search.js +28 -4
- package/dist/tools/builtins/write-file.js +13 -1
- package/dist/tools/registry.js +121 -46
- package/dist/tools/resource-lock.js +148 -0
- package/dist/tools/retry.js +105 -0
- package/dist/tools/validation.js +80 -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 +2 -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) {
|
|
@@ -31,7 +33,13 @@ export function permissionFingerprint(tool, args) {
|
|
|
31
33
|
subject = { command: args.command.trim() };
|
|
32
34
|
}
|
|
33
35
|
else {
|
|
34
|
-
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
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',
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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,
|
|
@@ -49,7 +49,12 @@ export const webFetchTool = {
|
|
|
49
49
|
const contentType = resp.headers.get('content-type') ?? '';
|
|
50
50
|
const text = await resp.text();
|
|
51
51
|
if (!resp.ok) {
|
|
52
|
-
return
|
|
52
|
+
return {
|
|
53
|
+
status: 'error',
|
|
54
|
+
code: 'HTTP_ERROR',
|
|
55
|
+
retryable: resp.status === 408 || resp.status === 429 || resp.status >= 500,
|
|
56
|
+
output: `错误:抓取失败 HTTP ${resp.status} ${resp.statusText}\n${text.slice(0, 500)}`,
|
|
57
|
+
};
|
|
53
58
|
}
|
|
54
59
|
const isHtml = /html/i.test(contentType) ||
|
|
55
60
|
/^\s*<!doctype html/i.test(text) ||
|
|
@@ -65,12 +70,23 @@ export const webFetchTool = {
|
|
|
65
70
|
}
|
|
66
71
|
catch (e) {
|
|
67
72
|
if (ctrl.signal.aborted) {
|
|
68
|
-
if (externalSignal?.aborted)
|
|
69
|
-
return `错误:已中断: ${url.href}
|
|
70
|
-
|
|
73
|
+
if (externalSignal?.aborted) {
|
|
74
|
+
return { status: 'aborted', code: 'ABORTED', retryable: false, output: `错误:已中断: ${url.href}` };
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
status: 'error',
|
|
78
|
+
code: 'TIMEOUT',
|
|
79
|
+
retryable: true,
|
|
80
|
+
output: `错误:抓取超时(${FETCH_TIMEOUT_MS}ms): ${url.href}`,
|
|
81
|
+
};
|
|
71
82
|
}
|
|
72
83
|
const msg = e instanceof Error ? e.message : String(e);
|
|
73
|
-
return
|
|
84
|
+
return {
|
|
85
|
+
status: 'error',
|
|
86
|
+
code: 'NETWORK_ERROR',
|
|
87
|
+
retryable: true,
|
|
88
|
+
output: `错误:抓取失败: ${msg}`,
|
|
89
|
+
};
|
|
74
90
|
}
|
|
75
91
|
finally {
|
|
76
92
|
clearTimeout(timer);
|
|
@@ -75,7 +75,12 @@ export const webSearchTool = {
|
|
|
75
75
|
data = JSON.parse(text);
|
|
76
76
|
}
|
|
77
77
|
catch {
|
|
78
|
-
return
|
|
78
|
+
return {
|
|
79
|
+
status: 'error',
|
|
80
|
+
code: 'HTTP_ERROR',
|
|
81
|
+
retryable: resp.status === 408 || resp.status === 429 || resp.status >= 500,
|
|
82
|
+
output: `错误:搜索返回非 JSON(HTTP ${resp.status}): ${text.slice(0, 500)}`,
|
|
83
|
+
};
|
|
79
84
|
}
|
|
80
85
|
// AnySearch 成功返回 code===0;否则把 message/request_id 喂回 LLM。
|
|
81
86
|
if (!resp.ok || data?.code !== 0) {
|
|
@@ -92,7 +97,13 @@ export const webSearchTool = {
|
|
|
92
97
|
else if (resp.status === 429) {
|
|
93
98
|
hint = ' 触发限流,请稍后重试。';
|
|
94
99
|
}
|
|
95
|
-
return
|
|
100
|
+
return {
|
|
101
|
+
status: 'error',
|
|
102
|
+
code: !resp.ok || Number(code) === 429 ? 'HTTP_ERROR' : 'EXECUTION_ERROR',
|
|
103
|
+
retryable: resp.status === 408 || resp.status === 429 ||
|
|
104
|
+
resp.status >= 500 || Number(code) === 429,
|
|
105
|
+
output: `错误:搜索失败 [${code}] ${message}${rid}${hint}`,
|
|
106
|
+
};
|
|
96
107
|
}
|
|
97
108
|
const results = data?.data?.results;
|
|
98
109
|
if (!Array.isArray(results) || results.length === 0) {
|
|
@@ -129,10 +140,23 @@ export const webSearchTool = {
|
|
|
129
140
|
}
|
|
130
141
|
catch (e) {
|
|
131
142
|
if (ctrl.signal.aborted) {
|
|
132
|
-
|
|
143
|
+
if (externalSignal?.aborted) {
|
|
144
|
+
return { status: 'aborted', code: 'ABORTED', retryable: false, output: '错误:搜索已中断。' };
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
status: 'error',
|
|
148
|
+
code: 'TIMEOUT',
|
|
149
|
+
retryable: true,
|
|
150
|
+
output: `错误:搜索超时(${SEARCH_TIMEOUT_MS}ms)。`,
|
|
151
|
+
};
|
|
133
152
|
}
|
|
134
153
|
const msg = e instanceof Error ? e.message : String(e);
|
|
135
|
-
return
|
|
154
|
+
return {
|
|
155
|
+
status: 'error',
|
|
156
|
+
code: 'NETWORK_ERROR',
|
|
157
|
+
retryable: true,
|
|
158
|
+
output: `错误:联网搜索请求失败: ${msg}`,
|
|
159
|
+
};
|
|
136
160
|
}
|
|
137
161
|
finally {
|
|
138
162
|
clearTimeout(timer);
|
|
@@ -1,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: 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
|
};
|