use std::sync::Arc;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use crate::core::{PolicyDecision, PolicyRequest, RunId, Seq, SourceId, Tainted};
use crate::journal::RecordKind;
use crate::manifest::Manifest;
use crate::peers::{AgentCard, ExtendedAgentCard, WELL_KNOWN_PATH};
use crate::runtime::Runtime;
use super::{Authenticator, Caller};
pub mod method {
pub const SEND_MESSAGE: &str = "SendMessage";
pub const GET_TASK: &str = "GetTask";
pub const CANCEL_TASK: &str = "CancelTask";
pub const GET_EXTENDED_CARD: &str = "GetExtendedAgentCard";
pub const SEND_STREAMING: &str = "SendStreamingMessage";
pub const SUBSCRIBE: &str = "SubscribeToTask";
pub const LIST_TASKS: &str = "ListTasks";
pub const CREATE_PUSH: &str = "CreateTaskPushNotificationConfig";
pub const GET_PUSH: &str = "GetTaskPushNotificationConfig";
pub const LIST_PUSH: &str = "ListTaskPushNotificationConfigs";
pub const DELETE_PUSH: &str = "DeleteTaskPushNotificationConfig";
}
pub mod code {
pub const PARSE_ERROR: i32 = -32700;
pub const INVALID_REQUEST: i32 = -32600;
pub const METHOD_NOT_FOUND: i32 = -32601;
pub const INVALID_PARAMS: i32 = -32602;
pub const INTERNAL_ERROR: i32 = -32603;
pub const TASK_NOT_FOUND: i32 = -32001;
pub const TASK_NOT_CANCELABLE: i32 = -32002;
pub const PUSH_NOT_SUPPORTED: i32 = -32003;
pub const UNSUPPORTED_OPERATION: i32 = -32004;
pub const CONTENT_TYPE_NOT_SUPPORTED: i32 = -32005;
pub const EXTENDED_CARD_NOT_CONFIGURED: i32 = -32007;
pub const VERSION_NOT_SUPPORTED: i32 = -32009;
pub const QUOTA_EXHAUSTED: i32 = -32029;
pub const HALTED: i32 = -32030;
pub const DRAINING: i32 = -32031;
}
pub mod action {
pub const MESSAGE_SEND: &str = "a2a:message.send";
pub const TASK_READ: &str = "a2a:task.read";
pub const TASK_CONTINUE: &str = "a2a:task.continue";
pub const TASK_CANCEL: &str = "a2a:task.cancel";
pub const CARD_EXTENDED: &str = "a2a:card.extended";
pub const TASK_PUSH: &str = "a2a:task.push";
pub const ALL: &[&str] = &[
MESSAGE_SEND,
TASK_READ,
TASK_CONTINUE,
TASK_CANCEL,
CARD_EXTENDED,
TASK_PUSH,
];
}
const VERSION_HEADER: &str = "a2a-version";
const QUOTA_EXHAUSTED_MESSAGE: &str =
"this agent cannot take the request on right now; retry later";
const HALTED_MESSAGE: &str =
"this agent is halted by its operator; do not retry until it is lifted";
const DRAINING_MESSAGE: &str =
"this instance is shutting down and did not take the request on; retry";
fn quota_refusal(e: &crate::quota::QuotaError) -> RpcError {
match e {
crate::quota::QuotaError::Halted { .. } => RpcError::new(code::HALTED, HALTED_MESSAGE),
_ => RpcError::new(code::QUOTA_EXHAUSTED, QUOTA_EXHAUSTED_MESSAGE),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TaskState {
#[serde(rename = "TASK_STATE_UNSPECIFIED")]
Unspecified,
#[serde(rename = "TASK_STATE_SUBMITTED")]
Submitted,
#[serde(rename = "TASK_STATE_WORKING")]
Working,
#[serde(rename = "TASK_STATE_COMPLETED")]
Completed,
#[serde(rename = "TASK_STATE_FAILED")]
Failed,
#[serde(rename = "TASK_STATE_CANCELED")]
Canceled,
#[serde(rename = "TASK_STATE_INPUT_REQUIRED")]
InputRequired,
#[serde(rename = "TASK_STATE_REJECTED")]
Rejected,
#[serde(rename = "TASK_STATE_AUTH_REQUIRED")]
AuthRequired,
}
fn state_of(status: &crate::runtime::RunStatus) -> TaskState {
use crate::runtime::RunStatus;
match status {
RunStatus::Succeeded => TaskState::Completed,
RunStatus::Suspended(_) => TaskState::InputRequired,
RunStatus::Cancelled { .. } => TaskState::Canceled,
RunStatus::Failed(_)
| RunStatus::Exhausted(_)
| RunStatus::Quarantined(_)
| RunStatus::Abandoned { .. }
| RunStatus::Replanning(_) => TaskState::Failed,
RunStatus::Swept | RunStatus::BrokeGlass { .. } => TaskState::Rejected,
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct TaskStatus {
pub state: TaskState,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<A2aMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct A2aTask {
pub id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub context_id: Option<String>,
pub status: TaskStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub artifacts: Option<Vec<A2aArtifact>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub history: Option<Vec<A2aMessage>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct A2aArtifact {
pub artifact_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub parts: Vec<Part>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extensions: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Part {
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub raw: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub filename: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub media_type: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
}
impl Part {
#[must_use]
pub fn text(text: impl Into<String>) -> Self {
Self {
text: Some(text.into()),
data: None,
raw: None,
url: None,
filename: None,
media_type: Some("text/plain".to_owned()),
metadata: None,
}
}
#[must_use]
pub fn data(data: Value) -> Self {
Self {
text: None,
data: Some(data),
raw: None,
url: None,
filename: None,
media_type: Some("application/json".to_owned()),
metadata: None,
}
}
#[must_use]
pub fn file_raw(
raw_base64: impl Into<String>,
media_type: impl Into<String>,
filename: impl Into<String>,
) -> Self {
Self {
text: None,
data: None,
raw: Some(raw_base64.into()),
url: None,
filename: Some(filename.into()),
media_type: Some(media_type.into()),
metadata: None,
}
}
#[must_use]
pub fn file_url(
url: impl Into<String>,
media_type: impl Into<String>,
filename: impl Into<String>,
) -> Self {
Self {
text: None,
data: None,
raw: None,
url: Some(url.into()),
filename: Some(filename.into()),
media_type: Some(media_type.into()),
metadata: None,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct A2aReply {
#[serde(skip_serializing_if = "Option::is_none")]
message: Option<Vec<Part>>,
#[serde(skip_serializing_if = "Option::is_none")]
artifacts: Option<Vec<Vec<Part>>>,
}
const REPLY_KEY: &str = "$a2a_reply";
impl A2aReply {
#[must_use]
pub const fn message(parts: Vec<Part>) -> Self {
Self {
message: Some(parts),
artifacts: None,
}
}
#[must_use]
pub fn artifact(parts: Vec<Part>) -> Self {
Self {
message: None,
artifacts: Some(vec![parts]),
}
}
#[must_use]
pub const fn artifacts(artifacts: Vec<Vec<Part>>) -> Self {
Self {
message: None,
artifacts: Some(artifacts),
}
}
#[must_use]
pub fn into_value(self) -> Value {
json!({ REPLY_KEY: self })
}
pub(super) fn of_output(output: &crate::core::Tainted<Value>) -> Option<Self> {
if output.label().is_untrusted() {
return None;
}
serde_json::from_value(output.peek().get(REPLY_KEY)?.clone()).ok()
}
pub(super) fn message_parts(&self) -> Option<Vec<Part>> {
self.message.clone()
}
pub(super) fn artifact_parts(&self) -> Vec<Vec<Part>> {
match (&self.artifacts, &self.message) {
(Some(artifacts), _) => artifacts.clone(),
(None, Some(message)) => vec![message.clone()],
(None, None) => Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct A2aMessage {
pub message_id: String,
pub role: String,
pub parts: Vec<Part>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub task_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metadata: Option<Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub extensions: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reference_task_ids: Vec<String>,
}
impl A2aMessage {
fn validate_parts(&self) -> Result<(), RpcError> {
if self.role != "ROLE_USER" {
return Err(RpcError::new(
code::INVALID_PARAMS,
"an inbound Message role must be ROLE_USER",
));
}
if self.message_id.is_empty() {
return Err(RpcError::new(
code::INVALID_PARAMS,
"messageId must be non-empty: it is what lets a retry of this \
message be recognised as the same message",
));
}
if self.parts.is_empty() {
return Err(RpcError::new(
code::CONTENT_TYPE_NOT_SUPPORTED,
"the message has no parts this agent can read; it accepts text and data parts",
));
}
for (index, part) in self.parts.iter().enumerate() {
if part.raw.is_some() || part.url.is_some() {
return Err(RpcError::new(
code::CONTENT_TYPE_NOT_SUPPORTED,
format!(
"message.parts[{index}] is file content; this agent card advertises only text/plain and application/json"
),
));
}
let variants = usize::from(part.text.is_some()) + usize::from(part.data.is_some());
if variants != 1 {
return Err(RpcError::new(
code::INVALID_PARAMS,
format!("message.parts[{index}] must contain exactly one of text or data"),
));
}
}
Ok(())
}
fn to_input(&self) -> Value {
let text: Vec<&str> = self
.parts
.iter()
.filter_map(|p| p.text.as_deref())
.collect();
let data: Vec<Value> = self.parts.iter().filter_map(|p| p.data.clone()).collect();
json!({
"text": text.join("\n"),
"data": data,
"$a2a_message": self,
})
}
fn requested_skill(&self) -> Option<&str> {
self.metadata.as_ref()?.get("skill")?.as_str()
}
}
#[derive(Debug, Clone, Deserialize)]
struct RpcRequest {
#[serde(default)]
jsonrpc: String,
#[serde(default)]
id: Value,
method: String,
#[serde(default)]
params: Value,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SendConfiguration {
#[serde(default)]
return_immediately: bool,
#[serde(default)]
accepted_output_modes: Vec<String>,
#[serde(default)]
history_length: Option<usize>,
#[serde(default)]
task_push_notification_config: Option<PushRequest>,
}
impl SendConfiguration {
fn validate(&self) -> Result<(), RpcError> {
if !self.accepted_output_modes.is_empty()
&& !self
.accepted_output_modes
.iter()
.any(|mode| matches!(mode.as_str(), "text/plain" | "application/json"))
{
return Err(RpcError::new(
code::CONTENT_TYPE_NOT_SUPPORTED,
"acceptedOutputModes contains no mode this agent can produce",
));
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PushAuthenticationRequest {
scheme: String,
#[serde(default)]
credentials: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PushRequest {
#[serde(default)]
id: Option<String>,
#[serde(default)]
task_id: Option<String>,
url: String,
#[serde(default)]
token: Option<String>,
#[serde(default)]
authentication: Option<PushAuthenticationRequest>,
}
impl PushRequest {
fn validate(&self) -> Result<(), RpcError> {
if self.id.as_deref().is_some_and(crate::push::is_operator_id) {
return Err(RpcError::new(
code::INVALID_PARAMS,
format!(
"a pushNotificationConfig id may not begin with '{}': that namespace \
belongs to destinations this deployment configured for itself",
crate::push::OPERATOR_PREFIX
),
));
}
Ok(())
}
fn config(&self, task: RunId) -> crate::push::PushConfig {
crate::push::PushConfig {
id: self.id.clone().unwrap_or_else(|| format!("push-{task}")),
task,
url: self.url.clone(),
token: self.token.clone().map(crate::core::Secret::new),
authentication: self.authentication.as_ref().map(|authentication| {
crate::push::PushAuthentication {
scheme: authentication.scheme.clone(),
credentials: crate::core::Secret::new(
authentication.credentials.clone().unwrap_or_default(),
),
}
}),
}
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct CommonParams {
#[serde(default)]
tenant: Option<String>,
#[serde(default)]
message: Option<A2aMessage>,
#[serde(default)]
id: Option<String>,
#[serde(default)]
configuration: Option<SendConfiguration>,
#[serde(default)]
#[allow(dead_code)]
metadata: Option<Value>,
#[serde(default, rename = "taskId")]
push_task: Option<String>,
#[serde(default)]
url: Option<String>,
#[serde(default)]
token: Option<String>,
#[serde(default)]
authentication: Option<PushAuthenticationRequest>,
#[serde(default, rename = "contextId")]
context_id: Option<String>,
#[serde(default)]
status: Option<TaskState>,
#[serde(default, rename = "pageSize")]
page_size: Option<usize>,
#[serde(default, rename = "pageToken")]
page_token: Option<String>,
#[serde(default, rename = "historyLength")]
history_length: Option<usize>,
#[serde(default, rename = "statusTimestampAfter")]
status_timestamp_after: Option<String>,
#[serde(default, rename = "includeArtifacts")]
include_artifacts: bool,
}
#[derive(Debug, Clone)]
pub struct RpcError {
code: i32,
message: String,
id: Value,
}
impl RpcError {
fn new(code: i32, message: impl Into<String>) -> Self {
Self {
code,
message: message.into(),
id: Value::Null,
}
}
fn with_id(mut self, id: Value) -> Self {
self.id = id;
self
}
const fn reason(&self) -> Option<(&'static str, &'static str)> {
const A2A: &str = "a2a-protocol.org";
const SELF_DOMAIN: &str = crate::peers::ERROR_DOMAIN;
const QUOTA_EXHAUSTED_REASON: &str = crate::peers::QUOTA_EXHAUSTED_REASON;
const HALTED_REASON: &str = crate::peers::HALTED_REASON;
const DRAINING_REASON: &str = crate::peers::DRAINING_REASON;
match self.code {
code::TASK_NOT_FOUND => Some((A2A, "TASK_NOT_FOUND")),
code::TASK_NOT_CANCELABLE => Some((A2A, "TASK_NOT_CANCELABLE")),
code::PUSH_NOT_SUPPORTED => Some((A2A, "PUSH_NOTIFICATION_NOT_SUPPORTED")),
code::UNSUPPORTED_OPERATION => Some((A2A, "UNSUPPORTED_OPERATION")),
code::CONTENT_TYPE_NOT_SUPPORTED => Some((A2A, "CONTENT_TYPE_NOT_SUPPORTED")),
code::EXTENDED_CARD_NOT_CONFIGURED => Some((A2A, "EXTENDED_AGENT_CARD_NOT_CONFIGURED")),
code::VERSION_NOT_SUPPORTED => Some((A2A, "VERSION_NOT_SUPPORTED")),
code::QUOTA_EXHAUSTED => Some((SELF_DOMAIN, QUOTA_EXHAUSTED_REASON)),
code::HALTED => Some((SELF_DOMAIN, HALTED_REASON)),
code::DRAINING => Some((SELF_DOMAIN, DRAINING_REASON)),
_ => None,
}
}
fn body(&self) -> Value {
match self.reason() {
Some((domain, reason)) => json!({
"code": self.code,
"message": self.message,
"data": [{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"domain": domain,
"reason": reason,
}],
}),
None => json!({ "code": self.code, "message": self.message }),
}
}
}
impl IntoResponse for RpcError {
fn into_response(self) -> Response {
(
StatusCode::OK,
Json(json!({
"jsonrpc": "2.0",
"id": self.id,
"error": self.body(),
})),
)
.into_response()
}
}
#[derive(Clone)]
pub struct A2aServer {
runtime: Arc<Runtime>,
auth: Arc<dyn Authenticator>,
policy: Arc<dyn crate::core::PolicyEngine>,
card: AgentCard,
extended: ExtendedAgentCard,
per_agent: std::collections::BTreeMap<String, crate::peers::AgentCard>,
skills: Vec<String>,
push: Option<PushRuntime>,
filter_scan_budget: usize,
artifact_replay_budget: usize,
artifact_cache: Arc<std::sync::Mutex<ArtifactCache>>,
}
#[derive(Debug, Default)]
struct ArtifactCache {
map: std::collections::HashMap<RunId, Option<Vec<A2aArtifact>>>,
order: std::collections::VecDeque<RunId>,
}
const ARTIFACT_CACHE_CAPACITY: usize = 256;
enum ArtifactRead {
Artifacts(Option<Vec<A2aArtifact>>),
OverBudget,
}
impl ArtifactCache {
fn get(&self, run: RunId) -> Option<ArtifactRead> {
self.map
.get(&run)
.map(|artifacts| ArtifactRead::Artifacts(artifacts.clone()))
}
fn insert(&mut self, run: RunId, artifacts: Option<Vec<A2aArtifact>>) {
if self.map.insert(run, artifacts).is_none() {
self.order.push_back(run);
}
while self.map.len() > ARTIFACT_CACHE_CAPACITY {
let Some(evicted) = self.order.pop_front() else {
break;
};
self.map.remove(&evicted);
}
}
}
#[derive(Debug, Clone)]
struct PushRuntime {
store: Arc<dyn crate::push::PushStore>,
transport: Arc<dyn crate::push::PushTransport>,
}
pub type A2aPushWorker = crate::push::DeliveryWorker;
pub use crate::push::PushSweepReport;
#[derive(Clone)]
struct A2aProjection {
runtime: Arc<Runtime>,
}
impl std::fmt::Debug for A2aProjection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("A2aProjection").finish_non_exhaustive()
}
}
#[async_trait::async_trait]
impl crate::push::Projection for A2aProjection {
async fn messages(
&self,
record: &crate::journal::Record,
) -> Result<Vec<crate::push::PushMessage>, crate::core::StoreError> {
let case = record.body.case.map(|case| case.to_string());
let payloads =
super::a2a_stream::payloads_for_record(&self.runtime, record, case.as_deref())
.await
.map_err(|error| crate::core::StoreError::Backend(error.to_string()))?;
Ok(payloads
.into_iter()
.enumerate()
.map(|(index, payload)| {
crate::push::PushMessage::json(
format!("{}/{}/{index}", record.body.run, record.body.seq),
payload,
)
.typed("application/a2a+json")
})
.collect())
}
fn namespace(&self) -> crate::push::PushNamespace {
crate::push::PushNamespace::Caller
}
}
impl std::fmt::Debug for A2aServer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("A2aServer")
.field("agent", &self.card.name)
.field("skills", &self.skills)
.finish_non_exhaustive()
}
}
#[derive(Debug, thiserror::Error)]
pub enum ServerSetupError {
#[error(
"the runtime has no policy engine, so every A2A method would be \
unauthorized. A surface reachable by other agents cannot be the one \
place that skips the gate"
)]
NoPolicy,
#[error(
"the runtime has no case layer, so this server cannot mint the \
contextId A2A 1.0 requires on every task — a generated contextId \
must be continuable, and continuation here is a case. Build the \
runtime with `.cases(store)`"
)]
NoCases,
#[error("the agent card could not be derived: {0}")]
Card(#[from] crate::manifest::ManifestError),
#[error("push changes the signed Agent Card; configure it before calling signing_cards_with")]
CardAlreadySigned,
#[error(
"an A2A server serves at least one agent, and no manifest was given — the well-known card path must answer with a card describing something"
)]
NoAgents,
#[error(
"agents '{first}' and '{second}' both advertise skill '{skill}', so a request naming it would be a routing decision the caller did not make. A2A dispatch is named, never inferred: give the skill distinct capability names, or serve the two agents from separate planes"
)]
AmbiguousSkill {
skill: String,
first: String,
second: String,
},
}
impl A2aServer {
pub fn new(
runtime: Arc<Runtime>,
auth: Arc<dyn Authenticator>,
security: &crate::peers::CardSecurity,
manifest: &Manifest,
url: impl Into<String>,
) -> Result<Self, ServerSetupError> {
Self::hosting(runtime, auth, security, &[manifest], url)
}
pub fn hosting(
runtime: Arc<Runtime>,
auth: Arc<dyn Authenticator>,
security: &crate::peers::CardSecurity,
manifests: &[&Manifest],
url: impl Into<String>,
) -> Result<Self, ServerSetupError> {
let policy = runtime.policy().ok_or(ServerSetupError::NoPolicy)?.clone();
if runtime.cases().is_none() {
return Err(ServerSetupError::NoCases);
}
let [primary, rest @ ..] = manifests else {
return Err(ServerSetupError::NoAgents);
};
let url = url.into();
let mut directory = Vec::new();
let mut per_agent = std::collections::BTreeMap::new();
let mut owner_of_skill: std::collections::BTreeMap<String, String> =
std::collections::BTreeMap::new();
for m in manifests {
let name = m.metadata.name.clone();
let mut agent_card = AgentCard::derive(m, url.clone())?;
security.apply(&mut agent_card);
for skill in &agent_card.skills {
if let Some(other) = owner_of_skill.insert(skill.id.clone(), name.clone())
&& other != name
{
return Err(ServerSetupError::AmbiguousSkill {
skill: skill.id.clone(),
first: other,
second: name,
});
}
}
directory.push(serde_json::json!({
"name": name,
"version": m.metadata.version,
"cardPath": crate::peers::agent_card_path(&name),
"manifestDigest": m.digest()?.to_hex(),
"skills": agent_card.skills.iter().map(|s| s.id.clone()).collect::<Vec<_>>(),
}));
per_agent.insert(name, agent_card);
}
let mut card = AgentCard::derive(primary, url.clone())?;
let mut extended = ExtendedAgentCard::derive(primary, url)?;
security.apply(&mut card);
security.apply(&mut extended.public);
if !rest.is_empty() {
let ext = crate::peers::AgentExtension {
uri: crate::peers::EXT_AGENT_DIRECTORY.to_owned(),
description: Some(
"Every agent this plane serves, and where each agent's own card is.".to_owned(),
),
required: false,
params: Some(serde_json::json!({ "agents": directory })),
};
card.capabilities.extensions.push(ext.clone());
extended.public.capabilities.extensions.push(ext);
}
let tenant = runtime.tenant();
if tenant.as_str() != crate::core::TenantId::DEFAULT {
for iface in &mut card.supported_interfaces {
iface.tenant = Some(tenant.to_string());
}
for iface in &mut extended.public.supported_interfaces {
iface.tenant = Some(tenant.to_string());
}
}
let skills = owner_of_skill.keys().cloned().collect();
Ok(Self {
runtime,
auth,
policy,
card,
extended,
per_agent,
skills,
push: None,
filter_scan_budget: FILTER_SCAN_BUDGET,
artifact_replay_budget: ARTIFACT_REPLAY_BUDGET,
artifact_cache: Arc::new(std::sync::Mutex::new(ArtifactCache::default())),
})
}
#[must_use]
pub fn filter_scan_budget(mut self, budget: usize) -> Self {
self.filter_scan_budget = budget.max(1);
self
}
#[must_use]
pub fn artifact_replay_budget(mut self, budget: usize) -> Self {
self.artifact_replay_budget = budget.max(1);
self
}
async fn artifacts_bounded(
&self,
run: RunId,
state: TaskState,
budget: Option<&mut usize>,
) -> Result<ArtifactRead, crate::core::RuntimeError> {
if state != TaskState::Completed {
return Ok(ArtifactRead::Artifacts(None));
}
if let Some(cached) = self
.artifact_cache
.lock()
.expect("the artifact cache mutex is never poisoned")
.get(run)
{
return Ok(cached);
}
if let Some(budget) = &budget
&& **budget == 0
{
return Ok(ArtifactRead::OverBudget);
}
let artifacts = task_artifacts(&self.runtime, run, state).await?;
if let Some(budget) = budget {
*budget -= 1;
}
self.artifact_cache
.lock()
.expect("the artifact cache mutex is never poisoned")
.insert(run, artifacts.clone());
Ok(ArtifactRead::Artifacts(artifacts))
}
pub fn signing_cards_with(
mut self,
signer: &dyn crate::peers::CardSigner,
) -> Result<Self, crate::peers::CardSignatureError> {
self.card.sign(signer)?;
self.extended.public.sign(signer)?;
Ok(self)
}
pub fn with_push(
mut self,
store: Arc<dyn crate::push::PushStore>,
transport: Arc<dyn crate::push::PushTransport>,
) -> Result<Self, ServerSetupError> {
if !self.card.signatures.is_empty() || !self.extended.public.signatures.is_empty() {
return Err(ServerSetupError::CardAlreadySigned);
}
self.card.capabilities.push_notifications = true;
self.extended.public.capabilities.push_notifications = true;
self.push = Some(PushRuntime { store, transport });
Ok(self)
}
#[must_use]
pub fn push_worker(&self) -> Option<A2aPushWorker> {
self.push.clone().map(|push| {
crate::push::DeliveryWorker::new(
Arc::clone(self.runtime.journal()),
push.store,
push.transport,
Arc::new(A2aProjection {
runtime: Arc::clone(&self.runtime),
}),
)
})
}
pub fn router(self) -> Router {
Router::new()
.route(WELL_KNOWN_PATH, get(agent_card))
.route("/agents/{agent}/agent-card.json", get(one_agent_card))
.route("/a2a", post(rpc))
.route("/a2a/", post(rpc))
.with_state(self)
}
async fn gate(
&self,
headers: &HeaderMap,
action: &str,
resource: &str,
) -> Result<Caller, RpcError> {
let caller = self.auth.authenticate(headers).await.map_err(|e| {
RpcError::new(code::INVALID_REQUEST, e.to_string())
})?;
if caller.tenant != *self.runtime.tenant() {
return Err(RpcError::new(
code::INVALID_PARAMS,
"this endpoint does not serve your tenant",
));
}
let context = json!({
"roles": caller.roles,
"peer": caller.actor,
"tenant": caller.tenant.as_str(),
});
match self.policy.authorize(&PolicyRequest {
principal: &caller.actor,
action,
resource,
context: &context,
}) {
PolicyDecision::Permit => Ok(caller),
PolicyDecision::Deny { reason } => {
tracing::warn!(
target: "agentplane::a2a",
action,
resource,
reason,
"A2A request denied at admission"
);
Err(RpcError::new(
code::INVALID_REQUEST,
"this request was not permitted",
))
}
PolicyDecision::Malformed { reason } => {
tracing::error!(
target: "agentplane::a2a",
action,
resource,
policy_error = true,
reason,
"A2A request refused because the policy set could not be evaluated"
);
Err(RpcError::new(
code::INVALID_REQUEST,
"this request was not permitted",
))
}
}
}
fn permits(&self, caller: &Caller, action: &str, resource: &str) -> bool {
let context = json!({
"roles": caller.roles,
"peer": caller.actor,
"tenant": caller.tenant.as_str(),
});
matches!(
self.policy.authorize(&PolicyRequest {
principal: &caller.actor,
action,
resource,
context: &context,
}),
PolicyDecision::Permit
)
}
fn check_version(headers: &HeaderMap) -> Result<(), RpcError> {
let claimed = headers
.get(VERSION_HEADER)
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let claimed_version = crate::peers::protocol_major_minor(claimed);
if claimed_version == crate::peers::protocol_major_minor(crate::peers::PROTOCOL_VERSION)
&& claimed_version.is_some()
{
return Ok(());
}
let seen = if claimed.is_empty() {
"0.3 (no A2A-Version header, which the spec reads as 0.3)".to_owned()
} else {
claimed.to_owned()
};
Err(RpcError::new(
code::VERSION_NOT_SUPPORTED,
format!(
"this agent speaks A2A {}, and the request asked for {seen}",
crate::peers::PROTOCOL_VERSION
),
))
}
fn check_tenant(&self, params: &CommonParams) -> Result<(), RpcError> {
let ours = self.runtime.tenant().as_str();
let advertised = if ours == crate::core::TenantId::DEFAULT {
""
} else {
ours
};
let sent = params.tenant.as_deref().unwrap_or("");
if sent == advertised {
return Ok(());
}
Err(RpcError::new(
code::INVALID_PARAMS,
format!(
"this endpoint serves the tenant advertised on its card, and \
the request named '{sent}'. A2A clients echo the `tenant` from \
the interface they selected; a different value is a request \
for a different agent"
),
))
}
}
async fn agent_card(State(server): State<A2aServer>) -> Json<AgentCard> {
Json(server.card)
}
async fn one_agent_card(
State(server): State<A2aServer>,
axum::extract::Path(agent): axum::extract::Path<String>,
) -> Response {
server.per_agent.get(&agent).map_or_else(
|| (StatusCode::NOT_FOUND, "no such agent on this plane").into_response(),
|card| Json(card.clone()).into_response(),
)
}
async fn rpc(
State(server): State<A2aServer>,
headers: HeaderMap,
body: Result<Json<RpcRequest>, axum::extract::rejection::JsonRejection>,
) -> Response {
let req = match body {
Ok(Json(req)) => req,
Err(rejection) => {
let error = match &rejection {
axum::extract::rejection::JsonRejection::MissingJsonContentType(_) => {
RpcError::new(
code::CONTENT_TYPE_NOT_SUPPORTED,
"the request body must be application/json",
)
}
_ => RpcError::new(code::PARSE_ERROR, "the request body is not valid JSON-RPC"),
};
return error.into_response();
}
};
let id = req.id.clone();
if req.jsonrpc != "2.0" {
return RpcError::new(
code::INVALID_REQUEST,
format!("`jsonrpc` must be \"2.0\", not {:?}", req.jsonrpc),
)
.with_id(id)
.into_response();
}
if let Err(e) = A2aServer::check_version(&headers) {
return e.with_id(id).into_response();
}
if matches!(
req.method.as_str(),
method::SEND_STREAMING | method::SUBSCRIBE
) {
return match stream_method(server, headers, req).await {
Ok(sse) => sse.into_response(),
Err(e) => e.with_id(id).into_response(),
};
}
match dispatch(&server, &headers, &req).await {
Ok(result) => Json(json!({ "jsonrpc": "2.0", "id": id, "result": result })).into_response(),
Err(e) => e.with_id(id).into_response(),
}
}
async fn stream_method(
server: A2aServer,
headers: HeaderMap,
req: RpcRequest,
) -> Result<
axum::response::sse::Sse<
impl futures_util::stream::Stream<
Item = Result<axum::response::sse::Event, std::convert::Infallible>,
>,
>,
RpcError,
> {
let params = parse_params(&req.method, &req.params)?;
server.check_tenant(¶ms)?;
if req.method == method::SEND_STREAMING
&& let Some(configuration) = ¶ms.configuration
{
configuration.validate()?;
}
let run = if req.method == method::SUBSCRIBE {
let id = task_id(¶ms)?;
server
.gate(&headers, action::TASK_READ, &id.to_string())
.await?;
id
} else {
let Some(message) = params.message.clone() else {
return Err(RpcError::new(
code::INVALID_PARAMS,
"`message` is required by SendStreamingMessage",
));
};
message.validate_parts()?;
if message.task_id.is_some() {
continue_task(&server, &headers, &message).await?
} else {
let skill = resolve_skill(&server, &message)?;
let caller = server.gate(&headers, action::MESSAGE_SEND, &skill).await?;
if let Some(push) = params
.configuration
.as_ref()
.and_then(|configuration| configuration.task_push_notification_config.as_ref())
{
validate_inline_push(&server, &headers, &skill, push).await?;
}
let source = super::peer_source(&caller.actor);
let input = Tainted::from_source(message.to_input(), SourceId::new(&source));
match spawn_a2a(&server, &skill, input, &message, &caller).await {
Ok(run) => {
if let Some(push) = params.configuration.as_ref().and_then(|configuration| {
configuration.task_push_notification_config.as_ref()
}) {
register_push(&server, push, run, 1).await?;
}
run
}
Err(
crate::core::RuntimeError::PolicyDenied(_)
| crate::core::RuntimeError::Delegation(_),
) => {
return Err(RpcError::new(
code::UNSUPPORTED_OPERATION,
"this agent declined the request",
));
}
Err(crate::core::RuntimeError::QuotaExceeded(e)) => {
return Err(quota_refusal(&e));
}
Err(crate::core::RuntimeError::Draining) => {
return Err(RpcError::new(code::DRAINING, DRAINING_MESSAGE));
}
Err(e) => return Err(RpcError::new(code::INTERNAL_ERROR, e.to_string())),
}
}
};
let Some((task, case, from)) = super::a2a_stream::current(&server.runtime, run).await else {
return Err(RpcError::new(
code::TASK_NOT_FOUND,
format!("no such task: {run}"),
));
};
if req.method == method::SUBSCRIBE && super::a2a_stream::closes(task.status.state) {
return Err(RpcError::new(
code::UNSUPPORTED_OPERATION,
"SubscribeToTask requires a non-terminal task",
));
}
Ok(super::a2a_stream::tail(
Arc::clone(&server.runtime),
run,
case,
req.id,
task,
from,
))
}
async fn dispatch(
server: &A2aServer,
headers: &HeaderMap,
req: &RpcRequest,
) -> Result<Value, RpcError> {
let params = parse_params(&req.method, &req.params)?;
server.check_tenant(¶ms)?;
match req.method.as_str() {
method::SEND_MESSAGE => send_message(server, headers, params).await,
method::GET_TASK => get_task(server, headers, params).await,
method::CANCEL_TASK => cancel_task(server, headers, params).await,
method::GET_EXTENDED_CARD => get_extended_card(server, headers).await,
method::SEND_STREAMING | method::SUBSCRIBE => Err(RpcError::new(
code::INTERNAL_ERROR,
"a streaming method reached the non-streaming dispatcher",
)),
method::LIST_TASKS => list_tasks(server, headers, ¶ms).await,
method::CREATE_PUSH => push_create(server, headers, ¶ms).await,
method::GET_PUSH => push_get(server, headers, ¶ms).await,
method::LIST_PUSH => push_list(server, headers, ¶ms).await,
method::DELETE_PUSH => push_delete(server, headers, ¶ms).await,
other => Err(RpcError::new(
code::METHOD_NOT_FOUND,
format!("no such A2A method: {other}"),
)),
}
}
const FIELDS_BY_METHOD: &[(&str, &[&str])] = &[
(
method::SEND_MESSAGE,
&["tenant", "message", "configuration", "metadata"],
),
(
method::SEND_STREAMING,
&["tenant", "message", "configuration", "metadata"],
),
(method::GET_TASK, &["tenant", "id", "historyLength"]),
(method::CANCEL_TASK, &["tenant", "id"]),
(method::SUBSCRIBE, &["tenant", "id"]),
(method::GET_EXTENDED_CARD, &["tenant"]),
(
method::LIST_TASKS,
&[
"tenant",
"contextId",
"status",
"pageSize",
"pageToken",
"historyLength",
"statusTimestampAfter",
"includeArtifacts",
],
),
(
method::CREATE_PUSH,
&["tenant", "taskId", "id", "url", "token", "authentication"],
),
(method::GET_PUSH, &["tenant", "taskId", "id"]),
(
method::LIST_PUSH,
&["tenant", "taskId", "pageSize", "pageToken"],
),
(method::DELETE_PUSH, &["tenant", "taskId", "id"]),
];
fn parse_params(method: &str, value: &Value) -> Result<CommonParams, RpcError> {
if value.is_null() {
return Ok(CommonParams::default());
}
let Some(object) = value.as_object() else {
return Err(RpcError::new(
code::INVALID_PARAMS,
"A2A method parameters must be a JSON object",
));
};
if let Some((_, allowed)) = FIELDS_BY_METHOD.iter().find(|(m, _)| *m == method)
&& let Some(stray) = object.keys().find(|k| !allowed.contains(&k.as_str()))
{
return Err(RpcError::new(
code::INVALID_PARAMS,
format!(
"'{stray}' is not a parameter of {method}; it takes {}",
allowed.join(", ")
),
));
}
serde_json::from_value(value.clone()).map_err(|error| {
RpcError::new(
code::INVALID_PARAMS,
format!("request parameters do not match the A2A method schema: {error}"),
)
})
}
fn continue_sealed_task(
run: RunId,
records: &[crate::journal::Record],
message: &A2aMessage,
) -> Result<RunId, RpcError> {
let delivered_before = records.iter().any(|record| {
matches!(
record.kind(),
RecordKind::EffectDone { output, .. }
if output
.get("$a2a_message")
.and_then(|m| m.get("messageId"))
.and_then(Value::as_str)
== Some(message.message_id.as_str())
)
});
if delivered_before {
return Ok(run);
}
Err(RpcError::new(
code::UNSUPPORTED_OPERATION,
"this task has completed and is not waiting for input",
))
}
async fn continue_task(
server: &A2aServer,
headers: &HeaderMap,
message: &A2aMessage,
) -> Result<RunId, RpcError> {
let raw = message
.task_id
.as_deref()
.ok_or_else(|| RpcError::new(code::INVALID_PARAMS, "`taskId` is required"))?;
let run = RunId::parse(raw)
.map_err(|_| RpcError::new(code::TASK_NOT_FOUND, format!("no such task: {raw}")))?;
let caller = server
.gate(headers, action::TASK_CONTINUE, &run.to_string())
.await?;
let records = server
.runtime
.journal()
.read(run, 1)
.await
.map_err(|_| RpcError::new(code::INTERNAL_ERROR, "the journal could not be read"))?;
let Some(last) = records.last() else {
return Err(RpcError::new(
code::TASK_NOT_FOUND,
format!("no such task: {run}"),
));
};
if matches!(
last.kind(),
RecordKind::RunSuspended {
reason: crate::core::SuspendReason::AwaitingTime { .. }
}
) {
return Err(RpcError::new(
code::UNSUPPORTED_OPERATION,
"this task is sleeping until a timer fires and cannot accept input",
));
}
if !matches!(
last.kind(),
RecordKind::RunSuspended { .. } | RecordKind::RunConcluded { .. }
) {
return Err(RpcError::new(
code::UNSUPPORTED_OPERATION,
"this task is not waiting for input",
));
}
if matches!(last.kind(), RecordKind::RunConcluded { .. }) {
return continue_sealed_task(run, &records, message);
}
let (kind, correlation) = records
.iter()
.rev()
.find_map(|record| match record.kind() {
RecordKind::RunSuspended {
reason:
crate::core::SuspendReason::AwaitingEvent {
kind, correlation, ..
},
} => Some((kind.clone(), correlation.clone())),
_ => None,
})
.ok_or_else(|| {
RpcError::new(
code::UNSUPPORTED_OPERATION,
"this task has no input wait to continue",
)
})?;
let context = records
.iter()
.find_map(|record| record.body.case.map(|case| case.to_string()));
if let Some(sent) = message.context_id.as_deref()
&& context.as_deref() != Some(sent)
{
return Err(RpcError::new(
code::INVALID_PARAMS,
"message.contextId does not match the referenced task",
));
}
let event = crate::core::InboundEvent {
source: super::peer_source(&caller.actor),
id: message.message_id.clone(),
kind,
correlation,
payload: message.to_input(),
};
match server.runtime.deliver_to(run, &event).await {
Ok(
crate::core::Delivery::Resumed { .. }
| crate::core::Delivery::Duplicate
| crate::core::Delivery::Buffered,
) => Ok(run),
Err(crate::core::RuntimeError::PlanContract(_)) => Err(RpcError::new(
code::UNSUPPORTED_OPERATION,
"this task is no longer waiting for input",
)),
Err(error) => Err(RpcError::new(code::INTERNAL_ERROR, error.to_string())),
}
}
#[allow(clippy::too_many_lines)]
async fn send_message(
server: &A2aServer,
headers: &HeaderMap,
params: CommonParams,
) -> Result<Value, RpcError> {
if let Some(configuration) = ¶ms.configuration {
configuration.validate()?;
}
let inline_push = params
.configuration
.as_ref()
.and_then(|configuration| configuration.task_push_notification_config.clone());
let Some(message) = params.message else {
return Err(RpcError::new(
code::INVALID_PARAMS,
"`message` is required by SendMessage",
));
};
message.validate_parts()?;
if message.task_id.is_some() {
let history_length = params
.configuration
.as_ref()
.and_then(|configuration| configuration.history_length);
let run = continue_task(server, headers, &message).await?;
return get_task(
server,
headers,
CommonParams {
id: Some(run.to_string()),
history_length,
..CommonParams::default()
},
)
.await
.map(|task| json!({ "task": task }));
}
let skill = resolve_skill(server, &message)?;
let caller = server.gate(headers, action::MESSAGE_SEND, &skill).await?;
if let Some(push) = &inline_push {
validate_inline_push(server, headers, &skill, push).await?;
}
let source = super::peer_source(&caller.actor);
let input = Tainted::from_source(message.to_input(), SourceId::new(&source));
if params
.configuration
.as_ref()
.is_some_and(|c| c.return_immediately)
{
return match spawn_a2a(server, &skill, input, &message, &caller).await {
Ok(run) => {
if let Some(push) = &inline_push {
register_push(server, push, run, 1).await?;
}
let case = task_context(server, run).await?;
Ok(json!({
"task": task_of(run, TaskState::Working, "accepted", case)
}))
}
Err(
crate::core::RuntimeError::PolicyDenied(_)
| crate::core::RuntimeError::Delegation(_),
) => Ok(json!({ "message": declined(&skill) })),
Err(crate::core::RuntimeError::QuotaExceeded(e)) => Err(quota_refusal(&e)),
Err(crate::core::RuntimeError::Draining) => {
Err(RpcError::new(code::DRAINING, DRAINING_MESSAGE))
}
Err(crate::core::RuntimeError::PlanContract(why)) if message.context_id.is_some() => {
Err(RpcError::new(code::TASK_NOT_FOUND, why))
}
Err(e) => Err(RpcError::new(code::INTERNAL_ERROR, e.to_string())),
};
}
let admission = match run_a2a(server, &skill, input, &message, &caller).await {
Ok(admission) => admission,
Err(
crate::core::RuntimeError::PolicyDenied(_) | crate::core::RuntimeError::Delegation(_),
) => {
return Ok(json!({ "message": declined(&skill) }));
}
Err(crate::core::RuntimeError::QuotaExceeded(e)) => {
return Err(quota_refusal(&e));
}
Err(crate::core::RuntimeError::Draining) => {
return Err(RpcError::new(code::DRAINING, DRAINING_MESSAGE));
}
Err(crate::core::RuntimeError::PlanContract(why)) if message.context_id.is_some() => {
return Err(RpcError::new(code::TASK_NOT_FOUND, why));
}
Err(e) => return Err(RpcError::new(code::INTERNAL_ERROR, e.to_string())),
};
let outcome = match admission {
crate::runtime::Admission::Fresh(outcome)
| crate::runtime::Admission::Replayed(outcome) => outcome,
crate::runtime::Admission::InFlight(run) => {
let case = task_context(server, run).await?;
return Ok(json!({
"task": task_of(run, TaskState::Working, "accepted", case)
}));
}
};
let case = task_context(server, outcome.run_id).await?;
if matches!(outcome.status, crate::runtime::RunStatus::Succeeded)
&& let Some(reply) = outcome.output.as_ref().and_then(A2aReply::of_output)
&& let Some(parts) = reply.message_parts()
{
return Ok(json!({
"message": A2aMessage {
message_id: format!("reply-{}", outcome.run_id),
role: "ROLE_AGENT".to_owned(),
parts,
context_id: case,
task_id: None,
metadata: None,
extensions: Vec::new(),
reference_task_ids: Vec::new(),
}
}));
}
if let Some(push) = &inline_push {
register_push(server, push, outcome.run_id, 1).await?;
}
let mut task = task_of_outcome(&outcome);
task.context_id = case;
let history_length = params
.configuration
.as_ref()
.and_then(|configuration| configuration.history_length);
if history_length != Some(0) {
let records = server
.runtime
.journal()
.read(outcome.run_id, 1)
.await
.map_err(|_| {
RpcError::new(code::INTERNAL_ERROR, "the task journal could not be read")
})?;
task.history = task_history(
outcome.run_id,
&records,
history_length,
task.context_id.as_deref(),
);
}
Ok(json!({ "task": task }))
}
async fn run_a2a(
server: &A2aServer,
skill: &str,
input: Tainted<Value>,
message: &A2aMessage,
caller: &Caller,
) -> Result<crate::runtime::Admission, crate::core::RuntimeError> {
server
.runtime
.run_under(skill, input, run_terms(message, caller)?)
.await
}
async fn spawn_a2a(
server: &A2aServer,
skill: &str,
input: Tainted<Value>,
message: &A2aMessage,
caller: &Caller,
) -> Result<RunId, crate::core::RuntimeError> {
Ok(server
.runtime
.spawn_under(skill, input, run_terms(message, caller)?)
.await?
.run)
}
fn run_terms(
message: &A2aMessage,
caller: &Caller,
) -> Result<crate::runtime::RunTerms, crate::core::RuntimeError> {
let source = super::peer_source(&caller.actor);
let keyed = format!("{source}\u{1f}{}", message.message_id);
let mut terms = crate::runtime::RunTerms::default().once(&keyed);
if let Some(chain) = caller.acting_as.as_ref() {
terms = terms.acting_as(chain.clone());
}
Ok(match message.context_id.as_deref() {
Some(context) => {
let case = crate::core::CaseId::parse(context).map_err(|_| {
crate::core::RuntimeError::PlanContract(
"contextId is not a case issued here".into(),
)
})?;
terms.in_case(case)
}
None => terms.correlated(
"a2a.context",
&[crate::core::CorrelationKey::new("a2a-message", &keyed)],
),
})
}
async fn task_context(server: &A2aServer, run: RunId) -> Result<Option<String>, RpcError> {
server
.runtime
.journal()
.read(run, 1)
.await
.map_err(|_| RpcError::new(code::INTERNAL_ERROR, "the task journal could not be read"))
.map(|records| {
records
.iter()
.find_map(|record| record.body.case.map(|case| case.to_string()))
})
}
pub(super) fn task_of_outcome(outcome: &crate::runtime::RunOutcome) -> A2aTask {
let artifacts_parts: Vec<Vec<Part>> =
match outcome.output.as_ref().and_then(A2aReply::of_output) {
Some(reply) => reply.artifact_parts(),
None => vec![vec![match outcome
.output
.as_ref()
.map_or(Value::Null, |o| o.peek().clone())
{
Value::String(text) => Part::text(text),
data => Part::data(data),
}]],
};
A2aTask {
id: outcome.run_id.to_string(),
context_id: None,
status: TaskStatus {
state: state_of(&outcome.status),
message: None,
timestamp: None,
},
artifacts: matches!(outcome.status, crate::runtime::RunStatus::Succeeded).then(|| {
artifacts_parts
.into_iter()
.enumerate()
.map(|(i, parts)| A2aArtifact {
artifact_id: format!("{}-result-{i}", outcome.run_id),
name: None,
description: None,
parts,
metadata: None,
extensions: Vec::new(),
})
.collect()
}),
history: None,
metadata: None,
}
}
fn resolve_skill(server: &A2aServer, message: &A2aMessage) -> Result<String, RpcError> {
if let Some(asked) = message.requested_skill() {
if server.skills.iter().any(|s| s == asked) {
return Ok(asked.to_owned());
}
return Err(RpcError::new(
code::INVALID_PARAMS,
format!(
"this agent has no skill '{asked}'. Its card advertises: {}",
server.skills.join(", ")
),
));
}
match server.skills.as_slice() {
[only] => Ok(only.clone()),
[] => Err(RpcError::new(
code::UNSUPPORTED_OPERATION,
"this agent advertises no skills, so there is nothing to send a \
message to",
)),
many => Err(RpcError::new(
code::INVALID_PARAMS,
format!(
"this agent advertises {} skills, so `message.metadata.skill` \
must name one of: {}. It is not inferred from the message — \
choosing what to run by reading the text would let the sender \
pick the capability",
many.len(),
many.join(", ")
),
)),
}
}
async fn get_task(
server: &A2aServer,
headers: &HeaderMap,
params: CommonParams,
) -> Result<Value, RpcError> {
let id = task_id(¶ms)?;
server
.gate(headers, action::TASK_READ, &id.to_string())
.await?;
load_task(server, id, params.history_length).await
}
async fn load_task(
server: &A2aServer,
id: RunId,
history_length: Option<usize>,
) -> Result<Value, RpcError> {
let records = server
.runtime
.journal()
.read(id, 1)
.await
.map_err(|_| RpcError::new(code::INTERNAL_ERROR, "the journal could not be read"))?;
let (state, detail) = state_from_history(&records)
.ok_or_else(|| RpcError::new(code::TASK_NOT_FOUND, format!("no such task: {id}")))?;
let case = records
.iter()
.find_map(|r| r.body.case.map(|c| c.to_string()));
let mut task = task_of(id, state, &detail, case.clone());
task.history = task_history(id, &records, history_length, case.as_deref());
task.artifacts = match server
.artifacts_bounded(id, state, None)
.await
.map_err(|error| RpcError::new(code::INTERNAL_ERROR, error.to_string()))?
{
ArtifactRead::Artifacts(artifacts) => artifacts,
ArtifactRead::OverBudget => unreachable!("an unmetered read has no budget to exceed"),
};
serde_json::to_value(task)
.map_err(|error| RpcError::new(code::INTERNAL_ERROR, error.to_string()))
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct TaskCursor {
updated_at: u64,
run: String,
context_id: Option<String>,
status: Option<TaskState>,
status_timestamp_after: Option<String>,
}
const TASK_SCAN: usize = 256;
const FILTER_SCAN_BUDGET: usize = 1024;
const ARTIFACT_REPLAY_BUDGET: usize = 16;
pub const ARTIFACTS_OMITTED_KEY: &str = "io.agentplane.a2a/artifactsOmitted";
#[allow(clippy::too_many_lines)]
async fn list_tasks(
server: &A2aServer,
headers: &HeaderMap,
params: &CommonParams,
) -> Result<Value, RpcError> {
let caller = server.gate(headers, action::TASK_READ, "tasks").await?;
let page_size = params.page_size.unwrap_or(50);
if !(1..=100).contains(&page_size) {
return Err(RpcError::new(
code::INVALID_PARAMS,
"pageSize must be between 1 and 100",
));
}
let after = params
.status_timestamp_after
.as_deref()
.map(|value| {
time::OffsetDateTime::parse(value, &time::format_description::well_known::Rfc3339)
.map_err(|_| {
RpcError::new(
code::INVALID_PARAMS,
"statusTimestampAfter must be an RFC 3339 timestamp",
)
})
})
.transpose()?;
let cursor = params
.page_token
.as_deref()
.map(decode_task_cursor)
.transpose()?;
if let Some(cursor) = &cursor
&& (cursor.context_id != params.context_id
|| cursor.status != params.status
|| cursor.status_timestamp_after != params.status_timestamp_after)
{
return Err(RpcError::new(
code::INVALID_PARAMS,
"pageToken was issued for different ListTasks filters",
));
}
let content_filtered = params.context_id.is_some() || params.status.is_some();
let mut cursor_pos = cursor
.as_ref()
.and_then(|c| RunId::parse(&c.run).ok().map(|run| (c.updated_at, run)));
let mut page: Vec<(RunId, u64, A2aTask)> = Vec::new();
let mut matched: u64 = 0;
let mut has_more = false;
let mut reads: usize = 0;
loop {
let batch = server
.runtime
.journal()
.recent_runs(cursor_pos, TASK_SCAN)
.await
.map_err(|_| RpcError::new(code::INTERNAL_ERROR, "the task index could not be read"))?;
if batch.is_empty() {
break;
}
cursor_pos = batch.last().map(|(run, updated)| (*updated, *run));
for (run, updated) in batch {
if !server.permits(&caller, action::TASK_READ, &run.to_string()) {
continue;
}
if after.is_some_and(|cutoff| {
i64::try_from(updated)
.ok()
.and_then(|seconds| time::OffsetDateTime::from_unix_timestamp(seconds).ok())
.is_none_or(|value| value < cutoff)
}) {
continue;
}
let wanted = page.len() < page_size;
if !content_filtered && !wanted {
matched += 1;
has_more = true;
continue;
}
reads += 1;
if content_filtered && reads > server.filter_scan_budget {
return Err(RpcError::new(
code::INVALID_PARAMS,
format!(
"this filter would require examining more than {} tasks to answer \
exactly — narrow it with statusTimestampAfter and page from there",
server.filter_scan_budget
),
));
}
let records = server.runtime.journal().read(run, 1).await.map_err(|_| {
RpcError::new(code::INTERNAL_ERROR, "a task journal could not be read")
})?;
let task = task_from_records(run, &records, updated, params.history_length);
if params
.context_id
.as_ref()
.is_some_and(|context| task.context_id.as_ref() != Some(context))
|| params
.status
.as_ref()
.is_some_and(|status| &task.status.state != status)
{
continue;
}
matched += 1;
if wanted {
page.push((run, updated, task));
} else {
has_more = true;
}
}
}
let total_size = matched.min(u64::try_from(i32::MAX).unwrap_or(u64::MAX));
let visible = &page[..];
let next_page_token = if has_more {
let (run, updated, _) = visible.last().expect("a page with more has a last item");
encode_task_cursor(&TaskCursor {
updated_at: *updated,
run: run.to_string(),
context_id: params.context_id.clone(),
status: params.status,
status_timestamp_after: params.status_timestamp_after.clone(),
})?
} else {
String::new()
};
let mut tasks = Vec::with_capacity(visible.len());
let mut replays_left = server.artifact_replay_budget;
for (run, _, task) in visible {
let mut task = task.clone();
if params.include_artifacts {
match server
.artifacts_bounded(*run, task.status.state, Some(&mut replays_left))
.await
.map_err(|error| RpcError::new(code::INTERNAL_ERROR, error.to_string()))?
{
ArtifactRead::Artifacts(artifacts) => task.artifacts = artifacts,
ArtifactRead::OverBudget => {
task.metadata = Some(json!({ ARTIFACTS_OMITTED_KEY: true }));
}
}
}
tasks.push(task);
}
Ok(json!({
"tasks": tasks,
"nextPageToken": next_page_token,
"pageSize": page_size,
"totalSize": total_size,
}))
}
fn encode_task_cursor(cursor: &TaskCursor) -> Result<String, RpcError> {
crate::core::canon::to_bytes(cursor)
.map(crate::core::b64::encode_url)
.map_err(|_| RpcError::new(code::INTERNAL_ERROR, "the task cursor could not be encoded"))
}
fn decode_task_cursor(token: &str) -> Result<TaskCursor, RpcError> {
crate::core::b64::decode_url(token)
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.ok_or_else(|| RpcError::new(code::INVALID_PARAMS, "pageToken is not a valid task cursor"))
}
fn task_from_records(
run: RunId,
records: &[crate::journal::Record],
updated: u64,
history_length: Option<usize>,
) -> A2aTask {
let (state, detail) =
state_from_history(records).unwrap_or_else(|| (TaskState::Working, "running".to_owned()));
let case = records
.iter()
.find_map(|record| record.body.case.map(|case| case.to_string()));
let timestamp = i64::try_from(updated)
.ok()
.and_then(|seconds| time::OffsetDateTime::from_unix_timestamp(seconds).ok())
.and_then(|value| {
value
.format(&time::format_description::well_known::Rfc3339)
.ok()
});
let history = task_history(run, records, history_length, case.as_deref());
let mut task = task_of(run, state, &detail, case);
task.status.timestamp = timestamp;
task.history = history;
task
}
const HISTORY_CAP: usize = 128;
fn task_history(
run: RunId,
records: &[crate::journal::Record],
history_length: Option<usize>,
case: Option<&str>,
) -> Option<Vec<A2aMessage>> {
let limit = history_length.unwrap_or(HISTORY_CAP);
{
if limit == 0 {
return None;
}
let mut history = Vec::new();
for record in records {
let input = match record.kind() {
RecordKind::RunAdmitted { input, .. } => Some(input),
RecordKind::EffectDone { output, .. } if output.get("$a2a_message").is_some() => {
Some(output)
}
_ => None,
};
let Some(input) = input else { continue };
if let Some(message) = input.get("$a2a_message")
&& let Ok(mut message) = serde_json::from_value::<A2aMessage>(message.clone())
{
message.task_id = Some(run.to_string());
if message.context_id.is_none() {
message.context_id = case.map(ToOwned::to_owned);
}
history.push(message);
continue;
}
if history.is_empty() {
let text = input
.get("text")
.and_then(Value::as_str)
.map(ToOwned::to_owned);
let media_type = if text.is_some() {
"text/plain"
} else {
"application/json"
};
history.push(A2aMessage {
message_id: format!("{run}-input"),
role: "ROLE_USER".to_owned(),
parts: vec![Part {
data: text.is_none().then(|| input.clone()),
text,
raw: None,
url: None,
filename: None,
media_type: Some(media_type.to_owned()),
metadata: None,
}],
context_id: case.map(ToOwned::to_owned),
task_id: Some(run.to_string()),
metadata: None,
extensions: Vec::new(),
reference_task_ids: Vec::new(),
});
}
}
let keep_from = history.len().saturating_sub(limit);
(!history.is_empty()).then(|| history.split_off(keep_from))
}
}
pub(super) async fn task_artifacts(
runtime: &Runtime,
run: RunId,
state: TaskState,
) -> Result<Option<Vec<A2aArtifact>>, crate::core::RuntimeError> {
if state != TaskState::Completed {
return Ok(None);
}
runtime
.replay(run, crate::runtime::Mode::Strict)
.await
.map(|outcome| task_of_outcome(&outcome).artifacts)
}
pub(super) fn sealed_state(outcome: &str) -> TaskState {
match outcome {
"succeeded" => TaskState::Completed,
"cancelled" => TaskState::Canceled,
"suspended" => TaskState::InputRequired,
"swept" | "broke-glass" => TaskState::Rejected,
_ => TaskState::Failed,
}
}
pub(super) fn state_from_history(
records: &[crate::journal::Record],
) -> Option<(TaskState, String)> {
let last = records.last()?;
Some(match last.kind() {
RecordKind::RunSuspended { reason } => (TaskState::InputRequired, reason.to_string()),
RecordKind::RunConcluded { outcome, .. } => (sealed_state(outcome), outcome.clone()),
_ => (TaskState::Working, "running".to_owned()),
})
}
async fn cancel_task(
server: &A2aServer,
headers: &HeaderMap,
params: CommonParams,
) -> Result<Value, RpcError> {
let id = task_id(¶ms)?;
let caller = server
.gate(headers, action::TASK_CANCEL, &id.to_string())
.await?;
let records = server
.runtime
.journal()
.read(id, 1)
.await
.map_err(|_| RpcError::new(code::INTERNAL_ERROR, "the journal could not be read"))?;
let Some(last) = records.last() else {
return Err(RpcError::new(
code::TASK_NOT_FOUND,
format!("no such task: {id}"),
));
};
if let RecordKind::RunConcluded { outcome, .. } = last.kind() {
return Err(RpcError::new(
code::TASK_NOT_CANCELABLE,
format!("this task already finished as '{outcome}'"),
));
}
server
.runtime
.request_cancel(id, &caller.actor, "cancelled over A2A")
.await
.map_err(|e| RpcError::new(code::INTERNAL_ERROR, e.to_string()))?;
let case = records
.iter()
.find_map(|record| record.body.case.map(|case| case.to_string()));
serde_json::to_value(task_of(
id,
TaskState::Working,
"cancellation requested",
case,
))
.map_err(|error| RpcError::new(code::INTERNAL_ERROR, error.to_string()))
}
async fn get_extended_card(server: &A2aServer, headers: &HeaderMap) -> Result<Value, RpcError> {
server
.gate(headers, action::CARD_EXTENDED, &server.card.name)
.await?;
serde_json::to_value(&server.extended)
.map_err(|e| RpcError::new(code::INTERNAL_ERROR, e.to_string()))
}
fn task_id(params: &CommonParams) -> Result<RunId, RpcError> {
let Some(raw) = params.id.as_deref() else {
return Err(RpcError::new(code::INVALID_PARAMS, "`id` is required"));
};
RunId::parse(raw).map_err(|_| {
RpcError::new(code::TASK_NOT_FOUND, format!("no such task: {raw}"))
})
}
fn declined(skill: &str) -> A2aMessage {
A2aMessage {
message_id: format!("declined-{skill}"),
role: "ROLE_AGENT".to_owned(),
parts: vec![Part {
text: Some("this agent declined the request".to_owned()),
data: None,
raw: None,
url: None,
filename: None,
media_type: Some("text/plain".to_owned()),
metadata: None,
}],
context_id: None,
task_id: None,
metadata: None,
extensions: Vec::new(),
reference_task_ids: Vec::new(),
}
}
pub(super) fn task_of(run: RunId, state: TaskState, detail: &str, case: Option<String>) -> A2aTask {
A2aTask {
id: run.to_string(),
context_id: case,
status: TaskStatus {
state,
message: Some(A2aMessage {
message_id: format!("{run}-status"),
role: "ROLE_AGENT".to_owned(),
parts: vec![Part {
text: Some(detail.to_owned()),
data: None,
raw: None,
url: None,
filename: None,
media_type: Some("text/plain".to_owned()),
metadata: None,
}],
context_id: None,
task_id: Some(run.to_string()),
metadata: None,
extensions: Vec::new(),
reference_task_ids: Vec::new(),
}),
timestamp: None,
},
artifacts: None,
history: None,
metadata: None,
}
}
fn push_runtime(server: &A2aServer) -> Result<&PushRuntime, RpcError> {
server.push.as_ref().ok_or_else(push_not_supported_error)
}
fn push_not_supported_error() -> RpcError {
RpcError::new(
code::PUSH_NOT_SUPPORTED,
"this agent does not implement push notifications; its card advertises pushNotifications as false",
)
}
async fn push_task(server: &A2aServer, raw: Option<&str>) -> Result<RunId, RpcError> {
let raw = raw.ok_or_else(|| RpcError::new(code::INVALID_PARAMS, "`taskId` is required"))?;
let task = RunId::parse(raw)
.map_err(|_| RpcError::new(code::TASK_NOT_FOUND, format!("no such task: {raw}")))?;
let records = server
.runtime
.journal()
.read(task, 1)
.await
.map_err(|_| RpcError::new(code::INTERNAL_ERROR, "the journal could not be read"))?;
if records.is_empty() {
return Err(RpcError::new(
code::TASK_NOT_FOUND,
format!("no such task: {task}"),
));
}
Ok(task)
}
fn push_request(params: &CommonParams) -> Result<PushRequest, RpcError> {
let url = params
.url
.clone()
.ok_or_else(|| RpcError::new(code::INVALID_PARAMS, "`url` is required"))?;
Ok(PushRequest {
id: params.id.clone(),
task_id: params.push_task.clone(),
url,
token: params.token.clone(),
authentication: params.authentication.clone(),
})
}
fn validate_push_request(server: &A2aServer, request: &PushRequest) -> Result<(), RpcError> {
let push = push_runtime(server)?;
request.validate()?;
let config = request.config(RunId::generate());
if let Some(authentication) = &config.authentication {
authentication
.validate()
.map_err(|error| RpcError::new(code::INVALID_PARAMS, error.to_string()))?;
}
push.transport
.validate(&config)
.map_err(|error| RpcError::new(code::INVALID_PARAMS, error.to_string()))
}
async fn validate_inline_push(
server: &A2aServer,
headers: &HeaderMap,
skill: &str,
request: &PushRequest,
) -> Result<(), RpcError> {
if request
.task_id
.as_deref()
.is_some_and(|task| !task.is_empty())
{
return Err(RpcError::new(
code::INVALID_PARAMS,
"taskPushNotificationConfig.taskId must be empty in SendMessage",
));
}
server
.gate(headers, action::TASK_PUSH, &format!("new:{skill}"))
.await?;
validate_push_request(server, request)
}
async fn register_push(
server: &A2aServer,
request: &PushRequest,
task: RunId,
next_seq: Seq,
) -> Result<crate::push::PushConfig, RpcError> {
let push = push_runtime(server)?;
if request
.task_id
.as_deref()
.is_some_and(|configured| !configured.is_empty() && configured != task.to_string())
{
return Err(RpcError::new(
code::INVALID_PARAMS,
"push configuration taskId does not match its task",
));
}
request.validate()?;
let config = request.config(task);
if let Some(authentication) = &config.authentication {
authentication
.validate()
.map_err(|error| RpcError::new(code::INVALID_PARAMS, error.to_string()))?;
}
push.transport
.validate(&config)
.map_err(|error| RpcError::new(code::INVALID_PARAMS, error.to_string()))?;
push.store
.put(&config, next_seq)
.await
.map_err(|error| RpcError::new(code::INTERNAL_ERROR, error.to_string()))?;
Ok(config)
}
async fn push_create(
server: &A2aServer,
headers: &HeaderMap,
params: &CommonParams,
) -> Result<Value, RpcError> {
let resource = params.push_task.as_deref().unwrap_or("push");
server.gate(headers, action::TASK_PUSH, resource).await?;
push_runtime(server)?;
let task = push_task(server, params.push_task.as_deref()).await?;
let request = push_request(params)?;
let head = server
.runtime
.journal()
.head(task)
.await
.map_err(|error| RpcError::new(code::INTERNAL_ERROR, error.to_string()))?;
let tail = server
.runtime
.journal()
.read(task, head.seq)
.await
.map_err(|error| RpcError::new(code::INTERNAL_ERROR, error.to_string()))?;
let next_seq = if tail
.last()
.is_some_and(|record| matches!(record.kind(), RecordKind::RunConcluded { .. }))
{
head.seq
} else {
head.seq.saturating_add(1)
};
Ok(register_push(server, &request, task, next_seq)
.await?
.redacted())
}
async fn push_get(
server: &A2aServer,
headers: &HeaderMap,
params: &CommonParams,
) -> Result<Value, RpcError> {
let resource = params.push_task.as_deref().unwrap_or("push");
server.gate(headers, action::TASK_PUSH, resource).await?;
let push = push_runtime(server)?;
let task = push_task(server, params.push_task.as_deref()).await?;
let id = params
.id
.as_deref()
.ok_or_else(|| RpcError::new(code::INVALID_PARAMS, "`id` is required"))?;
push.store
.get(task, id)
.await
.map_err(|error| RpcError::new(code::INTERNAL_ERROR, error.to_string()))?
.map(|config| config.redacted())
.ok_or_else(|| {
RpcError::new(
code::TASK_NOT_FOUND,
format!("no push configuration '{id}' for task {task}"),
)
})
}
async fn push_list(
server: &A2aServer,
headers: &HeaderMap,
params: &CommonParams,
) -> Result<Value, RpcError> {
let resource = params.push_task.as_deref().unwrap_or("push");
server.gate(headers, action::TASK_PUSH, resource).await?;
let push = push_runtime(server)?;
let task = push_task(server, params.push_task.as_deref()).await?;
let configs = push
.store
.list(task)
.await
.map_err(|error| RpcError::new(code::INTERNAL_ERROR, error.to_string()))?;
Ok(json!({
"configs": configs.iter().map(crate::push::PushConfig::redacted).collect::<Vec<_>>(),
"nextPageToken": "",
}))
}
async fn push_delete(
server: &A2aServer,
headers: &HeaderMap,
params: &CommonParams,
) -> Result<Value, RpcError> {
let resource = params.push_task.as_deref().unwrap_or("push");
server.gate(headers, action::TASK_PUSH, resource).await?;
let push = push_runtime(server)?;
let task = push_task(server, params.push_task.as_deref()).await?;
let id = params
.id
.as_deref()
.ok_or_else(|| RpcError::new(code::INVALID_PARAMS, "`id` is required"))?;
push.store
.delete(task, id)
.await
.map_err(|error| RpcError::new(code::INTERNAL_ERROR, error.to_string()))?;
Ok(json!({}))
}
#[cfg(test)]
mod state_agreement_tests {
use super::{TaskState, sealed_state, state_of};
#[test]
fn a_live_status_and_its_sealed_outcome_agree() {
let statuses = crate::runtime::every_status();
assert_eq!(
statuses.len(),
10,
"a RunStatus variant was added or removed — decide which A2A state it \
surfaces as, in `state_of` and in `sealed_state` both"
);
for status in &statuses {
assert_eq!(
state_of(status),
sealed_state(status.as_str()),
"'{}' surfaces as one state live and another once sealed",
status.as_str()
);
}
}
#[test]
fn the_mapping_distinguishes_more_than_failure() {
let seen: std::collections::BTreeSet<_> = crate::runtime::every_status()
.iter()
.map(|status| format!("{:?}", state_of(status)))
.collect();
assert!(
seen.len() >= 4,
"the run-status mapping collapsed to {seen:?}; a client cannot tell \
completed from cancelled from waiting"
);
assert_eq!(
state_of(&crate::runtime::RunStatus::Succeeded),
TaskState::Completed
);
}
}