- 新增 ContextSlot 类型(Full / Focused / Readonly 三种模式, New / Derived / Static 三种来源),支持 JSON blob 批次持久化 - AgentSession 新增 slots 字段与 5 个管理方法 (create_slot / switch_slot / list_slots / derive_slot / delete_slot), 自动创建 "default" slot - submit_turn / finalize_turn 改造为基于当前 slot 的增量追加写回, 确保 Focused 模式"读时过滤"语义不丢失数据 - finalize_turn 签名变更(新增 new_messages_from_cycle 参数, 返回 Result<(), AgentError>),向后兼容列于 docs/17 - 新增 3 个 AgentError 变体(SlotReadonly / SlotNotFound / SlotAlreadyExists) - 新增分支对话示例 context_slot_demo(法律咨询→两个派生方向→切换→隔离验证) - 新增 43 个测试覆盖持久化、Focused 过滤、Readonly 阻断、delete 保护、 派生逻辑、流式 finalize_turn、key 注入防护等场景 - 方案文档:docs/17-phase10-contextslot.md(含 §5 推荐方案、§6 实施建议、 §9 实施计划,经过 4 轮方案/计划/实施审查 + 1 轮非阻塞建议修复)
235 lines
7.0 KiB
Rust
235 lines
7.0 KiB
Rust
//! Agent Runtime 统一错误类型。
|
|
//!
|
|
//! `AgentError` 聚合 Phase 0-3 各层错误(LlmError / ToolError / MemoryError),
|
|
//! 加上 Agent 层特有的错误变体。设计原则:
|
|
//!
|
|
//! - 聚合而非包装:保留内层错误的类型信息(避免 `Box<dyn Error>` 丢失上下文)
|
|
//! - 显式 `From` 实现:让 `?` 运算符能透明传播下层错误
|
|
//! - `is_recoverable()`:根据变体类型判定可恢复性,便于上层决策
|
|
|
|
use thiserror::Error;
|
|
|
|
use crate::llm::error::LlmError;
|
|
use crate::memory::error::MemoryError;
|
|
use crate::tools::error::ToolError;
|
|
|
|
/// Agent Runtime 统一错误枚举。
|
|
///
|
|
/// **不实现 `Clone`**:透传内层 `LlmError` / `MemoryError`,两者均未派生 `Clone`(保留
|
|
/// 完整错误信息,传递所有权)。如需在多 session 间共享错误状态,用 `Arc<AgentError>` 包装。
|
|
#[derive(Debug, Error)]
|
|
#[non_exhaustive]
|
|
pub enum AgentError {
|
|
/// LLM 调用错误(透传 Phase 0)。
|
|
#[error("LLM 错误: {0}")]
|
|
Llm(#[from] LlmError),
|
|
|
|
/// 工具调用错误(透传 Phase 2)。
|
|
#[error("工具错误: {0}")]
|
|
Tool(#[from] ToolError),
|
|
|
|
/// 记忆系统错误(透传 Phase 3)。
|
|
#[error("记忆错误: {0}")]
|
|
Memory(#[from] MemoryError),
|
|
|
|
/// Plan 解析失败(Phase 4b 新增)。
|
|
#[error("Plan 解析错误: {0}")]
|
|
PlanParse(String),
|
|
|
|
/// Readonly slot 不允许写入(Phase 10 新增)。
|
|
#[error("Readonly slot 不允许写入: {0}")]
|
|
SlotReadonly(String),
|
|
|
|
/// Slot 不存在(Phase 10 新增)。
|
|
#[error("Slot '{0}' 不存在")]
|
|
SlotNotFound(String),
|
|
|
|
/// Slot 已存在(Phase 10 新增)。
|
|
#[error("Slot '{0}' 已存在")]
|
|
SlotAlreadyExists(String),
|
|
|
|
/// 钩子阻断操作(Agent 层特有)。
|
|
#[error("钩子阻断: {0}")]
|
|
HookBlocked(String),
|
|
|
|
/// 达到限制阈值(最大 turn、token 预算等)。
|
|
#[error("超过限制: {0}")]
|
|
LimitExceeded(String),
|
|
|
|
/// 配置错误(构建 RuntimeBundle / AgentSession 时校验失败)。
|
|
#[error("配置错误: {0}")]
|
|
Config(String),
|
|
|
|
/// 其他未分类错误(兜底)。
|
|
#[error("Agent 错误: {0}")]
|
|
Other(String),
|
|
}
|
|
|
|
impl AgentError {
|
|
/// 判定错误是否可恢复。
|
|
///
|
|
/// - `Llm` / `Memory`:由内层 `is_recoverable()` 决定
|
|
/// - `Tool`:由内层 `is_recoverable()` 决定
|
|
/// - `HookBlocked` / `LimitExceeded`:不可恢复(需人工介入或终止循环)
|
|
/// - `Config` / `Other`:不可恢复
|
|
/// - `SlotReadonly` / `SlotNotFound` / `SlotAlreadyExists`:不可恢复(结构性错误)
|
|
pub fn is_recoverable(&self) -> bool {
|
|
match self {
|
|
Self::Llm(e) => matches!(
|
|
e,
|
|
LlmError::RateLimit { .. } | LlmError::Timeout { .. } | LlmError::Stream(_)
|
|
),
|
|
Self::Tool(e) => e.is_recoverable(),
|
|
Self::Memory(e) => e.is_recoverable(),
|
|
Self::PlanParse(_) => false,
|
|
Self::SlotReadonly(_)
|
|
| Self::SlotNotFound(_)
|
|
| Self::SlotAlreadyExists(_)
|
|
| Self::HookBlocked(_)
|
|
| Self::LimitExceeded(_)
|
|
| Self::Config(_)
|
|
| Self::Other(_) => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn llm_recoverable_propagation() {
|
|
let err = AgentError::Llm(LlmError::Timeout {
|
|
duration: std::time::Duration::from_secs(30),
|
|
});
|
|
assert!(err.is_recoverable());
|
|
}
|
|
|
|
#[test]
|
|
fn llm_non_recoverable_propagation() {
|
|
let err = AgentError::Llm(LlmError::Authentication("bad key".into()));
|
|
assert!(!err.is_recoverable());
|
|
}
|
|
|
|
#[test]
|
|
fn tool_recoverable_propagation() {
|
|
let err = AgentError::Tool(ToolError::ExecutionFailed("foo".into(), "boom".into()));
|
|
assert!(err.is_recoverable());
|
|
}
|
|
|
|
#[test]
|
|
fn tool_non_recoverable_propagation() {
|
|
let err = AgentError::Tool(ToolError::NotFound("foo".into()));
|
|
assert!(!err.is_recoverable());
|
|
}
|
|
|
|
#[test]
|
|
fn memory_recoverable_propagation() {
|
|
let err = AgentError::Memory(MemoryError::NotFound("page".into()));
|
|
assert!(err.is_recoverable());
|
|
}
|
|
|
|
#[test]
|
|
fn memory_non_recoverable_propagation() {
|
|
let err = AgentError::Memory(MemoryError::Storage("disk full".into()));
|
|
assert!(!err.is_recoverable());
|
|
}
|
|
|
|
#[test]
|
|
fn hook_blocked_not_recoverable() {
|
|
assert!(!AgentError::HookBlocked("denied".into()).is_recoverable());
|
|
}
|
|
|
|
#[test]
|
|
fn limit_exceeded_not_recoverable() {
|
|
assert!(!AgentError::LimitExceeded("max turns".into()).is_recoverable());
|
|
}
|
|
|
|
#[test]
|
|
fn config_not_recoverable() {
|
|
assert!(!AgentError::Config("缺少 provider".into()).is_recoverable());
|
|
}
|
|
|
|
#[test]
|
|
fn other_not_recoverable() {
|
|
assert!(!AgentError::Other("unknown".into()).is_recoverable());
|
|
}
|
|
|
|
#[test]
|
|
fn plan_parse_not_recoverable() {
|
|
assert!(!AgentError::PlanParse("bad json".into()).is_recoverable());
|
|
}
|
|
|
|
#[test]
|
|
fn from_llm_via_question_mark() {
|
|
fn returns_llm() -> Result<(), LlmError> {
|
|
Err(LlmError::Other("test".into()))
|
|
}
|
|
fn caller() -> Result<(), AgentError> {
|
|
returns_llm()?;
|
|
Ok(())
|
|
}
|
|
let err = caller().unwrap_err();
|
|
assert!(matches!(err, AgentError::Llm(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn from_tool_via_question_mark() {
|
|
fn returns_tool() -> Result<(), ToolError> {
|
|
Err(ToolError::NotFound("x".into()))
|
|
}
|
|
fn caller() -> Result<(), AgentError> {
|
|
returns_tool()?;
|
|
Ok(())
|
|
}
|
|
let err = caller().unwrap_err();
|
|
assert!(matches!(err, AgentError::Tool(_)));
|
|
}
|
|
|
|
#[test]
|
|
fn from_memory_via_question_mark() {
|
|
fn returns_mem() -> Result<(), MemoryError> {
|
|
Err(MemoryError::Storage("x".into()))
|
|
}
|
|
fn caller() -> Result<(), AgentError> {
|
|
returns_mem()?;
|
|
Ok(())
|
|
}
|
|
let err = caller().unwrap_err();
|
|
assert!(matches!(err, AgentError::Memory(_)));
|
|
}
|
|
|
|
// ====== Phase 10: Slot 错误变体测试 ======
|
|
|
|
#[test]
|
|
fn slot_readonly_not_recoverable() {
|
|
assert!(!AgentError::SlotReadonly("readonly".into()).is_recoverable());
|
|
}
|
|
|
|
#[test]
|
|
fn slot_not_found_not_recoverable() {
|
|
assert!(!AgentError::SlotNotFound("missing".into()).is_recoverable());
|
|
}
|
|
|
|
#[test]
|
|
fn slot_already_exists_not_recoverable() {
|
|
assert!(!AgentError::SlotAlreadyExists("dup".into()).is_recoverable());
|
|
}
|
|
|
|
#[test]
|
|
fn slot_error_messages() {
|
|
assert_eq!(
|
|
format!("{}", AgentError::SlotReadonly("readonly".into())),
|
|
"Readonly slot 不允许写入: readonly"
|
|
);
|
|
assert_eq!(
|
|
format!("{}", AgentError::SlotNotFound("foo".into())),
|
|
"Slot 'foo' 不存在"
|
|
);
|
|
assert_eq!(
|
|
format!("{}", AgentError::SlotAlreadyExists("bar".into())),
|
|
"Slot 'bar' 已存在"
|
|
);
|
|
}
|
|
}
|