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;
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/// A composable agent: skills + scoped tools + a step driver.
55#[async_trait]
56pub trait Agent: Send + Sync {
57    fn id(&self) -> AgentId;
58    fn name(&self) -> &str;
59
60    /// Drive one investigation pass over `ctx`. Implementations decide
61    /// the iteration policy (single pass, fixed-point, until-conclude…).
62    async fn step(&self, ctx: &mut InvestigationContext) -> Result<AgentStepResult, KernelError>;
63}
64
65/// Default-shape agent built from a declared chain of skill ids and a
66/// scoped tool registry. Specialist agents in Phase 5 are constructed
67/// purely as different `GenericAgent` configurations — no new `Agent`
68/// impls required.
69pub struct GenericAgent {
70    id: AgentId,
71    name: String,
72    skills: Vec<Arc<dyn Skill>>,
73    tools: ToolRegistry,
74    /// If true, stop the chain when a skill emits [`NextAction::Conclude`]
75    /// or [`NextAction::Discard`]. Default `true`.
76    pub short_circuit_on_conclude: bool,
77}
78
79impl GenericAgent {
80    pub fn builder(name: impl Into<String>) -> GenericAgentBuilder {
81        GenericAgentBuilder {
82            name: name.into(),
83            skill_ids: Vec::new(),
84            allowed_tools: None,
85            short_circuit_on_conclude: true,
86        }
87    }
88
89    pub fn skills(&self) -> &[Arc<dyn Skill>] {
90        &self.skills
91    }
92
93    pub fn tools(&self) -> &ToolRegistry {
94        &self.tools
95    }
96}
97
98#[async_trait]
99impl Agent for GenericAgent {
100    fn id(&self) -> AgentId {
101        self.id
102    }
103
104    fn name(&self) -> &str {
105        &self.name
106    }
107
108    async fn step(&self, ctx: &mut InvestigationContext) -> Result<AgentStepResult, KernelError> {
109        let mut skills_run = Vec::new();
110        let mut skills_skipped = Vec::new();
111        let mut concluded = false;
112
113        for skill in &self.skills {
114            if concluded && self.short_circuit_on_conclude {
115                skills_skipped.push(skill.id().to_string());
116                continue;
117            }
118            if !skill.applies(ctx) {
119                skills_skipped.push(skill.id().to_string());
120                continue;
121            }
122
123            let outcome = skill.execute(ctx, &self.tools).await?;
124            ctx.confidence = (ctx.confidence + outcome.confidence_delta).clamp(0.0, 1.0);
125            ctx.pending_actions = outcome.next_actions.clone();
126            if outcome
127                .next_actions
128                .iter()
129                .any(|a| matches!(a, NextAction::Conclude | NextAction::Discard))
130            {
131                concluded = true;
132            }
133            skills_run.push(skill.id().to_string());
134        }
135
136        Ok(AgentStepResult {
137            skills_run,
138            skills_skipped,
139            confidence: ctx.confidence,
140            concluded,
141        })
142    }
143}
144
145/// Fluent builder for [`GenericAgent`]. The skill chain and tool whitelist
146/// are resolved against the supplied registries at [`Self::build`] time.
147pub struct GenericAgentBuilder {
148    name: String,
149    skill_ids: Vec<String>,
150    allowed_tools: Option<Vec<String>>,
151    short_circuit_on_conclude: bool,
152}
153
154impl GenericAgentBuilder {
155    pub fn with_skills<I, S>(mut self, ids: I) -> Self
156    where
157        I: IntoIterator<Item = S>,
158        S: Into<String>,
159    {
160        self.skill_ids.extend(ids.into_iter().map(Into::into));
161        self
162    }
163
164    pub fn with_tools<I, S>(mut self, names: I) -> Self
165    where
166        I: IntoIterator<Item = S>,
167        S: Into<String>,
168    {
169        self.allowed_tools = Some(names.into_iter().map(Into::into).collect());
170        self
171    }
172
173    pub fn short_circuit_on_conclude(mut self, v: bool) -> Self {
174        self.short_circuit_on_conclude = v;
175        self
176    }
177
178    pub fn build(
179        self,
180        skills: &SkillRegistry,
181        tools: &ToolRegistry,
182    ) -> Result<GenericAgent, KernelError> {
183        let resolved = skills.resolve_chain(self.skill_ids.iter())?;
184        let scoped_tools = match self.allowed_tools {
185            Some(list) => tools.scoped(list),
186            None => tools.clone(),
187        };
188        Ok(GenericAgent {
189            id: AgentId::new(),
190            name: self.name,
191            skills: resolved,
192            tools: scoped_tools,
193            short_circuit_on_conclude: self.short_circuit_on_conclude,
194        })
195    }
196}