From 1fcbd34d4f013fc38f23a33104be76146e85a9b5 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Sat, 25 Apr 2026 01:49:03 +0100 Subject: [PATCH 1/5] feat(a2a): configurable agent identity, fix serviceEndpoint path and docs URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three non-breaking A2A v0.3 fixes plus a configurable agent identity surface for the agent card: - supportedInterfaces[].url now appends '/a2a' (the JSON-RPC mount in register()), matching the spec — clients that POST to the advertised endpoint no longer 404 against the bare base URL. - documentationUrl threaded through a new --docs-url flag, defaulting to the workers repo a2a folder. The previous hardcoded link pointed at iii-hq/iii-connect, which was scrapped. - Identity (name, description, provider org/url) is now driven by AgentIdentity, populated from new --agent-name, --agent-description, --provider-org, --provider-url flags. Defaults preserve the existing hardcoded values byte-for-byte, so no in-flight client breaks. Crate gains a thin lib.rs (re-exports handler + types) so integration tests under a2a/tests/ can drive build_agent_card directly. Three tests cover default identity (a2a suffix + docs URL), trailing-slash normalisation, and full custom identity flow-through. --- a2a/Cargo.lock | 2 +- a2a/Cargo.toml | 4 ++ a2a/src/handler.rs | 52 ++++++++++++++++++---- a2a/src/lib.rs | 7 +++ a2a/src/main.rs | 48 ++++++++++++++++++-- a2a/tests/agent_card.rs | 97 +++++++++++++++++++++++++++++++++++++++++ 6 files changed, 196 insertions(+), 14 deletions(-) create mode 100644 a2a/src/lib.rs create mode 100644 a2a/tests/agent_card.rs diff --git a/a2a/Cargo.lock b/a2a/Cargo.lock index 09a5a35d7..acba2ce12 100644 --- a/a2a/Cargo.lock +++ b/a2a/Cargo.lock @@ -706,7 +706,7 @@ dependencies = [ [[package]] name = "iii-a2a" -version = "0.3.0" +version = "0.3.2" dependencies = [ "anyhow", "clap", diff --git a/a2a/Cargo.toml b/a2a/Cargo.toml index fd99696e9..9a4122c0c 100644 --- a/a2a/Cargo.toml +++ b/a2a/Cargo.toml @@ -11,6 +11,10 @@ rust-version = "1.85" keywords = ["a2a", "agent-to-agent", "ai", "iii-engine"] categories = ["command-line-utilities"] +[lib] +name = "iii_a2a" +path = "src/lib.rs" + [[bin]] name = "iii-a2a" path = "src/main.rs" diff --git a/a2a/src/handler.rs b/a2a/src/handler.rs index 3d0b6e1bc..8baff5154 100644 --- a/a2a/src/handler.rs +++ b/a2a/src/handler.rs @@ -55,6 +55,28 @@ impl ExposureConfig { } } +#[derive(Debug, Clone)] +pub struct AgentIdentity { + pub name: String, + pub description: String, + pub provider_org: String, + pub provider_url: String, + pub docs_url: String, +} + +impl Default for AgentIdentity { + fn default() -> Self { + Self { + name: "iii-engine".to_string(), + description: "iii-engine agent — invoke any registered function via A2A" + .to_string(), + provider_org: "iii".to_string(), + provider_url: "https://github.com/iii-hq/iii".to_string(), + docs_url: "https://github.com/iii-hq/workers/tree/main/a2a".to_string(), + } + } +} + fn is_exposed(f: &iii_sdk::FunctionInfo, cfg: &ExposureConfig) -> bool { if is_always_hidden(&f.function_id) { return false; @@ -94,10 +116,16 @@ async fn is_function_exposed(iii: &III, function_id: &str, cfg: &ExposureConfig) } } -pub fn register(iii: &III, exposure: ExposureConfig, base_url: String) { +pub fn register( + iii: &III, + exposure: ExposureConfig, + base_url: String, + identity: AgentIdentity, +) { let iii_card = iii.clone(); let card_cfg = exposure.clone(); let card_base_url = base_url.clone(); + let card_identity = identity.clone(); iii.register_function_with( RegisterFunctionMessage { id: "a2a::agent_card".to_string(), @@ -111,8 +139,9 @@ pub fn register(iii: &III, exposure: ExposureConfig, base_url: String) { let iii_inner = iii_card.clone(); let cfg = card_cfg.clone(); let base = card_base_url.clone(); + let ident = card_identity.clone(); async move { - let card = build_agent_card(&iii_inner, &cfg, &base).await; + let card = build_agent_card(&iii_inner, &cfg, &base, &ident).await; Ok(json!({ "status_code": 200, "headers": { "content-type": "application/json" }, @@ -189,7 +218,12 @@ pub fn register(iii: &III, exposure: ExposureConfig, base_url: String) { tracing::info!("A2A registered: GET /.well-known/agent-card.json, POST /a2a"); } -async fn build_agent_card(iii: &III, cfg: &ExposureConfig, base_url: &str) -> AgentCard { +pub async fn build_agent_card( + iii: &III, + cfg: &ExposureConfig, + base_url: &str, + identity: &AgentIdentity, +) -> AgentCard { let skills = match iii.list_functions().await { Ok(fns) => fns .iter() @@ -212,19 +246,19 @@ async fn build_agent_card(iii: &III, cfg: &ExposureConfig, base_url: &str) -> Ag }; AgentCard { - name: "iii-engine".to_string(), - description: "iii-engine agent — invoke any registered function via A2A".to_string(), + name: identity.name.clone(), + description: identity.description.clone(), version: env!("CARGO_PKG_VERSION").to_string(), supported_interfaces: vec![AgentInterface { - url: base_url.to_string(), + url: format!("{}/a2a", base_url.trim_end_matches('/')), protocol_binding: "JSONRPC".to_string(), protocol_version: "0.3".to_string(), }], provider: Some(AgentProvider { - organization: "iii".to_string(), - url: "https://github.com/iii-hq/iii".to_string(), + organization: identity.provider_org.clone(), + url: identity.provider_url.clone(), }), - documentation_url: Some("https://github.com/iii-hq/iii-connect".to_string()), + documentation_url: Some(identity.docs_url.clone()), capabilities: AgentCapabilities { streaming: false, push_notifications: false, diff --git a/a2a/src/lib.rs b/a2a/src/lib.rs new file mode 100644 index 000000000..8ec6a4a82 --- /dev/null +++ b/a2a/src/lib.rs @@ -0,0 +1,7 @@ +//! Library entry-point for `iii-a2a`. +//! +//! Exposes `handler` and `types` so integration tests under `a2a/tests/` +//! can reach `build_agent_card` and the agent-card structs without going +//! through the binary. +pub mod handler; +pub mod types; diff --git a/a2a/src/main.rs b/a2a/src/main.rs index aab8349da..49b709af9 100644 --- a/a2a/src/main.rs +++ b/a2a/src/main.rs @@ -1,7 +1,5 @@ -mod handler; -mod types; - use clap::Parser; +use iii_a2a::handler; use iii_sdk::{InitOptions, register_worker}; use tracing_subscriber::{EnvFilter, fmt, prelude::*}; @@ -37,6 +35,41 @@ struct Args { help = "Public base URL advertised in the agent card" )] base_url: String, + + #[arg( + long, + default_value = "iii-engine", + help = "Agent name advertised in the agent card" + )] + agent_name: String, + + #[arg( + long, + default_value = "iii-engine agent — invoke any registered function via A2A", + help = "Agent description advertised in the agent card" + )] + agent_description: String, + + #[arg( + long, + default_value = "iii", + help = "Provider organization advertised in the agent card" + )] + provider_org: String, + + #[arg( + long, + default_value = "https://github.com/iii-hq/iii", + help = "Provider URL advertised in the agent card" + )] + provider_url: String, + + #[arg( + long, + default_value = "https://github.com/iii-hq/workers/tree/main/a2a", + help = "Documentation URL advertised in the agent card" + )] + docs_url: String, } #[tokio::main] @@ -59,7 +92,14 @@ async fn main() -> anyhow::Result<()> { let iii = register_worker(&args.engine_url, InitOptions::default()); let exposure = handler::ExposureConfig::new(args.expose_all, args.tier.clone()); - handler::register(&iii, exposure, args.base_url); + let identity = handler::AgentIdentity { + name: args.agent_name, + description: args.agent_description, + provider_org: args.provider_org, + provider_url: args.provider_url, + docs_url: args.docs_url, + }; + handler::register(&iii, exposure, args.base_url, identity); tracing::info!("A2A endpoints registered on engine port. Ctrl+C to stop."); tokio::signal::ctrl_c().await?; diff --git a/a2a/tests/agent_card.rs b/a2a/tests/agent_card.rs new file mode 100644 index 000000000..73207986b --- /dev/null +++ b/a2a/tests/agent_card.rs @@ -0,0 +1,97 @@ +//! Unit tests for `build_agent_card`. +//! +//! These tests assert the static-shape pieces of the A2A v0.3 agent card: +//! the `/a2a` suffix on the advertised JSON-RPC interface, the configurable +//! documentation URL, and the configurable identity (name / description / +//! provider). The tests intentionally point `III` at a non-listening port so +//! `list_functions()` errors out and `skills` falls back to an empty vec — +//! that's the documented behaviour, and it lets us cover the static fields +//! without spinning up the engine. + +use iii_a2a::handler::{AgentIdentity, ExposureConfig, build_agent_card}; +use iii_sdk::III; + +fn unreachable_iii() -> III { + // Port 1 is reserved/unbound on every sane host; the SDK's reconnect + // logic will keep retrying in the background, but `list_functions()` + // returns Err immediately because no connection is established. That + // matches the `Err(_) => vec![]` branch in `build_agent_card`. + III::new("ws://127.0.0.1:1") +} + +#[tokio::test] +async fn default_identity_advertises_a2a_suffix_and_docs_url() { + let iii = unreachable_iii(); + let cfg = ExposureConfig::new(false, None); + let identity = AgentIdentity::default(); + + let card = build_agent_card(&iii, &cfg, "http://localhost:3111", &identity).await; + + assert_eq!(card.supported_interfaces.len(), 1); + assert_eq!( + card.supported_interfaces[0].url, + "http://localhost:3111/a2a", + "supported_interfaces[].url must point at the JSON-RPC mount, not the bare base URL" + ); + assert_eq!(card.supported_interfaces[0].protocol_binding, "JSONRPC"); + assert_eq!(card.supported_interfaces[0].protocol_version, "0.3"); + + assert_eq!( + card.documentation_url.as_deref(), + Some("https://github.com/iii-hq/workers/tree/main/a2a"), + "default docs_url must point at the workers repo a2a folder" + ); + + assert_eq!(card.name, "iii-engine"); + let provider = card.provider.expect("default identity always has a provider"); + assert_eq!(provider.organization, "iii"); + assert_eq!(provider.url, "https://github.com/iii-hq/iii"); +} + +#[tokio::test] +async fn trailing_slash_in_base_url_is_normalised() { + let iii = unreachable_iii(); + let cfg = ExposureConfig::new(false, None); + let identity = AgentIdentity::default(); + + let card = build_agent_card(&iii, &cfg, "http://localhost:3111/", &identity).await; + + assert_eq!( + card.supported_interfaces[0].url, "http://localhost:3111/a2a", + "trailing slash on base_url must not produce a doubled `//a2a`" + ); +} + +#[tokio::test] +async fn custom_identity_flows_through() { + let iii = unreachable_iii(); + let cfg = ExposureConfig::new(false, None); + let identity = AgentIdentity { + name: "acme-orchestrator".to_string(), + description: "Acme order pipeline agent".to_string(), + provider_org: "Acme Corp".to_string(), + provider_url: "https://acme.example/agents".to_string(), + docs_url: "https://docs.acme.example/agents/orchestrator".to_string(), + }; + + let card = build_agent_card(&iii, &cfg, "https://agent.acme.example", &identity).await; + + assert_eq!(card.name, "acme-orchestrator"); + assert_eq!(card.description, "Acme order pipeline agent"); + assert_eq!( + card.documentation_url.as_deref(), + Some("https://docs.acme.example/agents/orchestrator") + ); + let provider = card.provider.expect("custom identity always has a provider"); + assert_eq!(provider.organization, "Acme Corp"); + assert_eq!(provider.url, "https://acme.example/agents"); + + assert_eq!( + card.supported_interfaces[0].url, "https://agent.acme.example/a2a", + "custom base_url must still get the /a2a suffix" + ); + + // No engine connection, so `list_functions()` errors and skills is empty — + // documents the Err branch in build_agent_card. + assert!(card.skills.is_empty()); +} From 191ae71916d849f8504e09fd6a901245aa134e72 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Sat, 25 Apr 2026 14:52:44 +0100 Subject: [PATCH 2/5] chore(a2a): address phase0 review nits - Single-source DEFAULT_* consts shared between AgentIdentity::default() and clap default_value attributes --- a2a/src/handler.rs | 20 ++++++++++++++------ a2a/src/main.rs | 10 +++++----- 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/a2a/src/handler.rs b/a2a/src/handler.rs index 8baff5154..832fa140b 100644 --- a/a2a/src/handler.rs +++ b/a2a/src/handler.rs @@ -64,15 +64,23 @@ pub struct AgentIdentity { pub docs_url: String, } +// Single source of truth for AgentIdentity defaults — also referenced by +// clap's #[arg(default_value = ...)] in main.rs so the two stay in sync. +pub const DEFAULT_AGENT_NAME: &str = "iii-engine"; +pub const DEFAULT_AGENT_DESCRIPTION: &str = + "iii-engine agent — invoke any registered function via A2A"; +pub const DEFAULT_PROVIDER_ORG: &str = "iii"; +pub const DEFAULT_PROVIDER_URL: &str = "https://github.com/iii-hq/iii"; +pub const DEFAULT_DOCS_URL: &str = "https://github.com/iii-hq/workers/tree/main/a2a"; + impl Default for AgentIdentity { fn default() -> Self { Self { - name: "iii-engine".to_string(), - description: "iii-engine agent — invoke any registered function via A2A" - .to_string(), - provider_org: "iii".to_string(), - provider_url: "https://github.com/iii-hq/iii".to_string(), - docs_url: "https://github.com/iii-hq/workers/tree/main/a2a".to_string(), + name: DEFAULT_AGENT_NAME.to_string(), + description: DEFAULT_AGENT_DESCRIPTION.to_string(), + provider_org: DEFAULT_PROVIDER_ORG.to_string(), + provider_url: DEFAULT_PROVIDER_URL.to_string(), + docs_url: DEFAULT_DOCS_URL.to_string(), } } } diff --git a/a2a/src/main.rs b/a2a/src/main.rs index 49b709af9..74aeb14ff 100644 --- a/a2a/src/main.rs +++ b/a2a/src/main.rs @@ -38,35 +38,35 @@ struct Args { #[arg( long, - default_value = "iii-engine", + default_value = iii_a2a::handler::DEFAULT_AGENT_NAME, help = "Agent name advertised in the agent card" )] agent_name: String, #[arg( long, - default_value = "iii-engine agent — invoke any registered function via A2A", + default_value = iii_a2a::handler::DEFAULT_AGENT_DESCRIPTION, help = "Agent description advertised in the agent card" )] agent_description: String, #[arg( long, - default_value = "iii", + default_value = iii_a2a::handler::DEFAULT_PROVIDER_ORG, help = "Provider organization advertised in the agent card" )] provider_org: String, #[arg( long, - default_value = "https://github.com/iii-hq/iii", + default_value = iii_a2a::handler::DEFAULT_PROVIDER_URL, help = "Provider URL advertised in the agent card" )] provider_url: String, #[arg( long, - default_value = "https://github.com/iii-hq/workers/tree/main/a2a", + default_value = iii_a2a::handler::DEFAULT_DOCS_URL, help = "Documentation URL advertised in the agent card" )] docs_url: String, From 55027d3139704ad2ad78f66ea9d48af46aef163e Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Sat, 25 Apr 2026 21:56:05 +0100 Subject: [PATCH 3/5] ci(a2a): bump to 0.3.3 + cargo fmt --- a2a/Cargo.toml | 2 +- a2a/src/handler.rs | 7 +------ a2a/tests/agent_card.rs | 11 +++++++---- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/a2a/Cargo.toml b/a2a/Cargo.toml index 9a4122c0c..0c8eed6e8 100644 --- a/a2a/Cargo.toml +++ b/a2a/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iii-a2a" -version = "0.3.2" +version = "0.3.3" edition = "2024" description = "A2A protocol worker for iii-engine" license = "Apache-2.0" diff --git a/a2a/src/handler.rs b/a2a/src/handler.rs index 832fa140b..80d7a6172 100644 --- a/a2a/src/handler.rs +++ b/a2a/src/handler.rs @@ -124,12 +124,7 @@ async fn is_function_exposed(iii: &III, function_id: &str, cfg: &ExposureConfig) } } -pub fn register( - iii: &III, - exposure: ExposureConfig, - base_url: String, - identity: AgentIdentity, -) { +pub fn register(iii: &III, exposure: ExposureConfig, base_url: String, identity: AgentIdentity) { let iii_card = iii.clone(); let card_cfg = exposure.clone(); let card_base_url = base_url.clone(); diff --git a/a2a/tests/agent_card.rs b/a2a/tests/agent_card.rs index 73207986b..fc914fb75 100644 --- a/a2a/tests/agent_card.rs +++ b/a2a/tests/agent_card.rs @@ -29,8 +29,7 @@ async fn default_identity_advertises_a2a_suffix_and_docs_url() { assert_eq!(card.supported_interfaces.len(), 1); assert_eq!( - card.supported_interfaces[0].url, - "http://localhost:3111/a2a", + card.supported_interfaces[0].url, "http://localhost:3111/a2a", "supported_interfaces[].url must point at the JSON-RPC mount, not the bare base URL" ); assert_eq!(card.supported_interfaces[0].protocol_binding, "JSONRPC"); @@ -43,7 +42,9 @@ async fn default_identity_advertises_a2a_suffix_and_docs_url() { ); assert_eq!(card.name, "iii-engine"); - let provider = card.provider.expect("default identity always has a provider"); + let provider = card + .provider + .expect("default identity always has a provider"); assert_eq!(provider.organization, "iii"); assert_eq!(provider.url, "https://github.com/iii-hq/iii"); } @@ -82,7 +83,9 @@ async fn custom_identity_flows_through() { card.documentation_url.as_deref(), Some("https://docs.acme.example/agents/orchestrator") ); - let provider = card.provider.expect("custom identity always has a provider"); + let provider = card + .provider + .expect("custom identity always has a provider"); assert_eq!(provider.organization, "Acme Corp"); assert_eq!(provider.url, "https://acme.example/agents"); From 6fb246c4cb49eb3cf22ee35fb188d9ca82c2d094 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Sat, 25 Apr 2026 23:12:42 +0100 Subject: [PATCH 4/5] fix(a2a): strip leading / from api_path, trim base_url whitespace, optional provider/docs URL - api_path: '.well-known/agent-card.json' / 'a2a' (no leading slash; engine prepends '/' so leading slash produces double-slash 404) - base_url normalised via .trim().trim_end_matches('/') - AgentCard provider/documentation_url omitted when fields empty (spec-optional) --- a2a/Cargo.lock | 2 +- a2a/src/handler.rs | 27 +++++++++++++++++++-------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/a2a/Cargo.lock b/a2a/Cargo.lock index acba2ce12..b245049ae 100644 --- a/a2a/Cargo.lock +++ b/a2a/Cargo.lock @@ -706,7 +706,7 @@ dependencies = [ [[package]] name = "iii-a2a" -version = "0.3.2" +version = "0.3.3" dependencies = [ "anyhow", "clap", diff --git a/a2a/src/handler.rs b/a2a/src/handler.rs index 80d7a6172..f299f292f 100644 --- a/a2a/src/handler.rs +++ b/a2a/src/handler.rs @@ -203,7 +203,7 @@ pub fn register(iii: &III, exposure: ExposureConfig, base_url: String, identity: if let Err(e) = iii.register_trigger(RegisterTriggerInput { trigger_type: "http".to_string(), function_id: "a2a::agent_card".to_string(), - config: json!({ "api_path": "/.well-known/agent-card.json", "http_method": "GET" }), + config: json!({ "api_path": ".well-known/agent-card.json", "http_method": "GET" }), metadata: None, }) { tracing::error!(error = %e, "Failed to register a2a::agent_card trigger"); @@ -212,7 +212,7 @@ pub fn register(iii: &III, exposure: ExposureConfig, base_url: String, identity: if let Err(e) = iii.register_trigger(RegisterTriggerInput { trigger_type: "http".to_string(), function_id: "a2a::jsonrpc".to_string(), - config: json!({ "api_path": "/a2a", "http_method": "POST" }), + config: json!({ "api_path": "a2a", "http_method": "POST" }), metadata: None, }) { tracing::error!(error = %e, "Failed to register a2a::jsonrpc trigger"); @@ -248,20 +248,31 @@ pub async fn build_agent_card( Err(_) => vec![], }; + let base = base_url.trim().trim_end_matches('/'); + let provider = if identity.provider_org.is_empty() && identity.provider_url.is_empty() { + None + } else { + Some(AgentProvider { + organization: identity.provider_org.clone(), + url: identity.provider_url.clone(), + }) + }; + let documentation_url = if identity.docs_url.is_empty() { + None + } else { + Some(identity.docs_url.clone()) + }; AgentCard { name: identity.name.clone(), description: identity.description.clone(), version: env!("CARGO_PKG_VERSION").to_string(), supported_interfaces: vec![AgentInterface { - url: format!("{}/a2a", base_url.trim_end_matches('/')), + url: format!("{}/a2a", base), protocol_binding: "JSONRPC".to_string(), protocol_version: "0.3".to_string(), }], - provider: Some(AgentProvider { - organization: identity.provider_org.clone(), - url: identity.provider_url.clone(), - }), - documentation_url: Some(identity.docs_url.clone()), + provider, + documentation_url, capabilities: AgentCapabilities { streaming: false, push_notifications: false, From 8903f54ebd7613b438cbe92df1535ff0998ebfeb Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Sun, 26 Apr 2026 03:02:01 +0100 Subject: [PATCH 5/5] fix(a2a): omit provider when either org or url empty (A2A v0.3 spec compliance) Both AgentProvider.organization and AgentProvider.url are required by the A2A v0.3 spec. Half-populated provider violates the spec; switch && to || so we omit the field entirely if either is missing. --- a2a/src/handler.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/a2a/src/handler.rs b/a2a/src/handler.rs index f299f292f..3c34e5f1f 100644 --- a/a2a/src/handler.rs +++ b/a2a/src/handler.rs @@ -249,7 +249,10 @@ pub async fn build_agent_card( }; let base = base_url.trim().trim_end_matches('/'); - let provider = if identity.provider_org.is_empty() && identity.provider_url.is_empty() { + // A2A v0.3 AgentProvider requires BOTH organization and url. Omit the + // provider object if either is empty rather than emit a half-populated + // record that violates the spec. + let provider = if identity.provider_org.is_empty() || identity.provider_url.is_empty() { None } else { Some(AgentProvider {