diff --git a/a2a/Cargo.lock b/a2a/Cargo.lock index 09a5a35d7..b245049ae 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.3" dependencies = [ "anyhow", "clap", diff --git a/a2a/Cargo.toml b/a2a/Cargo.toml index fd99696e9..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" @@ -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..3c34e5f1f 100644 --- a/a2a/src/handler.rs +++ b/a2a/src/handler.rs @@ -55,6 +55,36 @@ 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, +} + +// 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: 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(), + } + } +} + fn is_exposed(f: &iii_sdk::FunctionInfo, cfg: &ExposureConfig) -> bool { if is_always_hidden(&f.function_id) { return false; @@ -94,10 +124,11 @@ 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 +142,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" }, @@ -171,7 +203,7 @@ pub fn register(iii: &III, exposure: ExposureConfig, base_url: String) { 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"); @@ -180,7 +212,7 @@ pub fn register(iii: &III, exposure: ExposureConfig, base_url: String) { 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"); @@ -189,7 +221,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() @@ -211,20 +248,34 @@ async fn build_agent_card(iii: &III, cfg: &ExposureConfig, base_url: &str) -> Ag Err(_) => vec![], }; + let base = base_url.trim().trim_end_matches('/'); + // 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 { + 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: "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), 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(), - }), - documentation_url: Some("https://github.com/iii-hq/iii-connect".to_string()), + provider, + documentation_url, 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..74aeb14ff 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_a2a::handler::DEFAULT_AGENT_NAME, + help = "Agent name advertised in the agent card" + )] + agent_name: String, + + #[arg( + long, + default_value = iii_a2a::handler::DEFAULT_AGENT_DESCRIPTION, + help = "Agent description advertised in the agent card" + )] + agent_description: String, + + #[arg( + long, + default_value = iii_a2a::handler::DEFAULT_PROVIDER_ORG, + help = "Provider organization advertised in the agent card" + )] + provider_org: String, + + #[arg( + long, + default_value = iii_a2a::handler::DEFAULT_PROVIDER_URL, + help = "Provider URL advertised in the agent card" + )] + provider_url: String, + + #[arg( + long, + default_value = iii_a2a::handler::DEFAULT_DOCS_URL, + 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..fc914fb75 --- /dev/null +++ b/a2a/tests/agent_card.rs @@ -0,0 +1,100 @@ +//! 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()); +}