refactor: 大文件模块化拆分、自进化Skill管线、前端设计系统统一

后端:
  - runtime: 拆分 AgentConfig/events/duplicate_detector 为独立模块
  - error_recovery: 1499行单体拆为 classification(21种FailoverReason)/overflow/mod
  - executor: 提取 helpers.rs (PreparedCall/ToolExecutionResult/execute_single_tool)
  - skills: 新增 SkillCreator + SelfImprovePipeline(模式检测→自动生成SKILL.md→质量审查)
  - clients/llm: 拆分为 chat/embedding/types 三个子模块
  - services/download: 1548行拆为 mod/headers(反爬+SSRF)/strategies(多级回退)
  - services/batch/asset: 1264行拆为 mod/helpers/process

  前端:
  - 设计系统统一: sky/indigo → blueprint 色系, rounded-xl→lg, shadow-lg→sm
  - 删除 Vite 模板残留 App.css
  - GlobalDialog/PaperDetailModal/UncachedPaperModal 提取公共 BaseModal 组件
This commit is contained in:
fmq
2026-06-27 09:56:36 +08:00
parent 5db4cc5998
commit 5f2d2d83f6
62 changed files with 6584 additions and 6308 deletions
@@ -0,0 +1,624 @@
// src/agent/runtime/error_recovery/classification.rs
//
// 故障转移原因分类引擎:FailoverReason / ErrorKind / classify_* 函数族。
use tracing::{error, info};
// ── FailoverReason21 种错误分类 ──
/// 故障转移原因 — 参考 Hermes FailoverReason 枚举。
/// 每个 variant 携带是否可重试、是否应触发凭据切换等信息。
#[derive(Debug, Clone, PartialEq)]
pub enum FailoverReason {
// ── Auth 类 ──
/// 认证失败(401),可切换凭据重试
Auth,
/// 永久认证失败,不应重试
AuthPermanent,
// ── Billing 类 ──
/// 计费问题(402/403 billing),可切换账号
Billing,
/// 限流(429),应退避重试
RateLimited,
// ── Server 类 ──
/// 服务过载(503/529),应退避重试
Overloaded,
/// 服务器错误(500/502),可重试
ServerError,
// ── Transport 类 ──
/// 网络超时,可重试
Timeout,
// ── Context/Payload 类 ──
/// 上下文溢出(prompt too long / 413
ContextOverflow,
/// 载荷过大
PayloadTooLarge,
/// 图片过大
ImageTooLarge,
// ── Model/Provider Policy 类 ──
/// 模型不存在或无权访问(404)
ModelNotFound,
/// Provider 策略阻止
ProviderPolicyBlocked,
/// 内容策略阻止(安全过滤)
ContentPolicyBlocked,
// ── Format 类 ──
/// 响应格式错误
FormatError,
/// 无效加密内容
InvalidEncryptedContent,
/// 多模态工具内容不支持
MultimodalToolContentUnsupported,
// ── Provider-specific 类 ──
/// Thinking 签名错误(Qwen/DashScope specific
ThinkingSignature,
/// 长上下文 tier 门控
LongContextTier,
/// OAuth 长上下文 beta 禁止
OauthLongContextBetaForbidden,
// ── 网络/传输 ──
/// SSL/TLS 瞬态错误
SslTransient,
// ── Catch-all ──
/// 未知错误
Unknown(String),
}
impl FailoverReason {
/// 是否可重试(auth/rate-limit/server/transport 类)
pub fn is_retryable(&self) -> bool {
matches!(
self,
FailoverReason::Auth
| FailoverReason::RateLimited
| FailoverReason::Overloaded
| FailoverReason::ServerError
| FailoverReason::Timeout
| FailoverReason::SslTransient
)
}
/// 是否应触发上下文压缩
pub fn should_compress(&self) -> bool {
matches!(
self,
FailoverReason::ContextOverflow
| FailoverReason::PayloadTooLarge
| FailoverReason::LongContextTier
)
}
/// 是否应切换凭据/模型
pub fn should_failover(&self) -> bool {
matches!(
self,
FailoverReason::Auth
| FailoverReason::Billing
| FailoverReason::ModelNotFound
| FailoverReason::ProviderPolicyBlocked
)
}
/// 是否为永久性错误(不应重试)
pub fn is_permanent(&self) -> bool {
matches!(
self,
FailoverReason::AuthPermanent
| FailoverReason::ContentPolicyBlocked
| FailoverReason::InvalidEncryptedContent
)
}
/// 错误归因:用户侧还是服务侧
pub fn is_user_error(&self) -> bool {
matches!(
self,
FailoverReason::ContextOverflow
| FailoverReason::PayloadTooLarge
| FailoverReason::ImageTooLarge
| FailoverReason::FormatError
)
}
}
impl std::fmt::Display for FailoverReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let label = match self {
FailoverReason::Auth => "auth",
FailoverReason::AuthPermanent => "auth_permanent",
FailoverReason::Billing => "billing",
FailoverReason::RateLimited => "rate_limited",
FailoverReason::Overloaded => "overloaded",
FailoverReason::ServerError => "server_error",
FailoverReason::Timeout => "timeout",
FailoverReason::ContextOverflow => "context_overflow",
FailoverReason::PayloadTooLarge => "payload_too_large",
FailoverReason::ImageTooLarge => "image_too_large",
FailoverReason::ModelNotFound => "model_not_found",
FailoverReason::ProviderPolicyBlocked => "provider_policy_blocked",
FailoverReason::ContentPolicyBlocked => "content_policy_blocked",
FailoverReason::FormatError => "format_error",
FailoverReason::InvalidEncryptedContent => "invalid_encrypted_content",
FailoverReason::MultimodalToolContentUnsupported => {
"multimodal_tool_content_unsupported"
}
FailoverReason::ThinkingSignature => "thinking_signature",
FailoverReason::LongContextTier => "long_context_tier",
FailoverReason::OauthLongContextBetaForbidden => "oauth_long_context_beta_forbidden",
FailoverReason::SslTransient => "ssl_transient",
FailoverReason::Unknown(s) => return write!(f, "unknown({})", s),
};
write!(f, "{}", label)
}
}
// ── 保留向后兼容的 ErrorKind 别名 ──
/// 错误类型分类(向后兼容简化版,新代码应使用 FailoverReason
#[derive(Debug, Clone, PartialEq)]
pub enum ErrorKind {
PromptTooLong,
TokenExhausted,
ModelError(String),
Timeout,
RateLimited,
Overloaded,
}
impl From<&FailoverReason> for ErrorKind {
fn from(reason: &FailoverReason) -> Self {
match reason {
FailoverReason::ContextOverflow | FailoverReason::PayloadTooLarge => {
ErrorKind::PromptTooLong
}
FailoverReason::LongContextTier => ErrorKind::TokenExhausted,
FailoverReason::RateLimited => ErrorKind::RateLimited,
FailoverReason::Overloaded | FailoverReason::ServerError => ErrorKind::Overloaded,
FailoverReason::Timeout | FailoverReason::SslTransient => ErrorKind::Timeout,
other => ErrorKind::ModelError(other.to_string()),
}
}
}
// ── 8 步分类管线 ──
/// Step 1: Provider-specific 模式匹配(最高优先级)
fn classify_provider_specific(error_str: &str) -> Option<FailoverReason> {
let lower = error_str.to_lowercase();
// Content policy blocks (非 provider 特定,但在 status 分类前检查)
if lower.contains("content_policy_violation")
|| lower.contains("safety filter")
|| lower.contains("content filter")
|| lower.contains("responsible_ai")
|| lower.contains("content management policy")
{
return Some(FailoverReason::ContentPolicyBlocked);
}
// Thinking signature error (Qwen/DashScope)
if lower.contains("thinking_signature") || lower.contains("thinking signature") {
return Some(FailoverReason::ThinkingSignature);
}
// Long context tier gate
if lower.contains("long_context_tier")
|| lower.contains("long context tier")
|| lower.contains("context_length_exceeded_for_tier")
{
return Some(FailoverReason::LongContextTier);
}
// OAuth 1M beta forbidden
if lower.contains("oauth_long_context") || lower.contains("1m_beta_forbidden") {
return Some(FailoverReason::OauthLongContextBetaForbidden);
}
// Llama.cpp grammar error
if lower.contains("grammar_pattern") || lower.contains("llama_cpp_grammar") {
return Some(FailoverReason::FormatError);
}
// Multimodal tool content
if lower.contains("multimodal_tool_content") || lower.contains("tool content not supported") {
return Some(FailoverReason::MultimodalToolContentUnsupported);
}
None
}
/// Step 2: HTTP Status Code 分类
fn classify_by_http_status(error_str: &str) -> Option<FailoverReason> {
let lower = error_str.to_lowercase();
// 401 → auth (temporary)
if lower.contains("401") || lower.contains("unauthorized") {
return Some(FailoverReason::Auth);
}
// 402 → billing vs rate-limit disambiguation
if lower.contains("402") {
if lower.contains("rate") || lower.contains("limit") || lower.contains("try again") {
return Some(FailoverReason::RateLimited);
}
return Some(FailoverReason::Billing);
}
// 403
if lower.contains("403") || lower.contains("forbidden") {
if lower.contains("billing") || lower.contains("quota") || lower.contains("insufficient") {
return Some(FailoverReason::Billing);
}
if lower.contains("policy") || lower.contains("blocked") {
return Some(FailoverReason::ProviderPolicyBlocked);
}
return Some(FailoverReason::AuthPermanent);
}
// 404
if lower.contains("404") || lower.contains("not found") {
if lower.contains("model") || lower.contains("deployment") {
return Some(FailoverReason::ModelNotFound);
}
if lower.contains("billing") || lower.contains("subscription") {
return Some(FailoverReason::Billing);
}
if lower.contains("policy") || lower.contains("blocked") {
return Some(FailoverReason::ProviderPolicyBlocked);
}
}
// 413 → context overflow (default), could also be payload_too_large
if lower.contains("413") {
if lower.contains("image") || lower.contains("media") {
return Some(FailoverReason::ImageTooLarge);
}
return Some(FailoverReason::ContextOverflow);
}
// 429 → rate limit (definitive)
if lower.contains("429") {
return Some(FailoverReason::RateLimited);
}
// 500/502 → server error
if lower.contains("\"500\"")
|| lower.contains(" 500 ")
|| lower.contains("502")
|| lower.contains("internal server error")
|| lower.contains("bad gateway")
{
// Check for request validation signals
if lower.contains("invalid_request")
|| lower.contains("unsupported_parameter")
|| lower.contains("unknown_parameter")
{
return Some(FailoverReason::FormatError);
}
return Some(FailoverReason::ServerError);
}
// 503/529 → overloaded
if lower.contains("503") || lower.contains("529") || lower.contains("service unavailable") {
return Some(FailoverReason::Overloaded);
}
None
}
/// Step 3: Structured Error Code 分类
fn classify_by_error_code(error_str: &str) -> Option<FailoverReason> {
let lower = error_str.to_lowercase();
// Structured error codes from API responses
if lower.contains("\"code\"") || lower.contains("\"error_type\"") {
if lower.contains("resource_exhausted") {
return Some(FailoverReason::RateLimited);
}
if lower.contains("insufficient_quota") {
return Some(FailoverReason::Billing);
}
if lower.contains("model_not_found") || lower.contains("deployment_not_found") {
return Some(FailoverReason::ModelNotFound);
}
if lower.contains("invalid_api_key") || lower.contains("token_expired") {
return Some(FailoverReason::Auth);
}
if lower.contains("context_length_exceeded") {
return Some(FailoverReason::ContextOverflow);
}
}
None
}
/// Step 4: Message Pattern 匹配(14 组模式集)
fn classify_by_message_pattern(error_str: &str) -> Option<FailoverReason> {
let lower = error_str.to_lowercase();
// 4a. Billing patterns
if lower.contains("insufficient_quota")
|| lower.contains("billing account")
|| lower.contains("payment required")
|| lower.contains("quota exceeded")
|| lower.contains("run out of")
|| lower.contains("no credit")
|| lower.contains("check your balance")
{
return Some(FailoverReason::Billing);
}
// 4b. Rate-limit patterns
if lower.contains("rate limit")
|| lower.contains("rate_limit")
|| lower.contains("too many requests")
|| lower.contains("requests too frequent")
|| lower.contains("try again in")
|| lower.contains("resets at")
|| lower.contains("please slow down")
{
return Some(FailoverReason::RateLimited);
}
// 4c. Context overflow patterns
if lower.contains("prompt_too_long")
|| lower.contains("prompt too long")
|| lower.contains("context length")
|| lower.contains("context_length_exceeded")
|| lower.contains("input length")
|| lower.contains("maximum context")
|| lower.contains("reduce the length")
|| lower.contains("too many tokens")
|| lower.contains("max_tokens_exceeded")
{
return Some(FailoverReason::ContextOverflow);
}
// 4d. Auth patterns
if lower.contains("invalid api key")
|| lower.contains("invalid_api_key")
|| lower.contains("incorrect api key")
|| lower.contains("authentication failed")
|| lower.contains("auth error")
|| lower.contains("not authenticated")
|| lower.contains("token is invalid")
{
return Some(FailoverReason::Auth);
}
// 4e. Payload size
if lower.contains("request too large")
|| lower.contains("payload too large")
|| lower.contains("request size exceeds")
|| lower.contains("request entity too large")
{
return Some(FailoverReason::PayloadTooLarge);
}
// 4f. Image size
if lower.contains("image too large")
|| lower.contains("image size exceeds")
|| lower.contains("max image size")
|| lower.contains("image_resolution_exceeded")
{
return Some(FailoverReason::ImageTooLarge);
}
// 4g. Multimodal tool content
if lower.contains("tool content not supported") || lower.contains("multimodal content in tool")
{
return Some(FailoverReason::MultimodalToolContentUnsupported);
}
// 4h. Timeout patterns
if lower.contains("request timed out")
|| lower.contains("timed out")
|| lower.contains("deadline exceeded")
|| lower.contains("idle timeout")
|| lower.contains("connection timeout")
{
return Some(FailoverReason::Timeout);
}
// 4i. Invalid encrypted content
if lower.contains("invalid encrypted content") || lower.contains("encrypted_content_error") {
return Some(FailoverReason::InvalidEncryptedContent);
}
// 4j. Format error
if lower.contains("invalid_request")
&& (lower.contains("parameter") || lower.contains("format") || lower.contains("schema"))
{
return Some(FailoverReason::FormatError);
}
None
}
/// Step 5: SSL/TLS 瞬态错误检测
fn classify_ssl_transient(error_str: &str) -> Option<FailoverReason> {
let lower = error_str.to_lowercase();
if lower.contains("ssl") || lower.contains("tls") || lower.contains("certificate") {
// Temporary TLS errors (connection reset, handshake failure)
if lower.contains("reset") || lower.contains("handshake") || lower.contains("timeout") {
return Some(FailoverReason::SslTransient);
}
// Permanent TLS errors (certificate expired, hostname mismatch)
if lower.contains("expired") || lower.contains("hostname") || lower.contains("verify") {
return Some(FailoverReason::AuthPermanent);
}
return Some(FailoverReason::SslTransient);
}
None
}
/// Step 6: Server disconnect + large session → context overflow
fn classify_disconnect_with_large_session(
error_str: &str,
estimated_tokens: Option<usize>,
context_length: Option<usize>,
) -> Option<FailoverReason> {
let lower = error_str.to_lowercase();
// Server disconnected without response
if lower.contains("connection reset")
|| lower.contains("eof")
|| lower.contains("incomplete response")
|| lower.contains("peer closed")
{
// If session is large (>60% of context), blame context overflow
if let (Some(tokens), Some(ctx_len)) = (estimated_tokens, context_length) {
let ratio = tokens as f64 / ctx_len as f64;
if ratio > 0.6 {
return Some(FailoverReason::ContextOverflow);
}
}
return Some(FailoverReason::Timeout);
}
None
}
/// Step 7: Transport error heuristics
fn classify_transport_error(error_str: &str) -> Option<FailoverReason> {
let lower = error_str.to_lowercase();
if lower.contains("connection")
|| lower.contains("network")
|| lower.contains("dns")
|| lower.contains("unreachable")
|| lower.contains("refused")
|| lower.contains("broken pipe")
|| lower.contains("transport")
|| lower.contains("eof")
|| lower.contains("reset by peer")
{
return Some(FailoverReason::Timeout);
}
None
}
/// Step 8: Unknown fallback
fn classify_unknown(error_str: &str) -> FailoverReason {
FailoverReason::Unknown(error_str.to_string())
}
// ── 主分类函数 ──
/// 8步分类管线:按优先级依次尝试各分类器。
///
/// 可选传入 `estimated_tokens` 和 `context_length` 用于
/// disconnect + large session 的上下文溢出检测。
pub fn classify_failover(
error_str: &str,
estimated_tokens: Option<usize>,
context_length: Option<usize>,
) -> FailoverReason {
// Step 1: Provider-specific (HIGHEST priority)
if let Some(reason) = classify_provider_specific(error_str) {
info!("[ErrorClassify] Step 1 (provider-specific): {:?}", reason);
return reason;
}
// Step 2: HTTP status code
if let Some(reason) = classify_by_http_status(error_str) {
info!("[ErrorClassify] Step 2 (HTTP status): {:?}", reason);
return reason;
}
// Step 3: Structured error code
if let Some(reason) = classify_by_error_code(error_str) {
info!("[ErrorClassify] Step 3 (error code): {:?}", reason);
return reason;
}
// Step 4: Message patterns (14 groups)
if let Some(reason) = classify_by_message_pattern(error_str) {
info!("[ErrorClassify] Step 4 (message pattern): {:?}", reason);
return reason;
}
// Step 5: SSL/TLS transient
if let Some(reason) = classify_ssl_transient(error_str) {
info!("[ErrorClassify] Step 5 (SSL/TLS): {:?}", reason);
return reason;
}
// Step 6: Server disconnect + large session
if let Some(reason) =
classify_disconnect_with_large_session(error_str, estimated_tokens, context_length)
{
info!(
"[ErrorClassify] Step 6 (disconnect + large session): {:?}",
reason
);
return reason;
}
// Step 7: Transport heuristics
if let Some(reason) = classify_transport_error(error_str) {
info!("[ErrorClassify] Step 7 (transport): {:?}", reason);
return reason;
}
// Step 8: Unknown fallback
error!(
"[ErrorClassify] Step 8 (unknown): unclassified error: {}",
error_str
);
classify_unknown(error_str)
}
// ── 向后兼容的 classify_error ──
/// 从错误字符串分类错误类型。
/// 内部调用 classify_failover 并转换为简化的 ErrorKind。
pub fn classify_error(error_str: &str) -> ErrorKind {
let reason = classify_failover(error_str, None, None);
ErrorKind::from(&reason)
}
// ── 恢复建议生成 ──
/// 根据 FailoverReason 生成面向用户的恢复建议。
pub fn recovery_suggestion(reason: &FailoverReason) -> &'static str {
match reason {
FailoverReason::Auth => "认证失败,尝试切换 API Key 或刷新凭据。",
FailoverReason::AuthPermanent => "认证凭据永久失效,请检查 API Key 配置。",
FailoverReason::Billing => "账户余额不足或配额用尽,请检查计费状态。",
FailoverReason::RateLimited => "请求频率超限,系统正在自动退避重试。",
FailoverReason::Overloaded => "模型服务过载,系统正在自动重试。",
FailoverReason::ServerError => "服务器内部错误,将自动重试。",
FailoverReason::Timeout => "请求超时,将自动重试。请检查网络连接。",
FailoverReason::ContextOverflow => "上下文超过限制,正在自动压缩对话历史。",
FailoverReason::PayloadTooLarge => "请求载荷过大,请尝试减少工具调用数量。",
FailoverReason::ImageTooLarge => "图片过大,请使用更小的图片或降低分辨率。",
FailoverReason::ModelNotFound => "模型不可用,请检查模型名称或尝试切换模型。",
FailoverReason::ProviderPolicyBlocked => "Provider 策略阻止了此请求。",
FailoverReason::ContentPolicyBlocked => {
"内容被安全策略过滤,请修改请求内容后重试。此错误不可自动恢复。"
}
FailoverReason::FormatError => "请求格式错误,请检查工具参数格式。",
FailoverReason::InvalidEncryptedContent => "加密内容无效,请重新生成。",
FailoverReason::MultimodalToolContentUnsupported => {
"当前模型不支持工具结果中的多模态内容,请改用纯文本。"
}
FailoverReason::ThinkingSignature => "思考模式签名错误,请禁用 thinking 模式或切换模型。",
FailoverReason::LongContextTier => "超出当前 tier 的上下文长度限制,正在压缩或升级 tier。",
FailoverReason::OauthLongContextBetaForbidden => "长上下文 Beta 功能未对当前凭据开放。",
FailoverReason::SslTransient => "SSL/TLS 连接暂时中断,将自动重试。",
FailoverReason::Unknown(_) => "未知错误,系统将尝试恢复。",
}
}
+726
View File
@@ -0,0 +1,726 @@
// src/agent/runtime/error_recovery/mod.rs
//
// LLM 调用错误恢复引擎。
// 子模块:
// classification — FailoverReason / ErrorKind / classify_* 分类函数族
// overflow — 上下文溢出解析与安全 Token 计算
pub(crate) mod classification;
pub(crate) mod overflow;
use tracing::{info, warn};
use crate::agent::runtime::token_budget::TokenBudget;
// Re-export 公共类型,保持外部引用路径不变
pub use classification::{
classify_error, classify_failover, recovery_suggestion, ErrorKind, FailoverReason,
};
pub use overflow::{
calculate_safe_max_tokens, parse_context_overflow, parse_retry_after, ContextOverflowInfo,
};
#[derive(Debug, Clone, PartialEq)]
pub enum RecoveryStep {
/// 调整 max_tokens 以适配上下文容量(ContextOverflow 首选策略)。
/// 参考 Claude Code — 解析 "X + Y > Z" 错误后计算安全值。
AdjustMaxTokens { new_max_tokens: usize },
/// 尝试更激进的 micro_compact
AggressiveCompact,
/// 使用 LLM 摘要压缩
ReactiveCompact,
/// 提升 token 上限
EscalateTokens { new_hard_limit: usize },
/// 分轮恢复(注入 meta 消息)
MultiTurn,
/// 放弃,暴露错误
Surface,
/// 指数退避重试(用于 429/529 瞬态错误)
RetryWithBackoff { attempt: u32, delay_ms: u64 },
}
/// 恢复尝试追踪
#[derive(Debug, Clone)]
pub struct RecoveryAttempts {
pub aggressive_compact: bool,
pub reactive_compact: bool,
pub escalate_tokens: bool,
pub multi_turn: bool,
}
impl RecoveryAttempts {
pub fn new() -> Self {
RecoveryAttempts {
aggressive_compact: false,
reactive_compact: false,
escalate_tokens: false,
multi_turn: false,
}
}
/// 是否有未尝试的恢复步骤
pub fn has_remaining(&self) -> bool {
!self.aggressive_compact
|| !self.reactive_compact
|| !self.escalate_tokens
|| !self.multi_turn
}
/// 获取下一个应尝试的恢复步骤。
///
/// 对于 ContextOverflow 类错误,恢复优先级为:
/// 1. AdjustMaxTokens — 从错误消息解析容量,精确计算安全的 max_tokens(参考 Claude Code
/// 2. AggressiveCompact — 激进微压缩
/// 3. ReactiveCompact — LLM 摘要压缩
/// 4. EscalateTokens — 提升 hard_limit
/// 5. MultiTurn — 分轮
pub fn next_step(
&mut self,
error_kind: &ErrorKind,
overflow_info: Option<&ContextOverflowInfo>,
) -> Option<RecoveryStep> {
if matches!(error_kind, ErrorKind::RateLimited | ErrorKind::Overloaded) {
return Some(RecoveryStep::RetryWithBackoff {
attempt: 0,
delay_ms: 500,
});
}
match error_kind {
ErrorKind::PromptTooLong | ErrorKind::TokenExhausted => {
// Step 0: 尝试精确调整 max_tokens(仅在有 Overflow info 时)
if let Some(info) = overflow_info {
if let Some(safe_tokens) = calculate_safe_max_tokens(info) {
// 仅在使用默认路径(尚未尝试过其他恢复方式)时触发
if !self.aggressive_compact
&& !self.reactive_compact
&& !self.escalate_tokens
{
return Some(RecoveryStep::AdjustMaxTokens {
new_max_tokens: safe_tokens,
});
}
}
}
// Step 1: 激进微压缩
if !self.aggressive_compact {
self.aggressive_compact = true;
return Some(RecoveryStep::AggressiveCompact);
}
// Step 2: LLM 摘要压缩
if !self.reactive_compact {
self.reactive_compact = true;
return Some(RecoveryStep::ReactiveCompact);
}
// Step 3: 提升硬限制
if !self.escalate_tokens {
self.escalate_tokens = true;
return Some(RecoveryStep::EscalateTokens {
new_hard_limit: 64_000,
});
}
// Step 4: 分轮
if !self.multi_turn {
self.multi_turn = true;
return Some(RecoveryStep::MultiTurn);
}
}
_ => {
if !self.multi_turn {
self.multi_turn = true;
return Some(RecoveryStep::Surface);
}
}
}
None
}
/// 向后兼容的无 overflow_info 版本。
pub fn next_step_compat(&mut self, error_kind: &ErrorKind) -> Option<RecoveryStep> {
self.next_step(error_kind, None)
}
}
impl Default for RecoveryAttempts {
fn default() -> Self {
Self::new()
}
}
/// 错误恢复器
pub struct ErrorRecovery {
pub attempts: RecoveryAttempts,
pub token_budget: TokenBudget,
}
impl ErrorRecovery {
pub fn new(token_budget: TokenBudget) -> Self {
ErrorRecovery {
attempts: RecoveryAttempts::new(),
token_budget,
}
}
pub fn try_recover(
&mut self,
error_kind: &ErrorKind,
overflow_info: Option<&ContextOverflowInfo>,
) -> Option<RecoveryStep> {
let step = self.attempts.next_step(error_kind, overflow_info);
match &step {
Some(RecoveryStep::AdjustMaxTokens { new_max_tokens }) => {
info!(
"[ErrorRecovery] 尝试步骤 0/5: AdjustMaxTokens → {} (从错误消息计算)",
new_max_tokens
);
// 将硬限制下调至安全值,使下一次 LLM 调用能在容量内完成
self.token_budget.hard_limit = *new_max_tokens;
}
Some(RecoveryStep::AggressiveCompact) => {
info!("[ErrorRecovery] 尝试步骤 1/5: AggressiveCompact");
}
Some(RecoveryStep::ReactiveCompact) => {
info!("[ErrorRecovery] 尝试步骤 2/5: ReactiveCompact");
}
Some(RecoveryStep::EscalateTokens { new_hard_limit }) => {
info!(
"[ErrorRecovery] 尝试步骤 3/5: EscalateTokens → {}",
new_hard_limit
);
self.token_budget.escalate_hard_limit(*new_hard_limit);
}
Some(RecoveryStep::RetryWithBackoff { attempt, delay_ms }) => {
info!(
"[ErrorRecovery] 退避重试: attempt={}, delay={}ms",
attempt, delay_ms
);
}
Some(RecoveryStep::MultiTurn) => {
info!("[ErrorRecovery] 尝试步骤 4/5: MultiTurn");
}
Some(RecoveryStep::Surface) => {
warn!("[ErrorRecovery] 无法恢复,暴露错误");
}
None => {
warn!("[ErrorRecovery] 所有恢复步骤已尝试完毕");
}
}
step
}
/// 向后兼容的无 overflow_info 版本。
pub fn try_recover_compat(&mut self, error_kind: &ErrorKind) -> Option<RecoveryStep> {
self.try_recover(error_kind, None)
}
pub fn multi_turn_message() -> String {
"由于 token 限制,当前回答被截断。请基于已收集的信息继续分析,\
重点关注尚未完成的部分。你可以:\n\
1. 总结已有发现\n\
2. 使用 compress_context 手动压缩上下文\n\
3. 分步完成剩余工作"
.to_string()
}
pub fn is_recoverable(error_kind: &ErrorKind) -> bool {
matches!(
error_kind,
ErrorKind::PromptTooLong
| ErrorKind::TokenExhausted
| ErrorKind::RateLimited
| ErrorKind::Overloaded
)
}
}
/// 计算指数退避延迟(毫秒)
pub fn backoff_delay(attempt: u32, retry_after_secs: Option<u64>) -> u64 {
if let Some(ra) = retry_after_secs {
return ra * 1000;
}
let base = 500u64 * 2u64.pow(attempt.min(6));
let base = base.min(32_000);
let jitter = (base / 4) * (attempt as u64 % 5) / 5;
base + jitter
}
#[cfg(test)]
mod tests {
use super::*;
// ── FailoverReason tests ──
#[test]
fn test_is_retryable() {
assert!(FailoverReason::Auth.is_retryable());
assert!(FailoverReason::RateLimited.is_retryable());
assert!(FailoverReason::Overloaded.is_retryable());
assert!(!FailoverReason::AuthPermanent.is_retryable());
assert!(!FailoverReason::ContentPolicyBlocked.is_retryable());
}
#[test]
fn test_should_compress() {
assert!(FailoverReason::ContextOverflow.should_compress());
assert!(FailoverReason::PayloadTooLarge.should_compress());
assert!(!FailoverReason::RateLimited.should_compress());
}
#[test]
fn test_should_failover() {
assert!(FailoverReason::Auth.should_failover());
assert!(FailoverReason::Billing.should_failover());
assert!(FailoverReason::ModelNotFound.should_failover());
assert!(!FailoverReason::Timeout.should_failover());
}
#[test]
fn test_is_permanent() {
assert!(FailoverReason::AuthPermanent.is_permanent());
assert!(FailoverReason::ContentPolicyBlocked.is_permanent());
assert!(!FailoverReason::Auth.is_permanent());
}
#[test]
fn test_is_user_error() {
assert!(FailoverReason::ContextOverflow.is_user_error());
assert!(FailoverReason::ImageTooLarge.is_user_error());
assert!(!FailoverReason::Timeout.is_user_error());
}
#[test]
fn test_failover_reason_display() {
assert_eq!(FailoverReason::Auth.to_string(), "auth");
assert_eq!(FailoverReason::RateLimited.to_string(), "rate_limited");
assert_eq!(
FailoverReason::Unknown("test".into()).to_string(),
"unknown(test)"
);
}
// ── Classification pipeline tests ──
#[test]
fn test_classify_rate_limited_429() {
let reason = classify_failover("HTTP 429: Too Many Requests", None, None);
assert_eq!(reason, FailoverReason::RateLimited);
}
#[test]
fn test_classify_overloaded_529() {
let reason = classify_failover("HTTP 529: Service Overloaded", None, None);
assert_eq!(reason, FailoverReason::Overloaded);
}
#[test]
fn test_classify_context_overflow() {
let reason = classify_failover(
"Error: prompt_too_long: context length exceeded",
None,
None,
);
assert_eq!(reason, FailoverReason::ContextOverflow);
}
#[test]
fn test_classify_auth_401() {
let reason = classify_failover("HTTP 401: Unauthorized - invalid api key", None, None);
assert_eq!(reason, FailoverReason::Auth);
}
#[test]
fn test_classify_billing_402() {
let reason = classify_failover("HTTP 402: insufficient_quota", None, None);
assert_eq!(reason, FailoverReason::Billing);
}
#[test]
fn test_classify_content_policy_blocked() {
let reason = classify_failover(
"content_policy_violation: responsible_ai filter triggered",
None,
None,
);
assert_eq!(reason, FailoverReason::ContentPolicyBlocked);
}
#[test]
fn test_classify_model_not_found() {
let reason = classify_failover(
"HTTP 404: model_not_found - deployment not found",
None,
None,
);
assert_eq!(reason, FailoverReason::ModelNotFound);
}
#[test]
fn test_classify_transport_timeout() {
let reason = classify_failover("Connection reset by peer", None, None);
// Connection reset with no session info → timeout
assert_eq!(reason, FailoverReason::Timeout);
}
#[test]
fn test_classify_disconnect_with_large_session() {
let reason = classify_failover(
"connection reset",
Some(8000), // estimated tokens
Some(10000), // context length (80% → >60%)
);
assert_eq!(reason, FailoverReason::ContextOverflow);
}
#[test]
fn test_classify_disconnect_with_small_session() {
let reason = classify_failover(
"connection reset",
Some(4000), // estimated tokens
Some(10000), // context length (40% → <60%)
);
assert_eq!(reason, FailoverReason::Timeout);
}
#[test]
fn test_classify_priorities_content_policy_before_status() {
// content_policy_violation should be detected before 400 status
let reason = classify_failover(
"HTTP 400: content_policy_violation - content management policy",
None,
None,
);
assert_eq!(reason, FailoverReason::ContentPolicyBlocked);
}
#[test]
fn test_classify_unknown_fallback() {
let reason = classify_failover("Some weird error that doesn't match anything", None, None);
assert!(matches!(reason, FailoverReason::Unknown(_)));
}
#[test]
fn test_classify_image_too_large() {
let reason = classify_failover("HTTP 413: image too large, max 20MB", None, None);
assert_eq!(reason, FailoverReason::ImageTooLarge);
}
#[test]
fn test_classify_provider_policy_blocked() {
let reason = classify_failover("HTTP 403: provider policy blocked this region", None, None);
assert_eq!(reason, FailoverReason::ProviderPolicyBlocked);
}
#[test]
fn test_classify_ssl_transient() {
let reason = classify_failover("SSL handshake timeout: connection reset", None, None);
assert_eq!(reason, FailoverReason::SslTransient);
}
// ── Backward compatibility tests ──
#[test]
fn test_classify_error_backward_compat() {
let kind = classify_error("HTTP 429: Too Many Requests");
assert_eq!(kind, ErrorKind::RateLimited);
}
#[test]
fn test_classify_error_529_backward_compat() {
let kind = classify_error("HTTP 529: Service Overloaded");
assert_eq!(kind, ErrorKind::Overloaded);
}
// ── Recovery step tests ──
#[test]
fn test_all_steps_sequence() {
let mut attempts = RecoveryAttempts::new();
assert_eq!(
attempts.next_step_compat(&ErrorKind::PromptTooLong),
Some(RecoveryStep::AggressiveCompact)
);
assert_eq!(
attempts.next_step_compat(&ErrorKind::PromptTooLong),
Some(RecoveryStep::ReactiveCompact)
);
assert_eq!(
attempts.next_step_compat(&ErrorKind::PromptTooLong),
Some(RecoveryStep::EscalateTokens {
new_hard_limit: 64_000
})
);
assert_eq!(
attempts.next_step_compat(&ErrorKind::PromptTooLong),
Some(RecoveryStep::MultiTurn)
);
assert_eq!(attempts.next_step_compat(&ErrorKind::PromptTooLong), None);
}
#[test]
fn test_model_error_goes_straight_to_surface() {
let mut attempts = RecoveryAttempts::new();
assert_eq!(
attempts.next_step_compat(&ErrorKind::ModelError("test".into())),
Some(RecoveryStep::Surface)
);
assert_eq!(
attempts.next_step_compat(&ErrorKind::ModelError("test".into())),
None
);
}
#[test]
fn test_has_remaining() {
let mut attempts = RecoveryAttempts::new();
assert!(attempts.has_remaining());
for _ in 0..4 {
attempts.next_step_compat(&ErrorKind::PromptTooLong);
}
assert!(!attempts.has_remaining());
}
#[test]
fn test_is_recoverable() {
assert!(ErrorRecovery::is_recoverable(&ErrorKind::PromptTooLong));
assert!(ErrorRecovery::is_recoverable(&ErrorKind::TokenExhausted));
assert!(ErrorRecovery::is_recoverable(&ErrorKind::RateLimited));
assert!(ErrorRecovery::is_recoverable(&ErrorKind::Overloaded));
assert!(!ErrorRecovery::is_recoverable(&ErrorKind::ModelError(
"test".into()
)));
assert!(!ErrorRecovery::is_recoverable(&ErrorKind::Timeout));
}
#[test]
fn test_parse_retry_after() {
let err = "HTTP 429: retry_after=Some(30)";
assert_eq!(parse_retry_after(err), Some(30));
}
#[test]
fn test_parse_retry_after_header_format() {
let err = "HTTP 429\nRetry-After: 60\n";
assert_eq!(parse_retry_after(err), Some(60));
}
#[test]
fn test_parse_retry_after_none() {
let err = "HTTP 500: Internal Server Error";
assert_eq!(parse_retry_after(err), None);
}
#[test]
fn test_backoff_delay() {
let d0 = backoff_delay(0, None);
assert!(d0 >= 500 && d0 <= 700);
let d3 = backoff_delay(3, None);
assert!(d3 >= 4000 && d3 <= 5000);
let d10 = backoff_delay(10, None);
assert!(d10 <= 40_000);
let d_ra = backoff_delay(0, Some(15));
assert_eq!(d_ra, 15000);
}
#[test]
fn test_rate_limited_goes_to_retry() {
let mut attempts = RecoveryAttempts::new();
let step = attempts.next_step_compat(&ErrorKind::RateLimited);
assert!(matches!(step, Some(RecoveryStep::RetryWithBackoff { .. })));
}
#[test]
fn test_token_budget_escalation() {
let budget = TokenBudget::new(32_000, 40_000);
let mut recovery = ErrorRecovery::new(budget);
assert_eq!(recovery.token_budget.hard_limit, 40_000);
recovery
.attempts
.next_step_compat(&ErrorKind::PromptTooLong); // aggressive
recovery
.attempts
.next_step_compat(&ErrorKind::PromptTooLong); // reactive
let step = recovery.try_recover_compat(&ErrorKind::PromptTooLong); // escalate
assert_eq!(
step,
Some(RecoveryStep::EscalateTokens {
new_hard_limit: 64_000
})
);
assert_eq!(recovery.token_budget.hard_limit, 64_000);
}
#[test]
fn test_recovery_suggestion_all_variants() {
// Every variant should return a non-empty suggestion
let variants = [
FailoverReason::Auth,
FailoverReason::AuthPermanent,
FailoverReason::Billing,
FailoverReason::RateLimited,
FailoverReason::Overloaded,
FailoverReason::ServerError,
FailoverReason::Timeout,
FailoverReason::ContextOverflow,
FailoverReason::PayloadTooLarge,
FailoverReason::ImageTooLarge,
FailoverReason::ModelNotFound,
FailoverReason::ProviderPolicyBlocked,
FailoverReason::ContentPolicyBlocked,
FailoverReason::FormatError,
FailoverReason::InvalidEncryptedContent,
FailoverReason::MultimodalToolContentUnsupported,
FailoverReason::ThinkingSignature,
FailoverReason::LongContextTier,
FailoverReason::OauthLongContextBetaForbidden,
FailoverReason::SslTransient,
FailoverReason::Unknown("test".into()),
];
for v in &variants {
let suggestion = recovery_suggestion(v);
assert!(!suggestion.is_empty(), "Empty suggestion for {:?}", v);
}
}
// ── Context Overflow 解析测试 ──
#[test]
fn test_parse_overflow_anthropic_format() {
let err = "input length and max_tokens exceed context limit: 180000 + 32000 > 200000. \
please reduce the length of the messages or the max_tokens.";
let info = parse_context_overflow(err).unwrap();
assert_eq!(info.input_length, 180000);
assert_eq!(info.requested_max_tokens, 32000);
assert_eq!(info.context_limit, 200000);
}
#[test]
fn test_parse_overflow_anthropic_format_different_numbers() {
let err = "Error: input length and max_tokens exceed context limit: 50000 + 16000 > 64000";
let info = parse_context_overflow(err).unwrap();
assert_eq!(info.input_length, 50000);
assert_eq!(info.requested_max_tokens, 16000);
assert_eq!(info.context_limit, 64000);
}
#[test]
fn test_parse_overflow_generic_format() {
let err = "context length exceeded: 130000 + 32000 > 128000";
let info = parse_context_overflow(err).unwrap();
assert_eq!(info.input_length, 130000);
assert_eq!(info.requested_max_tokens, 32000);
assert_eq!(info.context_limit, 128000);
}
#[test]
fn test_parse_overflow_no_match() {
let err = "Something went wrong but no overflow pattern here";
assert!(parse_context_overflow(err).is_none());
}
#[test]
fn test_calculate_safe_max_tokens_normal() {
let info = ContextOverflowInfo {
input_length: 180000,
requested_max_tokens: 32000,
context_limit: 200000,
};
// available = 200000 - 180000 = 20000
// adjusted = 20000 - 1000 = 19000
assert_eq!(calculate_safe_max_tokens(&info), Some(19000));
}
#[test]
fn test_calculate_safe_max_tokens_no_room() {
let info = ContextOverflowInfo {
input_length: 199000,
requested_max_tokens: 32000,
context_limit: 200000,
};
// available = 200000 - 199000 = 1000
// adjusted = 1000 - 1000 = 0 < MIN_ACCEPTABLE_TOKENS(500)
assert_eq!(calculate_safe_max_tokens(&info), None);
}
#[test]
fn test_calculate_safe_max_tokens_input_exceeds_context() {
let info = ContextOverflowInfo {
input_length: 210000,
requested_max_tokens: 1000,
context_limit: 200000,
};
// input > context → None (must compress)
assert_eq!(calculate_safe_max_tokens(&info), None);
}
#[test]
fn test_recovery_uses_adjust_max_tokens_first() {
let mut attempts = RecoveryAttempts::new();
let info = ContextOverflowInfo {
input_length: 180000,
requested_max_tokens: 32000,
context_limit: 200000,
};
let step = attempts.next_step(&ErrorKind::PromptTooLong, Some(&info));
assert_eq!(
step,
Some(RecoveryStep::AdjustMaxTokens {
new_max_tokens: 19000
})
);
}
#[test]
fn test_recovery_falls_back_to_compact_when_no_room() {
let mut attempts = RecoveryAttempts::new();
// Input almost fills context — no room to adjust max_tokens
let info = ContextOverflowInfo {
input_length: 199500,
requested_max_tokens: 32000,
context_limit: 200000,
};
let step = attempts.next_step(&ErrorKind::PromptTooLong, Some(&info));
// Should skip AdjustMaxTokens and go to AggressiveCompact
assert_eq!(step, Some(RecoveryStep::AggressiveCompact));
}
#[test]
fn test_recovery_adjust_after_compact_exhausted() {
let mut attempts = RecoveryAttempts::new();
// Mark aggressive_compact as tried
attempts.aggressive_compact = true;
let info = ContextOverflowInfo {
input_length: 180000,
requested_max_tokens: 32000,
context_limit: 200000,
};
// AdjustMaxTokens check is skipped when aggressive_compact is already true
let step = attempts.next_step(&ErrorKind::PromptTooLong, Some(&info));
assert_eq!(step, Some(RecoveryStep::ReactiveCompact));
}
#[test]
fn test_try_recover_adjusts_token_budget() {
let budget = TokenBudget::new(32_000, 40_000);
let mut recovery = ErrorRecovery::new(budget);
let info = ContextOverflowInfo {
input_length: 180000,
requested_max_tokens: 32000,
context_limit: 200000,
};
let step = recovery.try_recover(&ErrorKind::PromptTooLong, Some(&info));
assert_eq!(
step,
Some(RecoveryStep::AdjustMaxTokens {
new_max_tokens: 19000
})
);
// Token budget hard_limit should be adjusted down
assert_eq!(recovery.token_budget.hard_limit, 19000);
}
}
@@ -0,0 +1,157 @@
// src/agent/runtime/error_recovery/overflow.rs
//
// 上下文溢出解析与安全 Token 计算。
#[derive(Debug, Clone, Copy)]
pub struct ContextOverflowInfo {
/// 当前输入 token 数
pub input_length: usize,
/// 请求的 max_tokens 数
pub requested_max_tokens: usize,
/// 模型上下文窗口总容量
pub context_limit: usize,
}
/// 上下文溢出检测的最小可接受 token 数。
/// 低于此值说明即使调整 max_tokens 也没有意义,应触发压缩。
const MIN_ACCEPTABLE_TOKENS: usize = 500;
/// 上下文溢出调整的安全边际(token 数)。
/// 保留少量余量以防止重试时再次溢出。
const OVERFLOW_SAFETY_MARGIN: usize = 1000;
/// 从错误消息中解析 Anthropic-style context overflow 信息。
///
/// 匹配模式:
/// "input length and max_tokens exceed context limit: 180000 + 32000 > 200000"
/// "prompt too long: X tokens + Y max_tokens > Z context_window"
/// "context length exceeded: X + Y > Z"
/// "maximum context length is Z tokens, but X + Y = ..."
pub fn parse_context_overflow(error_str: &str) -> Option<ContextOverflowInfo> {
let lower = error_str.to_lowercase();
// Pattern 1: Anthropic standard format
// "input length and max_tokens exceed context limit: A + B > C"
if let Some(info) = parse_anthropic_overflow(&lower) {
return Some(info);
}
// Pattern 2: OpenAI format
// "maximum context length is C tokens. however X tokens ... and Y max_tokens"
if let Some(info) = parse_openai_overflow(&lower) {
return Some(info);
}
// Pattern 3: Generic "X + Y > Z" pattern
if let Some(info) = parse_generic_overflow(&lower) {
return Some(info);
}
None
}
/// 解析 Anthropic API 格式: "input length and max_tokens exceed context limit: A + B > C"
fn parse_anthropic_overflow(error_str: &str) -> Option<ContextOverflowInfo> {
// Find the "A + B > C" pattern after "context limit"
let marker = "input length and max_tokens exceed context limit";
let pos = error_str.find(marker)?;
let after_marker = &error_str[pos + marker.len()..];
// Extract numbers: look for pattern like " 180000 + 32000 > 200000"
let numbers = extract_three_numbers(after_marker)?;
Some(ContextOverflowInfo {
input_length: numbers.0,
requested_max_tokens: numbers.1,
context_limit: numbers.2,
})
}
/// 解析 OpenAI API 格式: "maximum context length is C tokens..."
fn parse_openai_overflow(error_str: &str) -> Option<ContextOverflowInfo> {
// "maximum context length is C tokens"
let context_re = regex::Regex::new(r"maximum context length is (\d+)").ok()?;
let caps = context_re.captures(error_str)?;
let context_limit: usize = caps.get(1)?.as_str().parse().ok()?;
// "you requested X tokens" or "messages resulted in X tokens"
let input_re = regex::Regex::new(r"(?:requested|resulted in|totals? to) (\d+) tokens").ok()?;
let input_length: usize = if let Some(caps) = input_re.captures(error_str) {
caps.get(1)?.as_str().parse().ok()?
} else {
// Fallback: find any substantial number and use it as input_length
return None;
};
// max_tokens might not be explicitly stated; use the context_limit - input_length - margin
let requested_max_tokens = context_limit.saturating_sub(input_length);
Some(ContextOverflowInfo {
input_length,
requested_max_tokens,
context_limit,
})
}
/// 解析通用格式: "X + Y > Z"
fn parse_generic_overflow(error_str: &str) -> Option<ContextOverflowInfo> {
// Look for patterns like "130000 + 32000 > 200000"
let (a, b, c) = extract_three_numbers(error_str)?;
Some(ContextOverflowInfo {
input_length: a,
requested_max_tokens: b,
context_limit: c,
})
}
/// 从字符串中提取三个连续的数字,匹配 "A + B > C" 模式。
fn extract_three_numbers(s: &str) -> Option<(usize, usize, usize)> {
let re = regex::Regex::new(r"(\d{4,})\s*\+\s*(\d+)\s*>\s*(\d{4,})").ok()?;
let caps = re.captures(s)?;
let a: usize = caps.get(1)?.as_str().parse().ok()?;
let b: usize = caps.get(2)?.as_str().parse().ok()?;
let c: usize = caps.get(3)?.as_str().parse().ok()?;
Some((a, b, c))
}
/// 根据 ContextOverflow 信息计算安全的 max_tokens 值。
///
/// 公式: `new_max_tokens = context_limit - input_length - SAFETY_MARGIN`
///
/// 返回 `None` 如果计算出的值低于最小可接受阈值(说明必须压缩上下文)。
pub fn calculate_safe_max_tokens(info: &ContextOverflowInfo) -> Option<usize> {
// 如果输入本身已经超过上下文窗口,max_tokens 调整无意义
if info.input_length >= info.context_limit {
return None;
}
let available = info.context_limit.saturating_sub(info.input_length);
let adjusted = available.saturating_sub(OVERFLOW_SAFETY_MARGIN);
if adjusted >= MIN_ACCEPTABLE_TOKENS {
Some(adjusted)
} else {
// 可用空间太小,调整 max_tokens 无意义 → 应触发压缩
None
}
}
// ── 向后兼容 ──
/// 解析 Retry-After header 值
pub fn parse_retry_after(error_str: &str) -> Option<u64> {
if let Some(pos) = error_str.find("retry_after=Some(") {
let prefix_len = "retry_after=Some(".len();
let rest = &error_str[pos + prefix_len..];
if let Some(end) = rest.find(')') {
return rest[..end].parse().ok();
}
}
if let Some(pos) = error_str.find("Retry-After:") {
let rest = &error_str[pos + "Retry-After:".len()..];
if let Some(end) = rest.find('\r').or_else(|| rest.find('\n')) {
return rest[..end].trim().parse().ok();
}
return rest.trim().parse().ok();
}
None
}