Skip to main content

recursive/
agent.rs

1//! Agent loop. The whole kernel.
2//!
3//! Tiny on purpose: receive a goal, ask the model what to do, run any tool
4//! calls, feed results back, repeat until the model stops requesting tools
5//! or we hit the step budget. Everything interesting (which model, which
6//! tools, what system prompt) is injected by the caller.
7//!
8//! The loop emits `StepEvent`s through a channel so a UI/CLI/log layer can
9//! observe progress without coupling to the agent's internals.
10
11use 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
24/// Threshold for consecutive identical failing tool calls before declaring stuck.
25const STUCK_THRESHOLD: usize = 3;
26
27/// Placeholder text used when trimming old tool results to fit the transcript budget.
28/// Decision returned by a permission hook to allow, deny, or transform a tool call.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum PermissionDecision {
32    /// Let the tool execute with the original arguments.
33    Allow,
34    /// Block execution and return the reason as a tool error to the model.
35    Deny(String),
36    /// Replace the arguments before execution.
37    Transform(serde_json::Value),
38}
39
40/// Signature for a permission hook: `Fn(&tool_name, &arguments) -> PermissionDecision`.
41///
42/// The hook is invoked just before each tool execution. It can:
43/// - `Allow` the call unchanged,
44/// - `Deny` it with a reason (fed back as a tool error),
45/// - `Transform` the arguments before execution.
46///
47/// Hooks must be `Send + Sync` because the agent loop is `Send`.
48pub 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/// Controls whether the agent executes tools immediately or presents a plan first.
53#[derive(Debug, Clone, PartialEq, Default)]
54pub enum PlanningMode {
55    /// Execute tool calls immediately (current behavior).
56    #[default]
57    Immediate,
58    /// Buffer tool calls and emit a plan for confirmation before executing.
59    PlanFirst,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(tag = "kind", rename_all = "snake_case")]
64#[non_exhaustive]
65pub enum StepEvent {
66    /// Model generated text without tool calls.
67    ///
68    /// Emitted when the LLM produces a response. This is typically the final
69    /// answer or intermediate reasoning. The `text` field contains the complete
70    /// model response, and `step` indicates which iteration this occurred on.
71    AssistantText { text: String, step: usize },
72    /// Model requested to execute a tool.
73    ///
74    /// Emitted when the LLM calls a tool. Contains the tool name, ID, and arguments
75    /// that will be executed. The `call` is dispatched to the registry after this event.
76    /// Tool errors are reported via `ToolResult` events, not `ToolCall` failures.
77    ToolCall { call: ToolCall, step: usize },
78    /// Time taken for the LLM request (excluding tool execution).
79    ///
80    /// Emitted after the model responds. Useful for measuring provider latency
81    /// and diagnosing slow responses. `llm_ms` is in milliseconds.
82    Latency { step: usize, llm_ms: u64 },
83    /// Result of executing a tool call.
84    ///
85    /// Emitted after a tool finishes executing. Contains the tool name, call ID,
86    /// the output string (or error message), and the step number. This result
87    /// is added to the transcript and sent back to the model for the next iteration.
88    /// In case of tool error, the output will be the error message prefixed with "ERROR: ".
89    ToolResult {
90        id: String,
91        name: String,
92        output: String,
93        step: usize,
94    },
95    /// Token usage statistics from the LLM provider.
96    ///
97    /// Emitted if the provider returns usage information (input tokens, output tokens).
98    /// Accumulated across all steps for total usage. Useful for cost tracking and
99    /// monitoring resource consumption.
100    Usage { usage: TokenUsage, step: usize },
101    /// Partial token from streaming response (if streaming enabled).
102    ///
103    /// Only emitted if streaming is enabled. Contains a single token or partial chunk
104    /// of the model's response text. Allows UI layers to display real-time incremental
105    /// updates to the model's output without waiting for the entire response.
106    PartialToken { text: String, step: usize },
107    /// Transcript was compacted to fit size constraints.
108    ///
109    /// Emitted when the transcript exceeds the max size and is automatically compacted.
110    /// The `removed` field shows how many messages were summarized, `kept` shows how many
111    /// remain, and `summary_chars` shows the size of the compaction summary added.
112    /// This allows UI layers to notify users that older context has been summarized.
113    /// Compaction only occurs if a `Compactor` is configured on the Agent.
114    Compacted {
115        removed: usize,
116        kept: usize,
117        summary_chars: usize,
118        step: usize,
119    },
120    /// Agent run completed.
121    ///
122    /// Emitted as the final event. Indicates the run is done and why it stopped.
123    /// `reason` explains the termination (no more tool calls, budget exceeded, stuck
124    /// detection, etc.). `steps` shows how many iterations were executed.
125    /// After this event, no more events will be emitted for this run.
126    Finished { reason: FinishReason, steps: usize },
127    /// Agent has produced a plan and is waiting for confirmation.
128    PlanProposed {
129        /// Human-readable plan description
130        plan_text: String,
131        /// The buffered tool calls
132        tool_calls: Vec<ToolCall>,
133    },
134    /// Plan was confirmed, execution will proceed.
135    PlanConfirmed,
136    /// Plan was rejected with a reason.
137    PlanRejected { reason: String },
138}
139
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(tag = "kind", rename_all = "snake_case")]
142/// Why the agent's run terminated.
143///
144/// # Variants
145///
146/// - `NoMoreToolCalls`: Model produced a response without tool calls (natural completion).
147/// - `BudgetExceeded`: Ran out of steps (hit `max_steps`). Agent likely unfinished.
148/// - `ProviderStop(reason)`: LLM provider stopped unexpectedly. `reason` may be "length"
149///   (truncated by token limit), "stop"/"end_turn", or a provider-specific code.
150/// - `Stuck`: Agent got stuck calling the same tool repeatedly with the same arguments.
151///   `repeated_call` is the tool name, `repeats` is how many times before stopping.
152/// - `TranscriptLimit`: Transcript size hit `max_transcript_chars` hard limit before
153///   compaction could reduce it further. Agent cannot continue. `chars` is final size,
154///   `limit` is the configured maximum.
155#[non_exhaustive]
156pub enum FinishReason {
157    /// Model generated final response without requesting more tools.
158    NoMoreToolCalls,
159    /// Agent exceeded the maximum number of steps allowed.
160    BudgetExceeded,
161    /// LLM provider stopped with a specific reason or status code.
162    ProviderStop(String),
163    /// Agent detected repeated identical tool calls (stuck loop).
164    Stuck {
165        repeated_call: String,
166        repeats: usize,
167    },
168    /// Transcript size exceeded hard limit and cannot be reduced further.
169    TranscriptLimit { chars: usize, limit: usize },
170    /// Agent proposed a plan (PlanFirst mode) and is waiting for confirmation.
171    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
184/// The ReAct loop: ask LLM, execute tools, repeat.
185///
186/// `Agent` orchestrates the interaction between an LLM provider, a tool registry,
187/// and a transcript. It drives a loop until the model stops calling tools or the
188/// budget (steps or transcript size) is exceeded.
189///
190/// # Usage
191///
192/// ```ignore
193/// let agent = Agent::builder()
194///     .llm(Arc::new(provider))
195///     .tools(registry)
196///     .system_prompt("You are a helpful assistant")
197///     .max_steps(32)
198///     .build()?;
199///
200/// let outcome = agent.run("Help me debug this file").await?;
201/// ```
202///
203/// # Events
204///
205/// The agent emits `StepEvent`s through an optional channel, allowing UI layers
206/// to observe progress without coupling to internals. See `StepEvent` for variants.
207///
208/// # Isolation
209///
210/// Each `Agent::run()` call is independent. The transcript is maintained across
211/// steps within a single run but cleared between runs. Use `seed_transcript()` to
212/// carry state between runs if needed.
213pub 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    /// Confirm a proposed plan, allowing execution to proceed.
236    pub fn confirm_plan(&mut self) {
237        self.plan_confirmed = true;
238        self.emit(StepEvent::PlanConfirmed);
239    }
240
241    /// Reject a proposed plan with a reason.
242    pub fn reject_plan(&mut self, reason: &str) {
243        // Feed the rejection back to the model as tool errors so it can revise.
244        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    /// Drive the loop until the model stops calling tools, or the budget is exhausted.
263    /// Execute a set of tool calls, returning (id, name, output, args) for each.
264    /// Read-only calls are batched and executed in parallel; write calls run
265    /// sequentially to preserve ordering guarantees.
266    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        // First pass: handle denied/skipped calls immediately, collect the rest.
274        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            // Apply lifecycle hooks before executing the tool
302            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        // Second pass: execute read-only calls in parallel, write calls sequentially.
338        let mut i = 0;
339        while i < pending.len() {
340            if self.tools.is_readonly(&pending[i].name) {
341                // Batch consecutive read-only calls
342                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        // Tracking for anti-stuck heuristic
426        let mut last_call_key: Option<(String, String)> = None;
427        let mut consecutive_errors: usize = 0;
428
429        // Accumulate token usage across all LLM calls
430        let mut total_usage = TokenUsage::default();
431        // Reset latency accumulator at start of run
432        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            // Check transcript size limit before making the next LLM call.
438            // First try trimming old tool results; fall back to hard stop.
439            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            // Optionally compact the transcript if it exceeds the threshold.
460            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 a plan was confirmed, execute the buffered calls without
466            // calling the LLM again.
467            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                // Create a channel for streaming deltas
488                let (delta_tx, mut delta_rx) = mpsc::unbounded_channel::<String>();
489                let stream_tx: Option<StreamSender> = Some(delta_tx);
490                // Spawn a task to forward deltas as PartialToken events
491                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            // Accumulate usage from this completion
508            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                // Treat a length-limit truncation as a real failure: the model
523                // didn't decide to stop, the server cut it off, so any "result"
524                // here is partial. Surfacing this as an error lets wrappers
525                // (CLI, self-improve scripts, etc.) react instead of silently
526                // believing the run succeeded.
527                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            // Phase 1: emit ToolCall events for all calls
564            for call in &completion.tool_calls {
565                self.emit(StepEvent::ToolCall {
566                    call: call.clone(),
567                    step,
568                });
569            }
570
571            // Planning mode: if PlanFirst and we haven't buffered these calls yet,
572            // buffer them and wait for external confirmation/rejection.
573            if self.planning_mode == PlanningMode::PlanFirst && self.plan_buffer.is_none() {
574                self.plan_buffer = Some(completion.tool_calls.clone());
575
576                // Build a human-readable plan description from the tool calls
577                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 PlanPending so the caller can decide via confirm_plan()/reject_plan()
597                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            // Phase 2: separate read-only and write calls, then execute.
608            // Read-only calls run in parallel; write calls run sequentially
609            // to preserve ordering guarantees.
610            let results = self.execute_tool_calls(&completion.tool_calls, step).await;
611
612            // Phase 3: emit results and push to transcript (preserving original order)
613            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                // Anti-stuck heuristic: track identical failing calls
622                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                // Check if stuck threshold reached
641                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    /// Try to trim old tool results to bring the transcript under the character limit.
687    ///
688    /// Walks the transcript from index 1 (skipping the system prompt at 0) forward,
689    /// and for any `Role::Tool` message whose content is longer than 200 characters,
690    /// replaces the content with [`TRIM_PLACEHOLDER`]. Stops as soon as the total
691    /// character count is below `limit`. Emits an `AssistantText` event (reusing the
692    /// existing variant) to surface that trimming happened.
693    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        // Need at least keep_recent_n + 2 messages to have something to compact.
705        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        // Replace the older portion with the summary message.
716        // Keep the last keep_recent_n messages verbatim.
717        let keep = compactor.keep_recent_n;
718        let mut split = self.transcript.len().saturating_sub(keep);
719
720        // Invariant: every `Role::Tool` message must be immediately preceded by
721        // an `Role::Assistant` message containing the matching `tool_calls`.
722        // OpenAI/DeepSeek/Anthropic all enforce this on the request side. If
723        // the kept window starts at a `Role::Tool` message, the parent
724        // assistant has just been drained — the next LLM request fails with
725        // HTTP 400 ("Messages with role 'tool' must be a response to a
726        // preceding message with 'tool_calls'"). Retreat the split until the
727        // window starts at a non-Tool message.
728        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        // Drain the older messages and insert the summary at the front.
736        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        // Walk from index 1 (skip system prompt at 0) forward, trimming old tool results.
764        // Track the running total ourselves to avoid re-borrowing self.transcript.
765        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                // Adjust the running total: we removed old_len and added placeholder_len.
771                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/// Builder for configuring and creating an agent.
802///
803/// Use `Agent::builder()` to start building. All methods are optional except `llm()`.
804///
805/// # Example
806///
807/// ```ignore
808/// let agent = Agent::builder()
809///     .llm(Arc::new(provider))
810///     .tools(registry)
811///     .system_prompt("You are a helpful assistant")
812///     .max_steps(50)
813///     .build()?;
814/// ```
815#[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    /// Set the LLM provider (required).
833    ///
834    /// The provider handles all requests to the model. Must be provided before
835    /// calling `build()`, otherwise `build()` will return an error.
836    pub fn llm(mut self, llm: Arc<dyn LlmProvider>) -> Self {
837        self.llm = Some(llm);
838        self
839    }
840    /// Set the tool registry (optional, defaults to empty registry).
841    ///
842    /// Tools are available to the model for execution during the run. If not set,
843    /// the agent will only generate text. The registry is shared via Arc, so tool
844    /// implementations must be thread-safe.
845    pub fn tools(mut self, tools: ToolRegistry) -> Self {
846        self.tools = tools;
847        self
848    }
849    /// Set the system prompt (optional).
850    ///
851    /// Prepended to the transcript before the first goal. Typically describes
852    /// the agent's role (e.g., "You are a code assistant"). If not set, no
853    /// system message is added.
854    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
855        self.system = Some(prompt.into());
856        self
857    }
858    /// Set the maximum number of agent iterations (optional, defaults to 32).
859    ///
860    /// Each iteration is one LLM call + tool execution round. Higher values
861    /// allow more complex reasoning but increase cost and latency. If exceeded,
862    /// the run finishes with `FinishReason::BudgetExceeded`.
863    pub fn max_steps(mut self, n: usize) -> Self {
864        self.max_steps = Some(n);
865        self
866    }
867    /// Set the maximum transcript character size (optional).
868    ///
869    /// Prevents the transcript from growing unbounded. When this limit is approached,
870    /// `Compactor` (if configured) will summarize and trim old messages. If the
871    /// transcript cannot fit within the limit, the run stops with
872    /// `FinishReason::TranscriptLimit`.
873    ///
874    /// # Note
875    ///
876    /// This is a hard limit; if compaction cannot reduce the size enough, the
877    /// run will terminate rather than add the next message.
878    pub fn max_transcript_chars(mut self, n: usize) -> Self {
879        self.max_transcript_chars = Some(n);
880        self
881    }
882    /// Attach an event channel to observe agent progress (optional).
883    ///
884    /// Events are sent through this channel as the agent runs. Non-blocking:
885    /// if the receiver is dropped or the channel is full, sends are silently
886    /// ignored. Allows UI layers to display real-time progress without coupling
887    /// to the agent's internals.
888    pub fn events(mut self, tx: mpsc::UnboundedSender<StepEvent>) -> Self {
889        self.events = Some(tx);
890        self
891    }
892    /// Seed the agent with a pre-existing transcript. Seeded messages are
893    /// placed in the transcript *after* the system prompt but *before* the
894    /// new goal. No `StepEvent`s are emitted for seeded messages; only events
895    /// produced during the new run are streamed.
896    pub fn seed_transcript(mut self, messages: Vec<Message>) -> Self {
897        self.seed = messages;
898        self
899    }
900    /// Enable token-by-token streaming from the provider (optional, defaults to false).
901    ///
902    /// If enabled, the agent creates a channel and asks the provider to emit
903    /// `PartialToken` events as they arrive. Requires provider support; some
904    /// providers ignore this and emit the complete response at once.
905    pub fn streaming(mut self, enabled: bool) -> Self {
906        self.streaming = enabled;
907        self
908    }
909    /// Attach a compactor to automatically trim old transcript content (optional).
910    ///
911    /// When the transcript reaches `max_transcript_chars`, the compactor
912    /// summarizes and removes old messages. Only active if both `max_transcript_chars`
913    /// and `compactor` are set. Without a compactor, `TranscriptLimit` errors occur.
914    pub fn compactor(mut self, compactor: Compactor) -> Self {
915        self.compactor = Some(compactor);
916        self
917    }
918    /// Attach a permission hook that is invoked before each tool execution (optional).
919    ///
920    /// The hook can allow, deny, or transform tool arguments. If denied, the tool
921    /// is not executed and the reason is returned as a tool error. If transformed,
922    /// the new arguments are passed to the tool instead.
923    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    /// Attach a pre-built permission hook (e.g. cloned from a parent agent).
932    ///
933    /// This is useful when inheriting a permission hook from a parent agent
934    /// into a sub-agent. Unlike `permission_hook()`, this accepts an
935    /// `Option<PermissionHook>` directly, avoiding the need to re-wrap.
936    pub fn permission_hook_opt(mut self, hook: Option<PermissionHook>) -> Self {
937        self.permission_hook = hook;
938        self
939    }
940    /// Register a lifecycle hook (optional).
941    ///
942    /// Hooks are invoked at well-defined lifecycle points during the agent run.
943    /// Multiple hooks are supported; they fire in registration order.
944    pub fn hook(mut self, hook: Arc<dyn Hook>) -> Self {
945        self.hooks.register(hook);
946        self
947    }
948    /// Set the planning mode (optional, defaults to Immediate).
949    ///
950    /// When set to `PlanFirst`, the agent will buffer tool calls and emit a
951    /// `PlanProposed` event instead of executing them immediately. The caller
952    /// must then call `confirm_plan()` or `reject_plan()` to proceed.
953    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        // transcript should be: user, assistant(tool_call), tool, assistant("5")
1062        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        // Transcript MUST be populated even on budget-exceeded — this is
1092        // what unlocks auto-resume in self-improve.sh.
1093        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        // tool message must contain the error so the model can recover
1119        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        // MockProvider scripted to call a non-existent tool 4 times
1182        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        // Should be stuck after 3 consecutive errors
1200        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        // Provider says finish_reason = "length": the response was cut off by
1214        // the server, not a deliberate stop. The agent must treat this as
1215        // failure rather than pretend the assistant ended its turn.
1216        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        // MockProvider scripted to call same tool with different args each time
1230        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        // Set max_steps low so test terminates with budget
1246        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        // Should hit budget, not stuck (args differ each time)
1255        assert!(matches!(out.finish, FinishReason::BudgetExceeded));
1256        assert_eq!(out.steps, 3);
1257    }
1258
1259    /// Regression test for self-improve.sh auto-resume.
1260    ///
1261    /// Before the fix, `Agent::run` returned `Err(StepBudgetExceeded)` on
1262    /// budget overrun, and `run_once` propagated the `?` before the
1263    /// transcript-save block. Result: `--transcript-out` produced no file,
1264    /// and self-improve.sh's auto-resume gate `[[ -f $TRANSCRIPT_OUT ]]`
1265    /// always failed → no resume ever ran.
1266    ///
1267    /// Now `Agent::run` returns `Ok(outcome)` with `finish: BudgetExceeded`
1268    /// and the full transcript populated. The CLI (`main.rs`) is then
1269    /// expected to save the transcript first and only then bail with a
1270    /// non-zero exit code via `exit_for_finish`.
1271    ///
1272    /// This test pins the agent half of that contract: on budget overrun,
1273    /// `outcome.transcript` is non-empty AND round-trips through
1274    /// `TranscriptFile::{write_to,read_from}` cleanly.
1275    #[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        // Script many small tool calls so the transcript grows past 50 chars.
1409        // Each iteration adds: assistant "x" (1 char) + tool result "2" (1 char).
1410        // User "hi" adds 2 chars. So after N iterations: 2 + 2N chars.
1411        // To reach 50: N >= 24. Script 30 completions to be safe.
1412        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        // A massive user goal that already exceeds the limit.
1471        // Use an empty MockProvider so any actual call would panic.
1472        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        // Should have stopped at step 1 without making any LLM call.
1488        assert_eq!(out.steps, 1);
1489    }
1490
1491    #[tokio::test]
1492    async fn compaction_triggers_with_low_threshold() {
1493        // Set up an agent with a very low compaction threshold (10 chars)
1494        // and a script that produces enough messages to exceed it.
1495        // The MockProvider's first two calls return tool calls to build up
1496        // the transcript, the third call is consumed by the compactor,
1497        // and the fourth call returns "done" to finish.
1498        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            // This completion is consumed by the compactor
1520            Completion {
1521                content: "Summary: added numbers, tests pass.".into(),
1522                tool_calls: vec![],
1523                finish_reason: Some("stop".into()),
1524                usage: None,
1525            },
1526            // This completion is the agent's next step after compaction
1527            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        // Check that a Compacted event was emitted
1549        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        // The transcript should contain the summary message (system role)
1558        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        // Without setting a compactor, no compaction should happen.
1578        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        // No Compacted events should be emitted
1609        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    /// Regression: a compaction whose `keep_recent_n` window started with a
1622    /// `Role::Tool` message orphaned that tool from its parent assistant's
1623    /// `tool_calls`. Subsequent LLM requests failed with HTTP 400 (DeepSeek)
1624    /// or 422 (OpenAI). Fix retreats the split point until the kept window
1625    /// begins at a non-Tool message.
1626    ///
1627    /// Discovered during batch 15 dogfooding: g43 + g47 both rolled back
1628    /// the moment Compactor fired with the new 200 KB threshold + AGENTS.md
1629    /// + skill_index inflation.
1630    ///
1631    /// Scenario: transcript after step 1 looks like
1632    ///   [0] System (prompt)
1633    ///   [1] User (goal)
1634    ///   [2] Assistant + tool_calls("adder")
1635    ///   [3] Tool result
1636    /// With `keep_recent_n=1`, naive split = len-1 = 3 lands on the Tool
1637    /// message. Without the fix, kept window = [Tool] — orphan. With the
1638    /// fix, split retreats to 2 (the parent Assistant).
1639    #[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            // Summary returned by the compactor's call to provider.complete()
1654            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        // keep_recent_n=1 forces the naive split onto the Tool message.
1669        // min_messages check is keep_recent_n + 2 = 3, satisfied at step 2.
1670        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        // Sanity: at least one Compacted event must have fired, otherwise
1683        // the test isn't exercising the code path we care about.
1684        // (We can't introspect easily here — instead assert via transcript
1685        // shape: the first message after compaction should be a synthetic
1686        // System summary with the "[compacted:" header.)
1687        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        // The kept transcript must NOT have an orphaned Tool message at
1696        // position 1 (right after the summary system message at position 0).
1697        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        // seed (3) + new user goal + new assistant reply = 5
1800        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        // Only events from the new run; nothing fired for seeded messages.
1838        assert_eq!(kinds, vec!["latency", "text", "done"]);
1839    }
1840
1841    #[tokio::test]
1842    async fn emits_latency_event_per_llm_call() {
1843        // Run the agent through 2 steps and verify Latency events are emitted.
1844        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        // total_llm_latency_ms should exist and be u64 (compile check)
1873        let _: u64 = out.total_llm_latency_ms;
1874
1875        // Count Latency events
1876        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        // Should have one Latency event per LLM call (2 steps)
1883        assert_eq!(latency_count, 2);
1884    }
1885
1886    // --- Transcript trimming tests ---
1887
1888    #[tokio::test]
1889    async fn trims_old_tool_result_to_fit_budget() {
1890        // Build a transcript with a large tool result followed by a small
1891        // assistant message. Set max_transcript_chars just under the big
1892        // result's size so trimming is triggered.
1893        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        // Use a custom tool that returns a big result
1912        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        // Set limit so the big result (500 chars) plus user goal (2 chars)
1928        // plus assistant text (~17 chars) would exceed, but trimming the
1929        // tool result to the placeholder (~50 chars) brings it under.
1930        // User "hi" = 2, assistant "let me run a tool" = 17, tool result
1931        // placeholder = 50, assistant "done" = 4. Total ~73.
1932        // Set limit to 100: the big result alone (500) would blow it,
1933        // but after trimming we're under.
1934        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        // Should complete normally, not hit TranscriptLimit
1943        assert_eq!(out.finish, FinishReason::NoMoreToolCalls);
1944        // The tool result should have been trimmed
1945        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        // Build a transcript where even after trimming all tool results,
1959        // the non-tool messages alone exceed the budget.
1960        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        // Set a very tight limit that even the user goal alone exceeds.
1980        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    // ========================================================================
1992    // Permission hook tests
1993    // ========================================================================
1994
1995    #[tokio::test]
1996    async fn permission_hook_allow_passes_args_unchanged() {
1997        // Hook returns Allow; tool should receive the original args.
1998        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        // Hook returns Deny; tool should NOT be executed, and the model
2031        // should receive an error result.
2032        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        // The tool result should contain the denial reason
2059        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        // The model should have received the error and responded
2067        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        // Hook returns Transform with different args; tool should receive
2073        // the transformed args, not the original ones.
2074        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        // Transform args to add 1+2 instead of 100+200
2094        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        // The tool result should be 3 (1+2), not 300 (100+200)
2102        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        // Without permission_hook(), existing behavior is preserved.
2114        // This is the same as terminates_when_model_emits_no_tool_calls.
2115        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    // --- PlanningMode tests ---
2128
2129    #[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// ============================================================================
2256
2257// Tracing tests - require tracing-test
2258// ============================================================================
2259#[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        // The span should have been created (check for the span name in the log prefix)
2280        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        // Should have both run and step spans
2296        assert!(logs_contain("run:"));
2297        assert!(logs_contain("step="));
2298    }
2299}
2300
2301// --- PlanningMode tests ---
2302
2303// ============================================================================
2304// Parallel execution tests
2305// ============================================================================
2306#[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    /// A read-only tool that records its execution order and simulates latency.
2319    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    /// A write tool that records its execution order.
2344    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        // Two read-only tools with delays. If executed in parallel, total
2366        // time should be ~100ms (not ~200ms).
2367        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        // Should complete in ~100ms (parallel), not ~200ms (sequential)
2416        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        // Both results should be present in the transcript
2424        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        // Two write tools. If executed sequentially, the order counter
2435        // should increment predictably.
2436        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        // Both results should be present
2481        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        // Mix of read-only and write tools. Read-only tools should run in
2493        // parallel, write tools sequentially, but results should appear in
2494        // the original call order in the transcript.
2495        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            // Adder not needed for this test
2506            .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        // Results should be in original order: read, write, read
2549        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        // Mix of read-only and unknown tools. Unknown tools should error
2561        // but not prevent parallel execution of read-only tools.
2562        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        // Should complete with both results (one error)
2605        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        // The unknown tool should have an error message
2612        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        // A write tool between read-only tools breaks parallel batching.
2619        // Calls: [read, write, read] — the write separates the reads into
2620        // two sequential batches, so total time >= 200ms (not ~100ms).
2621        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        // read(100ms) + write(~0ms) + read(100ms) = >=200ms
2676        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        // A single read-only tool call should execute normally regardless
2694        // of parallel execution logic. Regression test.
2695        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        // 3 write tool calls must execute in strict order: 0, 1, 2.
2743        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        // Verify strict sequential order via atomic counter
2796        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        // When the provider returns a completion with empty tool_calls and
2805        // finish_reason "stop", the agent should complete with NoMoreToolCalls.
2806        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        // No tool messages in transcript
2820        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}