gemini_adk_rs/text/
llm.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use gemini_genai_rs::prelude::{Content, FunctionCall, FunctionResponse, Part, Role};
5
6use futures_util::stream::BoxStream;
7use futures_util::{FutureExt, StreamExt};
8use tracing::Instrument;
9
10use super::{RunEvent, RunRequest, RunResult, TextAgent, ToolCallRecord};
11use crate::context::AgentEvent;
12use crate::error::AgentError;
13use crate::llm::{BaseLlm, LlmRequest, LlmResponse};
14use crate::middleware::MiddlewareChain;
15use crate::state::State;
16use crate::telemetry::spans;
17use crate::tool::ToolDispatcher;
18
19/// The `error.type` a failed tool call is reported under.
20fn tool_error_type(error: &crate::error::ToolError) -> &'static str {
21    use crate::error::ToolError;
22    match error {
23        ToolError::NotFound(_) => "not_found",
24        ToolError::InvalidArgs(_) => "invalid_args",
25        ToolError::Timeout(_) => "timeout",
26        ToolError::Cancelled => "cancelled",
27        ToolError::Declined(_) => "declined",
28        _ => "execution_failed",
29    }
30}
31
32/// Where a streamed run sends its events.
33type EventSender = tokio::sync::mpsc::UnboundedSender<Result<RunEvent, AgentError>>;
34
35/// Maximum number of tool-dispatch round-trips before giving up.
36const MAX_TOOL_ROUNDS: usize = 10;
37
38/// A dynamic model source: state in, the model to use for this run out.
39type LlmProviderFn = Arc<dyn Fn(&State) -> Arc<dyn BaseLlm> + Send + Sync>;
40
41/// Core text agent — calls `BaseLlm::generate()`, dispatches tools, loops
42/// until the model produces a final text response.
43///
44/// Middleware hooks fire at each lifecycle point:
45///
46/// - `before_model` / `after_model` — wraps each `BaseLlm::generate()` call;
47///   `before_model` may return a cached response to skip the LLM entirely.
48/// - `before_tool` / `after_tool` / `on_tool_error` — wraps each tool dispatch.
49/// - `on_error` — called when `run()` is about to return an error.
50///
51/// Note: `before_agent`/`after_agent` are Live-session hooks that require an
52/// `InvocationContext` (a Live WebSocket concept) and are therefore not invoked
53/// by `LlmTextAgent`.  Use `before_model` or wrap in a custom `TextAgent` if you
54/// need entry/exit hooks for the text path.
55pub struct LlmTextAgent {
56    name: String,
57    llm: Arc<dyn BaseLlm>,
58    instruction: Option<String>,
59    /// Dynamic instruction source, resolved against state on every run;
60    /// wins over the static `instruction` when both are set.
61    instruction_provider: Option<Arc<dyn crate::instruction::InstructionProvider>>,
62    /// Dynamic model source, resolved against state on every run;
63    /// wins over the constructor's model when set. Risk-based escalation,
64    /// cost routing, per-tenant model selection without rebuilding the agent.
65    llm_provider: Option<LlmProviderFn>,
66    dispatcher: Option<Arc<ToolDispatcher>>,
67    /// Every per-request setting (model, sampling, built-in tools, response
68    /// schema); each call starts from a copy with the conversation filled in.
69    template: LlmRequest,
70    /// A state key that receives the final text, besides `"output"`.
71    output_key: Option<String>,
72    middleware: MiddlewareChain,
73}
74
75impl LlmTextAgent {
76    /// Create a new LLM text agent.
77    ///
78    /// `llm` is any [`BaseLlm`]: a `GeminiLlm`, a shared `Arc<dyn BaseLlm>`,
79    /// or a [`MockLlm`](crate::llm::MockLlm) in tests.
80    pub fn new(name: impl Into<String>, llm: impl BaseLlm + 'static) -> Self {
81        Self {
82            name: name.into(),
83            llm: Arc::new(llm),
84            instruction: None,
85            instruction_provider: None,
86            llm_provider: None,
87            dispatcher: None,
88            template: LlmRequest::default(),
89            output_key: None,
90            middleware: MiddlewareChain::new(),
91        }
92    }
93
94    /// Set the system instruction.
95    pub fn instruction(mut self, inst: impl Into<String>) -> Self {
96        self.instruction = Some(inst.into());
97        self
98    }
99
100    /// Set a dynamic instruction source — an
101    /// [`InstructionProvider`](crate::instruction::InstructionProvider)
102    /// (any `Fn(&State) -> String` closure, or a `TemplateInstruction`
103    /// under the `templates` feature) resolved against session state at
104    /// the start of every run. Wins over [`instruction`](Self::instruction).
105    pub fn instruction_provider(
106        mut self,
107        provider: impl crate::instruction::InstructionProvider + 'static,
108    ) -> Self {
109        self.instruction_provider = Some(Arc::new(provider));
110        self
111    }
112
113    /// Set a dynamic model source, resolved against session state at the
114    /// start of every run — risk-based escalation to a stronger model, cost
115    /// routing to a cheaper one, per-tenant model selection — without
116    /// rebuilding the agent. Wins over the constructor's model when set.
117    pub fn llm_provider<F>(mut self, provider: F) -> Self
118    where
119        F: Fn(&State) -> Arc<dyn BaseLlm> + Send + Sync + 'static,
120    {
121        self.llm_provider = Some(Arc::new(provider));
122        self
123    }
124
125    /// Set the tool dispatcher.
126    pub fn tools(mut self, dispatcher: Arc<ToolDispatcher>) -> Self {
127        self.dispatcher = Some(dispatcher);
128        self
129    }
130
131    /// Call `model` instead of the provider's default model, e.g.
132    /// `"gemini-2.5-pro"`.
133    pub fn model(mut self, model: impl Into<String>) -> Self {
134        self.template.model = Some(model.into());
135        self
136    }
137
138    /// Set temperature.
139    pub fn temperature(mut self, t: f32) -> Self {
140        self.template.temperature = Some(t);
141        self
142    }
143
144    /// Set max output tokens.
145    pub fn max_output_tokens(mut self, n: u32) -> Self {
146        self.template.max_output_tokens = Some(n);
147        self
148    }
149
150    /// Set the nucleus sampling threshold.
151    pub fn top_p(mut self, p: f32) -> Self {
152        self.template.top_p = Some(p);
153        self
154    }
155
156    /// Sample from the `k` most likely tokens.
157    pub fn top_k(mut self, k: u32) -> Self {
158        self.template.top_k = Some(k);
159        self
160    }
161
162    /// End generation when the model produces any of `sequences`.
163    pub fn stop_sequences(
164        mut self,
165        sequences: impl IntoIterator<Item = impl Into<String>>,
166    ) -> Self {
167        self.template.stop_sequences = sequences.into_iter().map(Into::into).collect();
168        self
169    }
170
171    /// Give the model a thinking budget, in tokens.
172    pub fn thinking_budget(mut self, tokens: u32) -> Self {
173        self.template.thinking_budget = Some(tokens);
174        self
175    }
176
177    /// Add a built-in tool (Google Search, code execution, URL context),
178    /// sent alongside the dispatcher's function declarations.
179    pub fn built_in_tool(mut self, tool: gemini_genai_rs::prelude::Tool) -> Self {
180        self.template.tools.push(tool);
181        self
182    }
183
184    /// Constrain the reply to JSON matching `schema`.
185    pub fn response_schema(mut self, schema: serde_json::Value) -> Self {
186        self.template.response_mime_type = Some("application/json".into());
187        self.template.response_json_schema = Some(schema);
188        self
189    }
190
191    /// Also write the final text to state under `key` (it is always written
192    /// to `"output"`).
193    pub fn output_key(mut self, key: impl Into<String>) -> Self {
194        self.output_key = Some(key.into());
195        self
196    }
197
198    /// Append a middleware layer to the chain.
199    ///
200    /// Layers are run in insertion order for `before_*` / `on_error` hooks
201    /// and in reverse insertion order for `after_*` hooks (outermost last).
202    pub fn add_middleware(mut self, mw: Arc<dyn crate::middleware::Middleware>) -> Self {
203        self.middleware.add(mw);
204        self
205    }
206
207    /// Replace the entire middleware chain (advanced — prefer `add_middleware`).
208    pub fn with_middleware_chain(mut self, chain: MiddlewareChain) -> Self {
209        self.middleware = chain;
210        self
211    }
212
213    /// Build an LlmRequest, taking ownership of contents to avoid cloning.
214    fn build_request(&self, contents: Vec<Content>, instruction: &Option<String>) -> LlmRequest {
215        let mut req = LlmRequest {
216            contents,
217            system_instruction: instruction.clone(),
218            ..self.template.clone()
219        };
220        if let Some(dispatcher) = &self.dispatcher {
221            req.tools.extend(dispatcher.to_tool_declarations());
222        }
223        req
224    }
225
226    /// Call the model with streaming, passing each text chunk to `on_text`
227    /// when it is set, and return the whole reply.
228    async fn generate_streamed(
229        &self,
230        llm: &Arc<dyn BaseLlm>,
231        request: LlmRequest,
232        on_text: Option<&(dyn Fn(RunEvent) + Send + Sync)>,
233    ) -> Result<LlmResponse, crate::llm::LlmError> {
234        let mut chunks = llm.generate_stream(request).await?;
235        let mut whole: Option<LlmResponse> = None;
236        while let Some(chunk) = chunks.next().await {
237            let chunk = chunk?;
238            if let Some(on_text) = on_text {
239                let text = chunk.text();
240                if !text.is_empty() {
241                    on_text(RunEvent::TextDelta(text));
242                }
243            }
244            match &mut whole {
245                Some(so_far) => so_far.append(chunk),
246                None => whole = Some(chunk),
247            }
248        }
249        Ok(whole.unwrap_or(LlmResponse {
250            content: Content {
251                role: Some(Role::Model),
252                parts: Vec::new(),
253            },
254            finish_reason: None,
255            usage: None,
256        }))
257    }
258
259    /// Dispatch function calls and return function responses, firing middleware hooks.
260    async fn dispatch_tools(
261        &self,
262        calls: &[FunctionCall],
263        records: &mut Vec<ToolCallRecord>,
264        state: &State,
265    ) -> Vec<FunctionResponse> {
266        let mut responses = Vec::with_capacity(calls.len());
267        for call in calls {
268            // A model can call a tool the agent never declared; tell it so.
269            let Some(dispatcher) = &self.dispatcher else {
270                let missing = Err(crate::error::ToolError::NotFound(call.name.clone()));
271                records.push(ToolCallRecord::new(
272                    &call.name,
273                    call.args.clone(),
274                    missing.clone(),
275                ));
276                responses.push(ToolDispatcher::build_response(call, missing));
277                continue;
278            };
279            // before_tool hook
280            if let Err(e) = self.middleware.run_before_tool(call).await {
281                // Hook error — record it and return an error response.
282                let _ = self
283                    .middleware
284                    .run_on_tool_error(
285                        call,
286                        &crate::error::ToolError::ExecutionFailed(e.to_string()),
287                    )
288                    .await;
289                let refused = Err(crate::error::ToolError::ExecutionFailed(e.to_string()));
290                records.push(ToolCallRecord::new(
291                    &call.name,
292                    call.args.clone(),
293                    refused.clone(),
294                ));
295                responses.push(ToolDispatcher::build_response(call, refused));
296                continue;
297            }
298
299            let span = spans::execute_tool_span(&call.name, call.id.as_deref());
300            let mut tool_ctx = crate::tool::ToolContext::new(state.clone());
301            if let Some(id) = &call.id {
302                tool_ctx = tool_ctx.with_call_id(id.clone());
303            }
304            let result = dispatcher
305                .call_function_in(&call.name, call.args.clone(), tool_ctx)
306                .instrument(span.clone())
307                .await;
308            if let Err(e) = &result {
309                span.record("error.type", tool_error_type(e));
310            }
311
312            match &result {
313                Ok(value) => {
314                    let _ = self.middleware.run_after_tool(call, value).await;
315                }
316                Err(e) => {
317                    let _ = self.middleware.run_on_tool_error(call, e).await;
318                }
319            }
320
321            records.push(ToolCallRecord::new(
322                &call.name,
323                call.args.clone(),
324                result.clone(),
325            ));
326            responses.push(ToolDispatcher::build_response(call, result));
327        }
328        responses
329    }
330}
331
332#[async_trait]
333impl TextAgent for LlmTextAgent {
334    fn name(&self) -> &str {
335        &self.name
336    }
337
338    async fn run(&self, state: &State) -> Result<String, AgentError> {
339        let input = state.get::<String>("input").unwrap_or_default();
340        Ok(self.run_with(RunRequest::new(input), state).await?.text)
341    }
342
343    async fn run_with(&self, request: RunRequest, state: &State) -> Result<RunResult, AgentError> {
344        self.run_with_events(request, state, None).await
345    }
346
347    fn run_stream<'a>(
348        &'a self,
349        request: RunRequest,
350        state: State,
351    ) -> BoxStream<'a, Result<RunEvent, AgentError>> {
352        // The run drives itself inside the returned stream, sending events
353        // through a channel as they happen; `Finished` (or the error) is last.
354        let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
355        let driver = async move {
356            let finished = self.run_with_events(request, &state, Some(&tx)).await;
357            let _ = tx.send(finished.map(RunEvent::Finished));
358        };
359        let events = futures_util::stream::unfold(rx, |mut rx| async move {
360            rx.recv().await.map(|event| (event, rx))
361        });
362        futures_util::stream::select(
363            driver
364                .into_stream()
365                .filter_map(|()| async { None::<Result<RunEvent, AgentError>> }),
366            events,
367        )
368        .boxed()
369    }
370}
371
372impl LlmTextAgent {
373    /// One run, reporting events to `events` as they happen when it is set.
374    async fn run_with_events(
375        &self,
376        request: RunRequest,
377        state: &State,
378        events: Option<&EventSender>,
379    ) -> Result<RunResult, AgentError> {
380        self.execute(request, state, events)
381            .instrument(spans::invoke_agent_span(&self.name))
382            .await
383    }
384
385    async fn execute(
386        &self,
387        request: RunRequest,
388        state: &State,
389        events: Option<&EventSender>,
390    ) -> Result<RunResult, AgentError> {
391        // Resolve the instruction for this run: provider (against live
392        // state) wins over the static string.
393        let instruction = match &self.instruction_provider {
394            Some(provider) => Some(provider.provide(state)),
395            None => self.instruction.clone(),
396        };
397
398        // Resolve the LLM for this run: provider (against live state) wins
399        // over the constructor's LLM.
400        let llm = self
401            .llm_provider
402            .as_ref()
403            .map(|p| p(state))
404            .unwrap_or_else(|| self.llm.clone());
405
406        // Lifecycle event — makes `on_event` (e.g. M::tap) observe agent start.
407        let _ = self
408            .middleware
409            .run_on_event(&AgentEvent::AgentStarted {
410                name: self.name.clone(),
411            })
412            .await;
413
414        // Enforce the tightest middleware timeout (M::timeout) over the whole run.
415        let result = match self.middleware.timeout() {
416            Some(limit) => {
417                match tokio::time::timeout(
418                    limit,
419                    self.run_inner(request, &instruction, &llm, events, state),
420                )
421                .await
422                {
423                    Ok(r) => r,
424                    Err(_) => {
425                        let _ = self.middleware.run_on_event(&AgentEvent::Timeout).await;
426                        Err(AgentError::Other(format!(
427                            "agent '{}' timed out after {:?}",
428                            self.name, limit
429                        )))
430                    }
431                }
432            }
433            None => {
434                self.run_inner(request, &instruction, &llm, events, state)
435                    .await
436            }
437        };
438
439        match &result {
440            Err(e) => {
441                let _ = self.middleware.run_on_error(e).await;
442            }
443            Ok(done) => {
444                let _ = state.set("output", &done.text);
445                if let Some(key) = &self.output_key {
446                    let _ = state.set(key, &done.text);
447                }
448                let _ = self
449                    .middleware
450                    .run_on_event(&AgentEvent::AgentCompleted {
451                        name: self.name.clone(),
452                    })
453                    .await;
454            }
455        }
456
457        result
458    }
459
460    /// Inner execution loop — separated so `on_error` fires exactly once.
461    async fn run_inner(
462        &self,
463        request: RunRequest,
464        instruction: &Option<String>,
465        llm: &Arc<dyn BaseLlm>,
466        events: Option<&EventSender>,
467        state: &State,
468    ) -> Result<RunResult, AgentError> {
469        let emit = |event: RunEvent| {
470            if let Some(tx) = events {
471                let _ = tx.send(Ok(event));
472            }
473        };
474        // Without middleware nothing can rewrite a reply, so text is emitted
475        // as it streams; otherwise only after `after_model` has seen it.
476        let stream_live = events.is_some() && self.middleware.is_empty();
477        let history_len = request.history.len();
478        let mut contents = request.history;
479        contents.push(request.input);
480        let mut result = RunResult::default();
481
482        for _round in 0..MAX_TOOL_ROUNDS {
483            let mut llm_request = self.build_request(contents.clone(), instruction);
484            if let Some(schema) = &request.response_schema {
485                llm_request.response_mime_type = Some("application/json".into());
486                llm_request.response_json_schema = Some(schema.clone());
487            }
488
489            // transform_request hook — may rewrite the request (e.g. context
490            // policies trimming conversation history) before it is sent.
491            self.middleware
492                .run_transform_request(&mut llm_request)
493                .await?;
494
495            // before_model hook — may short-circuit with a cached response.
496            let mut streamed = false;
497            let response = match self.middleware.run_before_model(&llm_request).await? {
498                Some(cached) => cached,
499                None => {
500                    let model = llm_request
501                        .model
502                        .clone()
503                        .unwrap_or_else(|| llm.model_id().to_string());
504                    let span = spans::chat_span(&model, &llm_request);
505                    let started = std::time::Instant::now();
506                    let outcome = if events.is_some() {
507                        streamed = stream_live;
508                        self.generate_streamed(
509                            llm,
510                            llm_request.clone(),
511                            stream_live.then_some(&emit),
512                        )
513                        .instrument(span.clone())
514                        .await
515                    } else {
516                        llm.generate(llm_request.clone())
517                            .instrument(span.clone())
518                            .await
519                    };
520                    spans::record_chat_response(&span, outcome.as_ref());
521                    let llm_response = outcome.map_err(AgentError::Llm)?;
522                    let usage = llm_response.usage.unwrap_or_default();
523                    crate::telemetry::metrics::record_llm_call(
524                        &model,
525                        &self.name,
526                        started.elapsed().as_secs_f64() * 1000.0,
527                        usage.prompt_tokens,
528                        usage.completion_tokens,
529                    );
530                    result.model_calls += 1;
531                    if let Some(usage) = llm_response.usage {
532                        result.usage += usage;
533                    }
534
535                    // after_model hook — may replace the response.
536                    match self
537                        .middleware
538                        .run_after_model(&llm_request, &llm_response)
539                        .await?
540                    {
541                        Some(replaced) => replaced,
542                        None => llm_response,
543                    }
544                }
545            };
546
547            if events.is_some() && !streamed {
548                let text = response.text();
549                if !text.is_empty() {
550                    emit(RunEvent::TextDelta(text));
551                }
552            }
553
554            let calls: Vec<FunctionCall> = response.function_calls().into_iter().cloned().collect();
555
556            if calls.is_empty() {
557                // No tool calls — we have a final text response.
558                result.text = response.text();
559                if !response.content.parts.is_empty() {
560                    contents.push(response.content);
561                }
562                result.messages = contents.split_off(history_len);
563                return Ok(result);
564            }
565
566            // Move model response into conversation (no clone needed).
567            contents.push(response.content);
568
569            // Dispatch tools (middleware hooks inside). Media a tool
570            // attached under `_media` is lifted out of the JSON and
571            // delivered as inline_data parts in the same turn, so the
572            // model *sees* images rather than base64 noise.
573            for call in &calls {
574                emit(RunEvent::ToolCall {
575                    name: call.name.clone(),
576                    args: call.args.clone(),
577                });
578            }
579            let already = result.tool_calls.len();
580            let tool_responses = self
581                .dispatch_tools(&calls, &mut result.tool_calls, state)
582                .await;
583            for record in &result.tool_calls[already..] {
584                emit(RunEvent::ToolResult(record.clone()));
585            }
586            let mut media_parts: Vec<Part> = Vec::new();
587            let mut response_parts: Vec<Part> = tool_responses
588                .into_iter()
589                .map(|mut fr| {
590                    for attachment in crate::tool::media::extract(&mut fr.response) {
591                        media_parts.push(Part::inline_data(
592                            attachment.mime_type,
593                            attachment.data_base64,
594                        ));
595                    }
596                    Part::FunctionResponse {
597                        function_response: fr,
598                    }
599                })
600                .collect();
601            response_parts.append(&mut media_parts);
602
603            contents.push(Content {
604                role: Some(Role::User),
605                parts: response_parts,
606            });
607        }
608
609        Err(AgentError::Other(format!(
610            "Agent '{}' exceeded max tool rounds ({})",
611            self.name, MAX_TOOL_ROUNDS
612        )))
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619    use crate::context::AgentEvent;
620    use crate::llm::{LlmError, LlmResponse};
621    use crate::middleware::Middleware;
622    use gemini_genai_rs::prelude::{Content, Part, Role};
623    use std::sync::atomic::{AtomicBool, Ordering};
624    use std::time::Duration;
625
626    fn text_response(t: &str) -> LlmResponse {
627        LlmResponse {
628            content: Content {
629                role: Some(Role::Model),
630                parts: vec![Part::Text { text: t.into() }],
631            },
632            finish_reason: Some("STOP".into()),
633            usage: None,
634        }
635    }
636
637    /// LLM that returns a function call on the first request, text on the
638    /// second, capturing every request it sees.
639    struct CapturingLlm {
640        requests: std::sync::Mutex<Vec<LlmRequest>>,
641    }
642    #[async_trait]
643    impl BaseLlm for CapturingLlm {
644        fn model_id(&self) -> &str {
645            "capturing"
646        }
647        async fn generate(&self, req: LlmRequest) -> Result<LlmResponse, LlmError> {
648            let mut requests = self.requests.lock().unwrap();
649            requests.push(req);
650            if requests.len() == 1 {
651                Ok(LlmResponse {
652                    content: Content {
653                        role: Some(Role::Model),
654                        parts: vec![Part::FunctionCall {
655                            function_call: gemini_genai_rs::prelude::FunctionCall {
656                                name: "snap".into(),
657                                args: serde_json::json!({}),
658                                id: None,
659                            },
660                        }],
661                    },
662                    finish_reason: None,
663                    usage: None,
664                })
665            } else {
666                Ok(text_response("described"))
667            }
668        }
669    }
670
671    #[tokio::test]
672    async fn tool_media_reaches_the_model_as_inline_data() {
673        use crate::tool::{SimpleTool, ToolDispatcher, media};
674        let llm = Arc::new(CapturingLlm {
675            requests: std::sync::Mutex::new(Vec::new()),
676        });
677        let mut dispatcher = ToolDispatcher::new();
678        dispatcher.register(SimpleTool::new(
679            "snap",
680            "Take a snapshot",
681            None,
682            |_args| async move {
683                let mut result = serde_json::json!({"took": true});
684                media::attach(&mut result, "image/png", b"fakepng");
685                Ok(result)
686            },
687        ));
688        let agent = LlmTextAgent::new("vision", llm.clone()).tools(Arc::new(dispatcher));
689        let state = State::new();
690        let _ = state.set("input", "what do you see?");
691        assert_eq!(agent.run(&state).await.unwrap(), "described");
692
693        let requests = llm.requests.lock().unwrap();
694        assert_eq!(requests.len(), 2);
695        // The second request's tool-response turn carries the image part and
696        // the function response JSON no longer contains the base64 blob.
697        let turn = requests[1].contents.last().unwrap();
698        let has_inline = turn
699            .parts
700            .iter()
701            .any(|p| matches!(p, Part::InlineData { .. }));
702        assert!(has_inline, "expected an inline_data part, got {turn:?}");
703        let fr_clean = turn.parts.iter().all(|p| match p {
704            Part::FunctionResponse { function_response } => {
705                function_response.response.get(media::MEDIA_KEY).is_none()
706            }
707            _ => true,
708        });
709        assert!(
710            fr_clean,
711            "media key should be stripped from the response JSON"
712        );
713    }
714
715    #[tokio::test]
716    async fn instruction_provider_resolves_against_state_each_run() {
717        let llm = Arc::new(CapturingLlm {
718            requests: std::sync::Mutex::new(Vec::new()),
719        });
720        let agent = LlmTextAgent::new("persona", llm.clone()).instruction_provider(|s: &State| {
721            format!(
722                "You are {}.",
723                s.get::<String>("persona").unwrap_or_default()
724            )
725        });
726        let state = State::new();
727        let _ = state.set("input", "hi");
728        let _ = state.set("persona", "a pirate");
729        // CapturingLlm returns a function call first; with no dispatcher the
730        // loop sends an empty tool-response turn and the second reply ends
731        // the run — both requests must carry the resolved instruction.
732        let _ = agent.run(&state).await.unwrap();
733        let requests = llm.requests.lock().unwrap();
734        assert!(
735            requests
736                .iter()
737                .all(|r| r.system_instruction.as_deref() == Some("You are a pirate."))
738        );
739    }
740
741    struct SlowLlm;
742    #[async_trait]
743    impl BaseLlm for SlowLlm {
744        fn model_id(&self) -> &str {
745            "slow"
746        }
747        async fn generate(&self, _req: LlmRequest) -> Result<LlmResponse, LlmError> {
748            tokio::time::sleep(Duration::from_millis(500)).await;
749            Ok(text_response("done"))
750        }
751    }
752
753    struct FastLlm;
754    #[async_trait]
755    impl BaseLlm for FastLlm {
756        fn model_id(&self) -> &str {
757            "fast"
758        }
759        async fn generate(&self, _req: LlmRequest) -> Result<LlmResponse, LlmError> {
760            Ok(text_response("hi"))
761        }
762    }
763
764    struct ShortTimeout;
765    #[async_trait]
766    impl Middleware for ShortTimeout {
767        fn name(&self) -> &str {
768            "short-timeout"
769        }
770        fn timeout(&self) -> Option<Duration> {
771            Some(Duration::from_millis(20))
772        }
773    }
774
775    struct EventFlag(Arc<AtomicBool>);
776    #[async_trait]
777    impl Middleware for EventFlag {
778        fn name(&self) -> &str {
779            "event-flag"
780        }
781        async fn on_event(&self, event: &AgentEvent) -> Result<(), AgentError> {
782            if matches!(event, AgentEvent::AgentStarted { .. }) {
783                self.0.store(true, Ordering::SeqCst);
784            }
785            Ok(())
786        }
787    }
788
789    #[tokio::test]
790    async fn timeout_aborts_slow_run() {
791        let agent =
792            LlmTextAgent::new("slowpoke", Arc::new(SlowLlm)).add_middleware(Arc::new(ShortTimeout));
793        let state = State::new();
794        let _ = state.set("input", "hi");
795        let err = agent.run(&state).await.expect_err("expected timeout");
796        assert!(format!("{err:?}").contains("timed out"), "got: {err:?}");
797    }
798
799    #[tokio::test]
800    async fn on_event_fires_for_agent_lifecycle() {
801        let flag = Arc::new(AtomicBool::new(false));
802        let agent = LlmTextAgent::new("a", Arc::new(FastLlm))
803            .add_middleware(Arc::new(EventFlag(flag.clone())));
804        let state = State::new();
805        let _ = state.set("input", "hi");
806        let _ = agent.run(&state).await;
807        assert!(
808            flag.load(Ordering::SeqCst),
809            "on_event(AgentStarted) should fire"
810        );
811    }
812
813    #[tokio::test]
814    async fn llm_provider_switches_model_per_run() {
815        // Two mock LLMs with distinguishable responses.
816        struct MockLlmA;
817        #[async_trait]
818        impl BaseLlm for MockLlmA {
819            fn model_id(&self) -> &str {
820                "mock-a"
821            }
822            async fn generate(&self, _req: LlmRequest) -> Result<LlmResponse, LlmError> {
823                Ok(text_response("from-a"))
824            }
825        }
826
827        struct MockLlmB;
828        #[async_trait]
829        impl BaseLlm for MockLlmB {
830            fn model_id(&self) -> &str {
831                "mock-b"
832            }
833            async fn generate(&self, _req: LlmRequest) -> Result<LlmResponse, LlmError> {
834                Ok(text_response("from-b"))
835            }
836        }
837
838        let llm_a = Arc::new(MockLlmA);
839        let llm_b = Arc::new(MockLlmB);
840
841        // Agent with provider that switches based on escalate flag.
842        let agent = LlmTextAgent::new("switcher", llm_a.clone()).llm_provider(move |state| {
843            if state.get::<bool>("escalate").unwrap_or(false) {
844                llm_b.clone()
845            } else {
846                llm_a.clone()
847            }
848        });
849
850        // Run 1: without escalate flag -> should use model A
851        let state = State::new();
852        let _ = state.set("input", "hi");
853        let result = agent.run(&state).await.unwrap();
854        assert_eq!(result, "from-a", "without escalate, should use model A");
855
856        // Run 2: with escalate flag -> should use model B
857        let state2 = State::new();
858        let _ = state2.set("input", "hi");
859        let _ = state2.set("escalate", true);
860        let result2 = agent.run(&state2).await.unwrap();
861        assert_eq!(result2, "from-b", "with escalate, should use model B");
862    }
863}