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.
@@ -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
- retry: 0,
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, { dropContext }));
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
- if (tool) {
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
- const resources = tool.capabilities?.resources?.(args).filter(Boolean).sort();
36
+ let resources;
37
+ try {
38
+ resources = tool.capabilities?.resources?.(args).filter(Boolean).sort();
39
+ }
40
+ catch {
41
+ resources = undefined;
42
+ }
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 `错误:在 ${path} 中未找到 old_string。不要重试相同参数;请先 read_file 读取目标区域,再从返回内容逐字复制新的 old_string 后重试。`;
33
+ return {
34
+ status: 'error',
35
+ code: 'EDIT_CONFLICT',
36
+ retryable: false,
37
+ output: `错误:在 ${path} 中未找到 old_string。不要重试相同参数;请先 read_file 读取目标区域,再从返回内容逐字复制新的 old_string 后重试。`,
38
+ };
34
39
  }
35
40
  if (count > 1) {
36
- return `错误:old_string 在 ${path} 中出现 ${count} 次,不唯一。请加入更多上下文使其唯一。`;
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: true,
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 `错误:抓取失败 HTTP ${resp.status} ${resp.statusText}\n${text.slice(0, 500)}`;
52
+ return {
53
+ status: 'error',
54
+ code: 'HTTP_ERROR',
55
+ retryable: resp.status === 408 || resp.status === 429 || resp.status >= 500,
56
+ output: `错误:抓取失败 HTTP ${resp.status} ${resp.statusText}\n${text.slice(0, 500)}`,
57
+ };
53
58
  }
54
59
  const isHtml = /html/i.test(contentType) ||
55
60
  /^\s*<!doctype html/i.test(text) ||
@@ -65,12 +70,23 @@ export const webFetchTool = {
65
70
  }
66
71
  catch (e) {
67
72
  if (ctrl.signal.aborted) {
68
- if (externalSignal?.aborted)
69
- return `错误:已中断: ${url.href}`;
70
- return `错误:抓取超时(${FETCH_TIMEOUT_MS}ms): ${url.href}`;
73
+ if (externalSignal?.aborted) {
74
+ return { status: 'aborted', code: 'ABORTED', retryable: false, output: `错误:已中断: ${url.href}` };
75
+ }
76
+ return {
77
+ status: 'error',
78
+ code: 'TIMEOUT',
79
+ retryable: true,
80
+ output: `错误:抓取超时(${FETCH_TIMEOUT_MS}ms): ${url.href}`,
81
+ };
71
82
  }
72
83
  const msg = e instanceof Error ? e.message : String(e);
73
- return `错误:抓取失败: ${msg}`;
84
+ return {
85
+ status: 'error',
86
+ code: 'NETWORK_ERROR',
87
+ retryable: true,
88
+ output: `错误:抓取失败: ${msg}`,
89
+ };
74
90
  }
75
91
  finally {
76
92
  clearTimeout(timer);
@@ -75,7 +75,12 @@ export const webSearchTool = {
75
75
  data = JSON.parse(text);
76
76
  }
77
77
  catch {
78
- return `错误:搜索返回非 JSON(HTTP ${resp.status}): ${text.slice(0, 500)}`;
78
+ return {
79
+ status: 'error',
80
+ code: 'HTTP_ERROR',
81
+ retryable: resp.status === 408 || resp.status === 429 || resp.status >= 500,
82
+ output: `错误:搜索返回非 JSON(HTTP ${resp.status}): ${text.slice(0, 500)}`,
83
+ };
79
84
  }
80
85
  // AnySearch 成功返回 code===0;否则把 message/request_id 喂回 LLM。
81
86
  if (!resp.ok || data?.code !== 0) {
@@ -92,7 +97,13 @@ export const webSearchTool = {
92
97
  else if (resp.status === 429) {
93
98
  hint = ' 触发限流,请稍后重试。';
94
99
  }
95
- return `错误:搜索失败 [${code}] ${message}${rid}${hint}`;
100
+ return {
101
+ status: 'error',
102
+ code: !resp.ok || Number(code) === 429 ? 'HTTP_ERROR' : 'EXECUTION_ERROR',
103
+ retryable: resp.status === 408 || resp.status === 429 ||
104
+ resp.status >= 500 || Number(code) === 429,
105
+ output: `错误:搜索失败 [${code}] ${message}${rid}${hint}`,
106
+ };
96
107
  }
97
108
  const results = data?.data?.results;
98
109
  if (!Array.isArray(results) || results.length === 0) {
@@ -129,10 +140,23 @@ export const webSearchTool = {
129
140
  }
130
141
  catch (e) {
131
142
  if (ctrl.signal.aborted) {
132
- return `错误:搜索超时(${SEARCH_TIMEOUT_MS}ms)。`;
143
+ if (externalSignal?.aborted) {
144
+ return { status: 'aborted', code: 'ABORTED', retryable: false, output: '错误:搜索已中断。' };
145
+ }
146
+ return {
147
+ status: 'error',
148
+ code: 'TIMEOUT',
149
+ retryable: true,
150
+ output: `错误:搜索超时(${SEARCH_TIMEOUT_MS}ms)。`,
151
+ };
133
152
  }
134
153
  const msg = e instanceof Error ? e.message : String(e);
135
- return `错误:联网搜索请求失败: ${msg}`;
154
+ return {
155
+ status: 'error',
156
+ code: 'NETWORK_ERROR',
157
+ retryable: true,
158
+ output: `错误:联网搜索请求失败: ${msg}`,
159
+ };
136
160
  }
137
161
  finally {
138
162
  clearTimeout(timer);
@@ -25,7 +25,7 @@ export const writeFileTool = {
25
25
  return {
26
26
  status: 'error',
27
27
  code: 'POSTCONDITION_FAILED',
28
- retryable: true,
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'),
@@ -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, capabilities, durationMs, changedFiles) {
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
- retryable: failed && capabilities.retry !== 'never',
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
- * 结构化工具调度入口。永不抛错;旧字符串工具在此归一化为 ToolOutcome。
100
- * 权限仍由 Agent 在展示工具头之前预检,保持现有交互时序。
101
- */
102
- export async function executeToolOutcome(name, argsRaw, signal, opts) {
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
- // Diff 等执行前观察必须发生在真正持锁之后;同路径排队调用才能看到前序写入结果。
129
- opts?.onLockAcquired?.(args);
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, capabilities, Date.now() - startedAt, changedFiles);
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 terminalOutcome('error', 'EXECUTION_ERROR', t('toolError.execution', {
181
- name,
182
- message: error instanceof Error ? error.message : String(error),
183
- }), startedAt, changedFiles);
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.2",
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",