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 base64::Engine as _;
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 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";
#[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::Replanning(_) => TaskState::Failed,
}
}
#[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.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"),
));
}
let supported = match (part.text.is_some(), part.data.is_some()) {
(true, false) => part
.media_type
.as_deref()
.is_none_or(|value| value == "text/plain"),
(false, true) => part
.media_type
.as_deref()
.is_none_or(|value| value == "application/json"),
_ => false,
};
if !supported {
return Err(RpcError::new(
code::CONTENT_TYPE_NOT_SUPPORTED,
format!(
"message.parts[{index}] mediaType does not match this agent's text/plain and application/json inputs"
),
));
}
}
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 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)]
struct CommonParams {
#[serde(default)]
tenant: Option<String>,
#[serde(default)]
message: Option<A2aMessage>,
#[serde(default)]
id: Option<String>,
#[serde(default)]
configuration: Option<SendConfiguration>,
#[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> {
match self.code {
code::TASK_NOT_FOUND => Some("TASK_NOT_FOUND"),
code::TASK_NOT_CANCELABLE => Some("TASK_NOT_CANCELABLE"),
code::PUSH_NOT_SUPPORTED => Some("PUSH_NOTIFICATION_NOT_SUPPORTED"),
code::UNSUPPORTED_OPERATION => Some("UNSUPPORTED_OPERATION"),
code::CONTENT_TYPE_NOT_SUPPORTED => Some("CONTENT_TYPE_NOT_SUPPORTED"),
code::EXTENDED_CARD_NOT_CONFIGURED => Some("EXTENDED_AGENT_CARD_NOT_CONFIGURED"),
code::VERSION_NOT_SUPPORTED => Some("VERSION_NOT_SUPPORTED"),
_ => None,
}
}
fn body(&self) -> Value {
match self.reason() {
Some(reason) => json!({
"code": self.code,
"message": self.message,
"data": [{
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
"domain": "a2a-protocol.org",
"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,
skills: Vec<String>,
push: Option<PushRuntime>,
}
#[derive(Debug, Clone)]
struct PushRuntime {
store: Arc<dyn crate::push::PushStore>,
transport: Arc<dyn crate::push::PushTransport>,
}
#[derive(Debug, Clone)]
pub struct A2aPushWorker {
runtime: Arc<Runtime>,
push: PushRuntime,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PushSweepReport {
pub registrations: usize,
pub records: usize,
pub deliveries: usize,
pub retries: usize,
pub completed: usize,
pub saturated: bool,
}
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,
}
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> {
let policy = runtime.policy().ok_or(ServerSetupError::NoPolicy)?.clone();
if runtime.cases().is_none() {
return Err(ServerSetupError::NoCases);
}
let url = url.into();
let mut card = AgentCard::derive(manifest, url.clone())?;
let mut extended = ExtendedAgentCard::derive(manifest, url)?;
security.apply(&mut card);
security.apply(&mut extended.public);
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 = card.skills.iter().map(|s| s.id.clone()).collect();
Ok(Self {
runtime,
auth,
policy,
card,
extended,
skills,
push: None,
})
}
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| A2aPushWorker {
runtime: Arc::clone(&self.runtime),
push,
})
}
pub fn router(self) -> Router {
Router::new()
.route(WELL_KNOWN_PATH, get(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",
))
}
}
}
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"
),
))
}
}
impl A2aPushWorker {
pub async fn run_once(
&self,
at: u64,
limit: usize,
) -> Result<PushSweepReport, crate::core::StoreError> {
let due = self.push.store.due(at, limit.saturating_add(1)).await?;
let saturated = due.len() > limit;
let mut report = PushSweepReport {
registrations: due.len().min(limit),
saturated,
..PushSweepReport::default()
};
for registration in due.into_iter().take(limit) {
let mut attempts = registration.attempts;
let records = self
.runtime
.journal()
.read(registration.config.task, registration.next_seq)
.await?;
if records.is_empty() && self.cleanup_acknowledged_terminal(®istration).await? {
report.completed += 1;
continue;
}
for record in records {
let case = record.body.case.map(|case| case.to_string());
let payloads = match super::a2a_stream::payloads_for_record(
&self.runtime,
&record,
case.as_deref(),
)
.await
{
Ok(payloads) => payloads,
Err(error) => {
let exponent = attempts.min(8);
self.push
.store
.retry(
registration.config.task,
®istration.config.id,
at.saturating_add(1u64 << exponent),
&error.to_string(),
)
.await?;
report.retries += 1;
break;
}
};
let mut failed = None;
for payload in payloads {
match self
.push
.transport
.deliver(®istration.config, &payload)
.await
{
Ok(crate::push::Delivered::Accepted) => {
report.deliveries += 1;
}
Ok(other) => failed = Some(format!("receiver outcome: {other:?}")),
Err(error) => failed = Some(error.to_string()),
}
if failed.is_some() {
break;
}
}
if let Some(error) = failed {
let exponent = attempts.min(8);
let delay = 1u64 << exponent;
self.push
.store
.retry(
registration.config.task,
®istration.config.id,
at.saturating_add(delay),
&error,
)
.await?;
report.retries += 1;
break;
}
self.push
.store
.advance(
registration.config.task,
®istration.config.id,
record.body.seq.saturating_add(1),
)
.await?;
attempts = 0;
report.records += 1;
if matches!(record.kind(), RecordKind::RunSealed { .. }) {
self.push
.store
.delete(registration.config.task, ®istration.config.id)
.await?;
report.completed += 1;
break;
}
}
}
Ok(report)
}
async fn cleanup_acknowledged_terminal(
&self,
registration: &crate::push::PushRegistration,
) -> Result<bool, crate::core::StoreError> {
if registration.next_seq <= 1 {
return Ok(false);
}
let previous = self
.runtime
.journal()
.read(
registration.config.task,
registration.next_seq.saturating_sub(1),
)
.await?;
let completed = previous.last().is_some_and(|record| {
record.body.seq.saturating_add(1) == registration.next_seq
&& matches!(record.kind(), RecordKind::RunSealed { .. })
});
if completed {
self.push
.store
.delete(registration.config.task, ®istration.config.id)
.await?;
}
Ok(completed)
}
}
async fn agent_card(State(server): State<A2aServer>) -> Json<AgentCard> {
Json(server.card.clone())
}
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.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 input = Tainted::from_source(
message.to_input(),
SourceId::new(format!("peer:{}", caller.actor)),
);
match spawn_a2a(&server, &skill, input, &message).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(_)) => {
return Err(RpcError::new(
code::UNSUPPORTED_OPERATION,
"this agent declined the request",
));
}
Err(crate::core::RuntimeError::QuotaExceeded(why)) => {
return Err(RpcError::new(code::UNSUPPORTED_OPERATION, why.to_string()));
}
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
&& matches!(
task.status.state,
TaskState::Completed | TaskState::Failed | TaskState::Canceled | TaskState::Rejected
)
{
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.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}"),
)),
}
}
fn parse_params(value: &Value) -> Result<CommonParams, RpcError> {
if value.is_null() {
return Ok(CommonParams::default());
}
if !value.is_object() {
return Err(RpcError::new(
code::INVALID_PARAMS,
"A2A method parameters must be a JSON object",
));
}
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}"),
)
})
}
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::RunSealed { .. }
) {
return Err(RpcError::new(
code::UNSUPPORTED_OPERATION,
"this task is not waiting for input",
));
}
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: format!("a2a:peer:{}", 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) => Ok(run),
Ok(crate::core::Delivery::Buffered) => Err(RpcError::new(
code::INTERNAL_ERROR,
"targeted task input was unexpectedly buffered",
)),
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 input = Tainted::from_source(
message.to_input(),
SourceId::new(format!("peer:{}", caller.actor)),
);
if params
.configuration
.as_ref()
.is_some_and(|c| c.return_immediately)
{
return match spawn_a2a(server, &skill, input, &message).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(_)) => {
Ok(json!({ "message": declined(&skill) }))
}
Err(crate::core::RuntimeError::QuotaExceeded(why)) => {
Err(RpcError::new(code::UNSUPPORTED_OPERATION, why.to_string()))
}
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 outcome = match run_a2a(server, &skill, input, &message).await {
Ok(outcome) => outcome,
Err(crate::core::RuntimeError::PolicyDenied(_)) => {
return Ok(json!({ "message": declined(&skill) }));
}
Err(crate::core::RuntimeError::QuotaExceeded(why)) => {
return Err(RpcError::new(code::UNSUPPORTED_OPERATION, why.to_string()));
}
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 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;
if let Some(history_length) = params
.configuration
.as_ref()
.and_then(|configuration| configuration.history_length)
{
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,
Some(history_length),
task.context_id.as_deref(),
);
}
Ok(json!({ "task": task }))
}
async fn run_a2a(
server: &A2aServer,
skill: &str,
input: Tainted<Value>,
message: &A2aMessage,
) -> Result<crate::runtime::RunOutcome, crate::core::RuntimeError> {
if let Some(context) = message.context_id.as_deref() {
let case = crate::core::CaseId::parse(context).map_err(|_| {
crate::core::RuntimeError::PlanContract("contextId is not a case issued here".into())
})?;
return server.runtime.run_tainted_in_case(skill, input, case).await;
}
server
.runtime
.run_tainted_correlated(
skill,
input,
"a2a.context",
&[crate::core::CorrelationKey::new(
"a2a-message",
message.message_id.clone(),
)],
)
.await
}
async fn spawn_a2a(
server: &A2aServer,
skill: &str,
input: Tainted<Value>,
message: &A2aMessage,
) -> Result<RunId, crate::core::RuntimeError> {
if let Some(context) = message.context_id.as_deref() {
let case = crate::core::CaseId::parse(context).map_err(|_| {
crate::core::RuntimeError::PlanContract("contextId is not a case issued here".into())
})?;
return server
.runtime
.spawn_tainted_in_case(skill, input, case)
.await;
}
server
.runtime
.spawn_tainted_correlated(
skill,
input,
"a2a.context",
&[crate::core::CorrelationKey::new(
"a2a-message",
message.message_id.clone(),
)],
)
.await
}
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 Some(last) = records.last() else {
return Err(RpcError::new(
code::TASK_NOT_FOUND,
format!("no such task: {id}"),
));
};
let (state, detail) = match last.kind() {
RecordKind::RunSuspended { reason } => (TaskState::InputRequired, reason.to_string()),
RecordKind::RunSealed { outcome, .. } => (sealed_state(outcome), outcome.clone()),
_ => (TaskState::Working, "running".to_owned()),
};
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 = task_artifacts(&server.runtime, id, state)
.await
.map_err(|error| RpcError::new(code::INTERNAL_ERROR, error.to_string()))?;
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>,
}
#[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 recent = server
.runtime
.journal()
.recent_runs()
.await
.map_err(|_| RpcError::new(code::INTERNAL_ERROR, "the task index could not be read"))?;
let mut all = Vec::new();
for (run, updated) in &recent {
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 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;
}
all.push((*run, *updated, task));
}
let total_size = all.len();
let allowed: std::collections::BTreeSet<RunId> = recent
.iter()
.filter(|(run, updated)| {
cursor
.as_ref()
.is_none_or(|cursor| after_cursor(*run, *updated, cursor))
})
.map(|(run, _)| *run)
.collect();
let page: Vec<_> = all
.into_iter()
.filter(|(run, _, _)| allowed.contains(run))
.take(page_size + 1)
.collect();
let has_more = page.len() > page_size;
let visible = &page[..page.len().min(page_size)];
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());
for (run, _, task) in visible {
let mut task = task.clone();
if params.include_artifacts {
task.artifacts = task_artifacts(&server.runtime, *run, task.status.state)
.await
.map_err(|error| RpcError::new(code::INTERNAL_ERROR, error.to_string()))?;
}
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(|bytes| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes))
.map_err(|_| RpcError::new(code::INTERNAL_ERROR, "the task cursor could not be encoded"))
}
fn decode_task_cursor(token: &str) -> Result<TaskCursor, RpcError> {
base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(token)
.ok()
.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 after_cursor(run: RunId, updated: u64, cursor: &TaskCursor) -> bool {
updated < cursor.updated_at || (updated == cursor.updated_at && run.to_string() < cursor.run)
}
fn task_from_records(
run: RunId,
records: &[crate::journal::Record],
updated: u64,
history_length: Option<usize>,
) -> A2aTask {
let (state, detail) = records.last().map_or(
(TaskState::Working, "unknown".to_owned()),
|record| match record.kind() {
RecordKind::RunSuspended { reason } => (TaskState::InputRequired, reason.to_string()),
RecordKind::RunSealed { outcome, .. } => (sealed_state(outcome), outcome.clone()),
_ => (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
}
fn task_history(
run: RunId,
records: &[crate::journal::Record],
history_length: Option<usize>,
case: Option<&str>,
) -> Option<Vec<A2aMessage>> {
history_length.and_then(|limit| {
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,
_ => TaskState::Failed,
}
}
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::RunSealed { 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()))?;
serde_json::to_value(task_of(
id,
TaskState::Working,
"cancellation requested",
None,
))
.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)?;
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",
));
}
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::RunSealed { .. }))
{
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 cursor_tests {
use super::*;
#[test]
fn a_cursor_survives_its_anchor_moving_to_the_front() {
let anchor = RunId::generate();
let older = RunId::generate();
let cursor = TaskCursor {
updated_at: 20,
run: anchor.to_string(),
context_id: None,
status: None,
status_timestamp_after: None,
};
let recent = [(anchor, 30), (older, 10)];
let after: Vec<_> = recent
.iter()
.filter(|(run, updated)| after_cursor(*run, *updated, &cursor))
.map(|(run, _)| *run)
.collect();
assert_eq!(after, vec![older]);
}
}