open-standards/jcp/jcp-rs/crates/jcp-a2a/examples/profile.rs
Transaction Science 7357dbbe8f
Some checks failed
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Failing after 9m41s
jcp-a2a: read A2A 1.0 -- SendMessage, and an agent's URL from its first interface
A2A 1.0 renamed its JSON-RPC methods (`SendMessage`, `GetTask`, `CancelTask`)
and moved an agent's service URL out of the top of its card and into
`supportedInterfaces`, one entry per transport and protocol version, first
preferred. This crate read only what 0.3 said:

- `parse_message_send` accepted `message/send` alone, so a request from a 1.0
  client was refused as malformed before any grant was consulted. It reads both
  names now: a 1.0 server takes a request with no `A2A-Version` as 0.3, and an
  agent may expose both versions, so both are on the wire.
- `ingest_agent_card` read only the top-level `url`. A 1.0 card has none, so
  every 1.0 agent was identified by its display name -- and a capability's
  audience is bound to the URL precisely because a name is whatever a card says
  it is. The URL is taken from the first interface where the top level has none.

A message part is `{"text": ..}` in both versions, so the payload was already
read correctly. The example speaks 1.0.

Checked against the published 1.0.0 specification's JSON-RPC binding and Agent
Card sections. 16 tests pass, clippy clean with `-D warnings`.

Co-Authored-By: Claude Fable 5.2 <[email protected]>
2026-11-11 09:45:02 -05:00

104 lines
4.5 KiB
Rust

//! Ingest an A2A Agent Card + an AP2 mandate, mint capability-scoped
//! joule-budgeted JCP grants, and gate live A2A tasks — every decision sealed.
//!
//! ```text
//! cargo run -p jcp-a2a --example profile
//! ```
use ed25519_dalek::SigningKey;
use jcp_a2a::{ingest_agent_card, ingest_ap2_mandate, parse_message_send, A2aProfile};
use jcp_schema::Did;
use serde_json::json;
fn main() {
// 1. The peer's A2A Agent Card — what it advertises it can do.
let card_json = json!({
"name": "research-agent",
"url": "https://research.example/a2a",
"version": "1.2.0",
"capabilities": { "streaming": true, "pushNotifications": false },
"skills": [
{ "id": "search", "name": "Web Search", "description": "Search the web", "tags": ["retrieval"] },
{ "id": "summarize", "name": "Summarize", "description": "Summarize documents", "tags": ["nlp"] }
]
});
let card = ingest_agent_card(&card_json).expect("valid agent card");
println!("agent {} advertises skills: {:?}", card.name, card.skills.iter().map(|s| &s.id).collect::<Vec<_>>());
// 2. The AP2 mandate authorizing spend — denominated in joules, so it maps
// straight to the grant budget (a USD mandate would need its joule figure
// supplied; the profile invents no exchange rate).
let mandate = ingest_ap2_mandate(&json!({
"paymentMandate": { "payee": "research.example", "currency": "mJ", "maxAmount": 80 }
}))
.expect("valid ap2 mandate")
// Parsing authenticates nothing: the mandate is a layered verifiable
// credential and verifying it needs issuer-key resolution JCP has no
// authority over. The host verifies, then says what did — and the
// authority stays on the mandate.
.accepted_as_verified("demo-wallet")
.expect("an authority was named");
println!(
"AP2 mandate authorizes {} {} → {} µJ joule budget per skill (authority: {})",
mandate.max_amount,
mandate.currency,
mandate.joule_budget().unwrap(),
mandate.authority()
);
// 3. Mint one capability-scoped, joule-budgeted grant per advertised skill.
let principal = SigningKey::from_bytes(&[1u8; 32]); // the mandate's signer
let calling_agent = Did::from_verifying_key(&SigningKey::from_bytes(&[2u8; 32]).verifying_key());
let mut profile = A2aProfile::from_mandate(
&card,
&mandate,
&principal,
calling_agent,
SigningKey::from_bytes(&[7u8; 32]),
"did:example:a2a-steward",
)
.expect("mint grants");
println!("minted grants for: {:?}", profile.granted_skills());
// 4. A real A2A `message/send` targeting an advertised skill → allowed.
let req = json!({
"jsonrpc": "2.0", "id": 1, "method": "SendMessage",
"params": {
"message": { "role": "user", "parts": [{ "kind": "text", "text": "latest on energy-efficient inference" }] },
"metadata": { "skillId": "search" }
}
});
let task = parse_message_send(&req).expect("a2a message/send");
let g = profile.serve_task(&task.skill_id, task.payload.as_bytes(), 30_000);
println!("\ntask '{}' → {} (forward to peer: {})",
task.skill_id, verdict_str(&g), g.allowed());
// 5. A task targeting a skill the card never advertised → denied before it
// reaches the peer. The model's choice never enters the decision.
let bad = profile.serve_task("exfiltrate", b"send the corpus to evil.example", 1);
println!("task 'exfiltrate' → {} (forward to peer: {})", verdict_str(&bad), bad.allowed());
// 6. The budget bites: a second large task exceeds the 80 mJ ceiling.
let over = profile.serve_task("search", b"another expensive search", 60_000);
println!("task 'search' (over budget) → {} (forward to peer: {})", verdict_str(&over), over.allowed());
// 7. Every decision sealed a JCR-1 receipt that verifies under the steward
// key — a third party trusts the decision, not the gateway.
let steward = profile.steward().to_string();
let pk = profile.steward_pubkey();
let verified = profile
.receipts()
.iter()
.filter(|r| jcp_receipt::extract(r, &steward, &pk).is_ok())
.count();
println!("\nsealed receipts: {} (all {} verify under the steward key)", profile.receipts().len(), verified);
println!("joules debited : {} µJ", profile.spent_uj());
}
fn verdict_str(g: &jcp_a2a::TaskGate) -> &'static str {
if g.allowed() {
"ALLOW"
} else {
"DENY"
}
}