agentplane 0.38.0

Durable, replayable agent runtime — the journal is the plan of record
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
//! The Agent Card a peer reads before it trusts anything.
//!
//! [A2A] agents publish a card at `/.well-known/agent-card.json` describing what
//! they are and what they can do. It is the first thing a caller fetches and the
//! basis on which it decides to call at all — which makes it the most
//! consequential piece of prose an agent publishes, and the easiest to get
//! quietly wrong.
//!
//! # It is derived, never written
//!
//! The card is built from the [`Manifest`], so what an agent *advertises* and
//! what it is *permitted* cannot drift: both come from one digested document. A
//! hand-written card is a second source of truth about capability, and the two
//! disagree the first time somebody edits one.
//!
//! The plane refuses an agent whose manifest advertises a capability no skill
//! provides, **and** one whose skills answer a capability the manifest does not
//! advertise. Both are caught at startup, and deriving the published card from
//! that same field carries both outward: a peer cannot be told about a
//! capability the plane would not dispatch, and the plane will not dispatch one
//! no peer was told about.
//!
//! # What it will not claim
//!
//! Every capability flag is true only when the thing behind it exists. Push
//! notifications are advertised as **false** by derivation and enabled by the
//! A2A server only when that deployment wires both durable callback storage and
//! governed outbound delivery. A compiled feature is not a deployed capability,
//! and a card is a promise a caller plans against.
//!
//! Streaming and the extended card are true because both are implemented, not
//! because they sounded good on a card.
//!
//! [A2A]: https://a2a-protocol.org/latest/specification/

use serde::{Deserialize, Serialize};

use crate::manifest::Manifest;

/// Where a conforming client looks for the card.
pub const WELL_KNOWN_PATH: &str = "/.well-known/agent-card.json";

/// The one protocol binding this crate implements, spelled as the spec spells it.
pub const BINDING: &str = "JSONRPC";

/// One thing an agent can be asked to do.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CardSkill {
    /// Stable identifier — the capability, exactly as the plane dispatches it.
    pub id: String,
    pub name: String,
    pub description: String,
    pub tags: Vec<String>,
}

/// What a peer may expect this agent to support.
///
/// A field is `true` only when the thing behind it exists in this crate. There
/// is deliberately no builder that sets one: an embedder cannot turn on a
/// capability by asking, because the caller who believes the card does not care
/// who wrote it. Turning one on means editing this file, next to the code that
/// implements it.
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CardCapabilities {
    /// Server-sent events for incremental results.
    pub streaming: bool,
    /// Webhook callbacks for long tasks, when this deployment wires them.
    pub push_notifications: bool,
    /// A richer card for authenticated callers — see [`ExtendedAgentCard`].
    pub extended_agent_card: bool,
    /// Declared protocol extensions, in the spec's own slot for them.
    ///
    /// This is where anything the A2A schema does not name belongs. The card
    /// once carried this crate's extras (`manifestDigest`; the extended card's
    /// tools and budget) as top-level fields, and the official conformance kit
    /// rejected the document: `AgentCard` forbids unknown properties, so a
    /// spec-conforming peer is entitled to refuse the whole card over them.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub extensions: Vec<AgentExtension>,
}

/// One declared extension, as A2A 1.0 shapes it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentExtension {
    /// Identifies the extension. Stable, versioned, and documented at the URI.
    pub uri: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// Whether a caller must understand the extension to use the agent.
    ///
    /// Everything this crate declares is `false`: the extras are disclosure,
    /// and a peer that ignores them loses information rather than correctness.
    #[serde(default)]
    pub required: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub params: Option<serde_json::Value>,
}

/// The extension that carries the manifest digest on every derived card.
pub const EXT_MANIFEST_PROVENANCE: &str =
    "https://hupe1980.github.io/agentplane/a2a/ext/manifest-provenance/v1";

/// The extension that names where skill selection lives on a request.
///
/// A2A has no skill-selection field, and inferring one from message content
/// would put a model reading attacker-controlled text in charge of which
/// capability executes — so this server reads `message.metadata.skill`, and
/// a multi-skill agent *requires* it. Undeclared, that convention was
/// discoverable only from an error message; the card is where a caller
/// learns it before the first refusal.
pub const EXT_SKILL_SELECTION: &str =
    "https://hupe1980.github.io/agentplane/a2a/ext/skill-selection/v1";

/// The extension that carries tools, budget and topology on the extended card.
pub const EXT_GOVERNANCE: &str = "https://hupe1980.github.io/agentplane/a2a/ext/governance/v1";

/// The extension that lists every agent a plane serves, and where each card is.
///
/// # Why an extension rather than `AgentInterface::tenant`
///
/// A2A's well-known card path is singular per host, so a plane hosting several
/// declared agents could give each its own card only by running a server per
/// agent — 28 specialists, 28 processes. The obvious shortcut is the `tenant`
/// field A2A already puts on every interface, and it is the wrong one: its
/// documented meaning is *the tenant id to send back on a request*, so
/// overloading it to select an **agent** would make every caller echo an agent
/// name into a field the protocol reserves for tenancy — and a plane that also
/// serves several tenants would then have two meanings in one string.
///
/// So the discriminator is a path, and this extension is the directory that
/// makes the paths discoverable. The well-known card stays exactly what the
/// specification says it is: one valid `AgentCard`, describing one real agent.
pub const EXT_AGENT_DIRECTORY: &str =
    "https://hupe1980.github.io/agentplane/a2a/ext/agent-directory/v1";

/// Where one agent's own card is served.
///
/// A path rather than a full URL, because the card is served from whatever host
/// the caller reached and a card that hard-coded one would be wrong behind a
/// proxy — the same reason the interface URL is deployment configuration.
#[must_use]
pub fn agent_card_path(agent: &str) -> String {
    format!("/agents/{agent}/agent-card.json")
}

impl CardCapabilities {
    /// What this crate can actually do.
    const fn implemented() -> Self {
        Self {
            // `SendStreamingMessage` and `SubscribeToTask`, served from the
            // journal — see `api::a2a_stream`.
            streaming: true,
            // Conservative until a durable outbox and delivery worker exist.
            // Config storage plus a best-effort callback cannot satisfy A2A's
            // at-least-once delivery contract across process failure.
            push_notifications: false,
            extended_agent_card: true,
            extensions: Vec::new(),
        }
    }
}

/// How a peer can reach this agent — A2A's `AgentInterface`.
///
/// All four fields are the spec's, spelled the spec's way: `protocolVersion`
/// is required and the binding is `protocolBinding`, and a card carrying a
/// field a conforming 1.0 client does not look at is a card it cannot read. A
/// card is the one artifact whose whole job is being parsed by software nobody
/// here wrote, so drift in it is not cosmetic.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CardInterface {
    pub url: String,
    /// `JSONRPC`, the one binding this crate speaks.
    pub protocol_binding: String,
    /// The A2A version spoken at this URL.
    pub protocol_version: String,
    /// An opaque routing identifier for multi-tenant endpoints.
    ///
    /// A2A's own answer to serving several tenants behind one address: the
    /// client echoes this back in every request, and the server routes on it.
    /// Absent when the plane serves the default tenant, because a card that
    /// names a tenant is telling callers to send one.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub tenant: Option<String>,
}

/// HTTP authentication advertised by an A2A interface.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HttpAuthSecurityScheme {
    pub scheme: String,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub bearer_format: Option<String>,
}

/// One A2A security scheme.
///
/// Only the scheme the shipped client can send: HTTP bearer authentication.
/// More variants belong here only when a transport can actually acquire and
/// send them.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CardSecurityScheme {
    pub http_auth_security_scheme: HttpAuthSecurityScheme,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SecurityScopeList {
    pub list: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CardSecurityRequirement {
    pub schemes: std::collections::BTreeMap<String, SecurityScopeList>,
}

/// Deployment authentication published on an Agent Card.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CardSecurity {
    name: String,
    scheme: CardSecurityScheme,
    scopes: Vec<String>,
}

impl CardSecurity {
    /// Advertise HTTP bearer authentication under `name`.
    #[must_use]
    pub fn bearer(
        name: impl Into<String>,
        scopes: impl IntoIterator<Item = impl Into<String>>,
    ) -> Self {
        Self {
            name: name.into(),
            scheme: CardSecurityScheme {
                http_auth_security_scheme: HttpAuthSecurityScheme {
                    scheme: "Bearer".to_owned(),
                    bearer_format: None,
                },
            },
            scopes: scopes.into_iter().map(Into::into).collect(),
        }
    }

    #[cfg(feature = "a2a-server")]
    pub(crate) fn apply(&self, card: &mut AgentCard) {
        card.security_schemes
            .insert(self.name.clone(), self.scheme.clone());
        card.security_requirements.push(CardSecurityRequirement {
            schemes: std::collections::BTreeMap::from([(
                self.name.clone(),
                SecurityScopeList {
                    list: self.scopes.clone(),
                },
            )]),
        });
    }
}

/// An A2A Agent Card, derived from a manifest.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentCard {
    pub name: String,
    pub description: String,
    pub version: String,
    pub capabilities: CardCapabilities,
    pub supported_interfaces: Vec<CardInterface>,
    pub default_input_modes: Vec<String>,
    pub default_output_modes: Vec<String>,
    pub skills: Vec<CardSkill>,
    /// How to authenticate every non-card operation on this interface.
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub security_schemes: std::collections::BTreeMap<String, CardSecurityScheme>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub security_requirements: Vec<CardSecurityRequirement>,
    /// Detached JWS signatures over this card.
    ///
    /// Several are allowed so a publisher can rotate keys without a window in
    /// which nobody can verify the card.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub signatures: Vec<super::card_sig::CardSignature>,
}

impl AgentCard {
    /// Derive a card from a declaration.
    ///
    /// `url` is deployment wiring rather than a property of the agent — the same
    /// split as an API key: where an agent is reachable changes with the
    /// deployment, and an agent's declaration must not change when its address
    /// does.
    ///
    /// # Errors
    ///
    /// If the manifest's digest cannot be computed.
    pub fn derive(
        manifest: &Manifest,
        url: impl Into<String>,
    ) -> Result<Self, crate::manifest::ManifestError> {
        let description = manifest.spec.identity.as_ref().map_or_else(
            || format!("The {} agent.", manifest.metadata.name),
            |identity| identity.role.clone(),
        );

        // One skill per advertised capability, and no others. The plane refuses
        // to start if a capability here has no skill behind it, or if a skill
        // answers one this field omits — so a card built from it advertises
        // exactly the surface the plane serves, in both directions.
        let skills = manifest
            .spec
            .capabilities
            .provides
            .iter()
            .map(|capability| CardSkill {
                id: capability.clone(),
                name: capability.clone(),
                description: description.clone(),
                tags: Vec::new(),
            })
            .collect();

        Ok(Self {
            name: manifest.metadata.name.clone(),
            description,
            version: manifest.metadata.version.clone(),
            capabilities: {
                let mut capabilities = CardCapabilities::implemented();
                capabilities.extensions.push(AgentExtension {
                    uri: EXT_MANIFEST_PROVENANCE.to_owned(),
                    description: Some(
                        "The digest of the manifest this card was derived from.".to_owned(),
                    ),
                    required: false,
                    params: Some(serde_json::json!({
                        "manifestDigest": manifest.digest()?.to_hex(),
                    })),
                });
                capabilities.extensions.push(AgentExtension {
                    uri: EXT_SKILL_SELECTION.to_owned(),
                    description: Some(
                        "Skill selection is named, never inferred: set `metadata.skill` \
                         on the Message to one of this card's skill ids. Required when \
                         the card lists more than one skill."
                            .to_owned(),
                    ),
                    required: false,
                    params: Some(serde_json::json!({ "field": "message.metadata.skill" })),
                });
                capabilities
            },
            supported_interfaces: vec![CardInterface {
                url: url.into(),
                protocol_binding: BINDING.to_owned(),
                protocol_version: crate::peers::PROTOCOL_VERSION.to_owned(),
                tenant: None,
            }],
            // Text only. Declaring a modality this plane cannot accept produces
            // a caller that sends bytes nobody will read.
            default_input_modes: vec!["text/plain".to_owned(), "application/json".to_owned()],
            default_output_modes: vec!["text/plain".to_owned(), "application/json".to_owned()],
            skills,
            security_schemes: std::collections::BTreeMap::new(),
            security_requirements: Vec::new(),
            // Unsigned until somebody signs it. An empty list serializes as an
            // absent field, so an unsigned card is not a card with an empty
            // promise on it.
            signatures: Vec::new(),
        })
    }

    /// The digest of the manifest this card was derived from, when the card
    /// declares one.
    ///
    /// Carried as a declared extension rather than a top-level field: the A2A
    /// schema forbids unknown properties on a card, so a spec-conforming peer
    /// could refuse the whole document over an extra key. The information
    /// survives — it is what lets a caller tell two cards with the same name
    /// and version apart when the declaration behind them changed. A version
    /// string is what an author remembered to bump; a digest is what the
    /// document actually says.
    #[must_use]
    pub fn manifest_digest(&self) -> Option<&str> {
        self.capabilities
            .extensions
            .iter()
            .find(|e| e.uri == EXT_MANIFEST_PROVENANCE)?
            .params
            .as_ref()?
            .get("manifestDigest")?
            .as_str()
    }
}

/// The card an **authenticated** peer may fetch: the same agent, in more detail.
///
/// A2A's `GetExtendedAgentCard` exists because the public card is read by
/// anyone, and some of what a peer legitimately wants to know is not for
/// everyone: which tools an agent may reach, what it is allowed to spend, and
/// what part it plays in a larger arrangement.
///
/// # Why this is a separate type
///
/// Serving the wrong card is a one-line mistake with no symptom — the response
/// is valid JSON either way, and the extra fields simply appear on a public
/// endpoint where nobody notices until somebody reads them. A distinct type
/// makes that a compile error instead: a handler for the public path cannot
/// return this, because it is not an [`AgentCard`].
///
/// # What it still will not say
///
/// Not the model, and not the protected-field rules. Which model an agent runs
/// on is a fact about a supply chain, and the exact fields a sink guards is a
/// map of where to push — both are disclosure that helps an attacker more than
/// a caller. The tool *names* are here because a peer deciding whether to
/// delegate genuinely needs to know what the far side can reach.
/// # Where the extra detail lives
///
/// In the [`EXT_GOVERNANCE`] extension under `capabilities.extensions` — the
/// spec's own slot for what its schema does not name — rather than as top-level
/// fields, which the official conformance kit rejects outright. Injected at
/// **derive** time, not at serialization, so a signature taken over the card
/// covers the disclosure: an extension added after signing would be exactly the
/// unverifiable claim the signature exists to prevent.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct ExtendedAgentCard {
    /// The card, with the governance extension included.
    pub public: AgentCard,
}

/// A tool an agent may reach, as an authenticated peer is told.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExtendedTool {
    pub reference: String,
    /// The manifest's own words, when it has any.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,
    /// Whether calling it changes the world.
    ///
    /// The field a peer most needs when deciding whether to delegate: an agent
    /// that can only read is a different risk from one that can move money.
    pub mutates: bool,
}

/// What an agent may spend on one run.
///
/// The figures serialize as **strings**, for two reasons that happen to agree:
/// `ProtoJSON` — the encoding A2A documents are defined in — renders 64-bit
/// integers as strings, and a `u64` can exceed ±2⁵³, past which JCS reads two
/// distinct integers as one double and card signing refuses the value outright
/// (see `peers::card_sig`). A string never meets either problem.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExtendedBudget {
    #[serde(default, with = "stringly", skip_serializing_if = "Option::is_none")]
    pub max_steps: Option<u64>,
    #[serde(default, with = "stringly", skip_serializing_if = "Option::is_none")]
    pub max_effects: Option<u64>,
    #[serde(default, with = "stringly", skip_serializing_if = "Option::is_none")]
    pub max_tokens: Option<u64>,
}

/// `Option<u64>` as a decimal string, the `ProtoJSON` int64 convention.
mod stringly {
    use serde::{Deserialize, Deserializer, Serializer, de::Error as _};

    #[allow(clippy::ref_option)]
    pub fn serialize<S: Serializer>(v: &Option<u64>, s: S) -> Result<S::Ok, S::Error> {
        // `&Option<T>` is the signature `serde(with)` hands a field serializer;
        // the idiomatic `Option<&T>` is not this seam's to choose.
        match v {
            Some(n) => s.serialize_str(&n.to_string()),
            None => s.serialize_none(),
        }
    }

    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Option<u64>, D::Error> {
        let raw: Option<String> = Option::deserialize(d)?;
        raw.map(|v| v.parse().map_err(D::Error::custom)).transpose()
    }
}

impl ExtendedAgentCard {
    /// Derive the authenticated card.
    ///
    /// # Errors
    ///
    /// If the manifest's digest cannot be computed.
    pub fn derive(
        manifest: &Manifest,
        url: impl Into<String>,
    ) -> Result<Self, crate::manifest::ManifestError> {
        let tools: Vec<ExtendedTool> = manifest
            .spec
            .tools
            .iter()
            .map(|g| ExtendedTool {
                reference: g.reference.clone(),
                description: g.description.clone(),
                mutates: g.mutates,
            })
            .collect();

        let budget = manifest.spec.budgets.as_ref().map(|b| ExtendedBudget {
            max_steps: b.max_steps.map(|n| n as u64),
            max_effects: b.max_effects.map(|n| n as u64),
            max_tokens: b.max_tokens,
        });

        let topology = manifest
            .spec
            .topology
            .as_ref()
            .map(|t| format!("{:?}/{:?}", t.mode, t.role));

        let mut public = AgentCard::derive(manifest, url)?;
        let mut params = serde_json::Map::new();
        params.insert(
            "tools".to_owned(),
            serde_json::to_value(&tools).unwrap_or(serde_json::Value::Null),
        );
        if let Some(budget) = budget {
            params.insert(
                "budget".to_owned(),
                serde_json::to_value(budget).unwrap_or(serde_json::Value::Null),
            );
        }
        if let Some(topology) = topology {
            params.insert("topology".to_owned(), serde_json::Value::String(topology));
        }
        public.capabilities.extensions.push(AgentExtension {
            uri: EXT_GOVERNANCE.to_owned(),
            description: Some(
                "What this agent may reach and spend, for a peer deciding whether to delegate."
                    .to_owned(),
            ),
            required: false,
            params: Some(serde_json::Value::Object(params)),
        });
        Ok(Self { public })
    }

    /// The tools the governance extension discloses.
    #[must_use]
    pub fn tools(&self) -> Vec<ExtendedTool> {
        self.governance("tools")
            .and_then(|v| serde_json::from_value(v.clone()).ok())
            .unwrap_or_default()
    }

    /// The per-run budget the governance extension discloses, when one is.
    #[must_use]
    pub fn budget(&self) -> Option<ExtendedBudget> {
        self.governance("budget")
            .and_then(|v| serde_json::from_value(v.clone()).ok())
    }

    /// The declared topology, when one is.
    #[must_use]
    pub fn topology(&self) -> Option<String> {
        self.governance("topology")
            .and_then(|v| v.as_str())
            .map(ToOwned::to_owned)
    }

    fn governance(&self, key: &str) -> Option<&serde_json::Value> {
        self.public
            .capabilities
            .extensions
            .iter()
            .find(|e| e.uri == EXT_GOVERNANCE)?
            .params
            .as_ref()?
            .get(key)
    }
}