mocode-ai 0.7.2 → 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/agent/core.js +33 -5
- package/dist/permissions/index.js +7 -1
- package/dist/tools/builtins/edit-file.js +13 -3
- 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 +1 -1
- package/dist/tools/registry.js +104 -48
- package/dist/tools/retry.js +105 -0
- package/dist/tools/validation.js +80 -0
- package/package.json +2 -1
package/dist/agent/core.js
CHANGED
|
@@ -8,6 +8,7 @@ import { readFileSync } from 'node:fs';
|
|
|
8
8
|
import { chat, estimatePromptTokens, planChatTools, chatTools, } from '../llm/index.js';
|
|
9
9
|
import { executeToolOutcome, getToolCapabilities, isFileMutationTool, tools, } from '../tools/registry.js';
|
|
10
10
|
import { checkPermission } from '../permissions/index.js';
|
|
11
|
+
import { validateToolArguments } from '../tools/validation.js';
|
|
11
12
|
import { getPlanDisabledTools, getRuntimeDisabledTools } from '../tools/constants.js';
|
|
12
13
|
import { getAgentMode, setAgentMode } from './mode.js';
|
|
13
14
|
import { maybeCompact, contextState, dropContextFromHistory, createTraceEvent, summarizeToolArguments, safeProviderId, } from '../session/index.js';
|
|
@@ -479,6 +480,20 @@ export async function runAgentCore(opts) {
|
|
|
479
480
|
...(tc.id ? { providerToolCallId: tc.id } : {}),
|
|
480
481
|
});
|
|
481
482
|
}
|
|
483
|
+
const traceToolRetry = (tc, index, retry) => {
|
|
484
|
+
const traceCall = tracedCalls[index];
|
|
485
|
+
emitTrace('tool_retry', {
|
|
486
|
+
tool: tc.name,
|
|
487
|
+
argumentHash: traceCall.args.sha256,
|
|
488
|
+
attempt: retry.attempt,
|
|
489
|
+
nextAttempt: retry.nextAttempt,
|
|
490
|
+
waitMs: retry.waitMs,
|
|
491
|
+
code: retry.code,
|
|
492
|
+
}, {
|
|
493
|
+
toolCallId: traceCall.toolCallId,
|
|
494
|
+
...(tc.id ? { providerToolCallId: tc.id } : {}),
|
|
495
|
+
});
|
|
496
|
+
};
|
|
482
497
|
const traceToolEnd = (tc, index, outcome) => {
|
|
483
498
|
const traceCall = tracedCalls[index];
|
|
484
499
|
emitTrace('tool_call_end', {
|
|
@@ -488,7 +503,9 @@ export async function runAgentCore(opts) {
|
|
|
488
503
|
code: outcome.code,
|
|
489
504
|
retryable: outcome.retryable,
|
|
490
505
|
durationMs: outcome.durationMs ?? 0,
|
|
491
|
-
|
|
506
|
+
attempt: outcome.attempts ?? 1,
|
|
507
|
+
retry: Math.max(0, (outcome.attempts ?? 1) - 1),
|
|
508
|
+
retryDelayMs: outcome.retryDelayMs ?? 0,
|
|
492
509
|
changedFiles: outcome.changedFiles ?? [],
|
|
493
510
|
}, {
|
|
494
511
|
toolCallId: traceCall.toolCallId,
|
|
@@ -529,7 +546,10 @@ export async function runAgentCore(opts) {
|
|
|
529
546
|
for (const tc of batch)
|
|
530
547
|
hooks.onToolHeader?.(tc);
|
|
531
548
|
hooks.onToolStart?.(batch[0].name);
|
|
532
|
-
const started = batch.map((tc) => executeToolOutcome(tc.name, tc.arguments, signal, {
|
|
549
|
+
const started = batch.map((tc, offset) => executeToolOutcome(tc.name, tc.arguments, signal, {
|
|
550
|
+
dropContext,
|
|
551
|
+
onRetry: (retry) => traceToolRetry(tc, i + offset, retry),
|
|
552
|
+
}));
|
|
533
553
|
for (let k = 0; k < batch.length; k++) {
|
|
534
554
|
const tc = batch[k];
|
|
535
555
|
const outcome = await started[k];
|
|
@@ -559,8 +579,11 @@ export async function runAgentCore(opts) {
|
|
|
559
579
|
const tc = batch[k];
|
|
560
580
|
const parsed = parseArgs(tc.arguments);
|
|
561
581
|
const tool = tools.find((candidate) => candidate.name === tc.name);
|
|
582
|
+
const argumentsValid = tool && parsed !== null
|
|
583
|
+
? validateToolArguments(tool, parsed).valid
|
|
584
|
+
: false;
|
|
562
585
|
let denied;
|
|
563
|
-
if (tool) {
|
|
586
|
+
if (tool && argumentsValid) {
|
|
564
587
|
const perm = await checkPermission(tool, parsed ?? {}, signal);
|
|
565
588
|
emitTrace('permission', {
|
|
566
589
|
source: 'agent_tool',
|
|
@@ -586,13 +609,14 @@ export async function runAgentCore(opts) {
|
|
|
586
609
|
const firstAllowed = entries.find((entry) => !entry.denied);
|
|
587
610
|
if (firstAllowed)
|
|
588
611
|
hooks.onToolStart?.(firstAllowed.tc.name);
|
|
589
|
-
const started = entries.map((entry) => entry.denied
|
|
612
|
+
const started = entries.map((entry, offset) => entry.denied
|
|
590
613
|
? Promise.resolve(entry.denied)
|
|
591
614
|
: executeToolOutcome(entry.tc.name, entry.tc.arguments, signal, {
|
|
592
615
|
dropContext,
|
|
593
616
|
onLockAcquired: (lockedArgs) => {
|
|
594
617
|
entry.diff = readDiffContext(entry.tc, lockedArgs);
|
|
595
618
|
},
|
|
619
|
+
onRetry: (retry) => traceToolRetry(entry.tc, i + offset, retry),
|
|
596
620
|
}));
|
|
597
621
|
for (let k = 0; k < entries.length; k++) {
|
|
598
622
|
const entry = entries[k];
|
|
@@ -642,7 +666,10 @@ export async function runAgentCore(opts) {
|
|
|
642
666
|
// 拒绝时只渲染拒绝结果,不渲染执行头;放行则继续走 header → start → executeTool 流程。
|
|
643
667
|
const parsed = parseArgs(tc.arguments);
|
|
644
668
|
const tool = tools.find((t) => t.name === tc.name);
|
|
645
|
-
|
|
669
|
+
const argumentsValid = tool && parsed !== null
|
|
670
|
+
? validateToolArguments(tool, parsed).valid
|
|
671
|
+
: false;
|
|
672
|
+
if (tool && argumentsValid) {
|
|
646
673
|
const perm = await checkPermission(tool, parsed ?? {}, signal);
|
|
647
674
|
emitTrace('permission', {
|
|
648
675
|
source: 'agent_tool',
|
|
@@ -676,6 +703,7 @@ export async function runAgentCore(opts) {
|
|
|
676
703
|
if (mutationParsed)
|
|
677
704
|
diff = readDiffContext(tc, lockedArgs);
|
|
678
705
|
},
|
|
706
|
+
onRetry: (retry) => traceToolRetry(tc, i, retry),
|
|
679
707
|
});
|
|
680
708
|
traceToolEnd(tc, i, outcome);
|
|
681
709
|
const output = outcome.output;
|
|
@@ -33,7 +33,13 @@ export function permissionFingerprint(tool, args) {
|
|
|
33
33
|
subject = { command: args.command.trim() };
|
|
34
34
|
}
|
|
35
35
|
else {
|
|
36
|
-
|
|
36
|
+
let resources;
|
|
37
|
+
try {
|
|
38
|
+
resources = tool.capabilities?.resources?.(args).filter(Boolean).sort();
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
resources = undefined;
|
|
42
|
+
}
|
|
37
43
|
// File mutations may be granted by their concrete resource. Coarse resources such as
|
|
38
44
|
// "workspace" must retain arguments so task/process-like calls cannot become tool-wide.
|
|
39
45
|
subject = resources?.length && typeof args.path === 'string'
|
|
@@ -30,10 +30,20 @@ export const editFileTool = {
|
|
|
30
30
|
const normNew = newStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
|
31
31
|
const count = norm.split(normOld).length - 1;
|
|
32
32
|
if (count === 0) {
|
|
33
|
-
return
|
|
33
|
+
return {
|
|
34
|
+
status: 'error',
|
|
35
|
+
code: 'EDIT_CONFLICT',
|
|
36
|
+
retryable: false,
|
|
37
|
+
output: `错误:在 ${path} 中未找到 old_string。不要重试相同参数;请先 read_file 读取目标区域,再从返回内容逐字复制新的 old_string 后重试。`,
|
|
38
|
+
};
|
|
34
39
|
}
|
|
35
40
|
if (count > 1) {
|
|
36
|
-
return
|
|
41
|
+
return {
|
|
42
|
+
status: 'error',
|
|
43
|
+
code: 'EDIT_CONFLICT',
|
|
44
|
+
retryable: false,
|
|
45
|
+
output: `错误:old_string 在 ${path} 中出现 ${count} 次,不唯一。请加入更多上下文使其唯一。`,
|
|
46
|
+
};
|
|
37
47
|
}
|
|
38
48
|
// 用函数形式替换,避免 new_string 里的 $ 被当特殊模式
|
|
39
49
|
const updated = norm.replace(normOld, () => normNew);
|
|
@@ -45,7 +55,7 @@ export const editFileTool = {
|
|
|
45
55
|
return {
|
|
46
56
|
status: 'error',
|
|
47
57
|
code: 'POSTCONDITION_FAILED',
|
|
48
|
-
retryable:
|
|
58
|
+
retryable: false,
|
|
49
59
|
output: postcondition.diagnostics
|
|
50
60
|
.map((item) => `[${item.code ?? 'V0_FAILED'}] ${item.file ?? path}: ${item.message}`)
|
|
51
61
|
.join('\n'),
|
|
@@ -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);
|
|
@@ -25,7 +25,7 @@ export const writeFileTool = {
|
|
|
25
25
|
return {
|
|
26
26
|
status: 'error',
|
|
27
27
|
code: 'POSTCONDITION_FAILED',
|
|
28
|
-
retryable:
|
|
28
|
+
retryable: false,
|
|
29
29
|
output: postcondition.diagnostics
|
|
30
30
|
.map((item) => `[${item.code ?? 'V0_FAILED'}] ${item.file ?? path}: ${item.message}`)
|
|
31
31
|
.join('\n'),
|
package/dist/tools/registry.js
CHANGED
|
@@ -2,6 +2,8 @@ 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
4
|
import { resolveResourceLockRequests, toolResourceLockManager } from './resource-lock.js';
|
|
5
|
+
import { executeWithToolRetry } from './retry.js';
|
|
6
|
+
import { validateToolArguments } from './validation.js';
|
|
5
7
|
import { t } from '../i18n/index.js';
|
|
6
8
|
import { isToolErrorOutput } from './result.js';
|
|
7
9
|
/**
|
|
@@ -67,7 +69,7 @@ function isStructuredOutcome(value) {
|
|
|
67
69
|
typeof value.status === 'string' && typeof value.code === 'string' &&
|
|
68
70
|
typeof value.retryable === 'boolean' && typeof value.output === 'string';
|
|
69
71
|
}
|
|
70
|
-
function normalizeOutcome(value,
|
|
72
|
+
function normalizeOutcome(value, durationMs, changedFiles) {
|
|
71
73
|
if (isStructuredOutcome(value)) {
|
|
72
74
|
return {
|
|
73
75
|
...value,
|
|
@@ -79,7 +81,8 @@ function normalizeOutcome(value, capabilities, durationMs, changedFiles) {
|
|
|
79
81
|
return {
|
|
80
82
|
status: failed ? 'error' : 'success',
|
|
81
83
|
code: failed ? 'EXECUTION_ERROR' : 'OK',
|
|
82
|
-
|
|
84
|
+
// Legacy string errors carry no transient classification and are never retried blindly.
|
|
85
|
+
retryable: false,
|
|
83
86
|
output: value,
|
|
84
87
|
changedFiles,
|
|
85
88
|
durationMs,
|
|
@@ -95,92 +98,145 @@ function terminalOutcome(status, code, output, startedAt, changedFiles = []) {
|
|
|
95
98
|
durationMs: Date.now() - startedAt,
|
|
96
99
|
};
|
|
97
100
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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) {
|
|
103
137
|
const startedAt = Date.now();
|
|
104
|
-
if (signal?.aborted) {
|
|
105
|
-
return terminalOutcome('aborted', 'ABORTED', t('command.interrupted'), startedAt);
|
|
106
|
-
}
|
|
107
|
-
const tool = findTool(name);
|
|
108
|
-
if (!tool) {
|
|
109
|
-
return terminalOutcome('error', 'UNKNOWN_TOOL', t('toolError.unknown', { name }), startedAt);
|
|
110
|
-
}
|
|
111
|
-
let args;
|
|
112
|
-
try {
|
|
113
|
-
args = argsRaw.trim() ? JSON.parse(argsRaw) : {};
|
|
114
|
-
}
|
|
115
|
-
catch {
|
|
116
|
-
return terminalOutcome('error', 'INVALID_JSON', t('toolError.invalidJson', { name, arguments: argsRaw }), startedAt);
|
|
117
|
-
}
|
|
118
138
|
const capabilities = getToolCapabilities(tool);
|
|
119
139
|
let mutationVersionBefore;
|
|
120
140
|
let capturedPath;
|
|
121
141
|
try {
|
|
122
|
-
const sandboxError = enforceSandbox(name, args);
|
|
123
|
-
if (sandboxError) {
|
|
124
|
-
return terminalOutcome('denied', 'SANDBOX_DENIED', sandboxError, startedAt);
|
|
125
|
-
}
|
|
126
142
|
const requests = resolveResourceLockRequests(capabilities, args);
|
|
127
143
|
return await toolResourceLockManager.withLocks(requests, signal, async () => {
|
|
128
|
-
|
|
129
|
-
|
|
144
|
+
if (notifyLockAcquired)
|
|
145
|
+
opts?.onLockAcquired?.(args);
|
|
130
146
|
const mutationBefore = getCurrentTurnMutationState();
|
|
131
147
|
mutationVersionBefore = mutationBefore.version;
|
|
132
|
-
const pathCapture = isFileMutationTool(name) && typeof args.path === 'string' && args.path
|
|
148
|
+
const pathCapture = isFileMutationTool(tool.name) && typeof args.path === 'string' && args.path
|
|
133
149
|
? beginPathMutation(args.path)
|
|
134
150
|
: null;
|
|
135
151
|
capturedPath = pathCapture?.path;
|
|
136
|
-
// 进程和未知扩展可能间接改动任意文件;其 workspace lock 同时隔离全盘捕获。
|
|
137
152
|
const workspaceCapture = capabilities.effect === 'process' || capabilities.effect === 'unknown'
|
|
138
153
|
? beginWorkspaceMutation()
|
|
139
154
|
: null;
|
|
140
155
|
let raw;
|
|
141
156
|
try {
|
|
142
|
-
raw = await tool.execute(args, {
|
|
143
|
-
signal,
|
|
144
|
-
dropContext: opts?.dropContext,
|
|
145
|
-
});
|
|
157
|
+
raw = await tool.execute(args, { signal, dropContext: opts?.dropContext });
|
|
146
158
|
}
|
|
147
159
|
finally {
|
|
148
160
|
if (pathCapture)
|
|
149
|
-
endPathMutation(pathCapture, name);
|
|
161
|
+
endPathMutation(pathCapture, tool.name);
|
|
150
162
|
if (workspaceCapture)
|
|
151
|
-
endWorkspaceMutation(workspaceCapture, name);
|
|
163
|
+
endWorkspaceMutation(workspaceCapture, tool.name);
|
|
152
164
|
}
|
|
153
165
|
const mutationAfter = getCurrentTurnMutationState();
|
|
154
166
|
const changedFiles = mutationAfter.version !== mutationBefore.version
|
|
155
167
|
? pathCapture
|
|
156
|
-
? mutationAfter.changedFiles
|
|
157
|
-
.filter((item) => item.path === pathCapture.path)
|
|
158
|
-
.map((item) => item.path)
|
|
168
|
+
? mutationAfter.changedFiles.filter((item) => item.path === pathCapture.path).map((item) => item.path)
|
|
159
169
|
: mutationAfter.changedFiles.map((item) => item.path)
|
|
160
170
|
: [];
|
|
161
171
|
if (signal?.aborted) {
|
|
162
172
|
return terminalOutcome('aborted', 'ABORTED', String(isStructuredOutcome(raw) ? raw.output : raw), startedAt, changedFiles);
|
|
163
173
|
}
|
|
164
|
-
return normalizeOutcome(raw,
|
|
174
|
+
return normalizeOutcome(raw, Date.now() - startedAt, changedFiles);
|
|
165
175
|
});
|
|
166
176
|
}
|
|
167
177
|
catch (error) {
|
|
168
178
|
const mutationAfter = getCurrentTurnMutationState();
|
|
169
|
-
const changedFiles = mutationVersionBefore !== undefined &&
|
|
170
|
-
mutationAfter.version !== mutationVersionBefore
|
|
179
|
+
const changedFiles = mutationVersionBefore !== undefined && mutationAfter.version !== mutationVersionBefore
|
|
171
180
|
? capturedPath
|
|
172
|
-
? mutationAfter.changedFiles
|
|
173
|
-
.filter((item) => item.path === capturedPath)
|
|
174
|
-
.map((item) => item.path)
|
|
181
|
+
? mutationAfter.changedFiles.filter((item) => item.path === capturedPath).map((item) => item.path)
|
|
175
182
|
: mutationAfter.changedFiles.map((item) => item.path)
|
|
176
183
|
: [];
|
|
177
184
|
if (signal?.aborted || (error instanceof Error && error.name === 'AbortError')) {
|
|
178
185
|
return terminalOutcome('aborted', 'ABORTED', t('command.interrupted'), startedAt, changedFiles);
|
|
179
186
|
}
|
|
180
|
-
return
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* 结构化工具调度入口。永不抛错;旧字符串工具在此归一化为 ToolOutcome。
|
|
203
|
+
* 权限仍由 Agent 在展示工具头之前预检,保持现有交互时序。
|
|
204
|
+
*/
|
|
205
|
+
export async function executeToolOutcome(name, argsRaw, signal, opts) {
|
|
206
|
+
const startedAt = Date.now();
|
|
207
|
+
if (signal?.aborted) {
|
|
208
|
+
return terminalOutcome('aborted', 'ABORTED', t('command.interrupted'), startedAt);
|
|
209
|
+
}
|
|
210
|
+
const tool = findTool(name);
|
|
211
|
+
if (!tool) {
|
|
212
|
+
return terminalOutcome('error', 'UNKNOWN_TOOL', t('toolError.unknown', { name }), startedAt);
|
|
213
|
+
}
|
|
214
|
+
let parsed;
|
|
215
|
+
try {
|
|
216
|
+
parsed = argsRaw.trim() ? JSON.parse(argsRaw) : {};
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
return terminalOutcome('error', 'INVALID_JSON', t('toolError.invalidJson', { name, arguments: argsRaw }), startedAt);
|
|
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
|
+
}
|
|
231
|
+
const capabilities = getToolCapabilities(tool);
|
|
232
|
+
try {
|
|
233
|
+
return await executeWithToolRetry(capabilities, fingerprint, signal, (attempt) => executeToolAttempt(tool, args, signal, opts, attempt === 1), opts?.onRetry);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
if (signal?.aborted || (error instanceof Error && error.name === 'AbortError')) {
|
|
237
|
+
return terminalOutcome('aborted', 'ABORTED', t('command.interrupted'), startedAt);
|
|
238
|
+
}
|
|
239
|
+
return executionErrorOutcome(name, error, startedAt, []);
|
|
184
240
|
}
|
|
185
241
|
}
|
|
186
242
|
/** 字符串兼容入口:现有调用方、TUI 和 LLM history 无需同步迁移。 */
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mocode-ai",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"prepare": "npm run build"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
+
"ajv": "8.20.0",
|
|
30
31
|
"cli-highlight": "^2.1.11",
|
|
31
32
|
"dotenv": "^16.0.0",
|
|
32
33
|
"fast-glob": "^3.0.0",
|