gemini_adk_rs/text/
mod.rs

1//! Text-based agent execution — request/response LLM pipelines.
2//!
3//! While `Agent::run_live()` operates over a Gemini Live WebSocket session,
4//! `TextAgent::run()` makes standard `BaseLlm::generate()` calls. This enables
5//! dispatching text-based agent pipelines from Live session event hooks.
6//!
7//! # Agent types
8//!
9//! | Type | Purpose |
10//! |------|---------|
11//! | `LlmTextAgent` | Core agent — generate → tool dispatch → loop |
12//! | `FnTextAgent` | Zero-cost state transform (no LLM call) |
13//! | `SequentialTextAgent` | Run children in order, state flows forward |
14//! | `ParallelTextAgent` | Run children concurrently via `tokio::spawn` |
15//! | `LoopTextAgent` | Repeat until max iterations or predicate |
16//! | `FallbackTextAgent` | Try each child, first success wins |
17//! | `RouteTextAgent` | State-driven deterministic branching |
18//! | `RaceTextAgent` | Run concurrently, first to finish wins |
19//! | `TimeoutTextAgent` | Wrap an agent with a time limit |
20//! | `MapOverTextAgent` | Iterate an agent over a list in state |
21//! | `TapTextAgent` | Read-only observation (no mutation) |
22//! | `DispatchTextAgent` | Fire-and-forget background tasks |
23//! | `JoinTextAgent` | Wait for dispatched tasks |
24
25use 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// ── TextAgent trait ────────────────────────────────────────────────────────
60
61/// A text-based agent that runs via `BaseLlm::generate()` (request/response).
62///
63/// Unlike `Agent` (which requires a Live WebSocket session), `TextAgent` can be
64/// dispatched from anywhere — event hooks, background tasks, CLI tools.
65///
66/// Ask a question, get a typed answer, or hold a conversation:
67///
68/// ```
69/// use gemini_adk_rs::llm::{LlmResponse, MockLlm};
70/// use gemini_adk_rs::text::{LlmTextAgent, TextAgent};
71///
72/// #[derive(serde::Deserialize, schemars::JsonSchema)]
73/// struct City {
74///     name: String,
75///     country: String,
76/// }
77///
78/// # tokio_test::block_on(async {
79/// let llm = MockLlm::script([
80///     LlmResponse::from_text("Paris."),
81///     LlmResponse::from_text(r#"{"name":"Paris","country":"France"}"#),
82/// ]);
83/// let agent = LlmTextAgent::new("geo", llm);
84///
85/// assert_eq!(agent.ask("Capital of France?").await.unwrap(), "Paris.");
86///
87/// let city: City = agent.ask_as("Describe the capital of France.").await.unwrap();
88/// assert_eq!(city.country, "France");
89/// # });
90/// ```
91///
92/// [`run_with`](Self::run_with) is the primitive the others are built on;
93/// [`run`](Self::run) is the state-in, state-out form combinators use, reading
94/// the prompt from the `"input"` state key.
95#[async_trait]
96pub trait TextAgent: Send + Sync {
97    /// Human-readable name for logging and debugging.
98    fn name(&self) -> &str;
99
100    /// Execute this agent. Reads/writes `state`. Returns the final text output.
101    ///
102    /// The prompt is read from the `"input"` state key; prefer
103    /// [`run_with`](Self::run_with) or [`ask`](Self::ask), which take it
104    /// directly.
105    async fn run(&self, state: &State) -> Result<String, AgentError>;
106
107    /// Run one request and report everything it produced: the reply, the
108    /// turns to append to a conversation, token usage and tool calls.
109    ///
110    /// The default writes the request's text to the `"input"` state key and
111    /// calls [`run`](Self::run), so every agent supports it; history and a
112    /// response schema are honoured by agents that call a model
113    /// ([`LlmTextAgent`]).
114    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    /// Run one request as a stream of [`RunEvent`]s: text as the model writes
124    /// it, each tool call and result, then [`RunEvent::Finished`].
125    ///
126    /// The default emits the reply of [`run_with`](Self::run_with) as a single
127    /// delta. [`LlmTextAgent`] streams from the model; when it has middleware
128    /// (which may rewrite a reply, e.g. to redact it), each model turn is
129    /// emitted only after the middleware has seen it.
130    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    /// Stream the reply to one question, with no history and fresh state.
153    ///
154    /// ```
155    /// use futures_util::StreamExt;
156    /// use gemini_adk_rs::llm::MockLlm;
157    /// use gemini_adk_rs::text::{LlmTextAgent, RunEvent, TextAgent};
158    ///
159    /// # tokio_test::block_on(async {
160    /// let agent = LlmTextAgent::new("storyteller", MockLlm::text("Once upon a time"));
161    /// let mut events = agent.stream("Tell me a story.");
162    /// let mut story = String::new();
163    /// while let Some(event) = events.next().await {
164    ///     if let RunEvent::TextDelta(text) = event.unwrap() {
165    ///         story.push_str(&text);
166    ///     }
167    /// }
168    /// assert_eq!(story, "Once upon a time");
169    /// # });
170    /// ```
171    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    /// Ask one question, with no history and fresh state, and get the reply.
182    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    /// Ask one question and get the reply as a `T`.
193    ///
194    /// `T`'s JSON Schema is sent as the response schema. If the reply still
195    /// does not parse, the model is shown the error and asked once more;
196    /// a second failure is [`AgentError::InvalidOutput`].
197    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    /// Start a conversation that remembers its turns. See [`Chat`].
224    fn chat(&self) -> Chat<&Self>
225    where
226        Self: Sized,
227    {
228        Chat::new(self)
229    }
230}
231
232// Verify object safety at compile time.
233const _: () = {
234    fn _assert_object_safe(_: &dyn TextAgent) {}
235};
236
237/// Forward every required method, so a wrapper behaves exactly like the agent
238/// it holds (including an overridden `run_with`).
239macro_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
270// A shared agent is an agent, so a built `Arc<dyn TextAgent>` can be passed
271// straight back into any combinator or `agent_tool` without a cast; a borrow
272// is one too, which is what `chat()` holds.
273forward_text_agent!(std::sync::Arc<A>, Box<A>, &A);
274
275// ── Tests ─────────────────────────────────────────────────────────────────
276
277#[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    /// A model that echoes every text part it was sent, after `prefix`.
286    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    /// A model whose every call fails.
305    fn failing() -> MockLlm {
306        MockLlm::from_fn(|_| Err(LlmError::RequestFailed("intentional failure".into())))
307    }
308
309    // ── TextAgent trait ──
310
311    #[test]
312    fn text_agent_is_object_safe() {
313        fn _assert(_: &dyn TextAgent) {}
314    }
315
316    /// A built agent is handed around as `Arc<dyn TextAgent>`; it must satisfy
317    /// a generic `impl TextAgent` bound without a cast or a second API.
318    #[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    // ── run_with, ask, ask_as, chat ──
331
332    #[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        // user, model(call), user(tool response), model(text)
359        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    /// A model that calls a tool on an agent with no tools is told so, instead
423    /// of receiving an empty turn and calling again until the round limit.
424    #[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    // ── Streaming ──
443
444    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    /// Text arrives as the model writes it, tool calls and results in between,
466    /// and `Finished` carries the same result `run_with` would return.
467    #[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    /// A middleware that rewrites replies must see a turn before any of it is
502    /// streamed, or a redaction could be bypassed through the deltas.
503    #[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    // ── LlmTextAgent ──
551
552    #[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        // The second model call carries the tool's result back.
593        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    // ── FnTextAgent ──
617
618    #[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    // ── SequentialTextAgent ──
644
645    #[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        // step2 should receive step1's output as input
659        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    // ── ParallelTextAgent ──
688
689    #[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    // ── LoopTextAgent ──
726
727    #[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    // ── FallbackTextAgent ──
760
761    #[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    // ── RouteTextAgent ──
802
803    #[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    // ── Async test helper ──
848
849    /// A test agent that sleeps asynchronously (cooperative with tokio timeout).
850    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    // ── RaceTextAgent ──
866
867    #[tokio::test]
868    async fn race_returns_first_to_complete() {
869        // Fast agent completes immediately, slow agent sleeps async.
870        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    // ── TimeoutTextAgent ──
889
890    #[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    // ── MapOverTextAgent ──
911
912    #[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        // no "items" key → empty Vec
948        let result = map.run(&state).await.unwrap();
949        assert_eq!(result, "");
950    }
951
952    // ── TapTextAgent ──
953
954    #[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, ""); // Tap returns empty string
968        assert_eq!(*observed.lock().unwrap(), "hello");
969    }
970
971    // ── DispatchTextAgent + JoinTextAgent ──
972
973    #[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, ""); // Fire-and-forget returns empty
993
994        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        // Only join x and z
1025        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        // y should still be in registry
1032        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}