Skip to main content

recursive/
runner.rs

1//! Cross-turn agent wrapper.
2//!
3//! `AgentRunner` manages an `Agent` across multiple conversation turns,
4//! preserving the transcript between turns and streaming events to the
5//! caller. This is the extracted pattern from the REPL in `main.rs`,
6//! made reusable for loop mode, HTTP API sessions, and TUI.
7
8use std::sync::Arc;
9use tokio::sync::mpsc;
10use tokio::sync::Mutex;
11
12use crate::agent::{Agent, AgentOutcome, StepEvent};
13use crate::error::Result;
14use crate::tools::BackgroundJobManager;
15
16/// Manages an Agent across multiple conversation turns.
17///
18/// Preserves transcript between turns, emits events via a caller-provided
19/// channel, and tracks cumulative turn count. Each call to `turn()` runs
20/// the agent once with the given goal, then restores the transcript so the
21/// next turn continues the conversation.
22///
23/// Optionally holds a reference to a shared `BackgroundJobManager`. When
24/// `clear()` is called, any pending background jobs are also cleared.
25///
26/// # Example
27///
28/// ```ignore
29/// let mut runner = AgentRunner::new(agent);
30/// let (tx, mut rx) = mpsc::unbounded_channel();
31///
32/// // First turn
33/// let outcome = runner.turn("Hello", Some(tx.clone())).await?;
34///
35/// // Second turn — transcript from turn 1 is preserved
36/// let outcome = runner.turn("Follow up", Some(tx.clone())).await?;
37///
38/// // Start fresh
39/// runner.clear();
40/// ```
41pub struct AgentRunner {
42    agent: Agent,
43    total_turns: usize,
44    /// Optional shared background job manager. When set, `clear()` will
45    /// also cancel all tracked background jobs.
46    bg_manager: Option<Arc<Mutex<BackgroundJobManager>>>,
47}
48
49impl AgentRunner {
50    /// Create a runner from a pre-built Agent.
51    pub fn new(agent: Agent) -> Self {
52        Self {
53            agent,
54            total_turns: 0,
55            bg_manager: None,
56        }
57    }
58
59    /// Create a runner that also manages background jobs.
60    ///
61    /// When `clear()` is called, all tracked background jobs are removed
62    /// from the shared manager in addition to clearing the transcript.
63    pub fn with_bg_manager(agent: Agent, bg_manager: Arc<Mutex<BackgroundJobManager>>) -> Self {
64        Self {
65            agent,
66            total_turns: 0,
67            bg_manager: Some(bg_manager),
68        }
69    }
70
71    /// Run a single turn with the given goal.
72    ///
73    /// If `events` is `Some(sender)`, step events are streamed through the
74    /// channel in real time. Pass `None` to suppress events.
75    ///
76    /// The transcript is automatically preserved for the next turn. Returns
77    /// the outcome of this turn.
78    pub async fn turn(
79        &mut self,
80        goal: impl Into<String>,
81        events: Option<mpsc::UnboundedSender<StepEvent>>,
82    ) -> Result<AgentOutcome> {
83        self.agent.set_events(events);
84
85        let outcome = self.agent.run(goal).await?;
86
87        // Restore transcript for next turn (run() takes it via mem::take)
88        self.agent.set_transcript(outcome.transcript.clone());
89        self.agent.set_events(None);
90        self.total_turns += 1;
91
92        Ok(outcome)
93    }
94
95    /// Clear the conversation history and reset the turn counter.
96    ///
97    /// If a `BackgroundJobManager` was provided, all tracked background
98    /// jobs are also removed.
99    pub fn clear(&mut self) {
100        self.agent.set_transcript(Vec::new());
101        self.total_turns = 0;
102        if let Some(ref mgr) = self.bg_manager {
103            // Best-effort: if the lock is poisoned, we still clear the agent state.
104            if let Ok(mut mgr) = mgr.try_lock() {
105                mgr.clear();
106            }
107        }
108    }
109
110    /// Number of turns completed so far.
111    pub fn turns(&self) -> usize {
112        self.total_turns
113    }
114
115    /// Access the underlying agent (e.g., to call `confirm_plan`).
116    pub fn agent(&self) -> &Agent {
117        &self.agent
118    }
119
120    /// Mutable access to the underlying agent.
121    pub fn agent_mut(&mut self) -> &mut Agent {
122        &mut self.agent
123    }
124
125    /// Run a loop: execute turns until the agent stops scheduling wakeups.
126    ///
127    /// Between turns, sleeps for the requested `delay`. If the agent doesn't
128    /// call `schedule_wakeup` during a turn, the loop ends.
129    ///
130    /// The `wakeup_slot` should be the same slot registered with the
131    /// `ScheduleWakeup` tool in the agent's tool registry.
132    pub async fn run_loop(
133        &mut self,
134        initial_goal: impl Into<String>,
135        wakeup_slot: &crate::tools::WakeupSlot,
136        events: Option<mpsc::UnboundedSender<StepEvent>>,
137    ) -> Result<Vec<AgentOutcome>> {
138        let mut outcomes = Vec::new();
139        let mut next_goal = initial_goal.into();
140
141        loop {
142            let outcome = self.turn(&next_goal, events.clone()).await?;
143            outcomes.push(outcome);
144
145            // Check if the agent scheduled a wakeup
146            let wakeup = wakeup_slot.lock().ok().and_then(|mut slot| slot.take());
147
148            match wakeup {
149                Some(req) => {
150                    tokio::time::sleep(req.delay).await;
151                    next_goal = req.prompt;
152                }
153                None => break, // No wakeup = loop ends
154            }
155        }
156        Ok(outcomes)
157    }
158
159    /// Run a loop with background job awareness.
160    ///
161    /// After each turn, checks both:
162    /// 1. The `WakeupSlot` for an explicit wakeup request (from `schedule_wakeup` tool)
163    /// 2. The `BackgroundJobManager` for completed jobs
164    ///
165    /// If a background job completed, its output is injected as the next turn's
166    /// goal. If a wakeup was scheduled, the runner sleeps for the requested
167    /// delay then continues. If neither is present, the loop ends.
168    ///
169    /// The `wakeup_slot` should be the same slot registered with the
170    /// `ScheduleWakeup` tool in the agent's tool registry.
171    pub async fn run_event_loop(
172        &mut self,
173        initial_goal: impl Into<String>,
174        wakeup_slot: &crate::tools::WakeupSlot,
175        events: Option<mpsc::UnboundedSender<StepEvent>>,
176    ) -> Result<Vec<AgentOutcome>> {
177        let mut outcomes = Vec::new();
178        let mut next_goal = initial_goal.into();
179
180        loop {
181            let outcome = self.turn(&next_goal, events.clone()).await?;
182            outcomes.push(outcome);
183
184            // Priority 1: explicit wakeup
185            let wakeup = wakeup_slot
186                .lock()
187                .ok()
188                .and_then(|mut slot| slot.take());
189            if let Some(req) = wakeup {
190                tokio::time::sleep(req.delay).await;
191                next_goal = req.prompt;
192                continue;
193            }
194
195            // Priority 2: background job completed
196            if let Some(ref mgr) = self.bg_manager {
197                if let Ok(mut mgr) = mgr.try_lock() {
198                    if let Some((id, output)) = mgr.take_completed() {
199                        next_goal = format!(
200                            "Background job '{}' completed:\n{}",
201                            id, output
202                        );
203                        continue;
204                    }
205                }
206            }
207
208            // Nothing to do → loop ends
209            break;
210        }
211        Ok(outcomes)
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use std::sync::Arc;
218
219    use tokio::sync::mpsc;
220
221    use crate::agent::StepEvent;
222    use crate::llm::{Completion, MockProvider};
223    use crate::message::{Message, Role};
224    use crate::tools::ToolRegistry;
225    use crate::Agent;
226
227    use super::*;
228
229    fn make_agent(script: Vec<Completion>) -> Agent {
230        let provider = Arc::new(MockProvider::new(script));
231        Agent::builder()
232            .llm(provider)
233            .tools(ToolRegistry::default())
234            .system_prompt("You are a helpful assistant.")
235            .max_steps(5)
236            .build()
237            .unwrap()
238    }
239
240    fn user_content(msg: &Message) -> Option<&str> {
241        if msg.role == Role::User {
242            Some(msg.content.as_str())
243        } else {
244            None
245        }
246    }
247
248    #[tokio::test]
249    async fn preserves_transcript_across_turns() {
250        let script = vec![
251            Completion {
252                content: "First response".into(),
253                tool_calls: vec![],
254                finish_reason: Some("stop".to_string()),
255                usage: None,
256                reasoning_content: None,
257            },
258            Completion {
259                content: "Second response".into(),
260                tool_calls: vec![],
261                finish_reason: Some("stop".to_string()),
262                usage: None,
263                reasoning_content: None,
264            },
265        ];
266        let agent = make_agent(script);
267        let mut runner = AgentRunner::new(agent);
268
269        let outcome1 = runner.turn("First goal", None).await.unwrap();
270        assert_eq!(runner.turns(), 1);
271        assert!(outcome1
272            .transcript
273            .iter()
274            .any(|m| { m.role == Role::User && m.content == "First goal" }));
275
276        let outcome2 = runner.turn("Second goal", None).await.unwrap();
277        assert_eq!(runner.turns(), 2);
278
279        // Second turn's transcript should contain messages from both turns
280        let texts: Vec<&str> = outcome2
281            .transcript
282            .iter()
283            .filter_map(user_content)
284            .collect();
285        assert!(
286            texts.contains(&"First goal"),
287            "first turn goal should be in transcript: {texts:?}"
288        );
289        assert!(
290            texts.contains(&"Second goal"),
291            "second turn goal should be in transcript: {texts:?}"
292        );
293    }
294
295    #[tokio::test]
296    async fn clear_resets_transcript() {
297        // Provide two completions: one for the first turn, one for after clear
298        let script = vec![
299            Completion {
300                content: "Response".into(),
301                tool_calls: vec![],
302                finish_reason: Some("stop".to_string()),
303                usage: None,
304                reasoning_content: None,
305            },
306            Completion {
307                content: "After clear".into(),
308                tool_calls: vec![],
309                finish_reason: Some("stop".to_string()),
310                usage: None,
311                reasoning_content: None,
312            },
313        ];
314        let agent = make_agent(script);
315        let mut runner = AgentRunner::new(agent);
316
317        runner.turn("Goal", None).await.unwrap();
318        assert_eq!(runner.turns(), 1);
319
320        runner.clear();
321        assert_eq!(runner.turns(), 0);
322
323        // After clear, the agent should have no transcript
324        let agent = runner.agent();
325        // Verify the agent's internal state is reset by running another turn
326        // and checking it only has the new goal.
327        let _ = agent;
328        let outcome = runner.turn("New goal", None).await.unwrap();
329        let user_msgs: Vec<&str> = outcome.transcript.iter().filter_map(user_content).collect();
330        assert_eq!(user_msgs, vec!["New goal"]);
331    }
332
333    #[tokio::test]
334    async fn turns_increments() {
335        let script = vec![
336            Completion {
337                content: "One".into(),
338                tool_calls: vec![],
339                finish_reason: Some("stop".to_string()),
340                usage: None,
341                reasoning_content: None,
342            },
343            Completion {
344                content: "Two".into(),
345                tool_calls: vec![],
346                finish_reason: Some("stop".to_string()),
347                usage: None,
348                reasoning_content: None,
349            },
350            Completion {
351                content: "Three".into(),
352                tool_calls: vec![],
353                finish_reason: Some("stop".to_string()),
354                usage: None,
355                reasoning_content: None,
356            },
357        ];
358        let agent = make_agent(script);
359        let mut runner = AgentRunner::new(agent);
360
361        assert_eq!(runner.turns(), 0);
362        runner.turn("A", None).await.unwrap();
363        assert_eq!(runner.turns(), 1);
364        runner.turn("B", None).await.unwrap();
365        assert_eq!(runner.turns(), 2);
366        runner.turn("C", None).await.unwrap();
367        assert_eq!(runner.turns(), 3);
368    }
369
370    #[tokio::test]
371    async fn events_forwarded_when_provided() {
372        let script = vec![Completion {
373            content: "Hello world".into(),
374            tool_calls: vec![],
375            finish_reason: Some("stop".to_string()),
376            usage: None,
377            reasoning_content: None,
378        }];
379        let agent = make_agent(script);
380        let mut runner = AgentRunner::new(agent);
381
382        let (tx, mut rx) = mpsc::unbounded_channel();
383        runner.turn("Goal", Some(tx)).await.unwrap();
384
385        // We should have received events (at least a Finished event)
386        let events: Vec<StepEvent> = {
387            let mut v = Vec::new();
388            while let Ok(ev) = rx.try_recv() {
389                v.push(ev);
390            }
391            v
392        };
393        assert!(!events.is_empty(), "should have received events");
394        assert!(
395            events
396                .iter()
397                .any(|e| matches!(e, StepEvent::Finished { .. })),
398            "should have a Finished event"
399        );
400    }
401
402    #[tokio::test]
403    async fn no_events_when_none_passed() {
404        let script = vec![Completion {
405            content: "Silent".into(),
406            tool_calls: vec![],
407            finish_reason: Some("stop".to_string()),
408            usage: None,
409            reasoning_content: None,
410        }];
411        let agent = make_agent(script);
412        let mut runner = AgentRunner::new(agent);
413
414        runner.turn("Goal", None).await.unwrap();
415
416        // The agent's events channel should be None
417        // We verify by checking that no events were emitted
418        // (we can't inspect the agent's events field directly,
419        // but we can confirm the turn completed successfully)
420        assert_eq!(runner.turns(), 1);
421    }
422
423    #[tokio::test]
424    async fn agent_accessors_work() {
425        let script = vec![Completion {
426            content: "Test".into(),
427            tool_calls: vec![],
428            finish_reason: Some("stop".to_string()),
429            usage: None,
430            reasoning_content: None,
431        }];
432        let agent = make_agent(script);
433        let mut runner = AgentRunner::new(agent);
434
435        // Immutable access
436        let _agent_ref = runner.agent();
437        // Mutable access
438        let _agent_mut = runner.agent_mut();
439    }
440
441    #[tokio::test]
442    async fn run_event_loop_triggers_on_wakeup() {
443        use std::sync::Mutex as StdMutex;
444        use crate::tools::WakeupSlot;
445
446        let script = vec![
447            Completion {
448                content: "Turn 1".into(),
449                tool_calls: vec![],
450                finish_reason: Some("stop".to_string()),
451                usage: None,
452                reasoning_content: None,
453            },
454            Completion {
455                content: "Turn 2".into(),
456                tool_calls: vec![],
457                finish_reason: Some("stop".to_string()),
458                usage: None,
459                reasoning_content: None,
460            },
461        ];
462        let agent = make_agent(script);
463        let mut runner = AgentRunner::new(agent);
464        let wakeup_slot: WakeupSlot = Arc::new(StdMutex::new(None));
465
466        // Pre-seed a wakeup so the loop runs a second turn
467        {
468            let mut slot = wakeup_slot.lock().unwrap();
469            *slot = Some(crate::tools::WakeupRequest {
470                delay: std::time::Duration::from_millis(1),
471                reason: "continue".into(),
472                prompt: "Second goal".into(),
473            });
474        }
475
476        let outcomes = runner
477            .run_event_loop("First goal", &wakeup_slot, None)
478            .await
479            .unwrap();
480
481        assert_eq!(outcomes.len(), 2, "should have run 2 turns");
482        assert_eq!(runner.turns(), 2);
483    }
484
485    #[tokio::test]
486    async fn run_event_loop_ends_when_no_wakeup_or_bg() {
487        let script = vec![Completion {
488            content: "Only turn".into(),
489            tool_calls: vec![],
490            finish_reason: Some("stop".to_string()),
491            usage: None,
492            reasoning_content: None,
493        }];
494        let agent = make_agent(script);
495        let mut runner = AgentRunner::new(agent);
496        let wakeup_slot = Arc::new(std::sync::Mutex::new(None));
497
498        let outcomes = runner
499            .run_event_loop("Single goal", &wakeup_slot, None)
500            .await
501            .unwrap();
502
503        assert_eq!(outcomes.len(), 1, "should have run exactly 1 turn");
504        assert_eq!(runner.turns(), 1);
505    }
506
507    #[tokio::test]
508    async fn run_event_loop_triggers_on_bg_completion() {
509        use std::sync::Mutex as StdMutex;
510        use crate::tools::WakeupSlot;
511
512        let script = vec![
513            Completion {
514                content: "Turn 1".into(),
515                tool_calls: vec![],
516                finish_reason: Some("stop".to_string()),
517                usage: None,
518                reasoning_content: None,
519            },
520            Completion {
521                content: "Turn 2".into(),
522                tool_calls: vec![],
523                finish_reason: Some("stop".to_string()),
524                usage: None,
525                reasoning_content: None,
526            },
527        ];
528        let agent = make_agent(script);
529        let bg_manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
530        let mut runner = AgentRunner::with_bg_manager(agent, bg_manager.clone());
531        let wakeup_slot: WakeupSlot = Arc::new(StdMutex::new(None));
532
533        // Pre-seed a completed background job
534        {
535            let mut mgr = bg_manager.lock().await;
536            mgr.insert(crate::tools::run_background::Job {
537                state: crate::tools::run_background::JobState::Completed {
538                    stdout: "build succeeded".into(),
539                    stderr: "".into(),
540                    exit_code: 0,
541                },
542                created_at: std::time::Instant::now(),
543            });
544        }
545
546        let outcomes = runner
547            .run_event_loop("First goal", &wakeup_slot, None)
548            .await
549            .unwrap();
550
551        assert_eq!(outcomes.len(), 2, "should have run 2 turns (initial + bg trigger)");
552        assert_eq!(runner.turns(), 2);
553    }
554}