use std::sync::Arc;
use tokio::sync::mpsc;
use tokio::sync::Mutex;
use crate::agent::{Agent, AgentOutcome, StepEvent};
use crate::error::Result;
use crate::tools::BackgroundJobManager;
pub struct AgentRunner {
agent: Agent,
total_turns: usize,
bg_manager: Option<Arc<Mutex<BackgroundJobManager>>>,
}
impl AgentRunner {
pub fn new(agent: Agent) -> Self {
Self {
agent,
total_turns: 0,
bg_manager: None,
}
}
pub fn with_bg_manager(agent: Agent, bg_manager: Arc<Mutex<BackgroundJobManager>>) -> Self {
Self {
agent,
total_turns: 0,
bg_manager: Some(bg_manager),
}
}
pub async fn turn(
&mut self,
goal: impl Into<String>,
events: Option<mpsc::UnboundedSender<StepEvent>>,
) -> Result<AgentOutcome> {
self.agent.set_events(events);
let outcome = self.agent.run(goal).await?;
self.agent.set_transcript(outcome.transcript.clone());
self.agent.set_events(None);
self.total_turns += 1;
Ok(outcome)
}
pub fn clear(&mut self) {
self.agent.set_transcript(Vec::new());
self.total_turns = 0;
if let Some(ref mgr) = self.bg_manager {
if let Ok(mut mgr) = mgr.try_lock() {
mgr.clear();
}
}
}
pub fn turns(&self) -> usize {
self.total_turns
}
pub fn agent(&self) -> &Agent {
&self.agent
}
pub fn agent_mut(&mut self) -> &mut Agent {
&mut self.agent
}
pub async fn run_loop(
&mut self,
initial_goal: impl Into<String>,
wakeup_slot: &crate::tools::WakeupSlot,
events: Option<mpsc::UnboundedSender<StepEvent>>,
) -> Result<Vec<AgentOutcome>> {
let mut outcomes = Vec::new();
let mut next_goal = initial_goal.into();
loop {
let outcome = self.turn(&next_goal, events.clone()).await?;
outcomes.push(outcome);
let wakeup = wakeup_slot.lock().ok().and_then(|mut slot| slot.take());
match wakeup {
Some(req) => {
tokio::time::sleep(req.delay).await;
next_goal = req.prompt;
}
None => break, }
}
Ok(outcomes)
}
pub async fn run_event_loop(
&mut self,
initial_goal: impl Into<String>,
wakeup_slot: &crate::tools::WakeupSlot,
events: Option<mpsc::UnboundedSender<StepEvent>>,
) -> Result<Vec<AgentOutcome>> {
let mut outcomes = Vec::new();
let mut next_goal = initial_goal.into();
loop {
let outcome = self.turn(&next_goal, events.clone()).await?;
outcomes.push(outcome);
let wakeup = wakeup_slot
.lock()
.ok()
.and_then(|mut slot| slot.take());
if let Some(req) = wakeup {
tokio::time::sleep(req.delay).await;
next_goal = req.prompt;
continue;
}
if let Some(ref mgr) = self.bg_manager {
if let Ok(mut mgr) = mgr.try_lock() {
if let Some((id, output)) = mgr.take_completed() {
next_goal = format!(
"Background job '{}' completed:\n{}",
id, output
);
continue;
}
}
}
break;
}
Ok(outcomes)
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use tokio::sync::mpsc;
use crate::agent::StepEvent;
use crate::llm::{Completion, MockProvider};
use crate::message::{Message, Role};
use crate::tools::ToolRegistry;
use crate::Agent;
use super::*;
fn make_agent(script: Vec<Completion>) -> Agent {
let provider = Arc::new(MockProvider::new(script));
Agent::builder()
.llm(provider)
.tools(ToolRegistry::default())
.system_prompt("You are a helpful assistant.")
.max_steps(5)
.build()
.unwrap()
}
fn user_content(msg: &Message) -> Option<&str> {
if msg.role == Role::User {
Some(msg.content.as_str())
} else {
None
}
}
#[tokio::test]
async fn preserves_transcript_across_turns() {
let script = vec![
Completion {
content: "First response".into(),
tool_calls: vec![],
finish_reason: Some("stop".to_string()),
usage: None,
reasoning_content: None,
},
Completion {
content: "Second response".into(),
tool_calls: vec![],
finish_reason: Some("stop".to_string()),
usage: None,
reasoning_content: None,
},
];
let agent = make_agent(script);
let mut runner = AgentRunner::new(agent);
let outcome1 = runner.turn("First goal", None).await.unwrap();
assert_eq!(runner.turns(), 1);
assert!(outcome1
.transcript
.iter()
.any(|m| { m.role == Role::User && m.content == "First goal" }));
let outcome2 = runner.turn("Second goal", None).await.unwrap();
assert_eq!(runner.turns(), 2);
let texts: Vec<&str> = outcome2
.transcript
.iter()
.filter_map(user_content)
.collect();
assert!(
texts.contains(&"First goal"),
"first turn goal should be in transcript: {texts:?}"
);
assert!(
texts.contains(&"Second goal"),
"second turn goal should be in transcript: {texts:?}"
);
}
#[tokio::test]
async fn clear_resets_transcript() {
let script = vec![
Completion {
content: "Response".into(),
tool_calls: vec![],
finish_reason: Some("stop".to_string()),
usage: None,
reasoning_content: None,
},
Completion {
content: "After clear".into(),
tool_calls: vec![],
finish_reason: Some("stop".to_string()),
usage: None,
reasoning_content: None,
},
];
let agent = make_agent(script);
let mut runner = AgentRunner::new(agent);
runner.turn("Goal", None).await.unwrap();
assert_eq!(runner.turns(), 1);
runner.clear();
assert_eq!(runner.turns(), 0);
let agent = runner.agent();
let _ = agent;
let outcome = runner.turn("New goal", None).await.unwrap();
let user_msgs: Vec<&str> = outcome.transcript.iter().filter_map(user_content).collect();
assert_eq!(user_msgs, vec!["New goal"]);
}
#[tokio::test]
async fn turns_increments() {
let script = vec![
Completion {
content: "One".into(),
tool_calls: vec![],
finish_reason: Some("stop".to_string()),
usage: None,
reasoning_content: None,
},
Completion {
content: "Two".into(),
tool_calls: vec![],
finish_reason: Some("stop".to_string()),
usage: None,
reasoning_content: None,
},
Completion {
content: "Three".into(),
tool_calls: vec![],
finish_reason: Some("stop".to_string()),
usage: None,
reasoning_content: None,
},
];
let agent = make_agent(script);
let mut runner = AgentRunner::new(agent);
assert_eq!(runner.turns(), 0);
runner.turn("A", None).await.unwrap();
assert_eq!(runner.turns(), 1);
runner.turn("B", None).await.unwrap();
assert_eq!(runner.turns(), 2);
runner.turn("C", None).await.unwrap();
assert_eq!(runner.turns(), 3);
}
#[tokio::test]
async fn events_forwarded_when_provided() {
let script = vec![Completion {
content: "Hello world".into(),
tool_calls: vec![],
finish_reason: Some("stop".to_string()),
usage: None,
reasoning_content: None,
}];
let agent = make_agent(script);
let mut runner = AgentRunner::new(agent);
let (tx, mut rx) = mpsc::unbounded_channel();
runner.turn("Goal", Some(tx)).await.unwrap();
let events: Vec<StepEvent> = {
let mut v = Vec::new();
while let Ok(ev) = rx.try_recv() {
v.push(ev);
}
v
};
assert!(!events.is_empty(), "should have received events");
assert!(
events
.iter()
.any(|e| matches!(e, StepEvent::Finished { .. })),
"should have a Finished event"
);
}
#[tokio::test]
async fn no_events_when_none_passed() {
let script = vec![Completion {
content: "Silent".into(),
tool_calls: vec![],
finish_reason: Some("stop".to_string()),
usage: None,
reasoning_content: None,
}];
let agent = make_agent(script);
let mut runner = AgentRunner::new(agent);
runner.turn("Goal", None).await.unwrap();
assert_eq!(runner.turns(), 1);
}
#[tokio::test]
async fn agent_accessors_work() {
let script = vec![Completion {
content: "Test".into(),
tool_calls: vec![],
finish_reason: Some("stop".to_string()),
usage: None,
reasoning_content: None,
}];
let agent = make_agent(script);
let mut runner = AgentRunner::new(agent);
let _agent_ref = runner.agent();
let _agent_mut = runner.agent_mut();
}
#[tokio::test]
async fn run_event_loop_triggers_on_wakeup() {
use std::sync::Mutex as StdMutex;
use crate::tools::WakeupSlot;
let script = vec![
Completion {
content: "Turn 1".into(),
tool_calls: vec![],
finish_reason: Some("stop".to_string()),
usage: None,
reasoning_content: None,
},
Completion {
content: "Turn 2".into(),
tool_calls: vec![],
finish_reason: Some("stop".to_string()),
usage: None,
reasoning_content: None,
},
];
let agent = make_agent(script);
let mut runner = AgentRunner::new(agent);
let wakeup_slot: WakeupSlot = Arc::new(StdMutex::new(None));
{
let mut slot = wakeup_slot.lock().unwrap();
*slot = Some(crate::tools::WakeupRequest {
delay: std::time::Duration::from_millis(1),
reason: "continue".into(),
prompt: "Second goal".into(),
});
}
let outcomes = runner
.run_event_loop("First goal", &wakeup_slot, None)
.await
.unwrap();
assert_eq!(outcomes.len(), 2, "should have run 2 turns");
assert_eq!(runner.turns(), 2);
}
#[tokio::test]
async fn run_event_loop_ends_when_no_wakeup_or_bg() {
let script = vec![Completion {
content: "Only turn".into(),
tool_calls: vec![],
finish_reason: Some("stop".to_string()),
usage: None,
reasoning_content: None,
}];
let agent = make_agent(script);
let mut runner = AgentRunner::new(agent);
let wakeup_slot = Arc::new(std::sync::Mutex::new(None));
let outcomes = runner
.run_event_loop("Single goal", &wakeup_slot, None)
.await
.unwrap();
assert_eq!(outcomes.len(), 1, "should have run exactly 1 turn");
assert_eq!(runner.turns(), 1);
}
#[tokio::test]
async fn run_event_loop_triggers_on_bg_completion() {
use std::sync::Mutex as StdMutex;
use crate::tools::WakeupSlot;
let script = vec![
Completion {
content: "Turn 1".into(),
tool_calls: vec![],
finish_reason: Some("stop".to_string()),
usage: None,
reasoning_content: None,
},
Completion {
content: "Turn 2".into(),
tool_calls: vec![],
finish_reason: Some("stop".to_string()),
usage: None,
reasoning_content: None,
},
];
let agent = make_agent(script);
let bg_manager = Arc::new(Mutex::new(BackgroundJobManager::new()));
let mut runner = AgentRunner::with_bg_manager(agent, bg_manager.clone());
let wakeup_slot: WakeupSlot = Arc::new(StdMutex::new(None));
{
let mut mgr = bg_manager.lock().await;
mgr.insert(crate::tools::run_background::Job {
state: crate::tools::run_background::JobState::Completed {
stdout: "build succeeded".into(),
stderr: "".into(),
exit_code: 0,
},
created_at: std::time::Instant::now(),
});
}
let outcomes = runner
.run_event_loop("First goal", &wakeup_slot, None)
.await
.unwrap();
assert_eq!(outcomes.len(), 2, "should have run 2 turns (initial + bg trigger)");
assert_eq!(runner.turns(), 2);
}
}