feat: v0.7.0 四阶段全量迭代 — 修复面收口 · 安全纵深 · 架构还债 · 能力演进
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m45s
CI / 全量测试 (Electron ABI) (push) Failing after 5m22s
CI / 产物编译验证 (push) Successful in 10m3s

P1 修复面收口: v0.6.3 截断自愈推全量(Anthropic/Ollama/非流式/引擎兜底); SSE 上游错误帧检测进重试通道;
clearMessages 摘要游标根治; truncateResult 内联图片白名单统一; 前端四 bug(确认弹窗锁死/MemoryViewer/
Virtuoso Footer/abort 尾部过滤) + reasoning 缓冲跨迭代污染; 托盘通知过滤与新建会话死链接线

P2 安全纵深: MCP 审批闭环(ConfirmationHook×PolicyEngine 联动+重名拒注册); SSRF 收敛 ssrf-guard 共享模块
(web_fetch 双通道校验+重定向终态复检); Electron 加固(preload CJS 化→sandbox:true/CSP/权限白名单/will-navigate);
run_command cmd.exe 白名单通道元字符守门; diff_viewer 10MB 预检; Anthropic thinking 预算下限; Agnes 思考显式关闭

P3 架构还债: OpenAICompatibleAdapter 中间基类收敛四家样板; 错误分类单轨化(删 mapError/getFetchSignal,
超时显式 ETIMEDOUT); PRAGMA user_version 迁移版本化; 死代码清理专项(cn.ts/SHORTCUTS/ContextMenu 分支/
getWindowState/modifiedArgs/sandbox 空壳); i18next 引入; a11y 第一轮; SearXNG 页批量草稿模型统一

P4 能力演进: Ollama pull 可取消/capabilities 探测/num_ctx 实测缓存; UpdateService feed 比对式自动更新
(app:updateCheck IPC + StatusBar 入口); MiMo providerOptions(web_search 服务端工具/strict JSON);
web_fetch extract_mode=markdown(turndown); network.proxyUrl 全局代理(Chromium sessions+undici dispatcher)

测试: 264 → 507 用例(Electron ABI 全绿零跳过), 覆盖引擎压缩管线/重试竞速/MEMORY.md 闸门/file_editor 五操作/
filesystem 七工具实体夹具/git 真实仓库/SSE 错误帧/全线截断自愈/Provider 请求形态矩阵/SSRF 表测/钩子分级矩阵/
OutputValidator 全量/SLO 指标/MCP 安全纯函数/task_manager 链路/渲染层纯域/i18n 桥契约
This commit is contained in:
2026-08-27 17:06:58 +08:00
parent b6e2a8bd25
commit 3940716dc2
78 changed files with 6369 additions and 1341 deletions
+137 -30
View File
@@ -16,7 +16,8 @@
* @see https://docs.anthropic.com/en/api/messages
*/
import { BaseAdapter } from './base-adapter';
import { BaseAdapter, ContentFilterError } from './base-adapter';
import { truncatedArgumentsPayload } from './shared/sse-stream';
import log from 'electron-log';
import { nanoid } from 'nanoid';
import type { MetonaRequest, MetonaResponse, MetonaStreamEvent } from '../types';
@@ -119,6 +120,12 @@ export class AnthropicAdapter extends BaseAdapter {
// 工具调用缓冲:content block index → { id, name, argsBuffer }
const toolBlocks = new Map<number, { id: string; name: string; argsBuffer: string }>();
// v0.6.4 竞态修复: message_start 捕获的 input_tokens 改为本次调用的局部闭包变量。
// 原实现放在实例字段(this.lastInputTokens)—— fallback adapter 是跨引擎共享的
// 单例(agent-engine-manager 把同一实例注入所有引擎),故障转移后多个并发会话
// 共用该 Anthropic 实例时 input_tokens 会互相串号。局部化后天然隔离。
let messageStartInputTokens = 0;
const base = () => ({
requestId: request.meta.requestId,
sessionId: request.meta.sessionId,
@@ -127,6 +134,37 @@ export class AnthropicAdapter extends BaseAdapter {
timestamp: Date.now(),
});
/**
* v0.6.4 错误事件单轨化: Anthropic `error` SSE 事件不再以普通 ERROR 流事件转发
* (引擎对 ERROR 事件的旧处理是 throw 普通 Error,最终落入 UNKNOWN 且完全绕过
* chatStreamWithRetry 的重试/故障转移)。改为抛出携带归一化 status 的异常,
* 与 HTTP 层 throwHttpError 同轨:overloaded/rate_limit 走重试、authentication/
* invalid_request 不重试并可触发 fallback。
*/
const anthropicErrorCodeToStatus = (code: string): number => {
switch (code) {
case 'overloaded_error':
return 529;
case 'rate_limit_error':
return 429;
case 'api_error':
return 500;
case 'timeout_error':
return 504;
case 'authentication_error':
return 401;
case 'permission_error':
return 403;
case 'not_found_error':
return 404;
case 'request_too_large':
case 'invalid_request_error':
return 400;
default:
return 500;
}
};
const processEvent = (name: string, data: Record<string, unknown>): MetonaStreamEvent[] => {
const events: MetonaStreamEvent[] = [];
switch (name) {
@@ -173,8 +211,17 @@ export class AnthropicAdapter extends BaseAdapter {
let args: Record<string, unknown> = {};
try {
args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {};
} catch {
args = {};
} catch (err) {
// v0.6.4 缺口 A 修复: content_block_stop 时 argsBuffer 解析失败(流截断致
// JSON 半截)—— 原实现静默降级 args={},与 v0.6.3 已修复的 OpenAI 共享层
// 行为完全相同:工具以"缺少必要参数"泛化失败,模型无从得知发生了截断,
// 长文件写入场景直接导致"空回复 → 会话无声终止"。现统一转为
// _truncatedArguments 错误参数触发模型自愈(与共享层同源、同文案契约)。
const sample = block.argsBuffer.slice(-120);
log.warn(
`[Anthropic] Tool call args truncated at content_block_stop (unparseable JSON, ${(err as Error).message}). Tail: ...${sample}`,
);
args = truncatedArgumentsPayload((err as Error).message, sample);
}
events.push({
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
@@ -199,10 +246,14 @@ export class AnthropicAdapter extends BaseAdapter {
type: MetonaStreamEventType.USAGE,
...base(),
usage: {
inputTokens: (this.lastInputTokens as number) ?? 0,
inputTokens: messageStartInputTokens,
outputTokens: (usage.output_tokens as number) ?? 0,
totalTokens:
((this.lastInputTokens as number) ?? 0) + ((usage.output_tokens as number) ?? 0),
messageStartInputTokens + ((usage.output_tokens as number) ?? 0),
// v0.6.4: 补采 Anthropic 自己的缓存字段(其他 provider 均已采集,
// cache_read/creation_input_tokens 与 output_tokens 同在 usage 内)
cacheHitTokens: (usage.cache_read_input_tokens as number) ?? undefined,
cacheMissTokens: (usage.cache_creation_input_tokens as number) ?? undefined,
},
});
}
@@ -215,16 +266,18 @@ export class AnthropicAdapter extends BaseAdapter {
}
case 'error': {
const err = data.error as Record<string, unknown> | undefined;
events.push({
type: MetonaStreamEventType.ERROR,
...base(),
error: {
code: 'unknown' as never,
message: (err?.message as string) ?? 'Anthropic stream error',
retryable: false,
},
});
break;
const code = (err?.type as string) ?? 'api_error';
const message = (err?.message as string) ?? 'Anthropic stream error';
const status = anthropicErrorCodeToStatus(code);
log.warn(
`[Anthropic] Upstream error event: ${code} (normalized status=${status}) — throwing for retry/failover handling`,
);
if (code === 'content_filter_error') {
throw new ContentFilterError(message, 'Anthropic SSE error event');
}
const throwable = new Error(`anthropic_stream_error (${code}): ${message}`);
(throwable as Error & { status: number }).status = status;
throw throwable;
}
}
return events;
@@ -256,13 +309,15 @@ export class AnthropicAdapter extends BaseAdapter {
if (eventName === 'message_start') {
const msg = data.message as Record<string, unknown> | undefined;
const usage = msg?.usage as Record<string, unknown> | undefined;
this.lastInputTokens = (usage?.input_tokens as number) ?? 0;
messageStartInputTokens = (usage?.input_tokens as number) ?? 0;
continue;
}
for (const ev of processEvent(eventName, data)) {
yield ev;
}
} catch (parseErr) {
// ContentFilterError / 带 status 的上游错误由 processEvent 抛出,需原样透传
if (parseErr instanceof Error && parseErr.name !== 'SyntaxError') throw parseErr;
log.warn(
`[Anthropic] Failed to parse SSE line: ${(parseErr as Error).message}`,
trimmed.slice(0, 200),
@@ -271,15 +326,48 @@ export class AnthropicAdapter extends BaseAdapter {
}
}
// 流中断(连接断开等)补发 DONE,防止 Agent Loop 挂起(与 Ollama 行为一致)
// v0.6.4 缺口 B 修复: 流中断时不再让缓冲中的 tool_use 整体蒸发。
// 原实现在 content_block_start 与 content_block_stop 之间断连时,toolBlocks 里
// 未完成的 block 既不产生 TOOL_CALL_COMPLETE、也不 flush —— 引擎看到"零工具调用
// + 零文本"→ 误判 COMPLETED 空回复 → 会话无声停止(正是 v0.6.3 宣称根治、但在
// Anthropic 流上仍然存活的场景)。现于补发 DONE 之前,将所有未完成块按截断契约
// 转为 _truncatedArguments 自愈 tool call(解析成功的则正常产出)。
if (!streamEndedNormally) {
const unfinished = [...toolBlocks.entries()];
if (unfinished.length > 0) {
log.warn(
`[Anthropic] Stream ended without message_stop with ${unfinished.length} unfinished tool block(s) — flushing as truncated/self-healing tool calls`,
);
for (const [, block] of unfinished) {
let args: Record<string, unknown> = {};
try {
args = block.argsBuffer ? JSON.parse(block.argsBuffer) : {};
} catch (err) {
args = truncatedArgumentsPayload(
(err as Error).message,
block.argsBuffer.slice(-120),
);
}
yield {
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
...base(),
toolCall: {
id: block.id,
name: block.name,
args,
iteration: request.meta.iteration,
timestamp: Date.now(),
},
};
}
} else {
log.warn('[Anthropic] Stream ended without message_stop (connection likely dropped)');
}
toolBlocks.clear();
yield { type: MetonaStreamEventType.DONE, ...base() };
}
}
/** message_start 捕获的 input_tokens(供 message_delta 汇总 usage */
private lastInputTokens = 0;
// ===== 模型与上下文窗口 =====
override async listModels(): Promise<MetonaModelInfo[]> {
@@ -311,6 +399,8 @@ export class AnthropicAdapter extends BaseAdapter {
request: MetonaRequest,
stream: boolean,
): Promise<Record<string, unknown>> {
const thinkingRequested = Boolean(request.params.thinkingEnabled);
// System Prompt 拼接(Anthropic 使用顶层 system 字段)
const system = [
request.systemPrompt.roleDefinition,
@@ -405,9 +495,17 @@ export class AnthropicAdapter extends BaseAdapter {
const anthropicMaxOutput =
AnthropicAdapter.MODEL_INFO[this.config.defaultModel]?.maxOutputTokens ?? 64_000;
// v0.6.4 边界加固: thinking 开启时保证 max_tokens ≥ 2048 —— 协议要求
// budget_tokens >= 1024 且 < max_tokens。原实现当用户配置极小 maxTokens
// (如 1500)时 Math.floor(1500/2)=750 < 1024 直接 API 400。
const requestedMaxTokens = request.params.maxTokens ?? 8192;
const maxTokensForRequest = thinkingRequested
? Math.max(2048, Math.min(requestedMaxTokens, anthropicMaxOutput))
: Math.min(requestedMaxTokens, anthropicMaxOutput);
const body: Record<string, unknown> = {
model: this.config.defaultModel,
max_tokens: Math.min(request.params.maxTokens ?? 8192, anthropicMaxOutput),
max_tokens: maxTokensForRequest,
system,
messages: merged,
stream,
@@ -423,17 +521,15 @@ export class AnthropicAdapter extends BaseAdapter {
}
// Thinking 模式:budget_tokens(必须小于 max_tokens,此处钳制到一半)
if (request.params.thinkingEnabled) {
if (thinkingRequested) {
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),
);
const effortBudget = budgetMap[request.params.thinkingEffort ?? 'high'] ?? 16384;
const budget = Math.min(effortBudget, Math.floor(maxTokensForRequest / 2));
body.thinking = { type: 'enabled', budget_tokens: budget };
} else {
body.temperature = request.params.temperature;
@@ -485,9 +581,13 @@ 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 === 'tool_use') {
else if (block.type === 'thinking') {
// v0.6.4 修复: 多个 thinking 块应为累加(原实现后者覆盖前者,长推理链丢内容)
const thinking = (block.thinking as string) ?? '';
if (thinking) {
reasoningContent = reasoningContent ? `${reasoningContent}\n\n${thinking}` : thinking;
}
} else if (block.type === 'tool_use') {
let args: Record<string, unknown> = {};
const rawInput = block.input;
if (rawInput && typeof rawInput === 'object') args = rawInput as Record<string, unknown>;
@@ -503,12 +603,16 @@ export class AnthropicAdapter extends BaseAdapter {
const usage = (data.usage as Record<string, number>) ?? {};
const stopReason = (data.stop_reason as string) ?? 'end_turn';
// v0.6.4: refusal / content_filter 不再折叠为 STOP —— 语义丢失会让上层把
// "被拒绝的回答"当正常回复展示;统一映射为 CONTENT_FILTERED 走友好提示链路
const finishReason: MetonaFinishReason =
stopReason === 'tool_use'
? MetonaFinishReason.TOOL_CALLS
: stopReason === 'max_tokens'
? MetonaFinishReason.LENGTH
: MetonaFinishReason.STOP;
: stopReason === 'refusal' || stopReason === 'content_filter'
? MetonaFinishReason.CONTENT_FILTER
: MetonaFinishReason.STOP;
return {
meta: {
@@ -525,6 +629,9 @@ export class AnthropicAdapter extends BaseAdapter {
inputTokens: usage.input_tokens ?? 0,
outputTokens: usage.output_tokens ?? 0,
totalTokens: (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0),
// v0.6.4: 补采缓存字段(与非流式调用方对齐其他 provider 的口径)
cacheHitTokens: usage.cache_read_input_tokens,
cacheMissTokens: usage.cache_creation_input_tokens,
},
finishReason,
};