1use std::sync::Arc;
12
13use serde::{Deserialize, Serialize};
14use tokio::sync::mpsc;
15use tracing::{debug, info, warn};
16
17use crate::compact::Compactor;
18use crate::error::{Error, Result};
19use crate::hooks::{Hook, HookAction, HookEvent, HookRegistry};
20use crate::llm::{Completion, LlmProvider, StreamSender, TokenUsage, ToolCall};
21use crate::message::Message;
22use crate::tools::ToolRegistry;
23
24const STUCK_THRESHOLD: usize = 3;
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum PermissionDecision {
32 Allow,
34 Deny(String),
36 Transform(serde_json::Value),
38}
39
40pub type PermissionHook = Arc<dyn Fn(&str, &serde_json::Value) -> PermissionDecision + Send + Sync>;
49
50const TRIM_PLACEHOLDER: &str = "[older tool output trimmed to fit budget]";
51
52#[derive(Debug, Clone, PartialEq, Default)]
54pub enum PlanningMode {
55 #[default]
57 Immediate,
58 PlanFirst,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(tag = "kind", rename_all = "snake_case")]
64#[non_exhaustive]
65pub enum StepEvent {
66 AssistantText { text: String, step: usize },
72 ToolCall { call: ToolCall, step: usize },
78 Latency { step: usize, llm_ms: u64 },
83 ToolResult {
90 id: String,
91 name: String,
92 output: String,
93 step: usize,
94 },
95 Usage { usage: TokenUsage, step: usize },
101 PartialToken { text: String, step: usize },
107 Compacted {
115 removed: usize,
116 kept: usize,
117 summary_chars: usize,
118 step: usize,
119 },
120 Finished { reason: FinishReason, steps: usize },
127 PlanProposed {
129 plan_text: String,
131 tool_calls: Vec<ToolCall>,
133 },
134 PlanConfirmed,
136 PlanRejected { reason: String },
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(tag = "kind", rename_all = "snake_case")]
142#[non_exhaustive]
156pub enum FinishReason {
157 NoMoreToolCalls,
159 BudgetExceeded,
161 ProviderStop(String),
163 Stuck {
165 repeated_call: String,
166 repeats: usize,
167 },
168 TranscriptLimit { chars: usize, limit: usize },
170 PlanPending,
172}
173
174#[derive(Debug, Clone)]
175pub struct AgentOutcome {
176 pub final_message: Option<String>,
177 pub transcript: Vec<Message>,
178 pub steps: usize,
179 pub finish: FinishReason,
180 pub total_usage: TokenUsage,
181 pub total_llm_latency_ms: u64,
182}
183
184pub struct Agent {
214 llm: Arc<dyn LlmProvider>,
215 tools: ToolRegistry,
216 transcript: Vec<Message>,
217 max_steps: usize,
218 max_transcript_chars: Option<usize>,
219 events: Option<mpsc::UnboundedSender<StepEvent>>,
220 streaming: bool,
221 total_llm_latency_ms: u64,
222 compactor: Option<Compactor>,
223 permission_hook: Option<PermissionHook>,
224 hooks: HookRegistry,
225 planning_mode: PlanningMode,
226 plan_buffer: Option<Vec<ToolCall>>,
227 plan_confirmed: bool,
228}
229
230impl Agent {
231 pub fn builder() -> AgentBuilder {
232 AgentBuilder::default()
233 }
234
235 pub fn confirm_plan(&mut self) {
237 self.plan_confirmed = true;
238 self.emit(StepEvent::PlanConfirmed);
239 }
240
241 pub fn reject_plan(&mut self, reason: &str) {
243 if let Some(calls) = self.plan_buffer.take() {
245 for call in &calls {
246 let result = format!(
247 "ERROR: Plan rejected. Reason: {reason}. \
248 The tool call {}({}) was not executed. \
249 Please revise your approach.",
250 call.name,
251 serde_json::to_string(&call.arguments).unwrap_or_default()
252 );
253 self.transcript
254 .push(Message::tool_result(call.id.clone(), result));
255 }
256 }
257 self.emit(StepEvent::PlanRejected {
258 reason: reason.into(),
259 });
260 }
261
262 async fn execute_tool_calls(
267 &mut self,
268 calls: &[ToolCall],
269 _step: usize,
270 ) -> Vec<(String, String, String, serde_json::Value)> {
271 let mut results: Vec<(String, String, String, serde_json::Value)> = Vec::new();
272
273 struct PendingCall {
275 id: String,
276 name: String,
277 args: serde_json::Value,
278 }
279 let mut pending: Vec<PendingCall> = Vec::new();
280
281 for call in calls {
282 let effective_args = if let Some(ref hook) = self.permission_hook {
283 match hook(&call.name, &call.arguments) {
284 PermissionDecision::Allow => call.arguments.clone(),
285 PermissionDecision::Deny(reason) => {
286 let result = format!("ERROR: {reason}");
287 results.push((
288 call.id.clone(),
289 call.name.clone(),
290 result,
291 call.arguments.clone(),
292 ));
293 continue;
294 }
295 PermissionDecision::Transform(new_args) => new_args,
296 }
297 } else {
298 call.arguments.clone()
299 };
300
301 let hook_action = self.hooks.dispatch(HookEvent::PreToolCall {
303 name: &call.name,
304 args: &effective_args,
305 });
306 match hook_action {
307 HookAction::Skip => {
308 let result = "ERROR: tool call skipped by hook".to_string();
309 results.push((
310 call.id.clone(),
311 call.name.clone(),
312 result,
313 call.arguments.clone(),
314 ));
315 continue;
316 }
317 HookAction::Error(msg) => {
318 let result = format!("ERROR: {msg}");
319 results.push((
320 call.id.clone(),
321 call.name.clone(),
322 result,
323 call.arguments.clone(),
324 ));
325 continue;
326 }
327 HookAction::Continue => {}
328 }
329
330 pending.push(PendingCall {
331 id: call.id.clone(),
332 name: call.name.clone(),
333 args: effective_args,
334 });
335 }
336
337 let mut i = 0;
339 while i < pending.len() {
340 if self.tools.is_readonly(&pending[i].name) {
341 let batch_start = i;
343 while i < pending.len() && self.tools.is_readonly(&pending[i].name) {
344 i += 1;
345 }
346 let batch: Vec<PendingCall> = pending.drain(batch_start..i).collect();
347 i = batch_start;
348
349 let mut join_set = tokio::task::JoinSet::new();
350 for pc in &batch {
351 let name = pc.name.clone();
352 let args = pc.args.clone();
353 let tools = self.tools.clone();
354 join_set.spawn(async move {
355 let tool_start = std::time::Instant::now();
356 let result = match tools.invoke(&name, args).await {
357 Ok(output) => output,
358 Err(err) => format!("ERROR: {err}"),
359 };
360 let duration_ms = tool_start.elapsed().as_millis() as u64;
361 (name, result, duration_ms)
362 });
363 }
364
365 let mut batch_results: Vec<(String, String, u64)> = Vec::new();
366 while let Some(res) = join_set.join_next().await {
367 let (name, result, duration_ms) = res.unwrap();
368 batch_results.push((name, result, duration_ms));
369 }
370
371 for pc in &batch {
372 let (_, result, duration_ms) = batch_results
373 .iter()
374 .find(|(n, _, _)| n == &pc.name)
375 .unwrap();
376 results.push((
377 pc.id.clone(),
378 pc.name.clone(),
379 result.clone(),
380 pc.args.clone(),
381 ));
382 self.hooks.dispatch(HookEvent::PostToolCall {
383 name: &pc.name,
384 args: &pc.args,
385 result,
386 duration_ms: *duration_ms,
387 });
388 }
389 } else {
390 let pc = pending.remove(i);
391 let tool_start = std::time::Instant::now();
392 let result = match self.tools.invoke(&pc.name, pc.args.clone()).await {
393 Ok(output) => output,
394 Err(err) => format!("ERROR: {err}"),
395 };
396 let duration_ms = tool_start.elapsed().as_millis() as u64;
397 results.push((
398 pc.id.clone(),
399 pc.name.clone(),
400 result.clone(),
401 pc.args.clone(),
402 ));
403 self.hooks.dispatch(HookEvent::PostToolCall {
404 name: &pc.name,
405 args: &pc.args,
406 result: &result,
407 duration_ms,
408 });
409 }
410 }
411
412 results
413 }
414
415 #[tracing::instrument(skip(self), fields(goal))]
416 pub async fn run(&mut self, goal: impl Into<String>) -> Result<AgentOutcome> {
417 let goal = goal.into();
418 info!(target: "recursive::agent", goal = %truncate(&goal, 200), "agent run starting");
419 self.transcript.push(Message::user(goal.clone()));
420 self.hooks.dispatch(HookEvent::SessionStart { goal: &goal });
421
422 let mut final_message: Option<String> = None;
423 let specs = self.tools.specs();
424
425 let mut last_call_key: Option<(String, String)> = None;
427 let mut consecutive_errors: usize = 0;
428
429 let mut total_usage = TokenUsage::default();
431 self.total_llm_latency_ms = 0;
433
434 for step in 1..=self.max_steps {
435 let step_span = tracing::info_span!("agent.step", step);
436 let _guard = step_span.enter();
437 if let Some(limit) = self.max_transcript_chars {
440 self.maybe_trim_transcript(limit, step);
441 let chars: usize = self.transcript.iter().map(|m| m.content.len()).sum();
442 if chars >= limit {
443 let finish = FinishReason::TranscriptLimit { chars, limit };
444 self.emit(StepEvent::Finished {
445 reason: finish.clone(),
446 steps: step,
447 });
448 return Ok(AgentOutcome {
449 final_message,
450 transcript: std::mem::take(&mut self.transcript),
451 steps: step,
452 finish,
453 total_usage,
454 total_llm_latency_ms: self.total_llm_latency_ms,
455 });
456 }
457 }
458
459 self.hooks.dispatch(HookEvent::PreCompact {
461 transcript_len: self.transcript.iter().map(|m| m.content.len()).sum(),
462 });
463 self.maybe_compact(step).await?;
464
465 if self.plan_confirmed {
468 self.plan_confirmed = false;
469 if let Some(calls) = self.plan_buffer.take() {
470 let results = self.execute_tool_calls(&calls, step).await;
471 for (id, name, output, _args) in results {
472 self.emit(StepEvent::ToolResult {
473 id: id.clone(),
474 name: name.clone(),
475 output: output.clone(),
476 step,
477 });
478 self.transcript.push(Message::tool_result(id, output));
479 }
480 continue;
481 }
482 }
483
484 debug!(target: "recursive::agent", step, "calling llm");
485 let start = std::time::Instant::now();
486 let completion: Completion = if self.streaming {
487 let (delta_tx, mut delta_rx) = mpsc::unbounded_channel::<String>();
489 let stream_tx: Option<StreamSender> = Some(delta_tx);
490 let events_tx = self.events.clone();
492 tokio::spawn(async move {
493 while let Some(text) = delta_rx.recv().await {
494 if let Some(ref tx) = events_tx {
495 let _ = tx.send(StepEvent::PartialToken { text, step });
496 }
497 }
498 });
499 self.llm.stream(&self.transcript, &specs, stream_tx).await?
500 } else {
501 self.llm.complete(&self.transcript, &specs).await?
502 };
503 let llm_ms = start.elapsed().as_millis() as u64;
504 self.total_llm_latency_ms = self.total_llm_latency_ms.saturating_add(llm_ms);
505 self.emit(StepEvent::Latency { step, llm_ms });
506
507 if let Some(u) = completion.usage {
509 total_usage = total_usage.accumulate(u);
510 self.emit(StepEvent::Usage { usage: u, step });
511 }
512
513 if !completion.content.is_empty() {
514 self.emit(StepEvent::AssistantText {
515 text: completion.content.clone(),
516 step,
517 });
518 final_message = Some(completion.content.clone());
519 }
520
521 if completion.tool_calls.is_empty() {
522 if matches!(completion.finish_reason.as_deref(), Some("length")) {
528 self.emit(StepEvent::Finished {
529 reason: FinishReason::ProviderStop("length".into()),
530 steps: step,
531 });
532 return Err(Error::ProviderTruncated("length".into()));
533 }
534
535 self.transcript
536 .push(Message::assistant(completion.content.clone()));
537 let finish = match completion.finish_reason {
538 Some(r) if r != "stop" && r != "end_turn" => FinishReason::ProviderStop(r),
539 _ => FinishReason::NoMoreToolCalls,
540 };
541 self.emit(StepEvent::Finished {
542 reason: finish.clone(),
543 steps: step,
544 });
545 let outcome = AgentOutcome {
546 final_message,
547 transcript: std::mem::take(&mut self.transcript),
548 steps: step,
549 finish,
550 total_usage,
551 total_llm_latency_ms: self.total_llm_latency_ms,
552 };
553 self.hooks
554 .dispatch(HookEvent::SessionEnd { outcome: &outcome });
555 return Ok(outcome);
556 }
557
558 self.transcript.push(Message::assistant_with_tool_calls(
559 completion.content.clone(),
560 completion.tool_calls.clone(),
561 ));
562
563 for call in &completion.tool_calls {
565 self.emit(StepEvent::ToolCall {
566 call: call.clone(),
567 step,
568 });
569 }
570
571 if self.planning_mode == PlanningMode::PlanFirst && self.plan_buffer.is_none() {
574 self.plan_buffer = Some(completion.tool_calls.clone());
575
576 let plan_text = completion
578 .tool_calls
579 .iter()
580 .map(|tc| {
581 let args_str = serde_json::to_string(&tc.arguments).unwrap_or_default();
582 format!(" - {}({})", tc.name, args_str)
583 })
584 .collect::<Vec<_>>()
585 .join("\n");
586 let plan_text = format!(
587 "The agent proposes the following steps:\n{}\n\nConfirm or reject this plan.",
588 plan_text
589 );
590
591 self.emit(StepEvent::PlanProposed {
592 plan_text: plan_text.clone(),
593 tool_calls: completion.tool_calls.clone(),
594 });
595
596 return Ok(AgentOutcome {
598 final_message: Some(plan_text),
599 transcript: std::mem::take(&mut self.transcript),
600 steps: step,
601 finish: FinishReason::PlanPending,
602 total_usage: TokenUsage::default(),
603 total_llm_latency_ms: self.total_llm_latency_ms,
604 });
605 }
606
607 let results = self.execute_tool_calls(&completion.tool_calls, step).await;
611
612 for (id, name, result, args) in &results {
614 self.emit(StepEvent::ToolResult {
615 id: id.clone(),
616 name: name.clone(),
617 output: result.clone(),
618 step,
619 });
620
621 let call_key = (
623 name.clone(),
624 serde_json::to_string(args).unwrap_or_default(),
625 );
626 let is_error = result.starts_with("ERROR:");
627
628 if is_error {
629 if last_call_key == Some(call_key.clone()) {
630 consecutive_errors += 1;
631 } else {
632 consecutive_errors = 1;
633 }
634 } else {
635 consecutive_errors = 0;
636 }
637
638 last_call_key = Some(call_key);
639
640 if consecutive_errors >= STUCK_THRESHOLD {
642 let repeated_call = name.clone();
643 let repeats = consecutive_errors;
644 let finish = FinishReason::Stuck {
645 repeated_call,
646 repeats,
647 };
648 self.emit(StepEvent::Finished {
649 reason: finish.clone(),
650 steps: step,
651 });
652 return Ok(AgentOutcome {
653 final_message,
654 transcript: std::mem::take(&mut self.transcript),
655 steps: step,
656 finish,
657 total_usage,
658 total_llm_latency_ms: self.total_llm_latency_ms,
659 });
660 }
661
662 self.transcript
663 .push(Message::tool_result(id.clone(), result.clone()));
664 }
665 }
666
667 warn!(target: "recursive::agent", "step budget exceeded");
668 let finish = FinishReason::BudgetExceeded;
669 self.emit(StepEvent::Finished {
670 reason: finish.clone(),
671 steps: self.max_steps,
672 });
673 let outcome = AgentOutcome {
674 final_message,
675 transcript: std::mem::take(&mut self.transcript),
676 steps: self.max_steps,
677 finish,
678 total_usage,
679 total_llm_latency_ms: self.total_llm_latency_ms,
680 };
681 self.hooks
682 .dispatch(HookEvent::SessionEnd { outcome: &outcome });
683 Ok(outcome)
684 }
685
686 async fn maybe_compact(&mut self, step: usize) -> Result<()> {
694 let compactor = match &self.compactor {
695 Some(c) => c,
696 None => return Ok(()),
697 };
698
699 let chars = Compactor::estimate_chars(&self.transcript);
700 if chars < compactor.threshold_chars {
701 return Ok(());
702 }
703
704 let min_messages = compactor.keep_recent_n + 2;
706 if self.transcript.len() < min_messages {
707 return Ok(());
708 }
709
710 let summary_msg = compactor
711 .compact(self.llm.as_ref(), &self.transcript)
712 .await?;
713 let summary_chars = summary_msg.content.len();
714
715 let keep = compactor.keep_recent_n;
718 let mut split = self.transcript.len().saturating_sub(keep);
719
720 while split > 0 && matches!(self.transcript[split].role, crate::message::Role::Tool) {
729 split -= 1;
730 }
731
732 let removed = split;
733 let kept = self.transcript.len() - split;
734
735 self.transcript.drain(..split);
737 self.transcript.insert(0, summary_msg);
738
739 self.hooks.dispatch(HookEvent::PostCompact {
740 removed,
741 summary_chars,
742 });
743
744 self.emit(StepEvent::Compacted {
745 removed,
746 kept,
747 summary_chars,
748 step,
749 });
750
751 Ok(())
752 }
753
754 fn maybe_trim_transcript(&mut self, limit: usize, step: usize) {
755 let mut chars: usize = self.transcript.iter().map(|m| m.content.len()).sum();
756 if chars < limit {
757 return;
758 }
759
760 let mut trimmed_count: usize = 0;
761 let placeholder_len = TRIM_PLACEHOLDER.len();
762
763 for msg in self.transcript.iter_mut().skip(1) {
766 if msg.role == crate::message::Role::Tool && msg.content.len() > 200 {
767 let old_len = msg.content.len();
768 msg.content = TRIM_PLACEHOLDER.to_string();
769 trimmed_count += 1;
770 chars = chars
772 .saturating_sub(old_len)
773 .saturating_add(placeholder_len);
774 if chars < limit {
775 break;
776 }
777 }
778 }
779
780 if trimmed_count > 0 {
781 let note = format!(
782 "[trimmed {} old tool result{} to fit budget]",
783 trimmed_count,
784 if trimmed_count == 1 { "" } else { "s" }
785 );
786 self.emit(StepEvent::AssistantText { text: note, step });
787 }
788 }
789
790 pub fn transcript(&self) -> &[Message] {
791 &self.transcript
792 }
793
794 fn emit(&self, event: StepEvent) {
795 if let Some(tx) = &self.events {
796 let _ = tx.send(event);
797 }
798 }
799}
800
801#[derive(Default)]
816pub struct AgentBuilder {
817 llm: Option<Arc<dyn LlmProvider>>,
818 tools: ToolRegistry,
819 system: Option<String>,
820 max_steps: Option<usize>,
821 max_transcript_chars: Option<usize>,
822 events: Option<mpsc::UnboundedSender<StepEvent>>,
823 seed: Vec<Message>,
824 streaming: bool,
825 compactor: Option<Compactor>,
826 permission_hook: Option<PermissionHook>,
827 hooks: HookRegistry,
828 planning_mode: PlanningMode,
829}
830
831impl AgentBuilder {
832 pub fn llm(mut self, llm: Arc<dyn LlmProvider>) -> Self {
837 self.llm = Some(llm);
838 self
839 }
840 pub fn tools(mut self, tools: ToolRegistry) -> Self {
846 self.tools = tools;
847 self
848 }
849 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
855 self.system = Some(prompt.into());
856 self
857 }
858 pub fn max_steps(mut self, n: usize) -> Self {
864 self.max_steps = Some(n);
865 self
866 }
867 pub fn max_transcript_chars(mut self, n: usize) -> Self {
879 self.max_transcript_chars = Some(n);
880 self
881 }
882 pub fn events(mut self, tx: mpsc::UnboundedSender<StepEvent>) -> Self {
889 self.events = Some(tx);
890 self
891 }
892 pub fn seed_transcript(mut self, messages: Vec<Message>) -> Self {
897 self.seed = messages;
898 self
899 }
900 pub fn streaming(mut self, enabled: bool) -> Self {
906 self.streaming = enabled;
907 self
908 }
909 pub fn compactor(mut self, compactor: Compactor) -> Self {
915 self.compactor = Some(compactor);
916 self
917 }
918 pub fn permission_hook<F>(mut self, hook: F) -> Self
924 where
925 F: Fn(&str, &serde_json::Value) -> PermissionDecision + Send + Sync + 'static,
926 {
927 self.permission_hook = Some(Arc::new(hook));
928 self
929 }
930
931 pub fn permission_hook_opt(mut self, hook: Option<PermissionHook>) -> Self {
937 self.permission_hook = hook;
938 self
939 }
940 pub fn hook(mut self, hook: Arc<dyn Hook>) -> Self {
945 self.hooks.register(hook);
946 self
947 }
948 pub fn planning_mode(mut self, mode: PlanningMode) -> Self {
954 self.planning_mode = mode;
955 self
956 }
957 pub fn build(self) -> Result<Agent> {
958 let llm = self.llm.ok_or_else(|| Error::Config {
959 message: "agent: missing llm provider".into(),
960 })?;
961 let mut transcript = Vec::new();
962 if let Some(sys) = self.system {
963 transcript.push(Message::system(sys));
964 }
965 transcript.extend(self.seed);
966 Ok(Agent {
967 llm,
968 tools: self.tools,
969 transcript,
970 max_steps: self.max_steps.unwrap_or(32),
971 max_transcript_chars: self.max_transcript_chars,
972 events: self.events,
973 streaming: self.streaming,
974 total_llm_latency_ms: 0,
975 compactor: self.compactor,
976 permission_hook: self.permission_hook,
977 hooks: self.hooks,
978 planning_mode: self.planning_mode,
979 plan_buffer: None,
980 plan_confirmed: false,
981 })
982 }
983}
984
985fn truncate(s: &str, n: usize) -> String {
986 if s.chars().count() <= n {
987 s.to_string()
988 } else {
989 let mut out: String = s.chars().take(n).collect();
990 out.push_str("...");
991 out
992 }
993}
994
995#[cfg(test)]
996mod tests {
997 use super::*;
998 use crate::llm::{Completion, MockProvider, TokenUsage, ToolCall};
999 use crate::tools::Tool;
1000 use async_trait::async_trait;
1001 use serde_json::{json, Value};
1002
1003 struct Adder;
1004
1005 #[async_trait]
1006 impl Tool for Adder {
1007 fn spec(&self) -> crate::llm::ToolSpec {
1008 crate::llm::ToolSpec {
1009 name: "add".into(),
1010 description: "add a and b".into(),
1011 parameters: json!({"type":"object","properties":{"a":{"type":"integer"},"b":{"type":"integer"}}}),
1012 }
1013 }
1014 async fn execute(&self, args: Value) -> Result<String> {
1015 let a = args["a"].as_i64().unwrap_or(0);
1016 let b = args["b"].as_i64().unwrap_or(0);
1017 Ok((a + b).to_string())
1018 }
1019 }
1020
1021 #[tokio::test]
1022 async fn terminates_when_model_emits_no_tool_calls() {
1023 let llm = Arc::new(MockProvider::new(vec![Completion {
1024 content: "done".into(),
1025 tool_calls: vec![],
1026 finish_reason: Some("stop".into()),
1027 usage: None,
1028 }]));
1029 let mut agent = Agent::builder().llm(llm).build().unwrap();
1030 let out = agent.run("hi").await.unwrap();
1031 assert_eq!(out.final_message.as_deref(), Some("done"));
1032 assert_eq!(out.steps, 1);
1033 assert_eq!(out.finish, FinishReason::NoMoreToolCalls);
1034 }
1035
1036 #[tokio::test]
1037 async fn runs_a_tool_then_completes() {
1038 let llm = Arc::new(MockProvider::new(vec![
1039 Completion {
1040 content: "let me add".into(),
1041 tool_calls: vec![ToolCall {
1042 id: "c1".into(),
1043 name: "add".into(),
1044 arguments: json!({"a":2,"b":3}),
1045 }],
1046 finish_reason: Some("tool_calls".into()),
1047 usage: None,
1048 },
1049 Completion {
1050 content: "5".into(),
1051 tool_calls: vec![],
1052 finish_reason: Some("stop".into()),
1053 usage: None,
1054 },
1055 ]));
1056 let tools = ToolRegistry::local().register(Arc::new(Adder));
1057 let mut agent = Agent::builder().llm(llm).tools(tools).build().unwrap();
1058 let out = agent.run("what is 2+3?").await.unwrap();
1059 assert_eq!(out.final_message.as_deref(), Some("5"));
1060 assert_eq!(out.steps, 2);
1061 assert_eq!(out.transcript.len(), 4);
1063 }
1064
1065 #[tokio::test]
1066 async fn reports_step_budget_exceeded() {
1067 let mut script = Vec::new();
1068 for _ in 0..10 {
1069 script.push(Completion {
1070 content: "".into(),
1071 tool_calls: vec![ToolCall {
1072 id: "x".into(),
1073 name: "add".into(),
1074 arguments: json!({"a":1,"b":1}),
1075 }],
1076 finish_reason: Some("tool_calls".into()),
1077 usage: None,
1078 });
1079 }
1080 let llm = Arc::new(MockProvider::new(script));
1081 let tools = ToolRegistry::local().register(Arc::new(Adder));
1082 let mut agent = Agent::builder()
1083 .llm(llm)
1084 .tools(tools)
1085 .max_steps(3)
1086 .build()
1087 .unwrap();
1088 let out = agent.run("loop").await.unwrap();
1089 assert!(matches!(out.finish, FinishReason::BudgetExceeded));
1090 assert_eq!(out.steps, 3);
1091 assert!(!out.transcript.is_empty());
1094 }
1095
1096 #[tokio::test]
1097 async fn unknown_tool_returns_error_to_model_not_abort() {
1098 let llm = Arc::new(MockProvider::new(vec![
1099 Completion {
1100 content: "".into(),
1101 tool_calls: vec![ToolCall {
1102 id: "c1".into(),
1103 name: "nope".into(),
1104 arguments: json!({}),
1105 }],
1106 finish_reason: Some("tool_calls".into()),
1107 usage: None,
1108 },
1109 Completion {
1110 content: "ok i give up".into(),
1111 tool_calls: vec![],
1112 finish_reason: Some("stop".into()),
1113 usage: None,
1114 },
1115 ]));
1116 let mut agent = Agent::builder().llm(llm).build().unwrap();
1117 let out = agent.run("call a missing tool").await.unwrap();
1118 let tool_msg = out
1120 .transcript
1121 .iter()
1122 .find(|m| m.role == crate::message::Role::Tool)
1123 .unwrap();
1124 assert!(tool_msg.content.contains("ERROR"));
1125 assert_eq!(out.final_message.as_deref(), Some("ok i give up"));
1126 }
1127
1128 #[tokio::test]
1129 async fn emits_events_in_order() {
1130 let llm = Arc::new(MockProvider::new(vec![
1131 Completion {
1132 content: "thinking".into(),
1133 tool_calls: vec![ToolCall {
1134 id: "c1".into(),
1135 name: "add".into(),
1136 arguments: json!({"a":1,"b":1}),
1137 }],
1138 finish_reason: Some("tool_calls".into()),
1139 usage: None,
1140 },
1141 Completion {
1142 content: "two".into(),
1143 tool_calls: vec![],
1144 finish_reason: Some("stop".into()),
1145 usage: None,
1146 },
1147 ]));
1148 let tools = ToolRegistry::local().register(Arc::new(Adder));
1149 let (tx, mut rx) = mpsc::unbounded_channel();
1150 let mut agent = Agent::builder()
1151 .llm(llm)
1152 .tools(tools)
1153 .events(tx)
1154 .build()
1155 .unwrap();
1156 agent.run("add").await.unwrap();
1157 let mut kinds = Vec::new();
1158 while let Ok(e) = rx.try_recv() {
1159 kinds.push(match e {
1160 StepEvent::AssistantText { .. } => "text",
1161 StepEvent::ToolCall { .. } => "call",
1162 StepEvent::ToolResult { .. } => "result",
1163 StepEvent::Finished { .. } => "done",
1164 StepEvent::Usage { .. } => "usage",
1165 StepEvent::Latency { .. } => "latency",
1166 StepEvent::PartialToken { .. } => "partial",
1167 StepEvent::Compacted { .. } => "compacted",
1168 StepEvent::PlanProposed { .. } => "plan_proposed",
1169 StepEvent::PlanConfirmed => "plan_confirmed",
1170 StepEvent::PlanRejected { .. } => "plan_rejected",
1171 });
1172 }
1173 assert_eq!(
1174 kinds,
1175 vec!["latency", "text", "call", "result", "latency", "text", "done"]
1176 );
1177 }
1178
1179 #[tokio::test]
1180 async fn stops_when_repeated_call_keeps_erroring() {
1181 let mut script = Vec::new();
1183 for i in 0..4 {
1184 script.push(Completion {
1185 content: "".into(),
1186 tool_calls: vec![ToolCall {
1187 id: format!("c{}", i),
1188 name: "UnknownTool".into(),
1189 arguments: json!({"arg": "value"}),
1190 }],
1191 finish_reason: Some("tool_calls".into()),
1192 usage: None,
1193 });
1194 }
1195 let llm = Arc::new(MockProvider::new(script));
1196 let mut agent = Agent::builder().llm(llm).max_steps(10).build().unwrap();
1197 let out = agent.run("call unknown tool").await.unwrap();
1198
1199 assert!(matches!(out.finish, FinishReason::Stuck { .. }));
1201 if let FinishReason::Stuck {
1202 repeated_call,
1203 repeats,
1204 } = &out.finish
1205 {
1206 assert_eq!(repeated_call, "UnknownTool");
1207 assert_eq!(*repeats, 3);
1208 }
1209 }
1210
1211 #[tokio::test]
1212 async fn truncated_response_surfaces_as_error() {
1213 let llm = Arc::new(MockProvider::new(vec![Completion {
1217 content: "I was going to say more but ran out of".into(),
1218 tool_calls: vec![],
1219 finish_reason: Some("length".into()),
1220 usage: None,
1221 }]));
1222 let mut agent = Agent::builder().llm(llm).build().unwrap();
1223 let err = agent.run("hi").await.unwrap_err();
1224 assert!(matches!(err, Error::ProviderTruncated(ref s) if s == "length"));
1225 }
1226
1227 #[tokio::test]
1228 async fn does_not_trigger_when_args_differ() {
1229 let mut script = Vec::new();
1231 for i in 0..3 {
1232 script.push(Completion {
1233 content: "".into(),
1234 tool_calls: vec![ToolCall {
1235 id: format!("c{}", i),
1236 name: "add".into(),
1237 arguments: json!({"a": i, "b": i}),
1238 }],
1239 finish_reason: Some("tool_calls".into()),
1240 usage: None,
1241 });
1242 }
1243 let llm = Arc::new(MockProvider::new(script));
1244 let tools = ToolRegistry::local().register(Arc::new(Adder));
1245 let mut agent = Agent::builder()
1247 .llm(llm)
1248 .tools(tools)
1249 .max_steps(3)
1250 .build()
1251 .unwrap();
1252 let out = agent.run("add with different args").await.unwrap();
1253
1254 assert!(matches!(out.finish, FinishReason::BudgetExceeded));
1256 assert_eq!(out.steps, 3);
1257 }
1258
1259 #[tokio::test]
1276 async fn budget_exceeded_yields_writable_transcript() {
1277 use crate::TranscriptFile;
1278
1279 let mut script = Vec::new();
1280 for i in 0..10 {
1281 script.push(Completion {
1282 content: String::new(),
1283 tool_calls: vec![ToolCall {
1284 id: format!("t{i}"),
1285 name: "adder".into(),
1286 arguments: json!({"a": i, "b": i + 1}),
1287 }],
1288 finish_reason: Some("tool_calls".into()),
1289 usage: None,
1290 });
1291 }
1292 let llm = Arc::new(MockProvider::new(script));
1293 let tools = ToolRegistry::local().register(Arc::new(Adder));
1294 let mut agent = Agent::builder()
1295 .llm(llm)
1296 .tools(tools)
1297 .max_steps(3)
1298 .build()
1299 .unwrap();
1300 let out = agent.run("loop").await.unwrap();
1301
1302 assert!(matches!(out.finish, FinishReason::BudgetExceeded));
1303 assert!(
1304 !out.transcript.is_empty(),
1305 "transcript must survive BudgetExceeded for auto-resume"
1306 );
1307
1308 let tmp = tempfile::NamedTempFile::new().unwrap();
1309 let file =
1310 TranscriptFile::new(out.transcript.clone(), out.steps, Some("mock-model".into()));
1311 file.write_to(tmp.path()).unwrap();
1312
1313 let restored = TranscriptFile::read_from(tmp.path()).unwrap();
1314 assert_eq!(
1315 restored.messages().len(),
1316 out.transcript.len(),
1317 "round-trip transcript length must match"
1318 );
1319 }
1320
1321 #[tokio::test]
1322 async fn accumulates_usage_across_llm_calls() {
1323 let u1 = TokenUsage {
1324 prompt_tokens: 10,
1325 completion_tokens: 5,
1326 total_tokens: 15,
1327 cache_hit_tokens: 0,
1328 cache_miss_tokens: 0,
1329 };
1330 let u2 = TokenUsage {
1331 prompt_tokens: 10,
1332 completion_tokens: 5,
1333 total_tokens: 15,
1334 cache_hit_tokens: 0,
1335 cache_miss_tokens: 0,
1336 };
1337 let llm = Arc::new(MockProvider::new(vec![
1338 Completion {
1339 content: "step 1".into(),
1340 tool_calls: vec![ToolCall {
1341 id: "c1".into(),
1342 name: "add".into(),
1343 arguments: json!({"a":1,"b":1}),
1344 }],
1345 finish_reason: Some("tool_calls".into()),
1346 usage: Some(u1),
1347 },
1348 Completion {
1349 content: "step 2".into(),
1350 tool_calls: vec![],
1351 finish_reason: Some("stop".into()),
1352 usage: Some(u2),
1353 },
1354 ]));
1355 let tools = ToolRegistry::local().register(Arc::new(Adder));
1356 let mut agent = Agent::builder().llm(llm).tools(tools).build().unwrap();
1357 let out = agent.run("add twice").await.unwrap();
1358
1359 assert_eq!(out.total_usage.prompt_tokens, 20);
1360 assert_eq!(out.total_usage.completion_tokens, 10);
1361 assert_eq!(out.total_usage.total_tokens, 30);
1362 }
1363
1364 #[tokio::test]
1365 async fn outcome_has_zero_usage_when_provider_never_reports() {
1366 let llm = Arc::new(MockProvider::new(vec![Completion {
1367 content: "done".into(),
1368 tool_calls: vec![],
1369 finish_reason: Some("stop".into()),
1370 usage: None,
1371 }]));
1372 let mut agent = Agent::builder().llm(llm).build().unwrap();
1373 let out = agent.run("hi").await.unwrap();
1374
1375 assert_eq!(out.total_usage, TokenUsage::default());
1376 }
1377
1378 #[tokio::test]
1379 async fn step_event_usage_emitted_per_llm_call() {
1380 let u = TokenUsage {
1381 prompt_tokens: 10,
1382 completion_tokens: 5,
1383 total_tokens: 15,
1384 cache_hit_tokens: 0,
1385 cache_miss_tokens: 0,
1386 };
1387 let llm = Arc::new(MockProvider::new(vec![Completion {
1388 content: "first".into(),
1389 tool_calls: vec![],
1390 finish_reason: Some("stop".into()),
1391 usage: Some(u),
1392 }]));
1393 let (tx, mut rx) = mpsc::unbounded_channel();
1394 let mut agent = Agent::builder().llm(llm).events(tx).build().unwrap();
1395 agent.run("hi").await.unwrap();
1396
1397 let mut usage_events = 0;
1398 while let Ok(e) = rx.try_recv() {
1399 if matches!(e, StepEvent::Usage { .. }) {
1400 usage_events += 1;
1401 }
1402 }
1403 assert_eq!(usage_events, 1);
1404 }
1405
1406 #[tokio::test]
1407 async fn transcript_limit_stops_loop() {
1408 let mut script = Vec::new();
1413 for _ in 0..30 {
1414 script.push(Completion {
1415 content: "x".into(),
1416 tool_calls: vec![ToolCall {
1417 id: "c1".into(),
1418 name: "add".into(),
1419 arguments: json!({"a":1,"b":1}),
1420 }],
1421 finish_reason: Some("tool_calls".into()),
1422 usage: None,
1423 });
1424 }
1425 let llm = Arc::new(MockProvider::new(script));
1426 let tools = ToolRegistry::local().register(Arc::new(Adder));
1427 let mut agent = Agent::builder()
1428 .llm(llm)
1429 .tools(tools)
1430 .max_transcript_chars(50)
1431 .max_steps(100)
1432 .build()
1433 .unwrap();
1434 let out = agent.run("hi").await.unwrap();
1435 assert!(matches!(out.finish, FinishReason::TranscriptLimit { .. }));
1436 if let FinishReason::TranscriptLimit { chars, limit } = &out.finish {
1437 assert!(*chars >= 50);
1438 assert_eq!(*limit, 50);
1439 }
1440 }
1441
1442 #[tokio::test]
1443 async fn transcript_limit_unset_runs_to_completion() {
1444 let llm = Arc::new(MockProvider::new(vec![
1445 Completion {
1446 content: "let me add".into(),
1447 tool_calls: vec![ToolCall {
1448 id: "c1".into(),
1449 name: "add".into(),
1450 arguments: json!({"a":2,"b":3}),
1451 }],
1452 finish_reason: Some("tool_calls".into()),
1453 usage: None,
1454 },
1455 Completion {
1456 content: "5".into(),
1457 tool_calls: vec![],
1458 finish_reason: Some("stop".into()),
1459 usage: None,
1460 },
1461 ]));
1462 let tools = ToolRegistry::local().register(Arc::new(Adder));
1463 let mut agent = Agent::builder().llm(llm).tools(tools).build().unwrap();
1464 let out = agent.run("what is 2+3?").await.unwrap();
1465 assert_eq!(out.finish, FinishReason::NoMoreToolCalls);
1466 }
1467
1468 #[tokio::test]
1469 async fn transcript_limit_is_checked_before_llm_call() {
1470 let llm = Arc::new(MockProvider::new(vec![]));
1473 let mut agent = Agent::builder()
1474 .llm(llm)
1475 .max_transcript_chars(10)
1476 .build()
1477 .unwrap();
1478 let out = agent
1479 .run("a very long goal that exceeds the limit")
1480 .await
1481 .unwrap();
1482 assert!(matches!(out.finish, FinishReason::TranscriptLimit { .. }));
1483 if let FinishReason::TranscriptLimit { chars, limit } = &out.finish {
1484 assert!(*chars >= 10);
1485 assert_eq!(*limit, 10);
1486 }
1487 assert_eq!(out.steps, 1);
1489 }
1490
1491 #[tokio::test]
1492 async fn compaction_triggers_with_low_threshold() {
1493 let llm = Arc::new(MockProvider::new(vec![
1499 Completion {
1500 content: "first call".into(),
1501 tool_calls: vec![ToolCall {
1502 id: "c1".into(),
1503 name: "add".into(),
1504 arguments: json!({"a":1,"b":1}),
1505 }],
1506 finish_reason: Some("tool_calls".into()),
1507 usage: None,
1508 },
1509 Completion {
1510 content: "second call".into(),
1511 tool_calls: vec![ToolCall {
1512 id: "c1".into(),
1513 name: "add".into(),
1514 arguments: json!({"a":1,"b":1}),
1515 }],
1516 finish_reason: Some("tool_calls".into()),
1517 usage: None,
1518 },
1519 Completion {
1521 content: "Summary: added numbers, tests pass.".into(),
1522 tool_calls: vec![],
1523 finish_reason: Some("stop".into()),
1524 usage: None,
1525 },
1526 Completion {
1528 content: "done".into(),
1529 tool_calls: vec![],
1530 finish_reason: Some("stop".into()),
1531 usage: None,
1532 },
1533 ]));
1534 let tools = ToolRegistry::local().register(Arc::new(Adder));
1535 let (tx, mut rx) = mpsc::unbounded_channel();
1536 let compactor = crate::compact::Compactor::new(10).keep_recent_n(2);
1537 let mut agent = Agent::builder()
1538 .llm(llm)
1539 .tools(tools)
1540 .events(tx)
1541 .compactor(compactor)
1542 .max_steps(10)
1543 .build()
1544 .unwrap();
1545 let out = agent.run("hi").await.unwrap();
1546 assert_eq!(out.finish, FinishReason::NoMoreToolCalls);
1547
1548 let mut compacted_count = 0;
1550 while let Ok(e) = rx.try_recv() {
1551 if matches!(e, StepEvent::Compacted { .. }) {
1552 compacted_count += 1;
1553 }
1554 }
1555 assert_eq!(compacted_count, 1, "expected exactly one Compacted event");
1556
1557 let summary_msgs: Vec<&Message> = out
1559 .transcript
1560 .iter()
1561 .filter(|m| m.role == crate::message::Role::System)
1562 .collect();
1563 assert!(
1564 !summary_msgs.is_empty(),
1565 "expected at least one system message (the summary)"
1566 );
1567 assert!(
1568 summary_msgs
1569 .iter()
1570 .any(|m| m.content.contains("[compacted:")),
1571 "expected a system message with compacted header"
1572 );
1573 }
1574
1575 #[tokio::test]
1576 async fn compaction_disabled_by_default() {
1577 let llm = Arc::new(MockProvider::new(vec![
1579 Completion {
1580 content: "let me add".into(),
1581 tool_calls: vec![ToolCall {
1582 id: "c1".into(),
1583 name: "add".into(),
1584 arguments: json!({"a":1,"b":1}),
1585 }],
1586 finish_reason: Some("tool_calls".into()),
1587 usage: None,
1588 },
1589 Completion {
1590 content: "done".into(),
1591 tool_calls: vec![],
1592 finish_reason: Some("stop".into()),
1593 usage: None,
1594 },
1595 ]));
1596 let tools = ToolRegistry::local().register(Arc::new(Adder));
1597 let (tx, mut rx) = mpsc::unbounded_channel();
1598 let mut agent = Agent::builder()
1599 .llm(llm)
1600 .tools(tools)
1601 .events(tx)
1602 .max_steps(10)
1603 .build()
1604 .unwrap();
1605 let out = agent.run("hi").await.unwrap();
1606 assert_eq!(out.finish, FinishReason::NoMoreToolCalls);
1607
1608 let mut compacted_count = 0;
1610 while let Ok(e) = rx.try_recv() {
1611 if matches!(e, StepEvent::Compacted { .. }) {
1612 compacted_count += 1;
1613 }
1614 }
1615 assert_eq!(
1616 compacted_count, 0,
1617 "expected no Compacted events by default"
1618 );
1619 }
1620
1621 #[tokio::test]
1640 async fn compaction_keeps_tool_calls_paired_with_results() {
1641 use crate::message::Role;
1642 let llm = Arc::new(MockProvider::new(vec![
1643 Completion {
1644 content: "looking...".to_string(),
1645 tool_calls: vec![ToolCall {
1646 id: "call-1".to_string(),
1647 name: "adder".to_string(),
1648 arguments: json!({"a": 1, "b": 2}),
1649 }],
1650 finish_reason: None,
1651 usage: None,
1652 },
1653 Completion {
1655 content: "Summary of older messages.".to_string(),
1656 tool_calls: vec![],
1657 finish_reason: Some("stop".to_string()),
1658 usage: None,
1659 },
1660 Completion {
1661 content: "done".to_string(),
1662 tool_calls: vec![],
1663 finish_reason: Some("stop".to_string()),
1664 usage: None,
1665 },
1666 ]));
1667 let tools = ToolRegistry::local().register(Arc::new(Adder));
1668 let compactor = crate::compact::Compactor::new(10).keep_recent_n(1);
1671 let mut agent = Agent::builder()
1672 .llm(llm)
1673 .tools(tools)
1674 .max_steps(5)
1675 .compactor(compactor)
1676 .build()
1677 .unwrap();
1678
1679 let out = agent.run("test").await.unwrap();
1680 assert_eq!(out.finish, FinishReason::NoMoreToolCalls);
1681
1682 let first = &out.transcript[0];
1688 assert!(
1689 matches!(first.role, Role::System) && first.content.contains("[compacted:"),
1690 "compaction never fired — transcript[0] = {:?}, content={:?}",
1691 first.role,
1692 &first.content.chars().take(60).collect::<String>()
1693 );
1694
1695 for (i, m) in out.transcript.iter().enumerate() {
1698 if m.role == Role::Tool {
1699 assert!(
1700 i > 0,
1701 "Tool message at index 0 (right after summary) is impossible"
1702 );
1703 let prev = &out.transcript[i - 1];
1704 assert!(
1705 matches!(prev.role, Role::Assistant) && !prev.tool_calls.is_empty(),
1706 "tool message at index {i} is orphaned — previous message \
1707 has role={:?}, tool_calls={}",
1708 prev.role,
1709 prev.tool_calls.len()
1710 );
1711 }
1712 }
1713 }
1714
1715 #[test]
1716 fn step_event_serializes_with_kind_tag() {
1717 let ev = StepEvent::AssistantText {
1718 text: "hello".into(),
1719 step: 1,
1720 };
1721 let json = serde_json::to_string(&ev).unwrap();
1722 assert!(json.contains(r#""kind":"assistant_text""#));
1723 let back: StepEvent = serde_json::from_str(&json).unwrap();
1724 assert!(
1725 matches!(back, StepEvent::AssistantText { text, step } if text == "hello" && step == 1)
1726 );
1727 }
1728
1729 #[test]
1730 fn step_event_tool_call_uses_snake_case() {
1731 let ev = StepEvent::ToolCall {
1732 call: ToolCall {
1733 id: "c1".into(),
1734 name: "read_file".into(),
1735 arguments: json!({"path": "foo.txt"}),
1736 },
1737 step: 2,
1738 };
1739 let json = serde_json::to_string(&ev).unwrap();
1740 assert!(json.contains(r#""kind":"tool_call""#));
1741 }
1742
1743 #[test]
1744 fn step_event_latency_serializes_with_kind_tag() {
1745 let ev = StepEvent::Latency {
1746 step: 3,
1747 llm_ms: 42,
1748 };
1749 let json = serde_json::to_string(&ev).unwrap();
1750 assert!(json.contains(r#""kind":"latency""#));
1751 assert!(json.contains(r#""step":3"#));
1752 assert!(json.contains(r#""llm_ms":42"#));
1753 let back: StepEvent = serde_json::from_str(&json).unwrap();
1754 assert!(matches!(back, StepEvent::Latency { step, llm_ms } if step == 3 && llm_ms == 42));
1755 }
1756
1757 #[test]
1758 fn finish_reason_serializes_with_kind_tag() {
1759 let fr = FinishReason::Stuck {
1760 repeated_call: "read_file".into(),
1761 repeats: 3,
1762 };
1763 let json = serde_json::to_string(&fr).unwrap();
1764 assert!(json.contains(r#""kind":"stuck""#));
1765 let back: FinishReason = serde_json::from_str(&json).unwrap();
1766 assert_eq!(back, fr);
1767 }
1768
1769 #[test]
1770 fn finish_reason_transcript_limit_roundtrips() {
1771 let fr = FinishReason::TranscriptLimit {
1772 chars: 4096,
1773 limit: 2048,
1774 };
1775 let json = serde_json::to_string(&fr).unwrap();
1776 let back: FinishReason = serde_json::from_str(&json).unwrap();
1777 assert_eq!(back, fr);
1778 }
1779
1780 #[tokio::test]
1781 async fn seeded_transcript_lands_before_new_goal() {
1782 let seed = vec![
1783 Message::system("sys".to_string()),
1784 Message::user("old goal".to_string()),
1785 Message::assistant("old reply".to_string()),
1786 ];
1787 let llm = Arc::new(MockProvider::new(vec![Completion {
1788 content: "fresh reply".into(),
1789 tool_calls: vec![],
1790 finish_reason: Some("stop".into()),
1791 usage: None,
1792 }]));
1793 let mut agent = Agent::builder()
1794 .llm(llm)
1795 .seed_transcript(seed)
1796 .build()
1797 .unwrap();
1798 let out = agent.run("new goal").await.unwrap();
1799 assert_eq!(out.transcript.len(), 5);
1801 assert_eq!(out.transcript[0].content, "sys");
1802 assert_eq!(out.transcript[1].content, "old goal");
1803 assert_eq!(out.transcript[2].content, "old reply");
1804 assert_eq!(out.transcript[3].content, "new goal");
1805 assert_eq!(out.transcript[4].content, "fresh reply");
1806 }
1807
1808 #[tokio::test]
1809 async fn seed_transcript_does_not_emit_events_for_seed() {
1810 let seed = vec![
1811 Message::user("old goal".to_string()),
1812 Message::assistant("old reply".to_string()),
1813 ];
1814 let llm = Arc::new(MockProvider::new(vec![Completion {
1815 content: "fresh".into(),
1816 tool_calls: vec![],
1817 finish_reason: Some("stop".into()),
1818 usage: None,
1819 }]));
1820 let (tx, mut rx) = mpsc::unbounded_channel();
1821 let mut agent = Agent::builder()
1822 .llm(llm)
1823 .seed_transcript(seed)
1824 .events(tx)
1825 .build()
1826 .unwrap();
1827 agent.run("new goal").await.unwrap();
1828 let mut kinds: Vec<&'static str> = Vec::new();
1829 while let Ok(ev) = rx.try_recv() {
1830 kinds.push(match ev {
1831 StepEvent::AssistantText { .. } => "text",
1832 StepEvent::Finished { .. } => "done",
1833 StepEvent::Latency { .. } => "latency",
1834 _ => "other",
1835 });
1836 }
1837 assert_eq!(kinds, vec!["latency", "text", "done"]);
1839 }
1840
1841 #[tokio::test]
1842 async fn emits_latency_event_per_llm_call() {
1843 let llm = Arc::new(MockProvider::new(vec![
1845 Completion {
1846 content: "step 1".into(),
1847 tool_calls: vec![ToolCall {
1848 id: "c1".into(),
1849 name: "add".into(),
1850 arguments: json!({"a":1,"b":1}),
1851 }],
1852 finish_reason: Some("tool_calls".into()),
1853 usage: None,
1854 },
1855 Completion {
1856 content: "step 2".into(),
1857 tool_calls: vec![],
1858 finish_reason: Some("stop".into()),
1859 usage: None,
1860 },
1861 ]));
1862 let tools = ToolRegistry::local().register(Arc::new(Adder));
1863 let (tx, mut rx) = mpsc::unbounded_channel();
1864 let mut agent = Agent::builder()
1865 .llm(llm)
1866 .tools(tools)
1867 .events(tx)
1868 .build()
1869 .unwrap();
1870 let out = agent.run("add twice").await.unwrap();
1871
1872 let _: u64 = out.total_llm_latency_ms;
1874
1875 let mut latency_count = 0;
1877 while let Ok(e) = rx.try_recv() {
1878 if matches!(e, StepEvent::Latency { .. }) {
1879 latency_count += 1;
1880 }
1881 }
1882 assert_eq!(latency_count, 2);
1884 }
1885
1886 #[tokio::test]
1889 async fn trims_old_tool_result_to_fit_budget() {
1890 let llm = Arc::new(MockProvider::new(vec![
1894 Completion {
1895 content: "let me run a tool".into(),
1896 tool_calls: vec![ToolCall {
1897 id: "c1".into(),
1898 name: "big".into(),
1899 arguments: json!({}),
1900 }],
1901 finish_reason: Some("tool_calls".into()),
1902 usage: None,
1903 },
1904 Completion {
1905 content: "done".into(),
1906 tool_calls: vec![],
1907 finish_reason: Some("stop".into()),
1908 usage: None,
1909 },
1910 ]));
1911 struct BigResultTool;
1913 #[async_trait]
1914 impl Tool for BigResultTool {
1915 fn spec(&self) -> crate::llm::ToolSpec {
1916 crate::llm::ToolSpec {
1917 name: "big".into(),
1918 description: "returns a big result".into(),
1919 parameters: json!({"type":"object"}),
1920 }
1921 }
1922 async fn execute(&self, _args: Value) -> Result<String> {
1923 Ok("x".repeat(500))
1924 }
1925 }
1926 let tools = ToolRegistry::local().register(Arc::new(BigResultTool));
1927 let mut agent = Agent::builder()
1935 .llm(llm)
1936 .tools(tools)
1937 .max_transcript_chars(100)
1938 .max_steps(10)
1939 .build()
1940 .unwrap();
1941 let out = agent.run("hi").await.unwrap();
1942 assert_eq!(out.finish, FinishReason::NoMoreToolCalls);
1944 let tool_msgs: Vec<&Message> = out
1946 .transcript
1947 .iter()
1948 .filter(|m| m.role == crate::message::Role::Tool)
1949 .collect();
1950 assert!(!tool_msgs.is_empty());
1951 for msg in &tool_msgs {
1952 assert_eq!(msg.content, TRIM_PLACEHOLDER);
1953 }
1954 }
1955
1956 #[tokio::test]
1957 async fn transcript_limit_fires_when_trimming_not_enough() {
1958 let llm = Arc::new(MockProvider::new(vec![
1961 Completion {
1962 content: "x".into(),
1963 tool_calls: vec![ToolCall {
1964 id: "c1".into(),
1965 name: "add".into(),
1966 arguments: json!({"a":1,"b":1}),
1967 }],
1968 finish_reason: Some("tool_calls".into()),
1969 usage: None,
1970 },
1971 Completion {
1972 content: "y".into(),
1973 tool_calls: vec![],
1974 finish_reason: Some("stop".into()),
1975 usage: None,
1976 },
1977 ]));
1978 let tools = ToolRegistry::local().register(Arc::new(Adder));
1979 let mut agent = Agent::builder()
1981 .llm(llm)
1982 .tools(tools)
1983 .max_transcript_chars(1)
1984 .max_steps(10)
1985 .build()
1986 .unwrap();
1987 let out = agent.run("hi").await.unwrap();
1988 assert!(matches!(out.finish, FinishReason::TranscriptLimit { .. }));
1989 }
1990
1991 #[tokio::test]
1996 async fn permission_hook_allow_passes_args_unchanged() {
1997 let llm = Arc::new(MockProvider::new(vec![
1999 Completion {
2000 content: "let me add".into(),
2001 tool_calls: vec![ToolCall {
2002 id: "c1".into(),
2003 name: "add".into(),
2004 arguments: json!({"a": 2, "b": 3}),
2005 }],
2006 finish_reason: Some("tool_calls".into()),
2007 usage: None,
2008 },
2009 Completion {
2010 content: "5".into(),
2011 tool_calls: vec![],
2012 finish_reason: Some("stop".into()),
2013 usage: None,
2014 },
2015 ]));
2016 let tools = ToolRegistry::local().register(Arc::new(Adder));
2017 let mut agent = Agent::builder()
2018 .llm(llm)
2019 .tools(tools)
2020 .permission_hook(|_name, _args| PermissionDecision::Allow)
2021 .build()
2022 .unwrap();
2023 let out = agent.run("what is 2+3?").await.unwrap();
2024 assert_eq!(out.final_message.as_deref(), Some("5"));
2025 assert_eq!(out.steps, 2);
2026 }
2027
2028 #[tokio::test]
2029 async fn permission_hook_deny_returns_error_to_model() {
2030 let llm = Arc::new(MockProvider::new(vec![
2033 Completion {
2034 content: "let me add".into(),
2035 tool_calls: vec![ToolCall {
2036 id: "c1".into(),
2037 name: "add".into(),
2038 arguments: json!({"a": 2, "b": 3}),
2039 }],
2040 finish_reason: Some("tool_calls".into()),
2041 usage: None,
2042 },
2043 Completion {
2044 content: "i see the error".into(),
2045 tool_calls: vec![],
2046 finish_reason: Some("stop".into()),
2047 usage: None,
2048 },
2049 ]));
2050 let tools = ToolRegistry::local().register(Arc::new(Adder));
2051 let mut agent = Agent::builder()
2052 .llm(llm)
2053 .tools(tools)
2054 .permission_hook(|_name, _args| PermissionDecision::Deny("not allowed".into()))
2055 .build()
2056 .unwrap();
2057 let out = agent.run("add numbers").await.unwrap();
2058 let tool_msgs: Vec<&Message> = out
2060 .transcript
2061 .iter()
2062 .filter(|m| m.role == crate::message::Role::Tool)
2063 .collect();
2064 assert_eq!(tool_msgs.len(), 1);
2065 assert!(tool_msgs[0].content.contains("not allowed"));
2066 assert_eq!(out.final_message.as_deref(), Some("i see the error"));
2068 }
2069
2070 #[tokio::test]
2071 async fn permission_hook_transform_replaces_args() {
2072 let llm = Arc::new(MockProvider::new(vec![
2075 Completion {
2076 content: "let me add".into(),
2077 tool_calls: vec![ToolCall {
2078 id: "c1".into(),
2079 name: "add".into(),
2080 arguments: json!({"a": 100, "b": 200}),
2081 }],
2082 finish_reason: Some("tool_calls".into()),
2083 usage: None,
2084 },
2085 Completion {
2086 content: "done".into(),
2087 tool_calls: vec![],
2088 finish_reason: Some("stop".into()),
2089 usage: None,
2090 },
2091 ]));
2092 let tools = ToolRegistry::local().register(Arc::new(Adder));
2093 let mut agent = Agent::builder()
2095 .llm(llm)
2096 .tools(tools)
2097 .permission_hook(|_name, _args| PermissionDecision::Transform(json!({"a": 1, "b": 2})))
2098 .build()
2099 .unwrap();
2100 let out = agent.run("add numbers").await.unwrap();
2101 let tool_msgs: Vec<&Message> = out
2103 .transcript
2104 .iter()
2105 .filter(|m| m.role == crate::message::Role::Tool)
2106 .collect();
2107 assert_eq!(tool_msgs.len(), 1);
2108 assert_eq!(tool_msgs[0].content, "3");
2109 }
2110
2111 #[tokio::test]
2112 async fn default_no_hook_is_unchanged() {
2113 let llm = Arc::new(MockProvider::new(vec![Completion {
2116 content: "done".into(),
2117 tool_calls: vec![],
2118 finish_reason: Some("stop".into()),
2119 usage: None,
2120 }]));
2121 let mut agent = Agent::builder().llm(llm).build().unwrap();
2122 let out = agent.run("hi").await.unwrap();
2123 assert_eq!(out.final_message.as_deref(), Some("done"));
2124 assert_eq!(out.steps, 1);
2125 assert_eq!(out.finish, FinishReason::NoMoreToolCalls);
2126 }
2127 #[test]
2130 fn planning_mode_default_is_immediate() {
2131 assert_eq!(PlanningMode::default(), PlanningMode::Immediate);
2132 }
2133
2134 #[test]
2135 fn planning_mode_variants_are_distinct() {
2136 assert_ne!(PlanningMode::Immediate, PlanningMode::PlanFirst);
2137 }
2138
2139 #[tokio::test]
2140 async fn builder_with_planfirst_succeeds() {
2141 let llm = Arc::new(MockProvider::new(vec![Completion {
2142 content: "done".into(),
2143 tool_calls: vec![],
2144 finish_reason: Some("stop".into()),
2145 usage: None,
2146 }]));
2147 let agent = Agent::builder()
2148 .llm(llm)
2149 .planning_mode(PlanningMode::PlanFirst)
2150 .build()
2151 .unwrap();
2152 assert_eq!(agent.planning_mode, PlanningMode::PlanFirst);
2153 assert!(agent.plan_buffer.is_none());
2154 }
2155
2156 #[tokio::test]
2157 async fn immediate_mode_runs_normally() {
2158 let llm = Arc::new(MockProvider::new(vec![Completion {
2159 content: "done".into(),
2160 tool_calls: vec![],
2161 finish_reason: Some("stop".into()),
2162 usage: None,
2163 }]));
2164 let mut agent = Agent::builder().llm(llm).build().unwrap();
2165 let outcome = agent.run("test").await.unwrap();
2166 assert_eq!(outcome.finish, FinishReason::NoMoreToolCalls);
2167 }
2168
2169 #[test]
2170 fn plan_proposed_can_be_constructed() {
2171 let event = StepEvent::PlanProposed {
2172 plan_text: "Step 1: read file, Step 2: edit file".into(),
2173 tool_calls: vec![ToolCall {
2174 id: "call_1".into(),
2175 name: "read_file".into(),
2176 arguments: json!({"path": "test.txt"}),
2177 }],
2178 };
2179 match &event {
2180 StepEvent::PlanProposed {
2181 plan_text,
2182 tool_calls,
2183 } => {
2184 assert!(plan_text.contains("read file"));
2185 assert_eq!(tool_calls.len(), 1);
2186 }
2187 _ => panic!("wrong variant"),
2188 }
2189 }
2190
2191 #[test]
2192 fn plan_confirmed_can_be_constructed() {
2193 let event = StepEvent::PlanConfirmed;
2194 assert!(matches!(event, StepEvent::PlanConfirmed));
2195 }
2196
2197 #[test]
2198 fn plan_rejected_can_be_constructed() {
2199 let event = StepEvent::PlanRejected {
2200 reason: "too many steps".into(),
2201 };
2202 match &event {
2203 StepEvent::PlanRejected { reason } => {
2204 assert_eq!(reason, "too many steps");
2205 }
2206 _ => panic!("wrong variant"),
2207 }
2208 }
2209
2210 #[test]
2211 fn confirm_plan_does_not_panic() {
2212 let mut agent = Agent {
2213 llm: Arc::new(MockProvider::new(vec![])),
2214 tools: ToolRegistry::local(),
2215 transcript: vec![],
2216 max_steps: 32,
2217 max_transcript_chars: None,
2218 events: None,
2219 streaming: false,
2220 total_llm_latency_ms: 0,
2221 compactor: None,
2222 permission_hook: None,
2223 hooks: HookRegistry::new(),
2224 planning_mode: PlanningMode::Immediate,
2225 plan_buffer: Some(vec![]),
2226 plan_confirmed: false,
2227 };
2228 agent.confirm_plan();
2229 assert!(agent.plan_confirmed);
2230 }
2231
2232 #[test]
2233 fn reject_plan_does_not_panic() {
2234 let mut agent = Agent {
2235 llm: Arc::new(MockProvider::new(vec![])),
2236 tools: ToolRegistry::local(),
2237 transcript: vec![],
2238 max_steps: 32,
2239 max_transcript_chars: None,
2240 events: None,
2241 streaming: false,
2242 total_llm_latency_ms: 0,
2243 compactor: None,
2244 permission_hook: None,
2245 hooks: HookRegistry::new(),
2246 planning_mode: PlanningMode::Immediate,
2247 plan_buffer: Some(vec![]),
2248 plan_confirmed: false,
2249 };
2250 agent.reject_plan("bad plan");
2251 assert!(agent.plan_buffer.is_none());
2252 assert!(!agent.plan_confirmed);
2253 }
2254}
2255#[cfg(test)]
2260mod tracing_tests {
2261 use crate::llm::Completion;
2262 use crate::llm::MockProvider;
2263 use crate::Agent;
2264 use std::sync::Arc;
2265 use tracing_test::traced_test;
2266
2267 #[traced_test]
2268 #[tokio::test]
2269 async fn agent_run_creates_span() {
2270 let llm = Arc::new(MockProvider::new(vec![Completion {
2271 content: "done".into(),
2272 tool_calls: vec![],
2273 finish_reason: Some("stop".into()),
2274 usage: None,
2275 }]));
2276 let mut agent = Agent::builder().llm(llm).build().unwrap();
2277 agent.run("test goal").await.unwrap();
2278
2279 assert!(logs_contain("run:"));
2281 }
2282
2283 #[traced_test]
2284 #[tokio::test]
2285 async fn agent_step_spans_nested_under_run() {
2286 let llm = Arc::new(MockProvider::new(vec![Completion {
2287 content: "step 1".into(),
2288 tool_calls: vec![],
2289 finish_reason: Some("stop".into()),
2290 usage: None,
2291 }]));
2292 let mut agent = Agent::builder().llm(llm).build().unwrap();
2293 agent.run("test").await.unwrap();
2294
2295 assert!(logs_contain("run:"));
2297 assert!(logs_contain("step="));
2298 }
2299}
2300
2301#[cfg(test)]
2307mod parallel_tests {
2308 use super::*;
2309 use crate::llm::{Completion, MockProvider, ToolCall};
2310 use crate::tools::Tool;
2311 use crate::ToolSpec;
2312 use async_trait::async_trait;
2313 use serde_json::{json, Value};
2314 use std::sync::atomic::{AtomicUsize, Ordering};
2315 use std::sync::Arc;
2316 use std::time::Duration;
2317
2318 struct SlowReadOnly {
2320 counter: Arc<AtomicUsize>,
2321 delay_ms: u64,
2322 }
2323
2324 #[async_trait]
2325 impl Tool for SlowReadOnly {
2326 fn spec(&self) -> ToolSpec {
2327 ToolSpec {
2328 name: "slow_read".into(),
2329 description: "a slow read-only tool".into(),
2330 parameters: json!({"type": "object"}),
2331 }
2332 }
2333 fn is_readonly(&self) -> bool {
2334 true
2335 }
2336 async fn execute(&self, _args: Value) -> Result<String> {
2337 let order = self.counter.fetch_add(1, Ordering::SeqCst);
2338 tokio::time::sleep(Duration::from_millis(self.delay_ms)).await;
2339 Ok(format!("read-{}", order))
2340 }
2341 }
2342
2343 struct TrackingWrite {
2345 counter: Arc<AtomicUsize>,
2346 }
2347
2348 #[async_trait]
2349 impl Tool for TrackingWrite {
2350 fn spec(&self) -> ToolSpec {
2351 ToolSpec {
2352 name: "track_write".into(),
2353 description: "a tracked write tool".into(),
2354 parameters: json!({"type": "object"}),
2355 }
2356 }
2357 async fn execute(&self, _args: Value) -> Result<String> {
2358 let order = self.counter.fetch_add(1, Ordering::SeqCst);
2359 Ok(format!("write-{}", order))
2360 }
2361 }
2362
2363 #[tokio::test]
2364 async fn read_only_tools_execute_in_parallel() {
2365 let counter = Arc::new(AtomicUsize::new(0));
2368 let tool1 = Arc::new(SlowReadOnly {
2369 counter: counter.clone(),
2370 delay_ms: 100,
2371 });
2372 let tool2 = Arc::new(SlowReadOnly {
2373 counter: counter.clone(),
2374 delay_ms: 100,
2375 });
2376
2377 let tools = ToolRegistry::local().register(tool1).register(tool2);
2378
2379 let llm = Arc::new(MockProvider::new(vec![
2380 Completion {
2381 content: "reading...".into(),
2382 tool_calls: vec![
2383 ToolCall {
2384 id: "c1".into(),
2385 name: "slow_read".into(),
2386 arguments: json!({}),
2387 },
2388 ToolCall {
2389 id: "c2".into(),
2390 name: "slow_read".into(),
2391 arguments: json!({}),
2392 },
2393 ],
2394 finish_reason: Some("tool_calls".into()),
2395 usage: None,
2396 },
2397 Completion {
2398 content: "done".into(),
2399 tool_calls: vec![],
2400 finish_reason: Some("stop".into()),
2401 usage: None,
2402 },
2403 ]));
2404
2405 let start = std::time::Instant::now();
2406 let mut agent = Agent::builder()
2407 .llm(llm)
2408 .tools(tools)
2409 .max_steps(5)
2410 .build()
2411 .unwrap();
2412 let out = agent.run("read in parallel").await.unwrap();
2413 let elapsed = start.elapsed();
2414
2415 assert!(
2417 elapsed.as_millis() < 150,
2418 "parallel execution took {}ms, expected <150ms",
2419 elapsed.as_millis()
2420 );
2421 assert_eq!(out.finish, FinishReason::NoMoreToolCalls);
2422
2423 let tool_msgs: Vec<&Message> = out
2425 .transcript
2426 .iter()
2427 .filter(|m| m.role == crate::message::Role::Tool)
2428 .collect();
2429 assert_eq!(tool_msgs.len(), 2);
2430 }
2431
2432 #[tokio::test]
2433 async fn write_tools_execute_sequentially() {
2434 let counter = Arc::new(AtomicUsize::new(0));
2437 let tool1 = Arc::new(TrackingWrite {
2438 counter: counter.clone(),
2439 });
2440 let tool2 = Arc::new(TrackingWrite {
2441 counter: counter.clone(),
2442 });
2443
2444 let tools = ToolRegistry::local().register(tool1).register(tool2);
2445
2446 let llm = Arc::new(MockProvider::new(vec![
2447 Completion {
2448 content: "writing...".into(),
2449 tool_calls: vec![
2450 ToolCall {
2451 id: "c1".into(),
2452 name: "track_write".into(),
2453 arguments: json!({}),
2454 },
2455 ToolCall {
2456 id: "c2".into(),
2457 name: "track_write".into(),
2458 arguments: json!({}),
2459 },
2460 ],
2461 finish_reason: Some("tool_calls".into()),
2462 usage: None,
2463 },
2464 Completion {
2465 content: "done".into(),
2466 tool_calls: vec![],
2467 finish_reason: Some("stop".into()),
2468 usage: None,
2469 },
2470 ]));
2471
2472 let mut agent = Agent::builder()
2473 .llm(llm)
2474 .tools(tools)
2475 .max_steps(5)
2476 .build()
2477 .unwrap();
2478 let out = agent.run("write sequentially").await.unwrap();
2479
2480 let tool_msgs: Vec<&Message> = out
2482 .transcript
2483 .iter()
2484 .filter(|m| m.role == crate::message::Role::Tool)
2485 .collect();
2486 assert_eq!(tool_msgs.len(), 2);
2487 assert_eq!(out.finish, FinishReason::NoMoreToolCalls);
2488 }
2489
2490 #[tokio::test]
2491 async fn mixed_read_write_preserves_order() {
2492 let counter = Arc::new(AtomicUsize::new(0));
2496 let read_tool = Arc::new(SlowReadOnly {
2497 counter: counter.clone(),
2498 delay_ms: 10,
2499 });
2500 let write_tool = Arc::new(TrackingWrite {
2501 counter: counter.clone(),
2502 });
2503
2504 let tools = ToolRegistry::local()
2505 .register(read_tool)
2507 .register(write_tool);
2508
2509 let llm = Arc::new(MockProvider::new(vec![
2510 Completion {
2511 content: "mixed...".into(),
2512 tool_calls: vec![
2513 ToolCall {
2514 id: "c1".into(),
2515 name: "slow_read".into(),
2516 arguments: json!({}),
2517 },
2518 ToolCall {
2519 id: "c2".into(),
2520 name: "track_write".into(),
2521 arguments: json!({}),
2522 },
2523 ToolCall {
2524 id: "c3".into(),
2525 name: "slow_read".into(),
2526 arguments: json!({}),
2527 },
2528 ],
2529 finish_reason: Some("tool_calls".into()),
2530 usage: None,
2531 },
2532 Completion {
2533 content: "done".into(),
2534 tool_calls: vec![],
2535 finish_reason: Some("stop".into()),
2536 usage: None,
2537 },
2538 ]));
2539
2540 let mut agent = Agent::builder()
2541 .llm(llm)
2542 .tools(tools)
2543 .max_steps(5)
2544 .build()
2545 .unwrap();
2546 let out = agent.run("mixed").await.unwrap();
2547
2548 let tool_msgs: Vec<&Message> = out
2550 .transcript
2551 .iter()
2552 .filter(|m| m.role == crate::message::Role::Tool)
2553 .collect();
2554 assert_eq!(tool_msgs.len(), 3);
2555 assert_eq!(out.finish, FinishReason::NoMoreToolCalls);
2556 }
2557
2558 #[tokio::test]
2559 async fn parallel_read_only_with_unknown_tool() {
2560 let counter = Arc::new(AtomicUsize::new(0));
2563 let read_tool = Arc::new(SlowReadOnly {
2564 counter: counter.clone(),
2565 delay_ms: 10,
2566 });
2567
2568 let tools = ToolRegistry::local().register(read_tool);
2569
2570 let llm = Arc::new(MockProvider::new(vec![
2571 Completion {
2572 content: "mixed...".into(),
2573 tool_calls: vec![
2574 ToolCall {
2575 id: "c1".into(),
2576 name: "slow_read".into(),
2577 arguments: json!({}),
2578 },
2579 ToolCall {
2580 id: "c2".into(),
2581 name: "unknown_tool".into(),
2582 arguments: json!({}),
2583 },
2584 ],
2585 finish_reason: Some("tool_calls".into()),
2586 usage: None,
2587 },
2588 Completion {
2589 content: "done".into(),
2590 tool_calls: vec![],
2591 finish_reason: Some("stop".into()),
2592 usage: None,
2593 },
2594 ]));
2595
2596 let mut agent = Agent::builder()
2597 .llm(llm)
2598 .tools(tools)
2599 .max_steps(5)
2600 .build()
2601 .unwrap();
2602 let out = agent.run("mixed with unknown").await.unwrap();
2603
2604 let tool_msgs: Vec<&Message> = out
2606 .transcript
2607 .iter()
2608 .filter(|m| m.role == crate::message::Role::Tool)
2609 .collect();
2610 assert_eq!(tool_msgs.len(), 2);
2611 assert!(tool_msgs[1].content.contains("ERROR"));
2613 assert_eq!(out.finish, FinishReason::NoMoreToolCalls);
2614 }
2615
2616 #[tokio::test]
2617 async fn mixed_read_write_executes_sequentially() {
2618 let counter = Arc::new(AtomicUsize::new(0));
2622 let read_tool = Arc::new(SlowReadOnly {
2623 counter: counter.clone(),
2624 delay_ms: 100,
2625 });
2626 let write_tool = Arc::new(TrackingWrite {
2627 counter: counter.clone(),
2628 });
2629
2630 let tools = ToolRegistry::local()
2631 .register(read_tool)
2632 .register(write_tool);
2633
2634 let llm = Arc::new(MockProvider::new(vec![
2635 Completion {
2636 content: "mixed rw...".into(),
2637 tool_calls: vec![
2638 ToolCall {
2639 id: "c1".into(),
2640 name: "slow_read".into(),
2641 arguments: json!({}),
2642 },
2643 ToolCall {
2644 id: "c2".into(),
2645 name: "track_write".into(),
2646 arguments: json!({}),
2647 },
2648 ToolCall {
2649 id: "c3".into(),
2650 name: "slow_read".into(),
2651 arguments: json!({}),
2652 },
2653 ],
2654 finish_reason: Some("tool_calls".into()),
2655 usage: None,
2656 },
2657 Completion {
2658 content: "done".into(),
2659 tool_calls: vec![],
2660 finish_reason: Some("stop".into()),
2661 usage: None,
2662 },
2663 ]));
2664
2665 let start = std::time::Instant::now();
2666 let mut agent = Agent::builder()
2667 .llm(llm)
2668 .tools(tools)
2669 .max_steps(5)
2670 .build()
2671 .unwrap();
2672 let out = agent.run("mixed read write").await.unwrap();
2673 let elapsed = start.elapsed();
2674
2675 assert!(
2677 elapsed.as_millis() >= 200,
2678 "mixed read/write took {}ms, expected >=200ms (write breaks parallel batching)",
2679 elapsed.as_millis()
2680 );
2681
2682 let tool_msgs: Vec<&Message> = out
2683 .transcript
2684 .iter()
2685 .filter(|m| m.role == crate::message::Role::Tool)
2686 .collect();
2687 assert_eq!(tool_msgs.len(), 3);
2688 assert_eq!(out.finish, FinishReason::NoMoreToolCalls);
2689 }
2690
2691 #[tokio::test]
2692 async fn single_tool_call_unaffected_by_parallel_mode() {
2693 let counter = Arc::new(AtomicUsize::new(0));
2696 let read_tool = Arc::new(SlowReadOnly {
2697 counter: counter.clone(),
2698 delay_ms: 10,
2699 });
2700
2701 let tools = ToolRegistry::local().register(read_tool);
2702
2703 let llm = Arc::new(MockProvider::new(vec![
2704 Completion {
2705 content: "single call...".into(),
2706 tool_calls: vec![ToolCall {
2707 id: "c1".into(),
2708 name: "slow_read".into(),
2709 arguments: json!({}),
2710 }],
2711 finish_reason: Some("tool_calls".into()),
2712 usage: None,
2713 },
2714 Completion {
2715 content: "done".into(),
2716 tool_calls: vec![],
2717 finish_reason: Some("stop".into()),
2718 usage: None,
2719 },
2720 ]));
2721
2722 let mut agent = Agent::builder()
2723 .llm(llm)
2724 .tools(tools)
2725 .max_steps(5)
2726 .build()
2727 .unwrap();
2728 let out = agent.run("single tool call").await.unwrap();
2729
2730 let tool_msgs: Vec<&Message> = out
2731 .transcript
2732 .iter()
2733 .filter(|m| m.role == crate::message::Role::Tool)
2734 .collect();
2735 assert_eq!(tool_msgs.len(), 1);
2736 assert!(tool_msgs[0].content.contains("read-0"));
2737 assert_eq!(out.finish, FinishReason::NoMoreToolCalls);
2738 }
2739
2740 #[tokio::test]
2741 async fn multiple_write_tools_preserve_order() {
2742 let counter = Arc::new(AtomicUsize::new(0));
2744 let write_tool = Arc::new(TrackingWrite {
2745 counter: counter.clone(),
2746 });
2747
2748 let tools = ToolRegistry::local().register(write_tool);
2749
2750 let llm = Arc::new(MockProvider::new(vec![
2751 Completion {
2752 content: "writing three...".into(),
2753 tool_calls: vec![
2754 ToolCall {
2755 id: "c1".into(),
2756 name: "track_write".into(),
2757 arguments: json!({}),
2758 },
2759 ToolCall {
2760 id: "c2".into(),
2761 name: "track_write".into(),
2762 arguments: json!({}),
2763 },
2764 ToolCall {
2765 id: "c3".into(),
2766 name: "track_write".into(),
2767 arguments: json!({}),
2768 },
2769 ],
2770 finish_reason: Some("tool_calls".into()),
2771 usage: None,
2772 },
2773 Completion {
2774 content: "done".into(),
2775 tool_calls: vec![],
2776 finish_reason: Some("stop".into()),
2777 usage: None,
2778 },
2779 ]));
2780
2781 let mut agent = Agent::builder()
2782 .llm(llm)
2783 .tools(tools)
2784 .max_steps(5)
2785 .build()
2786 .unwrap();
2787 let out = agent.run("write three times").await.unwrap();
2788
2789 let tool_msgs: Vec<&Message> = out
2790 .transcript
2791 .iter()
2792 .filter(|m| m.role == crate::message::Role::Tool)
2793 .collect();
2794 assert_eq!(tool_msgs.len(), 3);
2795 assert!(tool_msgs[0].content.contains("write-0"));
2797 assert!(tool_msgs[1].content.contains("write-1"));
2798 assert!(tool_msgs[2].content.contains("write-2"));
2799 assert_eq!(out.finish, FinishReason::NoMoreToolCalls);
2800 }
2801
2802 #[tokio::test]
2803 async fn no_tool_calls_completes_immediately() {
2804 let llm = Arc::new(MockProvider::new(vec![Completion {
2807 content: "nothing to do".into(),
2808 tool_calls: vec![],
2809 finish_reason: Some("stop".into()),
2810 usage: None,
2811 }]));
2812
2813 let mut agent = Agent::builder().llm(llm).max_steps(5).build().unwrap();
2814 let out = agent.run("no tools needed").await.unwrap();
2815
2816 assert_eq!(out.finish, FinishReason::NoMoreToolCalls);
2817 assert_eq!(out.final_message.as_deref(), Some("nothing to do"));
2818 assert_eq!(out.steps, 1);
2819 let tool_msgs: Vec<&Message> = out
2821 .transcript
2822 .iter()
2823 .filter(|m| m.role == crate::message::Role::Tool)
2824 .collect();
2825 assert_eq!(tool_msgs.len(), 0);
2826 }
2827}