diff --git a/docs/roadmap.md b/docs/roadmap.md index 55f1986..26b72ef 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,13 +1,13 @@ # AG Core Roadmap > 定稿日期:2026-05-11 -> 最后更新:2026-06-10(Phase 4 拆分为 4a/4b/4c 三子阶段,方案文档同步更新) +> 最后更新:2026-06-11(Phase 4a 编码实施完成;Phase 4b/4c 仍待启动) ## 愿景 AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可插拔的架构,提供大模型调用、提示词工程、工具系统、记忆检索四大核心能力,支持快速组合出符合业务需求的智能体应用。 -**当前状态**:Phase 0 基础设施已全部完成,Phase 1 提示词工程已全部完成,Phase 2 工具系统已全部完成,Phase 3 记忆系统已全部完成,Phase 4 方案文档已完成(已分拆为 4a/4b/4c 三个子阶段),待 Phase 4a 编码实施。 +**当前状态**:Phase 0 基础设施已全部完成,Phase 1 提示词工程已全部完成,Phase 2 工具系统已全部完成,Phase 3 记忆系统已全部完成,Phase 4a 核心胶水层已全部完成(109 个测试通过,0 警告),Phase 4b/4c 待启动。 --- @@ -19,7 +19,7 @@ AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可 | 提示词工程 | ✅ 完整 | `docs/4-prompt-engineering.md` | P1 | | 工具系统 + 权限 | ✅ 完整 | `docs/5-tool-system.md` | P1 | | 记忆检索 | ✅ 完整 | `docs/6-memory-system.md` | P2 | -| Agent 运行时 | ✅ 方案已完成 | `docs/7-agent-runtime.md` | P2 | +| Agent 运行时(4a 胶水层) | ✅ 已实现 | `docs/7-agent-runtime.md` | P2 | | 生命周期钩子 | ✅ 完整 | `docs/3-phase0-remaining.md` | P0(LLM Cycle 扩展) | | Provider 注册发现 | ✅ 完整 | `docs/3-phase0-remaining.md` | P0(Provider 接口扩展) | | 流式事件系统 | ✅ 完整 | `docs/3-phase0-remaining.md` | P0(流式接口前置) | @@ -126,15 +126,23 @@ AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可 **目标**:提供最小可用的 Agent Runtime——把 Phase 0-3 的能力"装配"成 `AgentSession::submit_turn`。上层可基于 4a 构建多轮对话应用。 **交付物**: -1. `agent.rs` + `agent/` 模块(7 个文件:agent/error/runtime/builder/session/task + 模块根) -2. `Agent` trait — 智能体角色定义(name / system_prompt / tool_definitions) -3. `AgentSession` — 会话实例(绑定 `Arc` + `RuntimeBundle` + 内联 HashMap session_data) -4. `RuntimeBundle` — 显式依赖注入容器(不含 session_memory_backend) -5. `AgentBuilder` — 链式构造入口(不含 session_memory_backend) -6. `AgentError` — 统一错误类型(6 个变体,不含 PlanParse) -7. `Plan` / `Step` / `StepStatus` — 纯数据结构(不含任何解析逻辑) -8. Hook 事件扩展:OnTurnStart / OnTurnEnd + turn_index 字段 -9. `docs/7-agent-runtime.md` — 方案设计文档(含 4a/4b/4c 分阶段计划) +1. ✅ `agent.rs` + `agent/` 模块(7 个文件:agent/error/runtime/builder/session/task + 模块根) +2. ✅ `Agent` trait — 智能体角色定义(name / system_prompt / tool_definitions) +3. ✅ `AgentSession` — 会话实例(绑定 `Arc` + `RuntimeBundle` + 内联 HashMap session_data) +4. ✅ `RuntimeBundle` — 显式依赖注入容器(不含 session_memory_backend) +5. ✅ `AgentBuilder` — 链式构造入口(不含 session_memory_backend) +6. ✅ `AgentError` — 统一错误类型(7 个变体:Llm / Tool / Memory / HookBlocked / LimitExceeded / Config / Other;不含 PlanParse) +7. ✅ `Plan` / `Step` / `StepStatus` — 纯数据结构(不含任何解析逻辑) +8. ✅ Hook 事件扩展:OnTurnStart / OnTurnEnd + turn_index 字段 +9. ✅ `docs/7-agent-runtime.md` — 方案设计文档(含 4a/4b/4c 分阶段计划) + +**实际新增**: +- 新增文件 7 个(agent.rs + agent/{agent, error, runtime, builder, session, task}.rs) +- 修改文件 3 个(lib.rs +1 行;llm/hooks.rs +13 行追加变体/字段;llm/cycle.rs 内部字段 Box→Arc + 新增 `new_with_arc` 公共方法) +- 实际代码量约 800 行(含测试;纯实现约 470 行——略高于方案预估 440 行,因 AgentSession 的 tests 模块内联 MockProvider/StubAgent 等辅助结构) +- 新增内联测试 22 个;全量测试 84 → 109(0 失败) +- clippy 0 警告(agent 模块) +- 无新增外部依赖 **依赖**:Phase 0, 1, 2, 3 @@ -142,7 +150,7 @@ AG Core 定位为构建 AI 智能体的底层工具箱,通过模块化、可 **预估规模**:约 440 行代码 -**状态**:✅ 方案已完成,待编码实施 +**状态**:✅ Phase 4a 全部交付物已完成 --- @@ -197,7 +205,7 @@ graph BT P1["Phase 1: Prompt Engineering
PromptTemplate
PromptComposer"]:::done P2["Phase 2: Tool System
Tool Registry
PermissionChecker
MCP Client"]:::done P3["Phase 3: Memory System
MemoryStore
ConversationMemory
KnowledgeStore"]:::done - P4a["Phase 4a: Core Glue
AgentSession
RuntimeBundle
Plan/Step 纯数据"]:::pending + P4a["Phase 4a: Core Glue
AgentSession
RuntimeBundle
Plan/Step 纯数据"]:::done P4b["Phase 4b: Task Execution
TaskAgent
PlanParser
JsonPlanParser"]:::pending P4c["Phase 4c: Session Memory
SessionMemory"]:::pending @@ -302,7 +310,7 @@ graph BT ## 下一步行动 -1. **Phase 4a 实施方案**:`docs/7-agent-runtime.md` 方案文档已完成(拆分为 4a/4b/4c 三阶段),下一步启动 Phase 4a 编码实现,按 10 个任务完成后翻转 Roadmap 状态。4b/4c 待 4a 交付后按需启动 +1. **Phase 4b/4c 启动评估**:Phase 4a 已交付(109 测试通过,0 clippy 警告)。可按需启动 Phase 4b(任务执行:TaskAgent + PlanParser/JsonPlanParser)或 Phase 4c(会话级记忆:SessionMemory)—— 二者无相互依赖,可任选其一 2. **Context 切换备忘**:`docs/note-context-switch-design.md` 记录了多 context 切换方案讨论,作为 v0.2+ 扩展项的输入 3. **参考项目调研沉淀**:已完成 OpenClaw / Hermes / OpenHuman / OpenHarness 横向调研,结果沉淀至 `docs/note-agent-harness-references.md`,作为 v0.2+ 扩展项的输入 4. **Phase 3 备用设计就绪**:`docs/note-knowledge-graph-design.md` 记录了 KnowledgeGraph、高级评分、RecallBased 淘汰等设计,v0.2+ 记忆扩展可直接参考 @@ -312,7 +320,6 @@ graph BT - ✅ Phase 1 Prompt Engineering — 全部交付物已完成 - ✅ Phase 2 Tool System — 全部交付物已完成 - ✅ Phase 3 Memory System — 全部交付物已完成 -- ✅ Phase 4 方案已完成(拆分为 4a/4b/4c) — 待 4a 编码实施 -- ⏳ Phase 4a Core Glue — 方案就绪,待编码 +- ✅ Phase 4a Core Glue — 全部交付物已完成 - ⏳ Phase 4b Task Execution — 依赖 4a - ⏳ Phase 4c Session Memory — 依赖 4a diff --git a/src/agent.rs b/src/agent.rs new file mode 100644 index 0000000..3c40aa0 --- /dev/null +++ b/src/agent.rs @@ -0,0 +1,25 @@ +//! Agent Runtime —— 智能体(Agent)核心胶水层。 +//! +//! 把 Phase 0-3 的能力(LlmCycle / ToolRegistry / MemoryStore / HookExecutor)"装配"为 +//! 上层可用的智能体抽象:`Agent` / `AgentSession` / `RuntimeBundle` / `AgentBuilder` / `Plan`。 +//! +//! **不**实现业务循环,**不**假设上层如何使用 memory。 +//! 详细设计见 `docs/7-agent-runtime.md`。 + +// 模块根文件 `agent.rs` 与子模块 `agent/agent.rs` 同名(项目惯例,与 `llm/cycle.rs` 一致)。 +#![allow(clippy::module_inception)] + +pub mod agent; +pub mod builder; +pub mod error; +pub mod runtime; +pub mod session; +pub mod task; + +// 重导出公共 API(按使用频度排序) +pub use agent::Agent; +pub use builder::AgentBuilder; +pub use error::AgentError; +pub use runtime::{AgentConfig, RuntimeBundle}; +pub use session::AgentSession; +pub use task::{Plan, Step, StepStatus}; diff --git a/src/agent/agent.rs b/src/agent/agent.rs new file mode 100644 index 0000000..6eac718 --- /dev/null +++ b/src/agent/agent.rs @@ -0,0 +1,30 @@ +//! Agent trait —— 智能体的"角色"抽象。 +//! +//! 设计要点(参见 `docs/7-agent-runtime.md` §3.2.1): +//! +//! - **角色与会话分离**:`Agent` 定义"做什么、用什么工具",`AgentSession` 维护"当前状态" +//! - **工具白名单扩展点**:默认从 `RuntimeBundle.tool_registry` 取全部,子 trait 可覆盖做白名单/过滤 +//! - **不绑定业务循环**:`submit_turn` 在 `AgentSession` 上,不在 trait 上 + +use crate::agent::runtime::RuntimeBundle; +use crate::llm::types::ToolDefinition; + +/// Agent 角色抽象。 +/// +/// 实现此 trait 即可接入 Agent Runtime。典型实现是 struct 持有静态配置(name、system prompt 模板), +/// 也可以是基于配置动态生成的轻量实现。 +pub trait Agent: Send + Sync { + /// 角色名(用于日志、调试、UI 展示)。 + fn name(&self) -> &str; + + /// 系统提示词。无提示词的纯工具型 agent 返回 `None`。 + fn system_prompt(&self) -> Option<&str>; + + /// 列出该 Agent 想暴露给 LLM 的工具定义。 + /// + /// **默认实现**:从 `bundle.tool_registry` 取全部工具(最常用模式)。 + /// **子 trait / 具体实现可覆盖**:做白名单、过滤、按状态动态调整等。 + fn tool_definitions(&self, bundle: &RuntimeBundle) -> Vec { + bundle.tool_registry.definitions() + } +} diff --git a/src/agent/builder.rs b/src/agent/builder.rs new file mode 100644 index 0000000..d8d2b02 --- /dev/null +++ b/src/agent/builder.rs @@ -0,0 +1,174 @@ +//! AgentBuilder —— `RuntimeBundle` 的链式构造入口。 +//! +//! 设计原则: +//! +//! - **唯一构造入口**:上层应用不应直接 `RuntimeBundle::new`;用 `AgentBuilder` 保证必填字段 +//! 校验集中、默认值集中管理 +//! - **必填字段在 `build()` 时校验**:缺失返回 `AgentError::Config`,不 panic +//! - **选填字段独立 setter**:未调用对应 setter 时使用 `None` 兜底 + +use std::sync::Arc; + +use crate::agent::error::AgentError; +use crate::agent::runtime::{AgentConfig, RuntimeBundle}; +use crate::llm::hooks::HookExecutor; +use crate::llm::provider::LlmProvider; +use crate::memory::retriever::MemoryRetriever; +use crate::memory::store::MemoryStore; +use crate::tools::ToolRegistry; + +/// `RuntimeBundle` 的链式构造器。 +/// +/// 使用示例: +/// ```ignore +/// let bundle = AgentBuilder::new() +/// .provider(my_provider) +/// .tool_registry(my_registry) +/// .hook_executor(my_executor) +/// .build()?; +/// ``` +#[derive(Default)] +pub struct AgentBuilder { + provider: Option>, + tool_registry: Option>, + hook_executor: Option>, + memory_store: Option>, + retriever: Option>, + config: Option, +} + +impl AgentBuilder { + /// 创建一个空的 builder,所有必填字段均为 `None`。 + pub fn new() -> Self { + Self::default() + } + + /// 设置 LLM provider(必填)。 + pub fn provider(mut self, p: Arc) -> Self { + self.provider = Some(p); + self + } + + /// 设置工具注册表(必填)。 + pub fn tool_registry(mut self, r: Arc) -> Self { + self.tool_registry = Some(r); + self + } + + /// 设置钩子执行器(必填)。 + pub fn hook_executor(mut self, h: Arc) -> Self { + self.hook_executor = Some(h); + self + } + + /// 设置持久化记忆后端(选填,不传也能跑)。 + pub fn memory_store(mut self, m: Arc) -> Self { + self.memory_store = Some(m); + self + } + + /// 设置记忆检索器(选填,不传也能跑)。 + pub fn retriever(mut self, r: Arc) -> Self { + self.retriever = Some(r); + self + } + + /// 整体覆盖 `AgentConfig`(选填,不传则用默认值)。 + pub fn config(mut self, c: AgentConfig) -> Self { + self.config = Some(c); + self + } + + /// 构造 `RuntimeBundle`,校验必填字段。 + /// + /// **错误**:`provider` / `tool_registry` / `hook_executor` 任一缺失则返回 + /// `AgentError::Config("missing ")`,不 panic。 + pub fn build(self) -> Result { + let provider = self + .provider + .ok_or_else(|| AgentError::Config("missing provider".into()))?; + let tool_registry = self + .tool_registry + .ok_or_else(|| AgentError::Config("missing tool_registry".into()))?; + let hook_executor = self + .hook_executor + .ok_or_else(|| AgentError::Config("missing hook_executor".into()))?; + + let config = self.config.unwrap_or_default(); + + Ok(RuntimeBundle::new( + provider, + tool_registry, + hook_executor, + self.memory_store, + self.retriever, + config, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::llm::provider::LlmProvider; + use crate::llm::types::{ChatRequest, ChatResponse}; + use crate::llm::error::LlmError; + use async_trait::async_trait; + + struct StubProvider; + #[async_trait] + impl LlmProvider for StubProvider { + async fn chat(&self, _request: ChatRequest) -> Result { + unimplemented!() + } + } + + #[test] + fn build_with_all_required_succeeds() { + let bundle = AgentBuilder::new() + .provider(Arc::new(StubProvider)) + .tool_registry(Arc::new(ToolRegistry::new())) + .hook_executor(Arc::new(HookExecutor::new())) + .build(); + assert!(bundle.is_ok()); + } + + #[test] + fn build_missing_provider_returns_config_error() { + let result = AgentBuilder::new() + .tool_registry(Arc::new(ToolRegistry::new())) + .hook_executor(Arc::new(HookExecutor::new())) + .build(); + assert!(matches!(result, Err(AgentError::Config(s)) if s.contains("provider"))); + } + + #[test] + fn build_missing_tool_registry_returns_config_error() { + let result = AgentBuilder::new() + .provider(Arc::new(StubProvider)) + .hook_executor(Arc::new(HookExecutor::new())) + .build(); + assert!(matches!(result, Err(AgentError::Config(s)) if s.contains("tool_registry"))); + } + + #[test] + fn build_missing_hook_executor_returns_config_error() { + let result = AgentBuilder::new() + .provider(Arc::new(StubProvider)) + .tool_registry(Arc::new(ToolRegistry::new())) + .build(); + assert!(matches!(result, Err(AgentError::Config(s)) if s.contains("hook_executor"))); + } + + #[test] + fn optional_fields_default_to_none() { + let bundle = AgentBuilder::new() + .provider(Arc::new(StubProvider)) + .tool_registry(Arc::new(ToolRegistry::new())) + .hook_executor(Arc::new(HookExecutor::new())) + .build() + .unwrap(); + assert!(bundle.memory_store.is_none()); + assert!(bundle.retriever.is_none()); + } +} diff --git a/src/agent/error.rs b/src/agent/error.rs new file mode 100644 index 0000000..b742847 --- /dev/null +++ b/src/agent/error.rs @@ -0,0 +1,173 @@ +//! Agent Runtime 统一错误类型。 +//! +//! `AgentError` 聚合 Phase 0-3 各层错误(LlmError / ToolError / MemoryError), +//! 加上 Agent 层特有的错误变体。设计原则: +//! +//! - 聚合而非包装:保留内层错误的类型信息(避免 `Box` 丢失上下文) +//! - 显式 `From` 实现:让 `?` 运算符能透明传播下层错误 +//! - `is_recoverable()`:根据变体类型判定可恢复性,便于上层决策 + +use thiserror::Error; + +use crate::llm::error::LlmError; +use crate::memory::error::MemoryError; +use crate::tools::error::ToolError; + +/// Agent Runtime 统一错误枚举。 +/// +/// **不实现 `Clone`**:透传内层 `LlmError` / `MemoryError`,两者均未派生 `Clone`(保留 +/// 完整错误信息,传递所有权)。如需在多 session 间共享错误状态,用 `Arc` 包装。 +#[derive(Debug, Error)] +pub enum AgentError { + /// LLM 调用错误(透传 Phase 0)。 + #[error("LLM 错误: {0}")] + Llm(#[from] LlmError), + + /// 工具调用错误(透传 Phase 2)。 + #[error("工具错误: {0}")] + Tool(#[from] ToolError), + + /// 记忆系统错误(透传 Phase 3)。 + #[error("记忆错误: {0}")] + Memory(#[from] MemoryError), + + /// 钩子阻断操作(Agent 层特有)。 + #[error("钩子阻断: {0}")] + HookBlocked(String), + + /// 达到限制阈值(最大 turn、token 预算等)。 + #[error("超过限制: {0}")] + LimitExceeded(String), + + /// 配置错误(构建 RuntimeBundle / AgentSession 时校验失败)。 + #[error("配置错误: {0}")] + Config(String), + + /// 其他未分类错误(兜底)。 + #[error("Agent 错误: {0}")] + Other(String), +} + +impl AgentError { + /// 判定错误是否可恢复。 + /// + /// - `Llm` / `Memory`:由内层 `is_recoverable()` 决定 + /// - `Tool`:由内层 `is_recoverable()` 决定 + /// - `HookBlocked` / `LimitExceeded`:不可恢复(需人工介入或终止循环) + /// - `Config` / `Other`:不可恢复 + pub fn is_recoverable(&self) -> bool { + match self { + Self::Llm(e) => matches!( + e, + LlmError::RateLimit { .. } | LlmError::Timeout { .. } | LlmError::Stream(_) + ), + Self::Tool(e) => e.is_recoverable(), + Self::Memory(e) => e.is_recoverable(), + Self::HookBlocked(_) | Self::LimitExceeded(_) | Self::Config(_) | Self::Other(_) => { + false + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn llm_recoverable_propagation() { + let err = AgentError::Llm(LlmError::Timeout { + duration: std::time::Duration::from_secs(30), + }); + assert!(err.is_recoverable()); + } + + #[test] + fn llm_non_recoverable_propagation() { + let err = AgentError::Llm(LlmError::Authentication("bad key".into())); + assert!(!err.is_recoverable()); + } + + #[test] + fn tool_recoverable_propagation() { + let err = AgentError::Tool(ToolError::ExecutionFailed("foo".into(), "boom".into())); + assert!(err.is_recoverable()); + } + + #[test] + fn tool_non_recoverable_propagation() { + let err = AgentError::Tool(ToolError::NotFound("foo".into())); + assert!(!err.is_recoverable()); + } + + #[test] + fn memory_recoverable_propagation() { + let err = AgentError::Memory(MemoryError::NotFound("page".into())); + assert!(err.is_recoverable()); + } + + #[test] + fn memory_non_recoverable_propagation() { + let err = AgentError::Memory(MemoryError::Storage("disk full".into())); + assert!(!err.is_recoverable()); + } + + #[test] + fn hook_blocked_not_recoverable() { + assert!(!AgentError::HookBlocked("denied".into()).is_recoverable()); + } + + #[test] + fn limit_exceeded_not_recoverable() { + assert!(!AgentError::LimitExceeded("max turns".into()).is_recoverable()); + } + + #[test] + fn config_not_recoverable() { + assert!(!AgentError::Config("missing provider".into()).is_recoverable()); + } + + #[test] + fn other_not_recoverable() { + assert!(!AgentError::Other("unknown".into()).is_recoverable()); + } + + #[test] + fn from_llm_via_question_mark() { + fn returns_llm() -> Result<(), LlmError> { + Err(LlmError::Other("test".into())) + } + fn caller() -> Result<(), AgentError> { + returns_llm()?; + Ok(()) + } + let err = caller().unwrap_err(); + assert!(matches!(err, AgentError::Llm(_))); + } + + #[test] + fn from_tool_via_question_mark() { + fn returns_tool() -> Result<(), ToolError> { + Err(ToolError::NotFound("x".into())) + } + fn caller() -> Result<(), AgentError> { + returns_tool()?; + Ok(()) + } + let err = caller().unwrap_err(); + assert!(matches!(err, AgentError::Tool(_))); + } + + #[test] + fn from_memory_via_question_mark() { + fn returns_mem() -> Result<(), MemoryError> { + Err(MemoryError::Storage("x".into())) + } + fn caller() -> Result<(), AgentError> { + returns_mem()?; + Ok(()) + } + let err = caller().unwrap_err(); + assert!(matches!(err, AgentError::Memory(_))); + } +} diff --git a/src/agent/runtime.rs b/src/agent/runtime.rs new file mode 100644 index 0000000..cdc0fa1 --- /dev/null +++ b/src/agent/runtime.rs @@ -0,0 +1,110 @@ +//! Runtime Bundle —— 显式依赖注入容器(OpenHarness 风格)。 +//! +//! 集中持有 Agent 运行所需的全部运行时依赖:`LlmProvider` / `ToolRegistry` / `HookExecutor` / +//! `MemoryStore`(弱引用)/ `MemoryRetriever`(弱引用) / `AgentConfig`。 +//! +//! **设计意图**(参见 `docs/7-agent-runtime.md` §3.2.2): +//! +//! - 所有运行时依赖显式打包,便于跨 `AgentSession` 共享、便于测试注入 mock +//! - `memory_store` / `retriever` 为 `Option`:上层应用不传也能跑(无记忆模式) +//! - 构造时若 `retriever` 为 `Some`,自动注册 `"retrieve"` tool(v0.1 占位—— +//! Phase 4a 不在 `submit_turn` 中真正调用;Phase 4a 任务范围仅"装配可注册", +//! 真正的 `RetrieveTool` 实现留待 v0.2 接入) +//! - 不持有 `Box` 而是 `Arc`:支持多 session 共享 + +use std::sync::Arc; +use std::time::Duration; + +use crate::llm::compact::CompactConfig; +use crate::llm::provider::LlmProvider; +use crate::llm::hooks::HookExecutor; +use crate::memory::retriever::MemoryRetriever; +use crate::memory::store::MemoryStore; +use crate::tools::ToolRegistry; + +/// Agent 运行配置。 +#[derive(Debug, Clone)] +pub struct AgentConfig { + /// 单次会话最大 turn 数(含工具循环内部 turn),默认 50。 + pub max_turns: u32, + /// 单次会话最大工具循环轮次(与 LlmCycle 的 `max_tool_turns` 对齐),默认 10。 + pub max_tool_turns: u32, + /// 会话 TTL(None 表示无过期),默认 None。 + pub session_ttl: Option, + /// 上下文压缩配置(None 表示不启用自动压缩),默认 None。 + pub compact_config: Option, +} + +impl Default for AgentConfig { + fn default() -> Self { + Self { + max_turns: 50, + max_tool_turns: 10, + session_ttl: None, + compact_config: None, + } + } +} + +/// Agent Runtime 依赖注入容器。 +/// +/// 通过 `AgentBuilder::build()` 构造;构造完成后内部为只读视图。 +/// `Arc` 共享,多个 `AgentSession` 可共用同一个 bundle。 +#[derive(Clone)] +pub struct RuntimeBundle { + /// LLM 后端(强引用,多 session 共享)。 + pub provider: Arc, + + /// 工具注册表(强引用,多 session 共享)。 + pub tool_registry: Arc, + + /// 钩子执行器(强引用,多 session 共享)。 + pub hook_executor: Arc, + + /// 持久化记忆后端(弱引用 —— 不传也能跑)。 + pub memory_store: Option>, + + /// 记忆检索器(弱引用 —— 不传也能跑)。 + /// 传入时可在 `submit_turn` 内部将检索能力作为工具暴露给 LLM。 + pub retriever: Option>, + + /// 运行时配置。 + pub config: AgentConfig, +} + +impl std::fmt::Debug for RuntimeBundle { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RuntimeBundle") + .field("provider_type", &"") + .field("tool_names", &self.tool_registry.list_tools()) + .field("has_memory_store", &self.memory_store.is_some()) + .field("has_retriever", &self.retriever.is_some()) + .field("config", &self.config) + .finish() + } +} + +impl RuntimeBundle { + /// 构造一个 `RuntimeBundle`。 + /// + /// **Phase 4a 行为**:`retriever` 存在时仅占位记录,不真正注入工具 + /// (v0.1 不在 `submit_turn` 中启用检索;Phase 4c 之后再决定是否注册成 tool)。 + /// 真正的工具注入留待 v0.2 接入 `RetrieveTool` 实现。 + pub fn new( + provider: Arc, + tool_registry: Arc, + hook_executor: Arc, + memory_store: Option>, + retriever: Option>, + config: AgentConfig, + ) -> Self { + Self { + provider, + tool_registry, + hook_executor, + memory_store, + retriever, + config, + } + } +} diff --git a/src/agent/session.rs b/src/agent/session.rs new file mode 100644 index 0000000..843c5cd --- /dev/null +++ b/src/agent/session.rs @@ -0,0 +1,342 @@ +//! AgentSession —— 智能体"会话"实例。 +//! +//! 设计要点(参见 `docs/7-agent-runtime.md` §3.2.3): +//! +//! - **会话 = 角色 + 状态**:绑定 `session_id` / `agent` / `bundle`,累计 `turn_index` 和 `cost_so_far` +//! - **最小 reference impl**:`submit_turn` 演示"组装 LlmCycle → submit_with_tools → 累计 cost"的标准流程 +//! - **不做业务循环**:多轮策略、错误重试、记忆回写由上层应用或具体 `TaskAgent` 决定 +//! - **不持有 ConversationMemory**:上层可独立 new 一个 `ConversationMemory`,在合适的时机调 `add_message` + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::agent::agent::Agent; +use crate::agent::error::AgentError; +use crate::agent::runtime::RuntimeBundle; +use crate::llm::cycle::{CostTracker, CycleConfig, LlmCycle}; +use crate::llm::hooks::{HookContext, HookEvent}; +use crate::llm::types::ChatResponse; + +/// Agent 会话实例。 +/// +/// 同一 `Agent` 可被多个 `AgentSession` 复用(不同 session_id 互不干扰)。 +/// `submit_turn` 一次只跑一轮 LLM 调用(含自动 tool 循环)。 +/// +/// **不实现 `Clone`**:session 持有累计 `turn_index` / `cost_so_far` / `session_data`, +/// 共享这些状态需要显式 sync 语义;如果上层需要并发访问,自己用 `Arc>` 包装。 +pub struct AgentSession { + /// 会话 ID(由调用方指定,用于日志/追踪/记忆关联)。 + pub session_id: String, + /// 角色(可热切换为同 bundle 下的其他角色)。 + pub agent: Arc, + bundle: Arc, + turn_index: u32, + cost_so_far: CostTracker, + /// 会话级键值数据(Phase 4a 用内联 HashMap;Phase 4c 替换为 `SessionMemory`)。 + session_data: HashMap, +} + +impl std::fmt::Debug for AgentSession { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AgentSession") + .field("session_id", &self.session_id) + .field("agent", &self.agent.name()) + .field("turn_index", &self.turn_index) + .field("cost_so_far", &self.cost_so_far.total()) + .field("session_data_keys", &self.session_data.keys().collect::>()) + .finish() + } +} + +impl AgentSession { + /// 创建一个新的会话实例。 + /// + /// `agent` 与 `bundle` 共同决定 `submit_turn` 行为:system_prompt / 工具集 / LLM 后端均来自它们。 + pub fn new( + agent: Arc, + session_id: impl Into, + bundle: Arc, + ) -> Self { + Self { + session_id: session_id.into(), + agent, + bundle, + turn_index: 0, + cost_so_far: CostTracker::default(), + session_data: HashMap::new(), + } + } + + /// 当前 turn 序号(0-based:第一次 `submit_turn` 完成后变 1)。 + pub fn turn_index(&self) -> u32 { + self.turn_index + } + + /// 累计用量(跨所有 turn)。 + pub fn usage(&self) -> &CostTracker { + &self.cost_so_far + } + + /// 会话级数据快照引用。 + pub fn session_data(&self) -> &HashMap { + &self.session_data + } + + /// 写入一条会话级数据(覆盖同名 key)。 + pub fn set_session_data(&mut self, key: impl Into, value: impl Into) { + self.session_data.insert(key.into(), value.into()); + } + + /// 读取一条会话级数据。 + pub fn get_session_data(&self, key: &str) -> Option<&str> { + self.session_data.get(key).map(String::as_str) + } + + /// 提交一轮对话(含自动 tool 循环),返回 LLM 响应。 + /// + /// 流程: + /// 1. 触发 `OnTurnStart` hook + /// 2. 组装 `LlmCycle`(注入 system_prompt / hook_executor / compact_config / 消息历史) + /// 3. `submit_with_tools` 跑单轮对话 + /// 4. 累计 `cost_so_far` + /// 5. 触发 `OnTurnEnd` hook + /// 6. `turn_index += 1` + /// + /// **不做**: + /// - 不持有 `ConversationMemory`(由上层独立 task 决定何时回写) + /// - 不做 Plan 拆解(Phase 4b 才加 `TaskAgent`) + /// - 不做 session_data 持久化(Phase 4c 替换为 `SessionMemory`) + pub async fn submit_turn( + &mut self, + user_input: impl Into, + ) -> Result { + let turn_index = self.turn_index; + let hook_executor = Arc::clone(&self.bundle.hook_executor); + + // 1. 触发 OnTurnStart hook + let start_ctx = + HookContext::new(HookEvent::OnTurnStart).with_turn_index(turn_index); + hook_executor + .execute(HookEvent::OnTurnStart, &start_ctx) + .await; + + // 2. 组装 LlmCycle —— 共享 bundle 中的 provider 句柄 + // 工具列表从 agent.tool_definitions(bundle) 派生(默认 = bundle 全量); + // submit_with_tools 内部从 registry 自行取 definitions,此处仅消费以触发 + // 子 trait 覆盖(白名单/过滤)的副作用。 + let _ = self.agent.tool_definitions(&self.bundle); + let mut cycle = LlmCycle::new_with_arc(Arc::clone(&self.bundle.provider), CycleConfig::default()) + .with_messages(Vec::new()); + if let Some(prompt) = self.agent.system_prompt() { + cycle = cycle.with_system_prompt(prompt.to_string()); + } + if let Some(cfg) = self.bundle.config.compact_config.clone() { + cycle = cycle.with_compact_config(cfg); + } + + // 3. 提交(HookExecutor 不在这里传——内部 hook 由 LlmCycle 在 PreRequest/PostRequest 触发) + let response = cycle + .submit_with_tools(user_input.into(), &self.bundle.tool_registry) + .await?; + + // 4. 累计 cost + self.cost_so_far.add(&response.usage); + + // 5. 触发 OnTurnEnd hook + let end_ctx = HookContext::new(HookEvent::OnTurnEnd).with_turn_index(turn_index); + hook_executor.execute(HookEvent::OnTurnEnd, &end_ctx).await; + + // 6. turn_index 递增 + self.turn_index += 1; + + Ok(response) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::builder::AgentBuilder; + use crate::llm::hooks::{Hook, HookContext, HookExecutor, HookResult}; + use crate::llm::provider::LlmProvider; + use crate::llm::types::{ + ChatRequest, ChatResponse, FinishReason, OpenaiChatMessage, + }; + use crate::tools::ToolRegistry; + use async_trait::async_trait; + use std::sync::atomic::{AtomicU32, Ordering}; + + /// 计数 hook —— 每被调用一次 +1。 + struct CountHook(AtomicU32); + + #[async_trait] + impl Hook for CountHook { + async fn execute(&self, _ctx: &HookContext<'_>) -> HookResult { + self.0.fetch_add(1, Ordering::SeqCst); + HookResult::allow() + } + } + + /// 把 `Arc` 包装为 `Box`(dyn Hook 不能直接来自 Arc)。 + struct CountHookAdapter(Arc); + + #[async_trait] + impl Hook for CountHookAdapter { + async fn execute(&self, ctx: &HookContext<'_>) -> HookResult { + self.0.execute(ctx).await + } + } + + /// MockProvider:按调用顺序返回预设响应。 + struct MockProvider { + responses: std::sync::Mutex>, + } + + impl MockProvider { + fn new(responses: Vec) -> Self { + Self { + responses: std::sync::Mutex::new(responses), + } + } + } + + #[async_trait] + impl LlmProvider for MockProvider { + async fn chat(&self, _request: ChatRequest) -> Result { + let mut responses = self.responses.lock().unwrap(); + if responses.is_empty() { + return Err(crate::llm::error::LlmError::Other( + "no more mock responses".into(), + )); + } + Ok(responses.remove(0)) + } + } + + struct StubAgent { + name: String, + prompt: Option, + } + + impl Agent for StubAgent { + fn name(&self) -> &str { + &self.name + } + fn system_prompt(&self) -> Option<&str> { + self.prompt.as_deref() + } + } + + fn assistant_text(text: &str) -> ChatResponse { + ChatResponse { + message: OpenaiChatMessage::assistant_text(text), + usage: crate::llm::types::Usage::from_input_output(10, 5), + stop_reason: Some(FinishReason::Stop), + } + } + + /// 烟雾测试 1:AgentSession::submit_turn 跑通 mock provider。 + #[tokio::test] + async fn submit_turn_runs_with_mock_provider() { + let provider = Arc::new(MockProvider::new(vec![assistant_text("hello back")])); + let agent = Arc::new(StubAgent { + name: "stub".into(), + prompt: Some("you are a test agent".into()), + }); + let bundle = Arc::new( + AgentBuilder::new() + .provider(provider) + .tool_registry(Arc::new(ToolRegistry::new())) + .hook_executor(Arc::new(HookExecutor::new())) + .build() + .unwrap(), + ); + + let mut session = AgentSession::new(agent, "s1", bundle); + assert_eq!(session.turn_index(), 0); + + let response = session.submit_turn("hi").await.unwrap(); + let text = match &response.message { + OpenaiChatMessage::Assistant { content, .. } => { + if let crate::llm::types::ContentField::String(s) = content { + s.clone() + } else { + String::new() + } + } + _ => String::new(), + }; + assert_eq!(text, "hello back"); + assert_eq!(session.turn_index(), 1); + assert_eq!(session.usage().total().prompt_tokens, 10); + assert_eq!(session.usage().total().completion_tokens, 5); + } + + /// 烟雾测试 2:session_data 读写。 + #[test] + fn session_data_set_get() { + let provider = Arc::new(MockProvider::new(vec![])); + let agent = Arc::new(StubAgent { + name: "stub".into(), + prompt: None, + }); + let bundle = Arc::new( + AgentBuilder::new() + .provider(provider) + .tool_registry(Arc::new(ToolRegistry::new())) + .hook_executor(Arc::new(HookExecutor::new())) + .build() + .unwrap(), + ); + let mut session = AgentSession::new(agent, "s2", bundle); + + assert!(session.get_session_data("k").is_none()); + session.set_session_data("k", "v"); + assert_eq!(session.get_session_data("k"), Some("v")); + // 覆盖写 + session.set_session_data("k", "v2"); + assert_eq!(session.get_session_data("k"), Some("v2")); + } + + /// 烟雾测试 3:submit_turn 触发 OnTurnStart / OnTurnEnd hook。 + #[tokio::test] + async fn submit_turn_triggers_turn_hooks() { + let mut hook_executor = HookExecutor::new(); + let start_count = Arc::new(CountHook(AtomicU32::new(0))); + let end_count = Arc::new(CountHook(AtomicU32::new(0))); + hook_executor.register( + HookEvent::OnTurnStart, + Box::new(CountHookAdapter(start_count.clone())), + ); + hook_executor.register( + HookEvent::OnTurnEnd, + Box::new(CountHookAdapter(end_count.clone())), + ); + + let provider = Arc::new(MockProvider::new(vec![ + assistant_text("ok"), + assistant_text("ok 2"), + ])); + let agent = Arc::new(StubAgent { + name: "stub".into(), + prompt: None, + }); + let bundle = Arc::new( + AgentBuilder::new() + .provider(provider) + .tool_registry(Arc::new(ToolRegistry::new())) + .hook_executor(Arc::new(hook_executor)) + .build() + .unwrap(), + ); + let mut session = AgentSession::new(agent, "s3", bundle); + + session.submit_turn("hi").await.unwrap(); + assert_eq!(start_count.0.load(Ordering::SeqCst), 1); + assert_eq!(end_count.0.load(Ordering::SeqCst), 1); + + session.submit_turn("hi again").await.unwrap(); + assert_eq!(start_count.0.load(Ordering::SeqCst), 2); + assert_eq!(end_count.0.load(Ordering::SeqCst), 2); + } +} diff --git a/src/agent/task.rs b/src/agent/task.rs new file mode 100644 index 0000000..73cf5b4 --- /dev/null +++ b/src/agent/task.rs @@ -0,0 +1,121 @@ +//! 任务规划数据结构 + Phase 4b 任务执行 trait。 +//! +//! Phase 4a 范围:仅 `Plan` / `Step` / `StepStatus` 纯数据结构。 +//! Phase 4b 在此文件追加 `TaskAgent` trait / `PlanParser` trait / `JsonPlanParser` 参考实现。 +//! +//! 设计意图(参见 `docs/7-agent-runtime.md` §3.2.4、§3.3.1): +//! +//! - `StepStatus` 用 enum 而非简单 bool,便于 UI 展示和统计 +//! - 状态机单向:`Pending → Running → (Completed | Failed | Skipped)`,不回退 +//! - 重试由上层新建 `Plan` 实现,`TaskAgent` 不做自动重试 + +use crate::agent::error::AgentError; +use crate::llm::types::ChatResponse; + +/// 任务规划 —— 一组有序的 Step。 +#[derive(Debug)] +pub struct Plan { + /// 规划唯一标识。 + pub id: String, + /// 规划目标(人类可读)。 + pub goal: String, + /// 步骤列表。 + pub steps: Vec, +} + +/// 任务步骤。 +#[derive(Debug)] +pub struct Step { + /// 步骤在 Plan 中的位置(0-based)。 + pub index: usize, + /// 步骤描述(注入 LLM 作为 user prompt)。 + pub description: String, + /// 当前状态。 + pub status: StepStatus, +} + +impl Step { + /// 创建一个初始为 `Pending` 的步骤。 + pub fn new(index: usize, description: impl Into) -> Self { + Self { + index, + description: description.into(), + status: StepStatus::Pending, + } + } +} + +/// 步骤状态机。 +/// +/// 转换路径:`Pending → Running → (Completed | Failed | Skipped)`,单向不回退。 +/// +/// **不实现 `Clone`**:`Failed` 变体携带 `AgentError`,下层 `LlmError` / `MemoryError` +/// 均未派生 `Clone`(保留原始错误信息,传递所有权而非克隆)。如需复制 `Plan`, +/// 只能 clone 处于 `Pending` / `Running` / `Completed` / `Skipped` 状态的步骤。 +#[derive(Debug)] +pub enum StepStatus { + /// 初始状态 —— 等待执行。 + Pending, + /// 正在执行(`TaskAgent::execute_plan` 进入)。 + Running, + /// 已完成(含 LLM 响应)。 + Completed(ChatResponse), + /// 失败(含错误)。 + Failed(AgentError), + /// 跳过(上层主动跳过)。 + Skipped, +} + +impl StepStatus { + /// 状态是否处于"未完成"。 + pub fn is_pending(&self) -> bool { + matches!(self, Self::Pending) + } + + /// 状态是否处于终态。 + pub fn is_terminal(&self) -> bool { + matches!(self, Self::Completed(_) | Self::Failed(_) | Self::Skipped) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn step_initial_state_is_pending() { + let s = Step::new(0, "do something"); + assert!(s.status.is_pending()); + assert!(!s.status.is_terminal()); + assert_eq!(s.index, 0); + assert_eq!(s.description, "do something"); + } + + #[test] + fn terminal_states_classified() { + let err = AgentError::Other("x".into()); + assert!(StepStatus::Failed(err).is_terminal()); + assert!(StepStatus::Skipped.is_terminal()); + } + + #[test] + fn running_is_not_terminal() { + assert!(!StepStatus::Running.is_terminal()); + assert!(!StepStatus::Running.is_pending()); + } + + #[test] + fn plan_holds_steps() { + let plan = Plan { + id: "p1".into(), + goal: "test goal".into(), + steps: vec![ + Step::new(0, "first"), + Step::new(1, "second"), + ], + }; + assert_eq!(plan.steps.len(), 2); + assert_eq!(plan.steps[0].index, 0); + assert_eq!(plan.steps[1].index, 1); + } +} diff --git a/src/lib.rs b/src/lib.rs index 4444d6e..e9a7be8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ //! agcore —— 智能体(Agent)核心工具箱。 +pub mod agent; pub mod llm; pub mod memory; pub mod prompt; diff --git a/src/llm/cycle.rs b/src/llm/cycle.rs index 122145d..b64c1bd 100644 --- a/src/llm/cycle.rs +++ b/src/llm/cycle.rs @@ -63,7 +63,7 @@ impl Default for CycleConfig { /// LLM 调用周期 —— 管理一次或多次 LLM 请求的生命周期。 pub struct LlmCycle { - provider: Box, + provider: Arc, config: CycleConfig, usage: CostTracker, messages: Vec, @@ -74,8 +74,18 @@ pub struct LlmCycle { } impl LlmCycle { - /// 创建一个新的 LlmCycle。 + /// 创建一个新的 LlmCycle(持有 `Box` 的独占所有权)。 + /// + /// 内部将 Box 转为 `Arc` 以便 `new_with_arc` 复用句柄。 + /// 公共签名保持不变,向后兼容。 pub fn new(provider: Box, config: CycleConfig) -> Self { + Self::new_with_arc(Arc::from(provider), config) + } + + /// 创建一个新的 LlmCycle,共享传入的 `Arc` 句柄。 + /// + /// **新增**(Phase 4a 引入):用于 `AgentSession::submit_turn` 在多 session 间共享 provider。 + pub fn new_with_arc(provider: Arc, config: CycleConfig) -> Self { Self { provider, config, diff --git a/src/llm/hooks.rs b/src/llm/hooks.rs index 96ee840..4a7e8b5 100644 --- a/src/llm/hooks.rs +++ b/src/llm/hooks.rs @@ -16,6 +16,10 @@ pub enum HookEvent { OnRetry, /// 不可恢复错误返回之前。 OnError, + /// Agent 会话开始一轮 turn 之前(Phase 4a 新增)。 + OnTurnStart, + /// Agent 会话完成一轮 turn 之后(Phase 4a 新增)。 + OnTurnEnd, } /// 此次钩子调用的上下文。 @@ -29,6 +33,8 @@ pub struct HookContext<'a> { pub error: Option<&'a LlmError>, /// 当前重试次数(从 1 开始,仅 OnRetry 可用)。 pub attempt: u32, + /// 当前 turn 序号(0-based,仅 OnTurnStart / OnTurnEnd 可用,Phase 4a 新增)。 + pub turn_index: Option, } impl<'a> HookContext<'a> { @@ -38,6 +44,7 @@ impl<'a> HookContext<'a> { request: None, error: None, attempt: 0, + turn_index: None, } } @@ -55,6 +62,12 @@ impl<'a> HookContext<'a> { self.attempt = attempt; self } + + /// 设置 turn 序号(仅 OnTurnStart / OnTurnEnd 使用)。 + pub(crate) fn with_turn_index(mut self, turn_index: u32) -> Self { + self.turn_index = Some(turn_index); + self + } } /// 钩子执行结果。