Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion a2a/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion a2a/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand Down
77 changes: 64 additions & 13 deletions a2a/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(),
Expand All @@ -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" },
Expand Down Expand Up @@ -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");
Expand All @@ -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");
Expand All @@ -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()
Expand All @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions a2a/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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;
48 changes: 44 additions & 4 deletions a2a/src/main.rs
Original file line number Diff line number Diff line change
@@ -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::*};

Expand Down Expand Up @@ -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]
Expand All @@ -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?;
Expand Down
100 changes: 100 additions & 0 deletions a2a/tests/agent_card.rs
Original file line number Diff line number Diff line change
@@ -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());
}
Loading