Skip to main content

rig_compose/
agent.rs

1//! [`Agent`] — composes a [`SkillRegistry`] slice and a scoped
2//! [`ToolRegistry`] to drive an investigation.
3//!
4//! Agents are *thin*. They do not contain detection logic; that lives in
5//! skills. An agent's only responsibility is selecting which skills apply
6//! and folding their outcomes into the shared [`InvestigationContext`].
7
8use std::fmt;
9use std::sync::Arc;
10
11use async_trait::async_trait;
12use uuid::Uuid;
13
14use crate::context::{InvestigationContext, NextAction};
15use crate::registry::{KernelError, SkillRegistry, ToolRegistry};
16use crate::skill::{Skill, SkillOutcome};
17
18/// Identifier for an agent instance.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
20pub struct AgentId(pub Uuid);
21
22impl AgentId {
23    pub fn new() -> Self {
24        Self(Uuid::new_v4())
25    }
26}
27
28impl Default for AgentId {
29    fn default() -> Self {
30        Self::new()
31    }
32}
33
34impl fmt::Display for AgentId {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        write!(f, "{}", self.0)
37    }
38}
39
40/// Outcome of one [`Agent::step`] call.
41#[derive(Debug, Clone)]
42pub struct AgentStepResult {
43    /// Skills that were considered applicable and executed.
44    pub skills_run: Vec<String>,
45    /// Skills that were applicable but the agent declined to run (e.g.
46    /// because a previous skill emitted [`NextAction::Conclude`]).
47    pub skills_skipped: Vec<String>,
48    /// Final confidence after this step.
49    pub confidence: f32,
50    /// Whether the agent considers the investigation terminated.
51    pub concluded: bool,
52}
53
54/// Lifecycle hook around [`GenericAgent`] step and skill execution.
55///
56/// Hooks are producer-neutral: they do not depend on any telemetry backend.
57/// Downstream crates can implement this trait to emit tracing events, collect
58/// deterministic test records, or account for per-skill work without changing
59/// the [`Agent`] trait itself.
60#[async_trait]
61pub trait AgentLifecycleHook: Send + Sync {
62    /// Called before a [`GenericAgent`] step starts.
63    async fn before_step(
64        &self,
65        _agent: &GenericAgent,
66        _ctx: &InvestigationContext,
67    ) -> Result<(), KernelError> {
68        Ok(())
69    }
70
71    /// Called when a skill is considered by the agent loop.
72    async fn before_skill(
73        &self,
74        _agent: &GenericAgent,
75        _skill_id: &str,
76        _applies: bool,
77        _confidence: f32,
78    ) -> Result<(), KernelError> {
79        Ok(())
80    }
81
82    /// Called after an applicable skill executes.
83    async fn after_skill(
84        &self,
85        _agent: &GenericAgent,
86        _skill_id: &str,
87        _outcome: &SkillOutcome,
88        _confidence: f32,
89    ) -> Result<(), KernelError> {
90        Ok(())
91    }
92
93    /// Called when a step completed successfully.
94    async fn after_step(
95        &self,
96        _agent: &GenericAgent,
97        _result: &AgentStepResult,
98    ) -> Result<(), KernelError> {
99        Ok(())
100    }
101
102    /// Called when the agent loop is aborting because a skill or hook failed.
103    async fn on_step_error(
104        &self,
105        _agent: &GenericAgent,
106        _error: &KernelError,
107    ) -> Result<(), KernelError> {
108        Ok(())
109    }
110}
111
112/// A composable agent: skills + scoped tools + a step driver.
113#[async_trait]
114pub trait Agent: Send + Sync {
115    fn id(&self) -> AgentId;
116    fn name(&self) -> &str;
117
118    /// Drive one investigation pass over `ctx`. Implementations decide
119    /// the iteration policy (single pass, fixed-point, until-conclude…).
120    async fn step(&self, ctx: &mut InvestigationContext) -> Result<AgentStepResult, KernelError>;
121}
122
123/// Default-shape agent built from a declared chain of skill ids and a
124/// scoped tool registry. Specialist agents in Phase 5 are constructed
125/// purely as different `GenericAgent` configurations — no new `Agent`
126/// impls required.
127pub struct GenericAgent {
128    id: AgentId,
129    name: String,
130    skills: Vec<Arc<dyn Skill>>,
131    tools: ToolRegistry,
132    lifecycle_hooks: Vec<Arc<dyn AgentLifecycleHook>>,
133    /// If true, stop the chain when a skill emits [`NextAction::Conclude`]
134    /// or [`NextAction::Discard`]. Default `true`.
135    pub short_circuit_on_conclude: bool,
136}
137
138impl GenericAgent {
139    pub fn builder(name: impl Into<String>) -> GenericAgentBuilder {
140        GenericAgentBuilder {
141            name: name.into(),
142            skill_ids: Vec::new(),
143            allowed_tools: None,
144            lifecycle_hooks: Vec::new(),
145            short_circuit_on_conclude: true,
146        }
147    }
148
149    pub fn skills(&self) -> &[Arc<dyn Skill>] {
150        &self.skills
151    }
152
153    pub fn tools(&self) -> &ToolRegistry {
154        &self.tools
155    }
156}
157
158#[async_trait]
159impl Agent for GenericAgent {
160    fn id(&self) -> AgentId {
161        self.id
162    }
163
164    fn name(&self) -> &str {
165        &self.name
166    }
167
168    async fn step(&self, ctx: &mut InvestigationContext) -> Result<AgentStepResult, KernelError> {
169        let mut skills_run = Vec::new();
170        let mut skills_skipped = Vec::new();
171        let mut concluded = false;
172
173        // Telemetry hook failures must never abort the agent loop, nor mask
174        // the real error returned by a skill. Each `notify_*` helper logs
175        // and swallows hook errors internally.
176        self.notify_before_step(ctx).await;
177
178        for skill in &self.skills {
179            if concluded && self.short_circuit_on_conclude {
180                let skill_id = skill.id();
181                self.notify_before_skill(skill_id, false, ctx.confidence)
182                    .await;
183                skills_skipped.push(skill.id().to_string());
184                continue;
185            }
186            if !skill.applies(ctx) {
187                let skill_id = skill.id();
188                self.notify_before_skill(skill_id, false, ctx.confidence)
189                    .await;
190                skills_skipped.push(skill.id().to_string());
191                continue;
192            }
193
194            let skill_id = skill.id();
195            self.notify_before_skill(skill_id, true, ctx.confidence)
196                .await;
197
198            let outcome = match skill.execute(ctx, &self.tools).await {
199                Ok(outcome) => outcome,
200                Err(error) => {
201                    self.notify_step_error(&error).await;
202                    return Err(error);
203                }
204            };
205            ctx.confidence = (ctx.confidence + outcome.confidence_delta).clamp(0.0, 1.0);
206            ctx.pending_actions = outcome.next_actions.clone();
207            self.notify_after_skill(skill_id, &outcome, ctx.confidence)
208                .await;
209            if outcome
210                .next_actions
211                .iter()
212                .any(|a| matches!(a, NextAction::Conclude | NextAction::Discard))
213            {
214                concluded = true;
215            }
216            skills_run.push(skill_id.to_string());
217        }
218
219        let result = AgentStepResult {
220            skills_run,
221            skills_skipped,
222            confidence: ctx.confidence,
223            concluded,
224        };
225        self.notify_after_step(&result).await;
226        Ok(result)
227    }
228}
229
230impl GenericAgent {
231    async fn notify_before_step(&self, ctx: &InvestigationContext) {
232        for hook in &self.lifecycle_hooks {
233            if let Err(err) = hook.before_step(self, ctx).await {
234                tracing::warn!(error = %err, "lifecycle hook before_step failed; continuing");
235            }
236        }
237    }
238
239    async fn notify_before_skill(&self, skill_id: &str, applies: bool, confidence: f32) {
240        for hook in &self.lifecycle_hooks {
241            if let Err(err) = hook.before_skill(self, skill_id, applies, confidence).await {
242                tracing::warn!(
243                    error = %err,
244                    skill_id,
245                    "lifecycle hook before_skill failed; continuing"
246                );
247            }
248        }
249    }
250
251    async fn notify_after_skill(&self, skill_id: &str, outcome: &SkillOutcome, confidence: f32) {
252        for hook in &self.lifecycle_hooks {
253            if let Err(err) = hook.after_skill(self, skill_id, outcome, confidence).await {
254                tracing::warn!(
255                    error = %err,
256                    skill_id,
257                    "lifecycle hook after_skill failed; continuing"
258                );
259            }
260        }
261    }
262
263    async fn notify_after_step(&self, result: &AgentStepResult) {
264        for hook in &self.lifecycle_hooks {
265            if let Err(err) = hook.after_step(self, result).await {
266                tracing::warn!(error = %err, "lifecycle hook after_step failed; continuing");
267            }
268        }
269    }
270
271    async fn notify_step_error(&self, error: &KernelError) {
272        for hook in &self.lifecycle_hooks {
273            if let Err(err) = hook.on_step_error(self, error).await {
274                tracing::warn!(error = %err, "lifecycle hook on_step_error failed; continuing");
275            }
276        }
277    }
278}
279
280/// Fluent builder for [`GenericAgent`]. The skill chain and tool whitelist
281/// are resolved against the supplied registries at [`Self::build`] time.
282pub struct GenericAgentBuilder {
283    name: String,
284    skill_ids: Vec<String>,
285    allowed_tools: Option<Vec<String>>,
286    lifecycle_hooks: Vec<Arc<dyn AgentLifecycleHook>>,
287    short_circuit_on_conclude: bool,
288}
289
290impl GenericAgentBuilder {
291    pub fn with_skills<I, S>(mut self, ids: I) -> Self
292    where
293        I: IntoIterator<Item = S>,
294        S: Into<String>,
295    {
296        self.skill_ids.extend(ids.into_iter().map(Into::into));
297        self
298    }
299
300    pub fn with_tools<I, S>(mut self, names: I) -> Self
301    where
302        I: IntoIterator<Item = S>,
303        S: Into<String>,
304    {
305        self.allowed_tools = Some(names.into_iter().map(Into::into).collect());
306        self
307    }
308
309    pub fn short_circuit_on_conclude(mut self, v: bool) -> Self {
310        self.short_circuit_on_conclude = v;
311        self
312    }
313
314    /// Add a lifecycle hook invoked around step and skill execution.
315    pub fn with_lifecycle_hook(mut self, hook: Arc<dyn AgentLifecycleHook>) -> Self {
316        self.lifecycle_hooks.push(hook);
317        self
318    }
319
320    pub fn build(
321        self,
322        skills: &SkillRegistry,
323        tools: &ToolRegistry,
324    ) -> Result<GenericAgent, KernelError> {
325        let resolved = skills.resolve_chain(self.skill_ids.iter())?;
326        let scoped_tools = match self.allowed_tools {
327            Some(list) => tools.scoped(list),
328            None => tools.clone(),
329        };
330        Ok(GenericAgent {
331            id: AgentId::new(),
332            name: self.name,
333            skills: resolved,
334            tools: scoped_tools,
335            lifecycle_hooks: self.lifecycle_hooks,
336            short_circuit_on_conclude: self.short_circuit_on_conclude,
337        })
338    }
339}