feat: display subagent tool calls in CLI and UI (#6535)
Signed-off-by: rabi <ramishra@redhat.com>
This commit is contained in:
@@ -21,6 +21,7 @@ use tokio_util::task::AbortOnDropHandle;
|
|||||||
pub use self::export::message_to_markdown;
|
pub use self::export::message_to_markdown;
|
||||||
pub use builder::{build_session, SessionBuilderConfig};
|
pub use builder::{build_session, SessionBuilderConfig};
|
||||||
use console::Color;
|
use console::Color;
|
||||||
|
use goose::agents::subagent_handler::SUBAGENT_TOOL_REQUEST_TYPE;
|
||||||
use goose::agents::AgentEvent;
|
use goose::agents::AgentEvent;
|
||||||
use goose::permission::permission_confirmation::PrincipalType;
|
use goose::permission::permission_confirmation::PrincipalType;
|
||||||
use goose::permission::Permission;
|
use goose::permission::Permission;
|
||||||
@@ -1537,6 +1538,49 @@ fn handle_mcp_notification(
|
|||||||
) {
|
) {
|
||||||
match notification {
|
match notification {
|
||||||
ServerNotification::LoggingMessageNotification(log_notif) => {
|
ServerNotification::LoggingMessageNotification(log_notif) => {
|
||||||
|
if let Some(obj) = log_notif.params.data.as_object() {
|
||||||
|
if obj.get("type").and_then(|v| v.as_str()) == Some(SUBAGENT_TOOL_REQUEST_TYPE) {
|
||||||
|
if let (Some(subagent_id), Some(tool_call)) = (
|
||||||
|
obj.get("subagent_id").and_then(|v| v.as_str()),
|
||||||
|
obj.get("tool_call").and_then(|v| v.as_object()),
|
||||||
|
) {
|
||||||
|
let tool_name = tool_call
|
||||||
|
.get("name")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let arguments = tool_call
|
||||||
|
.get("arguments")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
.cloned();
|
||||||
|
|
||||||
|
if interactive {
|
||||||
|
let _ = progress_bars.hide();
|
||||||
|
}
|
||||||
|
if is_stream_json_mode {
|
||||||
|
emit_stream_event(&StreamEvent::Notification {
|
||||||
|
extension_id: extension_id.to_string(),
|
||||||
|
data: NotificationData::Log {
|
||||||
|
message: output::format_subagent_tool_call_message(
|
||||||
|
subagent_id,
|
||||||
|
tool_name,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !is_json_mode {
|
||||||
|
output::render_subagent_tool_call(
|
||||||
|
subagent_id,
|
||||||
|
tool_name,
|
||||||
|
arguments.as_ref(),
|
||||||
|
debug,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let (formatted, subagent_id, notif_type) =
|
let (formatted, subagent_id, notif_type) =
|
||||||
format_logging_notification(&log_notif.params.data, debug);
|
format_logging_notification(&log_notif.params.data, debug);
|
||||||
|
|
||||||
|
|||||||
@@ -613,21 +613,108 @@ fn render_default_request(call: &CallToolRequestParams, debug: bool) {
|
|||||||
println!();
|
println!();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn split_tool_name(tool_name: &str) -> (String, String) {
|
||||||
|
let parts: Vec<_> = tool_name.rsplit("__").collect();
|
||||||
|
let tool = parts.first().copied().unwrap_or("unknown");
|
||||||
|
let extension = parts
|
||||||
|
.split_first()
|
||||||
|
.map(|(_, s)| s.iter().rev().copied().collect::<Vec<_>>().join("__"))
|
||||||
|
.unwrap_or_default();
|
||||||
|
(tool.to_string(), extension)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn format_subagent_tool_call_message(subagent_id: &str, tool_name: &str) -> String {
|
||||||
|
let short_id = subagent_id.rsplit('_').next().unwrap_or(subagent_id);
|
||||||
|
let (tool, extension) = split_tool_name(tool_name);
|
||||||
|
|
||||||
|
if extension.is_empty() {
|
||||||
|
format!("[subagent:{}] {}", short_id, tool)
|
||||||
|
} else {
|
||||||
|
format!("[subagent:{}] {} | {}", short_id, tool, extension)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_subagent_tool_call(
|
||||||
|
subagent_id: &str,
|
||||||
|
tool_name: &str,
|
||||||
|
arguments: Option<&JsonObject>,
|
||||||
|
debug: bool,
|
||||||
|
) {
|
||||||
|
if tool_name == "code_execution__execute_code" {
|
||||||
|
let tool_graph = arguments
|
||||||
|
.and_then(|args| args.get("tool_graph"))
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.filter(|arr| !arr.is_empty());
|
||||||
|
if let Some(tool_graph) = tool_graph {
|
||||||
|
return render_subagent_tool_graph(subagent_id, tool_graph);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let tool_header = format!(
|
||||||
|
"─── {} ──────────────────────────",
|
||||||
|
style(format_subagent_tool_call_message(subagent_id, tool_name))
|
||||||
|
.magenta()
|
||||||
|
.dim()
|
||||||
|
);
|
||||||
|
println!();
|
||||||
|
println!("{}", tool_header);
|
||||||
|
print_params(&arguments.cloned(), 0, debug);
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_subagent_tool_graph(subagent_id: &str, tool_graph: &[Value]) {
|
||||||
|
let short_id = subagent_id.rsplit('_').next().unwrap_or(subagent_id);
|
||||||
|
let count = tool_graph.len();
|
||||||
|
let plural = if count == 1 { "" } else { "s" };
|
||||||
|
println!();
|
||||||
|
println!(
|
||||||
|
"─── {} {} tool call{} | {} ──────────────────────────",
|
||||||
|
style(format!("[subagent:{}]", short_id)).cyan(),
|
||||||
|
style(count).cyan(),
|
||||||
|
plural,
|
||||||
|
style("execute_code").magenta().dim()
|
||||||
|
);
|
||||||
|
|
||||||
|
for (i, node) in tool_graph.iter().filter_map(Value::as_object).enumerate() {
|
||||||
|
let tool = node
|
||||||
|
.get("tool")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("unknown");
|
||||||
|
let desc = node
|
||||||
|
.get("description")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("");
|
||||||
|
let deps: Vec<_> = node
|
||||||
|
.get("depends_on")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.filter_map(Value::as_u64)
|
||||||
|
.map(|d| (d + 1).to_string())
|
||||||
|
.collect();
|
||||||
|
let deps_str = if deps.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!(" (uses {})", deps.join(", "))
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" {}. {}: {}{}",
|
||||||
|
style(i + 1).dim(),
|
||||||
|
style(tool).cyan(),
|
||||||
|
style(desc).green(),
|
||||||
|
style(deps_str).dim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
|
|
||||||
fn print_tool_header(call: &CallToolRequestParams) {
|
fn print_tool_header(call: &CallToolRequestParams) {
|
||||||
let parts: Vec<_> = call.name.rsplit("__").collect();
|
let (tool, extension) = split_tool_name(&call.name);
|
||||||
let tool_header = format!(
|
let tool_header = format!(
|
||||||
"─── {} | {} ──────────────────────────",
|
"─── {} | {} ──────────────────────────",
|
||||||
style(parts.first().unwrap_or(&"unknown")),
|
style(tool),
|
||||||
style(
|
style(extension).magenta().dim(),
|
||||||
parts
|
|
||||||
.split_first()
|
|
||||||
.map(|(_, s)| s.iter().rev().copied().collect::<Vec<_>>().join("__"))
|
|
||||||
.unwrap_or_else(|| "unknown".to_string())
|
|
||||||
)
|
|
||||||
.magenta()
|
|
||||||
.dim(),
|
|
||||||
);
|
);
|
||||||
println!();
|
println!();
|
||||||
println!("{}", tool_header);
|
println!("{}", tool_header);
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
agents::{subagent_task_config::TaskConfig, Agent, AgentConfig, AgentEvent, SessionConfig},
|
agents::{subagent_task_config::TaskConfig, Agent, AgentConfig, AgentEvent, SessionConfig},
|
||||||
conversation::{message::Message, Conversation},
|
conversation::{
|
||||||
|
message::{Message, MessageContent},
|
||||||
|
Conversation,
|
||||||
|
},
|
||||||
prompt_template::render_template,
|
prompt_template::render_template,
|
||||||
recipe::Recipe,
|
recipe::Recipe,
|
||||||
};
|
};
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use rmcp::model::{ErrorCode, ErrorData};
|
use rmcp::model::{
|
||||||
|
ErrorCode, ErrorData, LoggingLevel, LoggingMessageNotification,
|
||||||
|
LoggingMessageNotificationMethod, LoggingMessageNotificationParam, ServerNotification,
|
||||||
|
};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use std::future::Future;
|
use std::future::Future;
|
||||||
use std::pin::Pin;
|
use std::pin::Pin;
|
||||||
@@ -15,18 +21,17 @@ use tokio_util::sync::CancellationToken;
|
|||||||
use tracing::{debug, info};
|
use tracing::{debug, info};
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct SubagentPromptContext {
|
pub struct SubagentPromptContext {
|
||||||
max_turns: usize,
|
pub max_turns: usize,
|
||||||
subagent_id: String,
|
pub subagent_id: String,
|
||||||
task_instructions: String,
|
pub task_instructions: String,
|
||||||
tool_count: usize,
|
pub tool_count: usize,
|
||||||
available_tools: String,
|
pub available_tools: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
type AgentMessagesFuture =
|
type AgentMessagesFuture =
|
||||||
Pin<Box<dyn Future<Output = Result<(Conversation, Option<String>)>> + Send>>;
|
Pin<Box<dyn Future<Output = Result<(Conversation, Option<String>)>> + Send>>;
|
||||||
|
|
||||||
/// Standalone function to run a complete subagent task with output options
|
|
||||||
pub async fn run_complete_subagent_task(
|
pub async fn run_complete_subagent_task(
|
||||||
config: AgentConfig,
|
config: AgentConfig,
|
||||||
recipe: Recipe,
|
recipe: Recipe,
|
||||||
@@ -35,16 +40,43 @@ pub async fn run_complete_subagent_task(
|
|||||||
session_id: String,
|
session_id: String,
|
||||||
cancellation_token: Option<CancellationToken>,
|
cancellation_token: Option<CancellationToken>,
|
||||||
) -> Result<String, anyhow::Error> {
|
) -> Result<String, anyhow::Error> {
|
||||||
let (messages, final_output) =
|
run_complete_subagent_task_with_notifications(
|
||||||
get_agent_messages(config, recipe, task_config, session_id, cancellation_token)
|
config,
|
||||||
.await
|
recipe,
|
||||||
.map_err(|e| {
|
task_config,
|
||||||
ErrorData::new(
|
return_last_only,
|
||||||
ErrorCode::INTERNAL_ERROR,
|
session_id,
|
||||||
format!("Failed to execute task: {}", e),
|
cancellation_token,
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
})?;
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_complete_subagent_task_with_notifications(
|
||||||
|
config: AgentConfig,
|
||||||
|
recipe: Recipe,
|
||||||
|
task_config: TaskConfig,
|
||||||
|
return_last_only: bool,
|
||||||
|
session_id: String,
|
||||||
|
cancellation_token: Option<CancellationToken>,
|
||||||
|
notification_tx: Option<tokio::sync::mpsc::UnboundedSender<rmcp::model::ServerNotification>>,
|
||||||
|
) -> Result<String, anyhow::Error> {
|
||||||
|
let (messages, final_output) = get_agent_messages_with_notifications(
|
||||||
|
config,
|
||||||
|
recipe,
|
||||||
|
task_config,
|
||||||
|
session_id,
|
||||||
|
cancellation_token,
|
||||||
|
notification_tx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
ErrorData::new(
|
||||||
|
ErrorCode::INTERNAL_ERROR,
|
||||||
|
format!("Failed to execute task: {}", e),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
if let Some(output) = final_output {
|
if let Some(output) = final_output {
|
||||||
return Ok(output);
|
return Ok(output);
|
||||||
@@ -67,40 +99,35 @@ pub async fn run_complete_subagent_task(
|
|||||||
let all_text_content: Vec<String> = messages
|
let all_text_content: Vec<String> = messages
|
||||||
.iter()
|
.iter()
|
||||||
.flat_map(|message| {
|
.flat_map(|message| {
|
||||||
message.content.iter().filter_map(|content| {
|
message.content.iter().filter_map(|content| match content {
|
||||||
match content {
|
crate::conversation::message::MessageContent::Text(text_content) => {
|
||||||
crate::conversation::message::MessageContent::Text(text_content) => {
|
Some(text_content.text.clone())
|
||||||
Some(text_content.text.clone())
|
}
|
||||||
}
|
crate::conversation::message::MessageContent::ToolResponse(tool_response) => {
|
||||||
crate::conversation::message::MessageContent::ToolResponse(
|
if let Ok(result) = &tool_response.tool_result {
|
||||||
tool_response,
|
let texts: Vec<String> = result
|
||||||
) => {
|
.content
|
||||||
// Extract text from tool response
|
.iter()
|
||||||
if let Ok(result) = &tool_response.tool_result {
|
.filter_map(|content| {
|
||||||
let texts: Vec<String> = result
|
if let rmcp::model::RawContent::Text(raw_text_content) =
|
||||||
.content
|
&content.raw
|
||||||
.iter()
|
{
|
||||||
.filter_map(|content| {
|
Some(raw_text_content.text.clone())
|
||||||
if let rmcp::model::RawContent::Text(raw_text_content) =
|
} else {
|
||||||
&content.raw
|
None
|
||||||
{
|
}
|
||||||
Some(raw_text_content.text.clone())
|
})
|
||||||
} else {
|
.collect();
|
||||||
None
|
if !texts.is_empty() {
|
||||||
}
|
Some(format!("Tool result: {}", texts.join("\n")))
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
if !texts.is_empty() {
|
|
||||||
Some(format!("Tool result: {}", texts.join("\n")))
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
}
|
}
|
||||||
_ => None,
|
|
||||||
}
|
}
|
||||||
|
_ => None,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -111,12 +138,15 @@ pub async fn run_complete_subagent_task(
|
|||||||
Ok(response_text)
|
Ok(response_text)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_agent_messages(
|
pub const SUBAGENT_TOOL_REQUEST_TYPE: &str = "subagent_tool_request";
|
||||||
|
|
||||||
|
fn get_agent_messages_with_notifications(
|
||||||
config: AgentConfig,
|
config: AgentConfig,
|
||||||
recipe: Recipe,
|
recipe: Recipe,
|
||||||
task_config: TaskConfig,
|
task_config: TaskConfig,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
cancellation_token: Option<CancellationToken>,
|
cancellation_token: Option<CancellationToken>,
|
||||||
|
notification_tx: Option<tokio::sync::mpsc::UnboundedSender<rmcp::model::ServerNotification>>,
|
||||||
) -> AgentMessagesFuture {
|
) -> AgentMessagesFuture {
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let system_instructions = recipe.instructions.clone().unwrap_or_default();
|
let system_instructions = recipe.instructions.clone().unwrap_or_default();
|
||||||
@@ -128,11 +158,11 @@ fn get_agent_messages(
|
|||||||
let agent = Arc::new(Agent::with_config(config));
|
let agent = Arc::new(Agent::with_config(config));
|
||||||
|
|
||||||
agent
|
agent
|
||||||
.update_provider(task_config.provider, &session_id)
|
.update_provider(task_config.provider.clone(), &session_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow!("Failed to set provider on sub agent: {}", e))?;
|
.map_err(|e| anyhow!("Failed to set provider on sub agent: {}", e))?;
|
||||||
|
|
||||||
for extension in task_config.extensions {
|
for extension in &task_config.extensions {
|
||||||
if let Err(e) = agent.add_extension(extension.clone(), &session_id).await {
|
if let Err(e) = agent.add_extension(extension.clone(), &session_id).await {
|
||||||
debug!(
|
debug!(
|
||||||
"Failed to add extension '{}' to subagent: {}",
|
"Failed to add extension '{}' to subagent: {}",
|
||||||
@@ -147,24 +177,8 @@ fn get_agent_messages(
|
|||||||
.apply_recipe_components(recipe.sub_recipes.clone(), recipe.response.clone(), true)
|
.apply_recipe_components(recipe.sub_recipes.clone(), recipe.response.clone(), true)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let tools = agent.list_tools(&session_id, None).await;
|
let subagent_prompt =
|
||||||
let subagent_prompt = render_template(
|
build_subagent_prompt(&agent, &task_config, &session_id, system_instructions).await?;
|
||||||
"subagent_system.md",
|
|
||||||
&SubagentPromptContext {
|
|
||||||
max_turns: task_config
|
|
||||||
.max_turns
|
|
||||||
.expect("TaskConfig always sets max_turns"),
|
|
||||||
subagent_id: session_id.clone(),
|
|
||||||
task_instructions: system_instructions,
|
|
||||||
tool_count: tools.len(),
|
|
||||||
available_tools: tools
|
|
||||||
.iter()
|
|
||||||
.map(|t| t.name.to_string())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", "),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(|e| anyhow!("Failed to render subagent system prompt: {}", e))?;
|
|
||||||
agent.override_system_prompt(subagent_prompt).await;
|
agent.override_system_prompt(subagent_prompt).await;
|
||||||
|
|
||||||
let user_message = Message::user().with_text(user_task);
|
let user_message = Message::user().with_text(user_task);
|
||||||
@@ -182,35 +196,186 @@ fn get_agent_messages(
|
|||||||
retry_config: recipe.retry,
|
retry_config: recipe.retry,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut stream = agent
|
conversation = run_subagent_stream(
|
||||||
.reply(user_message, session_config, cancellation_token)
|
agent.clone(),
|
||||||
.await
|
user_message,
|
||||||
.map_err(|e| anyhow!("Failed to get reply from agent: {}", e))?;
|
session_config,
|
||||||
while let Some(message_result) = stream.next().await {
|
cancellation_token,
|
||||||
match message_result {
|
&session_id,
|
||||||
Ok(AgentEvent::Message(msg)) => conversation.push(msg),
|
¬ification_tx,
|
||||||
Ok(AgentEvent::McpNotification(_)) | Ok(AgentEvent::ModelChange { .. }) => {}
|
conversation,
|
||||||
Ok(AgentEvent::HistoryReplaced(updated_conversation)) => {
|
)
|
||||||
conversation = updated_conversation;
|
.await?;
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Error receiving message from subagent: {}", e);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let final_output = if has_response_schema {
|
let final_output = get_final_output(&agent, has_response_schema).await;
|
||||||
agent
|
|
||||||
.final_output_tool
|
|
||||||
.lock()
|
|
||||||
.await
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|tool| tool.final_output.clone())
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok((conversation, final_output))
|
Ok((conversation, final_output))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn build_subagent_prompt(
|
||||||
|
agent: &Agent,
|
||||||
|
task_config: &TaskConfig,
|
||||||
|
session_id: &str,
|
||||||
|
system_instructions: String,
|
||||||
|
) -> Result<String> {
|
||||||
|
let tools = agent.list_tools(session_id, None).await;
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.join(", "),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow!("Failed to render subagent system prompt: {}", e))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_subagent_stream(
|
||||||
|
agent: Arc<Agent>,
|
||||||
|
user_message: Message,
|
||||||
|
session_config: SessionConfig,
|
||||||
|
cancellation_token: Option<CancellationToken>,
|
||||||
|
session_id: &str,
|
||||||
|
notification_tx: &Option<tokio::sync::mpsc::UnboundedSender<rmcp::model::ServerNotification>>,
|
||||||
|
mut conversation: Conversation,
|
||||||
|
) -> Result<Conversation> {
|
||||||
|
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 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::ModelChange { .. }) => {}
|
||||||
|
Ok(AgentEvent::HistoryReplaced(updated_conversation)) => {
|
||||||
|
conversation = updated_conversation;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Error receiving message from subagent: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(conversation)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_final_output(agent: &Agent, has_response_schema: bool) -> Option<String> {
|
||||||
|
if has_response_schema {
|
||||||
|
agent
|
||||||
|
.final_output_tool
|
||||||
|
.lock()
|
||||||
|
.await
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|tool| tool.final_output.clone())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_tool_notification(
|
||||||
|
content: &MessageContent,
|
||||||
|
subagent_id: &str,
|
||||||
|
) -> Option<ServerNotification> {
|
||||||
|
if let MessageContent::ToolRequest(req) = content {
|
||||||
|
let tool_call = req.tool_call.as_ref().ok()?;
|
||||||
|
|
||||||
|
Some(ServerNotification::LoggingMessageNotification(
|
||||||
|
LoggingMessageNotification {
|
||||||
|
method: LoggingMessageNotificationMethod,
|
||||||
|
params: LoggingMessageNotificationParam {
|
||||||
|
level: LoggingLevel::Info,
|
||||||
|
logger: Some(format!("subagent:{}", subagent_id)),
|
||||||
|
data: serde_json::json!({
|
||||||
|
"type": SUBAGENT_TOOL_REQUEST_TYPE,
|
||||||
|
"subagent_id": subagent_id,
|
||||||
|
"tool_call": {
|
||||||
|
"name": tool_call.name,
|
||||||
|
"arguments": tool_call.arguments
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
extensions: Default::default(),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
} 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 {
|
||||||
|
meta: None,
|
||||||
|
task: None,
|
||||||
|
name: "developer__shell".to_string().into(),
|
||||||
|
arguments: Some(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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use futures::FutureExt;
|
use futures::FutureExt;
|
||||||
use rmcp::model::{Content, ErrorCode, ErrorData, Tool};
|
use rmcp::model::{Content, ErrorCode, ErrorData, ServerNotification, Tool};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||||
use tokio_util::sync::CancellationToken;
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
use crate::agents::subagent_handler::run_complete_subagent_task;
|
use crate::agents::subagent_handler::run_complete_subagent_task_with_notifications;
|
||||||
use crate::agents::subagent_task_config::TaskConfig;
|
use crate::agents::subagent_task_config::TaskConfig;
|
||||||
use crate::agents::tool_execution::ToolCallResult;
|
use crate::agents::tool_execution::ToolCallResult;
|
||||||
use crate::agents::AgentConfig;
|
use crate::agents::AgentConfig;
|
||||||
@@ -31,7 +33,7 @@ Make sure your last message provides a comprehensive summary of:
|
|||||||
Be concise but complete.
|
Be concise but complete.
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct SubagentParams {
|
pub struct SubagentParams {
|
||||||
pub instructions: Option<String>,
|
pub instructions: Option<String>,
|
||||||
pub subrecipe: Option<String>,
|
pub subrecipe: Option<String>,
|
||||||
@@ -46,7 +48,7 @@ fn default_summary() -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize, Clone)]
|
||||||
pub struct SubagentSettings {
|
pub struct SubagentSettings {
|
||||||
pub provider: Option<String>,
|
pub provider: Option<String>,
|
||||||
pub model: Option<String>,
|
pub model: Option<String>,
|
||||||
@@ -224,29 +226,33 @@ pub fn handle_subagent_tool(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let config = config.clone();
|
let config = config.clone();
|
||||||
|
let (notification_tx, notification_rx) = mpsc::unbounded_channel();
|
||||||
|
|
||||||
ToolCallResult {
|
ToolCallResult {
|
||||||
notification_stream: None,
|
notification_stream: Some(Box::new(UnboundedReceiverStream::new(notification_rx))),
|
||||||
result: Box::new(
|
result: Box::new(
|
||||||
execute_subagent(
|
execute_subagent_with_notifications(
|
||||||
config,
|
config,
|
||||||
recipe,
|
recipe,
|
||||||
task_config,
|
task_config,
|
||||||
parsed_params,
|
parsed_params,
|
||||||
working_dir,
|
working_dir,
|
||||||
cancellation_token,
|
cancellation_token,
|
||||||
|
notification_tx,
|
||||||
)
|
)
|
||||||
.boxed(),
|
.boxed(),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn execute_subagent(
|
async fn execute_subagent_with_notifications(
|
||||||
config: AgentConfig,
|
config: AgentConfig,
|
||||||
recipe: Recipe,
|
recipe: Recipe,
|
||||||
task_config: TaskConfig,
|
task_config: TaskConfig,
|
||||||
params: SubagentParams,
|
params: SubagentParams,
|
||||||
working_dir: PathBuf,
|
working_dir: PathBuf,
|
||||||
cancellation_token: Option<CancellationToken>,
|
cancellation_token: Option<CancellationToken>,
|
||||||
|
notification_tx: mpsc::UnboundedSender<ServerNotification>,
|
||||||
) -> Result<rmcp::model::CallToolResult, ErrorData> {
|
) -> Result<rmcp::model::CallToolResult, ErrorData> {
|
||||||
let session = config
|
let session = config
|
||||||
.session_manager
|
.session_manager
|
||||||
@@ -270,13 +276,14 @@ async fn execute_subagent(
|
|||||||
data: None,
|
data: None,
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let result = run_complete_subagent_task(
|
let result = run_complete_subagent_task_with_notifications(
|
||||||
config,
|
config,
|
||||||
recipe,
|
recipe,
|
||||||
task_config,
|
task_config,
|
||||||
params.summary,
|
params.summary,
|
||||||
session.id,
|
session.id,
|
||||||
cancellation_token,
|
cancellation_token,
|
||||||
|
Some(notification_tx),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|||||||
@@ -291,10 +291,79 @@ interface Progress {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SubagentToolRequestData {
|
||||||
|
type: 'subagent_tool_request';
|
||||||
|
subagent_id: string;
|
||||||
|
tool_call: {
|
||||||
|
name: string;
|
||||||
|
arguments?: { tool_graph?: ToolGraphNode[] };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSubagentToolRequestData = (data: unknown): data is SubagentToolRequestData => {
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const record = data as Record<string, unknown>;
|
||||||
|
if (record.type !== 'subagent_tool_request') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (typeof record.subagent_id !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!record.tool_call || typeof record.tool_call !== 'object') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const toolCall = record.tool_call as Record<string, unknown>;
|
||||||
|
return typeof toolCall.name === 'string';
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatSubagentToolCall = (data: SubagentToolRequestData): string => {
|
||||||
|
const subagentId = data.subagent_id;
|
||||||
|
const toolCall = data.tool_call;
|
||||||
|
const toolCallName = toolCall.name;
|
||||||
|
|
||||||
|
const shortId = subagentId?.split('_').pop() || subagentId;
|
||||||
|
|
||||||
|
const parts = toolCallName.split('__').reverse();
|
||||||
|
const toolName = parts[0] || 'unknown';
|
||||||
|
const extensionName = parts.slice(1).reverse().join('__') || '';
|
||||||
|
const toolGraph = toolCall.arguments?.tool_graph;
|
||||||
|
|
||||||
|
if (toolName === 'execute_code' && toolGraph && toolGraph.length > 0) {
|
||||||
|
const plural = toolGraph.length === 1 ? '' : 's';
|
||||||
|
const header = `[subagent:${shortId}] ${toolGraph.length} tool call${plural} | execute_code`;
|
||||||
|
const lines = toolGraph.map((node, idx) => {
|
||||||
|
const deps =
|
||||||
|
node.depends_on && node.depends_on.length > 0
|
||||||
|
? ` (uses ${node.depends_on.map((d) => d + 1).join(', ')})`
|
||||||
|
: '';
|
||||||
|
return ` ${idx + 1}. ${node.tool}: ${node.description}${deps}`;
|
||||||
|
});
|
||||||
|
return [header, ...lines].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
return extensionName
|
||||||
|
? `[subagent:${shortId}] ${toolName} | ${extensionName}`
|
||||||
|
: `[subagent:${shortId}] ${toolName}`;
|
||||||
|
};
|
||||||
|
|
||||||
const logToString = (logMessage: NotificationEvent) => {
|
const logToString = (logMessage: NotificationEvent) => {
|
||||||
const message = logMessage.message as { method: string; params: unknown };
|
const message = logMessage.message as { method: string; params: unknown };
|
||||||
const params = message.params as Record<string, unknown>;
|
const params = message.params as Record<string, unknown>;
|
||||||
|
|
||||||
|
if (
|
||||||
|
params &&
|
||||||
|
params.data &&
|
||||||
|
typeof params.data === 'object' &&
|
||||||
|
'type' in params.data &&
|
||||||
|
params.data.type === 'subagent_tool_request'
|
||||||
|
) {
|
||||||
|
if (isSubagentToolRequestData(params.data)) {
|
||||||
|
return formatSubagentToolCall(params.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Special case for the developer system shell logs
|
// Special case for the developer system shell logs
|
||||||
if (
|
if (
|
||||||
params &&
|
params &&
|
||||||
|
|||||||
Reference in New Issue
Block a user