feat(core): 新增 Phase 10 ContextSlot 多上下文分区管理
- 新增 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 轮非阻塞建议修复)
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,161 @@
|
||||
//! context_slot_demo —— 多上下文槽位管理示例。
|
||||
//!
|
||||
//! 场景:法律咨询入口 → 派生两个独立探索方向 → 切换 → 隔离验证 → 删除。
|
||||
//!
|
||||
//! 展示:
|
||||
//! - 默认 slot 自动创建
|
||||
//! - 多 slot 间的消息隔离
|
||||
//! - 派生 slot 从父 slot 复制消息
|
||||
//! - 删除非 default slot 后自动回退到 default
|
||||
//!
|
||||
//! 运行:`cargo run --example context_slot_demo`(离线,零配置)
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use agcore::agent::{Agent, AgentBuilder, AgentSession};
|
||||
use agcore::llm::hooks::HookExecutor;
|
||||
use agcore::llm::mock::MockProvider;
|
||||
use agcore::llm::provider::LlmProvider;
|
||||
use agcore::llm::types::message::{ContentBlock, Message};
|
||||
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
|
||||
use agcore::llm::types::Usage;
|
||||
use agcore::tools::ToolRegistry;
|
||||
|
||||
struct LegalAdvisor;
|
||||
|
||||
impl Agent for LegalAdvisor {
|
||||
fn name(&self) -> &str {
|
||||
"legal-advisor"
|
||||
}
|
||||
fn system_prompt(&self) -> Option<&str> {
|
||||
Some("你是法律顾问。请用一句话回答用户问题。")
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造一个简单的 Assistant 响应(用于 MockProvider)。
|
||||
fn assistant_resp(text: &str) -> MessageResponse {
|
||||
MessageResponse {
|
||||
id: String::new(),
|
||||
model: "mock".into(),
|
||||
message: Message::Assistant {
|
||||
content: vec![ContentBlock::Text { text: text.into() }],
|
||||
},
|
||||
usage: Usage::from_input_output(5, 5),
|
||||
stop_reason: StopReason::Stop,
|
||||
extra: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// 1. 构造 session(自动包含 default slot)
|
||||
let provider: Arc<dyn LlmProvider> = Arc::new(MockProvider::new(vec![
|
||||
assistant_resp("您好,我可以帮您处理法律问题。"),
|
||||
assistant_resp("管辖权问题:建议选择合同签订地法院。"),
|
||||
assistant_resp("条款修改:建议将上限调整为 80 万。"),
|
||||
assistant_resp("已回到主对话。"),
|
||||
]));
|
||||
let bundle = Arc::new(
|
||||
AgentBuilder::new()
|
||||
.provider(provider)
|
||||
.tool_registry(Arc::new(ToolRegistry::new()))
|
||||
.hook_executor(Arc::new(HookExecutor::new()))
|
||||
.build()
|
||||
.unwrap(),
|
||||
);
|
||||
let mut session = AgentSession::new(Arc::new(LegalAdvisor), "legal-001", bundle);
|
||||
|
||||
println!("=== 1. 默认 slot 自动创建 ===");
|
||||
assert_eq!(session.current_slot_id(), "default");
|
||||
let slots: Vec<_> = session.list_slots().collect();
|
||||
println!("初始 slots: {slots:?}");
|
||||
assert_eq!(slots.len(), 1);
|
||||
assert!(slots.contains(&&"default".to_string()));
|
||||
|
||||
println!("\n=== 2. 在 default slot 中提交一轮 ===");
|
||||
let r1 = session.submit_turn("我需要法律援助").await.unwrap();
|
||||
println!("default slot response: {}", r1.text());
|
||||
|
||||
println!("\n=== 3. 派生两个独立探索方向的 slot ===");
|
||||
session
|
||||
.derive_slot("option_jurisdiction", "default", agcore::agent::DeriveStrategy::Full)
|
||||
.await
|
||||
.unwrap();
|
||||
session
|
||||
.derive_slot("option_amendment", "default", agcore::agent::DeriveStrategy::Full)
|
||||
.await
|
||||
.unwrap();
|
||||
let slots: Vec<_> = session.list_slots().cloned().collect();
|
||||
println!("派生后 slots: {slots:?}");
|
||||
assert_eq!(slots.len(), 3);
|
||||
|
||||
println!("\n=== 4. 切到 option_jurisdiction 并提交 ===");
|
||||
session.switch_slot("option_jurisdiction").await.unwrap();
|
||||
assert_eq!(session.current_slot_id(), "option_jurisdiction");
|
||||
let r2 = session.submit_turn("如果用户质疑管辖权?").await.unwrap();
|
||||
println!("option_jurisdiction response: {}", r2.text());
|
||||
|
||||
println!("\n=== 5. 切到 option_amendment 并提交 ===");
|
||||
session.switch_slot("option_amendment").await.unwrap();
|
||||
let r3 = session.submit_turn("用户要求提高赔偿上限?").await.unwrap();
|
||||
println!("option_amendment response: {}", r3.text());
|
||||
|
||||
println!("\n=== 6. 切回 default,验证消息隔离 ===");
|
||||
session.switch_slot("default").await.unwrap();
|
||||
let r4 = session.submit_turn("汇总一下我们的讨论").await.unwrap();
|
||||
println!("default response: {}", r4.text());
|
||||
// 验证 default slot 不包含 option_jurisdiction 的"管辖权"问题
|
||||
let (_, default_slot) = session.slots().find(|(id, _)| *id == "default").unwrap();
|
||||
let default_has_jurisdiction = default_slot
|
||||
.messages
|
||||
.iter()
|
||||
.any(|m| message_contains(m, "管辖权"));
|
||||
assert!(
|
||||
!default_has_jurisdiction,
|
||||
"default slot 不应包含 option_jurisdiction 的消息"
|
||||
);
|
||||
|
||||
println!("\n=== 7. 删除 option_amendment,验证回退到 default ===");
|
||||
session.delete_slot("option_amendment").await.unwrap();
|
||||
let slots: Vec<_> = session.list_slots().cloned().collect();
|
||||
println!("删除后 slots: {slots:?}");
|
||||
assert!(!slots.contains(&"option_amendment".to_string()));
|
||||
assert_eq!(slots.len(), 2);
|
||||
|
||||
println!("\n=== 8. 切到 option_jurisdiction 并删除,验证 current 回退 ===");
|
||||
session.switch_slot("option_jurisdiction").await.unwrap();
|
||||
session.delete_slot("option_jurisdiction").await.unwrap();
|
||||
assert_eq!(session.current_slot_id(), "default");
|
||||
let slots: Vec<_> = session.list_slots().cloned().collect();
|
||||
println!("删除后 slots: {slots:?}");
|
||||
assert_eq!(slots.len(), 1);
|
||||
assert_eq!(slots[0], "default");
|
||||
|
||||
println!("\n=== 9. 验证 delete_slot 保护逻辑 ===");
|
||||
let err = session.delete_slot("default").await.unwrap_err();
|
||||
println!("删除 default 返回错误: {err}");
|
||||
assert!(matches!(err, agcore::agent::AgentError::Config(_)));
|
||||
|
||||
println!("\n✓ context_slot_demo 完成");
|
||||
}
|
||||
|
||||
/// 检查 Message 是否包含指定文本(提取第一个 Text block)。
|
||||
fn message_contains(msg: &Message, needle: &str) -> bool {
|
||||
use agcore::llm::types::message::ContentBlock;
|
||||
let blocks = match msg {
|
||||
Message::System { content }
|
||||
| Message::User { content }
|
||||
| Message::Assistant { content } => content,
|
||||
Message::UserImage { .. } => return false,
|
||||
Message::ToolResult { content, .. } => content,
|
||||
_ => return false,
|
||||
};
|
||||
for block in blocks {
|
||||
if let ContentBlock::Text { text } = block
|
||||
&& text.contains(needle)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
pub mod agent;
|
||||
pub mod builder;
|
||||
pub mod context;
|
||||
pub mod error;
|
||||
pub mod runtime;
|
||||
pub mod session;
|
||||
@@ -20,6 +21,10 @@ pub mod task;
|
||||
// 重导出公共 API(按使用频度排序)
|
||||
pub use agent::Agent;
|
||||
pub use builder::AgentBuilder;
|
||||
pub use context::{
|
||||
ContextBudget, ContextSlot, DeriveStrategy, FocusedConfig, SlotConfig, SlotMeta, SlotMode,
|
||||
SlotSource,
|
||||
};
|
||||
pub use error::AgentError;
|
||||
pub use runtime::{AgentConfig, RuntimeBundle};
|
||||
pub use session::AgentSession;
|
||||
|
||||
@@ -0,0 +1,900 @@
|
||||
//! ContextSlot —— 多上下文槽位管理。
|
||||
//!
|
||||
//! 设计要点(参见 `docs/17-phase10-contextslot.md`):
|
||||
//!
|
||||
//! - **多上下文分区**:单个 session 内可创建/切换/派生多个独立消息上下文
|
||||
//! - **三种模式**:Full(完整历史)/ Focused(读时过滤)/ Readonly(禁止写入)
|
||||
//! - **三种来源**:New(全新)/ Derived(派生)/ Static(静态)
|
||||
//! - **基于 MemoryStore trait 持久化**:JSON blob 批次存储,每 slot 3-4 条 MemoryItem 记录
|
||||
//! - **零新依赖方向**:放在 `agent/` 下利用已有的 `agent → memory` 依赖
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use time::OffsetDateTime;
|
||||
|
||||
use crate::agent::error::AgentError;
|
||||
use crate::llm::types::message::Message;
|
||||
use crate::memory::store::MemoryStore;
|
||||
use crate::memory::types::{MemoryFilter, MemoryItem};
|
||||
|
||||
/// 上下文槽 —— 一段带策略配置的消息列表。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ContextSlot {
|
||||
/// 当前 slot 的唯一标识(同一个 session_id 内唯一)。
|
||||
pub id: String,
|
||||
/// 所属 session。
|
||||
pub session_id: String,
|
||||
/// 槽配置。
|
||||
pub config: SlotConfig,
|
||||
/// 消息列表(全量,Focused/Readonly 在读取时做策略过滤)。
|
||||
pub messages: Vec<Message>,
|
||||
/// 槽元数据。
|
||||
pub meta: SlotMeta,
|
||||
}
|
||||
|
||||
/// 槽配置。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlotConfig {
|
||||
/// 槽模式(Full / Focused / Readonly)。
|
||||
pub mode: SlotMode,
|
||||
/// 槽来源(New / Derived / Static)。
|
||||
pub source: SlotSource,
|
||||
/// 上下文预算(v0.2 纯数据结构,无消费逻辑)。
|
||||
pub budget: ContextBudget,
|
||||
/// 是否启用自动压缩(v0.2 保留字段,LlmCycle 内部自行判断)。
|
||||
pub compact: bool,
|
||||
}
|
||||
|
||||
impl Default for SlotConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: SlotMode::Full,
|
||||
source: SlotSource::New,
|
||||
budget: ContextBudget::default(),
|
||||
compact: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 槽模式。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub enum SlotMode {
|
||||
/// 完整对话历史(全部消息)。
|
||||
Full,
|
||||
/// 聚焦模式 —— 读取时按策略过滤,保持 LLM 注意力。
|
||||
Focused(FocusedConfig),
|
||||
/// 只读参考上下文 —— 禁止写入。
|
||||
Readonly,
|
||||
}
|
||||
|
||||
/// 聚焦模式配置。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FocusedConfig {
|
||||
/// 是否保留 system prompt。
|
||||
pub keep_system: bool,
|
||||
/// 保留的最近消息条数(以消息条数而非对话轮次为单位,因为一轮对话可能包含多条 tool 消息)。
|
||||
pub recent_messages: usize,
|
||||
/// 摘要覆盖(v0.2 仅消费端:手动设置则注入,不自动生成)。
|
||||
/// v0.3 将支持 Hook 驱动的自动摘要生成。
|
||||
pub summary_override: Option<String>,
|
||||
}
|
||||
|
||||
/// 槽来源。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub enum SlotSource {
|
||||
/// 全新空槽。
|
||||
New,
|
||||
/// 从父 slot 派生(记录 parent_id)。
|
||||
Derived {
|
||||
parent_id: String,
|
||||
strategy: DeriveStrategy,
|
||||
},
|
||||
/// 预置静态消息(不持久化,随 session 生命周期存在)。
|
||||
Static(Vec<Message>),
|
||||
}
|
||||
|
||||
/// 派生策略。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum DeriveStrategy {
|
||||
/// 完整复制父 slot 的消息。
|
||||
Full,
|
||||
/// 按聚焦策略复制父 slot 的消息。
|
||||
Focused(FocusedConfig),
|
||||
}
|
||||
|
||||
/// 上下文预算(v0.2 纯数据结构,无消费逻辑)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContextBudget {
|
||||
/// system prompt 预算。
|
||||
pub system: u32,
|
||||
/// 对话历史预算。
|
||||
pub history: u32,
|
||||
/// 工具定义预算。
|
||||
pub tools: u32,
|
||||
/// 工具结果预算。
|
||||
pub tool_results: u32,
|
||||
/// 预留 buffer。
|
||||
pub reserve: u32,
|
||||
}
|
||||
|
||||
impl Default for ContextBudget {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
system: 8_000,
|
||||
history: 80_000,
|
||||
tools: 10_000,
|
||||
tool_results: 20_000,
|
||||
reserve: 10_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextBudget {
|
||||
/// 自动分配:按上下文窗口的固定比例分配预算。
|
||||
/// v0.2 只做占位实现,v0.3 将根据实际 provider 的 context_window 计算。
|
||||
pub fn auto() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 槽元数据。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SlotMeta {
|
||||
/// 父 slot id(仅 Derived 来源有值)。
|
||||
pub parent_id: Option<String>,
|
||||
/// 消息总数。
|
||||
pub message_count: usize,
|
||||
/// 总 token 估算值(由 add_messages 时累计,v0.2 为近似值)。
|
||||
pub total_tokens: u32,
|
||||
/// 创建时间(Unix 时间戳,秒)。
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
impl SlotMeta {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
parent_id: None,
|
||||
message_count: 0,
|
||||
total_tokens: 0,
|
||||
created_at: std::time::SystemTime::now()
|
||||
.duration_since(std::time::SystemTime::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SlotMeta {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ContextSlot {
|
||||
/// 持久化 key 前缀。
|
||||
const KEY_DATA: &'static str = "slot_data";
|
||||
const KEY_META: &'static str = "slot_meta";
|
||||
const KEY_CONFIG: &'static str = "slot_config";
|
||||
const KEY_REL: &'static str = "slot_rel";
|
||||
|
||||
/// 校验 id 不含冒号(避免破坏 key 格式与 list prefix 过滤)。
|
||||
/// 失败时 panic —— 这是开发者错误而非用户错误。
|
||||
fn assert_no_colon(id: &str, field: &str) {
|
||||
if id.contains(':') {
|
||||
panic!(
|
||||
"{field} '{id}' contains ':' which would break key format. \
|
||||
Use only letters, digits, hyphens and underscores."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn data_key(session_id: &str, slot_id: &str) -> String {
|
||||
Self::assert_no_colon(session_id, "session_id");
|
||||
Self::assert_no_colon(slot_id, "slot_id");
|
||||
format!("{}:{}:{}", Self::KEY_DATA, session_id, slot_id)
|
||||
}
|
||||
pub(crate) fn meta_key(session_id: &str, slot_id: &str) -> String {
|
||||
Self::assert_no_colon(session_id, "session_id");
|
||||
Self::assert_no_colon(slot_id, "slot_id");
|
||||
format!("{}:{}:{}", Self::KEY_META, session_id, slot_id)
|
||||
}
|
||||
pub(crate) fn config_key(session_id: &str, slot_id: &str) -> String {
|
||||
Self::assert_no_colon(session_id, "session_id");
|
||||
Self::assert_no_colon(slot_id, "slot_id");
|
||||
format!("{}:{}:{}", Self::KEY_CONFIG, session_id, slot_id)
|
||||
}
|
||||
pub(crate) fn rel_key(session_id: &str, child_id: &str) -> String {
|
||||
Self::assert_no_colon(session_id, "session_id");
|
||||
Self::assert_no_colon(child_id, "child_id");
|
||||
format!("{}:{}:{}", Self::KEY_REL, session_id, child_id)
|
||||
}
|
||||
|
||||
/// 构造 MemoryItem 的辅助函数。
|
||||
fn make_item(key: String, content: String) -> MemoryItem {
|
||||
MemoryItem {
|
||||
id: key,
|
||||
content,
|
||||
metadata: serde_json::json!({}),
|
||||
created_at: OffsetDateTime::now_utc(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建一个新的空 ContextSlot(不持久化,仅内存构造)。
|
||||
pub fn new(
|
||||
session_id: impl Into<String>,
|
||||
slot_id: impl Into<String>,
|
||||
config: SlotConfig,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: slot_id.into(),
|
||||
session_id: session_id.into(),
|
||||
config,
|
||||
messages: Vec::new(),
|
||||
meta: SlotMeta::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存 slot 数据到存储后端(全量写入,含 config)。
|
||||
pub async fn save(&self, store: &dyn MemoryStore) -> Result<(), AgentError> {
|
||||
let data = serde_json::to_string(&self.messages)
|
||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
let meta = serde_json::to_string(&self.meta)
|
||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
let config = serde_json::to_string(&self.config)
|
||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
|
||||
store
|
||||
.save(Self::make_item(
|
||||
Self::data_key(&self.session_id, &self.id),
|
||||
data,
|
||||
))
|
||||
.await
|
||||
.map_err(AgentError::Memory)?;
|
||||
store
|
||||
.save(Self::make_item(
|
||||
Self::meta_key(&self.session_id, &self.id),
|
||||
meta,
|
||||
))
|
||||
.await
|
||||
.map_err(AgentError::Memory)?;
|
||||
store
|
||||
.save(Self::make_item(
|
||||
Self::config_key(&self.session_id, &self.id),
|
||||
config,
|
||||
))
|
||||
.await
|
||||
.map_err(AgentError::Memory)?;
|
||||
|
||||
// 派生关系
|
||||
if let SlotSource::Derived { parent_id, .. } = &self.config.source {
|
||||
store
|
||||
.save(Self::make_item(
|
||||
Self::rel_key(&self.session_id, &self.id),
|
||||
parent_id.clone(),
|
||||
))
|
||||
.await
|
||||
.map_err(AgentError::Memory)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从存储加载 slot,config 从 `slot_config` key 自行恢复。
|
||||
/// 若 config 记录不存在(旧版本升级场景),使用 `SlotConfig::default()`。
|
||||
pub async fn load(
|
||||
id: &str,
|
||||
session_id: &str,
|
||||
store: &dyn MemoryStore,
|
||||
) -> Result<Option<Self>, AgentError> {
|
||||
let meta_item = store
|
||||
.get(&Self::meta_key(session_id, id))
|
||||
.await
|
||||
.map_err(AgentError::Memory)?;
|
||||
let data_item = store
|
||||
.get(&Self::data_key(session_id, id))
|
||||
.await
|
||||
.map_err(AgentError::Memory)?;
|
||||
let config_item = store
|
||||
.get(&Self::config_key(session_id, id))
|
||||
.await
|
||||
.map_err(AgentError::Memory)?;
|
||||
|
||||
match (meta_item, data_item) {
|
||||
(Some(m), Some(d)) => {
|
||||
let meta: SlotMeta = serde_json::from_str(&m.content)
|
||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
let messages: Vec<Message> = serde_json::from_str(&d.content)
|
||||
.map_err(|e| AgentError::Other(e.to_string()))?;
|
||||
// config 从存储恢复;不存在则使用 default(兼容旧版本)
|
||||
let config = match config_item {
|
||||
Some(c) => serde_json::from_str(&c.content)
|
||||
.map_err(|e| AgentError::Other(e.to_string()))?,
|
||||
None => SlotConfig::default(),
|
||||
};
|
||||
Ok(Some(Self {
|
||||
id: id.to_string(),
|
||||
session_id: session_id.to_string(),
|
||||
config,
|
||||
messages,
|
||||
meta,
|
||||
}))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出某 session 下的所有 slot 元数据。
|
||||
pub async fn list(
|
||||
session_id: &str,
|
||||
store: &dyn MemoryStore,
|
||||
) -> Result<Vec<SlotMeta>, AgentError> {
|
||||
let prefix_str = format!("{}:{}:", Self::KEY_META, session_id);
|
||||
let filter = MemoryFilter {
|
||||
prefix: Some(prefix_str),
|
||||
..Default::default()
|
||||
};
|
||||
let items = store.list(&filter).await.map_err(AgentError::Memory)?;
|
||||
let mut metas = Vec::new();
|
||||
for item in items {
|
||||
if let Ok(meta) = serde_json::from_str::<SlotMeta>(&item.content) {
|
||||
metas.push(meta);
|
||||
}
|
||||
}
|
||||
Ok(metas)
|
||||
}
|
||||
|
||||
/// 删除 slot 的所有存储记录(slot_data + slot_meta + slot_config + slot_rel)。
|
||||
pub async fn delete(
|
||||
id: &str,
|
||||
session_id: &str,
|
||||
store: &dyn MemoryStore,
|
||||
) -> Result<(), AgentError> {
|
||||
store
|
||||
.delete(&Self::data_key(session_id, id))
|
||||
.await
|
||||
.map_err(AgentError::Memory)?;
|
||||
store
|
||||
.delete(&Self::meta_key(session_id, id))
|
||||
.await
|
||||
.map_err(AgentError::Memory)?;
|
||||
store
|
||||
.delete(&Self::config_key(session_id, id))
|
||||
.await
|
||||
.map_err(AgentError::Memory)?;
|
||||
// slot_rel 是 best-effort(仅 Derived 来源的 slot 才有此 key)
|
||||
let _ = store.delete(&Self::rel_key(session_id, id)).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 追加消息(Readonly 模式下返回 `SlotReadonly` 错误)。
|
||||
/// Full / Focused 模式下允许追加。
|
||||
pub fn append_messages(&mut self, new_messages: Vec<Message>) -> Result<(), AgentError> {
|
||||
if matches!(self.config.mode, SlotMode::Readonly) {
|
||||
return Err(AgentError::SlotReadonly(
|
||||
"Readonly slot does not allow writes".into(),
|
||||
));
|
||||
}
|
||||
let count = new_messages.len();
|
||||
self.messages.extend(new_messages);
|
||||
self.meta.message_count += count;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 按 FocusedConfig 过滤消息(静态辅助函数,被 `load_messages` 和 `derive_slot` 复用)。
|
||||
///
|
||||
/// 过滤逻辑:
|
||||
/// 1. 保留第一条 system 消息(如果 `keep_system=true`)
|
||||
/// 2. 取最近 `recent_messages` 条非 system 消息(如果 `recent_messages > 0`)
|
||||
/// 3. 追加摘要消息(如果 `summary_override` 存在)
|
||||
pub fn filter_focused(messages: &[Message], cfg: &FocusedConfig) -> Vec<Message> {
|
||||
let mut result = Vec::new();
|
||||
// 保留 system prompt
|
||||
if cfg.keep_system
|
||||
&& let Some(msg) = messages
|
||||
.iter()
|
||||
.find(|m| matches!(m, Message::System { .. }))
|
||||
{
|
||||
result.push(msg.clone());
|
||||
}
|
||||
// 处理 recent_messages=0 边界:上面已处理 system,下面仅取最近 N 条
|
||||
if cfg.recent_messages > 0 {
|
||||
let recent: Vec<&Message> = messages
|
||||
.iter()
|
||||
.filter(|m| !matches!(m, Message::System { .. }))
|
||||
.collect();
|
||||
let start = recent.len().saturating_sub(cfg.recent_messages);
|
||||
for msg in recent.iter().skip(start) {
|
||||
result.push((*msg).clone());
|
||||
}
|
||||
}
|
||||
// 注入摘要
|
||||
if let Some(summary) = &cfg.summary_override {
|
||||
result.push(Message::system(format!("[上下文摘要] {}", summary)));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// 返回消息列表。Focused 模式下按策略过滤(裁剪到最近 recent_messages 条)。
|
||||
pub fn load_messages(&self) -> Vec<Message> {
|
||||
match &self.config.mode {
|
||||
SlotMode::Focused(cfg) => Self::filter_focused(&self.messages, cfg),
|
||||
_ => self.messages.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::memory::store::InMemoryStore;
|
||||
|
||||
fn make_store() -> std::sync::Arc<dyn MemoryStore> {
|
||||
std::sync::Arc::new(InMemoryStore::new())
|
||||
}
|
||||
|
||||
fn make_slot(id: &str, session: &str) -> ContextSlot {
|
||||
ContextSlot::new(session, id, SlotConfig::default())
|
||||
}
|
||||
|
||||
/// 提取 `Message` 的第一个 Text block 的内容(用于测试断言)。
|
||||
/// 返回 None 表示该消息不含纯文本 block。
|
||||
fn extract_text(msg: &Message) -> &str {
|
||||
use crate::llm::types::message::ContentBlock;
|
||||
let blocks = match msg {
|
||||
Message::System { content }
|
||||
| Message::User { content }
|
||||
| Message::Assistant { content } => content,
|
||||
Message::UserImage { .. } => return "",
|
||||
Message::ToolResult { content, .. } => content,
|
||||
};
|
||||
for block in blocks {
|
||||
if let ContentBlock::Text { text } = block {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
""
|
||||
}
|
||||
|
||||
// ===== 持久化 =====
|
||||
|
||||
#[tokio::test]
|
||||
async fn slot_save_load_roundtrip() {
|
||||
let store = make_store();
|
||||
let mut slot = make_slot("default", "s1");
|
||||
slot.append_messages(vec![Message::user_text("hi")]).unwrap();
|
||||
slot.append_messages(vec![Message::assistant("hello")]).unwrap();
|
||||
|
||||
slot.save(&*store).await.unwrap();
|
||||
let loaded = ContextSlot::load("default", "s1", &*store).await.unwrap();
|
||||
let loaded = loaded.expect("slot should exist after save");
|
||||
assert_eq!(loaded.id, "default");
|
||||
assert_eq!(loaded.session_id, "s1");
|
||||
assert_eq!(loaded.messages.len(), 2);
|
||||
assert_eq!(loaded.meta.message_count, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slot_session_isolation() {
|
||||
let store = make_store();
|
||||
let mut a = make_slot("main", "sA");
|
||||
a.append_messages(vec![Message::user_text("only in A")])
|
||||
.unwrap();
|
||||
a.save(&*store).await.unwrap();
|
||||
|
||||
let mut b = make_slot("main", "sB");
|
||||
b.append_messages(vec![Message::user_text("only in B")])
|
||||
.unwrap();
|
||||
b.save(&*store).await.unwrap();
|
||||
|
||||
let loaded_a = ContextSlot::load("main", "sA", &*store).await.unwrap().unwrap();
|
||||
let loaded_b = ContextSlot::load("main", "sB", &*store).await.unwrap().unwrap();
|
||||
assert_eq!(extract_text(&loaded_a.messages[0]), "only in A");
|
||||
assert_eq!(extract_text(&loaded_b.messages[0]), "only in B");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slot_derived_parent_id_recorded() {
|
||||
let store = make_store();
|
||||
let slot = ContextSlot::new(
|
||||
"s1",
|
||||
"child",
|
||||
SlotConfig {
|
||||
mode: SlotMode::Full,
|
||||
source: SlotSource::Derived {
|
||||
parent_id: "default".to_string(),
|
||||
strategy: DeriveStrategy::Full,
|
||||
},
|
||||
budget: ContextBudget::default(),
|
||||
compact: true,
|
||||
},
|
||||
);
|
||||
slot.save(&*store).await.unwrap();
|
||||
|
||||
// rel_key 直接读
|
||||
let rel = store
|
||||
.get(&ContextSlot::rel_key("s1", "child"))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(rel.content, "default");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slot_readonly_rejects_write() {
|
||||
let mut slot = make_slot("ro", "s1");
|
||||
slot.config.mode = SlotMode::Readonly;
|
||||
let result = slot.append_messages(vec![Message::user_text("nope")]);
|
||||
assert!(matches!(result, Err(AgentError::SlotReadonly(_))));
|
||||
assert!(slot.messages.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slot_delete_then_load_none() {
|
||||
let store = make_store();
|
||||
let mut slot = make_slot("to_delete", "s1");
|
||||
slot.append_messages(vec![Message::user_text("hi")]).unwrap();
|
||||
slot.save(&*store).await.unwrap();
|
||||
|
||||
ContextSlot::delete("to_delete", "s1", &*store).await.unwrap();
|
||||
let loaded = ContextSlot::load("to_delete", "s1", &*store).await.unwrap();
|
||||
assert!(loaded.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slot_list_multiple() {
|
||||
let store = make_store();
|
||||
for id in ["alpha", "beta", "gamma"] {
|
||||
let mut s = make_slot(id, "sX");
|
||||
s.append_messages(vec![Message::user_text(id)]).unwrap();
|
||||
s.save(&*store).await.unwrap();
|
||||
}
|
||||
// 不同 session 不该列出
|
||||
let mut s2 = make_slot("alpha", "sY");
|
||||
s2.append_messages(vec![Message::user_text("y")]).unwrap();
|
||||
s2.save(&*store).await.unwrap();
|
||||
|
||||
let metas = ContextSlot::list("sX", &*store).await.unwrap();
|
||||
assert_eq!(metas.len(), 3);
|
||||
let metas_y = ContextSlot::list("sY", &*store).await.unwrap();
|
||||
assert_eq!(metas_y.len(), 1);
|
||||
}
|
||||
|
||||
// ===== Focused 模式 =====
|
||||
|
||||
#[tokio::test]
|
||||
async fn slot_focused_recent_messages() {
|
||||
let mut slot = make_slot("f", "s1");
|
||||
slot.append_messages(vec![Message::system("sys")]).unwrap();
|
||||
for i in 0..5 {
|
||||
slot.append_messages(vec![Message::user_text(format!("u{i}"))])
|
||||
.unwrap();
|
||||
slot.append_messages(vec![Message::assistant(format!("a{i}"))])
|
||||
.unwrap();
|
||||
}
|
||||
slot.config.mode = SlotMode::Focused(FocusedConfig {
|
||||
keep_system: true,
|
||||
recent_messages: 3,
|
||||
summary_override: None,
|
||||
});
|
||||
|
||||
let loaded = slot.load_messages();
|
||||
// system + 最近 3 条 (assistant 4, user 4, assistant 5 实际是按 vec 顺序取最近 3 条非 system)
|
||||
let has_sys = loaded.iter().any(|m| matches!(m, Message::System { .. }));
|
||||
assert!(has_sys, "system 提示应保留");
|
||||
// 最近 3 条非 system 应该是 a4, u4, a5 (按 messages 存储顺序的最后 3 条)
|
||||
assert_eq!(loaded.len(), 1 + 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slot_focused_summary_override() {
|
||||
let mut slot = make_slot("f", "s1");
|
||||
slot.append_messages(vec![Message::user_text("u")]).unwrap();
|
||||
slot.append_messages(vec![Message::assistant("a")]).unwrap();
|
||||
slot.config.mode = SlotMode::Focused(FocusedConfig {
|
||||
keep_system: false,
|
||||
recent_messages: 100,
|
||||
summary_override: Some("讨论了 X".to_string()),
|
||||
});
|
||||
|
||||
let loaded = slot.load_messages();
|
||||
// 2 条原始 + 1 条摘要 system = 3
|
||||
assert_eq!(loaded.len(), 3);
|
||||
// 最后一条是摘要
|
||||
if let Message::System { content } = &loaded[2] {
|
||||
let text = format!("{:?}", content);
|
||||
assert!(text.contains("上下文摘要"));
|
||||
assert!(text.contains("讨论了 X"));
|
||||
} else {
|
||||
panic!("最后一条应为 system 摘要");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slot_focused_zero_messages() {
|
||||
let mut slot = make_slot("f", "s1");
|
||||
slot.append_messages(vec![Message::system("sys")]).unwrap();
|
||||
slot.append_messages(vec![Message::user_text("u")]).unwrap();
|
||||
slot.config.mode = SlotMode::Focused(FocusedConfig {
|
||||
keep_system: true,
|
||||
recent_messages: 0,
|
||||
summary_override: None,
|
||||
});
|
||||
|
||||
let loaded = slot.load_messages();
|
||||
// recent_messages=0 但 keep_system=true 应只含 system
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert!(matches!(loaded[0], Message::System { .. }));
|
||||
}
|
||||
|
||||
// ===== 边界 =====
|
||||
|
||||
#[tokio::test]
|
||||
async fn slot_empty_messages_roundtrip() {
|
||||
let store = make_store();
|
||||
let slot = make_slot("empty", "s1");
|
||||
slot.save(&*store).await.unwrap();
|
||||
let loaded = ContextSlot::load("empty", "s1", &*store).await.unwrap().unwrap();
|
||||
assert!(loaded.messages.is_empty());
|
||||
assert_eq!(loaded.meta.message_count, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn slot_save_on_readonly_side_effect() {
|
||||
let store = make_store();
|
||||
let mut slot = make_slot("ro", "s1");
|
||||
slot.config.mode = SlotMode::Readonly;
|
||||
// save 本身允许(只禁止 append)
|
||||
slot.save(&*store).await.unwrap();
|
||||
let loaded = ContextSlot::load("ro", "s1", &*store).await.unwrap();
|
||||
assert!(loaded.is_some());
|
||||
}
|
||||
|
||||
// ===== 派生 (derive_slot 行为) =====
|
||||
|
||||
#[tokio::test]
|
||||
async fn derive_full_copies_parent_messages() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
for i in 0..3 {
|
||||
parent.append_messages(vec![Message::user_text(format!("u{i}"))])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 模拟 derive_slot 内部 Full 策略
|
||||
let child_messages = parent.messages.clone();
|
||||
let child = ContextSlot::new(
|
||||
"s1",
|
||||
"c",
|
||||
SlotConfig {
|
||||
mode: SlotMode::Full,
|
||||
source: SlotSource::Derived {
|
||||
parent_id: "p".to_string(),
|
||||
strategy: DeriveStrategy::Full,
|
||||
},
|
||||
budget: ContextBudget::default(),
|
||||
compact: true,
|
||||
},
|
||||
);
|
||||
let mut child = child;
|
||||
child.messages = child_messages;
|
||||
let store = make_store();
|
||||
child.save(&*store).await.unwrap();
|
||||
|
||||
let loaded = ContextSlot::load("c", "s1", &*store).await.unwrap().unwrap();
|
||||
assert_eq!(loaded.messages.len(), 3);
|
||||
assert!(matches!(loaded.config.source, SlotSource::Derived { .. }));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn derive_focused_filters_parent_messages() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::system("sys")]).unwrap();
|
||||
for i in 0..5 {
|
||||
parent.append_messages(vec![Message::user_text(format!("u{i}"))])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// 模拟 derive_slot 内部 Focused 策略:按 FocusedConfig 过滤
|
||||
let cfg = FocusedConfig {
|
||||
keep_system: true,
|
||||
recent_messages: 2,
|
||||
summary_override: None,
|
||||
};
|
||||
// 应用 load_messages 同样的过滤
|
||||
let mut filtered = Vec::new();
|
||||
if cfg.keep_system
|
||||
&& let Some(m) = parent
|
||||
.messages
|
||||
.iter()
|
||||
.find(|m| matches!(m, Message::System { .. }))
|
||||
{
|
||||
filtered.push(m.clone());
|
||||
}
|
||||
if cfg.recent_messages > 0 {
|
||||
let recent: Vec<&Message> = parent
|
||||
.messages
|
||||
.iter()
|
||||
.filter(|m| !matches!(m, Message::System { .. }))
|
||||
.collect();
|
||||
let start = recent.len().saturating_sub(cfg.recent_messages);
|
||||
for m in recent.iter().skip(start) {
|
||||
filtered.push((*m).clone());
|
||||
}
|
||||
}
|
||||
|
||||
assert_eq!(filtered.len(), 1 + 2); // system + 2 条
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn derived_slot_loadable_independently() {
|
||||
let store = make_store();
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::user_text("u")]).unwrap();
|
||||
parent.save(&*store).await.unwrap();
|
||||
|
||||
// 派生 child
|
||||
let mut child = ContextSlot::new(
|
||||
"s1",
|
||||
"c",
|
||||
SlotConfig {
|
||||
mode: SlotMode::Full,
|
||||
source: SlotSource::Derived {
|
||||
parent_id: "p".to_string(),
|
||||
strategy: DeriveStrategy::Full,
|
||||
},
|
||||
budget: ContextBudget::default(),
|
||||
compact: true,
|
||||
},
|
||||
);
|
||||
child.append_messages(vec![Message::user_text("derived msg")])
|
||||
.unwrap();
|
||||
child.save(&*store).await.unwrap();
|
||||
|
||||
// child 可独立加载
|
||||
let loaded = ContextSlot::load("c", "s1", &*store).await.unwrap().unwrap();
|
||||
assert_eq!(loaded.messages.len(), 1);
|
||||
assert_eq!(extract_text(&loaded.messages[0]), "derived msg");
|
||||
}
|
||||
|
||||
// ===== delete 保护 (AgentSession 层,但 ContextSlot.delete 不保护;逻辑测试在 session.rs) =====
|
||||
|
||||
#[tokio::test]
|
||||
async fn slot_delete_cleans_all_records() {
|
||||
let store = make_store();
|
||||
let mut slot = ContextSlot::new(
|
||||
"s1",
|
||||
"x",
|
||||
SlotConfig {
|
||||
mode: SlotMode::Full,
|
||||
source: SlotSource::Derived {
|
||||
parent_id: "p".to_string(),
|
||||
strategy: DeriveStrategy::Full,
|
||||
},
|
||||
budget: ContextBudget::default(),
|
||||
compact: true,
|
||||
},
|
||||
);
|
||||
slot.append_messages(vec![Message::user_text("u")]).unwrap();
|
||||
slot.save(&*store).await.unwrap();
|
||||
|
||||
// 确认所有记录存在
|
||||
assert!(store.get(&ContextSlot::data_key("s1", "x")).await.unwrap().is_some());
|
||||
assert!(store.get(&ContextSlot::meta_key("s1", "x")).await.unwrap().is_some());
|
||||
assert!(store.get(&ContextSlot::config_key("s1", "x")).await.unwrap().is_some());
|
||||
assert!(store.get(&ContextSlot::rel_key("s1", "x")).await.unwrap().is_some());
|
||||
|
||||
ContextSlot::delete("x", "s1", &*store).await.unwrap();
|
||||
|
||||
// data/meta/config 已删
|
||||
assert!(store.get(&ContextSlot::data_key("s1", "x")).await.unwrap().is_none());
|
||||
assert!(store.get(&ContextSlot::meta_key("s1", "x")).await.unwrap().is_none());
|
||||
assert!(store.get(&ContextSlot::config_key("s1", "x")).await.unwrap().is_none());
|
||||
}
|
||||
|
||||
// ===== 基础类型测试 =====
|
||||
|
||||
#[test]
|
||||
fn slot_meta_new_sets_zero_message_count() {
|
||||
let m = SlotMeta::new();
|
||||
assert_eq!(m.message_count, 0);
|
||||
assert_eq!(m.total_tokens, 0);
|
||||
assert!(m.parent_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn context_budget_default_sum_128k() {
|
||||
let b = ContextBudget::default();
|
||||
assert_eq!(b.system + b.history + b.tools + b.tool_results + b.reserve, 128_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slot_config_default_is_full_new() {
|
||||
let c = SlotConfig::default();
|
||||
assert!(matches!(c.mode, SlotMode::Full));
|
||||
assert!(matches!(c.source, SlotSource::New));
|
||||
assert!(c.compact);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn focused_config_serializes_roundtrip() {
|
||||
let cfg = FocusedConfig {
|
||||
keep_system: true,
|
||||
recent_messages: 5,
|
||||
summary_override: Some("sum".into()),
|
||||
};
|
||||
let json = serde_json::to_string(&cfg).unwrap();
|
||||
let back: FocusedConfig = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back.recent_messages, 5);
|
||||
assert_eq!(back.summary_override.as_deref(), Some("sum"));
|
||||
}
|
||||
|
||||
// ====== filter_focused 静态方法(被 load_messages 和 derive_slot 复用) ======
|
||||
|
||||
#[test]
|
||||
fn filter_focused_keeps_system_and_recent() {
|
||||
let mut messages = vec![Message::system("sys")];
|
||||
for i in 0..5 {
|
||||
messages.push(Message::user_text(format!("u{i}")));
|
||||
messages.push(Message::assistant(format!("a{i}")));
|
||||
}
|
||||
let cfg = FocusedConfig {
|
||||
keep_system: true,
|
||||
recent_messages: 3,
|
||||
summary_override: None,
|
||||
};
|
||||
let filtered = ContextSlot::filter_focused(&messages, &cfg);
|
||||
// system + 3 条最近的非 system 消息
|
||||
assert_eq!(filtered.len(), 1 + 3);
|
||||
assert!(matches!(filtered[0], Message::System { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_focused_injects_summary() {
|
||||
let messages = vec![
|
||||
Message::user_text("u"),
|
||||
Message::assistant("a"),
|
||||
];
|
||||
let cfg = FocusedConfig {
|
||||
keep_system: false,
|
||||
recent_messages: 100,
|
||||
summary_override: Some("讨论了 X".into()),
|
||||
};
|
||||
let filtered = ContextSlot::filter_focused(&messages, &cfg);
|
||||
// 2 条原始 + 1 条摘要 system
|
||||
assert_eq!(filtered.len(), 3);
|
||||
if let Message::System { content } = &filtered[2] {
|
||||
let text = format!("{:?}", content);
|
||||
assert!(text.contains("上下文摘要"));
|
||||
} else {
|
||||
panic!("最后一条应为 system 摘要");
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Colon 校验(key 格式保护) ======
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "session_id 's:1' contains ':'")]
|
||||
fn key_constructor_rejects_colon_in_session_id() {
|
||||
// 通过 make_slot 间接调用 slot.save 时会触发 data_key -> assert_no_colon
|
||||
let store = make_store();
|
||||
let slot = ContextSlot::new("s:1", "default", SlotConfig::default());
|
||||
let _ = tokio_test_runtime(slot.save(&*store));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "slot_id 'a:b' contains ':'")]
|
||||
fn key_constructor_rejects_colon_in_slot_id() {
|
||||
let store = make_store();
|
||||
let slot = ContextSlot::new("s1", "a:b", SlotConfig::default());
|
||||
let _ = tokio_test_runtime(slot.save(&*store));
|
||||
}
|
||||
|
||||
/// 在同步测试中运行 future 的辅助函数。
|
||||
fn tokio_test_runtime<F: std::future::Future>(f: F) -> F::Output {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(f)
|
||||
}
|
||||
}
|
||||
+53
-3
@@ -36,6 +36,18 @@ pub enum AgentError {
|
||||
#[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),
|
||||
@@ -60,6 +72,7 @@ impl AgentError {
|
||||
/// - `Tool`:由内层 `is_recoverable()` 决定
|
||||
/// - `HookBlocked` / `LimitExceeded`:不可恢复(需人工介入或终止循环)
|
||||
/// - `Config` / `Other`:不可恢复
|
||||
/// - `SlotReadonly` / `SlotNotFound` / `SlotAlreadyExists`:不可恢复(结构性错误)
|
||||
pub fn is_recoverable(&self) -> bool {
|
||||
match self {
|
||||
Self::Llm(e) => matches!(
|
||||
@@ -69,9 +82,13 @@ impl AgentError {
|
||||
Self::Tool(e) => e.is_recoverable(),
|
||||
Self::Memory(e) => e.is_recoverable(),
|
||||
Self::PlanParse(_) => false,
|
||||
Self::HookBlocked(_) | Self::LimitExceeded(_) | Self::Config(_) | Self::Other(_) => {
|
||||
false
|
||||
}
|
||||
Self::SlotReadonly(_)
|
||||
| Self::SlotNotFound(_)
|
||||
| Self::SlotAlreadyExists(_)
|
||||
| Self::HookBlocked(_)
|
||||
| Self::LimitExceeded(_)
|
||||
| Self::Config(_)
|
||||
| Self::Other(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,4 +198,37 @@ mod tests {
|
||||
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' 已存在"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+644
-207
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user