mod render;
#[cfg(test)]
mod tests;
use std::sync::Arc;
#[cfg(test)]
use std::time::Duration;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::{Value, json};
use super::client::A2aClient;
use super::exchange::{A2aExchange, A2aExchangeError, A2aRequest, A2aSend, exchange, state_name};
use super::name::a2a_tool_name;
use crate::runtime::RunOptions;
use crate::tool::{
ParallelToolContext, RuntimeToolDescriptor, ToolApprovalCategory, ToolCapability, ToolContext,
ToolDefinition, ToolDurability, ToolExecutionCategory, ToolExecutor, ToolOutput, ToolResult,
ToolSideEffectLevel,
};
use a2a_lf::{Task, TaskState};
use render::Frame;
pub struct A2aAgentTool {
client: Arc<A2aClient>,
tool_name: String,
description: String,
}
impl A2aAgentTool {
pub fn new(client: Arc<A2aClient>) -> Self {
let tool_name = a2a_tool_name(client.name());
let description = render::description(client.card(), client.name(), &tool_name);
Self {
client,
tool_name,
description,
}
}
pub fn client(&self) -> &Arc<A2aClient> {
&self.client
}
pub fn name(&self) -> &str {
&self.tool_name
}
fn frame(&self) -> Frame<'_> {
Frame {
agent: self.client.name(),
tool: &self.tool_name,
max_output_bytes: self.client.limits().max_output_bytes,
}
}
async fn run(
&self,
call: Call,
options: &RunOptions,
progress: &(dyn Fn(String) + Send + Sync),
) -> Result<ToolOutput, String> {
let request = match call {
Call::Send {
text,
task_id,
context_id,
} => {
let mut send = A2aSend::new(text);
send.task_id = task_id;
send.context_id = context_id;
A2aRequest::Send(send)
}
Call::Resume { task_id } => A2aRequest::resume(task_id),
};
let frame = self.frame();
match exchange(&self.client, request, options, progress).await {
Ok(A2aExchange::Reply(reply)) => {
let details = json!({ "context_id": reply.context_id });
Ok(ToolOutput::text(render::direct_reply(&frame, &reply)).with_details(details))
}
Ok(A2aExchange::Settled(task)) => finished(render::settled(&frame, &task), &task),
Ok(A2aExchange::StillWorking { task, reason }) => {
Ok(output(render::still_working(&frame, &task, reason), &task))
}
Err(A2aExchangeError::Send(error)) => Err(self.failure(error)),
Err(A2aExchangeError::Poll { task_id, error }) => {
Err(render::poll_failed(&frame, &task_id, &error.to_string()))
}
Err(A2aExchangeError::Abandoned { task_id, canceled }) => Err(render::abandoned(
&frame,
&task_id,
canceled.map_err(|error| error.to_string()),
)),
}
}
fn failure(&self, error: super::A2aError) -> String {
format!(
"A2A call to remote agent `{}` failed: {error}",
self.client.name()
)
}
}
impl std::fmt::Debug for A2aAgentTool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("A2aAgentTool")
.field("name", &self.tool_name)
.finish_non_exhaustive()
}
}
impl ToolDefinition for A2aAgentTool {
fn descriptor(&self) -> RuntimeToolDescriptor {
RuntimeToolDescriptor::builder(&self.tool_name)
.description(&self.description)
.input_schema(input_schema())
.capabilities([
ToolCapability::Delegation,
ToolCapability::Custom(format!("a2a:{}", self.client.name())),
])
.side_effect_level(ToolSideEffectLevel::External)
.durability(ToolDurability::Ephemeral)
.execution_category(ToolExecutionCategory::Delegation)
.approval_category(ToolApprovalCategory::Delegation)
.build()
}
}
#[async_trait]
impl ToolExecutor for A2aAgentTool {
async fn execute(&self, ctx: ParallelToolContext, input: Value) -> ToolResult {
self.execute_output(ctx, input)
.await
.map(|output| output.content.to_string())
}
async fn execute_mut(&self, ctx: ToolContext<'_>, input: Value) -> ToolResult {
self.execute(ctx.into(), input).await
}
async fn execute_output(
&self,
ctx: ParallelToolContext,
input: Value,
) -> Result<ToolOutput, String> {
let call = Call::parse(input)?;
let progress = |text: String| ctx.emit_progress(text);
self.run(call, &ctx.run_options, &progress).await
}
async fn execute_mut_output(
&self,
ctx: ToolContext<'_>,
input: Value,
) -> Result<ToolOutput, String> {
self.execute_output(ctx.into(), input).await
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Call {
Send {
text: String,
task_id: Option<String>,
context_id: Option<String>,
},
Resume { task_id: String },
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct Input {
#[serde(default)]
message: Option<String>,
#[serde(default)]
task_id: Option<String>,
#[serde(default)]
context_id: Option<String>,
}
impl Call {
fn parse(input: Value) -> Result<Self, String> {
let input: Input = serde_json::from_value(input).map_err(|error| {
format!("invalid input: {error}; expected {{message?, task_id?, context_id?}}")
})?;
let present = |value: Option<String>, field: &str| match value {
Some(value) if value.trim().is_empty() => {
Err(format!("invalid input: `{field}` must not be empty"))
}
other => Ok(other),
};
let message = present(input.message, "message")?;
let task_id = present(input.task_id, "task_id")?;
let context_id = present(input.context_id, "context_id")?;
match (message, task_id) {
(Some(text), task_id) => Ok(Self::Send {
text,
task_id,
context_id,
}),
(None, Some(task_id)) if context_id.is_none() => Ok(Self::Resume { task_id }),
(None, Some(_)) => {
Err("invalid input: `context_id` only applies when sending a `message`".to_string())
}
(None, None) => Err(
"invalid input: pass `message` to send, or `task_id` alone to resume waiting"
.to_string(),
),
}
}
}
fn input_schema() -> Value {
json!({
"type": "object",
"properties": {
"message": {
"type": "string",
"description": "Text to send to the remote agent. Alone, it starts a new task; \
with task_id, it answers a task that is waiting for input."
},
"task_id": {
"type": "string",
"description": "A task id from an earlier reply. Alone, it resumes waiting on \
that task without sending anything."
},
"context_id": {
"type": "string",
"description": "A context id from an earlier reply, to start the new task in \
that conversation."
}
},
"additionalProperties": false
})
}
fn finished(text: String, task: &Task) -> Result<ToolOutput, String> {
match task.status.state {
TaskState::Failed | TaskState::Rejected | TaskState::Canceled => Err(text),
_ => Ok(output(text, task)),
}
}
fn output(text: String, task: &Task) -> ToolOutput {
ToolOutput::text(text).with_details(json!({
"task_id": task.id,
"context_id": task.context_id,
"state": state_name(&task.status.state),
}))
}