fix: v0.5.3 模型能力完整性修复 — MCP 工具动态同步 + maxTokens 模型上限钳制
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m21s
CI / 产物编译验证 (push) Successful in 9m59s

背景:v0.5.2 工具调用失效修复后,对模型全能力矩阵(工具/思考/多模态/流式/
压缩/摘要/记忆/故障转移/余额)做契约级核查,发现并修复两处同类时序/边界缺陷。

P1 MCP 工具运行中增删不同步引擎:
- 症状:运行中添加/启用 MCP server 后,已打开会话拿不到新工具;断开
  server 后已有引擎仍持有失效工具定义(模型发起调用才报 Unknown tool)
- 根因:setToolsAll 仅在启动期与工具开关时调用,MCP 连接/断开路径缺失
  (与 v0.5.2 修复的懒创建陷阱同类——"变更点 × 同步路径"未全覆盖)
- 修复:MCPManager 新增 setOnToolsChanged 回调,connectServer 注册完成 /
  disconnectServer 注销完成后触发;main.ts 注入回调同步全部已存在引擎。
  懒创建引擎由 createEngine 实时拉取(v0.5.2),三条路径(启动/懒创建/
  运行中变更)全覆盖。README"无需重启动态发现"的宣称至此真实成立。

P1 maxTokens 超模型上限直接 400:
- 症状:引擎默认 maxTokens=63488,OpenAI gpt-4o(16384)/gpt-4.1(32768)、
  Anthropic opus/haiku(32000)、MiMo standard(32768) 每次请求 400,等于不可用
- 修复:五个 adapter(DeepSeek/Agnes/MiMo/OpenAI/Anthropic)统一按
  MODEL_INFO.maxOutputTokens 钳制;MiMo 保留 thinking 兜底 32768 语义;
  Anthropic thinking budget 在钳制后的 max_tokens 内二分,自动跟随

测试(224 → 236 用例):
- 新增 maxTokens 钳制契约测试 ×9(max-tokens-clamp.test.ts):mock fetch
  记录真实请求体断言——超限钳制(MiMo standard/OpenAI gpt-4o/Anthropic
  opus)/ 未超限原样传递(DeepSeek/Agnes/MiMo pro/o3-mini/sonnet)/
  推理模型字段名 / 未配置默认值安全性
- 新增 MCP 动态同步端到端测试 ×3(mcp-tools-sync.test.ts):mock MCP SDK
  + 真实 MCPManager/ToolRegistry/AgentEngineManager——先建引擎再连
  server,断言同一会话请求的 tools 动态更新 / 断开后移除失效定义 /
  回调异常不阻断 MCP 主流程

能力矩阵核查结论(无回归确认): 工具调用主链路 ✓(v0.5.2)/ SubAgent
工具 ✓(delegate 实时 resolveTools)/ thinking 热更新 ✓(baseConfig 合并
路径无懒创建陷阱)/ 多模态当轮 ✓ / 压缩与孤立 tool 消息配对 ✓ / 摘要分层 ✓ /
记忆注入 ✓ / 故障转移 ✓ / 余额 ✓(v0.5.2)。已知设计限制:历史轮图片不
回传(attachments 仅存缩略图,图片只在发送当轮注入上下文)。

验证: lint 0 / typecheck 双工程 0 / test:electron 236 全过 / build 成功
This commit is contained in:
2026-08-21 22:36:52 +08:00
parent e4d693c1c7
commit 3a30e8f5b4
12 changed files with 669 additions and 101 deletions
+66 -25
View File
@@ -72,17 +72,21 @@ export class AnthropicAdapter extends BaseAdapter {
async send(request: MetonaRequest): Promise<MetonaResponse> {
const body = await this.toNativeRequest(request, false);
const response = await this.fetchWithTimeout(`${this.config.baseURL}/v1/messages`, {
method: 'POST',
headers: this.buildHeaders(),
body: JSON.stringify(body),
}, this.config.timeoutMs ?? 120_000);
const response = await this.fetchWithTimeout(
`${this.config.baseURL}/v1/messages`,
{
method: 'POST',
headers: this.buildHeaders(),
body: JSON.stringify(body),
},
this.config.timeoutMs ?? 120_000,
);
if (!response.ok) {
await this.throwHttpError(response, 'Anthropic API error');
}
const data = await response.json() as Record<string, unknown>;
const data = (await response.json()) as Record<string, unknown>;
return this.toMetonaResponse(data, request.meta.requestId);
}
@@ -90,11 +94,15 @@ export class AnthropicAdapter extends BaseAdapter {
async *sendStream(request: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
const body = await this.toNativeRequest(request, true);
const response = await this.fetchWithTimeout(`${this.config.baseURL}/v1/messages`, {
method: 'POST',
headers: this.buildHeaders(),
body: JSON.stringify(body),
}, this.config.timeoutMs ?? 300_000);
const response = await this.fetchWithTimeout(
`${this.config.baseURL}/v1/messages`,
{
method: 'POST',
headers: this.buildHeaders(),
body: JSON.stringify(body),
},
this.config.timeoutMs ?? 300_000,
);
if (!response.ok || !response.body) {
await this.throwHttpError(response, 'Anthropic stream error');
@@ -140,7 +148,11 @@ export class AnthropicAdapter extends BaseAdapter {
if (delta?.type === 'text_delta' && typeof delta.text === 'string') {
events.push({ type: MetonaStreamEventType.TEXT_DELTA, ...base(), delta: delta.text });
} else if (delta?.type === 'thinking_delta' && typeof delta.thinking === 'string') {
events.push({ type: MetonaStreamEventType.REASONING_DELTA, ...base(), delta: delta.thinking });
events.push({
type: MetonaStreamEventType.REASONING_DELTA,
...base(),
delta: delta.thinking,
});
} else if (delta?.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
const block = toolBlocks.get(index);
if (block) {
@@ -189,7 +201,8 @@ export class AnthropicAdapter extends BaseAdapter {
usage: {
inputTokens: (this.lastInputTokens as number) ?? 0,
outputTokens: (usage.output_tokens as number) ?? 0,
totalTokens: ((this.lastInputTokens as number) ?? 0) + ((usage.output_tokens as number) ?? 0),
totalTokens:
((this.lastInputTokens as number) ?? 0) + ((usage.output_tokens as number) ?? 0),
},
});
}
@@ -250,7 +263,10 @@ export class AnthropicAdapter extends BaseAdapter {
yield ev;
}
} catch (parseErr) {
log.warn(`[Anthropic] Failed to parse SSE line: ${(parseErr as Error).message}`, trimmed.slice(0, 200));
log.warn(
`[Anthropic] Failed to parse SSE line: ${(parseErr as Error).message}`,
trimmed.slice(0, 200),
);
}
}
}
@@ -291,7 +307,10 @@ export class AnthropicAdapter extends BaseAdapter {
* 4. 连续同角色消息合并(API 要求严格交替)
* 5. 首条消息必须为 user(历史以 assistant 开头时补占位)
*/
private async toNativeRequest(request: MetonaRequest, stream: boolean): Promise<Record<string, unknown>> {
private async toNativeRequest(
request: MetonaRequest,
stream: boolean,
): Promise<Record<string, unknown>> {
// System Prompt 拼接(Anthropic 使用顶层 system 字段)
const system = [
request.systemPrompt.roleDefinition,
@@ -303,7 +322,10 @@ export class AnthropicAdapter extends BaseAdapter {
.join('\n\n');
// 转换消息(非 system
const converted: Array<{ role: 'user' | 'assistant'; content: Array<Record<string, unknown>> }> = [];
const converted: Array<{
role: 'user' | 'assistant';
content: Array<Record<string, unknown>>;
}> = [];
for (const m of request.messages) {
if (m.role === 'system') continue;
@@ -316,7 +338,9 @@ export class AnthropicAdapter extends BaseAdapter {
: JSON.stringify(m.toolResult.result);
converted.push({
role: 'user',
content: [{ type: 'tool_result', tool_use_id: m.toolResult.toolCallId, content: contentStr }],
content: [
{ type: 'tool_result', tool_use_id: m.toolResult.toolCallId, content: contentStr },
],
});
continue;
}
@@ -345,7 +369,8 @@ export class AnthropicAdapter extends BaseAdapter {
}
// 合并连续同角色消息(Anthropic 要求 user/assistant 交替)
const merged: Array<{ role: 'user' | 'assistant'; content: Array<Record<string, unknown>> }> = [];
const merged: Array<{ role: 'user' | 'assistant'; content: Array<Record<string, unknown>> }> =
[];
for (const msg of converted) {
const last = merged[merged.length - 1];
if (last && last.role === msg.role) {
@@ -357,12 +382,20 @@ export class AnthropicAdapter extends BaseAdapter {
// 首条消息必须为 user
if (merged.length === 0 || merged[0].role !== 'user') {
merged.unshift({ role: 'user', content: [{ type: 'text', text: '[Conversation history follows]' }] });
merged.unshift({
role: 'user',
content: [{ type: 'text', text: '[Conversation history follows]' }],
});
}
// v0.5.3: max_tokens 按模型上限钳制(sonnet 64000 / opus 32000 / haiku 32000)—
// 引擎默认 63488 超过 opus/haiku 上限时 API 直接 400thinking budget 已在此值内二分
const anthropicMaxOutput =
AnthropicAdapter.MODEL_INFO[this.config.defaultModel]?.maxOutputTokens ?? 64_000;
const body: Record<string, unknown> = {
model: this.config.defaultModel,
max_tokens: request.params.maxTokens ?? 8192,
max_tokens: Math.min(request.params.maxTokens ?? 8192, anthropicMaxOutput),
system,
messages: merged,
stream,
@@ -379,7 +412,12 @@ export class AnthropicAdapter extends BaseAdapter {
// Thinking 模式:budget_tokens(必须小于 max_tokens,此处钳制到一半)
if (request.params.thinkingEnabled) {
const budgetMap: Record<string, number> = { low: 1024, medium: 4096, high: 16384, max: 32768 };
const budgetMap: Record<string, number> = {
low: 1024,
medium: 4096,
high: 16384,
max: 32768,
};
const budget = Math.min(
budgetMap[request.params.thinkingEffort ?? 'high'] ?? 16384,
Math.floor((body.max_tokens as number) / 2),
@@ -435,7 +473,8 @@ export class AnthropicAdapter extends BaseAdapter {
for (const block of contentBlocks) {
if (block.type === 'text') text += (block.text as string) ?? '';
else if (block.type === 'thinking') reasoningContent = (block.thinking as string) ?? undefined;
else if (block.type === 'thinking')
reasoningContent = (block.thinking as string) ?? undefined;
else if (block.type === 'tool_use') {
let args: Record<string, unknown> = {};
const rawInput = block.input;
@@ -453,9 +492,11 @@ export class AnthropicAdapter extends BaseAdapter {
const usage = (data.usage as Record<string, number>) ?? {};
const stopReason = (data.stop_reason as string) ?? 'end_turn';
const finishReason: MetonaFinishReason =
stopReason === 'tool_use' ? MetonaFinishReason.TOOL_CALLS
: stopReason === 'max_tokens' ? MetonaFinishReason.LENGTH
: MetonaFinishReason.STOP;
stopReason === 'tool_use'
? MetonaFinishReason.TOOL_CALLS
: stopReason === 'max_tokens'
? MetonaFinishReason.LENGTH
: MetonaFinishReason.STOP;
return {
meta: {