5baa170508
- README 添加 feature 组合表 + 模块级 features 清单 + 升级指南 - 18 个 example 顶部添加 Required features 注释 - roadmap.md 和 roadmap-v0.3.2.md 同步 Phase 26-27 完成状态 - cargo fmt 全量格式化(修复预存格式问题,CI format job 可通过)
387 lines
13 KiB
Rust
387 lines
13 KiB
Rust
//! end_to_end —— 3 工具 + 3 轮对话 + SqliteStore 持久化跨连接验证。
|
||
//! Required features: cargo run --example end_to_end --features "agent,memory-sqlite,provider-openai"
|
||
//!
|
||
//! 运行:`cargo run --example end_to_end`(离线,零配置)
|
||
//!
|
||
//! ## 真实 LLM Provider 切换
|
||
//!
|
||
//! 设置环境变量即可使用真实 LLM Provider:
|
||
//! - `AG_LLM_BASE_URL` —— API 端点(如 `https://api.openai.com/v1`)
|
||
//! - `AG_LLM_API_KEY` —— API key
|
||
//! - `AG_LLM_MODEL` —— 模型名(如 `gpt-4o-mini`)
|
||
//! - `AG_LLM_PROVIDER`(可选)—— Provider 类型,默认 OpenaiChat(OpenAI / DeepSeek / Qwen / Ollama)
|
||
//!
|
||
//! 未设置上述变量时自动降级为 MockProvider,零配置可运行。
|
||
|
||
use std::env;
|
||
use std::sync::Arc;
|
||
|
||
use agcore::agent::{Agent, AgentBuilder, AgentSession};
|
||
use agcore::llm::LlmProvider;
|
||
use agcore::llm::hooks::HookExecutor;
|
||
use agcore::llm::mock::MockProvider;
|
||
use agcore::llm::provider::{ProviderConfig, ProviderType, create_provider};
|
||
use agcore::llm::types::{
|
||
Usage,
|
||
message::{ContentBlock, Message},
|
||
response_v2::{MessageResponse, StopReason},
|
||
};
|
||
use agcore::memory::store::{MemoryStore, SqliteStore};
|
||
use agcore::memory::types::{MemoryFilter, MemoryItem};
|
||
use agcore::tools::{BaseTool, ToolContext, ToolError, ToolRegistry};
|
||
use async_trait::async_trait;
|
||
use serde_json::{Value, json};
|
||
use tempfile::TempDir;
|
||
use time::OffsetDateTime;
|
||
|
||
// === Agent ===
|
||
|
||
struct AssistantAgent;
|
||
impl Agent for AssistantAgent {
|
||
fn name(&self) -> &str {
|
||
"end-to-end assistant"
|
||
}
|
||
fn system_prompt(&self) -> Option<&str> {
|
||
Some("简洁助手,必要时调用工具完成任务。")
|
||
}
|
||
}
|
||
|
||
// === Tools ===
|
||
|
||
struct EchoTool;
|
||
#[async_trait]
|
||
impl BaseTool for EchoTool {
|
||
fn name(&self) -> &str {
|
||
"echo"
|
||
}
|
||
fn description(&self) -> &str {
|
||
"回显输入文本"
|
||
}
|
||
fn parameters(&self) -> Value {
|
||
json!({"type":"object","properties":{"text":{"type":"string"}},"required":["text"]})
|
||
}
|
||
async fn execute(&self, args: Value, _: &ToolContext<'_>) -> Result<Value, ToolError> {
|
||
let text = args.get("text").and_then(|v| v.as_str()).ok_or_else(|| {
|
||
ToolError::InvalidArguments("text".into(), "需要 string 类型的 text 参数".into())
|
||
})?;
|
||
Ok(json!({"echoed": format!("收到: {text}")}))
|
||
}
|
||
}
|
||
|
||
/// 四则运算:'a op b' 格式(ponytail: 基础 +-*/ 不引入 rhai 依赖)。
|
||
struct CalcTool;
|
||
#[async_trait]
|
||
impl BaseTool for CalcTool {
|
||
fn name(&self) -> &str {
|
||
"calc"
|
||
}
|
||
fn description(&self) -> &str {
|
||
"四则运算:'a op b' 格式,op ∈ {+, -, *, /}"
|
||
}
|
||
fn parameters(&self) -> Value {
|
||
json!({"type":"object","properties":{"expr":{"type":"string"}},"required":["expr"]})
|
||
}
|
||
async fn execute(&self, args: Value, _: &ToolContext<'_>) -> Result<Value, ToolError> {
|
||
let expr = args["expr"].as_str().unwrap_or("");
|
||
let parts: Vec<&str> = expr.split_whitespace().collect();
|
||
if parts.len() != 3 {
|
||
return Err(ToolError::InvalidArguments(
|
||
"expr".into(),
|
||
"需要 'a op b' 三段式".into(),
|
||
));
|
||
}
|
||
let a: i64 = parts[0].parse().map_err(|_| {
|
||
ToolError::InvalidArguments("expr".into(), format!("无法解析 '{}'", parts[0]))
|
||
})?;
|
||
let b: i64 = parts[2].parse().map_err(|_| {
|
||
ToolError::InvalidArguments("expr".into(), format!("无法解析 '{}'", parts[2]))
|
||
})?;
|
||
let result = match parts[1] {
|
||
"+" => a + b,
|
||
"-" => a - b,
|
||
"*" => a * b,
|
||
"/" => a
|
||
.checked_div(b)
|
||
.ok_or_else(|| ToolError::InvalidArguments("expr".into(), "除数不能为 0".into()))?,
|
||
op => {
|
||
return Err(ToolError::InvalidArguments(
|
||
"expr".into(),
|
||
format!("不支持的运算符: {op}"),
|
||
));
|
||
}
|
||
};
|
||
Ok(json!({"result": result}))
|
||
}
|
||
}
|
||
|
||
/// 通过 MemoryStore trait 读写笔记:直接持有 Arc<dyn MemoryStore>,
|
||
/// 绕开 AgentSession 封装(NoteTool 在 tool.execute 中直接操作 store)。
|
||
/// 关键前缀 "note:" 用于 list 过滤。
|
||
struct NoteTool {
|
||
store: Arc<dyn MemoryStore>,
|
||
}
|
||
impl NoteTool {
|
||
const PREFIX: &'static str = "note:";
|
||
}
|
||
|
||
#[async_trait]
|
||
impl BaseTool for NoteTool {
|
||
fn name(&self) -> &str {
|
||
"note"
|
||
}
|
||
fn description(&self) -> &str {
|
||
"笔记 save/query: save(key, content) / query()"
|
||
}
|
||
fn parameters(&self) -> Value {
|
||
json!({
|
||
"type":"object",
|
||
"properties":{
|
||
"action":{"type":"string","enum":["save","query"]},
|
||
"key":{"type":"string"},
|
||
"content":{"type":"string"}
|
||
},
|
||
"required":["action"]
|
||
})
|
||
}
|
||
async fn execute(&self, args: Value, _: &ToolContext<'_>) -> Result<Value, ToolError> {
|
||
let action = args["action"].as_str().unwrap_or("");
|
||
match action {
|
||
"save" => {
|
||
let key = args["key"].as_str().unwrap_or("");
|
||
let content = args["content"].as_str().unwrap_or("");
|
||
let item = MemoryItem {
|
||
id: format!("{}{}", Self::PREFIX, key),
|
||
content: content.to_string(),
|
||
metadata: json!({}),
|
||
created_at: OffsetDateTime::now_utc(),
|
||
};
|
||
self.store
|
||
.save(item)
|
||
.await
|
||
.map_err(|e| ToolError::ExecutionFailed("note".into(), e.to_string()))?;
|
||
Ok(json!({"saved": key}))
|
||
}
|
||
"query" => {
|
||
let filter = MemoryFilter {
|
||
prefix: Some(Self::PREFIX.into()),
|
||
..Default::default()
|
||
};
|
||
let items = self
|
||
.store
|
||
.list(&filter)
|
||
.await
|
||
.map_err(|e| ToolError::ExecutionFailed("note".into(), e.to_string()))?;
|
||
let notes: Vec<String> = items.into_iter().map(|i| i.content).collect();
|
||
Ok(json!({"notes": notes}))
|
||
}
|
||
_ => Err(ToolError::InvalidArguments(
|
||
"action".into(),
|
||
format!("未知 action: {action}"),
|
||
)),
|
||
}
|
||
}
|
||
}
|
||
|
||
// === Mock response helper ===
|
||
|
||
fn resp(content: Vec<ContentBlock>, stop: StopReason, u: (u32, u32)) -> MessageResponse {
|
||
MessageResponse {
|
||
id: String::new(),
|
||
model: "mock".into(),
|
||
message: Message::Assistant { content },
|
||
usage: Usage::from_input_output(u.0, u.1),
|
||
stop_reason: stop,
|
||
extra: Default::default(),
|
||
}
|
||
}
|
||
|
||
fn mock_responses() -> Vec<MessageResponse> {
|
||
vec![
|
||
// 第 1 轮:calc(25 * 4) → tool_result(100) → 文本回答
|
||
resp(
|
||
vec![ContentBlock::ToolUse {
|
||
id: "t1".into(),
|
||
name: "calc".into(),
|
||
input: json!({"expr": "25 * 4"}),
|
||
}],
|
||
StopReason::ToolUse,
|
||
(5, 8),
|
||
),
|
||
resp(
|
||
vec![ContentBlock::Text {
|
||
text: "25 * 4 = 100".into(),
|
||
}],
|
||
StopReason::Stop,
|
||
(8, 12),
|
||
),
|
||
// 第 2 轮:note(save, last_calc, "100") → tool_result(saved) → 文本回答
|
||
resp(
|
||
vec![ContentBlock::ToolUse {
|
||
id: "t2".into(),
|
||
name: "note".into(),
|
||
input: json!({"action": "save", "key": "last_calc", "content": "100"}),
|
||
}],
|
||
StopReason::ToolUse,
|
||
(10, 14),
|
||
),
|
||
resp(
|
||
vec![ContentBlock::Text {
|
||
text: "已记录:last_calc = 100".into(),
|
||
}],
|
||
StopReason::Stop,
|
||
(12, 16),
|
||
),
|
||
// 第 3 轮:note(query) → tool_result([100]) → 文本回答
|
||
resp(
|
||
vec![ContentBlock::ToolUse {
|
||
id: "t3".into(),
|
||
name: "note".into(),
|
||
input: json!({"action": "query"}),
|
||
}],
|
||
StopReason::ToolUse,
|
||
(8, 8),
|
||
),
|
||
resp(
|
||
vec![ContentBlock::Text {
|
||
text: "您刚才的计算结果是 100".into(),
|
||
}],
|
||
StopReason::Stop,
|
||
(10, 14),
|
||
),
|
||
// 后续冗余响应(防止队列耗尽报错)
|
||
resp(
|
||
vec![ContentBlock::Text {
|
||
text: "done".into(),
|
||
}],
|
||
StopReason::Stop,
|
||
(1, 1),
|
||
),
|
||
resp(
|
||
vec![ContentBlock::Text {
|
||
text: "done".into(),
|
||
}],
|
||
StopReason::Stop,
|
||
(1, 1),
|
||
),
|
||
resp(
|
||
vec![ContentBlock::Text {
|
||
text: "done".into(),
|
||
}],
|
||
StopReason::Stop,
|
||
(1, 1),
|
||
),
|
||
]
|
||
}
|
||
|
||
// === Provider selection ===
|
||
|
||
fn select_provider() -> Arc<dyn LlmProvider> {
|
||
if env::var("AG_LLM_BASE_URL").is_ok() && env::var("AG_LLM_API_KEY").is_ok() {
|
||
let cfg = ProviderConfig::from_env("AG_LLM").expect("AG_LLM_* 环境变量解析失败");
|
||
let provider_type = env::var("AG_LLM_PROVIDER")
|
||
.ok()
|
||
.and_then(|s| s.parse::<ProviderType>().ok())
|
||
.unwrap_or(ProviderType::OpenaiChat);
|
||
Arc::from(create_provider(provider_type, cfg).expect("Provider 创建失败"))
|
||
} else {
|
||
let found: Vec<&str> = ["AG_LLM_BASE_URL", "AG_LLM_API_KEY", "AG_LLM_MODEL"]
|
||
.iter()
|
||
.filter(|k| env::var(k).is_ok())
|
||
.copied()
|
||
.collect();
|
||
eprintln!(
|
||
"AG_LLM_* 环境变量不完整(检测到: {:?}),回退到 MockProvider",
|
||
found
|
||
);
|
||
Arc::new(MockProvider::new(mock_responses()))
|
||
}
|
||
}
|
||
|
||
// === Main ===
|
||
|
||
#[tokio::main]
|
||
async fn main() {
|
||
println!("=== agcore 端到端演示 ===");
|
||
let dir = TempDir::new().expect("TempDir 创建失败");
|
||
let db_path = dir.path().join("agcore.db");
|
||
let backend: Arc<dyn MemoryStore> =
|
||
Arc::new(SqliteStore::open(&db_path).expect("SqliteStore 打开失败"));
|
||
println!("💾 SqliteStore: {}", db_path.display());
|
||
let provider_label =
|
||
if env::var("AG_LLM_BASE_URL").is_ok() && env::var("AG_LLM_API_KEY").is_ok() {
|
||
"真实 LLM Provider"
|
||
} else {
|
||
"MockProvider (离线回退模式)"
|
||
};
|
||
println!("🔄 Provider: {provider_label}");
|
||
|
||
let mut registry = ToolRegistry::new();
|
||
registry.register(Arc::new(EchoTool)).unwrap();
|
||
registry.register(Arc::new(CalcTool)).unwrap();
|
||
registry
|
||
.register(Arc::new(NoteTool {
|
||
store: backend.clone(),
|
||
}))
|
||
.unwrap();
|
||
println!("🔧 注册工具: {:?}", registry.list_tools());
|
||
|
||
let bundle = Arc::new(
|
||
AgentBuilder::new()
|
||
.provider(select_provider())
|
||
.tool_registry(Arc::new(registry))
|
||
.hook_executor(Arc::new(HookExecutor::new()))
|
||
.build()
|
||
.expect("RuntimeBundle 装配失败"),
|
||
);
|
||
|
||
let mut session = AgentSession::new(Arc::new(AssistantAgent), "e2e-1", bundle.clone());
|
||
|
||
println!("\n第 1 轮 用户: 帮我算 25 * 4");
|
||
let r1 = session
|
||
.submit_turn("帮我算 25 * 4")
|
||
.await
|
||
.expect("turn 1 失败");
|
||
println!(" → 回答: {}", r1.text());
|
||
|
||
println!("\n第 2 轮 用户: 记下来:结果是 100");
|
||
let r2 = session
|
||
.submit_turn("记下来:结果是 100")
|
||
.await
|
||
.expect("turn 2 失败");
|
||
println!(" → 回答: {}", r2.text());
|
||
|
||
println!("\n第 3 轮 用户: 我刚才算了什么?");
|
||
let r3 = session
|
||
.submit_turn("我刚才算了什么?")
|
||
.await
|
||
.expect("turn 3 失败");
|
||
println!(" → 回答: {}", r3.text());
|
||
|
||
let total = session.usage().total();
|
||
println!(
|
||
"\n📊 用量: prompt={}, completion={}, total={}",
|
||
total.prompt_tokens, total.completion_tokens, total.total_tokens
|
||
);
|
||
|
||
println!("\n=== 持久化验证 ===");
|
||
// 显式释放所有对 backend 的 Arc 引用,确保 SqliteStore Connection 真正关闭。
|
||
// 释放顺序:session → bundle(间接持有 NoteTool → backend clone)→ backend 局部变量。
|
||
drop(session); // session.bundle Arc 计数 -1
|
||
drop(bundle); // bundle Arc 计数归零 → registry → NoteTool → backend clone Arc 计数 2→1
|
||
drop(backend); // backend 局部变量 Arc 计数 1→0 → SqliteStore::drop → Connection 自动 close
|
||
let backend2: Arc<dyn MemoryStore> =
|
||
Arc::new(SqliteStore::open(&db_path).expect("重开 SqliteStore 失败"));
|
||
let filter = MemoryFilter {
|
||
prefix: Some("note:".into()),
|
||
..Default::default()
|
||
};
|
||
let items = backend2.list(&filter).await.expect("list 失败");
|
||
println!("✓ 跨连接数据存活: 找到 {} 条 note", items.len());
|
||
assert!(!items.is_empty(), "持久化验证失败:重开后无数据");
|
||
for i in &items {
|
||
println!(" - {} = {}", i.id, i.content);
|
||
}
|
||
|
||
println!("\n✓ 端到端演示完成");
|
||
}
|