use std::path::{Path, PathBuf};
use std::sync::Arc;
use futures::StreamExt as _;
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
use tokio::sync::watch;
use tokio_util::sync::CancellationToken;
use crate::compaction::{self, CompactOutcome, CompactionPolicy};
use crate::event::{AgentEvent, LlmStreamEvent};
use crate::hooks::{HooksSet, PlanModeHandle};
use crate::loop_::{LoopConfig, LoopError, run_loop};
use crate::message::{AgentMessage, StopReason, Usage};
use crate::session::{Session, SessionEntry};
use crate::streamfn::{LlmOpts, StreamFn};
use crate::tool::Tool;
pub const PLAN_SECTION: &str = "# Plan mode\nYou are planning, not executing. Explore and propose only — do NOT modify the\nworkspace: the editing tools are disabled and mutating shell commands are refused.\nFine-tune the plan with the user first. When the plan is final, record it as a\ntodo list (status pending), then tell the user to run `/plan off` to execute.";
pub struct AgentConfig {
pub system: String,
pub tools: Vec<Tool>,
pub llm: LlmOpts,
pub stream_fn: StreamFn,
pub hooks: HooksSet,
pub session: Option<Session>, pub context: Vec<AgentMessage>,
pub working_dir: PathBuf,
pub max_turns: usize,
pub parallel_tools: bool,
pub compaction: CompactionPolicy,
pub plan_mode: PlanModeHandle,
}
pub struct Agent {
system: String,
base_system: String,
tools: Vec<Tool>,
llm: LlmOpts,
stream_fn: StreamFn,
hooks: HooksSet,
session: Option<Session>,
working_dir: PathBuf,
max_turns: usize,
parallel_tools: bool,
compaction: CompactionPolicy,
plan_mode: PlanModeHandle,
ctx: Vec<AgentMessage>,
steer_tx: UnboundedSender<AgentMessage>,
steer_rx: Option<UnboundedReceiver<AgentMessage>>,
follow_tx: UnboundedSender<AgentMessage>,
follow_rx: Option<UnboundedReceiver<AgentMessage>>,
cancel: CancellationToken,
ctx_snapshot: watch::Sender<Vec<AgentMessage>>,
}
impl Agent {
pub fn new(mut cfg: AgentConfig) -> Agent {
cfg.llm.session_id = cfg
.session
.as_ref()
.and_then(session_header_id)
.or_else(|| cfg.llm.session_id.clone())
.or_else(|| Some(uuid::Uuid::new_v4().to_string()));
let (steer_tx, steer_rx) = mpsc::unbounded_channel();
let (follow_tx, follow_rx) = mpsc::unbounded_channel();
let (ctx_snapshot, _ctx_rx) = watch::channel(cfg.context.clone());
let working_dir = if cfg.working_dir.as_os_str().is_empty() {
default_working_dir()
} else {
cfg.working_dir
};
Agent {
system: cfg.system.clone(),
base_system: cfg.system,
tools: cfg.tools,
llm: cfg.llm,
stream_fn: cfg.stream_fn,
hooks: cfg.hooks,
session: cfg.session,
working_dir,
max_turns: cfg.max_turns,
parallel_tools: cfg.parallel_tools,
compaction: cfg.compaction,
plan_mode: cfg.plan_mode,
ctx: cfg.context,
steer_tx,
steer_rx: Some(steer_rx),
follow_tx,
follow_rx: Some(follow_rx),
cancel: CancellationToken::new(),
ctx_snapshot,
}
}
pub fn steer(&self, m: AgentMessage) {
let _ = self.steer_tx.send(m);
}
pub fn set_plan_mode(&mut self, on: bool) {
self.plan_mode.set(on);
self.system = if on {
format!("{}\n\n{}", self.base_system, PLAN_SECTION)
} else {
self.base_system.clone()
};
}
pub fn set_model(&mut self, model: String) -> std::io::Result<()> {
if let Some(session) = &mut self.session {
session.append(SessionEntry::ModelChange {
id: uuid::Uuid::new_v4().to_string(),
model: model.clone(),
})?;
}
self.llm.model = model;
self.llm.settle_provider();
Ok(())
}
pub fn effort(&self) -> Option<&str> {
self.llm.effort.as_deref()
}
pub fn set_effort(&mut self, effort: Option<String>) -> std::io::Result<()> {
if let Some(session) = &mut self.session {
session.append(SessionEntry::EffortChange {
id: uuid::Uuid::new_v4().to_string(),
effort: effort.clone(),
})?;
}
self.llm.effort = effort;
Ok(())
}
pub fn steer_sender(&self) -> UnboundedSender<AgentMessage> {
self.steer_tx.clone()
}
pub fn follow_up(&self, m: AgentMessage) {
let _ = self.follow_tx.send(m);
}
pub fn follow_up_sender(&self) -> UnboundedSender<AgentMessage> {
self.follow_tx.clone()
}
pub fn cancel(&self) {
self.cancel.cancel();
}
pub fn cancel_token(&self) -> CancellationToken {
self.cancel.clone()
}
pub fn hooks(&self) -> &HooksSet {
&self.hooks
}
pub async fn run(
&mut self,
user_text: &str,
sink: UnboundedSender<AgentEvent>,
) -> Result<StopReason, LoopError> {
let user = AgentMessage::user_text(user_text);
if let Some(session) = &mut self.session {
session
.append(SessionEntry::Message {
id: uuid::Uuid::new_v4().to_string(),
parent_id: None,
message: user.clone(),
})
.map_err(LoopError::Session)?;
}
self.ctx.push(user);
let _ = self.ctx_snapshot.send_replace(self.ctx.clone());
let steering = self.steer_rx.take().expect("steer_rx present between runs");
let follow_ups = self
.follow_rx
.take()
.expect("follow_rx present between runs");
let cfg = LoopConfig {
system: self.system.clone(),
tools: self.tools.clone(),
llm: self.llm.clone(),
stream_fn: Arc::clone(&self.stream_fn),
hooks: self.hooks.clone(),
steering,
follow_ups,
cancel: self.cancel.clone(),
working_dir: self.working_dir.clone(),
max_turns: self.max_turns,
parallel: self.parallel_tools,
compaction: self.compaction,
ctx_snapshot: self.ctx_snapshot.clone(),
session: self.session.as_mut(),
};
let res = run_loop(&mut self.ctx, cfg, sink).await;
self.cancel = CancellationToken::new();
match res {
Ok(result) => {
self.steer_rx = Some(result.steering);
self.follow_rx = Some(result.follow_ups);
self.hooks.after_run(&self.ctx, result.stop_reason).await;
Ok(result.stop_reason)
}
Err(e) => {
let (steer_tx, steer_rx) = mpsc::unbounded_channel();
self.steer_tx = steer_tx;
self.steer_rx = Some(steer_rx);
let (follow_tx, follow_rx) = mpsc::unbounded_channel();
self.follow_tx = follow_tx;
self.follow_rx = Some(follow_rx);
Err(e)
}
}
}
pub fn messages(&self) -> &[AgentMessage] {
&self.ctx
}
pub fn notify(&mut self, content: String) -> std::io::Result<AgentMessage> {
let message = AgentMessage::user_text(content);
if let Some(session) = &mut self.session {
session.append(SessionEntry::Message {
id: uuid::Uuid::new_v4().to_string(),
parent_id: None,
message: message.clone(),
})?;
}
self.ctx.push(message.clone());
Ok(message)
}
pub async fn side_ask(&self, text: &str) -> Result<SideAnswer, LoopError> {
side_ask_with(&self.stream_fn, &self.ctx, &self.system, &self.llm, text).await
}
pub fn side_handle(&self) -> SideHandle {
SideHandle {
stream_fn: Arc::clone(&self.stream_fn),
system: self.system.clone(),
llm: self.llm.clone(),
ctx: self.ctx_snapshot.clone(),
}
}
pub async fn compact(&mut self, instructions: Option<&str>) -> Result<CompactOutcome, String> {
compaction::compact_ctx(
&mut self.ctx,
&self.compaction,
&self.stream_fn,
&self.llm,
instructions,
self.session.as_mut(),
)
.await
}
pub fn session_path(&self) -> Option<&Path> {
self.session.as_ref().and_then(|s| s.path())
}
pub fn session_id(&self) -> Option<String> {
self.session.as_ref().and_then(session_header_id)
}
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct SideAnswer {
pub text: String,
pub usage: Option<Usage>,
}
#[derive(Clone)]
pub struct SideHandle {
stream_fn: StreamFn,
system: String,
llm: LlmOpts,
ctx: watch::Sender<Vec<AgentMessage>>,
}
impl SideHandle {
pub fn history(&self) -> Vec<AgentMessage> {
self.ctx.subscribe().borrow().to_vec()
}
pub fn last_assistant_text(&self) -> Option<String> {
let rx = self.ctx.subscribe();
let messages = rx.borrow();
crate::message::last_assistant_text(&messages[..])
}
pub async fn side_ask(&self, text: &str) -> Result<SideAnswer, LoopError> {
side_ask_with(&self.stream_fn, &self.history(), &self.system, &self.llm, text).await
}
}
async fn side_ask_with(
stream_fn: &StreamFn,
ctx: &[AgentMessage],
system: &str,
llm: &LlmOpts,
text: &str,
) -> Result<SideAnswer, LoopError> {
let mut msgs = ctx.to_vec();
msgs.push(AgentMessage::user_text(frame_btw(text)));
let mut stream = (stream_fn)(&msgs, system, &[], llm);
let mut out = String::new();
let mut usage = None;
while let Some(ev) = stream.next().await {
match ev {
LlmStreamEvent::TextDelta(delta) => out.push_str(&delta),
LlmStreamEvent::Done { usage: u, .. } => {
usage = u;
break;
}
LlmStreamEvent::Error { message, .. } => return Err(LoopError::Stream(message)),
_ => {}
}
}
let text = out.trim().to_string();
if text.is_empty() {
return Err(LoopError::Stream("side answer produced no text".to_string()));
}
Ok(SideAnswer { text, usage })
}
fn frame_btw(q: &str) -> String {
format!("[side question — no tools available; answer briefly and directly from the conversation above]\n{q}")
}
fn default_working_dir() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
fn session_header_id(session: &Session) -> Option<String> {
session.entries().iter().find_map(|e| match e {
SessionEntry::Header { id, .. } => Some(id.clone()),
_ => None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::compaction::CompactionPolicy;
use crate::hooks::HooksSet;
use crate::streamfn::{LlmStream, StreamFn};
fn scripted(script: Vec<LlmStreamEvent>) -> StreamFn {
Arc::new(move |_ctx: &[AgentMessage], _system, _tools, _opts: &LlmOpts| {
Box::pin(futures::stream::iter(script.clone())) as LlmStream
})
}
fn make_agent(session: Option<Session>, stream_fn: StreamFn, context: Vec<AgentMessage>) -> Agent {
Agent::new(AgentConfig {
system: "sys".into(),
tools: vec![],
llm: LlmOpts {
model: "m1".into(),
..LlmOpts::default()
},
stream_fn,
hooks: HooksSet::default(),
session,
context,
working_dir: PathBuf::new(),
max_turns: crate::loop_::DEFAULT_MAX_TURNS,
parallel_tools: true,
compaction: CompactionPolicy::default(),
plan_mode: PlanModeHandle::new(),
})
}
#[tokio::test]
async fn side_ask_leaves_ctx_and_session_untouched() {
let dir = tempfile::tempdir().unwrap();
let session = Session::create(dir.path()).unwrap();
let stream = scripted(vec![
LlmStreamEvent::TextDelta("an".into()),
LlmStreamEvent::TextDelta("swer".into()),
LlmStreamEvent::Done {
stop_reason: StopReason::Stop,
usage: None,
},
]);
let agent = make_agent(Some(session), stream, vec![AgentMessage::user_text("earlier")]);
let ctx_before = agent.ctx.clone();
let path = agent.session.as_ref().unwrap().path().unwrap().to_path_buf();
let entries_before = agent.session.as_ref().unwrap().entries().to_vec();
let bytes_before = std::fs::read(&path).unwrap();
let answer = agent.side_ask("why?").await.unwrap();
assert_eq!(answer.text, "answer");
assert_eq!(agent.ctx, ctx_before, "ctx is unchanged");
assert_eq!(agent.messages(), &ctx_before[..]);
assert_eq!(
agent.session.as_ref().unwrap().entries(),
&entries_before[..],
"session entries unchanged"
);
assert_eq!(
std::fs::read(&path).unwrap(),
bytes_before,
"session file bytes unchanged"
);
}
#[tokio::test]
async fn side_ask_folds_deltas_and_captures_usage() {
let usage = Usage {
input_tokens: 7,
output_tokens: 3,
cache_read_tokens: Some(2),
cache_write_tokens: None,
};
let stream = scripted(vec![
LlmStreamEvent::TextDelta("he".into()),
LlmStreamEvent::TextDelta("llo".into()),
LlmStreamEvent::Done {
stop_reason: StopReason::Stop,
usage: Some(usage),
},
]);
let agent = make_agent(None, stream, vec![]);
assert_eq!(
agent.side_ask("hi").await.unwrap(),
SideAnswer {
text: "hello".into(),
usage: Some(usage),
}
);
}
#[tokio::test]
async fn side_ask_surfaces_a_stream_error() {
let stream = scripted(vec![LlmStreamEvent::Error {
message: "boom".into(),
fatal: true,
}]);
let agent = make_agent(None, stream, vec![]);
let err = agent.side_ask("hi").await.unwrap_err();
assert!(err.to_string().contains("boom"), "{err}");
}
#[tokio::test]
async fn side_ask_errors_on_empty_output() {
let stream = scripted(vec![LlmStreamEvent::Done {
stop_reason: StopReason::Stop,
usage: None,
}]);
let agent = make_agent(None, stream, vec![]);
let err = agent.side_ask("hi").await.unwrap_err();
assert!(err.to_string().contains("no text"), "{err}");
}
#[test]
fn frame_btw_wraps_the_question() {
let framed = frame_btw("why?");
assert!(framed.starts_with("[side question"));
assert!(framed.ends_with("\nwhy?"));
}
#[test]
fn set_plan_mode_appends_and_restores_the_section() {
let mut agent = make_agent(None, scripted(vec![]), vec![]);
let base = agent.system.clone();
agent.set_plan_mode(true);
assert!(agent.plan_mode.get());
assert_eq!(agent.system, format!("{base}\n\n{PLAN_SECTION}"));
assert!(agent.system.contains("# Plan mode"));
assert!(
agent.system.contains("record it as a\ntodo list (status pending)"),
"the P2 plan\u{2192}todo instruction is present"
);
agent.set_plan_mode(false);
assert!(!agent.plan_mode.get());
assert_eq!(agent.system, base, "restored exactly");
}
}