feat(engine): 实现 Agent 执行引擎(SessionManager + Checkpointer + SessionSnapshot)

Phase 17 主体交付:解决 v0.2 中 session 在变量里、无父子关系、无 checkpoint、
不可序列化的空白。

新增模块 src/engine/(5 文件,约 1300 行纯实现 + 21 个内联测试):

- error.rs(46 行)—— EngineError 枚举(6 变体)
  - SessionNotFound / SessionAlreadyExists / CheckpointNotFound
  - Memory(#[from] MemoryError) 透传(与 AgentError 风格一致)
  - Serialization / Agent(#[from] AgentError)

- snapshot.rs(36 行)—— SessionSnapshot + SessionMemoryEntry
  - 独立 struct 避开 Arc<dyn Agent> 不可序列化限制
  - SessionMemoryEntry 保留 value/metadata/created_at 完整信息
  - 所有字段 #[serde(default)] 宽松反序列化保证前向兼容

- checkpointer.rs(377 行)—— Time-travel 检查点管理器
  - checkpoint() / rollback_load() / list_checkpoints() / delete_all() / latest_snapshot()
  - 存储 key:ckpt:{session_id}:{ckpt_id}
  - ckpt_id 纳秒+计数器(无外部依赖,ponytail)
  - CkptMeta.created_at_nanos 字段确保同秒内精确降序
  - 6 个内联测试覆盖 roundtrip / 不存在 ckpt / 降序排序 / delete_all 幂等 /
    latest_snapshot / 跨 session 隔离

- session_manager.rs(906 行)—— SessionManager 会话树管理
  - 内部 RwLock<HashMap> + Arc<tokio::sync::Mutex<AgentSession>> 双重锁
  - create / create_child / get / recover / replace / children / parent /
    destroy / submit_turn / submit_turn_stream / finalize_turn_stream 共 11 个公开方法
  - SessionManagerConfig.auto_checkpoint 默认 true(同步写入 +
    tracing::error! 失败不阻断主流程,不提供强持久化保证)
  - 孤儿策略:destroy 不递归删除子 session;父被销毁后 parent() 返回 None
  - create_child 限制:父 session 必须先 get/recover 到内存(bundle 不可序列化)
  - 15 个内联测试覆盖 CRUD / recover / replace / 树形 / 孤儿 / auto_checkpoint 开关 /
    序列化兼容性 / 幂等 / 10 并发创建

- mod.rs(20 行)—— 统一 pub use 重导出 EngineError / SessionManager /
  SessionManagerConfig / Checkpointer / CkptMeta / SessionSnapshot /
  SessionMemoryEntry

AgentSession 扩展(src/agent/session.rs,+152 行):
- to_snapshot() pub async —— 从 MemoryStore 拍平 session_memory 全量数据
- from_snapshot() pub fn Result —— 纯同步构造器(pending_memory_restore 暂存)
- restore_memory() pub async &mut self —— 写回持久层并清空 pending
- has_pending_memory_restore() —— 查询 pending 状态
- pub(crate) fn bundle() —— accessor 供 SessionManager::create_child 继承

SessionMemory 扩展(src/agent/session_memory.rs,+57 行):
- list_entries() —— 返回 Vec<(key, value, metadata, created_at_unix_secs)>
- set_with_meta() —— 保留 metadata/created_at 写入(供 restore_memory 完整恢复)

src/lib.rs —— pub mod engine 声明

examples/engine_demo.rs(+251 行):
端到端演示 create → submit_turn → checkpoint → list_checkpoints →
rollback_load → from_snapshot → restore_memory → replace → destroy 完整链路,
含 rollback 一致性 assert(turn_index 和 cost 恢复到 checkpoint 时刻)。

零新外部依赖(serde_json 已有)。全量 353 → 374(+21 新测试)。
clippy 0 警告,doc 0 warning,example exit 0。

Phase 17: Agent 执行引擎 — Step 2-7(合并提交)
This commit is contained in:
徐涛
2026-07-15 08:59:46 +08:00
parent 1d51dcdfe0
commit 34eec9f546
9 changed files with 1845 additions and 2 deletions
+152
View File
@@ -25,6 +25,8 @@ use crate::agent::error::AgentError;
use crate::agent::runtime::RuntimeBundle;
use crate::agent::session_memory::SessionMemory;
use crate::agent::summary::{format_messages_as_text, SummaryConfig};
use crate::engine::snapshot::{SessionMemoryEntry, SessionSnapshot};
use crate::engine::EngineError;
use crate::llm::cycle::{CostTracker, CycleConfig, LlmCycle};
use crate::llm::error::LlmError;
use crate::llm::hooks::{HookContext, HookEvent};
@@ -61,6 +63,11 @@ pub struct AgentSession {
/// Phase 16 新增:上次摘要生成时的 `turn_index`(用于 `debounce_turns` 防抖)。
/// `None` 表示从未生成过摘要(首次触发不受防抖约束)。
last_summary_turn: Option<u32>,
/// Phase 17 新增:`from_snapshot()` 后暂存的待写回条目。
/// `None` 表示无 pending restore(正常状态)。
/// 调用 `restore_memory()` 后会被消费并设为 `None`。
/// 这是 transient state,不参与序列化(AgentSession 本身不 derive Serialize)。
pending_memory_restore: Option<HashMap<String, SessionMemoryEntry>>,
}
impl std::fmt::Debug for AgentSession {
@@ -120,6 +127,7 @@ impl AgentSession {
slots,
current_slot_id: "default".to_string(),
last_summary_turn: None,
pending_memory_restore: None,
}
}
@@ -138,6 +146,11 @@ impl AgentSession {
&self.session_memory
}
/// RuntimeBundle 引用(Phase 17 新增,供 SessionManager::create_child 继承父 bundle)。
pub(crate) fn bundle(&self) -> &Arc<RuntimeBundle> {
&self.bundle
}
/// 写入一条会话级数据(覆盖同名 key)。
pub async fn set_session_data(
&mut self,
@@ -479,6 +492,145 @@ impl AgentSession {
Ok(())
}
// ====== Phase 17: 快照序列化 ======
/// 将当前状态拍平为 `SessionSnapshot`。
///
/// **需要 async**:因为 `session_memory` 的条目存储在 `MemoryStore` 中,读取需异步 I/O。
/// 通过 `SessionMemory::list_entries()` 获取完整条目(保留 `metadata` 和 `created_at`)。
///
/// `Arc<dyn Agent>` 和 `Arc<RuntimeBundle>` **不进入快照**——由 `from_snapshot()` 调用方注入。
pub async fn to_snapshot(&self) -> SessionSnapshot {
// 拍平 session_memory → HashMap<String, SessionMemoryEntry>
// 失败时回退到空 map(错误已记录,不阻断 checkpoint 主流程)。
let session_memory_data = match self.session_memory.list_entries().await {
Ok(entries) => entries
.into_iter()
.map(|(key, value, metadata, created_at)| {
(
key,
SessionMemoryEntry {
value,
metadata,
created_at: Some(created_at),
},
)
})
.collect(),
Err(e) => {
tracing::error!("session_memory list_entries failed: {}", e);
HashMap::new()
}
};
SessionSnapshot {
session_id: self.session_id.clone(),
agent_name: self.agent.name().to_string(),
turn_index: self.turn_index,
cost_so_far: self.cost_so_far.clone(),
slots: self.slots.clone(),
current_slot_id: self.current_slot_id.clone(),
last_summary_turn: self.last_summary_turn,
session_memory_data,
}
}
/// 从 `SessionSnapshot` + agent + bundle **纯同步**重建 `AgentSession`。
///
/// **不执行任何 I/O**`session_memory_data` 暂存于 `pending_memory_restore` 字段,
/// 由调用方显式 `await session.restore_memory()` 写回持久层。
///
/// 调用方负责提供与 `snapshot.agent_name` 对应的 `Arc<dyn Agent>`(引擎层只保留名字做调试用)。
pub fn from_snapshot(
snapshot: SessionSnapshot,
agent: Arc<dyn Agent>,
bundle: Arc<RuntimeBundle>,
) -> Result<Self, EngineError> {
// 校验 bundle 的 session_memory_backend 与 snapshot 兼容
// v0.3 不强制同 backend——以新构造的 session_memory 所属 backend 为准)
let backend = bundle
.session_memory_backend
.clone()
.unwrap_or_else(|| Arc::new(InMemoryStore::new()));
let session_memory = SessionMemory::new(backend, &snapshot.session_id);
// 解析 agent_name 仅供调试(不强制匹配,因为不同进程的 Agent 实现可能不同)
let _ = snapshot.agent_name.as_str();
// 确保至少有一个 slot(与 new() 行为一致)
let mut slots = snapshot.slots;
if slots.is_empty() {
slots.insert(
"default".to_string(),
ContextSlot::new(
&snapshot.session_id,
"default",
SlotConfig::default(),
),
);
}
Ok(Self {
session_id: snapshot.session_id,
agent,
bundle,
turn_index: snapshot.turn_index,
cost_so_far: snapshot.cost_so_far,
session_memory,
slots,
current_slot_id: snapshot.current_slot_id,
last_summary_turn: snapshot.last_summary_turn,
pending_memory_restore: if snapshot.session_memory_data.is_empty() {
None
} else {
Some(snapshot.session_memory_data)
},
})
}
/// 将 `from_snapshot()` 暂存的 `session_memory_data` 写回 `SessionMemory` 持久层。
///
/// **从 `from_snapshot()` 中剥离的异步操作**:确保构造函数是纯同步的。
/// 调用方在 `from_snapshot()` 后显式 `await`。
///
/// **错误处理**:逐条写入。某条失败时返回 `Err` 但**不回滚**已写入条目。
/// 调用方可选择重试或忽略——不影响 AgentSession 内存状态。
///
/// **幂等性**:重复调用安全(首次成功后 `pending_memory_restore` 已被设为 `None`
/// 第二次调用立即返回 `Ok(())`)。
///
/// **完整恢复**:使用 `SessionMemory::set_with_meta()` 保留原始 `metadata` 和 `created_at`
/// ——不像 `set()` 会清空 metadata 并把 created_at 设为当前时间。
pub async fn restore_memory(&mut self) -> Result<(), EngineError> {
// 取出 pending 并立即清空(避免重复 restore 时二次写入;幂等性保证)
let entries = self.pending_memory_restore.take();
let entries = match entries {
Some(m) if !m.is_empty() => m,
_ => return Ok(()), // 无 pending 或已被清空 → 立即返回
};
for (key, entry) in entries {
self.session_memory
.set_with_meta(
&key,
&entry.value,
entry.metadata.clone(),
entry.created_at,
)
.await
.map_err(EngineError::Agent)?;
}
Ok(())
}
/// 是否有待写回的 `session_memory_data``from_snapshot()` 后尚未 `restore_memory()`)。
pub fn has_pending_memory_restore(&self) -> bool {
self.pending_memory_restore
.as_ref()
.map(|m| !m.is_empty())
.unwrap_or(false)
}
// ====== Phase 16: 摘要自动生成 ======
/// 读取 SessionMemory 中最新的对话摘要(`None` 表示从未生成过)。