1use 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
17pub const MAX_A2A_HOPS: u64 = 4;
19
20pub 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
31fn 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
40fn 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
47fn 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
63pub struct AgentInfo {
69 pub name: String,
70 pub role: String,
71 pub expertise: String,
72 pub url: String,
74 pub executor: Arc<dyn Fn(String, String) -> futures::future::BoxFuture<'static, Result<String>> + Send + Sync>,
77}
78
79impl AgentInfo {
80 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
97pub struct DelegateWorkTool {
105 pub available_agents: Vec<AgentInfo>,
106}
107
108impl DelegateWorkTool {
109 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 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 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 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 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
301pub struct AskQuestionTool {
309 pub available_agents: Vec<AgentInfo>,
310}
311
312impl AskQuestionTool {
313 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 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 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.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 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 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 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
558pub struct ToolConfig {
564 inner: Arc<dyn Tool>,
565 pub max_usage_count: Option<u32>,
567 pub result_as_answer: bool,
570 usage_count: Mutex<u32>,
571}
572
573impl ToolConfig {
574 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 pub fn with_max_usage(mut self, n: u32) -> Self {
586 self.max_usage_count = Some(n);
587 self
588 }
589
590 pub fn with_result_as_answer(mut self) -> Self {
592 self.result_as_answer = true;
593 self
594 }
595
596 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 if self.result_as_answer {
640 Ok(format!("[FINAL_ANSWER]\n{}", result))
641 } else {
642 Ok(result)
643 }
644 }
645}
646
647#[derive(Debug)]
653pub enum GuardrailResult {
654 Pass,
655 Fail { reason: String },
656}
657
658pub trait GuardrailValidator: Send + Sync {
660 fn validate(&self, output: &str) -> GuardrailResult;
661}
662
663pub struct FnGuardrail {
665 validator: Box<dyn Fn(&str) -> GuardrailResult + Send + Sync>,
666 pub max_retries: u32,
667}
668
669impl FnGuardrail {
670 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
688pub 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
724pub 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 pub fn new() -> Self {
738 Self { validators: Vec::new() }
739 }
740
741 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
760pub 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 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 pub fn should_execute(&self, previous_output: &str) -> bool {
790 (self.condition)(previous_output)
791 }
792}
793
794#[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()); assert!(wrapped.execute(json!({}), &ctx).await.is_ok()); assert!(wrapped.execute(json!({}), &ctx).await.is_err()); }
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}