1use std::sync::Arc;
2
3use async_trait::async_trait;
4use gemini_genai_rs::prelude::{Content, FunctionCall, FunctionResponse, Part, Role};
5
6use super::TextAgent;
7use crate::context::AgentEvent;
8use crate::error::AgentError;
9use crate::llm::{BaseLlm, LlmRequest};
10use crate::middleware::MiddlewareChain;
11use crate::state::State;
12use crate::tool::ToolDispatcher;
13
14const MAX_TOOL_ROUNDS: usize = 10;
16
17type LlmProviderFn = Arc<dyn Fn(&State) -> Arc<dyn BaseLlm> + Send + Sync>;
19
20pub struct LlmTextAgent {
35 name: String,
36 llm: Arc<dyn BaseLlm>,
37 instruction: Option<String>,
38 instruction_provider: Option<Arc<dyn crate::instruction::InstructionProvider>>,
41 llm_provider: Option<LlmProviderFn>,
45 dispatcher: Option<Arc<ToolDispatcher>>,
46 temperature: Option<f32>,
47 max_output_tokens: Option<u32>,
48 middleware: MiddlewareChain,
49}
50
51impl LlmTextAgent {
52 pub fn new(name: impl Into<String>, llm: Arc<dyn BaseLlm>) -> Self {
54 Self {
55 name: name.into(),
56 llm,
57 instruction: None,
58 instruction_provider: None,
59 llm_provider: None,
60 dispatcher: None,
61 temperature: None,
62 max_output_tokens: None,
63 middleware: MiddlewareChain::new(),
64 }
65 }
66
67 pub fn instruction(mut self, inst: impl Into<String>) -> Self {
69 self.instruction = Some(inst.into());
70 self
71 }
72
73 pub fn instruction_provider(
79 mut self,
80 provider: impl crate::instruction::InstructionProvider + 'static,
81 ) -> Self {
82 self.instruction_provider = Some(Arc::new(provider));
83 self
84 }
85
86 pub fn llm_provider<F>(mut self, provider: F) -> Self
91 where
92 F: Fn(&State) -> Arc<dyn BaseLlm> + Send + Sync + 'static,
93 {
94 self.llm_provider = Some(Arc::new(provider));
95 self
96 }
97
98 pub fn tools(mut self, dispatcher: Arc<ToolDispatcher>) -> Self {
100 self.dispatcher = Some(dispatcher);
101 self
102 }
103
104 pub fn temperature(mut self, t: f32) -> Self {
106 self.temperature = Some(t);
107 self
108 }
109
110 pub fn max_output_tokens(mut self, n: u32) -> Self {
112 self.max_output_tokens = Some(n);
113 self
114 }
115
116 pub fn add_middleware(mut self, mw: Arc<dyn crate::middleware::Middleware>) -> Self {
121 self.middleware.add(mw);
122 self
123 }
124
125 pub fn with_middleware_chain(mut self, chain: MiddlewareChain) -> Self {
127 self.middleware = chain;
128 self
129 }
130
131 fn build_request(&self, contents: Vec<Content>, instruction: &Option<String>) -> LlmRequest {
133 let mut req = LlmRequest::from_contents(contents);
134 req.system_instruction = instruction.clone();
135 req.temperature = self.temperature;
136 req.max_output_tokens = self.max_output_tokens;
137
138 if let Some(dispatcher) = &self.dispatcher {
139 req.tools = dispatcher.to_tool_declarations();
140 }
141
142 req
143 }
144
145 async fn dispatch_tools(&self, calls: &[FunctionCall]) -> Vec<FunctionResponse> {
147 let dispatcher = match &self.dispatcher {
148 Some(d) => d,
149 None => return Vec::new(),
150 };
151
152 let mut responses = Vec::with_capacity(calls.len());
153 for call in calls {
154 if let Err(e) = self.middleware.run_before_tool(call).await {
156 let _ = self
158 .middleware
159 .run_on_tool_error(
160 call,
161 &crate::error::ToolError::ExecutionFailed(e.to_string()),
162 )
163 .await;
164 responses.push(ToolDispatcher::build_response(
165 call,
166 Err(crate::error::ToolError::ExecutionFailed(e.to_string())),
167 ));
168 continue;
169 }
170
171 let result = dispatcher
172 .call_function(&call.name, call.args.clone())
173 .await;
174
175 match &result {
176 Ok(value) => {
177 let _ = self.middleware.run_after_tool(call, value).await;
178 }
179 Err(e) => {
180 let _ = self.middleware.run_on_tool_error(call, e).await;
181 }
182 }
183
184 responses.push(ToolDispatcher::build_response(call, result));
185 }
186 responses
187 }
188}
189
190#[async_trait]
191impl TextAgent for LlmTextAgent {
192 fn name(&self) -> &str {
193 &self.name
194 }
195
196 async fn run(&self, state: &State) -> Result<String, AgentError> {
197 let input = state.get::<String>("input").unwrap_or_default();
199
200 let mut contents = vec![Content::user(&input)];
201
202 let instruction = match &self.instruction_provider {
205 Some(provider) => Some(provider.provide(state)),
206 None => self.instruction.clone(),
207 };
208
209 let llm = self
212 .llm_provider
213 .as_ref()
214 .map(|p| p(state))
215 .unwrap_or_else(|| self.llm.clone());
216
217 let _ = self
219 .middleware
220 .run_on_event(&AgentEvent::AgentStarted {
221 name: self.name.clone(),
222 })
223 .await;
224
225 let result = match self.middleware.timeout() {
227 Some(limit) => {
228 match tokio::time::timeout(limit, self.run_inner(&mut contents, &instruction, &llm))
229 .await
230 {
231 Ok(r) => r,
232 Err(_) => {
233 let _ = self.middleware.run_on_event(&AgentEvent::Timeout).await;
234 Err(AgentError::Other(format!(
235 "agent '{}' timed out after {:?}",
236 self.name, limit
237 )))
238 }
239 }
240 }
241 None => self.run_inner(&mut contents, &instruction, &llm).await,
242 };
243
244 if let Err(ref e) = result {
245 let _ = self.middleware.run_on_error(e).await;
246 } else if let Ok(ref text) = result {
247 let _ = state.set("output", text);
248 let _ = self
249 .middleware
250 .run_on_event(&AgentEvent::AgentCompleted {
251 name: self.name.clone(),
252 })
253 .await;
254 }
255
256 result
257 }
258}
259
260impl LlmTextAgent {
261 async fn run_inner(
263 &self,
264 contents: &mut Vec<Content>,
265 instruction: &Option<String>,
266 llm: &Arc<dyn BaseLlm>,
267 ) -> Result<String, AgentError> {
268 for _round in 0..MAX_TOOL_ROUNDS {
269 let mut request = self.build_request(contents.clone(), instruction);
270
271 self.middleware.run_transform_request(&mut request).await?;
274
275 let response = match self.middleware.run_before_model(&request).await? {
277 Some(cached) => cached,
278 None => {
279 let llm_response = llm
280 .generate(request.clone())
281 .await
282 .map_err(|e| AgentError::Other(format!("LLM error: {e}")))?;
283
284 match self
286 .middleware
287 .run_after_model(&request, &llm_response)
288 .await?
289 {
290 Some(replaced) => replaced,
291 None => llm_response,
292 }
293 }
294 };
295
296 let calls: Vec<FunctionCall> = response.function_calls().into_iter().cloned().collect();
297
298 if calls.is_empty() {
299 return Ok(response.text());
301 }
302
303 contents.push(response.content);
305
306 let tool_responses = self.dispatch_tools(&calls).await;
311 let mut media_parts: Vec<Part> = Vec::new();
312 let mut response_parts: Vec<Part> = tool_responses
313 .into_iter()
314 .map(|mut fr| {
315 for attachment in crate::tool::media::extract(&mut fr.response) {
316 media_parts.push(Part::inline_data(
317 attachment.mime_type,
318 attachment.data_base64,
319 ));
320 }
321 Part::FunctionResponse {
322 function_response: fr,
323 }
324 })
325 .collect();
326 response_parts.append(&mut media_parts);
327
328 contents.push(Content {
329 role: Some(Role::User),
330 parts: response_parts,
331 });
332 }
333
334 Err(AgentError::Other(format!(
335 "Agent '{}' exceeded max tool rounds ({})",
336 self.name, MAX_TOOL_ROUNDS
337 )))
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344 use crate::context::AgentEvent;
345 use crate::llm::{LlmError, LlmResponse};
346 use crate::middleware::Middleware;
347 use gemini_genai_rs::prelude::{Content, Part, Role};
348 use std::sync::atomic::{AtomicBool, Ordering};
349 use std::time::Duration;
350
351 fn text_response(t: &str) -> LlmResponse {
352 LlmResponse {
353 content: Content {
354 role: Some(Role::Model),
355 parts: vec![Part::Text { text: t.into() }],
356 },
357 finish_reason: Some("STOP".into()),
358 usage: None,
359 }
360 }
361
362 struct CapturingLlm {
365 requests: std::sync::Mutex<Vec<LlmRequest>>,
366 }
367 #[async_trait]
368 impl BaseLlm for CapturingLlm {
369 fn model_id(&self) -> &str {
370 "capturing"
371 }
372 async fn generate(&self, req: LlmRequest) -> Result<LlmResponse, LlmError> {
373 let mut requests = self.requests.lock().unwrap();
374 requests.push(req);
375 if requests.len() == 1 {
376 Ok(LlmResponse {
377 content: Content {
378 role: Some(Role::Model),
379 parts: vec![Part::FunctionCall {
380 function_call: gemini_genai_rs::prelude::FunctionCall {
381 name: "snap".into(),
382 args: serde_json::json!({}),
383 id: None,
384 },
385 }],
386 },
387 finish_reason: None,
388 usage: None,
389 })
390 } else {
391 Ok(text_response("described"))
392 }
393 }
394 }
395
396 #[tokio::test]
397 async fn tool_media_reaches_the_model_as_inline_data() {
398 use crate::tool::{SimpleTool, ToolDispatcher, media};
399 let llm = Arc::new(CapturingLlm {
400 requests: std::sync::Mutex::new(Vec::new()),
401 });
402 let mut dispatcher = ToolDispatcher::new();
403 dispatcher.register(SimpleTool::new(
404 "snap",
405 "Take a snapshot",
406 None,
407 |_args| async move {
408 let mut result = serde_json::json!({"took": true});
409 media::attach(&mut result, "image/png", b"fakepng");
410 Ok(result)
411 },
412 ));
413 let agent = LlmTextAgent::new("vision", llm.clone()).tools(Arc::new(dispatcher));
414 let state = State::new();
415 let _ = state.set("input", "what do you see?");
416 assert_eq!(agent.run(&state).await.unwrap(), "described");
417
418 let requests = llm.requests.lock().unwrap();
419 assert_eq!(requests.len(), 2);
420 let turn = requests[1].contents.last().unwrap();
423 let has_inline = turn
424 .parts
425 .iter()
426 .any(|p| matches!(p, Part::InlineData { .. }));
427 assert!(has_inline, "expected an inline_data part, got {turn:?}");
428 let fr_clean = turn.parts.iter().all(|p| match p {
429 Part::FunctionResponse { function_response } => {
430 function_response.response.get(media::MEDIA_KEY).is_none()
431 }
432 _ => true,
433 });
434 assert!(
435 fr_clean,
436 "media key should be stripped from the response JSON"
437 );
438 }
439
440 #[tokio::test]
441 async fn instruction_provider_resolves_against_state_each_run() {
442 let llm = Arc::new(CapturingLlm {
443 requests: std::sync::Mutex::new(Vec::new()),
444 });
445 let agent = LlmTextAgent::new("persona", llm.clone()).instruction_provider(|s: &State| {
446 format!(
447 "You are {}.",
448 s.get::<String>("persona").unwrap_or_default()
449 )
450 });
451 let state = State::new();
452 let _ = state.set("input", "hi");
453 let _ = state.set("persona", "a pirate");
454 let _ = agent.run(&state).await.unwrap();
458 let requests = llm.requests.lock().unwrap();
459 assert!(
460 requests
461 .iter()
462 .all(|r| r.system_instruction.as_deref() == Some("You are a pirate."))
463 );
464 }
465
466 struct SlowLlm;
467 #[async_trait]
468 impl BaseLlm for SlowLlm {
469 fn model_id(&self) -> &str {
470 "slow"
471 }
472 async fn generate(&self, _req: LlmRequest) -> Result<LlmResponse, LlmError> {
473 tokio::time::sleep(Duration::from_millis(500)).await;
474 Ok(text_response("done"))
475 }
476 }
477
478 struct FastLlm;
479 #[async_trait]
480 impl BaseLlm for FastLlm {
481 fn model_id(&self) -> &str {
482 "fast"
483 }
484 async fn generate(&self, _req: LlmRequest) -> Result<LlmResponse, LlmError> {
485 Ok(text_response("hi"))
486 }
487 }
488
489 struct ShortTimeout;
490 #[async_trait]
491 impl Middleware for ShortTimeout {
492 fn name(&self) -> &str {
493 "short-timeout"
494 }
495 fn timeout(&self) -> Option<Duration> {
496 Some(Duration::from_millis(20))
497 }
498 }
499
500 struct EventFlag(Arc<AtomicBool>);
501 #[async_trait]
502 impl Middleware for EventFlag {
503 fn name(&self) -> &str {
504 "event-flag"
505 }
506 async fn on_event(&self, event: &AgentEvent) -> Result<(), AgentError> {
507 if matches!(event, AgentEvent::AgentStarted { .. }) {
508 self.0.store(true, Ordering::SeqCst);
509 }
510 Ok(())
511 }
512 }
513
514 #[tokio::test]
515 async fn timeout_aborts_slow_run() {
516 let agent =
517 LlmTextAgent::new("slowpoke", Arc::new(SlowLlm)).add_middleware(Arc::new(ShortTimeout));
518 let state = State::new();
519 let _ = state.set("input", "hi");
520 let err = agent.run(&state).await.expect_err("expected timeout");
521 assert!(format!("{err:?}").contains("timed out"), "got: {err:?}");
522 }
523
524 #[tokio::test]
525 async fn on_event_fires_for_agent_lifecycle() {
526 let flag = Arc::new(AtomicBool::new(false));
527 let agent = LlmTextAgent::new("a", Arc::new(FastLlm))
528 .add_middleware(Arc::new(EventFlag(flag.clone())));
529 let state = State::new();
530 let _ = state.set("input", "hi");
531 let _ = agent.run(&state).await;
532 assert!(
533 flag.load(Ordering::SeqCst),
534 "on_event(AgentStarted) should fire"
535 );
536 }
537
538 #[tokio::test]
539 async fn llm_provider_switches_model_per_run() {
540 struct MockLlmA;
542 #[async_trait]
543 impl BaseLlm for MockLlmA {
544 fn model_id(&self) -> &str {
545 "mock-a"
546 }
547 async fn generate(&self, _req: LlmRequest) -> Result<LlmResponse, LlmError> {
548 Ok(text_response("from-a"))
549 }
550 }
551
552 struct MockLlmB;
553 #[async_trait]
554 impl BaseLlm for MockLlmB {
555 fn model_id(&self) -> &str {
556 "mock-b"
557 }
558 async fn generate(&self, _req: LlmRequest) -> Result<LlmResponse, LlmError> {
559 Ok(text_response("from-b"))
560 }
561 }
562
563 let llm_a = Arc::new(MockLlmA);
564 let llm_b = Arc::new(MockLlmB);
565
566 let agent = LlmTextAgent::new("switcher", llm_a.clone()).llm_provider(move |state| {
568 if state.get::<bool>("escalate").unwrap_or(false) {
569 llm_b.clone()
570 } else {
571 llm_a.clone()
572 }
573 });
574
575 let state = State::new();
577 let _ = state.set("input", "hi");
578 let result = agent.run(&state).await.unwrap();
579 assert_eq!(result, "from-a", "without escalate, should use model A");
580
581 let state2 = State::new();
583 let _ = state2.set("input", "hi");
584 let _ = state2.set("escalate", true);
585 let result2 = agent.run(&state2).await.unwrap();
586 assert_eq!(result2, "from-b", "with escalate, should use model B");
587 }
588}