use crate::setup::McpBuilderExt;
use aether_core::{
agent_spec::McpConfigSource,
core::{AgentBuilder, AgentHandle, Prompt},
events::{AgentMessage, UserMessage},
mcp::{McpSpawnResult, mcp, run_mcp_task::McpCommand},
};
use aether_project::AgentCatalog;
use llm::ToolDefinition;
use mcp_utils::display_meta::{ToolDisplayMeta, ToolResultMeta};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::{spawn, sync::mpsc};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct ArtifactReference {
pub path: String,
pub relation: String,
pub note: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct StructuredAgentOutput {
pub summary: String,
pub artifacts: Vec<ArtifactReference>,
pub decisions: Vec<String>,
pub next_steps: Vec<String>,
pub details: Option<String>,
}
impl StructuredAgentOutput {
pub fn parse(raw_output: &str) -> Result<Self, String> {
if let Ok(parsed) = serde_json::from_str::<Self>(raw_output) {
return Ok(parsed);
}
if let Some(json_str) = extract_json_from_markdown(raw_output)
&& let Ok(parsed) = serde_json::from_str::<Self>(&json_str)
{
return Ok(parsed);
}
Ok(Self {
summary: "Agent did not return structured output".to_string(),
artifacts: vec![],
decisions: vec![],
next_steps: vec![],
details: Some(raw_output.to_string()),
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct SubAgentTask {
#[serde(alias = "agent_name")]
pub agent_name: String,
pub prompt: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct SpawnSubAgentsInput {
pub tasks: Vec<SubAgentTask>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum SubAgentStatus {
Success,
Error,
Cancelled,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct SubAgentResult {
pub task_id: String,
pub agent_name: String,
pub status: SubAgentStatus,
pub output: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct SpawnSubAgentsOutput {
pub results: Vec<SubAgentResult>,
pub success_count: usize,
pub error_count: usize,
#[serde(rename = "_meta", skip_serializing_if = "Option::is_none")]
#[schemars(skip)]
pub meta: Option<ToolResultMeta>,
}
pub const STRUCTURED_OUTPUT_INSTRUCTIONS: &str = include_str!("structured_output_instructions.md");
pub fn extract_json_from_markdown(text: &str) -> Option<String> {
let json_block_start = text.find("```json")?;
let content_start = json_block_start + 7;
let remaining = &text[content_start..];
let content_end = remaining.find("```")?;
let json_content = remaining[..content_end].trim();
if json_content.is_empty() {
return None;
}
Some(json_content.to_string())
}
pub type ProgressCallback = Box<dyn Fn(&str, &str, &AgentMessage) + Send + Sync>;
pub struct AgentExecutor {
catalog: Arc<AgentCatalog>,
progress_callback: Option<Arc<ProgressCallback>>,
roots: Vec<PathBuf>,
}
impl AgentExecutor {
pub fn new(catalog: AgentCatalog, roots: Vec<PathBuf>) -> Self {
Self { catalog: Arc::new(catalog), progress_callback: None, roots }
}
pub fn with_progress_callback(mut self, callback: ProgressCallback) -> Self {
self.progress_callback = Some(Arc::new(callback));
self
}
pub async fn execute_tasks(&self, tasks: Vec<SubAgentTask>) -> SpawnSubAgentsOutput {
if tasks.is_empty() {
return SpawnSubAgentsOutput { results: vec![], success_count: 0, error_count: 0, meta: None };
}
let task_count = tasks.len();
let first_task = tasks.first().unwrap();
let first_agent_name = first_task.agent_name.clone();
let catalog = Arc::clone(&self.catalog);
let progress_callback = self.progress_callback.clone();
let roots = self.roots.clone();
let handles: Vec<_> = tasks
.into_iter()
.enumerate()
.map(|(i, task)| {
let task_id = format!("task_{i}");
let catalog = Arc::clone(&catalog);
let progress_callback = progress_callback.clone();
let roots = roots.clone();
spawn(async move { execute_single_agent(task_id, task, catalog, progress_callback, roots).await })
})
.collect();
let results: Vec<SubAgentResult> = futures::future::join_all(handles)
.await
.into_iter()
.map(|join_result| {
join_result.unwrap_or_else(|e| SubAgentResult {
task_id: "unknown".to_string(),
agent_name: "unknown".to_string(),
status: SubAgentStatus::Error,
output: None,
error: Some(format!("Task panicked: {e}")),
})
})
.collect();
let success_count = results.iter().filter(|r| matches!(r.status, SubAgentStatus::Success)).count();
let error_count = results.iter().filter(|r| matches!(r.status, SubAgentStatus::Error)).count();
let display_meta = ToolDisplayMeta::new("Spawn agent", format!("{first_agent_name} (1/{task_count})"));
SpawnSubAgentsOutput { results, success_count, error_count, meta: Some(display_meta.into()) }
}
}
#[allow(clippy::too_many_lines)]
async fn execute_single_agent(
task_id: String,
task: SubAgentTask,
catalog: Arc<AgentCatalog>,
progress_callback: Option<Arc<ProgressCallback>>,
roots: Vec<PathBuf>,
) -> SubAgentResult {
let agent_name = task.agent_name.clone();
let result: Result<String, String> = async {
let mut spec = catalog.resolve(&task.agent_name).map_err(|e| e.to_string())?;
if !spec.exposure.agent_invocable {
return Err(format!("Agent '{}' is not agent-invocable", task.agent_name));
}
let McpSpawnResult { tool_definitions, instructions, server_statuses: _, command_tx, event_rx: _, handle: _ } =
spawn_mcps(&spec.mcp_config_sources, roots, catalog.project_root()).await?;
let filtered_tools = spec.tools.apply(tool_definitions);
spec.prompts.push(Prompt::mcp_instructions(instructions));
let (user_tx, mut agent_rx, _agent_handle) = spawn_agent(spec, command_tx, filtered_tools).await?;
let prompt_with_instructions = format!("{}\n\n{}", task.prompt, STRUCTURED_OUTPUT_INSTRUCTIONS);
user_tx
.send(UserMessage::text(&prompt_with_instructions))
.await
.map_err(|e| format!("Failed to send message to agent: {e}"))?;
if let Some(ref callback) = progress_callback {
callback(&task_id, &agent_name, &AgentMessage::text("", "", false, ""));
}
let mut final_output = String::new();
let mut was_cancelled = false;
let mut error_message: Option<String> = None;
while let Some(message) = agent_rx.recv().await {
if let Some(ref callback) = progress_callback {
callback(&task_id, &agent_name, &message);
}
match &message {
AgentMessage::Text { chunk, is_complete, .. } if *is_complete => {
final_output.clone_from(chunk);
}
AgentMessage::Error { message } => {
error_message = Some(message.clone());
break;
}
AgentMessage::Cancelled { message } => {
was_cancelled = true;
error_message = Some(message.clone());
break;
}
AgentMessage::Done => {
break;
}
_ => {}
}
}
if was_cancelled {
return Err(format!("Agent cancelled: {}", error_message.unwrap_or_default()));
}
if let Some(err) = error_message {
return Err(format!("Agent error: {err}"));
}
Ok(final_output)
}
.await;
match result {
Ok(output) => {
SubAgentResult { task_id, agent_name, status: SubAgentStatus::Success, output: Some(output), error: None }
}
Err(error) => {
SubAgentResult { task_id, agent_name, status: SubAgentStatus::Error, output: None, error: Some(error) }
}
}
}
async fn spawn_mcps(
effective_mcp_config_sources: &[McpConfigSource],
roots: Vec<PathBuf>,
project_root: &Path,
) -> Result<McpSpawnResult, String> {
let mut builder = mcp().with_builtin_servers(project_root.to_path_buf(), project_root);
builder = builder.with_roots(roots);
if !effective_mcp_config_sources.is_empty() {
builder = builder
.from_mcp_config_sources(effective_mcp_config_sources)
.await
.map_err(|e| format!("Failed to load mcp configs: {e}"))?;
}
builder.spawn().await.map_err(|e| format!("Failed to spawn MCP manager: {e}"))
}
async fn spawn_agent(
spec: aether_core::agent_spec::AgentSpec,
mcp_tx: mpsc::Sender<McpCommand>,
tools: Vec<ToolDefinition>,
) -> Result<(mpsc::Sender<UserMessage>, mpsc::Receiver<AgentMessage>, AgentHandle), String> {
AgentBuilder::from_spec(&spec, vec![], None)
.await
.map_err(|e| format!("Failed to build agent from spec: {e}"))?
.tools(mcp_tx, tools)
.spawn()
.await
.map_err(|e| format!("Failed to spawn agent: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_valid_json() {
let json = r#"{
"summary": "Found the main entry point",
"artifacts": [{"path": "/src/main.rs", "relation": "read", "note": "entry point"}],
"decisions": ["Use async runtime"],
"nextSteps": ["Implement feature"],
"details": null
}"#;
let result = StructuredAgentOutput::parse(json).expect("Should parse valid JSON");
assert_eq!(result.summary, "Found the main entry point");
assert_eq!(result.artifacts.len(), 1);
assert_eq!(result.artifacts[0].path, "/src/main.rs");
assert_eq!(result.artifacts[0].relation, "read");
assert_eq!(result.decisions.len(), 1);
assert_eq!(result.next_steps.len(), 1);
}
#[test]
fn test_parse_json_in_markdown() {
let markdown = r#"Here's what I found:
```json
{
"summary": "Analyzed the codebase",
"artifacts": [],
"decisions": ["Use Rust"],
"nextSteps": [],
"details": null
}
```
Hope this helps!"#;
let result = StructuredAgentOutput::parse(markdown).expect("Should parse JSON from markdown");
assert_eq!(result.summary, "Analyzed the codebase");
assert_eq!(result.decisions.len(), 1);
assert_eq!(result.decisions[0], "Use Rust");
}
#[test]
fn test_parse_fallback() {
let plain_text = "I analyzed the code and found several issues.";
let result = StructuredAgentOutput::parse(plain_text).expect("Should fallback gracefully");
assert_eq!(result.summary, "Agent did not return structured output");
assert!(result.artifacts.is_empty());
assert!(result.decisions.is_empty());
assert!(result.next_steps.is_empty());
assert_eq!(result.details, Some(plain_text.to_string()));
}
#[test]
fn test_extract_json_from_markdown_valid() {
let markdown = r#"Some text
```json
{"key": "value"}
```
More text"#;
let result = extract_json_from_markdown(markdown);
assert_eq!(result, Some(r#"{"key": "value"}"#.to_string()));
}
#[test]
fn test_extract_json_from_markdown_no_block() {
let text = "Just plain text without code blocks";
let result = extract_json_from_markdown(text);
assert!(result.is_none());
}
#[test]
fn test_extract_json_from_markdown_empty_block() {
let markdown = r"```json
```";
let result = extract_json_from_markdown(markdown);
assert!(result.is_none());
}
#[test]
fn test_extract_json_from_markdown_unclosed() {
let markdown = r#"```json
{"unclosed": true}
"#;
let result = extract_json_from_markdown(markdown);
assert!(result.is_none());
}
#[test]
fn test_sub_agent_task_accepts_snake_case_agent_name() {
let task: SubAgentTask = serde_json::from_value(serde_json::json!({
"agent_name": "codebase-explorer",
"prompt": "Find entrypoints"
}))
.unwrap();
assert_eq!(task.agent_name, "codebase-explorer");
assert_eq!(task.prompt, "Find entrypoints");
}
}