Some checks failed
repository checks / lockfiles, advisories, tests in CI, workflow hygiene, spec and section references, public text (push) Failing after 9m41s
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]>
104 lines
4.5 KiB
Rust
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"
|
|
}
|
|
}
|