Skip to content
Closed
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
1 change: 1 addition & 0 deletions FEATURE_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| suppressToolErrors config | ✅ | ❌ | Hide tool errors from user |
| Intent-first tool display | ✅ | ❌ | Details and exec summaries |
| Transcript file size in status | ✅ | ❌ | Show size in session status |
| A2A (Agent-to-Agent) bridge | ❌ | ✅ | Google A2A protocol (JSON-RPC 2.0 + SSE), configurable tool name/endpoint |

### Owner: _Unassigned_

Expand Down
89 changes: 89 additions & 0 deletions scripts/test-a2a-bridge.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Test script for the A2A bridge tool.
#
# Usage:
# # Run unit + integration tests (no external server needed)
# ./scripts/test-a2a-bridge.sh
#
# # Run live E2E test against a real A2A agent
# A2A_AGENT_URL=http://your-agent:5085 \
# A2A_ASSISTANT_ID=your-assistant-id \
# ./scripts/test-a2a-bridge.sh --live
#
# Environment variables (for --live mode):
# A2A_AGENT_URL Base URL of the A2A-compatible agent server (required)
# A2A_ASSISTANT_ID Assistant/graph ID to query (required)

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_ROOT"

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m'

pass() { echo -e "${GREEN}✓ $1${NC}"; }
fail() { echo -e "${RED}✗ $1${NC}"; exit 1; }
info() { echo -e "${YELLOW}► $1${NC}"; }

LIVE=false
for arg in "$@"; do
case "$arg" in
--live) LIVE=true ;;
esac
done

# ── Step 1: Format check ────────────────────────────────────────────
info "Checking formatting..."
cargo fmt --check -- src/tools/builtin/a2a/*.rs src/config/a2a.rs \
2>/dev/null && pass "cargo fmt" || fail "cargo fmt"

# ── Step 2: Clippy ──────────────────────────────────────────────────
info "Running clippy on A2A modules..."
cargo clippy -p ironclaw --all-features -- -D warnings \
2>&1 | tail -3
pass "cargo clippy"

# ── Step 3: Unit tests ──────────────────────────────────────────────
info "Running A2A unit tests..."
cargo test --lib -- a2a 2>&1 | tail -5
pass "unit tests"

# ── Step 4: Integration tests (construction only) ───────────────────
info "Running A2A integration tests (construction)..."
cargo test --test a2a_bridge_integration 2>&1 | tail -5
pass "integration tests"

# ── Step 5: Feature-flag compilation ────────────────────────────────
info "Checking libsql feature compilation..."
cargo check --no-default-features --features libsql 2>&1 | tail -3
pass "libsql feature check"

# ── Step 6 (optional): Live E2E test ────────────────────────────────
if [ "$LIVE" = true ]; then
if [ -z "${A2A_AGENT_URL:-}" ] || [ -z "${A2A_ASSISTANT_ID:-}" ]; then
fail "Live test requires A2A_AGENT_URL and A2A_ASSISTANT_ID env vars"
fi

info "Running live A2A test against $A2A_AGENT_URL ..."

# Quick connectivity check
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
--connect-timeout 5 "$A2A_AGENT_URL/info" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "000" ]; then
fail "Cannot reach $A2A_AGENT_URL (connection refused or timeout)"
fi
pass "server reachable (HTTP $HTTP_CODE)"

# Run the ignored live test
A2A_AGENT_URL="$A2A_AGENT_URL" \
A2A_ASSISTANT_ID="$A2A_ASSISTANT_ID" \
cargo test --test a2a_bridge_integration -- --ignored 2>&1 | tail -5
pass "live E2E test"
fi

echo ""
echo -e "${GREEN}All A2A bridge tests passed.${NC}"
135 changes: 135 additions & 0 deletions src/config/a2a.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
use std::time::Duration;

use crate::config::helpers::{parse_bool_env, parse_optional_env, parse_string_env};
use crate::error::ConfigError;

/// Configuration for the A2A (Agent-to-Agent) protocol bridge.
///
/// Connects to a remote agent via the Google A2A protocol (JSON-RPC 2.0 + SSE
/// streaming). All agent-specific values (URL, assistant ID) must be set
/// explicitly — no hardcoded defaults.
#[derive(Debug, Clone)]
pub struct A2aConfig {
/// Whether the A2A bridge is enabled.
pub enabled: bool,
/// Base URL of the remote agent (required when enabled).
pub agent_url: String,
/// Assistant ID for the remote agent (required when enabled).
pub assistant_id: String,
/// Tool name exposed to the LLM (default: `"a2a_query"`).
pub tool_name: String,
/// Tool description exposed to the LLM.
pub tool_description: String,
/// Prefix for push-notification messages from the background SSE consumer.
pub message_prefix: String,
/// Timeout for reading the first SSE event after connection.
pub request_timeout: Duration,
/// Timeout for the entire background SSE stream consumption.
pub task_timeout: Duration,
/// Secret name in the secrets store for the API key.
pub api_key_secret: String,
}

impl A2aConfig {
pub(crate) fn resolve() -> Result<Option<Self>, ConfigError> {
let enabled = parse_bool_env("A2A_ENABLED", false)?;
if !enabled {
return Ok(None);
}

let agent_url = parse_string_env("A2A_AGENT_URL", "")?;
if agent_url.is_empty() {
return Err(ConfigError::InvalidValue {
key: "A2A_AGENT_URL".to_string(),
message: "must be set when A2A_ENABLED=true".to_string(),
});
}

let assistant_id = parse_string_env("A2A_ASSISTANT_ID", "")?;
if assistant_id.is_empty() {
return Err(ConfigError::InvalidValue {
key: "A2A_ASSISTANT_ID".to_string(),
message: "must be set when A2A_ENABLED=true".to_string(),
});
}

let tool_name = parse_string_env("A2A_TOOL_NAME", "a2a_query")?;
let tool_description = parse_string_env(
"A2A_TOOL_DESCRIPTION",
"Query a remote AI agent via the A2A (Agent-to-Agent) protocol. \
Supports multi-turn conversations with thread_id for context continuity.",
)?;
let message_prefix = parse_string_env("A2A_MESSAGE_PREFIX", "[a2a]")?;
let request_timeout_ms: u64 = parse_optional_env("A2A_REQUEST_TIMEOUT_MS", 60_000)?;
let task_timeout_ms: u64 = parse_optional_env("A2A_TASK_TIMEOUT_MS", 1_200_000)?;
let api_key_secret = parse_string_env("A2A_API_KEY_SECRET", "a2a_api_key")?;

Ok(Some(Self {
enabled,
agent_url,
assistant_id,
tool_name,
tool_description,
message_prefix,
request_timeout: Duration::from_millis(request_timeout_ms),
task_timeout: Duration::from_millis(task_timeout_ms),
api_key_secret,
}))
}

/// Whether the API key secret name is configured (non-empty).
pub fn has_api_key_configured(&self) -> bool {
!self.api_key_secret.is_empty()
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn disabled_by_default() {
let _guard = crate::config::helpers::ENV_MUTEX.lock();
unsafe {
std::env::remove_var("A2A_ENABLED");
}
let result = A2aConfig::resolve().unwrap();
assert!(result.is_none());
}

#[test]
fn requires_agent_url_when_enabled() {
let _guard = crate::config::helpers::ENV_MUTEX.lock();
unsafe {
std::env::set_var("A2A_ENABLED", "true");
std::env::remove_var("A2A_AGENT_URL");
}
let result = A2aConfig::resolve();
assert!(result.is_err());
unsafe {
std::env::remove_var("A2A_ENABLED");
}
}

#[test]
fn has_api_key_configured_checks_non_empty() {
let config = A2aConfig {
enabled: true,
agent_url: "https://example.com".to_string(),
assistant_id: "test-id".to_string(),
tool_name: "a2a_query".to_string(),
tool_description: "test".to_string(),
message_prefix: "[a2a]".to_string(),
request_timeout: Duration::from_secs(60),
task_timeout: Duration::from_secs(1200),
api_key_secret: "my_key".to_string(),
};
assert!(config.has_api_key_configured());

let config_empty = A2aConfig {
api_key_secret: String::new(),
..config
};
assert!(!config_empty.has_api_key_configured());
}
}
7 changes: 7 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
//! in startup). Everything else comes from env vars, the DB settings
//! table, or auto-detection.

mod a2a;
mod agent;
mod builder;
mod channels;
Expand Down Expand Up @@ -32,6 +33,7 @@ use crate::error::ConfigError;
use crate::settings::Settings;

// Re-export all public types so `crate::config::FooConfig` continues to work.
pub use self::a2a::A2aConfig;
pub use self::agent::AgentConfig;
pub use self::builder::BuilderModeConfig;
pub use self::channels::{
Expand Down Expand Up @@ -102,6 +104,9 @@ pub struct Config {
/// Channel-relay integration (Slack via external relay service).
/// Present only when both `CHANNEL_RELAY_URL` and `CHANNEL_RELAY_API_KEY` are set.
pub relay: Option<RelayConfig>,
/// A2A bridge configuration for connecting to remote agents.
/// Present only when `A2A_ENABLED=true`.
pub a2a: Option<A2aConfig>,
}

impl Config {
Expand Down Expand Up @@ -177,6 +182,7 @@ impl Config {
search: WorkspaceSearchConfig::default(),
observability: crate::observability::ObservabilityConfig::default(),
relay: None,
a2a: None,
}
}

Expand Down Expand Up @@ -329,6 +335,7 @@ impl Config {
backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()),
},
relay: RelayConfig::from_env(),
a2a: A2aConfig::resolve()?,
})
}
}
Expand Down
29 changes: 29 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ use ironclaw::{
llm::create_session_manager,
orchestrator::{ReaperConfig, SandboxReaper},
pairing::PairingStore,
tools::Tool,
tracing_fmt::{init_cli_tracing, init_worker_tracing},
webhooks::{self, ToolWebhookState},
};
Expand Down Expand Up @@ -466,6 +467,34 @@ async fn async_main() -> anyhow::Result<()> {
components.secrets_store.clone(),
);

// ── A2A bridge tool ────────────────────────────────────────────────
if let Some(ref a2a_config) = config.a2a {
if let Some(ref ss) = components.secrets_store {
match ironclaw::tools::builtin::A2aBridgeTool::new(
a2a_config.clone(),
Arc::clone(ss),
channels.inject_sender(),
)
.await
{
Ok(tool) => {
let tool_name = tool.name().to_string();
components.tools.register_sync(Arc::new(tool));
tracing::info!(
tool = %tool_name,
url = %a2a_config.agent_url,
"A2A bridge enabled"
);
}
Err(e) => {
tracing::error!("A2A bridge initialization failed: {}", e);
}
}
} else {
tracing::warn!("A2A bridge enabled but no secrets store available — skipping");
}
}

// ── Gateway channel ────────────────────────────────────────────────

let mut gateway_url: Option<String> = None;
Expand Down
Loading
Loading