feat(agent): 实现 ContextSlot fork/merge
- 新增 MergeStrategy 枚举(#[non_exhaustive],为 Phase 16 Summarize 预留) - 新增 ContextSlot::fork() 从父 slot 派生独立子 slot(Full / Focused 策略) - 新增 ContextSlot::merge() 将子 slot 消息合入父 slot(Append / Replace 策略) - 防御性检查:禁止 self-merge、跨 session merge、合并到 Readonly slot - 重构 AgentSession::derive_slot 复用 fork() 消除重复代码 - 新增 9 个内联测试覆盖 fork/merge happy path 与 error path - agent.rs 追加 MergeStrategy re-export(agcore::agent::MergeStrategy 路径可用)
This commit is contained in:
+2
-2
@@ -22,8 +22,8 @@ pub mod task;
|
||||
pub use agent::Agent;
|
||||
pub use builder::AgentBuilder;
|
||||
pub use context::{
|
||||
ContextBudget, ContextSlot, DeriveStrategy, FocusedConfig, SlotConfig, SlotMeta, SlotMode,
|
||||
SlotSource,
|
||||
ContextBudget, ContextSlot, DeriveStrategy, FocusedConfig, MergeStrategy, SlotConfig,
|
||||
SlotMeta, SlotMode, SlotSource,
|
||||
};
|
||||
pub use error::AgentError;
|
||||
pub use runtime::{AgentConfig, RuntimeBundle};
|
||||
|
||||
@@ -103,6 +103,18 @@ pub enum DeriveStrategy {
|
||||
Focused(FocusedConfig),
|
||||
}
|
||||
|
||||
/// 合并策略 —— Phase 13 新增,控制 `ContextSlot::merge` 如何将子 slot 消息合入父 slot。
|
||||
///
|
||||
/// `#[non_exhaustive]` 允许 Phase 16 加入 `Summarize` 变体而不破坏现有匹配。
|
||||
#[derive(Debug, Clone)]
|
||||
#[non_exhaustive]
|
||||
pub enum MergeStrategy {
|
||||
/// 子 slot 消息追加到父 slot 末尾。
|
||||
Append,
|
||||
/// 用子 slot 消息替换父 slot 内容。
|
||||
Replace,
|
||||
}
|
||||
|
||||
/// 上下文预算(v0.2 纯数据结构,无消费逻辑)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ContextBudget {
|
||||
@@ -422,6 +434,82 @@ impl ContextSlot {
|
||||
_ => self.messages.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从当前 slot 派生出独立的子 slot(不持久化,调用方负责 `save`)。
|
||||
///
|
||||
/// 子 slot 的 `meta` 全新创建(`SlotMeta::new()`),不继承父 slot 的 `message_count`。
|
||||
/// 子 slot 的 `source` 标记为 `Derived { parent_id, strategy }`,血缘可追溯。
|
||||
pub fn fork(&self, child_id: String, strategy: DeriveStrategy) -> ContextSlot {
|
||||
let messages = match &strategy {
|
||||
DeriveStrategy::Full => self.messages.clone(),
|
||||
DeriveStrategy::Focused(cfg) => Self::filter_focused(&self.messages, cfg),
|
||||
};
|
||||
tracing::debug!(
|
||||
parent_id = %self.id,
|
||||
child_id = %child_id,
|
||||
?strategy,
|
||||
"ContextSlot::fork"
|
||||
);
|
||||
ContextSlot {
|
||||
id: child_id,
|
||||
session_id: self.session_id.clone(),
|
||||
config: SlotConfig {
|
||||
mode: match &strategy {
|
||||
DeriveStrategy::Full => SlotMode::Full,
|
||||
DeriveStrategy::Focused(cfg) => SlotMode::Focused(cfg.clone()),
|
||||
},
|
||||
source: SlotSource::Derived {
|
||||
parent_id: self.id.clone(),
|
||||
strategy,
|
||||
},
|
||||
budget: self.config.budget.clone(),
|
||||
compact: self.config.compact,
|
||||
},
|
||||
messages,
|
||||
meta: SlotMeta::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 将子 slot 的消息合并到当前 slot。
|
||||
///
|
||||
/// **注意**:本方法仅操作内存数据,不自动持久化。
|
||||
/// 调用方需在 merge 后自行调用 `self.save(&store)` 将结果写入后端存储。
|
||||
///
|
||||
/// 防御性检查:
|
||||
/// - 禁止 self-merge(`self.id == child.id`)
|
||||
/// - 禁止跨 session merge
|
||||
/// - 禁止合并到 Readonly slot
|
||||
pub fn merge(&mut self, child: ContextSlot, strategy: MergeStrategy) -> Result<(), AgentError> {
|
||||
if self.id == child.id {
|
||||
return Err(AgentError::Config("不能将 slot 合并到自身".into()));
|
||||
}
|
||||
if self.session_id != child.session_id {
|
||||
return Err(AgentError::Config("不能合并不同 session 的 slot".into()));
|
||||
}
|
||||
if matches!(self.config.mode, SlotMode::Readonly) {
|
||||
return Err(AgentError::SlotReadonly("Readonly slot 不允许合并".into()));
|
||||
}
|
||||
|
||||
tracing::debug!(
|
||||
self_id = %self.id,
|
||||
child_id = %child.id,
|
||||
?strategy,
|
||||
"ContextSlot::merge"
|
||||
);
|
||||
|
||||
match strategy {
|
||||
MergeStrategy::Append => {
|
||||
let count = child.messages.len();
|
||||
self.messages.extend(child.messages);
|
||||
self.meta.message_count += count;
|
||||
}
|
||||
MergeStrategy::Replace => {
|
||||
self.messages = child.messages;
|
||||
self.meta.message_count = self.messages.len();
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -870,6 +958,141 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
// ====== Phase 13: fork/merge ======
|
||||
|
||||
#[test]
|
||||
fn fork_full_copies_messages() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::user_text("a")]).unwrap();
|
||||
parent.append_messages(vec![Message::assistant("b")]).unwrap();
|
||||
let child = parent.fork("c".into(), DeriveStrategy::Full);
|
||||
assert_eq!(child.messages.len(), 2);
|
||||
assert!(matches!(child.config.mode, SlotMode::Full));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_focused_filters_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();
|
||||
}
|
||||
let cfg = FocusedConfig {
|
||||
keep_system: true,
|
||||
recent_messages: 2,
|
||||
summary_override: None,
|
||||
};
|
||||
let child = parent.fork("c".into(), DeriveStrategy::Focused(cfg));
|
||||
// system + 最近 2 条非 system
|
||||
assert_eq!(child.messages.len(), 1 + 2);
|
||||
assert!(matches!(child.config.mode, SlotMode::Focused(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_preserves_independence() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::user_text("a")]).unwrap();
|
||||
let mut child = parent.fork("c".into(), DeriveStrategy::Full);
|
||||
let child_count_at_fork = child.messages.len();
|
||||
|
||||
// 父 slot 追加
|
||||
parent
|
||||
.append_messages(vec![Message::user_text("b")])
|
||||
.unwrap();
|
||||
// 子 slot 追加
|
||||
child.append_messages(vec![Message::user_text("c")]).unwrap();
|
||||
|
||||
assert_eq!(parent.messages.len(), 2);
|
||||
assert_eq!(child.messages.len(), child_count_at_fork + 1);
|
||||
assert_eq!(extract_text(child.messages.last().unwrap()), "c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_sets_derived_source() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::user_text("a")]).unwrap();
|
||||
let child = parent.fork("c".into(), DeriveStrategy::Full);
|
||||
match &child.config.source {
|
||||
SlotSource::Derived { parent_id, strategy } => {
|
||||
assert_eq!(parent_id, "p");
|
||||
assert!(matches!(strategy, DeriveStrategy::Full));
|
||||
}
|
||||
_ => panic!("子 slot source 应为 Derived"),
|
||||
}
|
||||
// 子 slot 的 meta 全新创建
|
||||
assert_eq!(child.meta.message_count, 0);
|
||||
assert!(child.meta.parent_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_append_appends_messages() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::user_text("p1")]).unwrap();
|
||||
let child = {
|
||||
let mut c = parent.fork("c".into(), DeriveStrategy::Full);
|
||||
// fork 时 child 继承父的 "p1";再追加一条 c1
|
||||
c.append_messages(vec![Message::user_text("c1")]).unwrap();
|
||||
c
|
||||
};
|
||||
parent
|
||||
.merge(child, MergeStrategy::Append)
|
||||
.expect("merge ok");
|
||||
// Append 追加 child 全部消息到父:1 (p1) + 2 (p1 + c1) = 3
|
||||
assert_eq!(parent.messages.len(), 3);
|
||||
assert_eq!(parent.meta.message_count, 3);
|
||||
assert_eq!(extract_text(&parent.messages[2]), "c1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_replace_replaces_messages() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::user_text("p1")]).unwrap();
|
||||
parent.append_messages(vec![Message::user_text("p2")]).unwrap();
|
||||
let child = {
|
||||
let mut c = parent.fork("c".into(), DeriveStrategy::Full);
|
||||
// 清空 child 再追加
|
||||
c.messages.clear();
|
||||
c.append_messages(vec![Message::user_text("c-only")])
|
||||
.unwrap();
|
||||
c
|
||||
};
|
||||
parent
|
||||
.merge(child, MergeStrategy::Replace)
|
||||
.expect("merge ok");
|
||||
assert_eq!(parent.messages.len(), 1);
|
||||
assert_eq!(extract_text(&parent.messages[0]), "c-only");
|
||||
assert_eq!(parent.meta.message_count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_self_rejected() {
|
||||
let mut slot = make_slot("p", "s1");
|
||||
slot.append_messages(vec![Message::user_text("a")]).unwrap();
|
||||
let child = slot.fork("p".into(), DeriveStrategy::Full);
|
||||
let err = slot.merge(child, MergeStrategy::Append).unwrap_err();
|
||||
assert!(matches!(err, AgentError::Config(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_readonly_rejected() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.config.mode = SlotMode::Readonly;
|
||||
let child = ContextSlot::new("s1", "c", SlotConfig::default());
|
||||
let err = parent.merge(child, MergeStrategy::Append).unwrap_err();
|
||||
assert!(matches!(err, AgentError::SlotReadonly(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_cross_session_rejected() {
|
||||
let mut parent = make_slot("p", "s1");
|
||||
parent.append_messages(vec![Message::user_text("a")]).unwrap();
|
||||
let child = ContextSlot::new("OTHER_SESSION", "c", SlotConfig::default());
|
||||
let err = parent.merge(child, MergeStrategy::Append).unwrap_err();
|
||||
assert!(matches!(err, AgentError::Config(_)));
|
||||
}
|
||||
|
||||
// ====== Colon 校验(key 格式保护) ======
|
||||
|
||||
#[test]
|
||||
|
||||
+7
-33
@@ -16,8 +16,11 @@ use futures_core::Stream;
|
||||
|
||||
use crate::agent::agent::Agent;
|
||||
use crate::agent::context::{
|
||||
ContextSlot, DeriveStrategy, SlotConfig, SlotMode, SlotSource,
|
||||
ContextSlot, DeriveStrategy, SlotConfig, SlotMode,
|
||||
};
|
||||
// SlotSource 仅在 `mod tests` 中使用(通过 `use super::*;` 引入),lib 主体保留以避免测试 import 变更。
|
||||
#[allow(unused_imports)]
|
||||
use crate::agent::context::SlotSource;
|
||||
use crate::agent::error::AgentError;
|
||||
use crate::agent::runtime::RuntimeBundle;
|
||||
use crate::agent::session_memory::SessionMemory;
|
||||
@@ -224,38 +227,9 @@ impl AgentSession {
|
||||
.slots
|
||||
.get(parent_id)
|
||||
.ok_or_else(|| AgentError::SlotNotFound(parent_id.to_string()))?;
|
||||
|
||||
let parent_messages = parent.messages.clone();
|
||||
let (messages, focused_cfg) = match &strategy {
|
||||
DeriveStrategy::Full => (parent_messages, None),
|
||||
DeriveStrategy::Focused(cfg) => {
|
||||
let filtered = ContextSlot::filter_focused(&parent_messages, cfg);
|
||||
(filtered, Some(cfg.clone()))
|
||||
}
|
||||
};
|
||||
|
||||
let mode = match focused_cfg {
|
||||
Some(cfg) => SlotMode::Focused(cfg),
|
||||
None => SlotMode::Full,
|
||||
};
|
||||
|
||||
let slot = ContextSlot::new(
|
||||
&self.session_id,
|
||||
&slot_id,
|
||||
SlotConfig {
|
||||
mode,
|
||||
source: SlotSource::Derived {
|
||||
parent_id: parent_id.to_string(),
|
||||
strategy,
|
||||
},
|
||||
budget: Default::default(),
|
||||
compact: true,
|
||||
},
|
||||
);
|
||||
let mut slot = slot;
|
||||
slot.messages = messages;
|
||||
slot.save(&*self.resolve_store()).await?;
|
||||
self.slots.insert(slot_id, slot);
|
||||
let child = parent.fork(slot_id.clone(), strategy);
|
||||
child.save(&*self.resolve_store()).await?;
|
||||
self.slots.insert(slot_id, child);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user