1use async_trait::async_trait;
14use futures::StreamExt;
15use futures::stream;
16use std::sync::Arc;
17use std::sync::atomic::{AtomicUsize, Ordering};
18
19use crate::driver_registry::{
20 BoxedChatDriver, ChatDriver, DriverDescriptor, DriverId, DriverRegistry, LlmCallConfig,
21 LlmCompletionMetadata, LlmMessage, LlmMessageRole, LlmResponseStream, LlmStreamEvent,
22};
23use crate::error::{AgentLoopError, Result};
24use crate::tool_types::ToolCall;
25use llmsim::generator::{LoremGenerator, ResponseGenerator};
26use llmsim::latency::LatencyProfile;
27use llmsim::openai::{ChatCompletionRequest, Message, Role, Usage};
28use llmsim::script::auto_tool_call_id;
29use llmsim::stream::TokenStreamBuilder;
30
31#[derive(Debug, Clone)]
37pub struct LlmSimConfig {
38 pub response: ResponseConfig,
40 pub tool_calls: Option<ToolCallConfig>,
42 pub simulate_latency: bool,
44 pub model_name: String,
46 pub response_delay: Option<std::time::Duration>,
50 pub response_id: Option<String>,
53 pub effort_capture: Option<Arc<std::sync::Mutex<Vec<Option<String>>>>>,
58 pub message_capture: Option<Arc<std::sync::Mutex<Vec<Vec<LlmMessage>>>>>,
64}
65
66impl Default for LlmSimConfig {
67 fn default() -> Self {
68 Self {
69 response: ResponseConfig::Fixed("Hello! I'm a simulated LLM response.".to_string()),
70 tool_calls: None,
71 simulate_latency: false,
72 model_name: "llmsim-model".to_string(),
73 response_delay: None,
74 response_id: None,
75 effort_capture: None,
76 message_capture: None,
77 }
78 }
79}
80
81impl LlmSimConfig {
82 pub fn fixed(response: impl Into<String>) -> Self {
84 Self {
85 response: ResponseConfig::Fixed(response.into()),
86 ..Default::default()
87 }
88 }
89
90 pub fn echo() -> Self {
92 Self {
93 response: ResponseConfig::Echo,
94 ..Default::default()
95 }
96 }
97
98 pub fn lorem(target_tokens: usize) -> Self {
100 Self {
101 response: ResponseConfig::Lorem { target_tokens },
102 ..Default::default()
103 }
104 }
105
106 pub fn sequence(responses: Vec<String>) -> Self {
108 Self {
109 response: ResponseConfig::Sequence(responses),
110 ..Default::default()
111 }
112 }
113
114 pub fn scripted(turns: Vec<SimTurn>) -> Self {
116 Self {
117 response: ResponseConfig::Scripted {
118 turns,
119 on_exhausted: OnExhausted::default(),
120 },
121 ..Default::default()
122 }
123 }
124
125 pub fn with_on_exhausted(mut self, mode: OnExhausted) -> Self {
127 if let ResponseConfig::Scripted { on_exhausted, .. } = &mut self.response {
128 *on_exhausted = mode;
129 }
130 self
131 }
132
133 pub fn with_tool_calls(mut self, tool_calls: Vec<ToolCall>) -> Self {
135 self.tool_calls = Some(ToolCallConfig::Fixed(tool_calls));
136 self
137 }
138
139 pub fn with_tool_call_sequence(mut self, sequences: Vec<Vec<ToolCall>>) -> Self {
141 self.tool_calls = Some(ToolCallConfig::Sequence(sequences));
142 self
143 }
144
145 pub fn with_latency(mut self) -> Self {
147 self.simulate_latency = true;
148 self
149 }
150
151 pub fn with_model(mut self, model: impl Into<String>) -> Self {
153 self.model_name = model.into();
154 self
155 }
156
157 pub fn with_response_delay(mut self, delay: std::time::Duration) -> Self {
160 self.response_delay = Some(delay);
161 self
162 }
163
164 pub fn with_response_id(mut self, id: impl Into<String>) -> Self {
166 self.response_id = Some(id.into());
167 self
168 }
169
170 pub fn with_effort_capture(
174 mut self,
175 capture: Arc<std::sync::Mutex<Vec<Option<String>>>>,
176 ) -> Self {
177 self.effort_capture = Some(capture);
178 self
179 }
180
181 pub fn with_message_capture(
185 mut self,
186 capture: Arc<std::sync::Mutex<Vec<Vec<LlmMessage>>>>,
187 ) -> Self {
188 self.message_capture = Some(capture);
189 self
190 }
191
192 pub fn error(message: impl Into<String>) -> Self {
194 Self {
195 response: ResponseConfig::Error(message.into()),
196 ..Default::default()
197 }
198 }
199
200 pub fn model_not_available() -> Self {
202 Self {
203 response: ResponseConfig::ModelNotAvailable,
204 ..Default::default()
205 }
206 }
207}
208
209#[derive(Debug, Clone)]
211pub enum ResponseConfig {
212 Fixed(String),
214 Echo,
216 Lorem { target_tokens: usize },
218 Sequence(Vec<String>),
220 Scripted {
222 turns: Vec<SimTurn>,
223 on_exhausted: OnExhausted,
224 },
225 Empty,
227 Error(String),
229 ModelNotAvailable,
231}
232
233#[derive(Debug, Clone, PartialEq)]
235pub enum SimTurn {
236 Assistant(String),
238 ToolCalls(Vec<SimToolCall>),
240 Mixed {
242 text: String,
243 tool_calls: Vec<SimToolCall>,
244 },
245 Error(SimError),
247 StreamStall,
249}
250
251#[derive(Debug, Clone, PartialEq)]
253pub struct SimToolCall {
254 pub name: String,
255 pub arguments: serde_json::Value,
256 pub id: Option<String>,
257}
258
259#[derive(Debug, Clone, PartialEq)]
261pub enum SimError {
262 RateLimit,
263 Timeout,
264 Transport,
265 Overloaded,
266 Authentication,
267 QuotaExhausted,
268 UnsupportedModel(String),
269 InvalidResponse(String),
270 Other(String),
271}
272
273impl SimError {
274 fn message(&self) -> String {
275 match self {
276 SimError::RateLimit => "Rate limit exceeded. Please retry after some time.".to_string(),
277 SimError::Timeout => "Request timed out".to_string(),
278 SimError::Transport => "Transport connection failed".to_string(),
279 SimError::Overloaded => "Provider overloaded".to_string(),
280 SimError::Authentication => "Invalid provider credentials".to_string(),
281 SimError::QuotaExhausted => "Provider quota exhausted".to_string(),
282 SimError::UnsupportedModel(model) => format!("Model not available: {model}"),
283 SimError::InvalidResponse(message) | SimError::Other(message) => message.clone(),
284 }
285 }
286
287 fn agent_error(&self) -> AgentLoopError {
288 use crate::error::LlmErrorKind;
289
290 match self {
291 SimError::RateLimit => {
292 AgentLoopError::llm_kind(LlmErrorKind::RateLimited, self.message())
293 }
294 SimError::Timeout | SimError::Transport | SimError::Overloaded => {
295 AgentLoopError::llm_kind(LlmErrorKind::Unavailable, self.message())
296 }
297 SimError::Other(_) => AgentLoopError::llm_kind(LlmErrorKind::Other, self.message()),
298 SimError::Authentication => {
299 AgentLoopError::llm_kind(LlmErrorKind::Authentication, self.message())
300 }
301 SimError::QuotaExhausted => {
302 AgentLoopError::llm_kind(LlmErrorKind::QuotaExhausted, self.message())
303 }
304 SimError::UnsupportedModel(model) => AgentLoopError::model_not_available(model),
305 SimError::InvalidResponse(_) => {
306 AgentLoopError::llm_kind(LlmErrorKind::InvalidRequest, self.message())
307 }
308 }
309 }
310}
311
312#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
314pub enum OnExhausted {
315 #[default]
317 RepeatLast,
318 Error,
320 Loop,
322}
323
324#[derive(Debug, Clone)]
326pub enum ToolCallConfig {
327 Fixed(Vec<ToolCall>),
329 Sequence(Vec<Vec<ToolCall>>),
331 Conditional {
333 patterns: Vec<ToolCallPattern>,
335 },
336}
337
338#[derive(Debug, Clone)]
340pub struct ToolCallPattern {
341 pub contains: String,
343 pub tool_calls: Vec<ToolCall>,
345}
346
347impl ToolCallPattern {
348 pub fn new(contains: impl Into<String>, tool_calls: Vec<ToolCall>) -> Self {
349 Self {
350 contains: contains.into(),
351 tool_calls,
352 }
353 }
354}
355
356fn materialize_scripted_tool_calls(
357 turn_index: usize,
358 calls: Vec<SimToolCall>,
359) -> Option<Vec<ToolCall>> {
360 if calls.is_empty() {
361 return None;
362 }
363
364 Some(
365 calls
366 .into_iter()
367 .enumerate()
368 .map(|(call_index, call)| ToolCall {
369 id: call
370 .id
371 .unwrap_or_else(|| auto_tool_call_id(turn_index, call_index)),
372 name: call.name,
373 arguments: call.arguments,
374 })
375 .collect(),
376 )
377}
378
379#[derive(Clone)]
412pub struct LlmSimDriver {
413 config: LlmSimConfig,
414 response_counter: Arc<AtomicUsize>,
416 tool_call_counter: Arc<AtomicUsize>,
418}
419
420struct GeneratedTurn {
421 text: String,
422 tool_calls: Option<Vec<ToolCall>>,
423 stream_stall: bool,
424}
425
426impl LlmSimDriver {
427 pub fn new(config: LlmSimConfig) -> Self {
429 Self {
430 config,
431 response_counter: Arc::new(AtomicUsize::new(0)),
432 tool_call_counter: Arc::new(AtomicUsize::new(0)),
433 }
434 }
435
436 pub fn default_driver() -> Self {
438 Self::new(LlmSimConfig::default())
439 }
440
441 fn generate_response(&self, messages: &[LlmMessage]) -> String {
443 match &self.config.response {
444 ResponseConfig::Fixed(text) => text.clone(),
445
446 ResponseConfig::Echo => {
447 let last_user = messages
449 .iter()
450 .rev()
451 .find(|m| m.role == LlmMessageRole::User)
452 .map(|m| m.content_as_text())
453 .unwrap_or_default();
454 format!("Echo: {}", last_user)
455 }
456
457 ResponseConfig::Lorem { target_tokens } => {
458 let generator = LoremGenerator::new(*target_tokens);
459 let request = self.to_chat_request(messages);
460 generator.generate(&request)
461 }
462
463 ResponseConfig::Sequence(responses) => {
464 if responses.is_empty() {
465 return String::new();
466 }
467 let idx = self.response_counter.fetch_add(1, Ordering::SeqCst);
468 responses[idx % responses.len()].clone()
469 }
470
471 ResponseConfig::Empty => String::new(),
472
473 ResponseConfig::Error(_)
475 | ResponseConfig::ModelNotAvailable
476 | ResponseConfig::Scripted { .. } => {
477 unreachable!("Special configs handled in chat_completion_stream")
478 }
479 }
480 }
481
482 fn get_tool_calls(&self, messages: &[LlmMessage]) -> Option<Vec<ToolCall>> {
484 match &self.config.tool_calls {
485 None => None,
486
487 Some(ToolCallConfig::Fixed(calls)) => {
488 if calls.is_empty() {
489 None
490 } else {
491 Some(calls.clone())
492 }
493 }
494
495 Some(ToolCallConfig::Sequence(sequences)) => {
496 if sequences.is_empty() {
497 return None;
498 }
499 let idx = self.tool_call_counter.fetch_add(1, Ordering::SeqCst);
500 let calls = &sequences[idx % sequences.len()];
501 if calls.is_empty() {
502 None
503 } else {
504 Some(calls.clone())
505 }
506 }
507
508 Some(ToolCallConfig::Conditional { patterns }) => {
509 for message in messages.iter().rev() {
518 if message.role != LlmMessageRole::User {
519 continue;
520 }
521 let text = message.content_as_text();
522 if let Some(pattern) = patterns.iter().find(|p| text.contains(&p.contains)) {
523 return if pattern.tool_calls.is_empty() {
524 None
525 } else {
526 Some(pattern.tool_calls.clone())
527 };
528 }
529 }
530 None
531 }
532 }
533 }
534
535 fn generate_turn(&self, messages: &[LlmMessage]) -> Result<GeneratedTurn> {
536 if let ResponseConfig::Scripted {
537 turns,
538 on_exhausted,
539 } = &self.config.response
540 {
541 return self.generate_scripted_turn(turns, *on_exhausted);
542 }
543
544 Ok(GeneratedTurn {
545 text: self.generate_response(messages),
546 tool_calls: self.get_tool_calls(messages),
547 stream_stall: false,
548 })
549 }
550
551 fn generate_scripted_turn(
552 &self,
553 turns: &[SimTurn],
554 on_exhausted: OnExhausted,
555 ) -> Result<GeneratedTurn> {
556 if turns.is_empty() {
557 return Err(AgentLoopError::config(
558 "llmsim scripted config must contain at least one turn",
559 ));
560 }
561
562 let turn_index = self.response_counter.fetch_add(1, Ordering::SeqCst);
563 let turn = if turn_index < turns.len() {
564 turns[turn_index].clone()
565 } else {
566 match on_exhausted {
567 OnExhausted::RepeatLast => turns[turns.len() - 1].clone(),
568 OnExhausted::Loop => turns[turn_index % turns.len()].clone(),
569 OnExhausted::Error => {
570 return Err(AgentLoopError::config("llmsim scripted config exhausted"));
571 }
572 }
573 };
574
575 match turn {
576 SimTurn::Assistant(text) => Ok(GeneratedTurn {
577 text,
578 tool_calls: None,
579 stream_stall: false,
580 }),
581 SimTurn::ToolCalls(calls) => Ok(GeneratedTurn {
582 text: String::new(),
583 tool_calls: materialize_scripted_tool_calls(turn_index, calls),
584 stream_stall: false,
585 }),
586 SimTurn::Mixed { text, tool_calls } => Ok(GeneratedTurn {
587 text,
588 tool_calls: materialize_scripted_tool_calls(turn_index, tool_calls),
589 stream_stall: false,
590 }),
591 SimTurn::Error(error) => Err(error.agent_error()),
592 SimTurn::StreamStall => Ok(GeneratedTurn {
593 text: String::new(),
594 tool_calls: None,
595 stream_stall: true,
596 }),
597 }
598 }
599
600 fn to_chat_request(&self, messages: &[LlmMessage]) -> ChatCompletionRequest {
602 let sim_messages: Vec<Message> = messages
603 .iter()
604 .map(|m| {
605 let role = match m.role {
606 LlmMessageRole::System => Role::System,
607 LlmMessageRole::User => Role::User,
608 LlmMessageRole::Assistant => Role::Assistant,
609 LlmMessageRole::Tool => Role::Tool,
610 };
611 Message {
612 role,
613 content: Some(m.content_as_text()),
614 name: None,
615 tool_calls: None,
616 tool_call_id: m.tool_call_id.clone(),
617 }
618 })
619 .collect();
620
621 ChatCompletionRequest {
622 model: self.config.model_name.clone(),
623 messages: sim_messages,
624 temperature: None,
625 top_p: None,
626 n: None,
627 max_tokens: None,
628 max_completion_tokens: None,
629 stream: true,
630 stop: None,
631 presence_penalty: None,
632 frequency_penalty: None,
633 logit_bias: None,
634 user: None,
635 tools: None,
636 tool_choice: None,
637 seed: None,
638 response_format: None,
639 }
640 }
641
642 fn resolve_latency_profile(&self, model_name: &str) -> LatencyProfile {
647 if self.config.simulate_latency || model_name.contains("-latency") {
648 LatencyProfile::fast()
649 } else {
650 LatencyProfile::instant()
651 }
652 }
653
654 fn estimate_tokens(text: &str) -> u32 {
656 (text.len() / 4).max(1) as u32
658 }
659}
660
661#[async_trait]
662impl ChatDriver for LlmSimDriver {
663 async fn chat_completion_stream(
664 &self,
665 _endpoint: &crate::ProviderEndpoint,
666 messages: Vec<LlmMessage>,
667 config: &LlmCallConfig,
668 ) -> Result<LlmResponseStream> {
669 if let Some(capture) = &self.config.effort_capture
672 && let Ok(mut efforts) = capture.lock()
673 {
674 efforts.push(config.reasoning_effort.clone());
675 }
676
677 if let Some(capture) = &self.config.message_capture
680 && let Ok(mut calls) = capture.lock()
681 {
682 calls.push(messages.clone());
683 }
684
685 if let ResponseConfig::Error(error_msg) = &self.config.response {
687 return Err(anyhow::anyhow!("LLM error: {}", error_msg).into());
688 }
689 if matches!(self.config.response, ResponseConfig::ModelNotAvailable) {
690 return Err(AgentLoopError::model_not_available(config.model.clone()));
691 }
692
693 let delay = self
698 .config
699 .response_delay
700 .or_else(|| parse_ttft_from_model_name(&config.model));
701 if let Some(delay) = delay {
702 tokio::time::sleep(delay).await;
703 }
704
705 let generated_turn = self.generate_turn(&messages)?;
706 if generated_turn.stream_stall {
707 return Ok(Box::pin(futures::stream::pending()));
708 }
709 let response_text = generated_turn.text;
710 let tool_calls = generated_turn.tool_calls;
711 let model_name = config.model.clone();
712 let response_id_for_done = self.config.response_id.clone();
713 let latency_profile = self.resolve_latency_profile(&model_name);
714
715 let prompt_tokens: u32 = messages
717 .iter()
718 .map(|m| Self::estimate_tokens(&m.content_as_text()))
719 .sum();
720 let completion_tokens = Self::estimate_tokens(&response_text);
721
722 let usage = Usage {
725 prompt_tokens,
726 completion_tokens,
727 total_tokens: prompt_tokens + completion_tokens,
728 };
729
730 let chunk_stream = TokenStreamBuilder::new(&model_name, &response_text)
731 .latency(latency_profile)
732 .usage(usage)
733 .build()
734 .into_chunk_stream();
735
736 let tool_calls_tail = tool_calls;
739 let model_name_done = model_name.clone();
740 let event_stream = chunk_stream.flat_map(move |chunk| {
741 let mut events: Vec<Result<LlmStreamEvent>> = Vec::new();
742
743 for choice in &chunk.choices {
744 if let Some(content) = &choice.delta.content
745 && !content.is_empty()
746 {
747 events.push(Ok(LlmStreamEvent::TextDelta(content.clone())));
748 }
749 }
750
751 stream::iter(events)
752 });
753
754 let done_events: Vec<Result<LlmStreamEvent>> = {
756 let mut tail = Vec::new();
757 if let Some(calls) = tool_calls_tail {
758 tail.push(Ok(LlmStreamEvent::ToolCalls(calls)));
759 }
760 tail.push(Ok(LlmStreamEvent::Done(Box::new(LlmCompletionMetadata {
761 total_tokens: Some(prompt_tokens + completion_tokens),
762 prompt_tokens: Some(prompt_tokens),
763 completion_tokens: Some(completion_tokens),
764 cache_read_tokens: None,
765 cache_creation_tokens: None,
766 provider_cost_usd: None,
767 model: Some(model_name_done),
768 finish_reason: Some("stop".to_string()),
769 retry_metadata: None,
770 response_id: response_id_for_done,
771 phase: None,
772 }))));
773 tail
774 };
775
776 let full_stream = event_stream.chain(stream::iter(done_events));
777 Ok(Box::pin(full_stream))
778 }
779}
780
781impl std::fmt::Debug for LlmSimDriver {
782 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
783 f.debug_struct("LlmSimDriver")
784 .field("model", &self.config.model_name)
785 .field("simulate_latency", &self.config.simulate_latency)
786 .finish()
787 }
788}
789
790pub fn register_driver(registry: &mut DriverRegistry) {
810 let mut descriptor = DriverDescriptor::chat_only(DriverId::LlmSim, |_config| {
811 Box::new(LlmSimDriver::default_driver()) as BoxedChatDriver
813 });
814 descriptor.display_name = "LLM Simulator".into();
815 registry.register_descriptor_or_replace(descriptor);
816}
817
818pub fn register_driver_with_config(registry: &mut DriverRegistry, config: LlmSimConfig) {
827 let driver = LlmSimDriver::new(config);
828 let mut descriptor = DriverDescriptor::chat_only(DriverId::LlmSim, move |_config| {
829 Box::new(driver.clone()) as BoxedChatDriver
830 });
831 descriptor.display_name = "LLM Simulator".into();
832 registry.register_descriptor_or_replace(descriptor);
833}
834
835fn parse_ttft_from_model_name(model_name: &str) -> Option<std::time::Duration> {
841 if let Some(idx) = model_name.find("-ttft-") {
842 let after_ttft = &model_name[idx + 6..]; let ms_str: String = after_ttft
844 .chars()
845 .take_while(|c| c.is_ascii_digit())
846 .collect();
847 if let Ok(ms) = ms_str.parse::<u64>()
848 && ms > 0
849 {
850 return Some(std::time::Duration::from_millis(ms));
851 }
852 }
853 None
854}
855
856pub fn create_chat_driver(config: LlmSimConfig) -> BoxedChatDriver {
872 Box::new(LlmSimDriver::new(config))
873}
874
875pub fn auditor_demo_script() -> LlmSimConfig {
893 let turns = vec![
894 SimTurn::Mixed {
895 text: "Starting the audit. Listing EC2 instances first.".to_string(),
896 tool_calls: vec![SimToolCall {
897 name: "aws_list_ec2_instances".to_string(),
898 arguments: serde_json::json!({}),
899 id: Some("call_demo_ec2".to_string()),
900 }],
901 },
902 SimTurn::Mixed {
903 text: "EC2 inventory captured. Listing S3 buckets next.".to_string(),
904 tool_calls: vec![SimToolCall {
905 name: "aws_list_s3_buckets".to_string(),
906 arguments: serde_json::json!({}),
907 id: Some("call_demo_s3".to_string()),
908 }],
909 },
910 SimTurn::Assistant(
911 "Audit complete: inventoried EC2 instances and S3 buckets. \
912 See /workspace/.audit.log for the per-tool-call audit trail \
913 written by the post_tool_use hook bundle."
914 .to_string(),
915 ),
916 ];
917 LlmSimConfig::scripted(turns)
918}
919
920pub fn guarded_bash_demo_script() -> LlmSimConfig {
930 let turns = vec![
931 SimTurn::Mixed {
932 text: "Step 1: attempting a destructive command.".to_string(),
933 tool_calls: vec![SimToolCall {
934 name: "bash".to_string(),
935 arguments: serde_json::json!({ "commands": "rm -rf /" }),
936 id: Some("call_demo_rm".to_string()),
937 }],
938 },
939 SimTurn::Mixed {
940 text: "Step 2: trying a safe command.".to_string(),
941 tool_calls: vec![SimToolCall {
942 name: "bash".to_string(),
943 arguments: serde_json::json!({ "commands": "ls -la /workspace" }),
944 id: Some("call_demo_ls".to_string()),
945 }],
946 },
947 SimTurn::Assistant(
948 "Guarded-bash demo complete. The first tool call should be \
949 blocked by the pre_tool_use hook; the second should succeed."
950 .to_string(),
951 ),
952 ];
953 LlmSimConfig::scripted(turns)
954}
955
956pub fn session_tasks_demo_script() -> LlmSimConfig {
964 let turns = vec![
965 SimTurn::Mixed {
966 text: "Kicking off a background bash run.".to_string(),
967 tool_calls: vec![SimToolCall {
968 name: "spawn_background".to_string(),
969 arguments: serde_json::json!({
970 "tool": "bash",
971 "args": { "commands": "echo task demo start; echo task demo done" },
972 "title": "Demo background run",
973 "signal_on_completion": false,
974 }),
975 id: Some("call_demo_spawn".to_string()),
976 }],
977 },
978 SimTurn::Mixed {
979 text: "Checking the session task registry.".to_string(),
980 tool_calls: vec![SimToolCall {
981 name: "list_tasks".to_string(),
982 arguments: serde_json::json!({}),
983 id: Some("call_demo_list".to_string()),
984 }],
985 },
986 SimTurn::Assistant(
987 "Session tasks demo complete: a background run was started and \
988 tracked as a session task. Inspect it via \
989 GET /v1/sessions/{session_id}/tasks."
990 .to_string(),
991 ),
992 ];
993 LlmSimConfig::scripted(turns)
994}
995
996pub fn monitor_demo_script() -> LlmSimConfig {
1001 let turns = vec![
1002 SimTurn::Mixed {
1003 text: "Setting up a recurring monitor.".to_string(),
1004 tool_calls: vec![SimToolCall {
1005 name: "spawn_background".to_string(),
1006 arguments: serde_json::json!({
1007 "tool": "bash",
1008 "args": { "commands": "echo monitor check" },
1009 "title": "Demo monitor",
1010 "signal_on_completion": false,
1011 "schedule": { "cron_expression": "0 * * * * * *", "timezone": "UTC" },
1012 }),
1013 id: Some("call_demo_monitor".to_string()),
1014 }],
1015 },
1016 SimTurn::Assistant(
1017 "Monitor demo complete: a recurring monitor was scheduled and tracked as a session task. Inspect it via GET /v1/sessions/{session_id}/tasks."
1018 .to_string(),
1019 ),
1020 ];
1021 LlmSimConfig::scripted(turns)
1022}
1023
1024#[cfg(test)]
1029mod tests {
1030 use super::*;
1031 use futures::StreamExt;
1032
1033 impl LlmSimDriver {
1034 async fn chat_completion(
1035 &self,
1036 messages: Vec<LlmMessage>,
1037 config: &LlmCallConfig,
1038 ) -> Result<crate::driver_registry::LlmResponse> {
1039 ChatDriver::chat_completion(self, &crate::ProviderEndpoint::default(), messages, config)
1040 .await
1041 }
1042
1043 async fn chat_completion_stream(
1044 &self,
1045 messages: Vec<LlmMessage>,
1046 config: &LlmCallConfig,
1047 ) -> Result<LlmResponseStream> {
1048 ChatDriver::chat_completion_stream(
1049 self,
1050 &crate::ProviderEndpoint::default(),
1051 messages,
1052 config,
1053 )
1054 .await
1055 }
1056 }
1057
1058 #[test]
1059 fn auditor_demo_script_calls_ec2_then_s3_then_summarises() {
1060 let config = auditor_demo_script();
1061 let turns = match &config.response {
1062 ResponseConfig::Scripted { turns, .. } => turns,
1063 other => panic!("expected Scripted, got {other:?}"),
1064 };
1065 assert_eq!(turns.len(), 3, "script has three turns");
1066 match &turns[0] {
1067 SimTurn::Mixed { tool_calls, .. } => {
1068 assert_eq!(tool_calls.len(), 1);
1069 assert_eq!(tool_calls[0].name, "aws_list_ec2_instances");
1070 }
1071 other => panic!("turn 0 should be Mixed, got {other:?}"),
1072 }
1073 match &turns[1] {
1074 SimTurn::Mixed { tool_calls, .. } => {
1075 assert_eq!(tool_calls.len(), 1);
1076 assert_eq!(tool_calls[0].name, "aws_list_s3_buckets");
1077 }
1078 other => panic!("turn 1 should be Mixed, got {other:?}"),
1079 }
1080 match &turns[2] {
1081 SimTurn::Assistant(text) => {
1082 assert!(
1083 text.contains("/workspace/.audit.log"),
1084 "summary mentions the audit log: {text:?}"
1085 );
1086 }
1087 other => panic!("turn 2 should be Assistant, got {other:?}"),
1088 }
1089 }
1090
1091 fn make_config() -> LlmCallConfig {
1092 LlmCallConfig {
1093 speed: None,
1094 verbosity: None,
1095 model: "test-model".to_string(),
1096 temperature: None,
1097 max_tokens: None,
1098 tools: vec![],
1099 reasoning_effort: None,
1100 metadata: std::collections::HashMap::new(),
1101 previous_response_id: None,
1102 provider_opaque_context: None,
1103 tool_search: None,
1104 prompt_cache: None,
1105 openrouter_routing: None,
1106 parallel_tool_calls: None,
1107 volatile_suffix_len: 0,
1108 }
1109 }
1110
1111 fn user_message(content: &str) -> LlmMessage {
1112 LlmMessage::text(LlmMessageRole::User, content)
1113 }
1114
1115 fn system_message(content: &str) -> LlmMessage {
1116 LlmMessage::text(LlmMessageRole::System, content)
1117 }
1118
1119 #[tokio::test]
1120 async fn test_fixed_response() {
1121 let driver = LlmSimDriver::new(LlmSimConfig::fixed("Hello, world!"));
1122 let messages = vec![user_message("Hi there")];
1123
1124 let response = driver
1125 .chat_completion(messages, &make_config())
1126 .await
1127 .unwrap();
1128
1129 assert_eq!(response.text, "Hello, world!");
1130 assert!(response.tool_calls.is_none());
1131 }
1132
1133 #[tokio::test]
1134 async fn test_echo_response() {
1135 let driver = LlmSimDriver::new(LlmSimConfig::echo());
1136 let messages = vec![
1137 system_message("You are a helpful assistant"),
1138 user_message("What is 2+2?"),
1139 ];
1140
1141 let response = driver
1142 .chat_completion(messages, &make_config())
1143 .await
1144 .unwrap();
1145
1146 assert_eq!(response.text, "Echo: What is 2+2?");
1147 }
1148
1149 #[tokio::test]
1150 async fn test_sequence_response() {
1151 let driver = LlmSimDriver::new(LlmSimConfig::sequence(vec![
1152 "First".to_string(),
1153 "Second".to_string(),
1154 "Third".to_string(),
1155 ]));
1156
1157 let messages = vec![user_message("test")];
1158
1159 let r1 = driver
1161 .chat_completion(messages.clone(), &make_config())
1162 .await
1163 .unwrap();
1164 assert_eq!(r1.text, "First");
1165
1166 let r2 = driver
1168 .chat_completion(messages.clone(), &make_config())
1169 .await
1170 .unwrap();
1171 assert_eq!(r2.text, "Second");
1172
1173 let r3 = driver
1175 .chat_completion(messages.clone(), &make_config())
1176 .await
1177 .unwrap();
1178 assert_eq!(r3.text, "Third");
1179
1180 let r4 = driver
1182 .chat_completion(messages.clone(), &make_config())
1183 .await
1184 .unwrap();
1185 assert_eq!(r4.text, "First");
1186 }
1187
1188 #[tokio::test]
1189 async fn test_lorem_response() {
1190 let driver = LlmSimDriver::new(LlmSimConfig::lorem(50));
1191 let messages = vec![user_message("Generate text")];
1192
1193 let response = driver
1194 .chat_completion(messages, &make_config())
1195 .await
1196 .unwrap();
1197
1198 assert!(!response.text.is_empty());
1200 assert!(response.text.split_whitespace().count() > 5);
1202 }
1203
1204 #[tokio::test]
1205 async fn test_fixed_tool_calls() {
1206 let tool_call = ToolCall {
1207 id: "call_123".to_string(),
1208 name: "get_weather".to_string(),
1209 arguments: serde_json::json!({"city": "NYC"}),
1210 };
1211
1212 let driver = LlmSimDriver::new(
1213 LlmSimConfig::fixed("Let me check the weather.")
1214 .with_tool_calls(vec![tool_call.clone()]),
1215 );
1216
1217 let messages = vec![user_message("What's the weather?")];
1218 let response = driver
1219 .chat_completion(messages, &make_config())
1220 .await
1221 .unwrap();
1222
1223 assert_eq!(response.text, "Let me check the weather.");
1224 let calls = response.tool_calls.expect("Expected tool calls");
1225 assert_eq!(calls.len(), 1);
1226 assert_eq!(calls[0].name, "get_weather");
1227 assert_eq!(calls[0].id, "call_123");
1228 }
1229
1230 #[tokio::test]
1231 async fn test_tool_call_sequence() {
1232 let call1 = ToolCall {
1233 id: "call_1".to_string(),
1234 name: "search".to_string(),
1235 arguments: serde_json::json!({"q": "rust"}),
1236 };
1237 let call2 = ToolCall {
1238 id: "call_2".to_string(),
1239 name: "fetch".to_string(),
1240 arguments: serde_json::json!({"url": "https://example.com"}),
1241 };
1242
1243 let driver = LlmSimDriver::new(
1244 LlmSimConfig::fixed("Processing...").with_tool_call_sequence(vec![
1245 vec![call1.clone()],
1246 vec![call2.clone()],
1247 vec![],
1248 ]),
1249 );
1250
1251 let messages = vec![user_message("test")];
1252
1253 let r1 = driver
1255 .chat_completion(messages.clone(), &make_config())
1256 .await
1257 .unwrap();
1258 let calls1 = r1.tool_calls.expect("Expected tool calls");
1259 assert_eq!(calls1[0].name, "search");
1260
1261 let r2 = driver
1263 .chat_completion(messages.clone(), &make_config())
1264 .await
1265 .unwrap();
1266 let calls2 = r2.tool_calls.expect("Expected tool calls");
1267 assert_eq!(calls2[0].name, "fetch");
1268
1269 let r3 = driver
1271 .chat_completion(messages.clone(), &make_config())
1272 .await
1273 .unwrap();
1274 assert!(r3.tool_calls.is_none());
1275 }
1276
1277 #[tokio::test]
1278 async fn test_scripted_multi_turn_tool_call_agent_sequence() {
1279 let driver = LlmSimDriver::new(
1280 LlmSimConfig::scripted(vec![
1281 SimTurn::ToolCalls(vec![SimToolCall {
1282 name: "bash".to_string(),
1283 arguments: serde_json::json!({"command": "echo hello > /tmp/x.txt"}),
1284 id: None,
1285 }]),
1286 SimTurn::ToolCalls(vec![SimToolCall {
1287 name: "bash".to_string(),
1288 arguments: serde_json::json!({"command": "sed -i s/hello/world/ /tmp/x.txt"}),
1289 id: None,
1290 }]),
1291 SimTurn::Assistant("done".to_string()),
1292 ])
1293 .with_on_exhausted(OnExhausted::Error),
1294 );
1295
1296 let messages = vec![user_message("create /tmp/x.txt then change hello to world")];
1297
1298 let first = driver
1299 .chat_completion(messages.clone(), &make_config())
1300 .await
1301 .unwrap();
1302 let first_calls = first.tool_calls.expect("first turn should call bash");
1303 assert_eq!(first.text, "");
1304 assert_eq!(first_calls[0].name, "bash");
1305 assert_eq!(first_calls[0].id, "call_llmsim_0_0");
1306
1307 let second = driver
1308 .chat_completion(messages.clone(), &make_config())
1309 .await
1310 .unwrap();
1311 let second_calls = second.tool_calls.expect("second turn should call bash");
1312 assert_eq!(second_calls[0].name, "bash");
1313 assert_eq!(second_calls[0].id, "call_llmsim_1_0");
1314
1315 let final_response = driver
1316 .chat_completion(messages.clone(), &make_config())
1317 .await
1318 .unwrap();
1319 assert_eq!(final_response.text, "done");
1320 assert!(final_response.tool_calls.is_none());
1321
1322 let exhausted = driver
1323 .chat_completion(messages, &make_config())
1324 .await
1325 .unwrap_err();
1326 assert!(matches!(exhausted, AgentLoopError::Configuration(_)));
1327 }
1328
1329 #[tokio::test]
1330 async fn test_scripted_mixed_turn_streams_text_and_tool_calls() {
1331 let driver = LlmSimDriver::new(LlmSimConfig::scripted(vec![SimTurn::Mixed {
1332 text: "Let me check".to_string(),
1333 tool_calls: vec![SimToolCall {
1334 name: "search".to_string(),
1335 arguments: serde_json::json!({"q": "rust"}),
1336 id: Some("call_search".to_string()),
1337 }],
1338 }]));
1339
1340 let mut stream = driver
1341 .chat_completion_stream(vec![user_message("find rust")], &make_config())
1342 .await
1343 .unwrap();
1344
1345 let mut text_parts = Vec::new();
1346 let mut tool_calls = None;
1347 while let Some(event) = stream.next().await {
1348 match event.unwrap() {
1349 LlmStreamEvent::TextDelta(text) => text_parts.push(text),
1350 LlmStreamEvent::ToolCalls(calls) => tool_calls = Some(calls),
1351 LlmStreamEvent::Done(_) => {}
1352 _ => {}
1353 }
1354 }
1355
1356 assert!(!text_parts.is_empty(), "scripted text should stream");
1357 assert_eq!(text_parts.join(""), "Let me check");
1358 let calls = tool_calls.expect("mixed turn should emit tool calls");
1359 assert_eq!(calls[0].id, "call_search");
1360 assert_eq!(calls[0].name, "search");
1361 }
1362
1363 #[tokio::test]
1364 async fn test_scripted_on_exhausted_modes() {
1365 let repeat = LlmSimDriver::new(LlmSimConfig::scripted(vec![
1366 SimTurn::Assistant("one".to_string()),
1367 SimTurn::Assistant("two".to_string()),
1368 ]));
1369 let messages = vec![user_message("test")];
1370 assert_eq!(
1371 repeat
1372 .chat_completion(messages.clone(), &make_config())
1373 .await
1374 .unwrap()
1375 .text,
1376 "one"
1377 );
1378 assert_eq!(
1379 repeat
1380 .chat_completion(messages.clone(), &make_config())
1381 .await
1382 .unwrap()
1383 .text,
1384 "two"
1385 );
1386 assert_eq!(
1387 repeat
1388 .chat_completion(messages.clone(), &make_config())
1389 .await
1390 .unwrap()
1391 .text,
1392 "two"
1393 );
1394
1395 let looping = LlmSimDriver::new(
1396 LlmSimConfig::scripted(vec![
1397 SimTurn::Assistant("a".to_string()),
1398 SimTurn::Assistant("b".to_string()),
1399 ])
1400 .with_on_exhausted(OnExhausted::Loop),
1401 );
1402 assert_eq!(
1403 looping
1404 .chat_completion(messages.clone(), &make_config())
1405 .await
1406 .unwrap()
1407 .text,
1408 "a"
1409 );
1410 assert_eq!(
1411 looping
1412 .chat_completion(messages.clone(), &make_config())
1413 .await
1414 .unwrap()
1415 .text,
1416 "b"
1417 );
1418 assert_eq!(
1419 looping
1420 .chat_completion(messages, &make_config())
1421 .await
1422 .unwrap()
1423 .text,
1424 "a"
1425 );
1426 }
1427
1428 #[tokio::test]
1429 async fn test_scripted_error_turn() {
1430 let driver = LlmSimDriver::new(LlmSimConfig::scripted(vec![SimTurn::Error(
1431 SimError::RateLimit,
1432 )]));
1433
1434 let err = driver
1435 .chat_completion(vec![user_message("test")], &make_config())
1436 .await
1437 .unwrap_err();
1438
1439 assert!(err.is_rate_limited());
1440 }
1441
1442 #[tokio::test]
1443 async fn test_conditional_tool_calls() {
1444 let weather_call = ToolCall {
1445 id: "call_w".to_string(),
1446 name: "get_weather".to_string(),
1447 arguments: serde_json::json!({}),
1448 };
1449 let search_call = ToolCall {
1450 id: "call_s".to_string(),
1451 name: "search".to_string(),
1452 arguments: serde_json::json!({}),
1453 };
1454
1455 let config = LlmSimConfig {
1456 response: ResponseConfig::Fixed("Response".to_string()),
1457 tool_calls: Some(ToolCallConfig::Conditional {
1458 patterns: vec![
1459 ToolCallPattern::new("weather", vec![weather_call]),
1460 ToolCallPattern::new("search", vec![search_call]),
1461 ],
1462 }),
1463 simulate_latency: false,
1464 model_name: "test".to_string(),
1465 response_delay: None,
1466 response_id: None,
1467 effort_capture: None,
1468 message_capture: None,
1469 };
1470
1471 let driver = LlmSimDriver::new(config);
1472
1473 let r1 = driver
1475 .chat_completion(vec![user_message("What's the weather?")], &make_config())
1476 .await
1477 .unwrap();
1478 let calls1 = r1.tool_calls.expect("Expected weather tool");
1479 assert_eq!(calls1[0].name, "get_weather");
1480
1481 let r2 = driver
1483 .chat_completion(vec![user_message("search for rust")], &make_config())
1484 .await
1485 .unwrap();
1486 let calls2 = r2.tool_calls.expect("Expected search tool");
1487 assert_eq!(calls2[0].name, "search");
1488
1489 let r3 = driver
1491 .chat_completion(vec![user_message("hello world")], &make_config())
1492 .await
1493 .unwrap();
1494 assert!(r3.tool_calls.is_none());
1495 }
1496
1497 #[tokio::test]
1498 async fn test_streaming() {
1499 let driver = LlmSimDriver::new(LlmSimConfig::fixed("Hello world test"));
1500 let messages = vec![user_message("test")];
1501
1502 let mut stream = driver
1503 .chat_completion_stream(messages, &make_config())
1504 .await
1505 .unwrap();
1506
1507 let mut text_parts = Vec::new();
1508 let mut got_done = false;
1509
1510 while let Some(event) = stream.next().await {
1511 match event.unwrap() {
1512 LlmStreamEvent::TextDelta(text) => text_parts.push(text),
1513 LlmStreamEvent::Done(meta) => {
1514 got_done = true;
1515 assert!(meta.total_tokens.is_some());
1516 assert!(meta.model.is_some());
1517 }
1518 _ => {}
1519 }
1520 }
1521
1522 assert!(got_done);
1523 assert!(!text_parts.is_empty());
1525 assert_eq!(text_parts.join(""), "Hello world test");
1526 }
1527
1528 #[tokio::test]
1529 async fn test_metadata() {
1530 let driver = LlmSimDriver::new(LlmSimConfig::fixed("Hi").with_model("custom-model"));
1531 let messages = vec![user_message("test")];
1532
1533 let mut config = make_config();
1534 config.model = "request-model".to_string();
1535
1536 let response = driver.chat_completion(messages, &config).await.unwrap();
1537
1538 assert_eq!(response.metadata.model, Some("request-model".to_string()));
1540 assert!(response.metadata.prompt_tokens.is_some());
1541 assert!(response.metadata.completion_tokens.is_some());
1542 }
1543
1544 #[tokio::test]
1545 async fn test_register_driver() {
1546 let mut registry = DriverRegistry::new();
1547 register_driver(&mut registry);
1548
1549 assert!(registry.has_driver(&DriverId::LlmSim));
1550
1551 let config =
1553 crate::driver_registry::ProviderConfig::new(DriverId::LlmSim).with_api_key("fake-key");
1554 let driver = registry.create_chat_driver(&config);
1555 assert!(driver.is_ok());
1556 }
1557
1558 #[tokio::test]
1559 async fn test_empty_response() {
1560 let config = LlmSimConfig {
1561 response: ResponseConfig::Empty,
1562 tool_calls: None,
1563 simulate_latency: false,
1564 model_name: "test".to_string(),
1565 response_delay: None,
1566 response_id: None,
1567 effort_capture: None,
1568 message_capture: None,
1569 };
1570
1571 let driver = LlmSimDriver::new(config);
1572 let messages = vec![user_message("test")];
1573
1574 let response = driver
1575 .chat_completion(messages, &make_config())
1576 .await
1577 .unwrap();
1578
1579 assert!(response.text.is_empty());
1580 }
1581
1582 #[test]
1583 fn test_driver_debug() {
1584 let driver = LlmSimDriver::new(LlmSimConfig::fixed("test").with_latency());
1585 let debug = format!("{:?}", driver);
1586
1587 assert!(debug.contains("LlmSimDriver"));
1588 assert!(debug.contains("simulate_latency"));
1589 }
1590
1591 #[test]
1592 fn test_default_config() {
1593 let config = LlmSimConfig::default();
1594 assert!(matches!(config.response, ResponseConfig::Fixed(_)));
1595 assert!(config.tool_calls.is_none());
1596 assert!(!config.simulate_latency);
1597 }
1598
1599 #[test]
1600 fn test_config_builder() {
1601 let tool_call = ToolCall {
1602 id: "call_1".to_string(),
1603 name: "get_weather".to_string(),
1604 arguments: serde_json::json!({"city": "NYC"}),
1605 };
1606
1607 let config = LlmSimConfig::fixed("Result")
1608 .with_tool_calls(vec![tool_call.clone()])
1609 .with_latency()
1610 .with_model("gpt-4")
1611 .with_response_delay(std::time::Duration::from_secs(2));
1612
1613 assert!(config.tool_calls.is_some());
1614 assert!(config.simulate_latency);
1615 assert_eq!(config.model_name, "gpt-4");
1616 assert_eq!(
1617 config.response_delay,
1618 Some(std::time::Duration::from_secs(2))
1619 );
1620 }
1621
1622 #[test]
1623 fn test_parse_ttft_from_model_name() {
1624 use super::parse_ttft_from_model_name;
1625
1626 assert_eq!(
1628 parse_ttft_from_model_name("llmsim-ttft-2000"),
1629 Some(std::time::Duration::from_millis(2000))
1630 );
1631 assert_eq!(
1632 parse_ttft_from_model_name("test-ttft-500-extra"),
1633 Some(std::time::Duration::from_millis(500))
1634 );
1635
1636 assert_eq!(parse_ttft_from_model_name("llmsim-model"), None);
1638 assert_eq!(parse_ttft_from_model_name("llmsim-ttft-0"), None);
1639 assert_eq!(parse_ttft_from_model_name("llmsim-ttft-abc"), None);
1640 }
1641
1642 #[test]
1643 fn test_resolve_latency_profile_from_model_name() {
1644 let driver = LlmSimDriver::new(LlmSimConfig::fixed("test"));
1645
1646 let profile = driver.resolve_latency_profile("llmsim-latency");
1648 assert!(profile.sample_ttft().as_nanos() > 0);
1649
1650 let profile = driver.resolve_latency_profile("llmsim-default");
1652 assert_eq!(profile.sample_ttft().as_nanos(), 0);
1653
1654 let driver = LlmSimDriver::new(LlmSimConfig::fixed("test").with_latency());
1656 let profile = driver.resolve_latency_profile("llmsim-default");
1657 assert!(profile.sample_ttft().as_nanos() > 0);
1658 }
1659
1660 #[tokio::test]
1661 async fn test_latency_streaming_from_model_name() {
1662 let driver = LlmSimDriver::new(LlmSimConfig::fixed("Hello world"));
1664 let messages = vec![user_message("test")];
1665
1666 let mut config = make_config();
1667 config.model = "llmsim-latency".to_string();
1668
1669 let start = std::time::Instant::now();
1670 let mut stream = driver
1671 .chat_completion_stream(messages, &config)
1672 .await
1673 .unwrap();
1674
1675 let mut text_parts = Vec::new();
1676 let mut got_done = false;
1677
1678 while let Some(event) = stream.next().await {
1679 match event.unwrap() {
1680 LlmStreamEvent::TextDelta(text) => text_parts.push(text),
1681 LlmStreamEvent::Done(meta) => {
1682 got_done = true;
1683 assert_eq!(meta.model, Some("llmsim-latency".to_string()));
1684 }
1685 _ => {}
1686 }
1687 }
1688
1689 assert!(got_done);
1690 assert_eq!(text_parts.join(""), "Hello world");
1691 assert!(
1694 start.elapsed().as_millis() > 0,
1695 "latency simulation should introduce delays"
1696 );
1697 }
1698
1699 #[tokio::test]
1700 async fn test_no_latency_streaming_is_instant() {
1701 let driver = LlmSimDriver::new(LlmSimConfig::fixed("Hello world"));
1702 let messages = vec![user_message("test")];
1703
1704 let mut config = make_config();
1705 config.model = "llmsim-default".to_string();
1706
1707 let start = std::time::Instant::now();
1708 let response = driver.chat_completion(messages, &config).await.unwrap();
1709 let elapsed = start.elapsed();
1710
1711 assert_eq!(response.text, "Hello world");
1712 assert!(
1714 elapsed.as_millis() < 50,
1715 "instant mode should have no delays, took {}ms",
1716 elapsed.as_millis()
1717 );
1718 }
1719}