Skip to main content

pulsehive_core/
agent.rs

1//! Agent definition types and workflow composition.
2//!
3//! Agents are defined as data ([`AgentDefinition`] + [`AgentKind`]) and composed
4//! declaratively. The framework handles execution — products only define
5//! what the agent knows and can do.
6//!
7//! # Example
8//! ```rust,ignore
9//! let agent = AgentDefinition {
10//!     name: "researcher".into(),
11//!     kind: AgentKind::Llm(LlmAgentConfig {
12//!         system_prompt: "You are a research assistant.".into(),
13//!         tools: vec![Arc::new(WebSearch)],
14//!         lens: Lens::new(["research", "papers"]),
15//!         llm_config: LlmConfig::new("openai", "gpt-4"),
16//!         experience_extractor: None,
17//!     }),
18//! };
19//! ```
20
21use std::sync::Arc;
22
23use crate::lens::Lens;
24use crate::llm::LlmConfig;
25use crate::tool::Tool;
26
27/// Blueprint for creating an agent. Not a running agent — just configuration.
28///
29/// The framework instantiates and runs agents internally via `HiveMind::deploy()`.
30/// `Clone` is supported: tools and extractors use `Arc` for cheap reference-counted sharing.
31#[derive(Clone)]
32pub struct AgentDefinition {
33    /// Human-readable name for this agent.
34    pub name: String,
35    /// What kind of agent this is (LLM-powered or workflow orchestrator).
36    pub kind: AgentKind,
37}
38
39/// Determines how an agent executes.
40#[derive(Clone)]
41pub enum AgentKind {
42    /// LLM-powered agent with tools and lens-based perception.
43    ///
44    /// Boxed because `LlmAgentConfig` is large (~232 bytes) while workflow
45    /// variants are small (~24 bytes). Boxing keeps the enum size uniform.
46    Llm(Box<LlmAgentConfig>),
47
48    /// Runs sub-agents sequentially — each sees previous agents' experiences.
49    Sequential(Vec<AgentDefinition>),
50
51    /// Runs sub-agents in parallel — all share substrate in real-time.
52    Parallel(Vec<AgentDefinition>),
53
54    /// Repeats a sub-agent until max_iterations or completion signal.
55    Loop {
56        agent: Box<AgentDefinition>,
57        max_iterations: usize,
58    },
59}
60
61/// Configuration for an LLM-powered agent.
62///
63/// `Clone` is supported: tools and extractors are `Arc`-wrapped for cheap sharing
64/// across workflow iterations (Loop) and concurrent tasks (Parallel).
65#[derive(Clone)]
66pub struct LlmAgentConfig {
67    /// System prompt that specializes this agent's behavior.
68    pub system_prompt: String,
69
70    /// Tools this agent can invoke during the Act phase.
71    pub tools: Vec<Arc<dyn Tool>>,
72
73    /// How this agent perceives the substrate.
74    pub lens: Lens,
75
76    /// Which LLM provider and model to use.
77    pub llm_config: LlmConfig,
78
79    /// Optional override for experience extraction logic.
80    /// `None` uses the framework's default extractor.
81    pub experience_extractor: Option<Arc<dyn ExperienceExtractor>>,
82
83    /// How often to re-perceive the substrate during the Think→Act loop.
84    ///
85    /// When set to `Some(n)`, the agent re-runs the Perceive phase every `n` tool calls,
86    /// picking up new experiences recorded by other agents in the same collective.
87    /// This enables real-time "shared consciousness" in parallel workflows.
88    ///
89    /// `None` disables mid-task refresh (perceive only once at start).
90    pub refresh_every_n_tool_calls: Option<usize>,
91}
92
93/// Context passed to the experience extractor during the Record phase.
94#[derive(Debug, Clone)]
95pub struct ExtractionContext {
96    /// ID of the agent whose conversation is being extracted.
97    pub agent_id: String,
98    /// Collective where extracted experiences will be stored.
99    pub collective_id: pulsedb::CollectiveId,
100    /// Description of the task the agent was working on.
101    pub task_description: String,
102}
103
104/// Trait for extracting experiences from an agent's conversation history.
105///
106/// The default implementation (provided by the framework) uses simple rules
107/// to create experiences from the agent's outcome. Products can override
108/// this to implement custom extraction logic (e.g., LLM-based summarization).
109#[async_trait::async_trait]
110pub trait ExperienceExtractor: Send + Sync {
111    /// Extract experiences from a completed agent conversation.
112    ///
113    /// Called after the agentic loop completes. Returns experiences to be
114    /// stored in the substrate for future perception by other agents.
115    async fn extract(
116        &self,
117        conversation: &[crate::llm::Message],
118        outcome: &AgentOutcome,
119        context: &ExtractionContext,
120    ) -> Vec<pulsedb::NewExperience>;
121}
122
123/// Outcome of an agent's execution.
124#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
125#[serde(tag = "status", rename_all = "snake_case")]
126pub enum AgentOutcome {
127    /// Agent completed successfully with a final response.
128    Complete { response: String },
129    /// Agent encountered an error.
130    ///
131    /// Uses `String` instead of `PulseHiveError` because `AgentOutcome`
132    /// must be `Clone` (used in `HiveEvent` which requires `Clone` for broadcast).
133    Error { error: String },
134    /// Agent hit the maximum iteration limit without completing.
135    MaxIterationsReached,
136}
137
138/// Compact tag for agent kind, used in [`HiveEvent`](crate::event::HiveEvent) variants.
139///
140/// Carries no data — just identifies the type of agent for event reporting.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
142#[serde(rename_all = "snake_case")]
143pub enum AgentKindTag {
144    Llm,
145    Sequential,
146    Parallel,
147    Loop,
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn test_agent_outcome_debug_clone() {
156        let outcome = AgentOutcome::Complete {
157            response: "Done!".into(),
158        };
159        let cloned = outcome.clone();
160        assert!(matches!(cloned, AgentOutcome::Complete { response } if response == "Done!"));
161
162        // Debug works
163        let debug = format!("{:?}", outcome);
164        assert!(debug.contains("Complete"));
165    }
166
167    #[test]
168    fn test_agent_outcome_variants() {
169        let complete = AgentOutcome::Complete {
170            response: "result".into(),
171        };
172        assert!(matches!(complete, AgentOutcome::Complete { .. }));
173
174        let error = AgentOutcome::Error {
175            error: "timeout".into(),
176        };
177        assert!(matches!(error, AgentOutcome::Error { .. }));
178
179        let max = AgentOutcome::MaxIterationsReached;
180        assert!(matches!(max, AgentOutcome::MaxIterationsReached));
181    }
182
183    #[test]
184    fn test_agent_kind_tag() {
185        assert_ne!(AgentKindTag::Llm, AgentKindTag::Sequential);
186        assert_eq!(AgentKindTag::Loop, AgentKindTag::Loop);
187
188        // Copy works
189        let tag = AgentKindTag::Parallel;
190        let copied = tag;
191        assert_eq!(tag, copied);
192    }
193
194    #[test]
195    fn test_sequential_workflow() {
196        let workflow = AgentDefinition {
197            name: "pipeline".into(),
198            kind: AgentKind::Sequential(vec![
199                AgentDefinition {
200                    name: "step1".into(),
201                    kind: AgentKind::Sequential(vec![]), // empty placeholder
202                },
203                AgentDefinition {
204                    name: "step2".into(),
205                    kind: AgentKind::Sequential(vec![]),
206                },
207            ]),
208        };
209        assert_eq!(workflow.name, "pipeline");
210        match workflow.kind {
211            AgentKind::Sequential(children) => assert_eq!(children.len(), 2),
212            _ => panic!("Expected Sequential"),
213        }
214    }
215
216    #[test]
217    fn test_nested_workflow() {
218        // Sequential(Parallel(a, b), Loop(c))
219        let workflow = AgentDefinition {
220            name: "complex".into(),
221            kind: AgentKind::Sequential(vec![
222                AgentDefinition {
223                    name: "explore".into(),
224                    kind: AgentKind::Parallel(vec![
225                        AgentDefinition {
226                            name: "explorer_a".into(),
227                            kind: AgentKind::Sequential(vec![]),
228                        },
229                        AgentDefinition {
230                            name: "explorer_b".into(),
231                            kind: AgentKind::Sequential(vec![]),
232                        },
233                    ]),
234                },
235                AgentDefinition {
236                    name: "refine".into(),
237                    kind: AgentKind::Loop {
238                        agent: Box::new(AgentDefinition {
239                            name: "refiner".into(),
240                            kind: AgentKind::Sequential(vec![]),
241                        }),
242                        max_iterations: 5,
243                    },
244                },
245            ]),
246        };
247        assert_eq!(workflow.name, "complex");
248    }
249
250    #[test]
251    fn test_loop_workflow() {
252        let looped = AgentDefinition {
253            name: "iterator".into(),
254            kind: AgentKind::Loop {
255                agent: Box::new(AgentDefinition {
256                    name: "worker".into(),
257                    kind: AgentKind::Sequential(vec![]),
258                }),
259                max_iterations: 10,
260            },
261        };
262        match looped.kind {
263            AgentKind::Loop { max_iterations, .. } => assert_eq!(max_iterations, 10),
264            _ => panic!("Expected Loop"),
265        }
266    }
267}