From 2d0d5c1592ec04c823466cf494a47920cacbaa6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=B6=9B?= Date: Fri, 3 Jul 2026 15:50:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(examples):=20=E6=96=B0=E5=A2=9E=20Phase=20?= =?UTF-8?q?B=207=20=E4=B8=AA=E7=A6=BB=E7=BA=BF=E5=8F=AF=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E7=A4=BA=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 7 个示例覆盖全 Phase 公共 API(不依赖 API key 即可运行): - agent_session_demo:AgentBuilder → AgentSession → SessionMemory - custom_tool:BaseTool 注册 + invoke/invoke_all + 权限检查 - prompt_composer:PromptTemplate + PromptComposer + validate_messages - task_agent_demo:JsonPlanParser + Step 状态机 + 错误路径 - conversation_memory_demo:滑动窗口 + 多角色 + 隔离 - knowledge_search_demo:KnowledgeStore + 关键词检索 + 停用词过滤 - streaming_events_demo:submit_stream 事件消费 + 队列耗尽错误路径 --- examples/agent_session_demo.rs | 130 +++++++++++++++++++++ examples/conversation_memory_demo.rs | 143 +++++++++++++++++++++++ examples/custom_tool.rs | 169 +++++++++++++++++++++++++++ examples/knowledge_search_demo.rs | 149 +++++++++++++++++++++++ examples/prompt_composer.rs | 108 +++++++++++++++++ examples/streaming_events_demo.rs | 117 +++++++++++++++++++ examples/task_agent_demo.rs | 113 ++++++++++++++++++ 7 files changed, 929 insertions(+) create mode 100644 examples/agent_session_demo.rs create mode 100644 examples/conversation_memory_demo.rs create mode 100644 examples/custom_tool.rs create mode 100644 examples/knowledge_search_demo.rs create mode 100644 examples/prompt_composer.rs create mode 100644 examples/streaming_events_demo.rs create mode 100644 examples/task_agent_demo.rs diff --git a/examples/agent_session_demo.rs b/examples/agent_session_demo.rs new file mode 100644 index 0000000..b321be8 --- /dev/null +++ b/examples/agent_session_demo.rs @@ -0,0 +1,130 @@ +//! agent_session_demo —— Agent 装配 + 会话链路 + SessionMemory 桥接。 +//! +//! 演示: +//! 1. 实现 `Agent` trait(定义角色 + system prompt) +//! 2. 用 `MockProvider` 预设响应(离线可跑) +//! 3. `AgentBuilder` 装配 `RuntimeBundle` +//! 4. `AgentSession::submit_turn` 跑多轮对话 +//! 5. `SessionMemory` 读写 + snapshot 输出 +//! 6. 跨 session 数据隔离验证 +//! +//! 运行:`cargo run --example agent_session_demo` + +use std::sync::Arc; + +use agcore::agent::{Agent, AgentBuilder, AgentSession}; +use agcore::llm::hooks::HookExecutor; +use agcore::llm::mock::MockProvider; +use agcore::llm::types::message::{ContentBlock, Message}; +use agcore::llm::types::response_v2::{MessageResponse, StopReason}; +use agcore::llm::types::Usage; +use agcore::tools::ToolRegistry; + +/// 计算器角色 Agent。 +struct CalculatorAgent; + +impl Agent for CalculatorAgent { + fn name(&self) -> &str { + "calculator" + } + fn system_prompt(&self) -> Option<&str> { + Some("你是一个简洁的计算器助手,每轮回答一句话。") + } +} + +/// 构造预设的纯文本 Assistant 响应。 +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() { + // 1. MockProvider:预设三轮响应(无须 API key 即可离线运行) + let provider = Arc::new(MockProvider::new(vec![ + assistant_text("1 + 1 = 2"), + assistant_text("2 + 2 = 4"), + assistant_text("会话即将结束。"), + ])); + + // 2. AgentBuilder 装配 RuntimeBundle(必填:provider / tool_registry / hook_executor) + let bundle = Arc::new( + AgentBuilder::new() + .provider(provider) + .tool_registry(Arc::new(ToolRegistry::new())) + .hook_executor(Arc::new(HookExecutor::new())) + .build() + .expect("RuntimeBundle 装配失败"), + ); + + // 3. 创建会话 + let agent: Arc = Arc::new(CalculatorAgent); + let mut session = AgentSession::new(agent, "demo-session", bundle.clone()); + assert_eq!(session.turn_index(), 0); + + // 4. 提交第一轮 + println!("=== 提交第 1 轮 ==="); + let resp = session.submit_turn("1+1=?").await.expect("submit_turn 失败"); + println!("LLM: {}", resp.text()); + session + .set_session_data("last_q", "1+1=?") + .await + .expect("set_session_data 失败"); + session + .set_session_data("last_a", resp.text()) + .await + .expect("set_session_data 失败"); + assert_eq!(session.turn_index(), 1); + + // 5. 提交第二轮 + println!("\n=== 提交第 2 轮 ==="); + let resp = session.submit_turn("再加一次 2+2=?").await.unwrap(); + println!("LLM: {}", resp.text()); + assert_eq!(session.turn_index(), 2); + + // 6. 验证 SessionMemory 读取 + println!("\n=== Session Memory 读取 ==="); + println!( + "last_q = {:?}", + session.get_session_data("last_q").await.unwrap() + ); + println!( + "last_a = {:?}", + session.get_session_data("last_a").await.unwrap() + ); + + // 7. Snapshot 格式化输出 + println!("\n=== Session Memory Snapshot ==="); + println!("{}", session.session_memory().snapshot().await.unwrap()); + + // 8. 跨 session 数据隔离验证 + println!("=== 数据隔离验证 ==="); + let other = AgentSession::new( + Arc::new(CalculatorAgent), + "other-session", + bundle, + ); + assert!( + other.get_session_data("last_q").await.unwrap().is_none(), + "新会话不应看到旧 session 的 last_q" + ); + println!("新会话 last_q = None ✓"); + + // 9. 用量累计验证 + println!("\n=== 用量累计 ==="); + let total = session.usage().total(); + println!( + "prompt={}, completion={}, total={}", + total.prompt_tokens, total.completion_tokens, total.total_tokens + ); + + println!("\n✓ agent_session_demo 完成"); +} \ No newline at end of file diff --git a/examples/conversation_memory_demo.rs b/examples/conversation_memory_demo.rs new file mode 100644 index 0000000..d04b844 --- /dev/null +++ b/examples/conversation_memory_demo.rs @@ -0,0 +1,143 @@ +//! conversation_memory_demo —— 对话记忆滑动窗口与隔离。 +//! +//! 演示: +//! 1. `ConversationMemoryConfig` 构造(SlidingWindow / Full 策略) +//! 2. `add_message` 写入多角色消息(Message IR) +//! 3. 滑动窗口自动淘汰旧消息 +//! 4. Full 策略保留全部 +//! 5. `get_history` / `len` / `clear` +//! 6. 跨 session 数据隔离(共用 MemoryStore) +//! +//! 运行:`cargo run --example conversation_memory_demo` + +use std::sync::Arc; + +use agcore::llm::types::message::Message; +use agcore::memory::{ConversationMemory, ConversationMemoryConfig, InMemoryStore, MemoryStrategy}; + +fn message_text(msg: &Message) -> &str { + match msg { + Message::User { content } + | Message::System { content } + | Message::Assistant { content } + | Message::ToolResult { content, .. } => content + .iter() + .filter_map(|b| match b { + agcore::llm::types::message::ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .next() + .unwrap_or(""), + Message::UserImage { .. } => "[image]", + } +} + +#[tokio::main] +async fn main() { + // 1. 滑动窗口策略:写入 5 条但只保留最近 3 条 + println!("=== SlidingWindow 策略(max_turns=3)==="); + let store = Arc::new(InMemoryStore::new()); + let config = ConversationMemoryConfig { + strategy: MemoryStrategy::SlidingWindow, + max_turns: 3, + compact_config: None, + }; + let mut memory = ConversationMemory::new(store, "session-1", config); + + for i in 0..5 { + memory + .add_message(Message::user_text(format!("消息 {i}"))) + .await + .expect("写入失败"); + } + println!("写入 5 条 → len = {} (期望 3)", memory.len()); + let history = memory.get_history(); + for (i, msg) in history.iter().enumerate() { + println!(" [{}] {}", i, message_text(msg)); + } + assert_eq!(memory.len(), 3); + assert_eq!(message_text(&history[0]), "消息 2", "最旧应是消息 2"); + assert_eq!(message_text(&history[2]), "消息 4", "最新应是消息 4"); + + // 2. Full 策略:保留全部 + println!("\n=== Full 策略(max_turns=3)==="); + let store2 = Arc::new(InMemoryStore::new()); + let config2 = ConversationMemoryConfig { + strategy: MemoryStrategy::Full, + max_turns: 3, + compact_config: None, + }; + let mut memory2 = ConversationMemory::new(store2, "session-2", config2); + for i in 0..5 { + memory2 + .add_message(Message::user_text(format!("Full {i}"))) + .await + .unwrap(); + } + println!("写入 5 条 → len = {} (期望 5)", memory2.len()); + assert_eq!(memory2.len(), 5); + + // 3. 多角色混合 + clear + println!("\n=== 多角色写入 + clear ==="); + let store3 = Arc::new(InMemoryStore::new()); + let mut memory3 = ConversationMemory::new( + store3, + "session-3", + ConversationMemoryConfig::default(), + ); + memory3 + .add_message(Message::user_text("你好")) + .await + .unwrap(); + memory3 + .add_message(Message::assistant("你好!有什么可以帮你的吗?")) + .await + .unwrap(); + memory3 + .add_message(Message::user_text("今天天气怎么样?")) + .await + .unwrap(); + memory3 + .add_message(Message::assistant("我无法查询实时天气,但你可以查看天气应用。")) + .await + .unwrap(); + println!( + "写入 4 条多角色消息 → len = {}, 最后一条: {:?}", + memory3.len(), + message_text(memory3.get_history().last().unwrap()) + ); + assert_eq!(memory3.len(), 4); + + memory3.clear().await.unwrap(); + println!( + "clear 后 → len = {}, is_empty = {}", + memory3.len(), + memory3.is_empty() + ); + assert!(memory3.is_empty()); + + // 4. Session 隔离 + println!("\n=== Session 隔离(共用 InMemoryStore)==="); + let store4 = Arc::new(InMemoryStore::new()); + let mut a = ConversationMemory::new( + store4.clone(), + "s-a", + ConversationMemoryConfig::default(), + ); + let mut b = ConversationMemory::new( + store4.clone(), + "s-b", + ConversationMemoryConfig::default(), + ); + a.add_message(Message::user_text("A 的消息")).await.unwrap(); + b.add_message(Message::user_text("B 的消息")).await.unwrap(); + println!( + "A.len = {}, B.len = {} (期望 1/1,互不污染)", + a.len(), + b.len() + ); + assert_eq!(a.len(), 1); + assert_eq!(b.len(), 1); + + println!("\n✓ conversation_memory_demo 完成"); +} \ No newline at end of file diff --git a/examples/custom_tool.rs b/examples/custom_tool.rs new file mode 100644 index 0000000..c3f5e2d --- /dev/null +++ b/examples/custom_tool.rs @@ -0,0 +1,169 @@ +//! custom_tool —— 自定义工具注册、单次 / 并行调用、权限检查。 +//! +//! 演示: +//! 1. 实现 `BaseTool` trait(WeatherTool + DeleteFileTool) +//! 2. 注册到 `ToolRegistry` +//! 3. 单次 `invoke`(含 tool_call_id 关联) +//! 4. 并行 `invoke_all` +//! 5. 调用未注册工具 → `ToolError::NotFound` +//! 6. `PermissionChecker` 黑名单阻断 `DeleteFileTool` +//! +//! 运行:`cargo run --example custom_tool` + +use std::sync::Arc; + +use agcore::tools::{ + BaseTool, Permission, PermissionChecker, PermissionConfig, ToolContext, ToolError, ToolRef, + ToolRegistry, +}; +use async_trait::async_trait; +use serde_json::{json, Value}; + +/// 天气查询工具 —— 模拟根据城市返回天气数据。 +struct WeatherTool; + +#[async_trait] +impl BaseTool for WeatherTool { + fn name(&self) -> &str { + "get_weather" + } + fn description(&self) -> &str { + "查询指定城市的天气" + } + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { + "city": { "type": "string", "description": "城市名" } + }, + "required": ["city"] + }) + } + fn required_permissions(&self) -> Vec { + vec![Permission::Network] + } + async fn execute( + &self, + args: Value, + _ctx: &ToolContext<'_>, + ) -> Result { + let city = args["city"].as_str().unwrap_or("未知"); + // 模拟查询:根据城市名给出不同温度 + let (temperature, condition) = match city { + "北京" => (22_i32, "晴"), + "上海" => (25, "多云"), + "广州" => (28, "雷阵雨"), + _ => (20, "晴"), + }; + Ok(json!({ + "city": city, + "temperature": temperature, + "condition": condition + })) + } +} + +/// 删除文件工具 —— 用于演示权限黑名单阻断。 +struct DeleteFileTool; + +#[async_trait] +impl BaseTool for DeleteFileTool { + fn name(&self) -> &str { + "delete_file" + } + fn description(&self) -> &str { + "删除指定路径的文件" + } + fn parameters(&self) -> Value { + json!({ + "type": "object", + "properties": { "path": { "type": "string" } }, + "required": ["path"] + }) + } + fn required_permissions(&self) -> Vec { + vec![Permission::Delete] + } + async fn execute( + &self, + _args: Value, + _ctx: &ToolContext<'_>, + ) -> Result { + Ok(json!({"deleted": true})) + } +} + +#[tokio::main] +async fn main() { + // 1. 注册工具 + let mut registry = ToolRegistry::new(); + let weather: ToolRef = Arc::new(WeatherTool); + let deleter: ToolRef = Arc::new(DeleteFileTool); + registry.register(weather).expect("注册 get_weather 失败"); + registry.register(deleter).expect("注册 delete_file 失败"); + println!("=== 已注册工具 ==="); + println!("{:?}", registry.list_tools()); + + // 2. 单次 invoke:tool_call_id 用于回传时与原始调用关联 + println!("\n=== 单次 invoke ==="); + let result = registry + .invoke("call_1", "get_weather", json!({"city": "北京"})) + .await + .expect("invoke 失败"); + println!("tool_call_id: {}", result.tool_call_id); + println!("tool_name: {}", result.tool_name); + println!("output: {}", result.output.unwrap()); + + // 3. 并行 invoke_all:三个并行天气查询 + println!("\n=== 并行 invoke_all(30s 超时)==="); + let calls = vec![ + ("c1".into(), "get_weather".into(), json!({"city": "北京"})), + ("c2".into(), "get_weather".into(), json!({"city": "上海"})), + ("c3".into(), "get_weather".into(), json!({"city": "广州"})), + ]; + let results = registry.invoke_all(calls, 30).await; + assert_eq!(results.len(), 3); + for r in &results { + let output = r.output.as_ref().unwrap(); + println!("[{}] {}", r.tool_call_id, output); + } + + // 4. 调用未注册工具 + println!("\n=== 未注册工具 ==="); + let err = registry + .invoke("c_x", "nope_tool", json!({})) + .await + .unwrap_err(); + println!("错误: {err}"); + + // 5. 权限检查:默认 PermissionConfig 黑名单含 Delete + println!("\n=== 权限检查(默认 PermissionConfig,denied = [Delete, Shell])==="); + let mut registry_with_checker = ToolRegistry::new().with_permission_checker(PermissionChecker::new( + PermissionConfig::default(), + )); + registry_with_checker + .register(Arc::new(WeatherTool) as ToolRef) + .unwrap(); + registry_with_checker + .register(Arc::new(DeleteFileTool) as ToolRef) + .unwrap(); + + // get_weather 声明 Network → 在 allowed 列表 → 通过 + let r = registry_with_checker + .invoke("c1", "get_weather", json!({"city": "北京"})) + .await + .unwrap(); + println!( + "get_weather 权限检查: {}", + if r.output.is_ok() { "通过 ✓" } else { "阻断 ✗" } + ); + + // delete_file 声明 Delete → 在 denied 列表 → 阻断 + let err = registry_with_checker + .invoke("c2", "delete_file", json!({"path": "/tmp/x"})) + .await + .unwrap_err(); + println!("delete_file 权限检查: 阻断 ✗ ({err})"); + + println!("\n✓ custom_tool 完成"); +} \ No newline at end of file diff --git a/examples/knowledge_search_demo.rs b/examples/knowledge_search_demo.rs new file mode 100644 index 0000000..3c702cd --- /dev/null +++ b/examples/knowledge_search_demo.rs @@ -0,0 +1,149 @@ +//! knowledge_search_demo —— 知识页面存储与关键词检索。 +//! +//! 演示: +//! 1. `KnowledgeStore` 存储多个 `KnowledgePage` +//! 2. `MemoryRetriever` 按关键词检索 + TextOverlap (Dice) 评分 +//! 3. 评分 [0.0, 1.0] 范围校验 +//! 4. `RetrieverConfig::min_score` 阈值过滤 +//! 5. `RetrieverConfig::max_results` 截断 +//! 6. 空 query 返回空结果 +//! +//! 运行:`cargo run --example knowledge_search_demo` + +use std::sync::Arc; + +use agcore::memory::{ + InMemoryStore, KnowledgePage, KnowledgeStore, MemoryRetriever, MemoryStore, RetrieverConfig, +}; +use time::OffsetDateTime; + +fn make_page(id: &str, title: &str, content: &str) -> KnowledgePage { + let now = OffsetDateTime::now_utc(); + KnowledgePage { + id: id.to_string(), + title: title.to_string(), + summary: content.chars().take(30).collect(), + content: content.to_string(), + tags: Vec::new(), + references: Vec::new(), + created_at: now, + updated_at: now, + } +} + +#[tokio::main] +async fn main() { + // 1. 创建知识库 + 批量存储页面 + let store: Arc = Arc::new(InMemoryStore::new()); + let ks = KnowledgeStore::new(store); + + let pages = vec![ + make_page("rust-1", "Rust 入门", "Rust 是一门系统级编程语言,注重安全性与并发。"), + make_page("python-1", "Python 简介", "Python 是一门动态类型的高级编程语言。"), + make_page( + "langgraph-1", + "LangGraph 框架", + "LangGraph 是 LangChain 的状态图扩展,用于构建多步 Agent。", + ), + make_page("rust-async", "Rust 异步编程", "Rust 异步基于 tokio 与 futures 抽象。"), + ]; + for p in &pages { + ks.add_page(p.clone()).await.expect("保存页面失败"); + } + println!("=== 已存储 {} 个知识页面 ===", pages.len()); + let index = ks.get_index().await; + for entry in &index { + println!(" - {} ({})", entry.title, entry.id); + } + + // 2. 关键词检索 —— 期望命中 Rust 相关页面 + println!("\n=== 关键词检索:'Rust 异步' ==="); + let retriever = MemoryRetriever::new(ks, RetrieverConfig::default()); + let result = retriever.retrieve("Rust 异步").await.unwrap(); + println!("query: {}", result.query); + for item in &result.items { + println!( + " 命中: {} (score={:.3})", + item.page.title, item.score + ); + assert!( + (0.0..=1.0).contains(&item.score), + "score 应在 [0, 1] 区间" + ); + } + assert!(!result.items.is_empty(), "应至少命中一个页面"); + + // 3. min_score 阈值过滤 + println!("\n=== min_score=0.5 阈值过滤(无关 query)==="); + let store2: Arc = Arc::new(InMemoryStore::new()); + let ks2 = KnowledgeStore::new(store2); + ks2.add_page(make_page("rust-1", "Rust 入门", "Rust 入门内容。")) + .await + .unwrap(); + let cfg = RetrieverConfig { + max_results: 20, + min_score: 0.5, + }; + let retriever2 = MemoryRetriever::new(ks2, cfg); + let result = retriever2 + .retrieve("完全不相关的火锅配方") + .await + .unwrap(); + println!( + "无关 query → items.len = {} (期望 0)", + result.items.len() + ); + assert!(result.items.is_empty()); + + // 4. max_results 截断 + println!("\n=== max_results=2 截断 ==="); + let store3: Arc = Arc::new(InMemoryStore::new()); + let ks3 = KnowledgeStore::new(store3); + for i in 0..5 { + ks3.add_page(make_page( + &format!("rust-{i}"), + "Rust 主题", + &format!("第 {i} 篇关于 Rust 的内容"), + )) + .await + .unwrap(); + } + let cfg = RetrieverConfig { + max_results: 2, + min_score: 0.0, + }; + let retriever3 = MemoryRetriever::new(ks3, cfg); + let result = retriever3.retrieve("Rust").await.unwrap(); + println!( + "5 个相关页面 → 返回 items.len = {} (期望 2)", + result.items.len() + ); + assert_eq!(result.items.len(), 2); + + // 5. 空 query + println!("\n=== 空 query ==="); + let empty = retriever3.retrieve("").await.unwrap(); + println!("空 query → items.len = {}", empty.items.len()); + assert!(empty.items.is_empty()); + + // 6. 停用词过滤:`extract_keywords` 在检索前过滤单字符词与停用词 + println!("\n=== 停用词过滤 ==="); + let mixed = retriever3.retrieve("the Rust is").await.unwrap(); + println!( + "query='the Rust is' → 命中 {} 个 (停用词 'the'/'is' 被过滤,仅 'rust' 进入搜索)", + mixed.items.len() + ); + assert!( + !mixed.items.is_empty(), + "非停用词 'rust' 应命中页面(即使 query 中含停用词)" + ); + + let only_stop = retriever3.retrieve("the is are").await.unwrap(); + println!( + "纯停用词 query='the is are' → 命中 {} 个 (期望 0,所有 token 均被过滤)", + only_stop.items.len() + ); + assert!(only_stop.items.is_empty(), "纯停用词 query 必须返回空结果"); + + println!("\n✓ knowledge_search_demo 完成"); +} \ No newline at end of file diff --git a/examples/prompt_composer.rs b/examples/prompt_composer.rs new file mode 100644 index 0000000..d23e1b8 --- /dev/null +++ b/examples/prompt_composer.rs @@ -0,0 +1,108 @@ +//! prompt_composer —— 提示词模板与组合器离线示例。 +//! +//! 演示: +//! 1. `PromptTemplate::compile` + `render` 变量插值(`{{var}}` 语法) +//! 2. 缺失变量返回 `PromptError` +//! 3. `PromptTemplateRegistry` 注册 + 按名渲染 +//! 4. `PromptComposer` 构造多角色消息序列 +//! 5. `validate_messages` 校验消息序列合法性 +//! +//! 运行:`cargo run --example prompt_composer` + +use agcore::llm::types::message::{ContentBlock, Message}; +use agcore::prompt::{ + validate_messages, PromptComposer, PromptTemplate, PromptTemplateRegistry, TemplateContext, +}; + +fn message_text(msg: &Message) -> String { + match msg { + Message::System { content } + | Message::User { content } + | Message::Assistant { content } + | Message::ToolResult { content, .. } => content + .iter() + .filter_map(|b| match b { + ContentBlock::Text { text } => Some(text.as_str()), + _ => None, + }) + .collect(), + Message::UserImage { .. } => "[image]".into(), + } +} + +fn main() { + // 1. PromptTemplate::compile + render —— 直接构造模板 + println!("=== PromptTemplate::compile + render ==="); + let tpl = PromptTemplate::compile( + "今日 {{location}} 天气:{{condition}},温度 {{temperature}}", + ) + .expect("编译失败"); + let mut ctx = TemplateContext::new(); + ctx.insert("location", "北京"); + ctx.insert("condition", "晴"); + ctx.insert("temperature", "25°C"); + let rendered = tpl.render(&ctx).expect("渲染失败"); + println!("渲染结果: {rendered}"); + + // 2. 缺失变量 → PromptError + println!("\n=== 缺失变量 ==="); + match tpl.render(&TemplateContext::new()) { + Ok(_) => println!("意外成功"), + Err(e) => println!("按预期报错: {e}"), + } + + // 3. PromptTemplateRegistry —— 按名注册 + 渲染(支持 #if 条件) + println!("\n=== PromptTemplateRegistry ==="); + let mut registry = PromptTemplateRegistry::new(); + registry + .register("weather", "今日 {{location}}:{{condition}}") + .expect("注册失败"); + registry + .register("greet", "你好 {{name}}!{{#if formal}} 见到您很荣幸。{{/if}}") + .expect("注册失败"); + + let mut ctx = TemplateContext::new(); + ctx.insert("name", "Alice"); + println!( + "greet (formal=false): {}", + registry.render("greet", &ctx).unwrap() + ); + ctx.insert("formal", true); + println!( + "greet (formal=true): {}", + registry.render("greet", &ctx).unwrap() + ); + + // 4. PromptComposer —— 构造多角色消息序列 + println!("\n=== PromptComposer ==="); + let messages = PromptComposer::new() + .system("你是一个天气助手") + .user("今天天气怎么样?") + .assistant("请告诉我城市名。") + .user(rendered) + .build(); + println!("消息数: {}", messages.len()); + for (i, m) in messages.iter().enumerate() { + let role = match m { + Message::System { .. } => "system", + Message::User { .. } | Message::UserImage { .. } => "user", + Message::Assistant { .. } => "assistant", + Message::ToolResult { .. } => "tool", + }; + println!("[{i}] {role}: {}", message_text(m)); + } + + // 5. validate_messages —— 消息序列合法性校验 + println!("\n=== validate_messages ==="); + match validate_messages(&messages) { + Ok(()) => println!("消息序列合法 ✓"), + Err(e) => println!("消息序列非法: {e}"), + } + let empty: Vec = Vec::new(); + match validate_messages(&empty) { + Ok(()) => println!("空消息合法"), + Err(e) => println!("空消息按预期报错: {e}"), + } + + println!("\n✓ prompt_composer 完成"); +} \ No newline at end of file diff --git a/examples/streaming_events_demo.rs b/examples/streaming_events_demo.rs new file mode 100644 index 0000000..b6bc5f0 --- /dev/null +++ b/examples/streaming_events_demo.rs @@ -0,0 +1,117 @@ +//! streaming_events_demo —— LLM 流式响应事件流消费(含错误路径)。 +//! +//! 演示: +//! 1. `MockProvider::chat_stream` 输出标准 `StreamEvent` 流(离线可跑) +//! 2. `LlmCycle::submit_stream` 消费流 +//! 3. match 各类 `StreamEvent`:MessageStart / ContentBlockStart / TextDelta / +//! ContentBlockEnd / CostUpdate / MessageComplete +//! 4. 实时累计文本与解析事件计数 +//! 5. 从 `MessageComplete` 拿到完整 `MessageResponse` +//! 6. **错误路径**:队列耗尽时 `chat_stream` 返回 `Err`,`submit_stream` 同样 +//! 返回 `Err`(错误不进流,直接 fail-fast) +//! +//! 运行:`cargo run --example streaming_events_demo` + +use std::sync::Arc; + +use agcore::llm::cycle::{CycleConfig, LlmCycle}; +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, StreamEvent}; +use agcore::llm::types::Usage; +use futures_util::StreamExt; + +/// 构造预设的纯文本响应。 +fn text_response(id: &str, text: &str) -> MessageResponse { + MessageResponse { + id: id.to_string(), + model: "mock".into(), + message: Message::Assistant { + content: vec![ContentBlock::Text { text: text.into() }], + }, + usage: Usage::from_input_output(3, text.chars().count() as u32), + stop_reason: StopReason::Stop, + extra: Default::default(), + } +} + +/// 消费一个流直到终止事件,并打印事件轨迹。 +async fn drain_stream(stream: &mut (impl futures_util::Stream + Unpin)) { + while let Some(event) = stream.next().await { + match event { + StreamEvent::MessageStart { id, model } => { + println!("[MessageStart] id={id}, model={model}"); + } + StreamEvent::ContentBlockStart { index, .. } => { + println!("[ContentBlockStart] index={index}"); + } + StreamEvent::TextDelta { text } => { + print!("[TextDelta] {text}"); + use std::io::Write; + std::io::stdout().flush().ok(); + } + StreamEvent::ContentBlockEnd { index } => { + println!("\n[ContentBlockEnd] index={index}"); + } + StreamEvent::CostUpdate { usage } => { + let p = usage.prompt_tokens.unwrap_or(0); + let c = usage.completion_tokens.unwrap_or(0); + println!("[CostUpdate] prompt={p}, completion={c}"); + } + StreamEvent::MessageComplete { full_response } => { + println!( + "[MessageComplete] stop_reason={:?}", + full_response.stop_reason + ); + break; + } + other => { + // 未匹配的事件变体(ThinkingDelta / RefusalDelta / ToolCall* 等) + // 在 MockProvider 当前实现下不可达;保留打印以便未来 Provider 扩展时易调试。 + eprintln!("[unhandled event] {other:?}"); + } + } + } +} + +#[tokio::main] +async fn main() { + // 1. 准备 Mock provider(仅含一轮文本响应,第二次调用会触发错误) + let provider = Arc::new(MockProvider::new(vec![text_response( + "resp-001", + "今天天气晴朗,适合户外活动。", + )])); + let dyn_provider: Arc = provider.clone(); + + // ===== 阶段 1:正常流式响应 ===== + println!("=== 阶段 1:正常流式响应 ==="); + let mut cycle = LlmCycle::new_with_arc(dyn_provider.clone(), CycleConfig::default()); + let mut stream = cycle + .submit_stream("讲个笑话".to_string(), vec![]) + .await + .expect("阶段 1 submit_stream 应成功"); + drain_stream(&mut stream).await; + + // ===== 阶段 2:错误路径(队列耗尽)===== + // 队列中已无响应 → `chat_stream` 返回 `Err(LlmError::Other)` → + // `submit_stream` 用 `?` 立即传播(错误不进流,fail-fast)。 + // 上层 Agent 通过 `match` 或 `?` 处理 `AgentError::Llm(_)`。 + println!("\n=== 阶段 2:错误路径(队列耗尽)==="); + let mut cycle = LlmCycle::new_with_arc(dyn_provider, CycleConfig::default()); + let result = cycle + .submit_stream("第二次提问".to_string(), vec![]) + .await; + match result { + Ok(_) => panic!("阶段 2 必须失败(队列耗尽)"), + Err(e) => { + eprintln!("[expected error] {e}"); + assert!( + e.to_string().contains("预设响应已用完"), + "应包含 MockProvider 的队列耗尽提示" + ); + } + } + + println!("\n✓ streaming_events_demo 完成"); +} \ No newline at end of file diff --git a/examples/task_agent_demo.rs b/examples/task_agent_demo.rs new file mode 100644 index 0000000..6846c90 --- /dev/null +++ b/examples/task_agent_demo.rs @@ -0,0 +1,113 @@ +//! task_agent_demo —— Plan 解析、Step 状态机、错误路径。 +//! +//! 演示: +//! 1. `JsonPlanParser::parse` 解析合法 JSON 输入 +//! 2. `Plan` / `Step` 数据结构遍历 +//! 3. `StepStatus` 状态机:Pending → Running → Completed / Failed / Skipped +//! 4. `is_pending()` / `is_terminal()` 语义 +//! 5. 错误路径:非法 JSON / 空 steps / 缺字段 → `AgentError::PlanParse` +//! +//! 运行:`cargo run --example task_agent_demo` +//! +//! ## 已知技术债(v0.2 迁移指南) +//! +//! 本示例使用 `#[deprecated]` 标记的旧 wire-format 类型: +//! - `ChatResponse`、`OpenaiChatMessage`、`FinishReason` —— `OpenaiChatProvider::chat_inner()` +//! 内部转换层仍在使用(参见 `docs/10a-phase0-types-and-trait.md` §2.5.1), +//! 故结构体定义保留。 +//! - `StepStatus::Completed(ChatResponse)` —— 因为 `Step` 的"已完成"变体需携带 +//! provider 响应,目前沿用旧的 `ChatResponse`。 +//! +//! **触发迁移的条件**:v0.2 引入 IR 层的 `StepResult` / 切换为 `MessageResponse`。 +//! **迁移路径**:将本文件 `ChatResponse`/`OpenaiChatMessage`/`FinishReason` 替换为 +//! `MessageResponse`/`Message`/`StopReason`,移除顶部 `#![allow(deprecated)]`。 +//! 上层应用代码(`TaskAgent` 消费者)也可同步迁移。 + +#![allow(deprecated)] + +use agcore::agent::{AgentError, JsonPlanParser, PlanParser, Step, StepStatus}; +use agcore::llm::types::openai_message::OpenaiChatMessage; +use agcore::llm::types::shared::FinishReason; +use agcore::llm::types::{ChatResponse, Usage}; + +#[tokio::main] +async fn main() { + // 1. JsonPlanParser 解析合法 JSON + println!("=== JsonPlanParser 解析合法 JSON ==="); + let parser = JsonPlanParser; + let input = r#"{ + "steps": [ + {"description": "查询北京天气"}, + {"description": "根据天气计算穿衣建议"}, + {"description": "生成最终回复"} + ] + }"#; + let plan = parser + .parse(input, "为用户生成今日出行建议") + .await + .expect("合法 JSON 应解析成功"); + println!("Plan goal: {}", plan.goal); + println!("Plan id: {}", plan.id); + println!("Steps: {}", plan.steps.len()); + for (i, step) in plan.steps.iter().enumerate() { + println!( + " [{}] {} (pending={}, terminal={})", + i, + step.description, + step.status.is_pending(), + step.status.is_terminal() + ); + } + assert!(plan.steps.iter().all(|s| s.status.is_pending())); + + // 2. Step 状态机:Pending → Running → Completed + println!("\n=== Step 状态机:Pending → Running → Completed ==="); + let mut step = plan.steps.into_iter().next().expect("应有 step"); + println!("初始: pending={}", step.status.is_pending()); + assert!(step.status.is_pending()); + + step.status = StepStatus::Running; + println!("Running: pending={}, terminal={}", step.status.is_pending(), step.status.is_terminal()); + + step.status = StepStatus::Completed(ChatResponse { + message: OpenaiChatMessage::assistant_text("天气:晴,22°C"), + usage: Usage::from_input_output(5, 10), + stop_reason: Some(FinishReason::Stop), + }); + println!("Completed: pending={}, terminal={}", step.status.is_pending(), step.status.is_terminal()); + assert!(step.status.is_terminal()); + + // 3. 失败路径 + println!("\n=== Step 状态机:失败路径 ==="); + let mut fail_step = Step::new(0, "调用天气 API"); + fail_step.status = StepStatus::Failed(AgentError::Other("API 不可用".into())); + println!("Failed: pending={}, terminal={}", fail_step.status.is_pending(), fail_step.status.is_terminal()); + assert!(fail_step.status.is_terminal()); + + // 4. 跳过路径 + let mut skipped = Step::new(0, "可选步骤"); + skipped.status = StepStatus::Skipped; + println!("Skipped: terminal={}", skipped.status.is_terminal()); + assert!(skipped.status.is_terminal()); + + // 5. 错误路径 1:非法 JSON + println!("\n=== 错误路径:非法 JSON ==="); + let err = parser.parse("not json", "goal").await.unwrap_err(); + println!("错误: {err}"); + assert!(matches!(err, AgentError::PlanParse(_))); + + // 6. 错误路径 2:空 steps + println!("\n=== 错误路径:空 steps ==="); + let err = parser.parse(r#"{"steps": []}"#, "goal").await.unwrap_err(); + println!("错误: {err}"); + assert!(matches!(err, AgentError::PlanParse(_))); + + // 7. 错误路径 3:缺 description 字段 + println!("\n=== 错误路径:缺 description 字段 ==="); + let bad_input = r#"{"steps": [{"description": "ok"}, {"no_desc": true}]}"#; + let err = parser.parse(bad_input, "goal").await.unwrap_err(); + println!("错误: {err}"); + assert!(matches!(err, AgentError::PlanParse(_))); + + println!("\n✓ task_agent_demo 完成"); +} \ No newline at end of file