use crate::{ agents::{subagent_task_config::TaskConfig, Agent, AgentConfig, AgentEvent, SessionConfig}, conversation::{ message::{Message, MessageContent}, Conversation, }, prompt_template::render_template, recipe::Recipe, }; use anyhow::{anyhow, Result}; use futures::StreamExt; use rmcp::model::{ ErrorCode, ErrorData, LoggingLevel, LoggingMessageNotificationParam, Notification, ServerNotification, }; use serde::Serialize; use std::future::Future; use std::pin::Pin; use std::sync::Arc; use tokio_util::sync::CancellationToken; use tracing::{debug, info}; pub type OnMessageCallback = Arc; #[derive(Serialize)] pub struct SubagentPromptContext { pub max_turns: usize, pub subagent_id: String, pub task_instructions: String, pub tool_count: usize, pub available_tools: String, } type AgentMessagesFuture = Pin)>> + Send>>; pub struct SubagentRunParams { pub config: AgentConfig, pub recipe: Recipe, pub task_config: TaskConfig, pub return_last_only: bool, pub session_id: String, pub cancellation_token: Option, pub on_message: Option, pub notification_tx: Option>, } pub async fn run_subagent_task(params: SubagentRunParams) -> Result { let return_last_only = params.return_last_only; let (messages, final_output) = get_agent_messages(params).await.map_err(|e| { ErrorData::new( ErrorCode::INTERNAL_ERROR, format!("Failed to execute task: {}", e), None, ) })?; if let Some(output) = final_output { return Ok(output); } Ok(extract_response_text(&messages, return_last_only)) } fn extract_response_text(messages: &Conversation, return_last_only: bool) -> String { if return_last_only { messages .messages() .last() .and_then(|message| { message.content.iter().find_map(|content| match content { crate::conversation::message::MessageContent::Text(text_content) => { Some(text_content.text.clone()) } _ => None, }) }) .unwrap_or_else(|| String::from("No text content in last message")) } else { let all_text_content: Vec = messages .iter() .flat_map(|message| { message.content.iter().filter_map(|content| match content { crate::conversation::message::MessageContent::Text(text_content) => { Some(text_content.text.clone()) } crate::conversation::message::MessageContent::ToolResponse(tool_response) => { if let Ok(result) = &tool_response.tool_result { let texts: Vec = result .content .iter() .filter_map(|content| { if let rmcp::model::RawContent::Text(raw_text_content) = &content.raw { Some(raw_text_content.text.clone()) } else { None } }) .collect(); if !texts.is_empty() { Some(format!("Tool result: {}", texts.join("\n"))) } else { None } } else { None } } _ => None, }) }) .collect(); all_text_content.join("\n") } } pub const SUBAGENT_TOOL_REQUEST_TYPE: &str = "subagent_tool_request"; fn get_agent_messages(params: SubagentRunParams) -> AgentMessagesFuture { Box::pin(async move { let SubagentRunParams { config, recipe, task_config, session_id, cancellation_token, on_message, notification_tx, .. } = params; let system_instructions = recipe.instructions.clone().unwrap_or_default(); let user_task = recipe .prompt .clone() .unwrap_or_else(|| "Begin.".to_string()); let agent = Arc::new(Agent::with_config(config)); agent .update_provider(task_config.provider.clone(), &session_id) .await .map_err(|e| anyhow!("Failed to set provider on sub agent: {}", e))?; for extension in &task_config.extensions { if let Err(e) = agent.add_extension(extension.clone(), &session_id).await { debug!( "Failed to add extension '{}' to subagent: {}", extension.name(), e ); } } let has_response_schema = recipe.response.is_some(); agent .apply_recipe_components(recipe.response.clone(), true) .await; let subagent_prompt = build_subagent_prompt(&agent, &task_config, &session_id, system_instructions).await?; agent.override_system_prompt(subagent_prompt).await; let user_message = Message::user().with_text(user_task); let mut conversation = Conversation::new_unvalidated(vec![user_message.clone()]); if let Some(activities) = recipe.activities { for activity in activities { info!("Recipe activity: {}", activity); } } let session_config = SessionConfig { id: session_id.clone(), schedule_id: None, max_turns: task_config.max_turns.map(|v| v as u32), retry_config: recipe.retry, }; let mut stream = crate::session_context::with_session_id(Some(session_id.to_string()), async { agent .reply(user_message, session_config, cancellation_token) .await }) .await .map_err(|e| anyhow!("Failed to get reply from agent: {}", e))?; while let Some(message_result) = stream.next().await { match message_result { Ok(AgentEvent::Message(msg)) => { if let Some(ref callback) = on_message { callback(&msg); } if let Some(ref tx) = notification_tx { for content in &msg.content { if let Some(notif) = create_tool_notification(content, &session_id) { if tx.send(notif).is_err() { debug!( "Notification receiver dropped for subagent {}", session_id ); } } } } conversation.push(msg); } Ok(AgentEvent::McpNotification(_)) => {} Ok(AgentEvent::HistoryReplaced(updated_conversation)) => { conversation = updated_conversation; } Err(e) => { tracing::error!("Error receiving message from subagent: {}", e); break; } } } let final_output = get_final_output(&agent, has_response_schema).await; Ok((conversation, final_output)) }) } async fn build_subagent_prompt( agent: &Agent, task_config: &TaskConfig, session_id: &str, system_instructions: String, ) -> Result { let tools: Vec<_> = agent .list_tools(session_id, None) .await .into_iter() .filter(super::reply_parts::is_tool_visible_to_model) .collect(); render_template( "subagent_system.md", &SubagentPromptContext { max_turns: task_config .max_turns .expect("TaskConfig always sets max_turns"), subagent_id: session_id.to_string(), task_instructions: system_instructions, tool_count: tools.len(), available_tools: tools .iter() .map(|t| t.name.to_string()) .collect::>() .join(", "), }, ) .map_err(|e| anyhow!("Failed to render subagent system prompt: {}", e)) } async fn get_final_output(agent: &Agent, has_response_schema: bool) -> Option { if has_response_schema { agent .final_output_tool .lock() .await .as_ref() .and_then(|tool| tool.final_output.clone()) } else { None } } pub fn create_tool_notification( content: &MessageContent, subagent_id: &str, ) -> Option { if let MessageContent::ToolRequest(req) = content { let tool_call = req.tool_call.as_ref().ok()?; Some(ServerNotification::LoggingMessageNotification( Notification::new( LoggingMessageNotificationParam::new( LoggingLevel::Info, serde_json::json!({ "type": SUBAGENT_TOOL_REQUEST_TYPE, "subagent_id": subagent_id, "tool_call": { "name": tool_call.name, "arguments": tool_call.arguments } }), ) .with_logger(format!("subagent:{}", subagent_id)), ), )) } else { None } } #[cfg(test)] mod tests { use super::{create_tool_notification, SUBAGENT_TOOL_REQUEST_TYPE}; use crate::conversation::message::MessageContent; use rmcp::model::{CallToolRequestParams, ServerNotification}; use serde_json::json; #[test] fn create_tool_notification_for_tool_request() { let tool_call = CallToolRequestParams::new("developer__shell".to_string()) .with_arguments(json!({"command": "ls"}).as_object().unwrap().clone()); let content = MessageContent::tool_request("req1", Ok(tool_call)); let notification = create_tool_notification(&content, "session_1").expect("expected notification"); let ServerNotification::LoggingMessageNotification(log_notif) = notification else { panic!("expected logging notification"); }; let data = log_notif .params .data .as_object() .expect("expected object data"); assert_eq!( data.get("type").and_then(|v| v.as_str()), Some(SUBAGENT_TOOL_REQUEST_TYPE) ); assert_eq!( data.get("subagent_id").and_then(|v| v.as_str()), Some("session_1") ); let tool_call = data .get("tool_call") .and_then(|v| v.as_object()) .expect("expected tool_call object"); assert_eq!( tool_call.get("name").and_then(|v| v.as_str()), Some("developer__shell") ); } #[test] fn create_tool_notification_ignores_non_tool_request() { let content = MessageContent::text("hello"); assert!(create_tool_notification(&content, "session_1").is_none()); } }