use crate::agent::{Agent, Conversation, RunContext};
use crate::tool::{Capabilities, Tool, ToolCtx, ToolOutput};
use anyhow::Result;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::sync::Arc;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SubagentProfile {
pub name: String,
pub description: String,
pub tools: Vec<String>,
pub system_prompt: Option<String>,
pub max_turns: u32,
pub model: Option<String>,
pub provider: Option<String>,
pub trusted_output: bool,
}
impl Default for SubagentProfile {
fn default() -> Self {
SubagentProfile {
name: "subagent".into(),
description: "Delegate a self-contained task.".into(),
tools: Vec::new(),
system_prompt: None,
max_turns: 12,
model: None,
provider: None,
trusted_output: false,
}
}
}
pub struct Subagent {
profile: SubagentProfile,
agent: Arc<Agent>,
capabilities: Capabilities,
}
impl Subagent {
pub fn new(profile: SubagentProfile, agent: Arc<Agent>) -> Self {
let child_reads_untrusted = agent
.registry()
.iter()
.any(|t| t.capabilities().untrusted_input);
let capabilities = Capabilities {
untrusted_input: child_reads_untrusted && !profile.trusted_output,
..Capabilities::default()
};
Subagent {
profile,
agent,
capabilities,
}
}
pub fn tool_names(&self) -> Vec<&str> {
self.agent.registry().iter().map(|t| t.name()).collect()
}
}
#[async_trait]
impl Tool for Subagent {
fn name(&self) -> &str {
&self.profile.name
}
fn description(&self) -> &str {
&self.profile.description
}
fn input_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"task": {
"type": "string",
"description": "The complete task, written for someone with no \
memory of this conversation. State the goal, any \
context they need, and what to return."
}
},
"required": ["task"]
})
}
fn read_only(&self) -> bool {
true
}
fn capabilities(&self) -> Capabilities {
self.capabilities
}
async fn call(&self, input: Value, ctx: &ToolCtx) -> Result<ToolOutput> {
let Some(task) = input.get("task").and_then(Value::as_str) else {
return Ok(ToolOutput::err("missing required string argument `task`"));
};
let mut convo = Conversation::user(task);
let cx = RunContext {
tools: Arc::new(ctx.clone()),
approver: Arc::clone(&self.agent.context().approver),
budget: Default::default(),
cancel: ctx.cancel.clone(),
compact_at_tokens: None,
phase: ctx.phase,
hooks: Arc::clone(&self.agent.context().hooks),
queued_input: None,
outbox: self.agent.context().outbox.clone(),
mailbox: None,
};
let (child_events, forwarder) = match &ctx.events {
Some(parent) => {
let parent = parent.clone();
let name = self.profile.name.clone();
let call_id = ctx.call_id.clone();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let task = tokio::spawn(async move {
while let Some(event) = rx.recv().await {
let _ = parent.send(crate::agent::AgentEvent::Nested {
tool: name.clone(),
id: call_id.clone(),
event: Box::new(event),
});
}
});
(Some(tx), Some(task))
}
None => (None, None),
};
let result = self.agent.run_in(&cx, &mut convo, child_events).await;
if let Some(task) = forwarder {
let _ = task.await;
}
let outcome = match result {
Ok(o) => o,
Err(e) => {
return Ok(ToolOutput::err(format!(
"subagent `{}` failed: {e:#}",
self.profile.name
)))
}
};
let mut content = outcome.text;
if content.trim().is_empty() {
content = format!(
"The `{}` subagent finished without producing an answer after {} turns.",
self.profile.name, outcome.turns
);
}
if outcome.exhausted {
content
.push_str("\n\n[note: the subagent ran out of turns, so this may be incomplete]");
}
if outcome.blocked_sends > 0 {
content
.push_str("\n\n[note: the subagent attempted an outbound call that was blocked]");
}
let output = ToolOutput::ok(content);
Ok(if self.capabilities.untrusted_input {
output.from_outside()
} else {
output
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn profile_defaults_are_conservative() {
let p = SubagentProfile::default();
assert!(
p.tools.is_empty(),
"a profile grants no tools unless it says so"
);
assert!(
!p.trusted_output,
"child output is untrusted unless opted out"
);
}
}