use mentra::a2a::{
A2aError as ClientError, A2aExchange, A2aExchangeError, A2aRequest, A2aRpcError, A2aSend,
A2aStillWorking, A2aSubmitted, is_settled, remote_output, remote_reply, settle as settle_task,
submit, types::TaskState,
};
use std::time::SystemTime;
use chrono::{DateTime, SecondsFormat, Utc};
use mentra::tool::{ParallelToolContext, ToolContext, ToolResult};
use serde_json::{Value, json};
use super::{describe::listed, no_remote_agents};
use crate::a2a::HOPS_EXTENSION;
use crate::a2a::remote::{
Conversation, Outstanding, Reached, RemoteAgents, Route, Unreached, Waiting,
};
pub(super) fn authorize(agents: &RemoteAgents, name: &str) -> Result<(), String> {
match agents.route(name) {
Route::Connected | Route::Retry => Ok(()),
Route::Backoff(wait) => Err(backing_off(name, wait)),
Route::Removed => Err(removed(name)),
Route::Unknown => Err(unknown(agents, name)),
}
}
fn removed(name: &str) -> String {
format!(
"the remote agent `{name}` is no longer configured for this workspace, so there is \
nothing to hand `@{name}` to; write the task with no `@` to hand it to a subagent here"
)
}
fn unknown(agents: &RemoteAgents, name: &str) -> String {
let configured = agents.names();
if configured.is_empty() {
return no_remote_agents(name);
}
format!(
"there is no remote agent named `{name}`; the ones this workspace configures are {}, \
and `@@` begins a task with a literal `@`",
listed(&configured)
)
}
fn backing_off(name: &str, wait: std::time::Duration) -> String {
format!(
"the remote agent `{name}` could not be reached when it was last tried; `@{name}` tries \
it again in {}s",
seconds(wait)
)
}
fn seconds(wait: std::time::Duration) -> u64 {
wait.as_secs_f64().ceil().max(1.0) as u64
}
fn unreached(agents: &RemoteAgents, name: &str, why: Unreached) -> String {
match why {
Unreached::Backoff(wait) => backing_off(name, wait),
Unreached::Failed { error, retry_in } => format!(
"the remote agent `{name}` could not be reached: {error}. `@{name}` tries it again \
in {}s",
seconds(retry_in)
),
Unreached::Removed => removed(name),
Unreached::Unknown => unknown(agents, name),
}
}
pub(super) async fn delegate(
ctx: ToolContext<'_>,
agents: &RemoteAgents,
name: &str,
body: &str,
remembered: bool,
) -> ToolResult {
let Reached { client, endpoint } = agents
.reach(name)
.await
.map_err(|why| unreached(agents, name, why))?;
let caller = ctx.agent_id.clone();
let options = ctx.child_run_options();
let hop = Hop {
hop: agents.hop().saturating_add(1),
deadline: options.deadline,
};
let mut conversation = agents.conversation(&caller, name, remembered);
let request = request(name, body, &conversation, hop)?;
let ctx: ParallelToolContext = ctx.into();
let progress = |text: String| ctx.emit_progress(text);
let save = |next: Conversation| {
if let Err(error) = agents.record(&caller, name, next, remembered) {
ctx.emit_progress(format!(
"the conversation with `{name}` was not saved: {error}"
));
}
};
let outcome = match submit(&client, request).await {
Ok(A2aSubmitted::Task(task)) if !is_settled(&task.status.state) => {
conversation = Conversation {
context_id: Some(task.context_id.clone()),
outstanding: Some(Outstanding {
task_id: task.id.clone(),
waiting_for: Waiting::Agent,
endpoint: Some(endpoint.clone()),
}),
};
save(conversation.clone());
settle_task(&client, task, &options, &progress).await
}
Ok(A2aSubmitted::Task(task)) => Ok(A2aExchange::Settled(task)),
Ok(A2aSubmitted::Reply(message)) => Ok(A2aExchange::Reply(message)),
Ok(_) => {
return Err(format!(
"Remote agent `{name}` answered in a way spawn does not understand."
));
}
Err(error) => Err(error),
};
let max_output_bytes = client.limits().max_output_bytes;
let (next, result) = settle(name, conversation, outcome, max_output_bytes, &endpoint);
save(next);
result
}
fn request(
name: &str,
body: &str,
conversation: &Conversation,
hop: Hop,
) -> Result<A2aRequest, String> {
match (&conversation.outstanding, body.is_empty()) {
(Some(outstanding), true) => Ok(A2aRequest::resume(&outstanding.task_id)),
(None, true) => Err(format!(
"`{name}` has nothing outstanding to wait on; write `@{name} <task>` to hand it one"
)),
(
Some(Outstanding {
waiting_for: Waiting::Agent,
..
}),
false,
) => Err(format!(
"`{name}` is still working on the last task you handed it, and takes one task at a \
time; write `@{name}` alone to keep waiting for that one first"
)),
(Some(outstanding), false) => Ok(send(
body,
Some(&outstanding.task_id),
conversation.context_id.as_deref(),
hop,
)),
(None, false) => Ok(send(body, None, conversation.context_id.as_deref(), hop)),
}
}
fn send(body: &str, task_id: Option<&str>, context_id: Option<&str>, hop: Hop) -> A2aRequest {
let mut message = A2aSend::new(body)
.with_extension(HOPS_EXTENSION)
.with_metadata(HOPS_EXTENSION, hop.metadata());
if let Some(task_id) = task_id {
message = message.with_task_id(task_id);
}
if let Some(context_id) = context_id {
message = message.with_context_id(context_id);
}
message.into()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Hop {
hop: u32,
deadline: Option<SystemTime>,
}
impl Hop {
fn metadata(self) -> Value {
let mut object = json!({ "hop": self.hop });
if let Some(deadline) = self.deadline {
object["deadline"] = Value::String(
DateTime::<Utc>::from(deadline).to_rfc3339_opts(SecondsFormat::Millis, true),
);
}
object
}
}
fn settle(
name: &str,
before: Conversation,
outcome: Result<A2aExchange, A2aExchangeError>,
max_output_bytes: usize,
endpoint: &str,
) -> (Conversation, ToolResult) {
let outstanding = |task_id: String, waiting_for: Waiting| {
Some(Outstanding {
task_id,
waiting_for,
endpoint: Some(endpoint.to_string()),
})
};
match outcome {
Ok(A2aExchange::Reply(message)) => (
Conversation {
context_id: message.context_id.clone().or(before.context_id),
outstanding: None,
},
Ok(said(
format!("Remote agent `{name}` replied:"),
remote_reply(name, &message, max_output_bytes),
)),
),
Ok(A2aExchange::Settled(task)) => {
let output = remote_output(name, &task, max_output_bytes);
let context_id = Some(task.context_id.clone());
match task.status.state {
TaskState::InputRequired => (
Conversation {
context_id,
outstanding: outstanding(task.id.clone(), Waiting::Input),
},
Ok(said(
format!(
"Remote agent `{name}` needs more before it can finish. Answer with \
`@{name} <your answer>`."
),
output,
)),
),
TaskState::AuthRequired => (
Conversation {
context_id,
outstanding: None,
},
Err(said(
format!(
"Remote agent `{name}` needs authorization this conversation cannot \
give it, so the task stopped there."
),
output,
)),
),
TaskState::Failed | TaskState::Rejected | TaskState::Canceled => (
Conversation {
context_id,
outstanding: None,
},
Err(said(
format!(
"Remote agent `{name}` {}.",
match task.status.state {
TaskState::Rejected => "rejected the task",
TaskState::Canceled => "reports the task was canceled",
_ => "reports the task failed",
}
),
output,
)),
),
_ => (
Conversation {
context_id,
outstanding: None,
},
Ok(said(format!("Remote agent `{name}` replied:"), output)),
),
}
}
Ok(A2aExchange::StillWorking { task, reason }) => {
let why = match reason {
A2aStillWorking::WaitBound(waited) => {
format!("did not finish within {}s", waited.as_secs().max(1))
}
A2aStillWorking::RunStopping => {
"was still working when this run began to stop".to_string()
}
_ => "was still working".to_string(),
};
(
Conversation {
context_id: Some(task.context_id.clone()),
outstanding: outstanding(task.id.clone(), Waiting::Agent),
},
Ok(said(
format!(
"Remote agent `{name}` {why}; it keeps working remotely. Write `@{name}` \
alone to keep waiting for it, and do not hand it the task again."
),
remote_output(name, &task, max_output_bytes),
)),
)
}
Ok(_) => (
before,
Err(format!(
"Remote agent `{name}` answered in a way spawn does not understand."
)),
),
Err(error) if moved(&before, endpoint) && cause(&error).is_some() => (
Conversation::default(),
Err(format!(
"Remote agent `{name}` has moved to a new address since it was handed the task it \
was working on, and the new address could not pick that task up ({}), so it is \
forgotten and the conversation with it starts over. Hand it the task again with \
`@{name} <task>` if it still needs doing.",
cause(&error).expect("checked by the guard")
)),
),
Err(error) if before.outstanding.is_some() && task_not_found(&error) => (
Conversation::default(),
Err(format!(
"Remote agent `{name}` no longer knows the task it was working on — it may have \
moved to a new address — so that task is forgotten and the conversation with \
it starts over. Hand it the task again with `@{name} <task>` if it still needs \
doing."
)),
),
Err(A2aExchangeError::Send(error)) => (
before,
Err(format!(
"Handing the task to remote agent `{name}` failed: {error}"
)),
),
Err(A2aExchangeError::Poll { task_id, error }) => (
Conversation {
outstanding: outstanding(task_id, Waiting::Agent),
..before
},
Err(format!(
"Checking on remote agent `{name}` failed: {error}. The task may still be \
running; write `@{name}` alone to check on it again."
)),
),
Err(A2aExchangeError::Abandoned { canceled, .. }) => (
Conversation {
outstanding: None,
..before
},
Err(match canceled {
Ok(()) => format!(
"The run was cancelled while remote agent `{name}` was working, and the task \
was asked to cancel."
),
Err(error) => format!(
"The run was cancelled while remote agent `{name}` was working, and asking \
the task to cancel failed: {error}"
),
}),
),
Err(error) => (
before,
Err(format!(
"Remote agent `{name}` could not be reached: {error}"
)),
),
}
}
fn moved(conversation: &Conversation, endpoint: &str) -> bool {
conversation
.outstanding
.as_ref()
.and_then(|outstanding| outstanding.endpoint.as_deref())
.is_some_and(|recorded| recorded != endpoint)
}
fn cause(error: &A2aExchangeError) -> Option<&ClientError> {
match error {
A2aExchangeError::Send(error) | A2aExchangeError::Poll { error, .. } => Some(error),
_ => None,
}
}
fn task_not_found(error: &A2aExchangeError) -> bool {
let not_found = |error: &ClientError| {
matches!(
error,
ClientError::Rpc {
error: A2aRpcError::TaskNotFound,
..
}
)
};
match error {
A2aExchangeError::Send(error) | A2aExchangeError::Poll { error, .. } => not_found(error),
_ => false,
}
}
fn said(headline: String, output: String) -> String {
if output.is_empty() {
return headline;
}
format!("{headline}\n\n{output}")
}
#[cfg(test)]
mod tests;