Skip to main content

aegis_tools/
delegation.rs

1/// Multi-agent delegation and collaboration tools.
2///
3/// Implements:
4/// - DelegateWorkTool  (#1): Agent discovers and delegates to coworkers via tool description
5/// - AskQuestionTool   (#2): Ask a coworker a question without full delegation
6/// - ToolConfig        (#12, #13): max_usage_count + result_as_answer
7/// - Guardrail         (#14, #15): validate task output with retry
8/// - ConditionalTask   (#11): execute task only if condition on previous output passes
9use crate::registry::{Tool, ToolContext};
10use aegis_a2a::client::A2AClient;
11use aegis_a2a::types::{Message, MessageRole, Part, TaskGetParams, TaskSendParams};
12use anyhow::{anyhow, Result};
13use async_trait::async_trait;
14use serde_json::{json, Value};
15use std::sync::{Arc, Mutex};
16
17/// Maximum A2A delegation hop count before a task is refused (loop protection).
18pub const MAX_A2A_HOPS: u64 = 4;
19
20/// True if `url` points at this process's own A2A endpoint (set via
21/// `AEGIS_A2A_SELF` by `aegis a2a`). Prevents the same-machine self-delegation
22/// loop where a peer's URL is the very server executing the task.
23pub fn is_self_url(url: &str) -> bool {
24    let norm = |u: &str| u.trim().trim_end_matches('/').to_ascii_lowercase();
25    std::env::var("AEGIS_A2A_SELF")
26        .ok()
27        .map(|s| norm(&s) == norm(url))
28        .unwrap_or(false)
29}
30
31/// Current delegation depth of the task this process is executing (set by the
32/// A2A server from the incoming task's `aegis_hops`); 0 for a top-level CLI.
33fn current_hops() -> u64 {
34    std::env::var("AEGIS_A2A_DEPTH")
35        .ok()
36        .and_then(|s| s.parse().ok())
37        .unwrap_or(0)
38}
39
40/// Metadata to attach when submitting a delegated task: the next hop count.
41fn next_hops_metadata() -> Option<std::collections::HashMap<String, Value>> {
42    let mut m = std::collections::HashMap::new();
43    m.insert("aegis_hops".to_string(), json!(current_hops() + 1));
44    Some(m)
45}
46
47/// Refusal message if delegating to `url` would loop (self) or exceed the hop
48/// cap; `None` means it's safe to proceed.
49fn delegation_block_reason(url: &str) -> Option<String> {
50    if is_self_url(url) {
51        return Some(format!(
52            "Refused: '{url}' is this agent's own A2A endpoint — delegating to self would loop."
53        ));
54    }
55    if current_hops() + 1 > MAX_A2A_HOPS {
56        return Some(format!(
57            "Refused: A2A delegation hop limit ({MAX_A2A_HOPS}) reached — aborting to avoid a delegation loop."
58        ));
59    }
60    None
61}
62
63// ═══════════════════════════════════════════
64// AgentInfo — lightweight description of a peer agent
65// ═══════════════════════════════════════════
66
67/// Minimal description of a peer agent, used for discovery.
68pub struct AgentInfo {
69    pub name: String,
70    pub role: String,
71    pub expertise: String,
72    /// A2A endpoint URL. If non-empty, delegation uses A2AClient.
73    pub url: String,
74    /// Callback that the delegation tool will invoke to execute the task.
75    /// Returns the agent's response as a String.
76    pub executor: Arc<dyn Fn(String, String) -> futures::future::BoxFuture<'static, Result<String>> + Send + Sync>,
77}
78
79impl AgentInfo {
80    /// Create a new AgentInfo with a simple async executor closure.
81    pub fn new(
82        name: impl Into<String>,
83        role: impl Into<String>,
84        expertise: impl Into<String>,
85        executor: Arc<dyn Fn(String, String) -> futures::future::BoxFuture<'static, Result<String>> + Send + Sync>,
86    ) -> Self {
87        Self {
88            name: name.into(),
89            role: role.into(),
90            expertise: expertise.into(),
91            url: String::new(),
92            executor,
93        }
94    }
95}
96
97// ═══════════════════════════════════════════
98// DelegateWorkTool (#1)
99// ═══════════════════════════════════════════
100
101/// Delegate a subtask to a coworker agent, discovered by name.
102/// The calling agent does not hold a direct reference to its peers;
103/// it discovers them through the tool description (decoupling).
104pub struct DelegateWorkTool {
105    pub available_agents: Vec<AgentInfo>,
106}
107
108impl DelegateWorkTool {
109    /// Create a new `DelegateWorkTool` with the given list of available agents.
110    pub fn new(agents: Vec<AgentInfo>) -> Self {
111        Self { available_agents: agents }
112    }
113
114    fn find_agent(&self, name: &str) -> Option<&AgentInfo> {
115        self.available_agents
116            .iter()
117            .find(|a| a.name.eq_ignore_ascii_case(name))
118    }
119
120    fn agent_list(&self) -> String {
121        let mut lines: Vec<String> = self
122            .available_agents
123            .iter()
124            .map(|a| format!("- {} ({}): {}", a.name, a.role, a.expertise))
125            .collect();
126        // Merge dynamically-registered peers (peer tool / peers.json).
127        for p in crate::peers::list() {
128            if !self.available_agents.iter().any(|a| a.name.eq_ignore_ascii_case(&p.name)) {
129                lines.push(format!("- {} ({}): {}", p.name, p.role, p.expertise));
130            }
131        }
132        lines.join("\n")
133    }
134
135    /// Poll a previously submitted task by ID.
136    pub async fn poll_task(task_id: &str, agent_url: &str) -> Result<String> {
137        let client = A2AClient::new(agent_url);
138        let task = client
139            .get(TaskGetParams {
140                id: task_id.to_string(),
141                history_length: None,
142            })
143            .await?;
144        Ok(format!(
145            "Task {} — state: {:?}",
146            task.id, task.status.state
147        ))
148    }
149}
150
151#[async_trait]
152impl Tool for DelegateWorkTool {
153    fn name(&self) -> &str {
154        "delegate_work"
155    }
156
157    fn description(&self) -> &str {
158        "Delegate a task to a coworker agent. \
159         Specify: coworker name, task description, and context. \
160         Available coworkers are listed in the parameters schema."
161    }
162
163    fn parameters(&self) -> Value {
164        let agent_names: Vec<Value> = {
165            let mut names: Vec<Value> = self
166                .available_agents
167                .iter()
168                .map(|a| Value::String(a.name.clone()))
169                .collect();
170            for p in crate::peers::list() {
171                if !self.available_agents.iter().any(|a| a.name.eq_ignore_ascii_case(&p.name)) {
172                    names.push(Value::String(p.name.clone()));
173                }
174            }
175            names
176        };
177        let agent_roster = self.agent_list();
178        json!({
179            "type": "object",
180            "properties": {
181                "coworker": {
182                    "type": "string",
183                    "description": format!("Name of the coworker to delegate to. Available:\n{}", agent_roster),
184                    "enum": agent_names
185                },
186                "task": {
187                    "type": "string",
188                    "description": "Detailed description of the task to delegate"
189                },
190                "context": {
191                    "type": "string",
192                    "description": "Background context the coworker needs to complete the task"
193                }
194            },
195            "required": ["coworker", "task"]
196        })
197    }
198
199    async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
200        let coworker = args["coworker"]
201            .as_str()
202            .ok_or_else(|| anyhow!("'coworker' is required"))?;
203        let task_desc = args["task"]
204            .as_str()
205            .ok_or_else(|| anyhow!("'task' is required"))?;
206        let context = args["context"].as_str().unwrap_or("");
207
208        let agent = self.find_agent(coworker);
209
210        // Static roster agent (may have a local executor) — original path.
211        if let Some(agent) = agent {
212            if !agent.url.is_empty() {
213                if let Some(reason) = delegation_block_reason(&agent.url) {
214                    return Ok(reason);
215                }
216                let client = A2AClient::new(&agent.url);
217                let text = if context.is_empty() {
218                    task_desc.to_string()
219                } else {
220                    format!("Context: {}\n\nTask: {}", context, task_desc)
221                };
222                let params = TaskSendParams {
223                    id: None,
224                    message: None,
225                    messages: vec![Message {
226                        role: MessageRole::User,
227                        parts: vec![Part::Text { text }],
228                        kind: "message".into(),
229                        message_id: None,
230                        context_id: None,
231                        task_id: None,
232                        metadata: None,
233                    }],
234                    metadata: next_hops_metadata(),
235                    session_id: None,
236                };
237                match client.submit(params).await {
238                    Ok(task) => Ok(format!(
239                        "[{} delegated via A2A] task_id={} state={:?}",
240                        agent.name, task.id, task.status.state
241                    )),
242                    Err(e) => {
243                        tracing::warn!(
244                            "A2A delegation to {} failed ({}), falling back to executor",
245                            agent.name,
246                            e
247                        );
248                        let result = (agent.executor)(task_desc.to_string(), context.to_string()).await?;
249                        Ok(format!("[{} completed task]\n{}", agent.name, result))
250                    }
251                }
252            } else {
253                let result = (agent.executor)(task_desc.to_string(), context.to_string()).await?;
254                Ok(format!("[{} completed task]\n{}", agent.name, result))
255            }
256        } else if let Some(peer) = crate::peers::get(coworker) {
257            // Dynamically-registered peer (peers.json) — A2A only.
258            if let Some(reason) = delegation_block_reason(&peer.url) {
259                return Ok(reason);
260            }
261            let mut client = A2AClient::new(&peer.url);
262            if let Some(t) = &peer.token {
263                client = client.with_bearer_token(t.clone());
264            }
265            let text = if context.is_empty() {
266                task_desc.to_string()
267            } else {
268                format!("Context: {}\n\nTask: {}", context, task_desc)
269            };
270            let params = TaskSendParams {
271                id: None,
272                message: None,
273                messages: vec![Message {
274                    role: MessageRole::User,
275                    parts: vec![Part::Text { text }],
276                    kind: "message".into(),
277                    message_id: None,
278                    context_id: None,
279                    task_id: None,
280                    metadata: None,
281                }],
282                metadata: next_hops_metadata(),
283                session_id: None,
284            };
285            match client.submit(params).await {
286                Ok(task) => Ok(format!(
287                    "[{} delegated via A2A] task_id={} state={:?}",
288                    peer.name, task.id, task.status.state
289                )),
290                Err(e) => Ok(format!("[{}] A2A delegation failed: {e}", peer.name)),
291            }
292        } else {
293            Err(anyhow!(
294                "Unknown coworker '{coworker}'. Available:\n{}",
295                self.agent_list()
296            ))
297        }
298    }
299}
300
301// ═══════════════════════════════════════════
302// AskQuestionTool (#2)
303// ═══════════════════════════════════════════
304
305/// Ask a coworker a specific question and get their answer.
306/// Unlike DelegateWorkTool, the calling agent retains control and
307/// uses the answer to inform its own decision.
308pub struct AskQuestionTool {
309    pub available_agents: Vec<AgentInfo>,
310}
311
312impl AskQuestionTool {
313    /// Create a new `AskQuestionTool` with the given list of available agents.
314    pub fn new(agents: Vec<AgentInfo>) -> Self {
315        Self { available_agents: agents }
316    }
317
318    fn find_agent(&self, name: &str) -> Option<&AgentInfo> {
319        self.available_agents
320            .iter()
321            .find(|a| a.name.eq_ignore_ascii_case(name))
322    }
323
324    fn agent_list(&self) -> String {
325        let mut lines: Vec<String> = self
326            .available_agents
327            .iter()
328            .map(|a| format!("- {} ({}): {}", a.name, a.role, a.expertise))
329            .collect();
330        // Merge dynamically-registered peers (peer tool / peers.json).
331        for p in crate::peers::list() {
332            if !self.available_agents.iter().any(|a| a.name.eq_ignore_ascii_case(&p.name)) {
333                lines.push(format!("- {} ({}): {}", p.name, p.role, p.expertise));
334            }
335        }
336        lines.join("\n")
337    }
338}
339
340#[async_trait]
341impl Tool for AskQuestionTool {
342    fn name(&self) -> &str {
343        "ask_question"
344    }
345
346    fn description(&self) -> &str {
347        "Ask a coworker agent a specific question to gather information. \
348         Unlike delegate_work, you retain control and will use the answer yourself. \
349         Use this when you need a peer's expertise on a narrow question."
350    }
351
352    fn parameters(&self) -> Value {
353        let agent_names: Vec<Value> = {
354            let mut names: Vec<Value> = self
355                .available_agents
356                .iter()
357                .map(|a| Value::String(a.name.clone()))
358                .collect();
359            for p in crate::peers::list() {
360                if !self.available_agents.iter().any(|a| a.name.eq_ignore_ascii_case(&p.name)) {
361                    names.push(Value::String(p.name.clone()));
362                }
363            }
364            names
365        };
366        let agent_roster = self.agent_list();
367        json!({
368            "type": "object",
369            "properties": {
370                "coworker": {
371                    "type": "string",
372                    "description": format!("Name of the coworker to ask. Available:\n{}", agent_roster),
373                    "enum": agent_names
374                },
375                "question": {
376                    "type": "string",
377                    "description": "The specific question you want answered"
378                },
379                "context": {
380                    "type": "string",
381                    "description": "Background context that helps the coworker understand the question"
382                }
383            },
384            "required": ["coworker", "question"]
385        })
386    }
387
388    async fn execute(&self, args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
389        let coworker = args["coworker"]
390            .as_str()
391            .ok_or_else(|| anyhow!("'coworker' is required"))?;
392        let question = args["question"]
393            .as_str()
394            .ok_or_else(|| anyhow!("'question' is required"))?;
395        let context = args["context"].as_str().unwrap_or("");
396
397        // Dynamically-registered peer (peers.json): use non-streaming submit
398        // (our A2A server doesn't stream) and return the completed answer.
399        if self.find_agent(coworker).is_none() {
400            if let Some(peer) = crate::peers::get(coworker) {
401                if let Some(reason) = delegation_block_reason(&peer.url) {
402                    return Ok(reason);
403                }
404                let mut client = A2AClient::new(&peer.url);
405                if let Some(t) = &peer.token {
406                    client = client.with_bearer_token(t.clone());
407                }
408                let text = if context.is_empty() {
409                    format!("Please answer this question concisely: {question}")
410                } else {
411                    format!("Context: {context}\n\nPlease answer this question concisely: {question}")
412                };
413                let params = TaskSendParams {
414                    id: None,
415                    message: None,
416                    messages: vec![Message {
417                        role: MessageRole::User,
418                        parts: vec![Part::Text { text }],
419                        kind: "message".into(),
420                        message_id: None,
421                        context_id: None,
422                        task_id: None,
423                        metadata: None,
424                    }],
425                    metadata: next_hops_metadata(),
426                    session_id: None,
427                };
428                return match client.submit(params).await {
429                    Ok(task) => {
430                        let ans = task
431                            .status
432                            .message
433                            .and_then(|m| {
434                                m.parts.into_iter().find_map(|p| match p {
435                                    Part::Text { text } => Some(text),
436                                    _ => None,
437                                })
438                            })
439                            .unwrap_or_else(|| format!("(submitted, state={:?})", task.status.state));
440                        Ok(format!("[{} answers]\n{}", peer.name, ans))
441                    }
442                    Err(e) => Ok(format!("[{}] A2A ask failed: {e}", peer.name)),
443                };
444            }
445        }
446
447        let agent = self
448            .find_agent(coworker)
449            .ok_or_else(|| anyhow!("Unknown coworker '{coworker}'. Available: {}", self.agent_list()))?;
450
451        // If agent has a URL, use A2AClient streaming; otherwise fall back to executor
452        if !agent.url.is_empty() {
453            if let Some(reason) = delegation_block_reason(&agent.url) {
454                return Ok(reason);
455            }
456            let client = A2AClient::new(&agent.url);
457            let text = if context.is_empty() {
458                format!("Please answer this question concisely: {}", question)
459            } else {
460                format!(
461                    "Context: {}\n\nPlease answer this question concisely: {}",
462                    context, question
463                )
464            };
465            let params = TaskSendParams {
466                id: None,
467                message: None,
468                messages: vec![Message {
469                    role: MessageRole::User,
470                    parts: vec![Part::Text { text }],
471                    kind: "message".into(),
472                    message_id: None,
473                    context_id: None,
474                    task_id: None,
475                    metadata: None,
476                }],
477                metadata: next_hops_metadata(),
478                session_id: None,
479            };
480            match client.subscribe(params).await {
481                Ok(mut stream) => {
482                    use futures::StreamExt;
483                    // Wait for the first event that carries text
484                    while let Some(event_result) = stream.next().await {
485                        match event_result {
486                            Ok(aegis_a2a::types::TaskEvent::StatusUpdate(ev)) => {
487                                if let Some(msg) = &ev.status.message {
488                                    for part in &msg.parts {
489                                        if let Part::Text { text } = part {
490                                            return Ok(format!("[{} answers]\n{}", agent.name, text));
491                                        }
492                                    }
493                                }
494                            }
495                            Ok(aegis_a2a::types::TaskEvent::ArtifactUpdate(ev)) => {
496                                for part in &ev.artifact.parts {
497                                    if let Part::Text { text } = part {
498                                        return Ok(format!("[{} answers]\n{}", agent.name, text));
499                                    }
500                                }
501                            }
502                            Err(e) => {
503                                tracing::warn!(
504                                    "A2A stream from {} failed ({}), falling back to executor",
505                                    agent.name,
506                                    e
507                                );
508                                break;
509                            }
510                        }
511                    }
512                    // Fallback if stream ended without text
513                    let prompt = if context.is_empty() {
514                        format!("Please answer this question concisely: {}", question)
515                    } else {
516                        format!(
517                            "Context: {}\n\nPlease answer this question concisely: {}",
518                            context, question
519                        )
520                    };
521                    let answer = (agent.executor)(prompt, String::new()).await?;
522                    Ok(format!("[{} answers]\n{}", agent.name, answer))
523                }
524                Err(e) => {
525                    tracing::warn!(
526                        "A2A subscribe to {} failed ({}), falling back to executor",
527                        agent.name,
528                        e
529                    );
530                    let prompt = if context.is_empty() {
531                        format!("Please answer this question concisely: {}", question)
532                    } else {
533                        format!(
534                            "Context: {}\n\nPlease answer this question concisely: {}",
535                            context, question
536                        )
537                    };
538                    let answer = (agent.executor)(prompt, String::new()).await?;
539                    Ok(format!("[{} answers]\n{}", agent.name, answer))
540                }
541            }
542        } else {
543            // No URL — use executor directly (fallback / simulation)
544            let prompt = if context.is_empty() {
545                format!("Please answer this question concisely: {}", question)
546            } else {
547                format!(
548                    "Context: {}\n\nPlease answer this question concisely: {}",
549                    context, question
550                )
551            };
552            let answer = (agent.executor)(prompt, String::new()).await?;
553            Ok(format!("[{} answers]\n{}", agent.name, answer))
554        }
555    }
556}
557
558// ═══════════════════════════════════════════
559// ToolConfig (#12, #13)
560// ═══════════════════════════════════════════
561
562/// Configuration that wraps a Tool to add usage limits and short-circuit behaviour.
563pub struct ToolConfig {
564    inner: Arc<dyn Tool>,
565    /// If Some(n), the tool is disabled after n successful calls.
566    pub max_usage_count: Option<u32>,
567    /// If true, the tool's output is used directly as the final answer,
568    /// bypassing further LLM reasoning in the agent loop.
569    pub result_as_answer: bool,
570    usage_count: Mutex<u32>,
571}
572
573impl ToolConfig {
574    /// Wrap a tool with default configuration (no usage limit, no result-as-answer).
575    pub fn new(tool: Arc<dyn Tool>) -> Self {
576        Self {
577            inner: tool,
578            max_usage_count: None,
579            result_as_answer: false,
580            usage_count: Mutex::new(0),
581        }
582    }
583
584    /// Set the maximum number of times this tool may be called before it is disabled.
585    pub fn with_max_usage(mut self, n: u32) -> Self {
586        self.max_usage_count = Some(n);
587        self
588    }
589
590    /// Mark this tool so that its output is treated as the final agent answer.
591    pub fn with_result_as_answer(mut self) -> Self {
592        self.result_as_answer = true;
593        self
594    }
595
596    /// Returns true when the tool has hit its usage cap.
597    pub fn is_exhausted(&self) -> bool {
598        match self.max_usage_count {
599            None => false,
600            Some(limit) => {
601                let count = self.usage_count.lock().expect("lock poisoned");
602                *count >= limit
603            }
604        }
605    }
606}
607
608#[async_trait]
609impl Tool for ToolConfig {
610    fn name(&self) -> &str {
611        self.inner.name()
612    }
613
614    fn description(&self) -> &str {
615        self.inner.description()
616    }
617
618    fn parameters(&self) -> Value {
619        self.inner.parameters()
620    }
621
622    async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
623        if self.is_exhausted() {
624            return Err(anyhow!(
625                "Tool '{}' has reached its maximum usage count of {}",
626                self.inner.name(),
627                self.max_usage_count.unwrap_or(0)
628            ));
629        }
630
631        let result = self.inner.execute(args, ctx).await?;
632
633        {
634            let mut count = self.usage_count.lock().expect("lock poisoned");
635            *count += 1;
636        }
637
638        // Prefix so the agent loop can detect result_as_answer
639        if self.result_as_answer {
640            Ok(format!("[FINAL_ANSWER]\n{}", result))
641        } else {
642            Ok(result)
643        }
644    }
645}
646
647// ═══════════════════════════════════════════
648// Guardrail (#14, #15)
649// ═══════════════════════════════════════════
650
651/// Outcome of a guardrail validation.
652#[derive(Debug)]
653pub enum GuardrailResult {
654    Pass,
655    Fail { reason: String },
656}
657
658/// A single validator in a guardrail chain.
659pub trait GuardrailValidator: Send + Sync {
660    fn validate(&self, output: &str) -> GuardrailResult;
661}
662
663/// A guardrail that wraps a validator closure.
664pub struct FnGuardrail {
665    validator: Box<dyn Fn(&str) -> GuardrailResult + Send + Sync>,
666    pub max_retries: u32,
667}
668
669impl FnGuardrail {
670    /// Create a new `FnGuardrail` with the given retry limit and validator closure.
671    pub fn new(
672        max_retries: u32,
673        validator: impl Fn(&str) -> GuardrailResult + Send + Sync + 'static,
674    ) -> Self {
675        Self {
676            validator: Box::new(validator),
677            max_retries,
678        }
679    }
680}
681
682impl GuardrailValidator for FnGuardrail {
683    fn validate(&self, output: &str) -> GuardrailResult {
684        (self.validator)(output)
685    }
686}
687
688/// Execute an async task producer with guardrail retry logic.
689///
690/// `produce` is called on each attempt; it receives optional feedback from previous
691/// failed attempts so the agent can correct itself.
692pub async fn execute_with_guardrail<F, Fut>(
693    guardrail: &FnGuardrail,
694    mut produce: F,
695) -> Result<String>
696where
697    F: FnMut(Option<String>) -> Fut,
698    Fut: std::future::Future<Output = Result<String>>,
699{
700    let mut feedback: Option<String> = None;
701    for attempt in 0..=guardrail.max_retries {
702        let output = produce(feedback.clone()).await?;
703        match (guardrail.validator)(&output) {
704            GuardrailResult::Pass => return Ok(output),
705            GuardrailResult::Fail { reason } => {
706                if attempt == guardrail.max_retries {
707                    return Err(anyhow!(
708                        "Guardrail failed after {} retries. Last reason: {}",
709                        guardrail.max_retries,
710                        reason
711                    ));
712                }
713                feedback = Some(format!(
714                    "Attempt {} failed guardrail check: {}. Please correct and try again.",
715                    attempt + 1,
716                    reason
717                ));
718            }
719        }
720    }
721    Err(anyhow!("Guardrail: exhausted retries"))
722}
723
724/// Chain multiple guardrail validators — all must pass.
725pub struct GuardrailChain {
726    validators: Vec<Box<dyn GuardrailValidator>>,
727}
728
729impl Default for GuardrailChain {
730    fn default() -> Self {
731        Self::new()
732    }
733}
734
735impl GuardrailChain {
736    /// Create an empty guardrail chain.
737    pub fn new() -> Self {
738        Self { validators: Vec::new() }
739    }
740
741    /// Append a validator to the chain. All validators must pass for the chain to pass.
742    pub fn push(mut self, v: impl GuardrailValidator + 'static) -> Self {
743        self.validators.push(Box::new(v));
744        self
745    }
746}
747
748impl GuardrailValidator for GuardrailChain {
749    fn validate(&self, output: &str) -> GuardrailResult {
750        for v in &self.validators {
751            match v.validate(output) {
752                GuardrailResult::Pass => continue,
753                fail => return fail,
754            }
755        }
756        GuardrailResult::Pass
757    }
758}
759
760// ═══════════════════════════════════════════
761// ConditionalTask (#11)
762// ═══════════════════════════════════════════
763
764/// Wraps a task description with a condition that is evaluated against
765/// the previous task's output. If the condition returns false, the task
766/// is skipped entirely.
767pub struct ConditionalTask {
768    pub name: String,
769    pub description: String,
770    pub condition: Box<dyn Fn(&str) -> bool + Send + Sync>,
771}
772
773impl ConditionalTask {
774    /// Create a new conditional task that only executes when `condition` returns true
775    /// on the previous task's output.
776    pub fn new(
777        name: impl Into<String>,
778        description: impl Into<String>,
779        condition: impl Fn(&str) -> bool + Send + Sync + 'static,
780    ) -> Self {
781        Self {
782            name: name.into(),
783            description: description.into(),
784            condition: Box::new(condition),
785        }
786    }
787
788    /// Returns true if this task should be executed given `previous_output`.
789    pub fn should_execute(&self, previous_output: &str) -> bool {
790        (self.condition)(previous_output)
791    }
792}
793
794// ═══════════════════════════════════════════
795// Tests
796// ═══════════════════════════════════════════
797
798#[cfg(test)]
799mod tests {
800    use super::*;
801    use std::path::PathBuf;
802
803    fn make_ctx() -> ToolContext<'static> {
804        ToolContext {
805            cwd: PathBuf::from("/tmp"),
806            session_id: "test".to_string(),
807            approve_fn: &|_| true,
808            yolo: true,
809            identity: None,
810            sandbox_enabled: false,
811        }
812    }
813
814    fn stub_agent(name: &str) -> AgentInfo {
815        let n = name.to_string();
816        AgentInfo {
817            name: n.clone(),
818            role: "Tester".to_string(),
819            expertise: "testing".to_string(),
820            url: String::new(),
821            executor: Arc::new(move |task, _ctx| {
822                let n = n.clone();
823                Box::pin(async move { Ok(format!("{} handled: {}", n, task)) })
824            }),
825        }
826    }
827
828    #[tokio::test]
829    async fn delegate_work_success() {
830        let tool = DelegateWorkTool::new(vec![stub_agent("Alice")]);
831        let ctx = make_ctx();
832        let result = tool
833            .execute(json!({"coworker": "Alice", "task": "write tests", "context": "Rust project"}), &ctx)
834            .await
835            .unwrap();
836        assert!(result.contains("Alice"));
837        assert!(result.contains("write tests"));
838    }
839
840    #[tokio::test]
841    async fn delegate_work_unknown_agent() {
842        let tool = DelegateWorkTool::new(vec![stub_agent("Alice")]);
843        let ctx = make_ctx();
844        let result = tool
845            .execute(json!({"coworker": "Bob", "task": "something"}), &ctx)
846            .await;
847        assert!(result.is_err());
848        assert!(result.unwrap_err().to_string().contains("Unknown coworker"));
849    }
850
851    #[tokio::test]
852    async fn ask_question_success() {
853        let tool = AskQuestionTool::new(vec![stub_agent("Bob")]);
854        let ctx = make_ctx();
855        let result = tool
856            .execute(json!({"coworker": "Bob", "question": "what is 2+2?"}), &ctx)
857            .await
858            .unwrap();
859        assert!(result.contains("Bob"));
860    }
861
862    #[tokio::test]
863    async fn tool_config_max_usage() {
864        use crate::registry::{Tool, ToolContext};
865
866        struct EchoTool;
867        #[async_trait]
868        impl Tool for EchoTool {
869            fn name(&self) -> &str { "echo" }
870            fn description(&self) -> &str { "echo" }
871            fn parameters(&self) -> Value { json!({}) }
872            async fn execute(&self, _args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
873                Ok("ok".to_string())
874            }
875        }
876
877        let wrapped = ToolConfig::new(Arc::new(EchoTool)).with_max_usage(2);
878        let ctx = make_ctx();
879        assert!(wrapped.execute(json!({}), &ctx).await.is_ok()); // use 1
880        assert!(wrapped.execute(json!({}), &ctx).await.is_ok()); // use 2
881        assert!(wrapped.execute(json!({}), &ctx).await.is_err()); // exhausted
882    }
883
884    #[tokio::test]
885    async fn tool_config_result_as_answer() {
886        use crate::registry::{Tool, ToolContext};
887
888        struct EchoTool;
889        #[async_trait]
890        impl Tool for EchoTool {
891            fn name(&self) -> &str { "echo" }
892            fn description(&self) -> &str { "echo" }
893            fn parameters(&self) -> Value { json!({}) }
894            async fn execute(&self, _args: Value, _ctx: &ToolContext<'_>) -> Result<String> {
895                Ok("42".to_string())
896            }
897        }
898
899        let wrapped = ToolConfig::new(Arc::new(EchoTool)).with_result_as_answer();
900        let ctx = make_ctx();
901        let out = wrapped.execute(json!({}), &ctx).await.unwrap();
902        assert!(out.starts_with("[FINAL_ANSWER]"));
903        assert!(out.contains("42"));
904    }
905
906    #[tokio::test]
907    async fn guardrail_passes() {
908        let g = FnGuardrail::new(3, |output| {
909            if output.contains("good") {
910                GuardrailResult::Pass
911            } else {
912                GuardrailResult::Fail { reason: "missing 'good'".to_string() }
913            }
914        });
915        let mut attempts = 0u32;
916        let result = execute_with_guardrail(&g, |_feedback| {
917            attempts += 1;
918            async move { Ok(if attempts >= 2 { "good output".to_string() } else { "bad".to_string() }) }
919        })
920        .await;
921        assert!(result.is_ok());
922        assert!(result.unwrap().contains("good"));
923    }
924
925    #[tokio::test]
926    async fn guardrail_exhausted() {
927        let g = FnGuardrail::new(2, |_| GuardrailResult::Fail { reason: "always fails".to_string() });
928        let result = execute_with_guardrail(&g, |_| async move { Ok("bad".to_string()) }).await;
929        assert!(result.is_err());
930    }
931
932    #[test]
933    fn conditional_task_skip() {
934        let task = ConditionalTask::new(
935            "deploy",
936            "Deploy to production",
937            |output| output.contains("tests passed"),
938        );
939        assert!(!task.should_execute("tests failed"));
940        assert!(task.should_execute("all tests passed successfully"));
941    }
942
943    #[test]
944    fn guardrail_chain() {
945        let chain = GuardrailChain::new()
946            .push(FnGuardrail::new(0, |o| {
947                if o.len() > 5 { GuardrailResult::Pass } else { GuardrailResult::Fail { reason: "too short".into() } }
948            }))
949            .push(FnGuardrail::new(0, |o| {
950                if o.contains("ok") { GuardrailResult::Pass } else { GuardrailResult::Fail { reason: "missing ok".into() } }
951            }));
952        assert!(matches!(chain.validate("this is ok"), GuardrailResult::Pass));
953        assert!(matches!(chain.validate("nope"), GuardrailResult::Fail { .. }));
954        assert!(matches!(chain.validate("this is bad"), GuardrailResult::Fail { .. }));
955    }
956}