1use async_trait::async_trait;
26
27use crate::error::AgentError;
28use crate::state::State;
29
30mod run;
31pub use run::{Chat, RunEvent, RunRequest, RunResult, ToolCallRecord};
32
33mod dispatch;
34mod fallback;
35mod fn_agent;
36mod llm;
37mod loop_agent;
38mod map_over;
39mod parallel;
40mod race;
41mod route;
42mod sequential;
43mod tap;
44mod timeout;
45
46pub use dispatch::{DispatchTextAgent, JoinTextAgent, TaskRegistry};
47pub use fallback::FallbackTextAgent;
48pub use fn_agent::FnTextAgent;
49pub use llm::LlmTextAgent;
50pub use loop_agent::LoopTextAgent;
51pub use map_over::MapOverTextAgent;
52pub use parallel::ParallelTextAgent;
53pub use race::RaceTextAgent;
54pub use route::{RouteRule, RouteTextAgent};
55pub use sequential::SequentialTextAgent;
56pub use tap::TapTextAgent;
57pub use timeout::TimeoutTextAgent;
58
59#[async_trait]
96pub trait TextAgent: Send + Sync {
97 fn name(&self) -> &str;
99
100 async fn run(&self, state: &State) -> Result<String, AgentError>;
106
107 async fn run_with(&self, request: RunRequest, state: &State) -> Result<RunResult, AgentError> {
115 state.set("input", request.input_text())?;
116 let text = self.run(state).await?;
117 Ok(RunResult {
118 messages: vec![request.input, run::model_turn(text.clone())],
119 ..RunResult::from_text(text)
120 })
121 }
122
123 fn run_stream<'a>(
131 &'a self,
132 request: RunRequest,
133 state: State,
134 ) -> futures_util::stream::BoxStream<'a, Result<RunEvent, AgentError>> {
135 use futures_util::StreamExt;
136
137 futures_util::stream::once(async move { self.run_with(request, &state).await })
138 .flat_map(|outcome| {
139 let events = match outcome {
140 Ok(result) if result.text.is_empty() => vec![Ok(RunEvent::Finished(result))],
141 Ok(result) => vec![
142 Ok(RunEvent::TextDelta(result.text.clone())),
143 Ok(RunEvent::Finished(result)),
144 ],
145 Err(e) => vec![Err(e)],
146 };
147 futures_util::stream::iter(events)
148 })
149 .boxed()
150 }
151
152 fn stream(
172 &self,
173 prompt: impl Into<String>,
174 ) -> futures_util::stream::BoxStream<'_, Result<RunEvent, AgentError>>
175 where
176 Self: Sized,
177 {
178 self.run_stream(RunRequest::new(prompt), State::new())
179 }
180
181 async fn ask(&self, prompt: impl Into<String> + Send) -> Result<String, AgentError>
183 where
184 Self: Sized,
185 {
186 Ok(self
187 .run_with(RunRequest::new(prompt), &State::new())
188 .await?
189 .text)
190 }
191
192 async fn ask_as<T>(&self, prompt: impl Into<String> + Send) -> Result<T, AgentError>
198 where
199 Self: Sized,
200 T: serde::de::DeserializeOwned + schemars::JsonSchema + Send,
201 {
202 let schema = crate::tool::wire_schema::<T>();
203 let state = State::new();
204 let first = self
205 .run_with(
206 RunRequest::new(prompt).response_schema(schema.clone()),
207 &state,
208 )
209 .await?;
210 let reason = match first.parse::<T>() {
211 Err(AgentError::InvalidOutput { reason, .. }) => reason,
212 parsed => return parsed,
213 };
214 let repair = RunRequest::new(format!(
215 "That reply could not be read as the requested JSON ({reason}). \
216 Reply again with only JSON that matches the schema."
217 ))
218 .history(first.messages)
219 .response_schema(schema);
220 self.run_with(repair, &state).await?.parse::<T>()
221 }
222
223 fn chat(&self) -> Chat<&Self>
225 where
226 Self: Sized,
227 {
228 Chat::new(self)
229 }
230}
231
232const _: () = {
234 fn _assert_object_safe(_: &dyn TextAgent) {}
235};
236
237macro_rules! forward_text_agent {
240 ($($wrapper:ty),*) => {$(
241 #[async_trait]
242 impl<A: TextAgent + ?Sized> TextAgent for $wrapper {
243 fn name(&self) -> &str {
244 (**self).name()
245 }
246
247 async fn run(&self, state: &State) -> Result<String, AgentError> {
248 (**self).run(state).await
249 }
250
251 async fn run_with(
252 &self,
253 request: RunRequest,
254 state: &State,
255 ) -> Result<RunResult, AgentError> {
256 (**self).run_with(request, state).await
257 }
258
259 fn run_stream<'a>(
260 &'a self,
261 request: RunRequest,
262 state: State,
263 ) -> futures_util::stream::BoxStream<'a, Result<RunEvent, AgentError>> {
264 (**self).run_stream(request, state)
265 }
266 }
267 )*};
268}
269
270forward_text_agent!(std::sync::Arc<A>, Box<A>, &A);
274
275#[cfg(test)]
278mod tests {
279 use super::*;
280 use crate::llm::{BaseLlm, LlmError, LlmResponse, MockLlm};
281 use gemini_genai_rs::prelude::Part;
282 use std::sync::Arc;
283 use std::time::Duration;
284
285 fn echo(prefix: &'static str) -> MockLlm {
287 MockLlm::from_fn(move |req| {
288 let input: Vec<&str> = req
289 .contents
290 .iter()
291 .flat_map(|c| &c.parts)
292 .filter_map(|p| match p {
293 Part::Text { text } => Some(text.as_str()),
294 _ => None,
295 })
296 .collect();
297 Ok(LlmResponse::from_text(format!(
298 "{prefix}{}",
299 input.join(" ")
300 )))
301 })
302 }
303
304 fn failing() -> MockLlm {
306 MockLlm::from_fn(|_| Err(LlmError::RequestFailed("intentional failure".into())))
307 }
308
309 #[test]
312 fn text_agent_is_object_safe() {
313 fn _assert(_: &dyn TextAgent) {}
314 }
315
316 #[tokio::test]
319 async fn shared_and_boxed_agents_are_agents() {
320 async fn run_it(agent: impl TextAgent) -> String {
321 agent.run(&State::new()).await.unwrap()
322 }
323 let built: Arc<dyn TextAgent> = Arc::new(LlmTextAgent::new("a", MockLlm::text("from arc")));
324 assert_eq!(built.name(), "a");
325 assert_eq!(run_it(built).await, "from arc");
326 let boxed: Box<dyn TextAgent> = Box::new(FnTextAgent::new("b", |_| Ok("from box".into())));
327 assert_eq!(run_it(boxed).await, "from box");
328 }
329
330 #[tokio::test]
333 async fn run_with_reports_messages_usage_and_tool_calls() {
334 let llm = MockLlm::script([
335 LlmResponse::tool_call("get_weather", serde_json::json!({"city": "Oslo"}))
336 .with_usage(10, 2),
337 LlmResponse::from_text("Cold.").with_usage(20, 1),
338 ]);
339 let mut dispatcher = crate::tool::ToolDispatcher::new();
340 dispatcher.register_function(Arc::new(crate::tool::SimpleTool::new(
341 "get_weather",
342 "Get weather",
343 None,
344 |_| async { Ok(serde_json::json!({"temp": -3})) },
345 )));
346 let agent = LlmTextAgent::new("weather", llm).tools(Arc::new(dispatcher));
347
348 let result = agent
349 .run_with(RunRequest::new("Weather in Oslo?"), &State::new())
350 .await
351 .unwrap();
352 assert_eq!(result.text, "Cold.");
353 assert_eq!(result.model_calls, 2);
354 assert_eq!(result.usage, crate::llm::TokenUsage::new(30, 3));
355 assert_eq!(result.tool_calls.len(), 1);
356 assert_eq!(result.tool_calls[0].name, "get_weather");
357 assert_eq!(result.tool_calls[0].outcome.as_ref().unwrap()["temp"], -3);
358 assert_eq!(result.messages.len(), 4);
360 }
361
362 #[tokio::test]
363 async fn ask_as_repairs_once_then_gives_up() {
364 #[derive(serde::Deserialize, schemars::JsonSchema, Debug)]
365 #[allow(dead_code)]
366 struct Answer {
367 value: u32,
368 }
369
370 let fixed = MockLlm::script([
371 LlmResponse::from_text("forty-two"),
372 LlmResponse::from_text(r#"{"value": 42}"#),
373 ]);
374 let agent = LlmTextAgent::new("a", fixed.clone());
375 let answer: Answer = agent.ask_as("What is 6 x 7?").await.unwrap();
376 assert_eq!(answer.value, 42);
377 let repair = fixed.last_request().unwrap();
378 assert_eq!(
379 repair.contents.len(),
380 3,
381 "the bad reply is shown to the model"
382 );
383 assert!(repair.response_json_schema.is_some());
384
385 let stubborn = MockLlm::text("no");
386 let agent = LlmTextAgent::new("b", stubborn.clone());
387 let err = agent.ask_as::<Answer>("?").await.unwrap_err();
388 assert!(matches!(err, AgentError::InvalidOutput { .. }), "{err}");
389 assert_eq!(stubborn.call_count(), 2, "one repair, not a loop");
390 }
391
392 #[tokio::test]
393 async fn any_agent_can_ask_and_chat() {
394 let upper = FnTextAgent::new("upper", |state| {
395 Ok(state
396 .get::<String>("input")
397 .unwrap_or_default()
398 .to_uppercase())
399 });
400 assert_eq!(upper.ask("hi").await.unwrap(), "HI");
401 let shared: Arc<dyn TextAgent> = Arc::new(upper);
402 let mut chat = shared.chat();
403 assert_eq!(chat.send("one").await.unwrap(), "ONE");
404 assert_eq!(chat.send("two").await.unwrap(), "TWO");
405 assert_eq!(chat.history().len(), 4);
406 }
407
408 #[tokio::test]
409 async fn a_failed_turn_is_not_added_to_the_history() {
410 let llm = MockLlm::script([LlmResponse::from_text("first")]).then_fail(LlmError::Api {
411 status: 503,
412 message: "overloaded".into(),
413 });
414 let agent = LlmTextAgent::new("a", llm);
415 let mut chat = Chat::new(&agent);
416 chat.send("1").await.unwrap();
417 let err = chat.send("2").await.unwrap_err();
418 assert!(err.as_llm().is_some_and(LlmError::is_retryable), "{err}");
419 assert_eq!(chat.history().len(), 2);
420 }
421
422 #[tokio::test]
425 async fn a_tool_call_without_tools_is_answered_not_found() {
426 let llm = MockLlm::script([
427 LlmResponse::tool_call("imaginary", serde_json::json!({})),
428 LlmResponse::from_text("Sorry, I cannot do that."),
429 ]);
430 let agent = LlmTextAgent::new("toolless", llm.clone());
431 let result = agent
432 .run_with(RunRequest::new("Use your tool."), &State::new())
433 .await
434 .unwrap();
435 assert_eq!(result.text, "Sorry, I cannot do that.");
436 assert!(matches!(
437 result.tool_calls[0].outcome,
438 Err(crate::error::ToolError::NotFound(_))
439 ));
440 }
441
442 async fn collect(
445 mut events: futures_util::stream::BoxStream<'_, Result<RunEvent, AgentError>>,
446 ) -> Vec<RunEvent> {
447 use futures_util::StreamExt;
448 let mut out = Vec::new();
449 while let Some(event) = events.next().await {
450 out.push(event.unwrap());
451 }
452 out
453 }
454
455 fn deltas(events: &[RunEvent]) -> String {
456 events
457 .iter()
458 .filter_map(|e| match e {
459 RunEvent::TextDelta(t) => Some(t.as_str()),
460 _ => None,
461 })
462 .collect()
463 }
464
465 #[tokio::test]
468 async fn stream_reports_text_tools_and_the_result_in_order() {
469 let llm = MockLlm::script([
470 LlmResponse::tool_call("get_weather", serde_json::json!({"city": "Oslo"})),
471 LlmResponse::from_text("It is cold today").with_usage(9, 4),
472 ]);
473 let mut dispatcher = crate::tool::ToolDispatcher::new();
474 dispatcher.register_function(Arc::new(crate::tool::SimpleTool::new(
475 "get_weather",
476 "Get weather",
477 None,
478 |_| async { Ok(serde_json::json!({"temp": -3})) },
479 )));
480 let agent = LlmTextAgent::new("weather", llm).tools(Arc::new(dispatcher));
481
482 let events = collect(agent.stream("Weather in Oslo?")).await;
483 assert!(matches!(&events[0], RunEvent::ToolCall { name, .. } if name == "get_weather"));
484 assert!(matches!(&events[1], RunEvent::ToolResult(r) if r.outcome.is_ok()));
485 let text_deltas = events
486 .iter()
487 .filter(|e| matches!(e, RunEvent::TextDelta(_)))
488 .count();
489 assert_eq!(text_deltas, 4, "one per word from the mock: {events:?}");
490 assert_eq!(deltas(&events), "It is cold today");
491 match events.last() {
492 Some(RunEvent::Finished(result)) => {
493 assert_eq!(result.text, "It is cold today");
494 assert_eq!(result.usage, crate::llm::TokenUsage::new(9, 4));
495 assert_eq!(result.tool_calls.len(), 1);
496 }
497 other => panic!("the last event must be Finished, got {other:?}"),
498 }
499 }
500
501 #[tokio::test]
504 async fn middleware_sees_a_reply_before_it_is_streamed() {
505 struct Redact;
506 #[async_trait]
507 impl crate::middleware::Middleware for Redact {
508 fn name(&self) -> &str {
509 "redact"
510 }
511 async fn after_model(
512 &self,
513 _request: &crate::llm::LlmRequest,
514 _response: &LlmResponse,
515 ) -> Result<Option<LlmResponse>, AgentError> {
516 Ok(Some(LlmResponse::from_text("[redacted]")))
517 }
518 }
519 let agent = LlmTextAgent::new("guarded", MockLlm::text("the secret is 42"))
520 .add_middleware(Arc::new(Redact));
521 let events = collect(agent.stream("?")).await;
522 assert_eq!(deltas(&events), "[redacted]", "{events:?}");
523 }
524
525 #[tokio::test]
526 async fn any_agent_streams_and_chat_streams_into_its_history() {
527 let echo = FnTextAgent::new("echo", |state| {
528 Ok(state.get::<String>("input").unwrap_or_default())
529 });
530 let events = collect(echo.stream("hi")).await;
531 assert_eq!(deltas(&events), "hi");
532
533 let agent = LlmTextAgent::new("a", MockLlm::text("hello there"));
534 let mut chat = agent.chat();
535 let events = collect(chat.send_stream("hi")).await;
536 assert_eq!(deltas(&events), "hello there");
537 assert_eq!(chat.history().len(), 2);
538 }
539
540 #[tokio::test]
541 async fn a_failed_stream_ends_with_the_error() {
542 use futures_util::StreamExt;
543 let agent = LlmTextAgent::new("a", failing());
544 let mut events = agent.stream("?");
545 let last = events.next().await.expect("one event");
546 assert!(matches!(last, Err(AgentError::Llm(_))), "{last:?}");
547 assert!(events.next().await.is_none());
548 }
549
550 #[tokio::test]
553 async fn llm_text_agent_returns_text() {
554 let llm = Arc::new(MockLlm::text("Hello world"));
555 let agent = LlmTextAgent::new("greeter", llm).instruction("Say hello");
556 let state = State::new();
557 let result = agent.run(&state).await.unwrap();
558 assert_eq!(result, "Hello world");
559 assert_eq!(state.get::<String>("output"), Some("Hello world".into()));
560 }
561
562 #[tokio::test]
563 async fn llm_text_agent_reads_input_from_state() {
564 let llm = Arc::new(echo("Echo: "));
565 let agent = LlmTextAgent::new("echoer", llm);
566 let state = State::new();
567 let _ = state.set("input", "test message");
568 let result = agent.run(&state).await.unwrap();
569 assert!(result.contains("test message"));
570 }
571
572 #[tokio::test]
573 async fn llm_text_agent_dispatches_tools() {
574 let llm = MockLlm::script([
575 LlmResponse::tool_call("get_weather", serde_json::json!({"city": "London"})),
576 LlmResponse::from_text("The weather is sunny"),
577 ]);
578
579 let mut dispatcher = crate::tool::ToolDispatcher::new();
580 dispatcher.register_function(Arc::new(crate::tool::SimpleTool::new(
581 "get_weather",
582 "Get weather",
583 None,
584 |_args| async { Ok(serde_json::json!({"temp": 22})) },
585 )));
586
587 let agent = LlmTextAgent::new("weather", llm.clone()).tools(Arc::new(dispatcher));
588 let state = State::new();
589 let result = agent.run(&state).await.unwrap();
590 assert_eq!(result, "The weather is sunny");
591
592 let followup = llm.last_request().expect("two model calls");
594 let returned = followup
595 .contents
596 .iter()
597 .flat_map(|c| &c.parts)
598 .find_map(|p| match p {
599 Part::FunctionResponse { function_response } => Some(function_response),
600 _ => None,
601 })
602 .expect("the tool result is sent back to the model");
603 assert_eq!(returned.name, "get_weather");
604 assert_eq!(returned.response["temp"], 22);
605 }
606
607 #[tokio::test]
608 async fn llm_text_agent_propagates_llm_error() {
609 let llm = Arc::new(failing());
610 let agent = LlmTextAgent::new("failer", llm);
611 let state = State::new();
612 let result = agent.run(&state).await;
613 assert!(result.is_err());
614 }
615
616 #[tokio::test]
619 async fn fn_agent_transforms_state() {
620 let agent = FnTextAgent::new("upper", |state: &State| {
621 let input = state.get::<String>("input").unwrap_or_default();
622 let upper = input.to_uppercase();
623 let _ = state.set("output", &upper);
624 Ok(upper)
625 });
626
627 let state = State::new();
628 let _ = state.set("input", "hello");
629 let result = agent.run(&state).await.unwrap();
630 assert_eq!(result, "HELLO");
631 assert_eq!(state.get::<String>("output"), Some("HELLO".into()));
632 }
633
634 #[tokio::test]
635 async fn fn_agent_can_fail() {
636 let agent = FnTextAgent::new("failer", |_state: &State| {
637 Err(AgentError::Other("nope".into()))
638 });
639 let state = State::new();
640 assert!(agent.run(&state).await.is_err());
641 }
642
643 #[tokio::test]
646 async fn sequential_chains_agents() {
647 let llm1: Arc<dyn BaseLlm> = Arc::new(MockLlm::text("step1 done"));
648 let llm2: Arc<dyn BaseLlm> = Arc::new(echo("step2: "));
649
650 let children: Vec<Arc<dyn TextAgent>> = vec![
651 Arc::new(LlmTextAgent::new("step1", llm1)),
652 Arc::new(LlmTextAgent::new("step2", llm2)),
653 ];
654
655 let pipeline = SequentialTextAgent::new("pipeline", children);
656 let state = State::new();
657 let result = pipeline.run(&state).await.unwrap();
658 assert!(result.contains("step2:"));
660 assert!(result.contains("step1 done"));
661 }
662
663 #[tokio::test]
664 async fn sequential_stops_on_error() {
665 let children: Vec<Arc<dyn TextAgent>> = vec![
666 Arc::new(LlmTextAgent::new("ok", Arc::new(MockLlm::text("fine")))),
667 Arc::new(LlmTextAgent::new("fail", Arc::new(failing()))),
668 Arc::new(LlmTextAgent::new(
669 "never",
670 Arc::new(MockLlm::text("unreachable")),
671 )),
672 ];
673
674 let pipeline = SequentialTextAgent::new("pipeline", children);
675 let state = State::new();
676 assert!(pipeline.run(&state).await.is_err());
677 }
678
679 #[tokio::test]
680 async fn sequential_empty_returns_empty() {
681 let pipeline = SequentialTextAgent::new("empty", vec![]);
682 let state = State::new();
683 let result = pipeline.run(&state).await.unwrap();
684 assert_eq!(result, "");
685 }
686
687 #[tokio::test]
690 async fn parallel_runs_concurrently() {
691 let branches: Vec<Arc<dyn TextAgent>> = vec![
692 Arc::new(FnTextAgent::new("a", |state: &State| {
693 let _ = state.set("key_a", "val_a");
694 Ok("result_a".into())
695 })),
696 Arc::new(FnTextAgent::new("b", |state: &State| {
697 let _ = state.set("key_b", "val_b");
698 Ok("result_b".into())
699 })),
700 ];
701
702 let par = ParallelTextAgent::new("parallel", branches);
703 let state = State::new();
704 let result = par.run(&state).await.unwrap();
705 assert!(result.contains("result_a"));
706 assert!(result.contains("result_b"));
707 assert_eq!(state.get::<String>("key_a"), Some("val_a".into()));
708 assert_eq!(state.get::<String>("key_b"), Some("val_b".into()));
709 }
710
711 #[tokio::test]
712 async fn parallel_fails_if_any_fails() {
713 let branches: Vec<Arc<dyn TextAgent>> = vec![
714 Arc::new(FnTextAgent::new("ok", |_| Ok("fine".into()))),
715 Arc::new(FnTextAgent::new("fail", |_| {
716 Err(AgentError::Other("boom".into()))
717 })),
718 ];
719
720 let par = ParallelTextAgent::new("parallel", branches);
721 let state = State::new();
722 assert!(par.run(&state).await.is_err());
723 }
724
725 #[tokio::test]
728 async fn loop_runs_max_iterations() {
729 let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
730 let counter_clone = counter.clone();
731
732 let body = Arc::new(FnTextAgent::new("counter", move |_state: &State| {
733 counter_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
734 Ok("tick".into())
735 }));
736
737 let loop_agent = LoopTextAgent::new("loop", body, 5);
738 let state = State::new();
739 loop_agent.run(&state).await.unwrap();
740 assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 5);
741 }
742
743 #[tokio::test]
744 async fn loop_breaks_on_predicate() {
745 let body = Arc::new(FnTextAgent::new("incrementer", |state: &State| {
746 let n = state.get::<i32>("n").unwrap_or(0);
747 let _ = state.set("n", n + 1);
748 Ok(format!("n={}", n + 1))
749 }));
750
751 let loop_agent = LoopTextAgent::new("loop", body, 100)
752 .until(|state: &State| state.get::<i32>("n").unwrap_or(0) >= 3);
753
754 let state = State::new();
755 loop_agent.run(&state).await.unwrap();
756 assert_eq!(state.get::<i32>("n"), Some(3));
757 }
758
759 #[tokio::test]
762 async fn fallback_returns_first_success() {
763 let candidates: Vec<Arc<dyn TextAgent>> = vec![
764 Arc::new(FnTextAgent::new("fail1", |_| {
765 Err(AgentError::Other("fail1".into()))
766 })),
767 Arc::new(FnTextAgent::new("ok", |_| Ok("success".into()))),
768 Arc::new(FnTextAgent::new("never", |_| Ok("unreachable".into()))),
769 ];
770
771 let fallback = FallbackTextAgent::new("fallback", candidates);
772 let state = State::new();
773 let result = fallback.run(&state).await.unwrap();
774 assert_eq!(result, "success");
775 }
776
777 #[tokio::test]
778 async fn fallback_returns_last_error() {
779 let candidates: Vec<Arc<dyn TextAgent>> = vec![
780 Arc::new(FnTextAgent::new("fail1", |_| {
781 Err(AgentError::Other("fail1".into()))
782 })),
783 Arc::new(FnTextAgent::new("fail2", |_| {
784 Err(AgentError::Other("fail2".into()))
785 })),
786 ];
787
788 let fallback = FallbackTextAgent::new("fallback", candidates);
789 let state = State::new();
790 let err = fallback.run(&state).await.unwrap_err();
791 assert!(err.to_string().contains("fail2"));
792 }
793
794 #[tokio::test]
795 async fn fallback_empty_returns_error() {
796 let fallback = FallbackTextAgent::new("fallback", vec![]);
797 let state = State::new();
798 assert!(fallback.run(&state).await.is_err());
799 }
800
801 #[tokio::test]
804 async fn route_dispatches_matching_rule() {
805 let agent_a: Arc<dyn TextAgent> = Arc::new(FnTextAgent::new("a", |_| Ok("route_a".into())));
806 let agent_b: Arc<dyn TextAgent> = Arc::new(FnTextAgent::new("b", |_| Ok("route_b".into())));
807 let default: Arc<dyn TextAgent> =
808 Arc::new(FnTextAgent::new("default", |_| Ok("default".into())));
809
810 let router = RouteTextAgent::new(
811 "router",
812 vec![
813 RouteRule::new(
814 |s: &State| s.get::<String>("mode") == Some("a".into()),
815 agent_a,
816 ),
817 RouteRule::new(
818 |s: &State| s.get::<String>("mode") == Some("b".into()),
819 agent_b,
820 ),
821 ],
822 default,
823 );
824
825 let state = State::new();
826 let _ = state.set("mode", "b");
827 let result = router.run(&state).await.unwrap();
828 assert_eq!(result, "route_b");
829 }
830
831 #[tokio::test]
832 async fn route_uses_default_when_no_match() {
833 let default: Arc<dyn TextAgent> =
834 Arc::new(FnTextAgent::new("default", |_| Ok("fallback".into())));
835
836 let router = RouteTextAgent::new(
837 "router",
838 vec![RouteRule::new(|_: &State| false, default.clone())],
839 default,
840 );
841
842 let state = State::new();
843 let result = router.run(&state).await.unwrap();
844 assert_eq!(result, "fallback");
845 }
846
847 struct AsyncSleepAgent {
851 delay: Duration,
852 }
853
854 #[async_trait]
855 impl TextAgent for AsyncSleepAgent {
856 fn name(&self) -> &str {
857 "async-sleeper"
858 }
859 async fn run(&self, _state: &State) -> Result<String, AgentError> {
860 tokio::time::sleep(self.delay).await;
861 Ok("too late".into())
862 }
863 }
864
865 #[tokio::test]
868 async fn race_returns_first_to_complete() {
869 let fast: Arc<dyn TextAgent> = Arc::new(FnTextAgent::new("fast", |_| Ok("winner".into())));
871 let slow: Arc<dyn TextAgent> = Arc::new(AsyncSleepAgent {
872 delay: Duration::from_millis(500),
873 });
874
875 let race = RaceTextAgent::new("race", vec![fast, slow]);
876 let state = State::new();
877 let result = race.run(&state).await.unwrap();
878 assert_eq!(result, "winner");
879 }
880
881 #[tokio::test]
882 async fn race_empty_returns_error() {
883 let race = RaceTextAgent::new("race", vec![]);
884 let state = State::new();
885 assert!(race.run(&state).await.is_err());
886 }
887
888 #[tokio::test]
891 async fn timeout_returns_result_within_limit() {
892 let fast: Arc<dyn TextAgent> = Arc::new(FnTextAgent::new("fast", |_| Ok("done".into())));
893 let timeout = TimeoutTextAgent::new("timeout", fast, Duration::from_secs(5));
894 let state = State::new();
895 let result = timeout.run(&state).await.unwrap();
896 assert_eq!(result, "done");
897 }
898
899 #[tokio::test]
900 async fn timeout_returns_error_when_exceeded() {
901 let slow: Arc<dyn TextAgent> = Arc::new(AsyncSleepAgent {
902 delay: Duration::from_secs(2),
903 });
904 let timeout = TimeoutTextAgent::new("timeout", slow, Duration::from_millis(50));
905 let state = State::new();
906 let err = timeout.run(&state).await.unwrap_err();
907 assert!(matches!(err, AgentError::Timeout));
908 }
909
910 #[tokio::test]
913 async fn map_over_iterates_items() {
914 let agent: Arc<dyn TextAgent> = Arc::new(FnTextAgent::new("upper", |state: &State| {
915 let item: String = state
916 .get::<serde_json::Value>("_item")
917 .map(|v| v.as_str().unwrap_or("").to_string())
918 .unwrap_or_default();
919 Ok(item.to_uppercase())
920 }));
921
922 let map = MapOverTextAgent::new("mapper", agent, "items");
923 let state = State::new();
924 let _ = state.set(
925 "items",
926 vec![
927 serde_json::Value::String("hello".into()),
928 serde_json::Value::String("world".into()),
929 ],
930 );
931
932 let result = map.run(&state).await.unwrap();
933 assert!(result.contains("HELLO"));
934 assert!(result.contains("WORLD"));
935
936 let results: Vec<String> = state.get("_results").unwrap();
937 assert_eq!(results.len(), 2);
938 assert_eq!(results[0], "HELLO");
939 assert_eq!(results[1], "WORLD");
940 }
941
942 #[tokio::test]
943 async fn map_over_empty_list() {
944 let agent: Arc<dyn TextAgent> = Arc::new(FnTextAgent::new("noop", |_| Ok("x".into())));
945 let map = MapOverTextAgent::new("mapper", agent, "items");
946 let state = State::new();
947 let result = map.run(&state).await.unwrap();
949 assert_eq!(result, "");
950 }
951
952 #[tokio::test]
955 async fn tap_observes_state() {
956 let observed = Arc::new(std::sync::Mutex::new(String::new()));
957 let observed_clone = observed.clone();
958
959 let tap = TapTextAgent::new("observer", move |state: &State| {
960 let val = state.get::<String>("input").unwrap_or_default();
961 *observed_clone.lock().unwrap() = val;
962 });
963
964 let state = State::new();
965 let _ = state.set("input", "hello");
966 let result = tap.run(&state).await.unwrap();
967 assert_eq!(result, ""); assert_eq!(*observed.lock().unwrap(), "hello");
969 }
970
971 #[tokio::test]
974 async fn dispatch_and_join_round_trip() {
975 let registry = TaskRegistry::new();
976 let budget = Arc::new(tokio::sync::Semaphore::new(10));
977
978 let agent_a: Arc<dyn TextAgent> =
979 Arc::new(FnTextAgent::new("task_a", |_| Ok("result_a".into())));
980 let agent_b: Arc<dyn TextAgent> =
981 Arc::new(FnTextAgent::new("task_b", |_| Ok("result_b".into())));
982
983 let dispatch = DispatchTextAgent::new(
984 "dispatch",
985 vec![("task_a".into(), agent_a), ("task_b".into(), agent_b)],
986 registry.clone(),
987 budget,
988 );
989
990 let state = State::new();
991 let dispatch_result = dispatch.run(&state).await.unwrap();
992 assert_eq!(dispatch_result, ""); let join = JoinTextAgent::new("joiner", registry);
995 let join_result = join.run(&state).await.unwrap();
996 assert!(join_result.contains("result_a"));
997 assert!(join_result.contains("result_b"));
998 }
999
1000 #[tokio::test]
1001 async fn join_with_target_names() {
1002 let registry = TaskRegistry::new();
1003 let budget = Arc::new(tokio::sync::Semaphore::new(10));
1004
1005 let children: Vec<(String, Arc<dyn TextAgent>)> = vec![
1006 (
1007 "x".into(),
1008 Arc::new(FnTextAgent::new("x", |_| Ok("rx".into()))),
1009 ),
1010 (
1011 "y".into(),
1012 Arc::new(FnTextAgent::new("y", |_| Ok("ry".into()))),
1013 ),
1014 (
1015 "z".into(),
1016 Arc::new(FnTextAgent::new("z", |_| Ok("rz".into()))),
1017 ),
1018 ];
1019
1020 let dispatch = DispatchTextAgent::new("dispatch", children, registry.clone(), budget);
1021 let state = State::new();
1022 dispatch.run(&state).await.unwrap();
1023
1024 let join =
1026 JoinTextAgent::new("joiner", registry.clone()).targets(vec!["x".into(), "z".into()]);
1027 let result = join.run(&state).await.unwrap();
1028 assert!(result.contains("rx"));
1029 assert!(result.contains("rz"));
1030
1031 let remaining = registry.inner.lock().await;
1033 assert!(remaining.contains_key("y"));
1034 }
1035
1036 #[tokio::test]
1037 async fn join_with_timeout() {
1038 let registry = TaskRegistry::new();
1039 let budget = Arc::new(tokio::sync::Semaphore::new(10));
1040
1041 let slow: Arc<dyn TextAgent> = Arc::new(AsyncSleepAgent {
1042 delay: Duration::from_secs(2),
1043 });
1044
1045 let dispatch = DispatchTextAgent::new(
1046 "dispatch",
1047 vec![("slow".into(), slow)],
1048 registry.clone(),
1049 budget,
1050 );
1051 let state = State::new();
1052 dispatch.run(&state).await.unwrap();
1053
1054 let join = JoinTextAgent::new("joiner", registry).timeout(Duration::from_millis(50));
1055 let err = join.run(&state).await.unwrap_err();
1056 assert!(matches!(err, AgentError::Timeout));
1057 }
1058}