package engine // 统一错误处理系统. // // 提供结构化的错误类型,每个错误都包含: // - 错误码:用于程序化处理 // - 人类可读消息:用于展示给用户 // - 技术细节:用于调试和日志 // - 恢复建议:帮助用户解决问题 // - 是否可重试:指导重试逻辑 // // 设计原则: // - 消费层不需要解析错误字符串来判断错误类型 // - 每种错误都有默认的恢复建议 // - 原始错误保留在 Cause 中,方便调试 import ( "errors" "fmt" "regexp" "strconv" "strings" "git.flytoex.net/yuanwei/flyto-agent/core/pkg/flyto" ) // ErrorCode 是引擎错误码枚举. // 消费层可以根据错误码进行程序化处理. type ErrorCode string const ( ErrAPIAuth ErrorCode = "api_auth_error" // API key 无效或过期 ErrAPIRateLimit ErrorCode = "api_rate_limit" // API 速率限制 ErrAPIOverloaded ErrorCode = "api_overloaded" // API 服务过载 ErrAPIBadRequest ErrorCode = "api_bad_request" // API 请求格式错误 ErrContextTooLong ErrorCode = "context_too_long" // 超出模型上下文窗口(触发自适应校准) ErrToolNotFound ErrorCode = "tool_not_found" // 工具不存在 ErrToolExecution ErrorCode = "tool_execution_error" // 工具执行失败 ErrPermissionDenied ErrorCode = "permission_denied" // 权限被拒绝 ErrContextOverflow ErrorCode = "context_overflow" // 上下文溢出 ErrBudgetExceeded ErrorCode = "budget_exceeded" // 预算超限 ErrMaxTurns ErrorCode = "max_turns_reached" // 轮次超限 ErrSessionNotFound ErrorCode = "session_not_found" // 会话不存在 ErrSessionClosed ErrorCode = "session_closed" // 会话已关闭 ErrMCPConnection ErrorCode = "mcp_connection_error" // MCP 连接失败 ErrPluginLoad ErrorCode = "plugin_load_error" // 插件加载失败 ErrInternal ErrorCode = "internal_error" // 内部错误 ErrStreamTruncated ErrorCode = "stream_truncated" // 流式响应被代理截断(partial-stream) // ADR-0006 typed error codes (Bug U fail-loud audit, 2026-05-01). // These split out specific failure modes that previously collapsed // to ErrInternal, so consumers can distinguish a tool-name regex // rejection from a 5xx outage from a malformed SSE chunk without // pattern-matching error strings. ADR-0006 § 3 (red-line) prohibits // using these codes to drive auto-fallback behavior in the engine // itself (e.g. silently retrying with tools removed) -- classify // only, never act. Recovery decisions belong to the consumer. // // ADR-0006 typed 错误码 (Bug U fail-loud audit, 2026-05-01). // 把原本一律折叠成 ErrInternal 的具体失败形态拆出来, 让消费方区分 // tool 名 regex 拒绝 / 5xx 宕机 / SSE chunk 形态错而不必字符串模糊 // 匹配. ADR-0006 § 3 红线禁用这些 code 在引擎内驱动自动 fallback // (如静默 retry 关 tools) -- 只分类不行为, 恢复决策归消费方. ErrProviderHTTPStatus ErrorCode = "provider_http_status" // HTTP 非 2xx (顶层透传, 非 401/429/529 的状态码) ErrProviderNonSSE ErrorCode = "provider_non_sse" // HTTP 200 但 Content-Type 非 text/event-stream ErrProviderMidStreamErr ErrorCode = "provider_mid_stream_err" // SSE 流中部 chunk.error 字段 ErrWireUnmarshal ErrorCode = "wire_unmarshal_error" // wire 层 SSE chunk JSON 解析失败 ErrModelToolUnsupported ErrorCode = "model_tool_unsupported" // 模型/底层 provider 拒绝 tool schema (名 regex / 数量 / 形态) // ADR-0008 v2 引擎层反射器契约 (2026-05-02 范式修法 follow-up). // engine.New 装配期检测 cfg.ResponseReflector 与 cfg.Toolset 同名 // 双挂时返回. 反射器是引擎强制 hook (LLM 不感知, fail-closed retry); // 双挂让 LLM 把同一逻辑当普通工具调, 模糊 ADR-0008 v2 立的反射器 vs // 工具语义. ADR-0006 § 3 红线延续 — 装配期 fail-fast 不 silent // override 不 warning. // // ADR-0008 v2 engine-level reflector contract (2026-05-02 paradigm // follow-up). Returned by engine.New when cfg.ResponseReflector.Name() // collides with a Tool.Name() in cfg.Toolset. Reflectors are // engine-forced hooks (LLM-invisible, fail-closed retry); // double-wiring lets the LLM call the same logic as an ordinary // tool, blurring the reflector-vs-tool semantics that ADR-0008 v2 // established. ADR-0006 § 3 red-line continued -- construction-time // fail-fast, no silent override, no warning. ErrReflectorDoubleWired ErrorCode = "reflector_double_wired" // 反射器与同名 Tool 双挂 (ADR-0008 v2) ) // EngineError 是引擎统一错误类型. // // 所有引擎内部产生的错误都应包装为此类型, // 消费层可以通过类型断言获取结构化的错误信息. type EngineError struct { // Code 是错误码,用于程序化处理 Code ErrorCode // Message 是人类可读的错误描述 Message string // Detail 是技术细节,用于调试和日志 Detail string // Suggestion 是恢复建议,帮助用户解决问题 Suggestion string // Cause 是导致此错误的原始错误 Cause error // Retryable 指示此错误是否可以通过重试解决 Retryable bool } // Error implements the error interface. When Detail is populated // (typically the wire/provider layer's specific failure message such // as an HTTP body or upstream error code), append it after Message // using the canonical Go wrapping form `Message: Detail`. // // ELEVATED (Bug U, ADR-0006): the previous implementation returned // only Message, which collapsed all wire-layer specifics into the // engine's tier-1 Chinese sentence (e.g. "API 调用失败"). Operators // could not distinguish a tool-name regex violation from a context // overflow from a 5xx outage -- the error string was a black box. // fail-loud means the caller sees what actually broke; this makes it // the default for any errors.Error() / fmt.Errorf("%v") consumer // without forcing every call site to type-assert. // // 替代方案: <加 ErrorEvent.Detail 字段不改 Error()> -- 否决: 80% 调用方 // 是 fmt.Errorf / log.Fatalf 字符串场景, 不改 Error() 等于不解决人类 // 可读问题. 真要分离结构化字段 (C5 ErrorEvent.Detail) 跟 Error() 拼接 // 是叠加不冲突, 拼接版同时拿到字符串与结构化双轨. // // 替代方案: <用 errors.Wrap 把 Cause 链入 Error()> -- 否决: Cause 可能 // 含敏感细节 (HTTP raw body 中的 API key 片段或租户内 ID), Detail 是 // wire 层 sanitize 后的字段; Cause 走 Unwrap 路径让程序化消费方按需展开. // // Error 实现 error 接口. 当 Detail 非空 (通常是 wire/provider 层的 // 具体失败消息, 如 HTTP body 或上游错误码), 用 Go 经典 wrapping 形态 // `Message: Detail` 拼接. // // ELEVATED (Bug U, ADR-0006): 原实现只返 Message, 把所有 wire 层细节 // 折叠成引擎层一级中文短句 (如 "API 调用失败"), 调用方分不清是 tool // 名 regex 违反 / context 溢出 / 5xx 宕机 -- 错误串成黑盒. fail-loud // 要求调用方看见真坏点, 这条让 errors.Error() / fmt.Errorf("%v") // 消费方默认拿到具体信息, 不必每个 caller 自己 type-assert. // // 替代方案: <加 ErrorEvent.Detail 字段不改 Error()> -- 否决: 80% 调用 // 方走 fmt.Errorf / log.Fatalf 字符串场景, 不改 Error() 等于不解决 // 人类可读问题. 真要分离结构化字段 (C5 ErrorEvent.Detail) 与 Error() // 拼接是叠加不冲突, 字符串与结构化双轨. // // 替代方案: <用 errors.Wrap 把 Cause 链入 Error()> -- 否决: Cause 可能 // 含敏感细节 (HTTP raw body 中的 API key 片段 / 租户内 ID), Detail 是 // wire 层 sanitize 后字段; Cause 走 Unwrap 路径让程序化消费方按需展开. func (e *EngineError) Error() string { switch { case e.Message != "" && e.Detail != "": return e.Message + ": " + e.Detail case e.Message != "": return e.Message case e.Detail != "": return e.Detail default: return string(e.Code) } } // Unwrap 支持 errors.Is/As 链式解包. func (e *EngineError) Unwrap() error { return e.Cause } // defaultSuggestions 是每种错误码的默认恢复建议. var defaultSuggestions = map[ErrorCode]string{ ErrAPIAuth: "请检查 API Key(如 ANTHROPIC_API_KEY)或 Provider 配置是否正确", ErrAPIRateLimit: "已达到 API 速率限制,将自动重试", ErrAPIOverloaded: "API 服务暂时过载,将自动重试", ErrAPIBadRequest: "请求格式错误,请检查输入参数", ErrToolNotFound: "请检查工具名称是否正确,使用 /tools 查看可用工具列表", ErrToolExecution: "工具执行失败,请检查输入参数和工作目录", ErrPermissionDenied: "权限被拒绝。可以通过修改权限模式或添加规则来授权", ErrContextOverflow: "对话上下文已满,已自动压缩。如果问题持续,请使用 /compact 手动压缩", ErrBudgetExceeded: "已达到本次运行的预算上限。增加 --max-budget 参数或不设限制", ErrMaxTurns: "已达到最大对话轮次限制。增加 --max-turns 参数或不设限制", ErrSessionNotFound: "指定的会话不存在。使用 /sessions 查看可用会话", ErrSessionClosed: "会话已关闭,请创建新的会话", ErrMCPConnection: "MCP 服务器连接失败,请检查服务器配置和网络", ErrPluginLoad: "插件加载失败,请检查插件配置", ErrInternal: "发生内部错误,请重试。如果问题持续,请报告此问题", ErrStreamTruncated: "流式响应被中间代理截断,已重试仍然失败。检查网络代理配置或切换为直连", // ADR-0006 typed errors -- Suggestion 文案是 actionable hint 不是 // auto-action; 引擎层不据此自动改请求. 见 errors.go 顶部 ADR-0006 红线. // // Suggestion text is an actionable hint, not an auto-action; // the engine never auto-modifies requests based on these codes. ErrProviderHTTPStatus: "上游 provider 返回非 2xx HTTP, 检查 provider 状态页或切换 provider/账号", ErrProviderNonSSE: "上游返回非 SSE 响应体 (通常是配置错或代理拦截), 查看 Detail 字段的原始消息", ErrProviderMidStreamErr: "上游在 SSE 流中部返回错误事件, 查看 Detail 字段的 provider 错误码与消息", ErrWireUnmarshal: "wire 层无法解析 SSE chunk (provider 输出畸形或 SSE 协议变更), 请反馈给项目维护者", ErrModelToolUnsupported: "底层 provider 拒绝 tool schema (常见: 名违反 regex `^[a-zA-Z0-9_-]+$` / 工具数超上限 / schema 含不支持字段). 检查 tool.Name 与 InputSchema", } // defaultRetryable 是每种错误码的默认可重试性. var defaultRetryable = map[ErrorCode]bool{ ErrAPIAuth: false, ErrAPIRateLimit: true, ErrAPIOverloaded: true, ErrAPIBadRequest: false, ErrToolNotFound: false, ErrToolExecution: false, ErrPermissionDenied: false, ErrContextOverflow: false, ErrBudgetExceeded: false, ErrMaxTurns: false, ErrSessionNotFound: false, ErrSessionClosed: false, ErrMCPConnection: true, ErrPluginLoad: false, ErrInternal: true, ErrStreamTruncated: true, // 瞬态代理故障,消费层可以选择重试整个 Run() // ADR-0006 typed errors retryable defaults. // HTTP non-2xx 默认 false (具体码消费方自己分流, 4xx 几乎不可重试, // 5xx 由现有 ErrAPIOverloaded/RateLimit 命中); non-SSE 与 mid-stream // 通常是 provider 端固定故障; wire unmarshal 是协议错; tool // unsupported 是配置错改了才能重试. 重试策略在消费方, ADR-0006 红线. ErrProviderHTTPStatus: false, ErrProviderNonSSE: false, ErrProviderMidStreamErr: false, ErrWireUnmarshal: false, ErrModelToolUnsupported: false, } // NewEngineError 创建一个新的引擎错误. // // 使用默认的 Suggestion 和 Retryable 值. // 如果需要自定义,可以在创建后修改字段. func NewEngineError(code ErrorCode, message string, cause error) *EngineError { suggestion := defaultSuggestions[code] retryable := defaultRetryable[code] return &EngineError{ Code: code, Message: message, Suggestion: suggestion, Cause: cause, Retryable: retryable, } } // WrapError 将一个普通错误包装为 EngineError. // // 如果 cause 已经是 EngineError,则保留其信息并用新的 code 和 message 覆盖. // 否则创建新的 EngineError,原始错误保存在 Cause 中. func WrapError(cause error, code ErrorCode, message string) *EngineError { if cause == nil { return NewEngineError(code, message, nil) } // 如果已经是 EngineError,保留 Detail 等信息 var existing *EngineError if errors.As(cause, &existing) { return &EngineError{ Code: code, Message: message, Detail: existing.Detail, Suggestion: defaultSuggestions[code], Cause: cause, Retryable: defaultRetryable[code], } } // ADR-0006 (Bug U): wire 层构造的 *flyto.EngineError sibling type -- // 直接复用其 Detail (已是 sanitize 后的 wire 层失败消息), 不让 // "Message: Detail" 字符串叠加进 Detail 造成 "API 调用失败: // openai_compat: http 400: provider error: ..." 三层冗余. // // ADR-0006 (Bug U): wire 层构造的 *flyto.EngineError sibling type -- // 直接复用其 Detail (wire 层 sanitize 后字段) 避免字符串叠加冗余. var flytoErr *flyto.EngineError if errors.As(cause, &flytoErr) { // 优先 Detail; flyto 端 Detail 空则取 Message 作 fallback. // flyto.Detail empty fallback to flyto.Message. detail := flytoErr.Detail if detail == "" { detail = flytoErr.Message } return &EngineError{ Code: code, Message: message, Detail: detail, Suggestion: defaultSuggestions[code], Cause: cause, Retryable: defaultRetryable[code], } } return &EngineError{ Code: code, Message: message, Detail: cause.Error(), Suggestion: defaultSuggestions[code], Cause: cause, Retryable: defaultRetryable[code], } } // IsRetryable 检查错误是否可重试. // // 支持检查 EngineError 和普通 error. // 对于普通 error,通过错误字符串匹配判断. func IsRetryable(err error) bool { if err == nil { return false } // 检查 EngineError var engineErr *EngineError if errors.As(err, &engineErr) { return engineErr.Retryable } // 对于普通错误,通过字符串模式判断 errStr := err.Error() return strings.Contains(errStr, "HTTP 429") || strings.Contains(errStr, "HTTP 529") || strings.Contains(errStr, "connection reset") || strings.Contains(errStr, "connection refused") || strings.Contains(errStr, "timeout") } // ClassifyAPIError 将 API 错误字符串分类为对应的 ErrorCode. // // 根据 HTTP 状态码和错误消息内容判断错误类型. // 升华改进(ELEVATED): ErrContextTooLong 在 ErrAPIBadRequest 之前检测-- // 大多数 provider 以 HTTP 400 返回 context-too-long, // 若顺序颠倒,HTTP 400 会先命中,engine 会当成格式错误报错而非触发自适应校准. // // LEGACY (deprecated by ADR-0006, 2026-05-01): 字符串模式分类是早期路径, // 当 wire 层只用 fmt.Errorf 返普通 error 时唯一可用. 新路径见 // ClassifyAPIErrorTyped (errors.As 优先 typed code, 字符串 fallback). // 这条函数保留用于旧测试 + 字符串场景的内部 helper. func ClassifyAPIError(errStr string) ErrorCode { lower := strings.ToLower(errStr) switch { case strings.Contains(errStr, "HTTP 401") || strings.Contains(lower, "authentication"): return ErrAPIAuth case strings.Contains(errStr, "HTTP 429"): return ErrAPIRateLimit case strings.Contains(errStr, "HTTP 529") || strings.Contains(lower, "overloaded"): return ErrAPIOverloaded case isContextTooLongMessage(lower): return ErrContextTooLong case strings.Contains(errStr, "HTTP 400") || strings.Contains(lower, "invalid"): return ErrAPIBadRequest default: return ErrInternal } } // ClassifyAPIErrorTyped classifies an error preferring typed codes // from wrapped EngineError (engine or flyto package), falling back to // string heuristics on err.Error() when no typed code is present. // // ADR-0006 (Bug U fail-loud, 2026-05-01): wire 层把具体失败形态构造 // 成 flyto.EngineError 带 typed code; engine 层不必字符串模式认就能 // 拿到具体分类. ErrInternal 视为 sentinel 不真分类, 仍走字符串 fallback // 让 isContextTooLongMessage 等模式继续工作 (兼容旧 transport.classifier // 路径返回 *api.APIError + plain string error 的场景). // // ClassifyAPIErrorTyped 优先 errors.As 链解 typed EngineError code // (engine 或 flyto 包), 没有 typed code 时回退字符串 heuristic. ErrInternal // 当 sentinel 不当真分类继续走字符串 fallback (兼容 *api.APIError + // 普通 string error 路径). func ClassifyAPIErrorTyped(err error) ErrorCode { if err == nil { return ErrInternal } // engine.EngineError 优先 (引擎内部包装的) var engErr *EngineError if errors.As(err, &engErr) && engErr.Code != "" && engErr.Code != ErrInternal { return refineGenericProviderCode(engErr.Code, err) } // flyto.EngineError 其次 (wire 层构造的, sibling type) var flytoErr *flyto.EngineError if errors.As(err, &flytoErr) && flytoErr.Code != "" && flytoErr.Code != flyto.ErrInternal { // flyto.ErrorCode 与 engine.ErrorCode 都是 string alias, 字面量 // 一致, 直接转换. 镜像表保证. return refineGenericProviderCode(ErrorCode(flytoErr.Code), err) } return ClassifyAPIError(err.Error()) } // refineGenericProviderCode upgrades a GENERIC transport code to // context_too_long when the error text carries a context-overflow signature // (consumer bug report 2026-07-17): an OpenAI-compatible upstream returns // "maximum context length ..." inside an HTTP 400, which the wire layer // wraps as provider_http_status / provider_mid_stream_err. Honoring the // generic code verbatim hides the actionable classification, so the // forceCompact-and-retry path never triggers and the run fails outright. // Deliberately NOT applied to specific codes (rate limit / auth / // overloaded): OpenAI TPM rate-limit messages also say "too many tokens", // and a rate limit must back off, not compact. // // refineGenericProviderCode 把**泛型**传输码在错误文本带上下文超限特征时 // 升级为 context_too_long (消费者 2026-07-17 报障): OpenAI 兼容上游把 // "maximum context length ..." 装在 HTTP 400 里, wire 层包成 // provider_http_status / provider_mid_stream_err, 照单全收会盖掉可行动 // 分类, forceCompact 重试永不触发. 刻意不套用到具体码 (限流/鉴权/过载): // OpenAI TPM 限流文案同样含 "too many tokens", 限流该退避而不是压缩. func refineGenericProviderCode(code ErrorCode, err error) ErrorCode { switch code { case ErrProviderHTTPStatus, ErrProviderMidStreamErr: if isContextTooLongMessage(strings.ToLower(err.Error())) { return ErrContextTooLong } } return code } // isContextTooLongMessage 检测错误消息是否表示上下文超长. // // 精妙之处(CLEVER): 多模式宽泛匹配--不同 provider 和网关代理的错误格式各异: // // Anthropic: "prompt is too long: 210000 tokens > 200000 maximum" // OpenAI/兼容: "This model's maximum context length is 128000 tokens" // MiniMax: "prompt too long" 或 "too many tokens" // HTTP 网关: 413 Payload Too Large(上游截断) func isContextTooLongMessage(lower string) bool { return strings.Contains(lower, "prompt is too long") || strings.Contains(lower, "prompt too long") || strings.Contains(lower, "too many tokens") || strings.Contains(lower, "context length exceeded") || strings.Contains(lower, "maximum context length") || strings.Contains(lower, "context_length_exceeded") || strings.Contains(lower, "http 413") } // 上下文超长错误解析正则--从错误消息中提取 actual / max token 数. var ( // Anthropic 格式: "prompt is too long: 210000 tokens > 200000 maximum" ctxErrAnthropicRe = regexp.MustCompile(`(?i)prompt is too long[^0-9]*(\d+)\s*tokens?\s*>\s*(\d+)`) // OpenAI 格式: "maximum context length is 128000 tokens. However, ... resulted in 130000 tokens" ctxErrOpenAIRe = regexp.MustCompile(`(?i)maximum context length is\s+(\d+)\s*tokens.*resulted in\s+(\d+)\s*tokens`) ) // ParseContextError 从 context-too-long 错误消息中提取 (actual, max) token 数. // // actual = 本次请求实际 token 数(> max) // max = provider 申报的上下文窗口上限 // 无法解析时返回 (0, 0)--调用方应退化到使用静态默认值. func ParseContextError(errStr string) (actual, max int) { if m := ctxErrAnthropicRe.FindStringSubmatch(errStr); len(m) == 3 { actual, _ = strconv.Atoi(m[1]) max, _ = strconv.Atoi(m[2]) return actual, max } if m := ctxErrOpenAIRe.FindStringSubmatch(errStr); len(m) == 3 { max, _ = strconv.Atoi(m[1]) actual, _ = strconv.Atoi(m[2]) return actual, max } return 0, 0 } // FormatErrorForDisplay 将 EngineError 格式化为适合展示给用户的文本. // // 输出格式: // // 错误: <人类可读消息> // 建议: <恢复建议> // // 如果有 Detail,在 Verbose 模式下追加技术细节. func FormatErrorForDisplay(err error, verbose bool) string { var engineErr *EngineError if !errors.As(err, &engineErr) { return fmt.Sprintf("错误: %s", err.Error()) } var sb strings.Builder sb.WriteString(fmt.Sprintf("错误: %s", engineErr.Message)) if engineErr.Suggestion != "" { sb.WriteString(fmt.Sprintf("\n建议: %s", engineErr.Suggestion)) } if verbose && engineErr.Detail != "" { sb.WriteString(fmt.Sprintf("\n详情: %s", engineErr.Detail)) } if engineErr.Retryable { sb.WriteString("\n(此错误可自动重试)") } return sb.String() }