feat(engine): 实现 Agent 角色热切换与子代理调度

新增 SessionManager::switch_agent / dispatch / dispatch_all / dispatch_stream
四个核心方法,补齐多 Agent 基础系统原语。

交付物:
- switch_agent 运行时替换 Arc<dyn Agent>,保留上下文并更新 SessionMeta
- dispatch 单任务派发:create_child → inherit_memory → submit_turn
- dispatch_all 并行派发:Semaphore 并发控制 + 部分成功语义
- dispatch_stream 流式派发:unbounded_channel + spawn task 消息重建 + finalize_turn
- DispatchConfig / SubTaskResult / SubTaskStreamEvent 公开类型
- 4 个端到端示例(agent_switch_demo / sub_agent_dispatch_demo /
  bridge_keys_demo / dispatch_stream_demo)

辅助变更:
- EngineError 新增 DispatchFailed 变体
- CostTracker 实现 From<Usage> 转换
- save_session_meta / load_session_meta 改 pub(crate) 供 switch.rs 使用

测试: +17 个内联测试(4 switch + 5 dispatch + 4 dispatch_all +
4 dispatch_stream),全量 391 passed / 0 failed,
clippy 0 警告,doc 0 warning。
This commit is contained in:
徐涛
2026-07-15 11:15:58 +08:00
parent cb922b03de
commit 46de111965
11 changed files with 2586 additions and 3 deletions
+115
View File
@@ -0,0 +1,115 @@
//! agent_switch_demo —— Agent 角色热切换示例。
//!
//! 演示:
//! 1. 创建 session(绑定 Analyst agent
//! 2. 提交一轮对话(角色 A 输出"分析数据"
//! 3. switch_agent 切换为 Reporter agent
//! 4. 提交第二轮对话(角色 B 基于已有上下文输出"报告")
//! 5. 验证:turn_index 连续、session_memory 保留、slot 历史保留
//!
//! 运行:`cargo run --example agent_switch_demo`
use std::sync::Arc;
use agcore::agent::{Agent, AgentBuilder};
use agcore::engine::SessionManager;
use agcore::llm::hooks::HookExecutor;
use agcore::llm::mock::MockProvider;
use agcore::llm::types::Usage;
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
use agcore::memory::store::InMemoryStore;
use agcore::tools::ToolRegistry;
struct AnalystAgent;
struct ReporterAgent;
impl Agent for AnalystAgent {
fn name(&self) -> &str {
"analyst"
}
fn system_prompt(&self) -> Option<&str> {
Some("You are a data analyst. Analyze the input concisely.")
}
}
impl Agent for ReporterAgent {
fn name(&self) -> &str {
"reporter"
}
fn system_prompt(&self) -> Option<&str> {
Some("You are a report writer. Write concise reports based on context.")
}
}
fn assistant_text(text: &str) -> MessageResponse {
MessageResponse {
id: String::new(),
model: String::new(),
message: Message::Assistant {
content: vec![ContentBlock::Text { text: text.into() }],
},
usage: Usage::from_input_output(8, 4),
stop_reason: StopReason::Stop,
extra: Default::default(),
}
}
#[tokio::main]
async fn main() {
println!("=== Agent Switch Demo ===\n");
// 1. 准备组件
let store: Arc<dyn agcore::memory::store::MemoryStore> = Arc::new(InMemoryStore::new());
let provider = Arc::new(MockProvider::new(vec![
assistant_text("Analyst: data analyzed (Q3 sales up 15%)"),
assistant_text("Reporter: report drafted (3 paragraphs)"),
]));
let bundle = Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(Arc::new(ToolRegistry::new()))
.hook_executor(Arc::new(HookExecutor::new()))
.session_memory_backend(store.clone())
.build()
.expect("RuntimeBundle 装配失败"),
);
let analyst: Arc<dyn Agent> = Arc::new(AnalystAgent);
let reporter: Arc<dyn Agent> = Arc::new(ReporterAgent);
let sm = Arc::new(SessionManager::new(store));
let session_id = sm.create(analyst, bundle.clone()).await.expect("create");
println!("[1] session created: {session_id}");
// 2. Analyst 跑一轮
let resp1 = sm
.submit_turn(&session_id, "Analyze Q3 sales data")
.await
.expect("submit_turn 1");
println!("[2] analyst turn 1: {:?}", resp1.text());
// 3. 切换到 Reporter
sm.switch_agent(&session_id, reporter)
.await
.expect("switch_agent");
println!("[3] agent switched to 'reporter'");
// 4. Reporter 跑一轮(基于已有上下文)
let resp2 = sm
.submit_turn(&session_id, "Write a report based on the analysis")
.await
.expect("submit_turn 2");
println!("[4] reporter turn 2: {:?}", resp2.text());
// 5. 验证 turn_index 连续
let (turn_index, agent_name_owned) = {
let session = sm.get(&session_id).await.unwrap();
let guard = session.lock().await;
(guard.turn_index(), guard.agent.name().to_string())
};
println!("\n[verify] turn_index = {turn_index}, agent = {agent_name_owned}");
assert_eq!(turn_index, 2, "turn_index should be 2 after 2 turns");
assert_eq!(agent_name_owned, "reporter", "current agent should be reporter");
println!("✓ context preserved across agent switch");
}
+197
View File
@@ -0,0 +1,197 @@
//! bridge_keys_demo —— bridge_keys 过滤 + 子↔子共享 namespace 示例。
//!
//! 演示:
//! 1. 父 session 设置 SessionMemorykey: "project_goal", "constraints", "noise"
//! 2. dispatch + bridge_keys = ["project_goal", "constraints"] → 只继承这两个
//! 3. 验证子 session 读到的 session_memory 与过滤一致
//! 4. 演示子↔子共享 namespacedispatch 时设 `shared_namespace`
//! 子 A 写入 `shared:{parent_id}:fact_x`,子 B 通过约定 key 读取
//!
//! 运行:`cargo run --example bridge_keys_demo`
use std::sync::Arc;
use agcore::agent::{Agent, AgentBuilder};
use agcore::engine::{DispatchConfig, SessionManager};
use agcore::llm::hooks::HookExecutor;
use agcore::llm::mock::MockProvider;
use agcore::llm::types::Usage;
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
use agcore::memory::store::InMemoryStore;
use agcore::tools::ToolRegistry;
struct WorkerAgent;
impl Agent for WorkerAgent {
fn name(&self) -> &str {
"worker"
}
fn system_prompt(&self) -> Option<&str> {
Some("You are a worker.")
}
}
fn assistant_text(text: &str) -> MessageResponse {
MessageResponse {
id: String::new(),
model: String::new(),
message: Message::Assistant {
content: vec![ContentBlock::Text { text: text.into() }],
},
usage: Usage::from_input_output(8, 4),
stop_reason: StopReason::Stop,
extra: Default::default(),
}
}
#[tokio::main]
async fn main() {
println!("=== Bridge Keys Demo ===\n");
let store: Arc<dyn agcore::memory::store::MemoryStore> = Arc::new(InMemoryStore::new());
// 3 个 dispatch 调用需要 3 个 mock response
let provider = Arc::new(MockProvider::new(vec![
assistant_text("Worker 1: done"),
assistant_text("Worker 2: done"),
assistant_text("Worker 3: done"),
]));
let bundle = Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(Arc::new(ToolRegistry::new()))
.hook_executor(Arc::new(HookExecutor::new()))
.session_memory_backend(store.clone())
.build()
.expect("RuntimeBundle"),
);
let worker: Arc<dyn Agent> = Arc::new(WorkerAgent);
let sm = Arc::new(SessionManager::new(store));
let parent_id = sm
.create(worker.clone(), bundle.clone())
.await
.expect("create");
println!("[1] parent session created: {parent_id}");
// 父 session_memory 写入 3 个 key
{
let session = sm.get(&parent_id).await.unwrap();
let mut guard = session.lock().await;
guard
.set_session_data("project_goal", "Build a fast compiler")
.await
.unwrap();
guard
.set_session_data("constraints", "Rust, no unsafe")
.await
.unwrap();
guard
.set_session_data("noise", "should NOT be inherited")
.await
.unwrap();
}
println!("[2] parent set 3 keys: project_goal, constraints, noise");
// dispatch + bridge_keys 过滤
let config = DispatchConfig {
bridge_keys: Some(vec!["project_goal".to_string(), "constraints".to_string()]),
..Default::default()
};
let result = sm
.dispatch(&parent_id, worker.clone(), "do work", config)
.await
.expect("dispatch");
println!("[3] dispatched sub-agent (child_id={})\n", &result.child_id[..20]);
// 验证过滤效果
let child_session = sm.get(&result.child_id).await.unwrap();
let child_guard = child_session.lock().await;
let inherited_goal = child_guard.session_memory().get("project_goal").await.unwrap();
let inherited_constraint = child_guard.session_memory().get("constraints").await.unwrap();
let filtered_noise = child_guard.session_memory().get("noise").await.unwrap();
drop(child_guard);
println!("[verify] inherited keys in child session:");
println!(" - project_goal: {:?}", inherited_goal);
println!(" - constraints: {:?}", inherited_constraint);
println!(" - noise: {:?} (should be None)", filtered_noise);
assert_eq!(inherited_goal, Some("Build a fast compiler".to_string()));
assert_eq!(inherited_constraint, Some("Rust, no unsafe".to_string()));
assert_eq!(filtered_noise, None, "noise should be filtered out");
println!("\n✓ bridge_keys filtering works correctly");
// =============== 第二部分:子↔子共享 namespaceconvention===============
println!("\n=== Part 2: Child↔Child Shared Namespace (convention) ===\n");
// 关键点:`SessionMemory::get`/`set` 通过 session 自身 namespace 隔离
// (每个 session 一个独立 namespace),所以"子↔子共享"不能直接通过 SessionMemory。
// 真正的子↔子共享需要直接操作底层 MemoryStore,或由上层应用维护一个
// 跨 session 的"共享通道"(例如独立的 namespace + 所有子 session 知道 key 前缀)。
//
// 本 demo 演示通过 DispatchConfig.shared_namespaceconvention-based):
// - `shared_namespace: Some(prefix)` 作为约定标记,告知子 agent
// "你的数据共享 namespace 是 shared:{prefix}:*"
// - 子 agent 自行通过 `sm.store()` 直接操作 MemoryStore(绕过 SessionMemory 的 namespace 隔离)
//
// 演示 2 个子 agent 通过约定 namespace prefix 共享数据。
let shared_ns_config = DispatchConfig {
bridge_keys: Some(vec![]),
shared_namespace: Some("parent-123".to_string()),
..Default::default()
};
// dispatch 第一个子 agent
let _researcher_result = sm
.dispatch(
&parent_id,
worker.clone(),
"research task",
shared_ns_config.clone(),
)
.await
.expect("dispatch researcher");
// 子 A 通过 `sm.store()` 直接写入共享 namespace key
// (约定 prefix: "shared:parent-123:")
let shared_key = "shared:parent-123:fact_architecture";
sm.store()
.save(agcore::memory::types::MemoryItem {
id: shared_key.to_string(),
content: "Microservices with event sourcing".to_string(),
metadata: serde_json::json!({}),
created_at: time::OffsetDateTime::now_utc(),
})
.await
.expect("save shared fact");
println!("[4] researcher wrote {shared_key}");
// dispatch 第二个子 agent
let _writer_result = sm
.dispatch(
&parent_id,
worker.clone(),
"writing task",
shared_ns_config,
)
.await
.expect("dispatch writer");
// 子 B 通过 `sm.store()` 直接读取共享 namespace key
let read_item = sm.store().get(shared_key).await.expect("get");
let read_fact = read_item.map(|i| i.content);
println!("[5] writer reads {shared_key} = {read_fact:?}");
assert_eq!(
read_fact,
Some("Microservices with event sourcing".to_string()),
"writer should read researcher's shared fact"
);
println!("\n✓ child↔child shared namespace works correctly (via MemoryStore convention)");
println!("\n=== All Bridge Keys Demo checks passed ===");
}
+121
View File
@@ -0,0 +1,121 @@
//! dispatch_stream_demo —— 流式子代理调度示例。
//!
//! 演示:
//! 1. 创建父 session
//! 2. dispatch_stream 单个子 agent
//! 3. 消费 SubTaskStreamEvent 序列
//! 4. 验证事件序列:ChildCreated → Stream(...) × N → Completed
//! 5. 验证:完成时 turn_index 已递增(finalize 副作用)
//!
//! 运行:`cargo run --example dispatch_stream_demo`
use std::sync::Arc;
use agcore::agent::{Agent, AgentBuilder};
use agcore::engine::{SessionManager, SubTaskStreamEvent};
use agcore::llm::hooks::HookExecutor;
use agcore::llm::mock::MockProvider;
use agcore::llm::types::Usage;
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
use agcore::memory::store::InMemoryStore;
use agcore::tools::ToolRegistry;
use futures_util::StreamExt;
struct StreamWorkerAgent;
impl Agent for StreamWorkerAgent {
fn name(&self) -> &str {
"stream_worker"
}
fn system_prompt(&self) -> Option<&str> {
Some("You are a streaming worker.")
}
}
fn assistant_text(text: &str) -> MessageResponse {
MessageResponse {
id: String::new(),
model: String::new(),
message: Message::Assistant {
content: vec![ContentBlock::Text { text: text.into() }],
},
usage: Usage::from_input_output(8, 4),
stop_reason: StopReason::Stop,
extra: Default::default(),
}
}
#[tokio::main]
async fn main() {
println!("=== Dispatch Stream Demo ===\n");
let store: Arc<dyn agcore::memory::store::MemoryStore> = Arc::new(InMemoryStore::new());
let provider = Arc::new(MockProvider::new(vec![assistant_text("streamed response")]));
let bundle = Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(Arc::new(ToolRegistry::new()))
.hook_executor(Arc::new(HookExecutor::new()))
.session_memory_backend(store.clone())
.build()
.expect("RuntimeBundle"),
);
let worker: Arc<dyn Agent> = Arc::new(StreamWorkerAgent);
let sm = Arc::new(SessionManager::new(store));
let parent_id = sm
.create(worker.clone(), bundle.clone())
.await
.expect("create");
println!("[1] parent session: {parent_id}");
// dispatch_stream
let mut stream = sm
.dispatch_stream(&parent_id, worker, "do streaming work", Default::default())
.await
.expect("dispatch_stream");
println!("[2] consuming SubTaskStreamEvent sequence...\n");
let mut saw_child_created = false;
let mut saw_stream_count = 0;
let mut completed = None;
while let Some(event) = stream.next().await {
match event {
SubTaskStreamEvent::ChildCreated { child_id } => {
println!(" → ChildCreated({})", &child_id[..20]);
saw_child_created = true;
}
SubTaskStreamEvent::Stream(_) => {
saw_stream_count += 1;
}
SubTaskStreamEvent::Completed(r) => {
println!(" → Completed(child_id={}, {} tokens)", &r.child_id[..20], r.usage.total().total_tokens);
completed = Some(r);
break;
}
SubTaskStreamEvent::Error { child_id, error } => {
panic!("unexpected error: child_id={child_id}, error={error}");
}
}
}
let result = completed.expect("Completed should arrive");
// 验证事件序列
assert!(saw_child_created, "ChildCreated should be received");
assert!(saw_stream_count > 0, "at least one Stream event");
println!("\n[3] received {} stream events", saw_stream_count);
// 验证 finalize 已发生(turn_index 递增)
let child_session = sm.get(&result.child_id).await.unwrap();
let child_guard = child_session.lock().await;
let child_turn_index = child_guard.turn_index();
drop(child_guard);
assert_eq!(child_turn_index, 1, "turn_index should increment after finalize");
println!("[4] child session turn_index = {child_turn_index} (finalize works)");
println!("\n✓ dispatch_stream completed successfully");
}
+141
View File
@@ -0,0 +1,141 @@
//! sub_agent_dispatch_demo —— SubAgent 并行派发示例。
//!
//! 演示:
//! 1. 创建父 session"主编" agent
//! 2. 并行 dispatch_all 3 个子 agent(研究员 / 写手 / 审校)
//! 3. 收集子任务结果
//! 4. 验证树形结构:children(parent_id) 应返回 3 个子 ID
//!
//! 运行:`cargo run --example sub_agent_dispatch_demo`
use std::sync::Arc;
use agcore::agent::{Agent, AgentBuilder};
use agcore::engine::{SessionManager, SubTaskResult};
use agcore::llm::hooks::HookExecutor;
use agcore::llm::mock::MockProvider;
use agcore::llm::types::Usage;
use agcore::llm::types::message::{ContentBlock, Message};
use agcore::llm::types::response_v2::{MessageResponse, StopReason};
use agcore::memory::store::InMemoryStore;
use agcore::tools::ToolRegistry;
struct EditorAgent;
struct ResearcherAgent;
struct WriterAgent;
struct ReviewerAgent;
impl Agent for EditorAgent {
fn name(&self) -> &str {
"editor"
}
fn system_prompt(&self) -> Option<&str> {
Some("You are an editor coordinating a team.")
}
}
impl Agent for ResearcherAgent {
fn name(&self) -> &str {
"researcher"
}
fn system_prompt(&self) -> Option<&str> {
Some("You are a researcher. Provide 3 key findings.")
}
}
impl Agent for WriterAgent {
fn name(&self) -> &str {
"writer"
}
fn system_prompt(&self) -> Option<&str> {
Some("You are a writer. Draft a section.")
}
}
impl Agent for ReviewerAgent {
fn name(&self) -> &str {
"reviewer"
}
fn system_prompt(&self) -> Option<&str> {
Some("You are a reviewer. Check for accuracy.")
}
}
fn assistant_text(text: &str) -> MessageResponse {
MessageResponse {
id: String::new(),
model: String::new(),
message: Message::Assistant {
content: vec![ContentBlock::Text { text: text.into() }],
},
usage: Usage::from_input_output(8, 4),
stop_reason: StopReason::Stop,
extra: Default::default(),
}
}
fn print_result(name: &str, r: &Result<SubTaskResult, agcore::engine::EngineError>) {
match r {
Ok(res) => println!(
"{name} (child_id={}): {} tokens",
&res.child_id[..20.min(res.child_id.len())],
res.usage.total().total_tokens,
),
Err(e) => println!("{name}: {e}"),
}
}
#[tokio::main]
async fn main() {
println!("=== SubAgent Dispatch Demo ===\n");
let store: Arc<dyn agcore::memory::store::MemoryStore> = Arc::new(InMemoryStore::new());
let provider = Arc::new(MockProvider::new(vec![
assistant_text("Researcher: finding 1, 2, 3"),
assistant_text("Writer: section drafted"),
assistant_text("Reviewer: looks good"),
]));
let bundle = Arc::new(
AgentBuilder::new()
.provider(provider)
.tool_registry(Arc::new(ToolRegistry::new()))
.hook_executor(Arc::new(HookExecutor::new()))
.session_memory_backend(store.clone())
.build()
.expect("RuntimeBundle"),
);
let editor: Arc<dyn Agent> = Arc::new(EditorAgent);
let researcher: Arc<dyn Agent> = Arc::new(ResearcherAgent);
let writer: Arc<dyn Agent> = Arc::new(WriterAgent);
let reviewer: Arc<dyn Agent> = Arc::new(ReviewerAgent);
let sm = Arc::new(SessionManager::new(store));
let parent_id = sm.create(editor, bundle.clone()).await.expect("create");
println!("[1] parent session created: {parent_id}");
// dispatch_all 3 个子 agent
println!("[2] dispatching 3 sub-agents in parallel...\n");
let results = sm
.dispatch_all(
&parent_id,
vec![
(researcher, "Research topic X".to_string()),
(writer, "Draft intro section".to_string()),
(reviewer, "Review draft".to_string()),
],
agcore::engine::DispatchConfig::default(),
)
.await;
print_result("researcher", &results[0]);
print_result("writer", &results[1]);
print_result("reviewer", &results[2]);
let success_count = results.iter().filter(|r| r.is_ok()).count();
assert_eq!(success_count, 3, "all 3 should succeed");
// 验证树形
let children = sm.children(&parent_id).await.expect("children");
println!("\n[3] children(parent) = {} session(s)", children.len());
assert_eq!(children.len(), 3);
println!("\n✓ dispatch_all completed: 3/3 sub-agents succeeded");
}