mocode-ai 1.7.5 → 1.7.6

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.
@@ -11,7 +11,7 @@ import { defaultAgentRuntimeContext } from './runtime-context.js';
11
11
  import { runModelTurn } from './model-turn.js';
12
12
  import { runToolTurn } from './tool-turn.js';
13
13
  import { createTurnLifecycle } from './turn-lifecycle.js';
14
- import { parseArgs, argumentErrorHint, isParallelTool, isParallelOrchestrationCall, isResourceLockedCall, deniedOutcome, readDiffContext, pushToolResult, } from './tool-helpers.js';
14
+ import { parseArgs, argumentErrorHint, isParallelTool, isParallelOrchestrationCall, isResourceLockedCall, deniedOutcome, readDiffContext, pushToolResult, unregisteredToolOutcome, } from './tool-helpers.js';
15
15
  import { contextState, summarizeToolArguments } from '../session/index.js';
16
16
  import { createReadDedup } from '../tools/read-dedup.js';
17
17
  import { createBudgetScheduler } from '../session/scheduler.js';
@@ -424,10 +424,14 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
424
424
  const tc = calls[index];
425
425
  const outcome = {
426
426
  status: 'denied',
427
- code: 'TOOL_DISABLED',
427
+ code: isToolDeniedForStep(tc.name) && !ctx.toolRuntime.findTool(tc.name)
428
+ ? 'UNKNOWN_TOOL'
429
+ : 'TOOL_DISABLED',
428
430
  retryable: false,
429
431
  output: isToolDeniedForStep(tc.name)
430
- ? `错误:当前 tool policy snapshot 不允许调用 ${tc.name}。`
432
+ ? !ctx.toolRuntime.findTool(tc.name)
433
+ ? unregisteredToolOutcome(tc.name, ctx.toolRuntime).output
434
+ : `错误:当前 tool policy snapshot 不允许调用 ${tc.name}。`
431
435
  : `错误:add_tool_groups 不能与写/执行工具 ${tc.name} 同批执行,本 step 已跳过该调用(其余只读调用与扩容已正常处理);请在下一 step 重试 ${tc.name}。`,
432
436
  changedFiles: [],
433
437
  durationMs: 0,
@@ -526,10 +530,14 @@ export async function runAgentCoreLegacy(opts, historyManager, stages) {
526
530
  const currentCall = calls[i];
527
531
  if (isToolDeniedForStep(currentCall.name)) {
528
532
  hooks.onToolHeader?.(currentCall);
529
- const error = t('task.disabled');
533
+ // registry 中根本不存在的名字(模型幻觉/拼写错误)给专门纠错提示,
534
+ // 不再复用 task.disabled 的 /subagent 误导文案;已注册但本步不允许才用 gate 文案。
535
+ const error = !ctx.toolRuntime.findTool(currentCall.name)
536
+ ? unregisteredToolOutcome(currentCall.name, ctx.toolRuntime).output
537
+ : t('task.disabled');
530
538
  const outcome = {
531
539
  status: 'denied',
532
- code: 'TOOL_DISABLED',
540
+ code: !ctx.toolRuntime.findTool(currentCall.name) ? 'UNKNOWN_TOOL' : 'TOOL_DISABLED',
533
541
  retryable: false,
534
542
  output: error,
535
543
  changedFiles: [],
@@ -5,11 +5,19 @@ function replaceMessages(backing, messages) {
5
5
  backing.push(...replacement);
6
6
  }
7
7
  function assistantMessage(turn) {
8
+ const hasToolCalls = turn.toolCalls.length > 0;
8
9
  const message = {
9
10
  role: 'assistant',
10
- content: turn.content,
11
+ // 无 tool_calls 时 content 不能是 null:那是 OpenAI 自己吐 tool_call 时的规范形状,
12
+ // 纯文本轮次写 null 会被严格 OpenAI 兼容网关整请求 400(实测 speedbear:
13
+ // "messages[N].content 不能为 null … 无文本请传空字符串"),且消息一旦落进 history
14
+ // 就每轮重发 → 整个会话永久中毒,换个会话同模型却正常。来源是 SSE 流中途结束
15
+ // (网关掐断)时 SDK 不抛错、content 停在 null 而 tool_calls 为空,框架仍当
16
+ // 「agent 完成了这一轮」写盘。构造点在此收敛,transport 边界另有兜底
17
+ // (src/llm/message-shape.ts)负责让存量中毒会话自愈。
18
+ content: !hasToolCalls && turn.content === null ? '' : turn.content,
11
19
  };
12
- if (turn.toolCalls.length > 0) {
20
+ if (hasToolCalls) {
13
21
  // thoughtSignature 不是 OpenAI 字段,是 mocode 给 Gemini/Vertex 透传用的;
14
22
  // 挂在 tool_call 上随 history 落盘/回灌,Vertex encoder 会读回它。
15
23
  message.tool_calls = turn.toolCalls.map((call) => ({
@@ -7,7 +7,7 @@ import { summarizeToolArguments } from '../../session/index.js';
7
7
  import { getPlanDisabledTools } from '../../tools/constants.js';
8
8
  import { defaultToolRuntime } from '../../tools/registry.js';
9
9
  import { validateToolArguments } from '../../tools/validation.js';
10
- import { deniedOutcome, isParallelTool, isParallelOrchestrationCall, isResourceLockedCall, parseArgs, readDiffContext, } from '../tool-helpers.js';
10
+ import { deniedOutcome, isParallelTool, isParallelOrchestrationCall, isResourceLockedCall, parseArgs, readDiffContext, unregisteredToolOutcome, } from '../tool-helpers.js';
11
11
  const DEFAULT_DEPENDENCIES = {
12
12
  toolRuntime: defaultToolRuntime,
13
13
  checkPermission: defaultCheckPermission,
@@ -119,10 +119,12 @@ class LegacyCompatibleToolDispatcher {
119
119
  const call = calls[index];
120
120
  const outcome = {
121
121
  status: 'denied',
122
- code: 'TOOL_DISABLED',
122
+ code: request.isDenied(call.name) && !toolRuntime.findTool(call.name) ? 'UNKNOWN_TOOL' : 'TOOL_DISABLED',
123
123
  retryable: false,
124
124
  output: request.isDenied(call.name)
125
- ? `错误:当前 tool policy snapshot 不允许调用 ${call.name}。`
125
+ ? !toolRuntime.findTool(call.name)
126
+ ? unregisteredToolOutcome(call.name, toolRuntime).output
127
+ : `错误:当前 tool policy snapshot 不允许调用 ${call.name}。`
126
128
  : `错误:add_tool_groups 不能与写/执行工具 ${call.name} 同批执行,本 step 已跳过该调用(其余只读调用与扩容已正常处理);请在下一 step 重试 ${call.name}。`,
127
129
  changedFiles: [],
128
130
  durationMs: 0,
@@ -213,10 +215,12 @@ class LegacyCompatibleToolDispatcher {
213
215
  const current = calls[index];
214
216
  if (request.isDenied(current.name)) {
215
217
  request.onEvent({ type: 'header', call: current });
216
- const output = t('task.disabled');
218
+ // registry 中不存在的名字(幻觉/拼写错误)给纠错提示,不误导用户去开 /subagent。
219
+ const unregistered = !toolRuntime.findTool(current.name);
220
+ const output = unregistered ? unregisteredToolOutcome(current.name, toolRuntime).output : t('task.disabled');
217
221
  const outcome = {
218
222
  status: 'denied',
219
- code: 'TOOL_DISABLED',
223
+ code: unregistered ? 'UNKNOWN_TOOL' : 'TOOL_DISABLED',
220
224
  retryable: false,
221
225
  output,
222
226
  changedFiles: [],
@@ -16,6 +16,7 @@ import { capToolResultForHistory } from '../session/compact.js';
16
16
  import { recordArtifact, knownEditTargets } from '../context/index.js';
17
17
  import { isToolResultSuccess } from '../context/utils.js';
18
18
  import { appendGuiAction } from '../session/gui-actions.js';
19
+ import { t } from '../i18n/index.js';
19
20
  /** 解析工具 arguments JSON;非法或空返 null(调用方据此降级到普通 preview)。 */
20
21
  export function parseArgs(raw) {
21
22
  try {
@@ -76,6 +77,52 @@ export function deniedOutcome(name) {
76
77
  output: `错误:用户拒绝了工具 ${name} 的执行。`,
77
78
  };
78
79
  }
80
+ /** 编辑距离(Levenshtein);名字很短时用上限截断避免 O(n²) 浪费。 */
81
+ function editDistance(a, b, max = 4) {
82
+ if (Math.abs(a.length - b.length) > max)
83
+ return max + 1;
84
+ const prev = new Array(b.length + 1);
85
+ const curr = new Array(b.length + 1);
86
+ for (let j = 0; j <= b.length; j++)
87
+ prev[j] = j;
88
+ for (let i = 1; i <= a.length; i++) {
89
+ curr[0] = i;
90
+ let rowMin = curr[0];
91
+ for (let j = 1; j <= b.length; j++) {
92
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
93
+ if (curr[j] < rowMin)
94
+ rowMin = curr[j];
95
+ }
96
+ if (rowMin > max)
97
+ return max + 1;
98
+ for (let j = 0; j <= b.length; j++)
99
+ prev[j] = curr[j];
100
+ }
101
+ return prev[b.length];
102
+ }
103
+ /**
104
+ * 未注册工具名(模型幻觉/拼写错误)的专门 denied 结果。
105
+ * isToolDeniedForStep 对不在 allowedToolNames 里的一切名字(含 registry 中不存在的)都返回
106
+ * true,旧路径一律复用 task.disabled 文案,误导用户去开 /subagent。此 helper 先查 registry:
107
+ * 名字根本不存在时给出「未注册 + 相近候选」的纠错提示;存在但本步不允许时才回落 gate 文案。
108
+ */
109
+ export function unregisteredToolOutcome(name, toolRuntime) {
110
+ const registered = toolRuntime.tools.map((tool) => tool.name);
111
+ const ranked = registered
112
+ .map((candidate) => ({
113
+ candidate,
114
+ distance: editDistance(name.toLowerCase(), candidate.toLowerCase()),
115
+ }))
116
+ .sort((x, y) => x.distance - y.distance);
117
+ const close = ranked.filter((entry) => entry.distance <= 3).slice(0, 3);
118
+ const suggestions = close.length > 0 ? close.map((entry) => entry.candidate).join(', ') : t('toolError.noCloseMatch');
119
+ return {
120
+ status: 'denied',
121
+ code: 'UNKNOWN_TOOL',
122
+ retryable: false,
123
+ output: t('toolError.unregistered', { name, suggestions }),
124
+ };
125
+ }
79
126
  /** mutation 执行前读旧内容供 diff:write_file 取整文件旧内容(不存在→null=新建),
80
127
  * edit_file 取 old_string 起始行号(供 diff 显示真实文件行号)。读不到则 diff 退化为相对行号。
81
128
  * 非 mutation 或参数非法返 { preWriteOld: null, editStartLine: 1 }。失败不阻断。 */
@@ -241,6 +241,8 @@ const zhCN = {
241
241
  'toolSummary.noOutput': '(无输出)',
242
242
  'toolSummary.imageAttached': '图片已作为视觉输入附加',
243
243
  'toolError.unknown': '错误:未知工具 "{name}"',
244
+ 'toolError.unregistered': '错误:工具 "{name}" 未注册(不在本会话可用工具列表中,可能是拼写错误或模型幻觉出的名称)。相近的已注册工具:{suggestions}。请只调用 tools 列表中存在的名称,不要自造工具名。',
245
+ 'toolError.noCloseMatch': '无相近的已注册工具名',
244
246
  'toolError.invalidJson': '错误:工具 {name} 的 arguments 不是合法 JSON: {arguments}',
245
247
  'toolError.execution': '错误:工具 {name} 执行失败: {message}',
246
248
  'command.outputTruncated': '...(输出已截断 {count} 字符,保留开头与结尾)...',
@@ -624,6 +626,8 @@ const en = {
624
626
  'toolSummary.noOutput': '(no output)',
625
627
  'toolSummary.imageAttached': 'Image attached as visual input',
626
628
  'toolError.unknown': 'Error: unknown tool "{name}"',
629
+ 'toolError.unregistered': 'Error: tool "{name}" is not registered (it is not in this session\'s available tool list — likely a misspelling or a model-hallucinated name). Closest registered tools: {suggestions}. Only call names that appear in the tools list; never invent tool names.',
630
+ 'toolError.noCloseMatch': 'no close match among registered tools',
627
631
  'toolError.invalidJson': 'Error: arguments for tool {name} are not valid JSON: {arguments}',
628
632
  'toolError.execution': 'Error: tool {name} failed: {message}',
629
633
  'command.outputTruncated': '...(output truncated by {count} characters; keeping the beginning and end)...',
package/dist/llm/index.js CHANGED
@@ -5,6 +5,7 @@ import { getPlanDisabledTools, getProfileDisabledTools } from '../tools/constant
5
5
  import { imageSizeFromDataUrl } from '../attachments/image.js';
6
6
  import { ThinkTagFilter } from './think-filter.js';
7
7
  import { sanitizeToolSchemas } from './tool-schema.js';
8
+ import { sanitizeAssistantContent } from './message-shape.js';
8
9
  import { isMarkedStreamInterrupted } from './stream-interrupt.js';
9
10
  import { defaultAnthropicFetch, anthropicChatOnce } from './providers/anthropic.js';
10
11
  import { vertexChatOnce } from './providers/vertex.js';
@@ -676,7 +677,7 @@ async function chatOnce(messages, handlers, signal, toolsOverride, runtime = def
676
677
  });
677
678
  const stream = await create({
678
679
  model: runtime.getModel(),
679
- messages: normalizeImageDetail(messages),
680
+ messages: sanitizeAssistantContent(normalizeImageDetail(messages)),
680
681
  stream: true,
681
682
  // 空工具表(如摘要请求)不发 tools 字段——部分网关拒绝空数组。
682
683
  ...(activeTools.length > 0
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 把「无文本、无 tool_calls」的 assistant 消息的 `content: null` 改写成空字符串。
3
+ *
4
+ * 背景:`content: null` 在 assistant 消息上**只在携带 tool_calls 时合法**(那是 OpenAI 自己
5
+ * 吐 tool_call 时的规范形状)。纯文本轮次写成 null 会被严格 OpenAI 兼容网关整请求拒绝。
6
+ * speedbear(https://speedbear.redbearai.com/api/v1)实测:
7
+ * `{"error_code":"REQUEST_VALIDATION_FAILED",
8
+ * "detail":"messages[12].content 不能为 null(assistant 消息仅在携带 tool_calls/
9
+ * function_call 时允许省略 content;无文本请传空字符串 \"\")"}`
10
+ * 且形状探针逐条确认:`null`+tool_calls → 200;`content:[]` → 200;缺 content 字段 → 200;
11
+ * `content:""` → 200;连续 user 消息 → 200。**只有「null 且无 tool_calls」这一种被拒。**
12
+ *
13
+ * 在 agent 侧的表现极具迷惑性——「某个会话从某一轮起每一步都 400、换个会话同模型却正常」:
14
+ * 毒消息一旦写进 history 就会每轮重发,而它是**合法 JSON、合法 OpenAI 类型**,肉眼 review
15
+ * 抓不到;`/resume` 也救不回来(毒在落盘文件里)。更隐蔽的是它的来源:SSE 流中途结束
16
+ * (网关超时/上游掐断)时 SDK 不抛错,`content` 停在 null、tool_calls 为空,
17
+ * 框架把它当「agent 完成了这一轮」照写 history——于是一步正常的历史被永久污染。
18
+ *
19
+ * 与 `sanitizeToolSchemas`(剔 uniqueItems)同源同策:**放 transport 边界而非只修构造点**——
20
+ * 已经被旧版本写进 session.json 的中毒会话、外部注入的历史、resume 续接的会话都能自愈,
21
+ * 只修构造点救不了存量。构造点(`agent/stages/history-manager.ts`)同步收敛,保证新历史
22
+ * 不再生产这种形状,两层各司其职。
23
+ *
24
+ * 无需改写时返回**原数组引用**——history 参与前缀缓存,逐字节稳定才能命中;
25
+ * 每次新建对象会击穿缓存(同 `sanitizeToolSchemas` 的返回值约定)。
26
+ */
27
+ export function sanitizeAssistantContent(messages) {
28
+ let changed = false;
29
+ const next = messages.map((message) => {
30
+ if (message.role !== 'assistant')
31
+ return message;
32
+ const raw = message;
33
+ // 带 tool_calls 时 null 是规范形状(OpenAI 自己就这么吐),原样保留。
34
+ if (Array.isArray(raw.tool_calls) && raw.tool_calls.length > 0)
35
+ return message;
36
+ if (raw.content !== null)
37
+ return message;
38
+ changed = true;
39
+ return { ...message, content: '' };
40
+ });
41
+ return changed ? next : messages;
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "1.7.5",
3
+ "version": "1.7.6",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 27 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {