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
19fn 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
32type EventSender = tokio::sync::mpsc::UnboundedSender<Result<RunEvent, AgentError>>;
34
35const MAX_TOOL_ROUNDS: usize = 10;
37
38type LlmProviderFn = Arc<dyn Fn(&State) -> Arc<dyn BaseLlm> + Send + Sync>;
40
41pub struct LlmTextAgent {
56 name: String,
57 llm: Arc<dyn BaseLlm>,
58 instruction: Option<String>,
59 instruction_provider: Option<Arc<dyn crate::instruction::InstructionProvider>>,
62 llm_provider: Option<LlmProviderFn>,
66 dispatcher: Option<Arc<ToolDispatcher>>,
67 template: LlmRequest,
70 output_key: Option<String>,
72 middleware: MiddlewareChain,
73}
74
75impl LlmTextAgent {
76 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 pub fn instruction(mut self, inst: impl Into<String>) -> Self {
96 self.instruction = Some(inst.into());
97 self
98 }
99
100 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 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 pub fn tools(mut self, dispatcher: Arc<ToolDispatcher>) -> Self {
127 self.dispatcher = Some(dispatcher);
128 self
129 }
130
131 pub fn model(mut self, model: impl Into<String>) -> Self {
134 self.template.model = Some(model.into());
135 self
136 }
137
138 pub fn temperature(mut self, t: f32) -> Self {
140 self.template.temperature = Some(t);
141 self
142 }
143
144 pub fn max_output_tokens(mut self, n: u32) -> Self {
146 self.template.max_output_tokens = Some(n);
147 self
148 }
149
150 pub fn top_p(mut self, p: f32) -> Self {
152 self.template.top_p = Some(p);
153 self
154 }
155
156 pub fn top_k(mut self, k: u32) -> Self {
158 self.template.top_k = Some(k);
159 self
160 }
161
162 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 pub fn thinking_budget(mut self, tokens: u32) -> Self {
173 self.template.thinking_budget = Some(tokens);
174 self
175 }
176
177 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 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 pub fn output_key(mut self, key: impl Into<String>) -> Self {
194 self.output_key = Some(key.into());
195 self
196 }
197
198 pub fn add_middleware(mut self, mw: Arc<dyn crate::middleware::Middleware>) -> Self {
203 self.middleware.add(mw);
204 self
205 }
206
207 pub fn with_middleware_chain(mut self, chain: MiddlewareChain) -> Self {
209 self.middleware = chain;
210 self
211 }
212
213 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 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 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 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 if let Err(e) = self.middleware.run_before_tool(call).await {
281 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 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 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 let instruction = match &self.instruction_provider {
394 Some(provider) => Some(provider.provide(state)),
395 None => self.instruction.clone(),
396 };
397
398 let llm = self
401 .llm_provider
402 .as_ref()
403 .map(|p| p(state))
404 .unwrap_or_else(|| self.llm.clone());
405
406 let _ = self
408 .middleware
409 .run_on_event(&AgentEvent::AgentStarted {
410 name: self.name.clone(),
411 })
412 .await;
413
414 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 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 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 self.middleware
492 .run_transform_request(&mut llm_request)
493 .await?;
494
495 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 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 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 contents.push(response.content);
568
569 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 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 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 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 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 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 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 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}