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/tools/registry.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
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';
|
|
5
|
+
import { executeWithToolRetry } from './retry.js';
|
|
6
|
+
import { validateToolArguments } from './validation.js';
|
|
4
7
|
import { t } from '../i18n/index.js';
|
|
5
8
|
import { isToolErrorOutput } from './result.js';
|
|
6
9
|
/**
|
|
@@ -66,7 +69,7 @@ function isStructuredOutcome(value) {
|
|
|
66
69
|
typeof value.status === 'string' && typeof value.code === 'string' &&
|
|
67
70
|
typeof value.retryable === 'boolean' && typeof value.output === 'string';
|
|
68
71
|
}
|
|
69
|
-
function normalizeOutcome(value,
|
|
72
|
+
function normalizeOutcome(value, durationMs, changedFiles) {
|
|
70
73
|
if (isStructuredOutcome(value)) {
|
|
71
74
|
return {
|
|
72
75
|
...value,
|
|
@@ -78,7 +81,8 @@ function normalizeOutcome(value, capabilities, durationMs, changedFiles) {
|
|
|
78
81
|
return {
|
|
79
82
|
status: failed ? 'error' : 'success',
|
|
80
83
|
code: failed ? 'EXECUTION_ERROR' : 'OK',
|
|
81
|
-
|
|
84
|
+
// Legacy string errors carry no transient classification and are never retried blindly.
|
|
85
|
+
retryable: false,
|
|
82
86
|
output: value,
|
|
83
87
|
changedFiles,
|
|
84
88
|
durationMs,
|
|
@@ -94,6 +98,106 @@ function terminalOutcome(status, code, output, startedAt, changedFiles = []) {
|
|
|
94
98
|
durationMs: Date.now() - startedAt,
|
|
95
99
|
};
|
|
96
100
|
}
|
|
101
|
+
function isTransientExecutionError(error) {
|
|
102
|
+
if (!error || typeof error !== 'object')
|
|
103
|
+
return false;
|
|
104
|
+
const value = error;
|
|
105
|
+
if (value.name === 'AbortError' || value.name === 'APIUserAbortError')
|
|
106
|
+
return false;
|
|
107
|
+
if (value.status === 408 || value.status === 429 ||
|
|
108
|
+
(typeof value.status === 'number' && value.status >= 500))
|
|
109
|
+
return true;
|
|
110
|
+
if (['ETIMEDOUT', 'ECONNRESET', 'ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'EPIPE']
|
|
111
|
+
.includes(value.code ?? ''))
|
|
112
|
+
return true;
|
|
113
|
+
return value.name === 'APIConnectionError' ||
|
|
114
|
+
value.name === 'APIConnectionTimeoutError' ||
|
|
115
|
+
(typeof value.message === 'string' && /\btime(?:d)?\s*out\b|ETIMEDOUT/i.test(value.message));
|
|
116
|
+
}
|
|
117
|
+
function executionErrorOutcome(name, error, startedAt, changedFiles) {
|
|
118
|
+
const transient = isTransientExecutionError(error);
|
|
119
|
+
const value = error;
|
|
120
|
+
const timeout = transient && (value?.code === 'ETIMEDOUT' ||
|
|
121
|
+
value?.name === 'APIConnectionTimeoutError' ||
|
|
122
|
+
(error instanceof Error && /\btime(?:d)?\s*out\b|ETIMEDOUT/i.test(error.message)));
|
|
123
|
+
return {
|
|
124
|
+
status: 'error',
|
|
125
|
+
code: timeout ? 'TIMEOUT' : transient ? 'NETWORK_ERROR' : 'EXECUTION_ERROR',
|
|
126
|
+
retryable: transient,
|
|
127
|
+
output: t('toolError.execution', {
|
|
128
|
+
name,
|
|
129
|
+
message: error instanceof Error ? error.message : String(error),
|
|
130
|
+
}),
|
|
131
|
+
changedFiles,
|
|
132
|
+
durationMs: Date.now() - startedAt,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
/** One complete attempt: acquire/release locks and capture rollback independently. */
|
|
136
|
+
async function executeToolAttempt(tool, args, signal, opts, notifyLockAcquired) {
|
|
137
|
+
const startedAt = Date.now();
|
|
138
|
+
const capabilities = getToolCapabilities(tool);
|
|
139
|
+
let mutationVersionBefore;
|
|
140
|
+
let capturedPath;
|
|
141
|
+
try {
|
|
142
|
+
const requests = resolveResourceLockRequests(capabilities, args);
|
|
143
|
+
return await toolResourceLockManager.withLocks(requests, signal, async () => {
|
|
144
|
+
if (notifyLockAcquired)
|
|
145
|
+
opts?.onLockAcquired?.(args);
|
|
146
|
+
const mutationBefore = getCurrentTurnMutationState();
|
|
147
|
+
mutationVersionBefore = mutationBefore.version;
|
|
148
|
+
const pathCapture = isFileMutationTool(tool.name) && typeof args.path === 'string' && args.path
|
|
149
|
+
? beginPathMutation(args.path)
|
|
150
|
+
: null;
|
|
151
|
+
capturedPath = pathCapture?.path;
|
|
152
|
+
const workspaceCapture = capabilities.effect === 'process' || capabilities.effect === 'unknown'
|
|
153
|
+
? beginWorkspaceMutation()
|
|
154
|
+
: null;
|
|
155
|
+
let raw;
|
|
156
|
+
try {
|
|
157
|
+
raw = await tool.execute(args, { signal, dropContext: opts?.dropContext });
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
if (pathCapture)
|
|
161
|
+
endPathMutation(pathCapture, tool.name);
|
|
162
|
+
if (workspaceCapture)
|
|
163
|
+
endWorkspaceMutation(workspaceCapture, tool.name);
|
|
164
|
+
}
|
|
165
|
+
const mutationAfter = getCurrentTurnMutationState();
|
|
166
|
+
const changedFiles = mutationAfter.version !== mutationBefore.version
|
|
167
|
+
? pathCapture
|
|
168
|
+
? mutationAfter.changedFiles.filter((item) => item.path === pathCapture.path).map((item) => item.path)
|
|
169
|
+
: mutationAfter.changedFiles.map((item) => item.path)
|
|
170
|
+
: [];
|
|
171
|
+
if (signal?.aborted) {
|
|
172
|
+
return terminalOutcome('aborted', 'ABORTED', String(isStructuredOutcome(raw) ? raw.output : raw), startedAt, changedFiles);
|
|
173
|
+
}
|
|
174
|
+
return normalizeOutcome(raw, Date.now() - startedAt, changedFiles);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
const mutationAfter = getCurrentTurnMutationState();
|
|
179
|
+
const changedFiles = mutationVersionBefore !== undefined && mutationAfter.version !== mutationVersionBefore
|
|
180
|
+
? capturedPath
|
|
181
|
+
? mutationAfter.changedFiles.filter((item) => item.path === capturedPath).map((item) => item.path)
|
|
182
|
+
: mutationAfter.changedFiles.map((item) => item.path)
|
|
183
|
+
: [];
|
|
184
|
+
if (signal?.aborted || (error instanceof Error && error.name === 'AbortError')) {
|
|
185
|
+
return terminalOutcome('aborted', 'ABORTED', t('command.interrupted'), startedAt, changedFiles);
|
|
186
|
+
}
|
|
187
|
+
return executionErrorOutcome(tool.name, error, startedAt, changedFiles);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
function stableJson(value) {
|
|
191
|
+
if (Array.isArray(value))
|
|
192
|
+
return `[${value.map(stableJson).join(',')}]`;
|
|
193
|
+
if (value && typeof value === 'object') {
|
|
194
|
+
return `{${Object.entries(value)
|
|
195
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
196
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${stableJson(item)}`)
|
|
197
|
+
.join(',')}}`;
|
|
198
|
+
}
|
|
199
|
+
return JSON.stringify(value) ?? 'null';
|
|
200
|
+
}
|
|
97
201
|
/**
|
|
98
202
|
* 结构化工具调度入口。永不抛错;旧字符串工具在此归一化为 ToolOutcome。
|
|
99
203
|
* 权限仍由 Agent 在展示工具头之前预检,保持现有交互时序。
|
|
@@ -107,61 +211,32 @@ export async function executeToolOutcome(name, argsRaw, signal, opts) {
|
|
|
107
211
|
if (!tool) {
|
|
108
212
|
return terminalOutcome('error', 'UNKNOWN_TOOL', t('toolError.unknown', { name }), startedAt);
|
|
109
213
|
}
|
|
110
|
-
let
|
|
214
|
+
let parsed;
|
|
111
215
|
try {
|
|
112
|
-
|
|
216
|
+
parsed = argsRaw.trim() ? JSON.parse(argsRaw) : {};
|
|
113
217
|
}
|
|
114
218
|
catch {
|
|
115
219
|
return terminalOutcome('error', 'INVALID_JSON', t('toolError.invalidJson', { name, arguments: argsRaw }), startedAt);
|
|
116
220
|
}
|
|
221
|
+
const validation = validateToolArguments(tool, parsed);
|
|
222
|
+
if (!validation.valid) {
|
|
223
|
+
return terminalOutcome('error', validation.code, `错误:工具 ${name} 参数无效: ${validation.message}`, startedAt);
|
|
224
|
+
}
|
|
225
|
+
const args = parsed;
|
|
226
|
+
const fingerprint = `${name}\x00${stableJson(args)}`;
|
|
227
|
+
const sandboxError = enforceSandbox(name, args);
|
|
228
|
+
if (sandboxError) {
|
|
229
|
+
return terminalOutcome('denied', 'SANDBOX_DENIED', sandboxError, startedAt);
|
|
230
|
+
}
|
|
117
231
|
const capabilities = getToolCapabilities(tool);
|
|
118
|
-
const mutationBefore = getCurrentTurnMutationState();
|
|
119
232
|
try {
|
|
120
|
-
|
|
121
|
-
if (sandboxError) {
|
|
122
|
-
return terminalOutcome('denied', 'SANDBOX_DENIED', sandboxError, startedAt);
|
|
123
|
-
}
|
|
124
|
-
const pathCapture = isFileMutationTool(name) && typeof args.path === 'string' && args.path
|
|
125
|
-
? beginPathMutation(args.path)
|
|
126
|
-
: null;
|
|
127
|
-
// 进程和未知扩展可能间接改动任意文件;已声明 write 的非文件工具自行管理其状态。
|
|
128
|
-
const workspaceCapture = capabilities.effect === 'process' || capabilities.effect === 'unknown'
|
|
129
|
-
? beginWorkspaceMutation()
|
|
130
|
-
: null;
|
|
131
|
-
let raw;
|
|
132
|
-
try {
|
|
133
|
-
raw = await tool.execute(args, {
|
|
134
|
-
signal,
|
|
135
|
-
dropContext: opts?.dropContext,
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
finally {
|
|
139
|
-
if (pathCapture)
|
|
140
|
-
endPathMutation(pathCapture, name);
|
|
141
|
-
if (workspaceCapture)
|
|
142
|
-
endWorkspaceMutation(workspaceCapture, name);
|
|
143
|
-
}
|
|
144
|
-
const mutationAfter = getCurrentTurnMutationState();
|
|
145
|
-
const changedFiles = mutationAfter.version !== mutationBefore.version
|
|
146
|
-
? mutationAfter.changedFiles.map((item) => item.path)
|
|
147
|
-
: [];
|
|
148
|
-
if (signal?.aborted) {
|
|
149
|
-
return terminalOutcome('aborted', 'ABORTED', String(isStructuredOutcome(raw) ? raw.output : raw), startedAt, changedFiles);
|
|
150
|
-
}
|
|
151
|
-
return normalizeOutcome(raw, capabilities, Date.now() - startedAt, changedFiles);
|
|
233
|
+
return await executeWithToolRetry(capabilities, fingerprint, signal, (attempt) => executeToolAttempt(tool, args, signal, opts, attempt === 1), opts?.onRetry);
|
|
152
234
|
}
|
|
153
235
|
catch (error) {
|
|
154
|
-
const mutationAfter = getCurrentTurnMutationState();
|
|
155
|
-
const changedFiles = mutationAfter.version !== mutationBefore.version
|
|
156
|
-
? mutationAfter.changedFiles.map((item) => item.path)
|
|
157
|
-
: [];
|
|
158
236
|
if (signal?.aborted || (error instanceof Error && error.name === 'AbortError')) {
|
|
159
|
-
return terminalOutcome('aborted', 'ABORTED', t('command.interrupted'), startedAt
|
|
237
|
+
return terminalOutcome('aborted', 'ABORTED', t('command.interrupted'), startedAt);
|
|
160
238
|
}
|
|
161
|
-
return
|
|
162
|
-
name,
|
|
163
|
-
message: error instanceof Error ? error.message : String(error),
|
|
164
|
-
}), startedAt, changedFiles);
|
|
239
|
+
return executionErrorOutcome(name, error, startedAt, []);
|
|
165
240
|
}
|
|
166
241
|
}
|
|
167
242
|
/** 字符串兼容入口:现有调用方、TUI 和 LLM history 无需同步迁移。 */
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { normalize } from 'node:path';
|
|
2
|
+
import { jailResolve } from '../sandbox/index.js';
|
|
3
|
+
function abortError() {
|
|
4
|
+
const error = new Error('Resource lock acquisition aborted');
|
|
5
|
+
error.name = 'AbortError';
|
|
6
|
+
return error;
|
|
7
|
+
}
|
|
8
|
+
function requestConflicts(a, b) {
|
|
9
|
+
if (a.scope === 'workspace' || b.scope === 'workspace') {
|
|
10
|
+
if (a.mode === 'write' || b.mode === 'write')
|
|
11
|
+
return true;
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
return a.key === b.key && (a.mode === 'write' || b.mode === 'write');
|
|
15
|
+
}
|
|
16
|
+
function claimsConflict(a, b) {
|
|
17
|
+
return a.requests.some((left) => b.requests.some((right) => requestConflicts(left, right)));
|
|
18
|
+
}
|
|
19
|
+
/** Fair, abort-aware multi-resource read/write lock shared by all agent loops. */
|
|
20
|
+
export class ResourceLockManager {
|
|
21
|
+
active = new Set();
|
|
22
|
+
waiting = [];
|
|
23
|
+
acquire(requests, signal) {
|
|
24
|
+
if (signal?.aborted)
|
|
25
|
+
return Promise.reject(abortError());
|
|
26
|
+
const normalized = dedupeRequests(requests);
|
|
27
|
+
if (normalized.length === 0)
|
|
28
|
+
return Promise.resolve(() => undefined);
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
const waiter = { requests: normalized, resolve, reject, signal };
|
|
31
|
+
if (signal) {
|
|
32
|
+
waiter.onAbort = () => {
|
|
33
|
+
const index = this.waiting.indexOf(waiter);
|
|
34
|
+
if (index < 0)
|
|
35
|
+
return;
|
|
36
|
+
this.waiting.splice(index, 1);
|
|
37
|
+
signal.removeEventListener('abort', waiter.onAbort);
|
|
38
|
+
reject(abortError());
|
|
39
|
+
this.dispatch();
|
|
40
|
+
};
|
|
41
|
+
signal.addEventListener('abort', waiter.onAbort, { once: true });
|
|
42
|
+
}
|
|
43
|
+
this.waiting.push(waiter);
|
|
44
|
+
this.dispatch();
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
async withLocks(requests, signal, action) {
|
|
48
|
+
const release = await this.acquire(requests, signal);
|
|
49
|
+
try {
|
|
50
|
+
return await action();
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
release();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
dispatch() {
|
|
57
|
+
const blocked = [];
|
|
58
|
+
for (let index = 0; index < this.waiting.length;) {
|
|
59
|
+
const waiter = this.waiting[index];
|
|
60
|
+
const conflictsActive = [...this.active].some((claim) => claimsConflict(waiter, claim));
|
|
61
|
+
const conflictsEarlier = blocked.some((claim) => claimsConflict(waiter, claim));
|
|
62
|
+
if (conflictsActive || conflictsEarlier) {
|
|
63
|
+
blocked.push(waiter);
|
|
64
|
+
index++;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
this.waiting.splice(index, 1);
|
|
68
|
+
if (waiter.onAbort)
|
|
69
|
+
waiter.signal?.removeEventListener('abort', waiter.onAbort);
|
|
70
|
+
const claim = { requests: waiter.requests };
|
|
71
|
+
this.active.add(claim);
|
|
72
|
+
let released = false;
|
|
73
|
+
waiter.resolve(() => {
|
|
74
|
+
if (released)
|
|
75
|
+
return;
|
|
76
|
+
released = true;
|
|
77
|
+
this.active.delete(claim);
|
|
78
|
+
this.dispatch();
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function dedupeRequests(requests) {
|
|
84
|
+
const byKey = new Map();
|
|
85
|
+
for (const request of requests) {
|
|
86
|
+
const identity = `${request.scope}:${request.key}`;
|
|
87
|
+
const existing = byKey.get(identity);
|
|
88
|
+
if (!existing || request.mode === 'write')
|
|
89
|
+
byKey.set(identity, request);
|
|
90
|
+
}
|
|
91
|
+
return [...byKey.values()].sort((a, b) => `${a.scope}:${a.key}`.localeCompare(`${b.scope}:${b.key}`));
|
|
92
|
+
}
|
|
93
|
+
/** Stable lock identity: sandbox realpath plus Windows case/separator normalization. */
|
|
94
|
+
export function canonicalFileResourceKey(input) {
|
|
95
|
+
let canonical = normalize(jailResolve(input));
|
|
96
|
+
if (process.platform === 'win32')
|
|
97
|
+
canonical = canonical.toLowerCase();
|
|
98
|
+
return `file:${canonical}`;
|
|
99
|
+
}
|
|
100
|
+
function modeFor(effect) {
|
|
101
|
+
return effect === 'read' ? 'read' : 'write';
|
|
102
|
+
}
|
|
103
|
+
const workspaceWrite = () => [{
|
|
104
|
+
key: 'workspace',
|
|
105
|
+
scope: 'workspace',
|
|
106
|
+
mode: 'write',
|
|
107
|
+
}];
|
|
108
|
+
/** Resolve declared logical resources. Any ambiguity fails closed to a workspace write lock. */
|
|
109
|
+
export function resolveResourceLockRequests(capabilities, args) {
|
|
110
|
+
if (capabilities.delegatesResourceLocks)
|
|
111
|
+
return [];
|
|
112
|
+
if (capabilities.effect === 'process' || capabilities.effect === 'unknown') {
|
|
113
|
+
return workspaceWrite();
|
|
114
|
+
}
|
|
115
|
+
let keys;
|
|
116
|
+
try {
|
|
117
|
+
keys = capabilities.resources?.(args) ?? [];
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return workspaceWrite();
|
|
121
|
+
}
|
|
122
|
+
if (keys.length === 0) {
|
|
123
|
+
return capabilities.effect === 'network' ? [] : workspaceWrite();
|
|
124
|
+
}
|
|
125
|
+
const mode = modeFor(capabilities.effect);
|
|
126
|
+
const requests = [];
|
|
127
|
+
try {
|
|
128
|
+
for (const key of keys) {
|
|
129
|
+
if (typeof key !== 'string' || key.trim().length === 0)
|
|
130
|
+
return workspaceWrite();
|
|
131
|
+
if (key === 'workspace') {
|
|
132
|
+
requests.push({ key, scope: 'workspace', mode });
|
|
133
|
+
}
|
|
134
|
+
else if (key.startsWith('file:') && key.length > 5) {
|
|
135
|
+
requests.push({ key: canonicalFileResourceKey(key.slice(5)), scope: 'resource', mode });
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
// Non-file logical resources are still lockable, but never treated as filesystem paths.
|
|
139
|
+
requests.push({ key, scope: 'resource', mode });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return workspaceWrite();
|
|
145
|
+
}
|
|
146
|
+
return dedupeRequests(requests);
|
|
147
|
+
}
|
|
148
|
+
export const toolResourceLockManager = new ResourceLockManager();
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
export const TOOL_RETRY_MAX_ATTEMPTS = 3;
|
|
2
|
+
export const TOOL_RETRY_BASE_MS = 250;
|
|
3
|
+
export const TOOL_RETRY_MAX_MS = 1_000;
|
|
4
|
+
export const TOOL_RETRY_TOTAL_BUDGET_MS = 15_000;
|
|
5
|
+
export const TOOL_RETRY_SAME_ARGS_WINDOW_MS = 60_000;
|
|
6
|
+
export const TOOL_RETRY_SAME_ARGS_BUDGET = 2;
|
|
7
|
+
const NEVER_RETRY_CODES = new Set([
|
|
8
|
+
'INVALID_JSON',
|
|
9
|
+
'INVALID_ARGUMENTS',
|
|
10
|
+
'INVALID_TOOL_SCHEMA',
|
|
11
|
+
'UNKNOWN_TOOL',
|
|
12
|
+
'SANDBOX_DENIED',
|
|
13
|
+
'PERMISSION_DENIED',
|
|
14
|
+
'TOOL_DISABLED',
|
|
15
|
+
'MODE_DENIED',
|
|
16
|
+
'ABORTED',
|
|
17
|
+
'EDIT_CONFLICT',
|
|
18
|
+
'POSTCONDITION_FAILED',
|
|
19
|
+
'PROCESS_FAILED',
|
|
20
|
+
'MCP_ERROR',
|
|
21
|
+
]);
|
|
22
|
+
const fingerprintBudgets = new Map();
|
|
23
|
+
const MAX_TRACKED_FINGERPRINTS = 512;
|
|
24
|
+
function reserveFingerprintRetry(fingerprint, now) {
|
|
25
|
+
let budget = fingerprintBudgets.get(fingerprint);
|
|
26
|
+
if (!budget || now - budget.startedAt >= TOOL_RETRY_SAME_ARGS_WINDOW_MS) {
|
|
27
|
+
budget = { startedAt: now, retries: 0 };
|
|
28
|
+
fingerprintBudgets.set(fingerprint, budget);
|
|
29
|
+
}
|
|
30
|
+
if (budget.retries >= TOOL_RETRY_SAME_ARGS_BUDGET)
|
|
31
|
+
return false;
|
|
32
|
+
budget.retries++;
|
|
33
|
+
if (fingerprintBudgets.size > MAX_TRACKED_FINGERPRINTS) {
|
|
34
|
+
const oldest = fingerprintBudgets.keys().next().value;
|
|
35
|
+
if (oldest)
|
|
36
|
+
fingerprintBudgets.delete(oldest);
|
|
37
|
+
}
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
function shouldRetry(outcome, capabilities) {
|
|
41
|
+
return capabilities.retry !== 'never' &&
|
|
42
|
+
outcome.status === 'error' &&
|
|
43
|
+
outcome.retryable === true &&
|
|
44
|
+
!NEVER_RETRY_CODES.has(outcome.code);
|
|
45
|
+
}
|
|
46
|
+
function backoff(attempt) {
|
|
47
|
+
return Math.min(TOOL_RETRY_MAX_MS, TOOL_RETRY_BASE_MS * 2 ** (attempt - 1));
|
|
48
|
+
}
|
|
49
|
+
function abortError() {
|
|
50
|
+
const error = new Error('Tool retry aborted');
|
|
51
|
+
error.name = 'AbortError';
|
|
52
|
+
return error;
|
|
53
|
+
}
|
|
54
|
+
function sleep(ms, signal) {
|
|
55
|
+
if (signal?.aborted)
|
|
56
|
+
return Promise.reject(abortError());
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const timer = setTimeout(() => {
|
|
59
|
+
signal?.removeEventListener('abort', onAbort);
|
|
60
|
+
resolve();
|
|
61
|
+
}, ms);
|
|
62
|
+
const onAbort = () => {
|
|
63
|
+
clearTimeout(timer);
|
|
64
|
+
signal?.removeEventListener('abort', onAbort);
|
|
65
|
+
reject(abortError());
|
|
66
|
+
};
|
|
67
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/** Retry safe/idempotent transient outcomes; each execute call owns one complete lock attempt. */
|
|
71
|
+
export async function executeWithToolRetry(capabilities, fingerprint, signal, execute, onRetry) {
|
|
72
|
+
const startedAt = Date.now();
|
|
73
|
+
let retryDelayMs = 0;
|
|
74
|
+
for (let attempt = 1; attempt <= TOOL_RETRY_MAX_ATTEMPTS; attempt++) {
|
|
75
|
+
const outcome = await execute(attempt);
|
|
76
|
+
const elapsed = Date.now() - startedAt;
|
|
77
|
+
const waitMs = backoff(attempt);
|
|
78
|
+
const canRetry = attempt < TOOL_RETRY_MAX_ATTEMPTS &&
|
|
79
|
+
shouldRetry(outcome, capabilities) &&
|
|
80
|
+
elapsed + waitMs <= TOOL_RETRY_TOTAL_BUDGET_MS &&
|
|
81
|
+
!signal?.aborted &&
|
|
82
|
+
reserveFingerprintRetry(fingerprint, Date.now());
|
|
83
|
+
if (!canRetry) {
|
|
84
|
+
return {
|
|
85
|
+
...outcome,
|
|
86
|
+
durationMs: elapsed,
|
|
87
|
+
attempts: attempt,
|
|
88
|
+
retryDelayMs,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
onRetry?.({ attempt, nextAttempt: attempt + 1, waitMs, code: outcome.code });
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Retry telemetry is best-effort and must never change tool execution.
|
|
96
|
+
}
|
|
97
|
+
await sleep(waitMs, signal);
|
|
98
|
+
retryDelayMs += waitMs;
|
|
99
|
+
}
|
|
100
|
+
throw new Error('unreachable tool retry state');
|
|
101
|
+
}
|
|
102
|
+
/** Test/session reset seam; production never needs to clear the bounded TTL map. */
|
|
103
|
+
export function resetToolRetryBudgets() {
|
|
104
|
+
fingerprintBudgets.clear();
|
|
105
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import Ajv from 'ajv';
|
|
2
|
+
import Ajv2020 from 'ajv/dist/2020.js';
|
|
3
|
+
const options = {
|
|
4
|
+
allErrors: true,
|
|
5
|
+
strict: false,
|
|
6
|
+
coerceTypes: false,
|
|
7
|
+
useDefaults: false,
|
|
8
|
+
removeAdditional: false,
|
|
9
|
+
validateFormats: false,
|
|
10
|
+
allowUnionTypes: true,
|
|
11
|
+
};
|
|
12
|
+
const draft7 = new Ajv(options);
|
|
13
|
+
const draft2020 = new Ajv2020(options);
|
|
14
|
+
const cache = new WeakMap();
|
|
15
|
+
function compile(schema) {
|
|
16
|
+
const cached = cache.get(schema);
|
|
17
|
+
if (cached)
|
|
18
|
+
return cached;
|
|
19
|
+
const preferred = typeof schema.$schema === 'string' && schema.$schema.includes('2020-12')
|
|
20
|
+
? [draft2020, draft7]
|
|
21
|
+
: [draft7, draft2020];
|
|
22
|
+
let lastError;
|
|
23
|
+
for (const ajv of preferred) {
|
|
24
|
+
try {
|
|
25
|
+
const result = { valid: true, validate: ajv.compile(schema) };
|
|
26
|
+
cache.set(schema, result);
|
|
27
|
+
return result;
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
lastError = error;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
const result = {
|
|
34
|
+
valid: false,
|
|
35
|
+
message: lastError instanceof Error ? lastError.message : String(lastError),
|
|
36
|
+
};
|
|
37
|
+
cache.set(schema, result);
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
function formatErrors(errors) {
|
|
41
|
+
if (!errors?.length)
|
|
42
|
+
return '参数不符合 JSON Schema';
|
|
43
|
+
return errors.slice(0, 5).map((error) => {
|
|
44
|
+
const location = error.instancePath || '/';
|
|
45
|
+
if (error.keyword === 'required') {
|
|
46
|
+
const property = String(error.params.missingProperty ?? '?');
|
|
47
|
+
return `${location} 缺少必填字段 ${JSON.stringify(property)}`;
|
|
48
|
+
}
|
|
49
|
+
if (error.keyword === 'additionalProperties') {
|
|
50
|
+
const property = String(error.params.additionalProperty ?? '?');
|
|
51
|
+
return `${location} 含未知字段 ${JSON.stringify(property)}`;
|
|
52
|
+
}
|
|
53
|
+
return `${location} ${error.message ?? error.keyword}`;
|
|
54
|
+
}).join('; ');
|
|
55
|
+
}
|
|
56
|
+
/** Validate without coercing, defaulting, removing, or otherwise mutating model arguments. */
|
|
57
|
+
export function validateToolArguments(tool, args) {
|
|
58
|
+
if (!args || typeof args !== 'object' || Array.isArray(args)) {
|
|
59
|
+
return {
|
|
60
|
+
valid: false,
|
|
61
|
+
code: 'INVALID_ARGUMENTS',
|
|
62
|
+
message: '参数根节点必须是 JSON object',
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
const compiled = compile(tool.parameters);
|
|
66
|
+
if (!compiled.valid) {
|
|
67
|
+
return {
|
|
68
|
+
valid: false,
|
|
69
|
+
code: 'INVALID_TOOL_SCHEMA',
|
|
70
|
+
message: `工具 schema 无法编译: ${compiled.message}`,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
if (compiled.validate(args))
|
|
74
|
+
return { valid: true };
|
|
75
|
+
return {
|
|
76
|
+
valid: false,
|
|
77
|
+
code: 'INVALID_ARGUMENTS',
|
|
78
|
+
message: formatErrors(compiled.validate.errors),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { pathToFileURL } from 'node:url';
|
|
5
|
+
const SUPPORTED_EXTENSIONS = new Set([
|
|
6
|
+
'.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs',
|
|
7
|
+
]);
|
|
8
|
+
async function loadTypeScript(root) {
|
|
9
|
+
const candidates = [];
|
|
10
|
+
try {
|
|
11
|
+
candidates.push(createRequire(path.join(root, 'package.json')).resolve('typescript'));
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
// Target project may not depend on TypeScript; fall back to mocode's installation.
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
candidates.push(createRequire(import.meta.url).resolve('typescript'));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// A production install may intentionally omit the optional parser.
|
|
21
|
+
}
|
|
22
|
+
for (const candidate of [...new Set(candidates)]) {
|
|
23
|
+
try {
|
|
24
|
+
const loaded = await import(pathToFileURL(candidate).href);
|
|
25
|
+
return loaded.default ?? loaded;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Try the next resolution root.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
function severity(category, ts) {
|
|
34
|
+
if (category === ts.DiagnosticCategory.Error)
|
|
35
|
+
return 'error';
|
|
36
|
+
if (category === ts.DiagnosticCategory.Warning)
|
|
37
|
+
return 'warning';
|
|
38
|
+
return 'info';
|
|
39
|
+
}
|
|
40
|
+
/** Parse only changed TS/JS files; package-wide semantic checking remains V3. */
|
|
41
|
+
export async function runChangedFileDiagnostics(root, changedFiles, inputFingerprint) {
|
|
42
|
+
const startedAt = Date.now();
|
|
43
|
+
const files = [...new Set(changedFiles)]
|
|
44
|
+
.map((file) => path.resolve(process.cwd(), file))
|
|
45
|
+
.filter((file) => SUPPORTED_EXTENSIONS.has(path.extname(file).toLowerCase()));
|
|
46
|
+
if (files.length === 0) {
|
|
47
|
+
return {
|
|
48
|
+
level: 'V1', status: 'skipped', adapter: 'typescript-parser', diagnostics: [],
|
|
49
|
+
output: 'No changed TypeScript or JavaScript files.', durationMs: Date.now() - startedAt,
|
|
50
|
+
skipReason: 'unsupported_files', inputFingerprint,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const ts = await loadTypeScript(root);
|
|
54
|
+
if (!ts) {
|
|
55
|
+
return {
|
|
56
|
+
level: 'V1', status: 'skipped', adapter: 'typescript-parser', diagnostics: [],
|
|
57
|
+
output: 'TypeScript parser is unavailable.', durationMs: Date.now() - startedAt,
|
|
58
|
+
skipReason: 'typescript_unavailable', inputFingerprint,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const diagnostics = [];
|
|
62
|
+
for (const file of files) {
|
|
63
|
+
let source;
|
|
64
|
+
try {
|
|
65
|
+
source = await readFile(file, 'utf8');
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
diagnostics.push({
|
|
69
|
+
level: 'V1', source: 'typescript', severity: 'error', code: 'READ_FAILED',
|
|
70
|
+
file: path.relative(root, file), message: error instanceof Error ? error.message : String(error),
|
|
71
|
+
});
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const result = ts.transpileModule(source, {
|
|
75
|
+
fileName: file,
|
|
76
|
+
reportDiagnostics: true,
|
|
77
|
+
compilerOptions: {
|
|
78
|
+
allowJs: true,
|
|
79
|
+
jsx: ts.JsxEmit.Preserve,
|
|
80
|
+
module: ts.ModuleKind.ESNext,
|
|
81
|
+
target: ts.ScriptTarget.Latest,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
for (const item of result.diagnostics ?? []) {
|
|
85
|
+
const location = item.file && item.start !== undefined
|
|
86
|
+
? item.file.getLineAndCharacterOfPosition(item.start)
|
|
87
|
+
: undefined;
|
|
88
|
+
diagnostics.push({
|
|
89
|
+
level: 'V1',
|
|
90
|
+
source: 'typescript',
|
|
91
|
+
severity: severity(item.category, ts),
|
|
92
|
+
code: item.code,
|
|
93
|
+
file: item.file ? path.relative(root, item.file.fileName) : path.relative(root, file),
|
|
94
|
+
line: location ? location.line + 1 : undefined,
|
|
95
|
+
column: location ? location.character + 1 : undefined,
|
|
96
|
+
message: ts.flattenDiagnosticMessageText(item.messageText, '\n'),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const failed = diagnostics.some((item) => item.severity === 'error');
|
|
101
|
+
const output = diagnostics.length === 0
|
|
102
|
+
? `Parsed ${files.length} changed TypeScript/JavaScript file(s).`
|
|
103
|
+
: diagnostics.map((item) => `${item.file ?? '<unknown>'}:${item.line ?? 0}:${item.column ?? 0} TS${item.code ?? ''} ${item.message}`).join('\n');
|
|
104
|
+
return {
|
|
105
|
+
level: 'V1', status: failed ? 'failed' : 'passed', adapter: 'typescript-parser',
|
|
106
|
+
diagnostics, output, durationMs: Date.now() - startedAt, inputFingerprint,
|
|
107
|
+
};
|
|
108
|
+
}
|