use std::time::Duration;
use color_eyre::{eyre::bail, Result};
use serde::Deserialize;
use super::{ContentPart, Message, ToolDefinition};
use crate::config::AppConfig;
use crate::llm::LlmClient;
use crate::tools::ToolRegistry;
#[derive(Deserialize)]
pub struct SubAgentConfig {
pub system_prompt: String,
pub message: String,
pub model: Option<String>,
pub max_tokens: Option<u32>,
pub max_rounds: Option<usize>,
pub allowed_tools: Option<Vec<String>>,
pub timeout_secs: Option<u64>,
}
const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
pub struct SubAgentResponse {
pub text: String,
pub round_trips: usize,
}
pub async fn run_subagent(
config: &AppConfig,
subagent_config: SubAgentConfig,
registry: &ToolRegistry,
) -> Result<SubAgentResponse> {
let client = build_client(
config,
subagent_config.model.as_deref(),
subagent_config.max_tokens,
)?;
let all_defs = registry.definitions();
let tool_defs: Vec<ToolDefinition> = match &subagent_config.allowed_tools {
Some(allowed) => all_defs
.into_iter()
.filter(|d| allowed.contains(&d.name))
.collect(),
None => all_defs
.into_iter()
.filter(|d| d.name != "spawn_agent")
.collect(),
};
let model = subagent_config
.model
.as_deref()
.unwrap_or(&config.default_model);
let max_output = subagent_config.max_tokens.unwrap_or(config.max_tokens) as usize;
let envelope = crate::budget::price_envelope(Some(&subagent_config.system_prompt), &tool_defs);
let window = crate::llm::ollama_context_length(config, model)
.await
.unwrap_or_else(|| crate::budget::context_window(config.provider, model));
let ceiling = crate::budget::threshold_tokens(crate::budget::usable_window(window, max_output));
let mut history = vec![Message::user(&subagent_config.message)];
let mut round_trips = 0;
let request_timeout = subagent_config
.timeout_secs
.filter(|secs| *secs > 0)
.map(Duration::from_secs)
.unwrap_or(DEFAULT_REQUEST_TIMEOUT);
let max_rounds = subagent_config.max_rounds.unwrap_or(20);
let mut retried_empty = false;
let mut hit_ceiling = false;
while round_trips < max_rounds {
if should_stop(
envelope + crate::budget::price_history(&history),
ceiling,
last_assistant_text(&history).is_some(),
) {
hit_ceiling = true;
break;
}
round_trips += 1;
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let outcome = match tokio::time::timeout(
request_timeout,
client.send_message_streaming(
&history,
Some(&tool_defs),
Some(&subagent_config.system_prompt),
&tx,
),
)
.await
{
Ok(result) => result?,
Err(_) => bail!(
"Sub-agent LLM request timed out after {}s (round {})",
request_timeout.as_secs(),
round_trips
),
};
drop(tx);
while rx.try_recv().is_ok() {}
let blocks = outcome.blocks;
if blocks.is_empty() {
let last_was_tool_results = history.last().is_some_and(|m| {
m.content
.iter()
.any(|p| matches!(p, ContentPart::ToolResult { .. }))
});
if last_was_tool_results && !retried_empty {
retried_empty = true;
continue;
}
break;
}
retried_empty = false;
history.push(Message::assistant(blocks.clone()));
let tool_uses: Vec<_> = blocks
.iter()
.filter_map(|b| match b {
ContentPart::ToolUse { id, name, input } => {
Some((id.clone(), name.clone(), input.clone()))
}
_ => None,
})
.collect();
if tool_uses.is_empty() {
let text = blocks
.iter()
.filter_map(|b| match b {
ContentPart::Text { text } => Some(text.as_str()),
_ => None,
})
.collect::<Vec<_>>()
.join("");
return Ok(SubAgentResponse { text, round_trips });
}
let mut results = Vec::with_capacity(tool_uses.len());
for (id, name, input) in tool_uses {
let outcome = registry.execute(&name, input).await;
let result_str = super::clamp_tool_result(match outcome {
Ok(r) => r,
Err(e) => {
let err_str = e.to_string();
let sanitized = err_str
.lines()
.next()
.unwrap_or("Unknown error")
.to_string();
format!("Error: {}", sanitized)
}
});
results.push((id, result_str));
}
history.push(Message::tool_results(results));
}
let text = last_assistant_text(&history)
.unwrap_or_else(|| "Sub-agent did not produce a response".to_string());
let text = if hit_ceiling {
format!(
"{}\n\n[Sub-agent stopped after {} round(s): its context window was full. The answer \
above is partial. Split the task or narrow the tools it may use.]",
text, round_trips
)
} else {
text
};
Ok(SubAgentResponse { text, round_trips })
}
fn should_stop(priced: usize, ceiling: usize, has_answer: bool) -> bool {
has_answer && priced > ceiling
}
fn last_assistant_text(history: &[Message]) -> Option<String> {
history.iter().rev().find_map(|message| {
if message.role != super::Role::Assistant {
return None;
}
let text: String = message
.content
.iter()
.filter_map(|part| match part {
ContentPart::Text { text } => Some(text.as_str()),
_ => None,
})
.collect();
(!text.is_empty()).then_some(text)
})
}
fn build_client(
config: &AppConfig,
model_override: Option<&str>,
max_tokens: Option<u32>,
) -> Result<LlmClient> {
let mut cfg = config.clone();
if let Some(model) = model_override {
cfg.default_model = model.to_string();
}
if let Some(tokens) = max_tokens {
cfg.max_tokens = tokens;
}
LlmClient::from_config(&cfg)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn subagent_config_holds_all_fields() {
let config = SubAgentConfig {
system_prompt: "You are Tyler.".to_string(),
message: "Design the contract.".to_string(),
model: Some("claude-haiku".to_string()),
max_tokens: Some(2048),
max_rounds: Some(10),
allowed_tools: Some(vec!["read_file".to_string()]),
timeout_secs: Some(30),
};
assert_eq!(config.system_prompt, "You are Tyler.");
assert_eq!(config.message, "Design the contract.");
assert_eq!(config.model.as_deref(), Some("claude-haiku"));
assert_eq!(config.max_tokens, Some(2048));
assert_eq!(config.max_rounds, Some(10));
assert_eq!(config.allowed_tools.as_ref().unwrap().len(), 1);
assert_eq!(config.timeout_secs, Some(30));
}
#[test]
fn the_default_request_timeout_is_bounded() {
assert_eq!(DEFAULT_REQUEST_TIMEOUT, Duration::from_secs(120));
}
fn resolve_timeout(configured: Option<u64>) -> Duration {
configured
.filter(|secs| *secs > 0)
.map(Duration::from_secs)
.unwrap_or(DEFAULT_REQUEST_TIMEOUT)
}
#[test]
fn a_zero_timeout_falls_back_to_the_default() {
assert_eq!(resolve_timeout(Some(0)), DEFAULT_REQUEST_TIMEOUT);
assert_eq!(resolve_timeout(None), DEFAULT_REQUEST_TIMEOUT);
assert_eq!(resolve_timeout(Some(30)), Duration::from_secs(30));
}
#[test]
fn being_over_budget_with_nothing_to_show_is_not_a_reason_to_stop() {
assert!(!should_stop(100_000, 800, false));
}
#[test]
fn a_round_that_would_risk_an_existing_answer_stops() {
assert!(should_stop(801, 800, true));
assert!(!should_stop(800, 800, true));
}
#[test]
fn the_answer_kept_is_the_last_thing_the_model_said() {
let history = vec![
Message::user("go"),
Message::assistant(vec![ContentPart::Text {
text: "first".to_string(),
}]),
Message::assistant(vec![ContentPart::Text {
text: "second".to_string(),
}]),
];
assert_eq!(last_assistant_text(&history).as_deref(), Some("second"));
}
#[test]
fn a_history_with_no_answer_in_it_reports_none() {
assert!(last_assistant_text(&[Message::user("go")]).is_none());
let tools_only = vec![Message::assistant(vec![ContentPart::ToolUse {
id: "1".to_string(),
name: "read_file".to_string(),
input: serde_json::json!({}),
}])];
assert!(last_assistant_text(&tools_only).is_none());
}
#[test]
fn subagent_response_holds_text_and_round_trips() {
let response = SubAgentResponse {
text: "Done.".to_string(),
round_trips: 3,
};
assert_eq!(response.text, "Done.");
assert_eq!(response.round_trips, 3);
}
}