Skip to main content

laser_sdk/
a2a.rs

1use crate::agent::Laser;
2use crate::bridge_hops::hops_metadata;
3use crate::error::LaserError;
4use crate::provenance::AgentTopic;
5use crate::types::ConversationId;
6#[cfg(feature = "a2a-http")]
7use axum::Router;
8#[cfg(feature = "a2a-http")]
9use axum::extract::State;
10#[cfg(feature = "a2a-http")]
11use axum::routing::post;
12use laser_wire::agent::{
13    self as agdx, AgentEnvelope, AgentId, CapabilityDescriptor, ContentRef, CorrelationId,
14    METADATA_BRIDGE_HOPS, OPERATION_CHAT,
15};
16use laser_wire::content::ContentType;
17use serde::{Deserialize, Serialize};
18use serde_json::Value as JsonValue;
19use serde_json::to_value;
20use std::str::FromStr;
21#[cfg(feature = "a2a-http")]
22use std::sync::Arc;
23use strum::{Display, EnumString};
24
25pub use crate::bridge_hops::enter_bridge;
26pub use laser_wire::agent::TaskState;
27
28const JSONRPC_VERSION: &str = "2.0";
29// JSON-RPC reserved range ends at -32000, and -32000..=-32099 is for application errors.
30const APP_ERROR_CODE: i32 = -32000;
31
32/// The A2A JSON-RPC methods the bridge serves. `Display`/`FromStr` (strum) carry
33/// the exact wire spelling (v1.0's PascalCase operation names), so the dispatch
34/// never matches on bare string literals. v1.0's `ListTasks` is not served: the
35/// bridge is stateless over the log, and an unknown method already answers with
36/// the bridge's application error code (`APP_ERROR_CODE`, -32000), not JSON-RPC's
37/// reserved -32601 method-not-found.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Display, EnumString)]
39pub enum A2aMethod {
40    #[strum(serialize = "SendMessage")]
41    MessageSend,
42    #[strum(serialize = "SendStreamingMessage")]
43    MessageStream,
44    #[strum(serialize = "GetTask")]
45    TasksGet,
46    #[strum(serialize = "CancelTask")]
47    TasksCancel,
48}
49
50/// The A2A protocol version this bridge's Agent Card declares.
51pub const A2A_PROTOCOL_VERSION: &str = "1.0";
52
53/// The JSON-RPC binding label of a v1.0 `AgentInterface`.
54pub const A2A_JSONRPC_BINDING: &str = "JSONRPC";
55
56/// The bridge's A2A Agent Card: the discovery document an A2A client fetches.
57/// Field names follow the v1.0 `AgentCard` schema: the endpoint and its
58/// protocol version live in `supportedInterfaces` (v1.0 dropped the top-level
59/// `protocolVersion` / `url` / `preferredTransport`), and `signatures` carries
60/// the optional detached JWS set (`sign` feature).
61#[derive(Debug, Clone, Serialize)]
62pub struct AgentCard {
63    pub name: String,
64    pub description: String,
65    pub version: String,
66    #[serde(rename = "supportedInterfaces")]
67    pub supported_interfaces: Vec<AgentInterface>,
68    pub capabilities: AgentCardCapabilities,
69    #[serde(rename = "defaultInputModes")]
70    pub default_input_modes: Vec<String>,
71    #[serde(rename = "defaultOutputModes")]
72    pub default_output_modes: Vec<String>,
73    pub skills: Vec<AgentSkill>,
74    /// Detached JWS signatures over the JCS-canonicalized card (RFC 8785 +
75    /// RFC 7515), empty when unsigned.
76    #[serde(skip_serializing_if = "Vec::is_empty", default)]
77    pub signatures: Vec<AgentCardSignature>,
78}
79
80/// One v1.0 `AgentInterface`: where the agent serves and over what binding.
81#[derive(Debug, Clone, Serialize)]
82pub struct AgentInterface {
83    pub url: String,
84    #[serde(rename = "protocolBinding")]
85    pub protocol_binding: String,
86    #[serde(rename = "protocolVersion")]
87    pub protocol_version: String,
88}
89
90/// One v1.0 `AgentCardSignature`: an RFC 7515 JWS over the card, detached (the
91/// payload is the JCS-canonicalized card without `signatures`, reconstructed by
92/// the verifier).
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct AgentCardSignature {
95    /// The base64url-encoded protected JWS header (carries `alg`).
96    pub protected: String,
97    /// The base64url-encoded signature.
98    pub signature: String,
99}
100
101/// The A2A `AgentCapabilities` flags of an [`AgentCard`].
102#[derive(Debug, Clone, Serialize)]
103pub struct AgentCardCapabilities {
104    /// Whether the agent streams (AGDX chunk channels ride the log, consumed
105    /// over Iggy's own transport rather than SSE).
106    pub streaming: bool,
107    #[serde(rename = "pushNotifications")]
108    pub push_notifications: bool,
109    #[serde(rename = "stateTransitionHistory")]
110    pub state_transition_history: bool,
111    /// v1.0: whether an authenticated extended card is served (never, the
112    /// well-known card is the whole truth here).
113    #[serde(rename = "extendedAgentCard")]
114    pub extended_agent_card: bool,
115}
116
117/// One A2A `AgentSkill` advertised on the card.
118#[derive(Debug, Clone, Serialize)]
119pub struct AgentSkill {
120    pub id: String,
121    pub name: String,
122    pub description: String,
123    pub tags: Vec<String>,
124    /// The skill's accepted input content modes (A2A `inputModes`), derived from
125    /// the capability descriptor's input `ContentRef`. Omitted when unspecified.
126    #[serde(rename = "inputModes", skip_serializing_if = "Vec::is_empty")]
127    pub input_modes: Vec<String>,
128    /// The skill's produced output content modes (A2A `outputModes`).
129    #[serde(rename = "outputModes", skip_serializing_if = "Vec::is_empty")]
130    pub output_modes: Vec<String>,
131}
132
133/// Project a capability's [`ContentRef`] to an A2A content mode string: a
134/// content-type code to its media type, a registered schema id to an AGDX schema
135/// media type. Unknown codes fall back to opaque bytes.
136fn content_ref_mode(reference: &ContentRef) -> String {
137    match reference {
138        ContentRef::ContentType(content_type) => match content_type {
139            ContentType::Raw => "application/octet-stream",
140            ContentType::Json => "application/json",
141            ContentType::Msgpack => "application/msgpack",
142            ContentType::Cbor => "application/cbor",
143            ContentType::Bson => "application/bson",
144            ContentType::Avro => "application/avro",
145            ContentType::Protobuf => "application/protobuf",
146            ContentType::Arrow => "application/vnd.apache.arrow.stream",
147            ContentType::Ref => "application/x-agdx-ref",
148            ContentType::Any => "*/*",
149            _ => "application/octet-stream",
150        }
151        .to_owned(),
152        ContentRef::SchemaId(id) => format!("application/x-agdx-schema;id={id}"),
153    }
154}
155
156/// An A2A task's current state. `state` is the ONE wire dictionary
157/// (`laser_wire::agent::TaskState`, re-exported below): the bridge maps its
158/// codes to A2A's kebab-case names at the JSON boundary, never a second enum.
159#[derive(Debug, Clone, Serialize, Deserialize)]
160pub struct TaskStatus {
161    #[serde(with = "task_state_json")]
162    pub state: TaskState,
163}
164
165// The JSON boundary for the task-state dictionary: A2A speaks the kebab-case
166// names (the dictionary's `Display`/`FromStr`), while the wire type itself
167// rides CBOR as a u8 code. An unknown inbound name is a protocol error.
168mod task_state_json {
169    use super::TaskState;
170    use serde::de::Error;
171    use serde::{Deserialize, Deserializer, Serializer};
172
173    pub fn serialize<S: Serializer>(state: &TaskState, serializer: S) -> Result<S::Ok, S::Error> {
174        serializer.collect_str(state)
175    }
176
177    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<TaskState, D::Error> {
178        String::deserialize(deserializer)?
179            .parse()
180            .map_err(D::Error::custom)
181    }
182}
183
184/// An output artifact produced by an A2A task.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct Artifact {
187    pub text: String,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191/// An A2A task: its id, status, and artifacts.
192pub struct Task {
193    pub id: String,
194    pub status: TaskStatus,
195    #[serde(default, skip_serializing_if = "Vec::is_empty")]
196    pub artifacts: Vec<Artifact>,
197}
198
199/// A JSON-RPC request envelope.
200#[derive(Debug, Deserialize)]
201pub struct JsonRpcRequest {
202    #[serde(default)]
203    pub id: JsonValue,
204    pub method: String,
205    #[serde(default)]
206    pub params: JsonValue,
207}
208
209/// A JSON-RPC response envelope.
210#[derive(Debug, Serialize)]
211pub struct JsonRpcResponse {
212    pub jsonrpc: &'static str,
213    pub id: JsonValue,
214    #[serde(skip_serializing_if = "Option::is_none")]
215    pub result: Option<JsonValue>,
216    #[serde(skip_serializing_if = "Option::is_none")]
217    pub error: Option<JsonRpcError>,
218}
219
220/// A JSON-RPC error object.
221#[derive(Debug, Serialize)]
222pub struct JsonRpcError {
223    pub code: i32,
224    pub message: String,
225}
226
227/// Map an inbound `message/send` onto its on-log `command` envelope: the
228/// mapped core is the ids (the fresh task id rides `correlation`), and the
229/// tunneled remainder is the ORIGINAL params JSON, carried byte-identical in
230/// `body` with `agdx.ct = json`, so a round trip back out returns the foreign
231/// payload untouched instead of flattening it into a lossy intermediate.
232pub fn command_from_message_send(
233    record: agdx::RecordId,
234    conversation: agdx::ConversationId,
235    source: agdx::AgentId,
236    correlation: agdx::CorrelationId,
237    params_json: Vec<u8>,
238) -> AgentEnvelope {
239    AgentEnvelope::command(record, conversation, source, correlation, params_json)
240        .with_operation(OPERATION_CHAT)
241}
242
243/// The outbound view: the envelope answering a task becomes the A2A task.
244/// `task_state` maps through the dictionary when present. Otherwise a
245/// `response` reads completed and an `error` reads failed. A non-empty body
246/// becomes the text artifact.
247pub fn task_from_envelope(task_id: impl Into<String>, envelope: &AgentEnvelope) -> Task {
248    let state = envelope.task_state.unwrap_or(match envelope.kind {
249        agdx::AgentKind::Error => TaskState::Failed,
250        _ => TaskState::Completed,
251    });
252    let artifacts = if envelope.body.is_empty() {
253        Vec::new()
254    } else {
255        vec![Artifact {
256            text: String::from_utf8_lossy(&envelope.body).into_owned(),
257        }]
258    };
259    Task {
260        id: task_id.into(),
261        status: TaskStatus { state },
262        artifacts,
263    }
264}
265
266/// Bridges the synchronous A2A JSON-RPC edge onto durable agent topics: a request
267/// becomes a message on `request_topic` keyed by a fresh task (conversation) id,
268/// and a task lookup replays `reply_topic` for that conversation. The log is the
269/// source of truth, so tasks survive a bridge restart and stay replayable.
270pub struct A2aBridge {
271    laser: Laser,
272    source: AgentId,
273    request_topic: AgentTopic<'static>,
274    reply_topic: AgentTopic<'static>,
275    capabilities: Vec<CapabilityDescriptor>,
276    // The `bridge_hops` path stamped on every record the bridge publishes,
277    // ending in its own id.
278    hops: Vec<String>,
279    /// When set, control records the bridge emits (the cancel) are signed, so a
280    /// verifying consumer can authorize them against the enrolled key. Closes the
281    /// unauthenticated-cancel hole. Requires the `sign` feature.
282    #[cfg(feature = "sign")]
283    signing_key: Option<crate::sign::SigningKey>,
284    /// When set, every task id is bound to the caller that submitted it.
285    task_binding: Option<TaskBinding>,
286}
287
288/// The key and lifetime task ids are bound with.
289#[derive(Clone)]
290struct TaskBinding {
291    key: [u8; 32],
292    ttl: std::time::Duration,
293}
294
295impl TaskBinding {
296    /// The keyed digest a bound task id carries: the caller, the stream
297    /// generation, the conversation and the expiry, so an id works only for
298    /// its caller, on the stream incarnation it was made on, until it expires.
299    fn mac(
300        &self,
301        caller: &str,
302        stream_generation: u64,
303        task: ConversationId,
304        expires: u64,
305    ) -> blake3::Hash {
306        let mut hasher = blake3::Hasher::new_keyed(&self.key);
307        hasher.update(b"laserdata a2a task v1\0");
308        hasher.update(&(caller.len() as u64).to_le_bytes());
309        hasher.update(caller.as_bytes());
310        hasher.update(&stream_generation.to_le_bytes());
311        hasher.update(&task.as_u128().to_le_bytes());
312        hasher.update(&expires.to_le_bytes());
313        hasher.finalize()
314    }
315}
316
317/// The authenticated caller of an A2A HTTP request. A host that binds tasks
318/// to callers inserts it into the request extensions from its own
319/// authentication, and the router passes it to the bridge.
320#[derive(Clone, Debug, PartialEq, Eq)]
321pub struct A2aCallerPrincipal(pub String);
322
323/// The bridge acting for one authenticated caller. Build it with
324/// [`A2aBridge::caller`].
325pub struct A2aCaller<'a> {
326    bridge: &'a A2aBridge,
327    principal: String,
328}
329
330impl A2aCaller<'_> {
331    /// [`A2aBridge::submit`] for this caller.
332    pub async fn submit(&self, params_json: Vec<u8>) -> Result<Task, LaserError> {
333        self.bridge
334            .submit_addressed(None, None, params_json, Some(&self.principal))
335            .await
336    }
337
338    /// [`A2aBridge::submit_to`] for this caller.
339    pub async fn submit_to(
340        &self,
341        target: impl Into<AgentId>,
342        params_json: Vec<u8>,
343    ) -> Result<Task, LaserError> {
344        self.bridge
345            .submit_addressed(
346                Some(target.into()),
347                None,
348                params_json,
349                Some(&self.principal),
350            )
351            .await
352    }
353
354    /// [`A2aBridge::submit_in`] for this caller.
355    pub async fn submit_in(
356        &self,
357        parent: ConversationId,
358        root: ConversationId,
359        params_json: Vec<u8>,
360    ) -> Result<Task, LaserError> {
361        self.bridge
362            .submit_addressed(
363                None,
364                Some((parent, root)),
365                params_json,
366                Some(&self.principal),
367            )
368            .await
369    }
370
371    /// [`A2aBridge::submit_in_to`] for this caller.
372    pub async fn submit_in_to(
373        &self,
374        target: impl Into<AgentId>,
375        parent: ConversationId,
376        root: ConversationId,
377        params_json: Vec<u8>,
378    ) -> Result<Task, LaserError> {
379        self.bridge
380            .submit_addressed(
381                Some(target.into()),
382                Some((parent, root)),
383                params_json,
384                Some(&self.principal),
385            )
386            .await
387    }
388
389    /// [`A2aBridge::task`] for this caller. Another caller's task id answers
390    /// like an unknown one.
391    pub async fn task(&self, id: &str) -> Result<Task, LaserError> {
392        self.bridge.task_for(id, Some(&self.principal)).await
393    }
394
395    /// [`A2aBridge::cancel`] for this caller. Another caller's task id is
396    /// refused before anything is published.
397    pub async fn cancel(&self, id: &str) -> Result<Task, LaserError> {
398        self.bridge.cancel_for(id, Some(&self.principal)).await
399    }
400
401    /// [`A2aBridge::handle_rpc`] for this caller.
402    pub async fn handle_rpc(&self, request: JsonValue) -> JsonRpcResponse {
403        self.bridge
404            .handle_rpc_for(request, Some(&self.principal))
405            .await
406    }
407}
408
409impl A2aBridge {
410    /// A bridge mapping A2A JSON-RPC methods onto agent topics over `laser`,
411    /// publishing as `source` (the bridge's agent id). Every topic lives on the
412    /// stream of `laser`. Pass `laser.with_default_stream(stream)` to run the bridge on a
413    /// non-default stream (the unit of multi-stream topologies and per-stream
414    /// Iggy RBAC). `request_topic`/`reply_topic` are any [`AgentTopic`], including
415    /// `AgentTopic::Custom` for an arbitrary name.
416    pub fn new(
417        laser: Laser,
418        source: impl Into<AgentId>,
419        request_topic: AgentTopic<'static>,
420        reply_topic: AgentTopic<'static>,
421    ) -> Self {
422        let source = source.into();
423        let hops = vec![source.as_str().to_owned()];
424        Self {
425            laser,
426            source,
427            request_topic,
428            reply_topic,
429            capabilities: Vec::new(),
430            hops,
431            #[cfg(feature = "sign")]
432            signing_key: None,
433            task_binding: None,
434        }
435    }
436
437    /// Bind every task id to the caller that submitted it, keyed with `key`
438    /// and valid for `ttl`. A bound id names the caller, the stream
439    /// incarnation, the conversation and an expiry under a keyed digest, so
440    /// another caller cannot read or cancel the task. Once set, task
441    /// operations need a caller: use [`caller`](Self::caller), and the HTTP
442    /// router reads [`A2aCallerPrincipal`] from the request extensions.
443    #[must_use]
444    pub fn with_task_binding(mut self, key: [u8; 32], ttl: std::time::Duration) -> Self {
445        self.task_binding = Some(TaskBinding { key, ttl });
446        self
447    }
448
449    /// The bridge acting for one authenticated caller.
450    pub fn caller(&self, principal: impl Into<String>) -> A2aCaller<'_> {
451        A2aCaller {
452            bridge: self,
453            principal: principal.into(),
454        }
455    }
456
457    /// The creation time of the bridge's stream, which tells a recreated
458    /// stream apart from the one a task id was made on.
459    async fn stream_generation(&self) -> Result<u64, LaserError> {
460        let stream = iggy::prelude::Identifier::named(self.laser.stream_required()?)?;
461        let details =
462            iggy::prelude::StreamClient::get_stream(self.laser.client().as_ref(), &stream)
463                .await?
464                .ok_or_else(|| {
465                    LaserError::HandlerConfig("the bridge stream does not exist".to_owned())
466                })?;
467        Ok(details.created_at.as_micros())
468    }
469
470    /// The id a new task is known by.
471    async fn task_id(
472        &self,
473        task: ConversationId,
474        caller: Option<&str>,
475    ) -> Result<String, LaserError> {
476        let Some(binding) = &self.task_binding else {
477            return Ok(task.to_string());
478        };
479        let caller = caller.ok_or_else(unbound_caller)?;
480        let generation = self.stream_generation().await?;
481        let expires =
482            now_micros().saturating_add(u64::try_from(binding.ttl.as_micros()).unwrap_or(u64::MAX));
483        let mac = binding.mac(caller, generation, task, expires);
484        Ok(format!("{task}.{expires}.{}", mac.to_hex()))
485    }
486
487    /// The conversation a task id names, refused like a malformed id when it
488    /// is not this caller's, expired, or from another stream incarnation.
489    async fn open_task(
490        &self,
491        id: &str,
492        caller: Option<&str>,
493    ) -> Result<ConversationId, LaserError> {
494        let invalid = || LaserError::HandlerConfig(format!("invalid task id `{id}`"));
495        let Some(binding) = &self.task_binding else {
496            return id.parse::<ConversationId>().map_err(|_| invalid());
497        };
498        let caller = caller.ok_or_else(unbound_caller)?;
499        let mut parts = id.split('.');
500        let (Some(task), Some(expires), Some(mac), None) =
501            (parts.next(), parts.next(), parts.next(), parts.next())
502        else {
503            return Err(invalid());
504        };
505        let task = task.parse::<ConversationId>().map_err(|_| invalid())?;
506        let expires = expires.parse::<u64>().map_err(|_| invalid())?;
507        let mac = blake3::Hash::from_hex(mac).map_err(|_| invalid())?;
508        if expires < now_micros() {
509            return Err(invalid());
510        }
511        let generation = self.stream_generation().await?;
512        // `blake3::Hash` compares in constant time.
513        if binding.mac(caller, generation, task, expires) != mac {
514            return Err(invalid());
515        }
516        Ok(task)
517    }
518
519    /// Continue the loop-guard path of work that already crossed other
520    /// bridges: `previous` is its `bridge_hops`, and this bridge's id is
521    /// appended to it. Every record the bridge publishes carries the path.
522    /// Refuses a path that already holds this bridge, the loop the guard
523    /// exists to stop.
524    pub fn with_bridge_hops(mut self, previous: &[String]) -> Result<Self, LaserError> {
525        self.hops = enter_bridge(self.source.as_str(), previous)?;
526        Ok(self)
527    }
528
529    /// Sign the control records the bridge emits (the cancel) with `key`, so a
530    /// verifying consumer authorizes them against the enrolled key registry. This
531    /// is what corrects the otherwise-unauthenticated cancel. Requires the `sign`
532    /// feature.
533    #[cfg(feature = "sign")]
534    #[must_use]
535    pub fn with_signing_key(mut self, key: crate::sign::SigningKey) -> Self {
536        self.signing_key = Some(key);
537        self
538    }
539
540    /// Advertise the agent's structured capabilities on the A2A card. Each
541    /// [`CapabilityDescriptor`] is projected to an A2A `AgentSkill` by
542    /// [`card`](Self::card), so a remote A2A client discovers the agent's skills
543    /// and their I/O shapes. Consumes and returns self for chaining.
544    #[must_use]
545    pub fn with_capabilities(mut self, capabilities: Vec<CapabilityDescriptor>) -> Self {
546        self.capabilities = capabilities;
547        self
548    }
549
550    /// `message/send`: publish the params as a typed AGDX `command` on a fresh
551    /// task conversation, tunneling the foreign JSON byte-identical in the body
552    /// (`agdx.ct = json`), and returns Submitted. The task id is the conversation, and
553    /// the A2A task identity rides `correlation` (derived from it, so the lookup
554    /// stays stateless). The command is addressed to every agent (`agdx.to = *`),
555    /// so on a shared session topic every listening agent receives it. Use
556    /// [`submit_to`](Self::submit_to) to address one agent.
557    pub async fn submit(&self, params_json: Vec<u8>) -> Result<Task, LaserError> {
558        self.submit_addressed(None, None, params_json, None).await
559    }
560
561    /// [`submit`](Self::submit) addressed to `target`, so only that agent
562    /// handles the task on a shared session topic.
563    pub async fn submit_to(
564        &self,
565        target: impl Into<AgentId>,
566        params_json: Vec<u8>,
567    ) -> Result<Task, LaserError> {
568        let target = target.into();
569        self.submit_addressed(Some(target), None, params_json, None)
570            .await
571    }
572
573    /// `message/send` as a child session of `parent`, in the tree rooted at
574    /// `root`: the task's submitted start lands on `agent.sessions` with the
575    /// ancestry before the command, and the command carries it too. The
576    /// agent that handles the task ends the child. The command is addressed to
577    /// every agent, like [`submit`](Self::submit).
578    pub async fn submit_in(
579        &self,
580        parent: ConversationId,
581        root: ConversationId,
582        params_json: Vec<u8>,
583    ) -> Result<Task, LaserError> {
584        self.submit_addressed(None, Some((parent, root)), params_json, None)
585            .await
586    }
587
588    /// [`submit_in`](Self::submit_in) addressed to `target`.
589    pub async fn submit_in_to(
590        &self,
591        target: impl Into<AgentId>,
592        parent: ConversationId,
593        root: ConversationId,
594        params_json: Vec<u8>,
595    ) -> Result<Task, LaserError> {
596        let target = target.into();
597        self.submit_addressed(Some(target), Some((parent, root)), params_json, None)
598            .await
599    }
600
601    async fn submit_addressed(
602        &self,
603        target: Option<AgentId>,
604        ancestry: Option<(ConversationId, ConversationId)>,
605        params_json: Vec<u8>,
606        caller: Option<&str>,
607    ) -> Result<Task, LaserError> {
608        let task = ConversationId::new();
609        // Minted before anything is published, so an unbound caller on a
610        // binding bridge publishes nothing.
611        let id = self.task_id(task, caller).await?;
612        if let Some((parent, root)) = ancestry {
613            crate::agent::contract::start_child(
614                &self.laser,
615                &self.source,
616                &self.source,
617                task.into(),
618                parent.into(),
619                root.into(),
620            )
621            .await?;
622        }
623        let producer =
624            self.laser
625                .agdx(self.request_topic.clone(), self.source.clone(), task.into());
626        let mut command = producer
627            .command(correlation_of(task), params_json)
628            .with_operation(OPERATION_CHAT)
629            .with_metadata(METADATA_BRIDGE_HOPS, hops_metadata(&self.hops))
630            .content_type(ContentType::Json);
631        if let Some((parent, root)) = ancestry {
632            command = command.with_ancestry(Some(parent.into()), Some(root.into()));
633        }
634        if let Some(target) = target {
635            command = command.with_target(target);
636        }
637        command.send().await?;
638        Ok(Task {
639            id,
640            status: TaskStatus {
641                state: TaskState::Submitted,
642            },
643            artifacts: Vec::new(),
644        })
645    }
646
647    /// `tasks/get`: replay the reply topic for the task and map the answering
648    /// AGDX envelope (the `response`/`error` carrying this task's `correlation`)
649    /// to the A2A task. Still Working until one lands, and Unknown when the
650    /// bounded scan could not reach the end of the reply topic.
651    pub async fn task(&self, id: &str) -> Result<Task, LaserError> {
652        self.task_for(id, None).await
653    }
654
655    async fn task_for(&self, id: &str, caller: Option<&str>) -> Result<Task, LaserError> {
656        let conversation = self.open_task(id, caller).await?;
657        let correlation = correlation_of(conversation);
658        // A point lookup over the reply topic via the forward reply reader (no
659        // full re-scan + sort of the conversation each call).
660        let answer = self
661            .laser
662            .find_agdx_reply(self.reply_topic.clone(), correlation)
663            .await?;
664        Ok(match answer {
665            crate::agent::ReplyLookup::Found(envelope) => task_from_envelope(id, &envelope),
666            crate::agent::ReplyLookup::NotFound => Task {
667                id: id.to_owned(),
668                status: TaskStatus {
669                    state: TaskState::Working,
670                },
671                artifacts: Vec::new(),
672            },
673            // The bounded scan did not reach the end of the reply topic, so
674            // the task may already be answered. Unknown asks the caller to
675            // look again rather than claiming the task still works.
676            crate::agent::ReplyLookup::Incomplete => Task {
677                id: id.to_owned(),
678                status: TaskStatus {
679                    state: TaskState::Unknown,
680                },
681                artifacts: Vec::new(),
682            },
683        })
684    }
685
686    /// `tasks/cancel`: publish an AGDX `error` terminal (code `Cancelled`,
687    /// `task_state = Canceled`) correlated to the task, so a later `tasks/get`
688    /// also reports Canceled, and returns the canceled task.
689    pub async fn cancel(&self, id: &str) -> Result<Task, LaserError> {
690        self.cancel_for(id, None).await
691    }
692
693    async fn cancel_for(&self, id: &str, caller: Option<&str>) -> Result<Task, LaserError> {
694        // Checked before the cancel is published, so a foreign id has no effect.
695        let conversation = self.open_task(id, caller).await?;
696        let error = agdx::AgentErrorBody {
697            code: agdx::AgentErrorCode::Cancelled,
698            message: Some("canceled by the A2A client".to_owned()),
699            retryable: false,
700            detail: None,
701        };
702        let producer = self.laser.agdx(
703            self.reply_topic.clone(),
704            self.source.clone(),
705            conversation.into(),
706        );
707        let send = producer
708            .fail(correlation_of(conversation), &error)?
709            .with_task_state(TaskState::Canceled)
710            .with_metadata(METADATA_BRIDGE_HOPS, hops_metadata(&self.hops));
711        // Sign the cancel when a key is configured, so a verifying consumer can
712        // authorize it. Without a key it rides unsigned, and a topic that
713        // mandates verification rejects it.
714        #[cfg(feature = "sign")]
715        let send = match &self.signing_key {
716            Some(key) => send.signed_by(key),
717            None => send,
718        };
719        send.send().await?;
720        Ok(Task {
721            id: id.to_owned(),
722            status: TaskStatus {
723                state: TaskState::Canceled,
724            },
725            artifacts: Vec::new(),
726        })
727    }
728
729    /// The bridge's A2A Agent Card, for discovery. Streaming is advertised
730    /// because AGDX chunk channels replay off the log. Each advertised
731    /// [`CapabilityDescriptor`] (set via [`with_capabilities`](Self::with_capabilities))
732    /// projects to an A2A `AgentSkill`: the skill id is its id and name, and the
733    /// I/O `ContentRef`s become the skill's input and output modes.
734    pub fn card(&self) -> AgentCard {
735        let skills = self
736            .capabilities
737            .iter()
738            .map(|capability| AgentSkill {
739                id: capability.skill_id.clone(),
740                name: capability.skill_id.clone(),
741                description: String::new(),
742                tags: Vec::new(),
743                input_modes: capability.input.iter().map(content_ref_mode).collect(),
744                output_modes: capability.output.iter().map(content_ref_mode).collect(),
745            })
746            .collect();
747        AgentCard {
748            name: self.source.as_str().to_owned(),
749            description: "LaserData AGDX bridge over the durable log".to_owned(),
750            version: env!("CARGO_PKG_VERSION").to_owned(),
751            supported_interfaces: vec![AgentInterface {
752                url: "/".to_owned(),
753                protocol_binding: A2A_JSONRPC_BINDING.to_owned(),
754                protocol_version: A2A_PROTOCOL_VERSION.to_owned(),
755            }],
756            capabilities: AgentCardCapabilities {
757                streaming: true,
758                push_notifications: false,
759                state_transition_history: false,
760                extended_agent_card: false,
761            },
762            default_input_modes: vec!["text/plain".to_owned()],
763            default_output_modes: vec!["text/plain".to_owned()],
764            skills,
765            signatures: Vec::new(),
766        }
767    }
768
769    /// The card with a detached JWS signature attached (`sign` feature): the
770    /// JCS-canonicalized card (RFC 8785, minus `signatures`) signed EdDSA with
771    /// the same enrolled Ed25519 key the envelope scheme uses, so one principal
772    /// key covers both surfaces. Verify with
773    /// [`verify_card`](crate::sign::verify_card).
774    #[cfg(feature = "sign")]
775    pub fn signed_card(&self, key: &crate::sign::SigningKey) -> Result<AgentCard, LaserError> {
776        let mut card = self.card();
777        let value = serde_json::to_value(&card)
778            .map_err(|error| LaserError::Codec(format!("card to JSON: {error}")))?;
779        let signature = crate::sign::sign_card_value(key, &value)?;
780        card.signatures.push(signature);
781        Ok(card)
782    }
783
784    pub async fn handle_rpc(&self, request: JsonValue) -> JsonRpcResponse {
785        self.handle_rpc_for(request, None).await
786    }
787
788    async fn handle_rpc_for(&self, request: JsonValue, caller: Option<&str>) -> JsonRpcResponse {
789        let id = request.get("id").cloned().unwrap_or(JsonValue::Null);
790        match self.dispatch_rpc(&request, caller).await {
791            Ok(result) => JsonRpcResponse {
792                jsonrpc: JSONRPC_VERSION,
793                id,
794                result: Some(result),
795                error: None,
796            },
797            Err(error) => {
798                tracing::warn!(error = %error, "A2A request failed");
799                JsonRpcResponse {
800                    jsonrpc: JSONRPC_VERSION,
801                    id,
802                    result: None,
803                    error: Some(JsonRpcError {
804                        code: APP_ERROR_CODE,
805                        message: crate::error::public_error_message(&error).to_owned(),
806                    }),
807                }
808            }
809        }
810    }
811
812    async fn dispatch_rpc(
813        &self,
814        request: &JsonValue,
815        caller: Option<&str>,
816    ) -> Result<JsonValue, LaserError> {
817        // Serde also reads a struct from an array, so the object check comes first.
818        if !request.is_object() {
819            return Err(LaserError::HandlerConfig(
820                "A2A JSON-RPC request must be an object".to_owned(),
821            ));
822        }
823        let JsonRpcRequest { method, params, .. } = JsonRpcRequest::deserialize(request)
824            .map_err(|_| LaserError::HandlerConfig("A2A method must be a string".to_owned()))?;
825        let params = if params.is_null() {
826            JsonValue::Object(Default::default())
827        } else {
828            params
829        };
830        let task = match A2aMethod::from_str(&method) {
831            Ok(A2aMethod::MessageSend | A2aMethod::MessageStream) => {
832                let body = serde_json::to_vec(&params)
833                    .map_err(|error| LaserError::Codec(error.to_string()))?;
834                self.submit_addressed(None, None, body, caller).await?
835            }
836            Ok(operation @ (A2aMethod::TasksGet | A2aMethod::TasksCancel)) => {
837                let id = params
838                    .as_object()
839                    .and_then(|params| params.get("id"))
840                    .and_then(JsonValue::as_str)
841                    .ok_or_else(|| {
842                        LaserError::HandlerConfig("A2A task id must be a string".to_owned())
843                    })?;
844                if operation == A2aMethod::TasksGet {
845                    self.task_for(id, caller).await?
846                } else {
847                    self.cancel_for(id, caller).await?
848                }
849            }
850            Err(_) => {
851                return Err(LaserError::HandlerConfig(format!(
852                    "unknown A2A method `{method}`"
853                )));
854            }
855        };
856        to_value(task).map_err(|error| LaserError::Codec(error.to_string()))
857    }
858
859    /// An axum router: the JSON-RPC endpoint at `/` plus the Agent Card at the
860    /// A2A well-known discovery path. Requires the `a2a-http` feature. The bridge
861    /// adapter (`submit` / `task` / `cancel` / `card`) is usable without it.
862    #[cfg(feature = "a2a-http")]
863    pub fn router(self: Arc<Self>) -> Router {
864        Router::new()
865            .route("/", post(handle_rpc))
866            .route(
867                "/.well-known/agent-card.json",
868                axum::routing::get(handle_card),
869            )
870            .with_state(self)
871    }
872}
873
874#[cfg(feature = "a2a-http")]
875async fn handle_card(State(bridge): State<Arc<A2aBridge>>) -> axum::Json<AgentCard> {
876    axum::Json(bridge.card())
877}
878
879// The A2A task identity rides AGDX `correlation`, and deriving it from the task's
880// conversation keeps the bridge stateless: `tasks/get` reconstructs it from
881// the id alone with no mapping table.
882fn correlation_of(conversation: ConversationId) -> CorrelationId {
883    CorrelationId::from_u128(conversation.as_u128())
884}
885
886#[cfg(feature = "a2a-http")]
887async fn handle_rpc(
888    State(bridge): State<Arc<A2aBridge>>,
889    principal: Option<axum::Extension<A2aCallerPrincipal>>,
890    axum::Json(request): axum::Json<JsonValue>,
891) -> axum::Json<JsonRpcResponse> {
892    let caller = principal.as_ref().map(|principal| principal.0.0.as_str());
893    axum::Json(bridge.handle_rpc_for(request, caller).await)
894}
895
896fn now_micros() -> u64 {
897    std::time::SystemTime::now()
898        .duration_since(std::time::UNIX_EPOCH)
899        .map_or(0, |elapsed| {
900            u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX)
901        })
902}
903
904fn unbound_caller() -> LaserError {
905    LaserError::HandlerConfig(
906        "this bridge binds tasks to callers, so the request needs an authenticated caller"
907            .to_owned(),
908    )
909}
910
911#[cfg(test)]
912mod tests {
913    use super::*;
914    use laser_wire::framing::{decode_named, encode_named};
915    use serde_json::{from_str, to_string};
916
917    #[test]
918    fn given_a_task_binding_when_any_bound_field_changes_then_the_digest_should_change() {
919        let binding = TaskBinding {
920            key: [7; 32],
921            ttl: std::time::Duration::from_secs(60),
922        };
923        let task = ConversationId::derive("task-42");
924        let base = binding.mac("alice", 10, task, 1_000);
925        assert_eq!(base, binding.mac("alice", 10, task, 1_000));
926        assert_ne!(base, binding.mac("bob", 10, task, 1_000), "another caller");
927        assert_ne!(
928            base,
929            binding.mac("alice", 11, task, 1_000),
930            "a recreated stream"
931        );
932        assert_ne!(
933            base,
934            binding.mac("alice", 10, ConversationId::derive("task-43"), 1_000),
935            "another task"
936        );
937        assert_ne!(
938            base,
939            binding.mac("alice", 10, task, 2_000),
940            "another expiry"
941        );
942        let other_key = TaskBinding {
943            key: [8; 32],
944            ..binding.clone()
945        };
946        assert_ne!(base, other_key.mac("alice", 10, task, 1_000), "another key");
947        // A caller name cannot be shifted into the following fields.
948        assert_ne!(
949            binding.mac("ab", 10, task, 1),
950            binding.mac("a", 10, task, 1)
951        );
952        // The shared vector the Python and TypeScript SDKs check as well.
953        assert_eq!(
954            base.to_hex().as_str(),
955            "d1ea2040a6c1451ff74ad5eb597715ce67fa2d144aa0a2b369d9e56ebf0fee1b"
956        );
957    }
958
959    #[test]
960    fn given_task_states_when_serialized_then_should_use_the_kebab_case_wire_names() {
961        let status = TaskStatus {
962            state: TaskState::InputRequired,
963        };
964        assert_eq!(
965            to_string(&status).expect("status serializes"),
966            r#"{"state":"input-required"}"#
967        );
968        let back: TaskStatus = from_str(r#"{"state":"auth-required"}"#).expect("status parses");
969        assert_eq!(back.state, TaskState::AuthRequired);
970        // An unknown inbound name is a protocol error, never a guess.
971        assert!(from_str::<TaskStatus>(r#"{"state":"nope"}"#).is_err());
972    }
973
974    #[cfg(feature = "a2a-http")]
975    #[test]
976    fn given_a2a_methods_when_round_tripped_then_should_match_the_wire_names() {
977        assert_eq!(A2aMethod::MessageSend.to_string(), "SendMessage");
978        assert_eq!(
979            "GetTask".parse::<A2aMethod>().expect("the method parses"),
980            A2aMethod::TasksGet
981        );
982        // The 0.3.0 path-style spelling is gone: v1.0 clients speak PascalCase.
983        assert!("tasks/get".parse::<A2aMethod>().is_err());
984        assert!("nope/now".parse::<A2aMethod>().is_err());
985    }
986
987    #[test]
988    fn given_a_message_send_when_mapped_then_the_foreign_payload_should_tunnel_byte_identical() {
989        let params = br#"{"message":{"role":"user","parts":[{"kind":"text","text":"hi"}]},"metadata":{"trace":"abc"}}"#;
990        let envelope = command_from_message_send(
991            agdx::RecordId::from_u128(1),
992            agdx::ConversationId::from_u128(2),
993            "source-agent".parse().expect("valid agent id"),
994            agdx::CorrelationId::from_u128(4),
995            params.to_vec(),
996        );
997        agdx::validate(&envelope).expect("the mapped command validates");
998        let payload = encode_named(&envelope).expect("encodes");
999        let back: AgentEnvelope = decode_named(&payload).expect("decodes");
1000        assert_eq!(
1001            back.body,
1002            params.to_vec(),
1003            "the tunneled remainder must round-trip byte-identical"
1004        );
1005        assert_eq!(back.correlation, envelope.correlation);
1006
1007        // The way back out: the answering envelope renders as the A2A task.
1008        let reply = AgentEnvelope::response(
1009            agdx::RecordId::from_u128(5),
1010            agdx::ConversationId::from_u128(2),
1011            "responder-agent".parse().expect("valid agent id"),
1012            agdx::CorrelationId::from_u128(4),
1013            b"plan ready".to_vec(),
1014        )
1015        .with_task_state(TaskState::Completed);
1016        let task = task_from_envelope("t-1", &reply);
1017        assert_eq!(
1018            to_string(&task).expect("task serializes"),
1019            r#"{"id":"t-1","status":{"state":"completed"},"artifacts":[{"text":"plan ready"}]}"#
1020        );
1021
1022        // A failure terminal reads failed without a task_state.
1023        let failure = AgentEnvelope::error(
1024            agdx::RecordId::from_u128(7),
1025            agdx::ConversationId::from_u128(2),
1026            "responder-agent".parse().expect("valid agent id"),
1027            agdx::CorrelationId::from_u128(4),
1028            b"boom".to_vec(),
1029        );
1030        assert_eq!(
1031            task_from_envelope("t-1", &failure).status.state,
1032            TaskState::Failed
1033        );
1034    }
1035
1036    fn bridge() -> A2aBridge {
1037        let laser = Laser::from_client(iggy::prelude::IggyClient::default());
1038        A2aBridge::new(
1039            laser,
1040            "a2a-edge".parse::<AgentId>().expect("valid agent id"),
1041            AgentTopic::Sessions,
1042            AgentTopic::Sessions,
1043        )
1044    }
1045
1046    #[tokio::test]
1047    async fn given_malformed_rpc_requests_when_dispatched_then_should_echo_ids_and_return_public_errors()
1048     {
1049        for request in [
1050            serde_json::json!(null),
1051            serde_json::json!([]),
1052            serde_json::json!([7, "GetTask", {"id": "invalid"}]),
1053            serde_json::json!({"id": 7}),
1054            serde_json::json!({"id": 7, "method": "unknown"}),
1055            serde_json::json!({"id": 7, "method": "GetTask", "params": {"id": 12}}),
1056            serde_json::json!({"id": 7, "method": "CancelTask", "params": []}),
1057            serde_json::json!({"id": 7, "method": "GetTask", "params": {"id": "invalid"}}),
1058        ] {
1059            let expected = request.get("id").cloned().unwrap_or(JsonValue::Null);
1060            let response = bridge().handle_rpc(request).await;
1061            assert_eq!(response.id, expected);
1062            assert_eq!(response.jsonrpc, "2.0");
1063            assert!(response.result.is_none());
1064            let error = response.error.expect("malformed request returns an error");
1065            assert_eq!(error.code, -32000);
1066            assert_eq!(error.message, "invalid request");
1067        }
1068    }
1069
1070    #[test]
1071    fn given_a_new_bridge_when_built_then_should_stamp_its_own_id_as_the_hop_path() {
1072        assert_eq!(bridge().hops, ["a2a-edge"]);
1073    }
1074
1075    #[test]
1076    fn given_an_upstream_hop_path_when_continued_then_should_append_this_bridge() {
1077        let bridge = bridge()
1078            .with_bridge_hops(&["mcp-edge".to_owned()])
1079            .expect("no loop");
1080        assert_eq!(bridge.hops, ["mcp-edge", "a2a-edge"]);
1081        assert!(matches!(
1082            bridge.with_bridge_hops(&["a2a-edge".to_owned()]),
1083            Err(LaserError::Invalid(_))
1084        ));
1085    }
1086
1087    #[test]
1088    fn given_a_jsonrpc_message_send_when_parsed_then_should_expose_the_text_part() {
1089        let request: JsonRpcRequest = from_str(
1090            r#"{"jsonrpc":"2.0","id":1,"method":"SendMessage",
1091                "params":{"message":{"role":"user","parts":[{"kind":"text","text":"hi"}]}}}"#,
1092        )
1093        .expect("the request parses");
1094        assert_eq!(request.method, "SendMessage");
1095        assert_eq!(
1096            request
1097                .params
1098                .pointer("/message/parts/0/text")
1099                .and_then(JsonValue::as_str),
1100            Some("hi")
1101        );
1102    }
1103}