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}