Skip to main content

rig_compose/
delegate.rs

1//! Agent delegation primitives — **the default path for agent-to-agent
2//! collaboration.**
3//!
4//! A delegate is another agent-shaped capability exposed as a normal
5//! [`Tool`]. The parent agent's model still decides when to call it;
6//! the runtime merely provides an implementation. This keeps delegation
7//! agentic while letting hosts run child agents in-process when that is
8//! cheaper than MCP or another remote transport.
9//!
10//! Because the surface is [`Tool`], delegation is symmetric across
11//! transports: a child agent registered in-process today can be moved
12//! behind an MCP server tomorrow with no change to the parent — the
13//! parent emits the same tool call.
14//!
15//! For deterministic, no-LLM routing by signal tag (e.g. an overseer
16//! fanning one specialist out per partition), see
17//! [`crate::coordinator::CoordinatorAgent`] instead.
18
19use std::sync::Arc;
20
21use async_trait::async_trait;
22use dashmap::DashMap;
23use serde_json::{Value, json};
24
25use crate::agent::Agent;
26use crate::context::{InvestigationContext, Signal};
27use crate::registry::KernelError;
28use crate::tool::{Tool, ToolSchema};
29
30/// Stable key for a delegate executor. Manifests usually reference this
31/// through `delegates[].agent`; when omitted, `delegates[].name` is used.
32pub type DelegateName = String;
33
34/// Async implementation behind a delegate tool.
35#[async_trait]
36pub trait DelegateExecutor: Send + Sync {
37    async fn invoke(&self, args: Value) -> Result<Value, KernelError>;
38}
39
40/// Registry of in-process delegate executors provided by the host.
41#[derive(Clone, Default)]
42pub struct DelegateRegistry {
43    inner: Arc<DashMap<DelegateName, Arc<dyn DelegateExecutor>>>,
44}
45
46impl DelegateRegistry {
47    pub fn new() -> Self {
48        Self::default()
49    }
50
51    pub fn register(&self, name: impl Into<String>, executor: Arc<dyn DelegateExecutor>) {
52        self.inner.insert(name.into(), executor);
53    }
54
55    pub fn get(&self, name: &str) -> Option<Arc<dyn DelegateExecutor>> {
56        self.inner.get(name).map(|v| v.clone())
57    }
58
59    pub fn len(&self) -> usize {
60        self.inner.len()
61    }
62
63    pub fn is_empty(&self) -> bool {
64        self.inner.is_empty()
65    }
66}
67
68/// Tool wrapper around a delegate executor.
69pub struct DelegateTool {
70    schema: ToolSchema,
71    executor: Arc<dyn DelegateExecutor>,
72}
73
74impl DelegateTool {
75    pub fn new(
76        name: impl Into<String>,
77        description: impl Into<String>,
78        executor: Arc<dyn DelegateExecutor>,
79    ) -> Self {
80        Self {
81            schema: ToolSchema {
82                name: name.into(),
83                description: description.into(),
84                args_schema: json!({"type": "object"}),
85                result_schema: json!({"type": "object"}),
86            },
87            executor,
88        }
89    }
90
91    pub fn with_schema(schema: ToolSchema, executor: Arc<dyn DelegateExecutor>) -> Self {
92        Self { schema, executor }
93    }
94}
95
96#[async_trait]
97impl Tool for DelegateTool {
98    fn schema(&self) -> ToolSchema {
99        self.schema.clone()
100    }
101
102    fn name(&self) -> crate::ToolName {
103        self.schema.name.clone()
104    }
105
106    async fn invoke(&self, args: Value) -> Result<Value, KernelError> {
107        self.executor.invoke(args).await
108    }
109}
110
111/// Delegate executor that drives another [`Agent`] in the same process.
112pub struct InProcessAgentDelegate {
113    agent: Arc<dyn Agent>,
114}
115
116impl InProcessAgentDelegate {
117    pub fn new(agent: Arc<dyn Agent>) -> Self {
118        Self { agent }
119    }
120
121    pub fn arc(agent: Arc<dyn Agent>) -> Arc<dyn DelegateExecutor> {
122        Arc::new(Self::new(agent))
123    }
124}
125
126#[async_trait]
127impl DelegateExecutor for InProcessAgentDelegate {
128    async fn invoke(&self, args: Value) -> Result<Value, KernelError> {
129        let mut ctx = context_from_args(args)?;
130        let result = self.agent.step(&mut ctx).await?;
131        Ok(json!({
132            "delegate_kind": "in_process",
133            "agent": self.agent.name(),
134            "result": {
135                "skills_run": result.skills_run,
136                "skills_skipped": result.skills_skipped,
137                "confidence": result.confidence,
138                "concluded": result.concluded,
139            },
140            "context": ctx,
141        }))
142    }
143}
144
145fn context_from_args(args: Value) -> Result<InvestigationContext, KernelError> {
146    if let Some(context) = args.get("context") {
147        return Ok(serde_json::from_value(context.clone())?);
148    }
149    if let Ok(ctx) = serde_json::from_value::<InvestigationContext>(args.clone()) {
150        return Ok(ctx);
151    }
152
153    let entity_id = args
154        .get("entity_id")
155        .and_then(Value::as_str)
156        .unwrap_or("delegate")
157        .to_string();
158    let partition = args
159        .get("partition")
160        .and_then(Value::as_str)
161        .unwrap_or("default")
162        .to_string();
163    let mut ctx = InvestigationContext::new(entity_id, partition);
164    if let Some(confidence) = args.get("confidence").and_then(Value::as_f64) {
165        ctx.confidence = (confidence as f32).clamp(0.0, 1.0);
166    }
167    if let Some(signals) = args.get("signals").and_then(Value::as_array) {
168        ctx.signals.extend(
169            signals
170                .iter()
171                .filter_map(Value::as_str)
172                .map(|s| Signal::new(s.to_string())),
173        );
174    }
175    Ok(ctx)
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use crate::{GenericAgent, Skill, SkillOutcome, SkillRegistry, ToolRegistry};
182
183    struct ConfidenceSkill;
184
185    #[async_trait]
186    impl Skill for ConfidenceSkill {
187        fn id(&self) -> &str {
188            "test.confidence"
189        }
190
191        fn description(&self) -> &str {
192            "raises confidence"
193        }
194
195        fn applies(&self, ctx: &InvestigationContext) -> bool {
196            ctx.has_signal("raise")
197        }
198
199        async fn execute(
200            &self,
201            _ctx: &mut InvestigationContext,
202            _tools: &ToolRegistry,
203        ) -> Result<SkillOutcome, KernelError> {
204            Ok(SkillOutcome::default().with_delta(0.4))
205        }
206    }
207
208    #[tokio::test]
209    async fn in_process_delegate_drives_child_agent() {
210        let skills = SkillRegistry::new();
211        skills.register(Arc::new(ConfidenceSkill));
212        let tools = ToolRegistry::new();
213        let agent = GenericAgent::builder("child")
214            .with_skills(["test.confidence"])
215            .build(&skills, &tools)
216            .expect("agent");
217        let executor = InProcessAgentDelegate::arc(Arc::new(agent));
218        let tool = DelegateTool::new("child_agent", "child", executor);
219        let out = tool
220            .invoke(json!({
221                "entity_id": "host-a",
222                "partition": "lab",
223                "signals": ["raise"],
224            }))
225            .await
226            .expect("invoke");
227        assert_eq!(out["delegate_kind"], "in_process");
228        assert_eq!(out["agent"], "child");
229        assert_eq!(out["result"]["skills_run"][0], "test.confidence");
230        assert!((out["result"]["confidence"].as_f64().unwrap() - 0.4).abs() < 1e-6);
231    }
232}