use crate::registry::{Tool, ToolContext};
use aegis_a2a::client::A2AClient;
use aegis_a2a::types::{Message, MessageRole, Part, TaskGetParams, TaskSendParams};
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use serde_json::{json, Value};
use std::sync::{Arc, Mutex};
pub const MAX_A2A_HOPS: u64 = 4;
pub fn is_self_url(url: &str) -> bool {
let norm = |u: &str| u.trim().trim_end_matches('/').to_ascii_lowercase();
std::env::var("AEGIS_A2A_SELF")
.ok()
.map(|s| norm(&s) == norm(url))
.unwrap_or(false)
}
fn current_hops() -> u64 {
std::env::var("AEGIS_A2A_DEPTH")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(0)
}
fn next_hops_metadata() -> Option<std::collections::HashMap<String, Value>> {
let mut m = std::collections::HashMap::new();
m.insert("aegis_hops".to_string(), json!(current_hops() + 1));
Some(m)
}
fn delegation_block_reason(url: &str) -> Option<String> {
if is_self_url(url) {
return Some(format!(
"Refused: '{url}' is this agent's own A2A endpoint — delegating to self would loop."
));
}
if current_hops() + 1 > MAX_A2A_HOPS {
return Some(format!(
"Refused: A2A delegation hop limit ({MAX_A2A_HOPS}) reached — aborting to avoid a delegation loop."
));
}
None
}
pub struct AgentInfo {
pub name: String,
pub role: String,
pub expertise: String,
pub url: String,
pub executor: Arc<dyn Fn(String, String) -> futures::future::BoxFuture<'static, Result<String>> + Send + Sync>,
}
impl AgentInfo {
pub fn new(
name: impl Into<String>,
role: impl Into<String>,
expertise: impl Into<String>,
executor: Arc<dyn Fn(String, String) -> futures::future::BoxFuture<'static, Result<String>> + Send + Sync>,
) -> Self {
Self {
name: name.into(),
role: role.into(),
expertise: expertise.into(),
url: String::new(),
executor,
}
}
}
pub struct DelegateWorkTool {
pub available_agents: Vec<AgentInfo>,
}
impl DelegateWorkTool {
pub fn new(agents: Vec<AgentInfo>) -> Self {
Self { available_agents: agents }
}
fn find_agent(&self, name: &str) -> Option<&AgentInfo> {
self.available_agents
.iter()
.find(|a| a.name.eq_ignore_ascii_case(name))
}
fn agent_list(&self) -> String {
let mut lines: Vec<String> = self
.available_agents
.iter()
.map(|a| format!("- {} ({}): {}", a.name, a.role, a.expertise))
.collect();
for p in crate::peers::list() {
if !self.available_agents.iter().any(|a| a.name.eq_ignore_ascii_case(&p.name)) {
lines.push(format!("- {} ({}): {}", p.name, p.role, p.expertise));
}
}
lines.join("\n")
}
pub async fn poll_task(task_id: &str, agent_url: &str) -> Result<String> {
let client = A2AClient::new(agent_url);
let task = client
.get(TaskGetParams {
id: task_id.to_string(),
history_length: None,
})
.await?;
Ok(format!(
"Task {} — state: {:?}",
task.id, task.status.state
))
}
}
#[async_trait]
impl Tool for DelegateWorkTool {
fn name(&self) -> &str {
"delegate_work"
}
fn description(&self) -> &str {
"Delegate a task to a coworker agent. \
Specify: coworker name, task description, and context. \
Available coworkers are listed in the parameters schema."
}
fn parameters(&self) -> Value {
let agent_names: Vec<Value> = {
let mut names: Vec<Value> = self
.available_agents
.iter()
.map(|a| Value::String(a.name.clone()))
.collect();
for p in crate::peers::list() {
if !self.available_agents.iter().any(|a| a.name.eq_ignore_ascii_case(&p.name)) {
names.push(Value::String(p.name.clone()));
}
}
names
};
let agent_roster = self.agent_list();
json!({
"type": "object",
"properties": {
"coworker": {
"type": "string",
"description": format!("Name of the coworker to delegate to. Available:\n{}", agent_roster),
"enum": agent_names
},
"task": {
"type": "string",
"description": "Detailed description of the task to delegate"
},
"context": {
"type": "string",
"description": "Background context the coworker needs to complete the task"
}
},
"required": ["coworker", "task"]
})
}
async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
let coworker = args["coworker"]
.as_str()
.ok_or_else(|| anyhow!("'coworker' is required"))?;
let task_desc = args["task"]
.as_str()
.ok_or_else(|| anyhow!("'task' is required"))?;
let context = args["context"].as_str().unwrap_or("");
let agent = self.find_agent(coworker);
if let Some(agent) = agent {
if !agent.url.is_empty() {
if let Some(reason) = delegation_block_reason(&agent.url) {
return Ok(reason);
}
let client = A2AClient::new(&agent.url);
let text = if context.is_empty() {
task_desc.to_string()
} else {
format!("Context: {}\n\nTask: {}", context, task_desc)
};
let params = TaskSendParams {
id: None,
message: None,
messages: vec![Message {
role: MessageRole::User,
parts: vec![Part::Text { text }],
kind: "message".into(),
message_id: None,
context_id: None,
task_id: None,
metadata: None,
}],
metadata: next_hops_metadata(),
session_id: None,
};
match client.submit(params).await {
Ok(task) => Ok(format!(
"[{} delegated via A2A] task_id={} state={:?}",
agent.name, task.id, task.status.state
)),
Err(e) => {
tracing::warn!(
"A2A delegation to {} failed ({}), falling back to executor",
agent.name,
e
);
let result = (agent.executor)(task_desc.to_string(), context.to_string()).await?;
Ok(format!("[{} completed task]\n{}", agent.name, result))
}
}
} else {
let result = (agent.executor)(task_desc.to_string(), context.to_string()).await?;
Ok(format!("[{} completed task]\n{}", agent.name, result))
}
} else if let Some(peer) = crate::peers::get(coworker) {
if let Some(reason) = delegation_block_reason(&peer.url) {
return Ok(reason);
}
let mut client = A2AClient::new(&peer.url);
if let Some(t) = &peer.token {
client = client.with_bearer_token(t.clone());
}
let text = if context.is_empty() {
task_desc.to_string()
} else {
format!("Context: {}\n\nTask: {}", context, task_desc)
};
let params = TaskSendParams {
id: None,
message: None,
messages: vec![Message {
role: MessageRole::User,
parts: vec![Part::Text { text }],
kind: "message".into(),
message_id: None,
context_id: None,
task_id: None,
metadata: None,
}],
metadata: next_hops_metadata(),
session_id: None,
};
match client.submit(params).await {
Ok(task) => Ok(format!(
"[{} delegated via A2A] task_id={} state={:?}",
peer.name, task.id, task.status.state
)),
Err(e) => Ok(format!("[{}] A2A delegation failed: {e}", peer.name)),
}
} else {
Err(anyhow!(
"Unknown coworker '{coworker}'. Available:\n{}",
self.agent_list()
))
}
}
}
pub struct AskQuestionTool {
pub available_agents: Vec<AgentInfo>,
}
impl AskQuestionTool {
pub fn new(agents: Vec<AgentInfo>) -> Self {
Self { available_agents: agents }
}
fn find_agent(&self, name: &str) -> Option<&AgentInfo> {
self.available_agents
.iter()
.find(|a| a.name.eq_ignore_ascii_case(name))
}
fn agent_list(&self) -> String {
let mut lines: Vec<String> = self
.available_agents
.iter()
.map(|a| format!("- {} ({}): {}", a.name, a.role, a.expertise))
.collect();
for p in crate::peers::list() {
if !self.available_agents.iter().any(|a| a.name.eq_ignore_ascii_case(&p.name)) {
lines.push(format!("- {} ({}): {}", p.name, p.role, p.expertise));
}
}
lines.join("\n")
}
}
#[async_trait]
impl Tool for AskQuestionTool {
fn name(&self) -> &str {
"ask_question"
}
fn description(&self) -> &str {
"Ask a coworker agent a specific question to gather information. \
Unlike delegate_work, you retain control and will use the answer yourself. \
Use this when you need a peer's expertise on a narrow question."
}
fn parameters(&self) -> Value {
let agent_names: Vec<Value> = {
let mut names: Vec<Value> = self
.available_agents
.iter()
.map(|a| Value::String(a.name.clone()))
.collect();
for p in crate::peers::list() {
if !self.available_agents.iter().any(|a| a.name.eq_ignore_ascii_case(&p.name)) {
names.push(Value::String(p.name.clone()));
}
}
names
};
let agent_roster = self.agent_list();
json!({
"type": "object",
"properties": {
"coworker": {
"type": "string",
"description": format!("Name of the coworker to ask. Available:\n{}", agent_roster),
"enum": agent_names
},
"question": {
"type": "string",
"description": "The specific question you want answered"
},
"context": {
"type": "string",
"description": "Background context that helps the coworker understand the question"
}
},
"required": ["coworker", "question"]
})
}
async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
let coworker = args["coworker"]
.as_str()
.ok_or_else(|| anyhow!("'coworker' is required"))?;
let question = args["question"]
.as_str()
.ok_or_else(|| anyhow!("'question' is required"))?;
let context = args["context"].as_str().unwrap_or("");
if self.find_agent(coworker).is_none() {
if let Some(peer) = crate::peers::get(coworker) {
if let Some(reason) = delegation_block_reason(&peer.url) {
return Ok(reason);
}
let mut client = A2AClient::new(&peer.url);
if let Some(t) = &peer.token {
client = client.with_bearer_token(t.clone());
}
let text = if context.is_empty() {
format!("Please answer this question concisely: {question}")
} else {
format!("Context: {context}\n\nPlease answer this question concisely: {question}")
};
let params = TaskSendParams {
id: None,
message: None,
messages: vec![Message {
role: MessageRole::User,
parts: vec![Part::Text { text }],
kind: "message".into(),
message_id: None,
context_id: None,
task_id: None,
metadata: None,
}],
metadata: next_hops_metadata(),
session_id: None,
};
return match client.submit(params).await {
Ok(task) => {
let ans = task
.status
.message
.and_then(|m| {
m.parts.into_iter().find_map(|p| match p {
Part::Text { text } => Some(text),
_ => None,
})
})
.unwrap_or_else(|| format!("(submitted, state={:?})", task.status.state));
Ok(format!("[{} answers]\n{}", peer.name, ans))
}
Err(e) => Ok(format!("[{}] A2A ask failed: {e}", peer.name)),
};
}
}
let agent = self
.find_agent(coworker)
.ok_or_else(|| anyhow!("Unknown coworker '{coworker}'. Available: {}", self.agent_list()))?;
if !agent.url.is_empty() {
if let Some(reason) = delegation_block_reason(&agent.url) {
return Ok(reason);
}
let client = A2AClient::new(&agent.url);
let text = if context.is_empty() {
format!("Please answer this question concisely: {}", question)
} else {
format!(
"Context: {}\n\nPlease answer this question concisely: {}",
context, question
)
};
let params = TaskSendParams {
id: None,
message: None,
messages: vec![Message {
role: MessageRole::User,
parts: vec![Part::Text { text }],
kind: "message".into(),
message_id: None,
context_id: None,
task_id: None,
metadata: None,
}],
metadata: next_hops_metadata(),
session_id: None,
};
match client.subscribe(params).await {
Ok(mut stream) => {
use futures::StreamExt;
while let Some(event_result) = stream.next().await {
match event_result {
Ok(aegis_a2a::types::TaskEvent::StatusUpdate(ev)) => {
if let Some(msg) = &ev.status.message {
for part in &msg.parts {
if let Part::Text { text } = part {
return Ok(format!("[{} answers]\n{}", agent.name, text));
}
}
}
}
Ok(aegis_a2a::types::TaskEvent::ArtifactUpdate(ev)) => {
for part in &ev.artifact.parts {
if let Part::Text { text } = part {
return Ok(format!("[{} answers]\n{}", agent.name, text));
}
}
}
Err(e) => {
tracing::warn!(
"A2A stream from {} failed ({}), falling back to executor",
agent.name,
e
);
break;
}
}
}
let prompt = if context.is_empty() {
format!("Please answer this question concisely: {}", question)
} else {
format!(
"Context: {}\n\nPlease answer this question concisely: {}",
context, question
)
};
let answer = (agent.executor)(prompt, String::new()).await?;
Ok(format!("[{} answers]\n{}", agent.name, answer))
}
Err(e) => {
tracing::warn!(
"A2A subscribe to {} failed ({}), falling back to executor",
agent.name,
e
);
let prompt = if context.is_empty() {
format!("Please answer this question concisely: {}", question)
} else {
format!(
"Context: {}\n\nPlease answer this question concisely: {}",
context, question
)
};
let answer = (agent.executor)(prompt, String::new()).await?;
Ok(format!("[{} answers]\n{}", agent.name, answer))
}
}
} else {
let prompt = if context.is_empty() {
format!("Please answer this question concisely: {}", question)
} else {
format!(
"Context: {}\n\nPlease answer this question concisely: {}",
context, question
)
};
let answer = (agent.executor)(prompt, String::new()).await?;
Ok(format!("[{} answers]\n{}", agent.name, answer))
}
}
}
pub struct ToolConfig {
inner: Arc<dyn Tool>,
pub max_usage_count: Option<u32>,
pub result_as_answer: bool,
usage_count: Mutex<u32>,
}
impl ToolConfig {
pub fn new(tool: Arc<dyn Tool>) -> Self {
Self {
inner: tool,
max_usage_count: None,
result_as_answer: false,
usage_count: Mutex::new(0),
}
}
pub fn with_max_usage(mut self, n: u32) -> Self {
self.max_usage_count = Some(n);
self
}
pub fn with_result_as_answer(mut self) -> Self {
self.result_as_answer = true;
self
}
pub fn is_exhausted(&self) -> bool {
match self.max_usage_count {
None => false,
Some(limit) => {
let count = self.usage_count.lock().expect("lock poisoned");
*count >= limit
}
}
}
}
#[async_trait]
impl Tool for ToolConfig {
fn name(&self) -> &str {
self.inner.name()
}
fn description(&self) -> &str {
self.inner.description()
}
fn parameters(&self) -> Value {
self.inner.parameters()
}
async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
if self.is_exhausted() {
return Err(anyhow!(
"Tool '{}' has reached its maximum usage count of {}",
self.inner.name(),
self.max_usage_count.unwrap_or(0)
));
}
let result = self.inner.execute(args, ctx).await?;
{
let mut count = self.usage_count.lock().expect("lock poisoned");
*count += 1;
}
if self.result_as_answer {
Ok(format!("[FINAL_ANSWER]\n{}", result))
} else {
Ok(result)
}
}
}
#[derive(Debug)]
pub enum GuardrailResult {
Pass,
Fail { reason: String },
}
pub trait GuardrailValidator: Send + Sync {
fn validate(&self, output: &str) -> GuardrailResult;
}
pub struct FnGuardrail {
validator: Box<dyn Fn(&str) -> GuardrailResult + Send + Sync>,
pub max_retries: u32,
}
impl FnGuardrail {
pub fn new(
max_retries: u32,
validator: impl Fn(&str) -> GuardrailResult + Send + Sync + 'static,
) -> Self {
Self {
validator: Box::new(validator),
max_retries,
}
}
}
impl GuardrailValidator for FnGuardrail {
fn validate(&self, output: &str) -> GuardrailResult {
(self.validator)(output)
}
}
pub async fn execute_with_guardrail<F, Fut>(
guardrail: &FnGuardrail,
mut produce: F,
) -> Result<String>
where
F: FnMut(Option<String>) -> Fut,
Fut: std::future::Future<Output = Result<String>>,
{
let mut feedback: Option<String> = None;
for attempt in 0..=guardrail.max_retries {
let output = produce(feedback.clone()).await?;
match (guardrail.validator)(&output) {
GuardrailResult::Pass => return Ok(output),
GuardrailResult::Fail { reason } => {
if attempt == guardrail.max_retries {
return Err(anyhow!(
"Guardrail failed after {} retries. Last reason: {}",
guardrail.max_retries,
reason
));
}
feedback = Some(format!(
"Attempt {} failed guardrail check: {}. Please correct and try again.",
attempt + 1,
reason
));
}
}
}
Err(anyhow!("Guardrail: exhausted retries"))
}
pub struct GuardrailChain {
validators: Vec<Box<dyn GuardrailValidator>>,
}
impl Default for GuardrailChain {
fn default() -> Self {
Self::new()
}
}
impl GuardrailChain {
pub fn new() -> Self {
Self { validators: Vec::new() }
}
pub fn push(mut self, v: impl GuardrailValidator + 'static) -> Self {
self.validators.push(Box::new(v));
self
}
}
impl GuardrailValidator for GuardrailChain {
fn validate(&self, output: &str) -> GuardrailResult {
for v in &self.validators {
match v.validate(output) {
GuardrailResult::Pass => continue,
fail => return fail,
}
}
GuardrailResult::Pass
}
}
pub struct ConditionalTask {
pub name: String,
pub description: String,
pub condition: Box<dyn Fn(&str) -> bool + Send + Sync>,
}
impl ConditionalTask {
pub fn new(
name: impl Into<String>,
description: impl Into<String>,
condition: impl Fn(&str) -> bool + Send + Sync + 'static,
) -> Self {
Self {
name: name.into(),
description: description.into(),
condition: Box::new(condition),
}
}
pub fn should_execute(&self, previous_output: &str) -> bool {
(self.condition)(previous_output)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn make_ctx() -> ToolContext<'static> {
ToolContext {
cwd: PathBuf::from("/tmp"),
session_id: "test".to_string(),
approve_fn: &|_| true,
yolo: true,
identity: None,
sandbox_enabled: false,
}
}
fn stub_agent(name: &str) -> AgentInfo {
let n = name.to_string();
AgentInfo {
name: n.clone(),
role: "Tester".to_string(),
expertise: "testing".to_string(),
url: String::new(),
executor: Arc::new(move |task, _ctx| {
let n = n.clone();
Box::pin(async move { Ok(format!("{} handled: {}", n, task)) })
}),
}
}
#[tokio::test]
async fn delegate_work_success() {
let tool = DelegateWorkTool::new(vec![stub_agent("Alice")]);
let ctx = make_ctx();
let result = tool
.execute(json!({"coworker": "Alice", "task": "write tests", "context": "Rust project"}), &ctx)
.await
.unwrap();
assert!(result.contains("Alice"));
assert!(result.contains("write tests"));
}
#[tokio::test]
async fn delegate_work_unknown_agent() {
let tool = DelegateWorkTool::new(vec![stub_agent("Alice")]);
let ctx = make_ctx();
let result = tool
.execute(json!({"coworker": "Bob", "task": "something"}), &ctx)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("Unknown coworker"));
}
#[tokio::test]
async fn ask_question_success() {
let tool = AskQuestionTool::new(vec![stub_agent("Bob")]);
let ctx = make_ctx();
let result = tool
.execute(json!({"coworker": "Bob", "question": "what is 2+2?"}), &ctx)
.await
.unwrap();
assert!(result.contains("Bob"));
}
#[tokio::test]
async fn tool_config_max_usage() {
use crate::registry::{Tool, ToolContext};
struct EchoTool;
#[async_trait]
impl Tool for EchoTool {
fn name(&self) -> &str { "echo" }
fn description(&self) -> &str { "echo" }
fn parameters(&self) -> Value { json!({}) }
async fn execute(&self, _args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
Ok("ok".to_string())
}
}
let wrapped = ToolConfig::new(Arc::new(EchoTool)).with_max_usage(2);
let ctx = make_ctx();
assert!(wrapped.execute(json!({}), &ctx).await.is_ok()); assert!(wrapped.execute(json!({}), &ctx).await.is_ok()); assert!(wrapped.execute(json!({}), &ctx).await.is_err()); }
#[tokio::test]
async fn tool_config_result_as_answer() {
use crate::registry::{Tool, ToolContext};
struct EchoTool;
#[async_trait]
impl Tool for EchoTool {
fn name(&self) -> &str { "echo" }
fn description(&self) -> &str { "echo" }
fn parameters(&self) -> Value { json!({}) }
async fn execute(&self, _args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
Ok("42".to_string())
}
}
let wrapped = ToolConfig::new(Arc::new(EchoTool)).with_result_as_answer();
let ctx = make_ctx();
let out = wrapped.execute(json!({}), &ctx).await.unwrap();
assert!(out.starts_with("[FINAL_ANSWER]"));
assert!(out.contains("42"));
}
#[tokio::test]
async fn guardrail_passes() {
let g = FnGuardrail::new(3, |output| {
if output.contains("good") {
GuardrailResult::Pass
} else {
GuardrailResult::Fail { reason: "missing 'good'".to_string() }
}
});
let mut attempts = 0u32;
let result = execute_with_guardrail(&g, |_feedback| {
attempts += 1;
async move { Ok(if attempts >= 2 { "good output".to_string() } else { "bad".to_string() }) }
})
.await;
assert!(result.is_ok());
assert!(result.unwrap().contains("good"));
}
#[tokio::test]
async fn guardrail_exhausted() {
let g = FnGuardrail::new(2, |_| GuardrailResult::Fail { reason: "always fails".to_string() });
let result = execute_with_guardrail(&g, |_| async move { Ok("bad".to_string()) }).await;
assert!(result.is_err());
}
#[test]
fn conditional_task_skip() {
let task = ConditionalTask::new(
"deploy",
"Deploy to production",
|output| output.contains("tests passed"),
);
assert!(!task.should_execute("tests failed"));
assert!(task.should_execute("all tests passed successfully"));
}
#[test]
fn guardrail_chain() {
let chain = GuardrailChain::new()
.push(FnGuardrail::new(0, |o| {
if o.len() > 5 { GuardrailResult::Pass } else { GuardrailResult::Fail { reason: "too short".into() } }
}))
.push(FnGuardrail::new(0, |o| {
if o.contains("ok") { GuardrailResult::Pass } else { GuardrailResult::Fail { reason: "missing ok".into() } }
}));
assert!(matches!(chain.validate("this is ok"), GuardrailResult::Pass));
assert!(matches!(chain.validate("nope"), GuardrailResult::Fail { .. }));
assert!(matches!(chain.validate("this is bad"), GuardrailResult::Fail { .. }));
}
}