1use crate::backend_trait::LlmBackend;
30use crate::message::Message;
31use crate::observer::{Disposition, NoOpObserver, Observer, StepContext, ToolResult};
32use crate::store_trait::{MessageStore, ToolLog};
33use crate::tool::Registry;
34use std::collections::HashMap;
35use std::io::Write;
36use std::sync::Arc;
37use tracing::{debug, error, info_span, warn};
38
39pub const DEFAULT_MAX_TOOL_RESULT_BYTES: usize = 64 * 1024;
41
42#[derive(Debug, Clone, Default)]
64pub struct ToolQuota {
65 pub max_calls: Option<u32>,
68 pub max_duration_us: Option<u64>,
71 pub max_result_bytes: Option<usize>,
75}
76
77#[derive(Default)]
80struct QuotaUsage {
81 calls: u32,
82 duration_us: u64,
83}
84
85pub struct Agent<B: LlmBackend> {
87 pub backend: B,
89 pub messages: Vec<Message>,
91 pub tools: Registry,
93 pub max_steps: usize,
95 pub max_window: usize,
99 pub max_tool_result_bytes: usize,
102 pub tool_quotas: HashMap<String, ToolQuota>,
116 pub max_step_duration: Option<std::time::Duration>,
133 pub store: Option<Arc<dyn MessageStore>>,
135 pub session: String,
137 pub observer: Arc<dyn Observer>,
139 pub on_token: Option<Box<dyn FnMut(&str) + Send>>,
150 #[deprecated(
153 since = "0.2.0",
154 note = "Use `Agent::on_token` for a user-controlled token sink. `stream = true` still prints to stdout when `on_token` is None."
155 )]
156 pub stream: bool,
157}
158
159impl<B: LlmBackend> Agent<B> {
160 #[allow(deprecated)]
165 pub fn new(backend: B, system: &str) -> Self {
166 Self {
167 backend,
168 messages: vec![Message {
169 role: "system".into(),
170 content: Some(system.into()),
171 tool_calls: None,
172 tool_call_id: None,
173 name: None,
174 }],
175 tools: Registry::new(),
176 max_steps: 10,
177 max_window: 40,
178 max_tool_result_bytes: DEFAULT_MAX_TOOL_RESULT_BYTES,
179 tool_quotas: HashMap::new(),
180 max_step_duration: None,
181 store: None,
182 session: "default".into(),
183 observer: Arc::new(NoOpObserver),
184 on_token: None,
185 stream: true,
186 }
187 }
188
189 pub fn attach_store(
195 &mut self,
196 store: Arc<dyn MessageStore>,
197 session: &str,
198 ) -> Result<(), String> {
199 let loaded = store.load(session).map_err(|e| e.to_string())?;
200 if loaded.is_empty() {
201 for m in &self.messages {
202 store.append(session, m).map_err(|e| e.to_string())?;
203 }
204 } else {
205 self.messages = loaded;
206 }
207 self.store = Some(store);
208 self.session = session.into();
209 Ok(())
210 }
211
212 fn persist(&self, msg: &Message) {
213 if let Some(s) = &self.store {
214 if let Err(e) = s.append(&self.session, msg) {
215 eprintln!("persist: {}", e);
216 }
217 }
218 }
219
220 fn window_start(&self) -> Option<usize> {
229 if self.messages.len() <= self.max_window {
230 return None;
231 }
232 let n = self.max_window;
233 let mut start = self.messages.len() - (n - 1);
234 while start < self.messages.len() && self.messages[start].role != "user" {
235 start += 1;
236 }
237 Some(start)
238 }
239
240 fn windowed_truncated(&self, start: usize) -> Vec<Message> {
246 let mut out = Vec::with_capacity(self.messages.len() - start + 1);
247 out.push(self.messages[0].clone());
248 out.extend(self.messages[start..].iter().cloned());
249 out
250 }
251
252 #[cfg(test)]
255 fn windowed(&self) -> Vec<Message> {
256 match self.window_start() {
257 None => self.messages.clone(),
258 Some(start) => self.windowed_truncated(start),
259 }
260 }
261
262 fn frame_tool_output(&self, name: &str, id: &str, raw: &str) -> String {
266 let cap = self.max_tool_result_bytes;
267 let (body, truncated) = if raw.len() > cap {
268 let mut end = cap;
270 while end > 0 && !raw.is_char_boundary(end) {
271 end -= 1;
272 }
273 (&raw[..end], true)
274 } else {
275 (raw, false)
276 };
277 if truncated {
278 format!(
279 "<tool_output name=\"{}\" id=\"{}\" truncated=\"true\" raw_bytes=\"{}\">{}</tool_output>",
280 escape_attr(name),
281 escape_attr(id),
282 raw.len(),
283 body
284 )
285 } else {
286 format!(
287 "<tool_output name=\"{}\" id=\"{}\">{}</tool_output>",
288 escape_attr(name),
289 escape_attr(id),
290 body
291 )
292 }
293 }
294
295 #[allow(deprecated)]
304 pub fn step(&mut self, user_input: &str) -> Result<String, String> {
305 let _span = info_span!(
306 "agnt.step",
307 session = %self.session,
308 input_len = user_input.len(),
309 )
310 .entered();
311 debug!(user_input_len = user_input.len(), "agent.step start");
312
313 let ctx = StepContext {
314 session: self.session.clone(),
315 user_input: user_input.into(),
316 };
317 self.observer.on_step_start(&ctx);
318
319 let user = Message {
320 role: "user".into(),
321 content: Some(user_input.into()),
322 tool_calls: None,
323 tool_call_id: None,
324 name: None,
325 };
326 self.persist(&user);
327 self.messages.push(user);
328
329 let tools = self.tools.as_openai_tools();
330
331 let mut quota_usage: HashMap<String, QuotaUsage> = HashMap::new();
334
335 let step_started = std::time::Instant::now();
339 let deadline_check = |stage: &str| -> Result<(), String> {
340 if let Some(limit) = self.max_step_duration {
341 if step_started.elapsed() >= limit {
342 return Err(format!(
343 "step deadline exceeded at {}: {}ms >= {}ms",
344 stage,
345 step_started.elapsed().as_millis(),
346 limit.as_millis()
347 ));
348 }
349 }
350 Ok(())
351 };
352
353 for _ in 0..self.max_steps {
354 if let Err(e) = deadline_check("turn_start") {
355 self.observer.on_step_error(&e);
356 return Err(e);
357 }
358 let window_start = self.window_start();
362 let truncated_buf: Vec<Message> = match window_start {
363 Some(start) => self.windowed_truncated(start),
364 None => Vec::new(),
365 };
366 let send: &[Message] = match window_start {
367 Some(_) => &truncated_buf,
368 None => &self.messages,
369 };
370
371 let use_on_token = self.on_token.is_some();
373 let use_legacy_stream = !use_on_token && self.stream;
374
375 let _backend_span = info_span!(
376 "agnt.backend.chat",
377 model = %self.backend.model(),
378 window_size = send.len(),
379 )
380 .entered();
381
382 let resp = if use_on_token {
383 let mut cb = self.on_token.take().expect("on_token is_some");
386 let mut sink = |s: &str| cb(s);
387 let r = self
388 .backend
389 .chat(send, &tools, Some(&mut sink))
390 .map_err(|e| {
391 let es = e.to_string();
392 error!(error = %es, "backend chat error");
393 self.observer.on_step_error(&es);
394 es
395 });
396 self.on_token = Some(cb);
397 r?
398 } else if use_legacy_stream {
399 let mut sink = |s: &str| {
400 print!("{}", s);
401 std::io::stdout().flush().ok();
402 };
403 let r = self
404 .backend
405 .chat(send, &tools, Some(&mut sink))
406 .map_err(|e| {
407 let es = e.to_string();
408 error!(error = %es, "backend chat error");
409 self.observer.on_step_error(&es);
410 es
411 })?;
412 println!();
413 r
414 } else {
415 self.backend
416 .chat(send, &tools, None)
417 .map_err(|e| {
418 let es = e.to_string();
419 error!(error = %es, "backend chat error");
420 self.observer.on_step_error(&es);
421 es
422 })?
423 };
424 drop(_backend_span);
425
426 self.persist(&resp);
429 let resp_idx = self.messages.len();
430 self.messages.push(resp);
431
432 let has_calls = self.messages[resp_idx]
437 .tool_calls
438 .as_ref()
439 .map(|c| !c.is_empty())
440 .unwrap_or(false);
441
442 if !has_calls {
443 let out = self.messages[resp_idx]
444 .content
445 .clone()
446 .unwrap_or_default();
447 let final_msg = Message {
448 role: "assistant".into(),
449 content: Some(out.clone()),
450 tool_calls: None,
451 tool_call_id: None,
452 name: None,
453 };
454 self.observer.on_step_end(&final_msg);
455 return Ok(out);
456 }
457
458 let calls = self.messages[resp_idx]
460 .tool_calls
461 .as_ref()
462 .expect("has_calls checked above")
463 .clone();
464
465 if let Err(e) = deadline_check("pre_dispatch") {
466 self.observer.on_step_error(&e);
467 return Err(e);
468 }
469
470 enum CallDecision {
478 Allow,
480 Refused(String),
482 }
483 let observer_clone = self.observer.clone();
484 let decisions: Vec<CallDecision> = calls
485 .iter()
486 .map(|call| {
487 if let Disposition::Refused(msg) = observer_clone.should_dispatch(call) {
489 warn!(tool = %call.function.name, reason = %msg, "observer refused dispatch");
490 return CallDecision::Refused(format!(
491 "refused by observer: {}",
492 msg
493 ));
494 }
495 if let Some(quota) = self.tool_quotas.get(&call.function.name) {
497 let usage = quota_usage
498 .entry(call.function.name.clone())
499 .or_default();
500 if let Some(max) = quota.max_calls {
501 if usage.calls >= max {
502 warn!(
503 tool = %call.function.name,
504 max = max,
505 "tool call quota exceeded"
506 );
507 return CallDecision::Refused(format!(
508 "quota exceeded: {} reached max {} calls this step",
509 call.function.name, max
510 ));
511 }
512 }
513 if let Some(max_us) = quota.max_duration_us {
514 if usage.duration_us >= max_us {
515 warn!(
516 tool = %call.function.name,
517 max_us = max_us,
518 "tool duration quota exceeded"
519 );
520 return CallDecision::Refused(format!(
521 "quota exceeded: {} reached max {}µs wall time this step",
522 call.function.name, max_us
523 ));
524 }
525 }
526 usage.calls += 1;
528 }
529 CallDecision::Allow
530 })
531 .collect();
532
533 let registry = &self.tools;
534 let observer = self.observer.clone();
535 type ToolOutcome = (String, String, String, String, u64);
545 let results: Vec<ToolOutcome> =
546 std::thread::scope(|s| {
547 type Handle<'s> = (
554 std::thread::ScopedJoinHandle<'s, ToolOutcome>,
555 String,
556 String,
557 String,
558 );
559 let handles: Vec<Handle<'_>> = calls
560 .iter()
561 .zip(decisions.into_iter())
562 .map(|(call, decision)| {
563 let name = call.function.name.clone();
564 let id = call.id.clone();
565 let args_str = call.function.arguments.clone();
566 let sidecar_id = id.clone();
567 let sidecar_name = name.clone();
568 let sidecar_args = args_str.clone();
569 let observer = observer.clone();
570 let call_clone = call.clone();
571 let handle = s.spawn(move || {
572 let _tool_span = info_span!(
573 "agnt.tool",
574 name = %name,
575 id = %id,
576 )
577 .entered();
578 observer.on_tool_start(&call_clone);
579
580 let (result, dur) = match decision {
581 CallDecision::Refused(msg) => (msg, 0u64),
582 CallDecision::Allow => {
583 let args: serde_json::Value =
584 serde_json::from_str(&args_str)
585 .unwrap_or(serde_json::Value::Null);
586 let t0 = std::time::Instant::now();
587 let result = registry
588 .dispatch(&name, args)
589 .unwrap_or_else(|e| {
590 warn!(tool = %name, error = %e, "tool dispatch failed");
591 format!("error: {}", e)
592 });
593 let dur = t0.elapsed().as_micros() as u64;
594 debug!(
595 tool = %name,
596 duration_us = dur,
597 "tool completed"
598 );
599 (result, dur)
600 }
601 };
602
603 let tool_result = ToolResult {
604 name: name.clone(),
605 output: Ok(result.clone()),
606 duration_us: dur,
607 };
608 observer.on_tool_end(&call_clone, &tool_result);
609 (id, name, args_str, result, dur)
610 });
611 (handle, sidecar_id, sidecar_name, sidecar_args)
612 })
613 .collect();
614 handles
615 .into_iter()
616 .map(|(h, id, name, args_str)| {
617 h.join().unwrap_or_else(|panic_payload| {
618 let msg = panic_to_string(panic_payload);
619 warn!(
620 tool = %name,
621 id = %id,
622 panic = %msg,
623 "tool thread panicked"
624 );
625 (
626 id,
627 name,
628 args_str,
629 format!("error: tool thread panicked: {}", msg),
630 0,
631 )
632 })
633 })
634 .collect()
635 });
636
637 for (_id, name, _args, _result, dur) in &results {
640 if self.tool_quotas.contains_key(name) {
641 let u = quota_usage.entry(name.clone()).or_default();
642 u.duration_us = u.duration_us.saturating_add(*dur);
643 }
644 }
645
646 for (id, name, args_str, result, dur_us) in results {
647 if use_legacy_stream {
648 println!("[tool: {} ({:.2}ms)]", name, dur_us as f64 / 1000.0);
649 }
650 if let Some(s) = &self.store {
651 let log = ToolLog {
652 name: &name,
653 args: &args_str,
654 result: &result,
655 duration_us: dur_us,
656 };
657 if let Err(e) = s.log_tool(&self.session, &log) {
658 eprintln!("log_tool: {}", e);
659 }
660 }
661 let result = match self
664 .tool_quotas
665 .get(&name)
666 .and_then(|q| q.max_result_bytes)
667 {
668 Some(cap) if result.len() > cap => {
669 let mut end = cap;
670 while end > 0 && !result.is_char_boundary(end) {
671 end -= 1;
672 }
673 result[..end].to_string()
674 }
675 _ => result,
676 };
677 let framed = self.frame_tool_output(&name, &id, &result);
679 let msg = Message {
680 role: "tool".into(),
681 content: Some(framed),
682 tool_calls: None,
683 tool_call_id: Some(id),
684 name: Some(name),
685 };
686 self.persist(&msg);
687 self.messages.push(msg);
688 }
689 }
690
691 let err = "max steps exceeded".to_string();
692 self.observer.on_step_error(&err);
693 Err(err)
694 }
695}
696
697fn panic_to_string(payload: Box<dyn std::any::Any + Send>) -> String {
700 if let Some(s) = payload.downcast_ref::<&'static str>() {
701 (*s).to_string()
702 } else if let Some(s) = payload.downcast_ref::<String>() {
703 s.clone()
704 } else {
705 "unknown panic payload".to_string()
706 }
707}
708
709fn escape_attr(s: &str) -> String {
714 let mut out = String::with_capacity(s.len());
715 for c in s.chars() {
716 match c {
717 '&' => out.push_str("&"),
718 '"' => out.push_str("""),
719 '<' => out.push_str("<"),
720 '>' => out.push_str(">"),
721 _ => out.push(c),
722 }
723 }
724 out
725}
726
727#[cfg(test)]
728mod tests {
729 use super::*;
730 use crate::backend_trait::BackendError;
731 use crate::message::{FunctionCall, ToolCall};
732 use serde_json::Value;
733
734 struct MockBackend;
736 impl LlmBackend for MockBackend {
737 fn model(&self) -> &str {
738 "mock"
739 }
740 fn chat(
741 &self,
742 _messages: &[Message],
743 _tools: &Value,
744 _on_token: Option<&mut dyn FnMut(&str)>,
745 ) -> Result<Message, BackendError> {
746 Ok(Message {
747 role: "assistant".into(),
748 content: Some("mock response".into()),
749 tool_calls: None,
750 tool_call_id: None,
751 name: None,
752 })
753 }
754 }
755
756 fn msg(role: &str, content: &str) -> Message {
757 Message {
758 role: role.into(),
759 content: Some(content.into()),
760 tool_calls: None,
761 tool_call_id: None,
762 name: None,
763 }
764 }
765
766 #[test]
767 fn windowing_empty_session_returns_all() {
768 let mut a = Agent::new(MockBackend, "sys");
769 a.max_window = 10;
770 a.messages.push(msg("user", "hi"));
771 a.messages.push(msg("assistant", "hello"));
772 let w = a.windowed();
773 assert_eq!(w.len(), 3);
774 assert_eq!(w[0].role, "system");
775 }
776
777 #[test]
778 fn windowing_preserves_system_and_starts_at_user() {
779 let mut a = Agent::new(MockBackend, "sys");
780 a.max_window = 5;
781 for i in 0..20 {
782 let role = if i % 2 == 0 { "user" } else { "assistant" };
783 a.messages.push(msg(role, &format!("m{}", i)));
784 }
785 let w = a.windowed();
786 assert_eq!(w[0].role, "system", "system slot preserved");
787 assert!(w.len() <= 5, "window respects max_window: {}", w.len());
788 assert_eq!(w[1].role, "user", "first post-system must be user");
789 }
790
791 #[test]
792 fn windowing_skips_orphan_tool_results() {
793 let mut a = Agent::new(MockBackend, "sys");
794 a.max_window = 4;
795 a.messages.push(msg("user", "do thing"));
796 a.messages.push(Message {
797 role: "assistant".into(),
798 content: None,
799 tool_calls: Some(vec![ToolCall {
800 id: "c1".into(),
801 call_type: "function".into(),
802 function: FunctionCall {
803 name: "t".into(),
804 arguments: "{}".into(),
805 },
806 }]),
807 tool_call_id: None,
808 name: None,
809 });
810 a.messages.push(Message {
811 role: "tool".into(),
812 content: Some("result".into()),
813 tool_calls: None,
814 tool_call_id: Some("c1".into()),
815 name: Some("t".into()),
816 });
817 a.messages.push(msg("assistant", "done"));
818 a.messages.push(msg("user", "next"));
819 a.messages.push(msg("assistant", "ok"));
820 let w = a.windowed();
821 assert_eq!(w[0].role, "system");
822 assert_eq!(w[1].role, "user");
823 }
824
825 #[test]
826 fn window_start_is_none_when_history_fits() {
827 let mut a = Agent::new(MockBackend, "sys");
828 a.max_window = 10;
829 a.messages.push(msg("user", "hi"));
830 assert!(
831 a.window_start().is_none(),
832 "short history must not allocate a window vec"
833 );
834 }
835
836 #[test]
837 fn frame_tool_output_wraps_and_escapes() {
838 #[allow(deprecated)]
839 let a = Agent::new(MockBackend, "sys");
840 let framed = a.frame_tool_output("fetch", "call_1", "hello");
841 assert_eq!(
842 framed,
843 r#"<tool_output name="fetch" id="call_1">hello</tool_output>"#
844 );
845 }
846
847 #[test]
848 fn frame_tool_output_truncates_past_cap() {
849 #[allow(deprecated)]
850 let mut a = Agent::new(MockBackend, "sys");
851 a.max_tool_result_bytes = 8;
852 let framed = a.frame_tool_output("t", "id", "0123456789ABCDEF");
853 assert!(framed.contains("truncated=\"true\""));
854 assert!(framed.contains("raw_bytes=\"16\""));
855 assert!(framed.contains("01234567"));
856 assert!(!framed.contains("89ABCDEF"));
857 }
858
859 #[test]
860 fn frame_tool_output_respects_utf8_boundary() {
861 #[allow(deprecated)]
862 let mut a = Agent::new(MockBackend, "sys");
863 a.max_tool_result_bytes = 3; let framed = a.frame_tool_output("t", "id", "é中");
866 assert!(framed.contains("truncated=\"true\""));
868 }
869
870 #[test]
871 fn frame_tool_output_escapes_attrs() {
872 #[allow(deprecated)]
873 let a = Agent::new(MockBackend, "sys");
874 let framed = a.frame_tool_output("na\"me", "id&1", "x");
875 assert!(framed.contains("name=\"na"me\""));
876 assert!(framed.contains("id=\"id&1\""));
877 }
878
879 use crate::tool::Tool;
882 use std::sync::atomic::{AtomicUsize, Ordering};
883 use std::sync::Mutex;
884
885 struct ScriptedBackend {
889 script: Mutex<std::collections::VecDeque<Message>>,
890 }
891 impl ScriptedBackend {
892 fn new(script: Vec<Message>) -> Self {
893 Self { script: Mutex::new(script.into()) }
894 }
895 }
896 impl LlmBackend for ScriptedBackend {
897 fn model(&self) -> &str { "scripted" }
898 fn chat(
899 &self,
900 _messages: &[Message],
901 _tools: &Value,
902 _on_token: Option<&mut dyn FnMut(&str)>,
903 ) -> Result<Message, BackendError> {
904 let m = self.script.lock().unwrap().pop_front().unwrap_or_else(|| Message {
905 role: "assistant".into(),
906 content: Some("done".into()),
907 tool_calls: None,
908 tool_call_id: None,
909 name: None,
910 });
911 Ok(m)
912 }
913 }
914
915 struct CountingTool {
917 hits: Arc<AtomicUsize>,
918 payload: String,
919 }
920 impl Tool for CountingTool {
921 fn name(&self) -> &str { "counter" }
922 fn description(&self) -> &str { "test counter" }
923 fn schema(&self) -> Value {
924 serde_json::json!({"type":"object","properties":{}})
925 }
926 fn call(&self, _args: Value) -> Result<String, String> {
927 self.hits.fetch_add(1, Ordering::SeqCst);
928 Ok(self.payload.clone())
929 }
930 }
931
932 fn tool_call(id: &str, name: &str) -> Message {
933 Message {
934 role: "assistant".into(),
935 content: None,
936 tool_calls: Some(vec![ToolCall {
937 id: id.into(),
938 call_type: "function".into(),
939 function: FunctionCall {
940 name: name.into(),
941 arguments: "{}".into(),
942 },
943 }]),
944 tool_call_id: None,
945 name: None,
946 }
947 }
948
949 #[test]
950 fn quota_max_calls_refuses_after_limit_within_single_step() {
951 let script = vec![
954 tool_call("c1", "counter"),
955 tool_call("c2", "counter"),
956 ];
957 let hits = Arc::new(AtomicUsize::new(0));
958 #[allow(deprecated)]
959 let mut a = Agent::new(ScriptedBackend::new(script), "sys");
960 a.tools.register(Box::new(CountingTool {
961 hits: hits.clone(),
962 payload: "ok".into(),
963 }));
964 a.tool_quotas.insert(
965 "counter".into(),
966 ToolQuota { max_calls: Some(1), ..Default::default() },
967 );
968 let out = a.step("go").unwrap();
969 assert_eq!(hits.load(Ordering::SeqCst), 1, "tool must run exactly once");
970 assert_eq!(out, "done");
971 let refused = a.messages.iter().any(|m| {
973 m.role == "tool"
974 && m.content.as_deref().map(|c| c.contains("quota exceeded")).unwrap_or(false)
975 });
976 assert!(refused, "second call must produce a quota-refused tool message");
977 }
978
979 #[test]
980 fn quota_max_result_bytes_truncates_before_framing() {
981 let script = vec![tool_call("c1", "counter")];
982 let hits = Arc::new(AtomicUsize::new(0));
983 #[allow(deprecated)]
984 let mut a = Agent::new(ScriptedBackend::new(script), "sys");
985 a.tools.register(Box::new(CountingTool {
986 hits,
987 payload: "0123456789ABCDEF".into(),
988 }));
989 a.tool_quotas.insert(
990 "counter".into(),
991 ToolQuota { max_result_bytes: Some(4), ..Default::default() },
992 );
993 a.step("go").unwrap();
994 let tool_msg = a
995 .messages
996 .iter()
997 .find(|m| m.role == "tool")
998 .expect("tool message present");
999 let body = tool_msg.content.as_deref().unwrap();
1000 assert!(body.contains("0123"), "kept prefix");
1001 assert!(!body.contains("456789"), "truncated tail");
1002 }
1003
1004 #[test]
1005 fn observer_refuses_dispatch_and_tool_never_runs() {
1006 struct DenyObserver;
1007 impl Observer for DenyObserver {
1008 fn should_dispatch(&self, _call: &ToolCall) -> Disposition {
1009 Disposition::Refused("policy".into())
1010 }
1011 }
1012
1013 let script = vec![tool_call("c1", "counter")];
1014 let hits = Arc::new(AtomicUsize::new(0));
1015 #[allow(deprecated)]
1016 let mut a = Agent::new(ScriptedBackend::new(script), "sys");
1017 a.observer = Arc::new(DenyObserver);
1018 a.tools.register(Box::new(CountingTool {
1019 hits: hits.clone(),
1020 payload: "should not run".into(),
1021 }));
1022 a.step("go").unwrap();
1023 assert_eq!(hits.load(Ordering::SeqCst), 0, "observer must block dispatch");
1024 let refused = a.messages.iter().any(|m| {
1025 m.role == "tool"
1026 && m.content.as_deref().map(|c| c.contains("refused by observer")).unwrap_or(false)
1027 });
1028 assert!(refused);
1029 }
1030
1031 struct SleepyTool {
1035 dur: std::time::Duration,
1036 }
1037 impl Tool for SleepyTool {
1038 fn name(&self) -> &str { "sleepy" }
1039 fn description(&self) -> &str { "sleeps" }
1040 fn schema(&self) -> Value {
1041 serde_json::json!({"type":"object","properties":{}})
1042 }
1043 fn call(&self, _args: Value) -> Result<String, String> {
1044 std::thread::sleep(self.dur);
1045 Ok("awake".into())
1046 }
1047 }
1048
1049 #[test]
1050 fn max_step_duration_terminates_runaway_loop() {
1051 let script = vec![
1055 tool_call("c1", "sleepy"),
1056 tool_call("c2", "sleepy"),
1057 ];
1058 #[allow(deprecated)]
1059 let mut a = Agent::new(ScriptedBackend::new(script), "sys");
1060 a.tools.register(Box::new(SleepyTool {
1061 dur: std::time::Duration::from_millis(80),
1062 }));
1063 a.max_step_duration = Some(std::time::Duration::from_millis(100));
1064 let err = a.step("go").expect_err("deadline must fire");
1065 assert!(err.contains("step deadline"), "got: {}", err);
1066 }
1067
1068 #[test]
1069 fn no_deadline_means_no_deadline() {
1070 let script = vec![tool_call("c1", "sleepy")];
1073 #[allow(deprecated)]
1074 let mut a = Agent::new(ScriptedBackend::new(script), "sys");
1075 a.tools.register(Box::new(SleepyTool {
1076 dur: std::time::Duration::from_millis(20),
1077 }));
1078 let out = a.step("go").unwrap();
1080 assert_eq!(out, "done");
1081 }
1082
1083 struct PanickingTool;
1086 impl Tool for PanickingTool {
1087 fn name(&self) -> &str { "panicker" }
1088 fn description(&self) -> &str { "always panics" }
1089 fn schema(&self) -> Value {
1090 serde_json::json!({"type":"object","properties":{}})
1091 }
1092 fn call(&self, _args: Value) -> Result<String, String> {
1093 panic!("deliberate test panic");
1094 }
1095 }
1096
1097 #[test]
1098 fn panicked_tool_preserves_attribution_in_transcript() {
1099 let script = vec![tool_call("pc1", "panicker")];
1101 #[allow(deprecated)]
1102 let mut a = Agent::new(ScriptedBackend::new(script), "sys");
1103 a.tools.register(Box::new(PanickingTool));
1104 a.step("go").unwrap();
1105 let tool_msg = a
1109 .messages
1110 .iter()
1111 .find(|m| m.role == "tool")
1112 .expect("tool message present");
1113 assert_eq!(tool_msg.name.as_deref(), Some("panicker"));
1114 assert_eq!(tool_msg.tool_call_id.as_deref(), Some("pc1"));
1115 let body = tool_msg.content.as_deref().unwrap();
1116 assert!(
1117 body.contains("panicked") && body.contains("deliberate test panic"),
1118 "panic body: {}",
1119 body
1120 );
1121 }
1122}