1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use futures::StreamExt as _;
5use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender};
6use tokio_util::sync::CancellationToken;
7
8use crate::compaction::{self, CompactOutcome, CompactionPolicy};
9use crate::event::{AgentEvent, LlmStreamEvent};
10use crate::hooks::{HooksSet, PlanModeHandle};
11use crate::loop_::{LoopConfig, LoopError, run_loop};
12use crate::message::{AgentMessage, StopReason, Usage};
13use crate::session::{Session, SessionEntry};
14use crate::streamfn::{LlmOpts, StreamFn};
15use crate::tool::Tool;
16
17pub 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.";
20
21pub struct AgentConfig {
22 pub system: String,
23 pub tools: Vec<Tool>,
24 pub llm: LlmOpts,
25 pub stream_fn: StreamFn,
26 pub hooks: HooksSet,
27 pub session: Option<Session>, pub context: Vec<AgentMessage>,
30 pub working_dir: PathBuf,
34 pub max_turns: usize,
38 pub parallel_tools: bool,
41 pub compaction: CompactionPolicy,
44 pub plan_mode: PlanModeHandle,
47}
48
49pub struct Agent {
50 system: String,
51 base_system: String,
53 tools: Vec<Tool>,
54 llm: LlmOpts,
55 stream_fn: StreamFn,
56 hooks: HooksSet,
57 session: Option<Session>,
58 working_dir: PathBuf,
59 max_turns: usize,
60 parallel_tools: bool,
61 compaction: CompactionPolicy,
62 plan_mode: PlanModeHandle,
63 ctx: Vec<AgentMessage>,
64 steer_tx: UnboundedSender<AgentMessage>,
65 steer_rx: Option<UnboundedReceiver<AgentMessage>>,
66 follow_tx: UnboundedSender<AgentMessage>,
67 follow_rx: Option<UnboundedReceiver<AgentMessage>>,
68 cancel: CancellationToken,
69}
70
71impl Agent {
72 pub fn new(mut cfg: AgentConfig) -> Agent {
73 cfg.llm.session_id = cfg
78 .session
79 .as_ref()
80 .and_then(session_header_id)
81 .or_else(|| cfg.llm.session_id.clone())
82 .or_else(|| Some(uuid::Uuid::new_v4().to_string()));
83 let (steer_tx, steer_rx) = mpsc::unbounded_channel();
84 let (follow_tx, follow_rx) = mpsc::unbounded_channel();
85 let working_dir = if cfg.working_dir.as_os_str().is_empty() {
87 default_working_dir()
88 } else {
89 cfg.working_dir
90 };
91 Agent {
92 system: cfg.system.clone(),
93 base_system: cfg.system,
94 tools: cfg.tools,
95 llm: cfg.llm,
96 stream_fn: cfg.stream_fn,
97 hooks: cfg.hooks,
98 session: cfg.session,
99 working_dir,
100 max_turns: cfg.max_turns,
101 parallel_tools: cfg.parallel_tools,
102 compaction: cfg.compaction,
103 plan_mode: cfg.plan_mode,
104 ctx: cfg.context,
105 steer_tx,
106 steer_rx: Some(steer_rx),
107 follow_tx,
108 follow_rx: Some(follow_rx),
109 cancel: CancellationToken::new(),
110 }
111 }
112
113 pub fn steer(&self, m: AgentMessage) {
115 let _ = self.steer_tx.send(m);
116 }
117
118 pub fn set_plan_mode(&mut self, on: bool) {
123 self.plan_mode.set(on);
124 self.system = if on {
125 format!("{}\n\n{}", self.base_system, PLAN_SECTION)
126 } else {
127 self.base_system.clone()
128 };
129 }
130 pub fn set_model(&mut self, model: String) -> std::io::Result<()> {
133 if let Some(session) = &mut self.session {
136 session.append(SessionEntry::ModelChange {
137 id: uuid::Uuid::new_v4().to_string(),
138 model: model.clone(),
139 })?;
140 }
141 self.llm.model = model;
142 Ok(())
143 }
144
145 pub fn effort(&self) -> Option<&str> {
147 self.llm.effort.as_deref()
148 }
149
150 pub fn set_effort(&mut self, effort: Option<String>) -> std::io::Result<()> {
153 if let Some(session) = &mut self.session {
154 session.append(SessionEntry::EffortChange {
155 id: uuid::Uuid::new_v4().to_string(),
156 effort: effort.clone(),
157 })?;
158 }
159 self.llm.effort = effort;
160 Ok(())
161 }
162
163 pub fn steer_sender(&self) -> UnboundedSender<AgentMessage> {
170 self.steer_tx.clone()
171 }
172
173 pub fn follow_up(&self, m: AgentMessage) {
175 let _ = self.follow_tx.send(m);
176 }
177
178 pub fn follow_up_sender(&self) -> UnboundedSender<AgentMessage> {
182 self.follow_tx.clone()
183 }
184
185 pub fn cancel(&self) {
186 self.cancel.cancel();
187 }
188
189 pub fn cancel_token(&self) -> CancellationToken {
192 self.cancel.clone()
193 }
194
195 pub fn hooks(&self) -> &HooksSet {
198 &self.hooks
199 }
200
201 pub async fn run(
202 &mut self,
203 user_text: &str,
204 sink: UnboundedSender<AgentEvent>,
205 ) -> Result<StopReason, LoopError> {
206 let user = AgentMessage::user_text(user_text);
211 if let Some(session) = &mut self.session {
212 session
213 .append(SessionEntry::Message {
214 id: uuid::Uuid::new_v4().to_string(),
215 parent_id: None,
216 message: user.clone(),
217 })
218 .map_err(LoopError::Session)?;
219 }
220 self.ctx.push(user);
221
222 let steering = self.steer_rx.take().expect("steer_rx present between runs");
223 let follow_ups = self
224 .follow_rx
225 .take()
226 .expect("follow_rx present between runs");
227 let cfg = LoopConfig {
228 system: self.system.clone(),
229 tools: self.tools.clone(),
230 llm: self.llm.clone(),
231 stream_fn: Arc::clone(&self.stream_fn),
232 hooks: self.hooks.clone(),
233 steering,
234 follow_ups,
235 cancel: self.cancel.clone(),
236 working_dir: self.working_dir.clone(),
237 max_turns: self.max_turns,
238 parallel: self.parallel_tools,
239 compaction: self.compaction,
240 session: self.session.as_mut(),
241 };
242 let res = run_loop(&mut self.ctx, cfg, sink).await;
243
244 self.cancel = CancellationToken::new();
247
248 match res {
249 Ok(result) => {
250 self.steer_rx = Some(result.steering);
255 self.follow_rx = Some(result.follow_ups);
256 self.hooks.after_run(&self.ctx, result.stop_reason).await;
259 Ok(result.stop_reason)
260 }
261 Err(e) => {
262 let (steer_tx, steer_rx) = mpsc::unbounded_channel();
266 self.steer_tx = steer_tx;
267 self.steer_rx = Some(steer_rx);
268 let (follow_tx, follow_rx) = mpsc::unbounded_channel();
269 self.follow_tx = follow_tx;
270 self.follow_rx = Some(follow_rx);
271 Err(e)
272 }
273 }
274 }
275
276 pub fn messages(&self) -> &[AgentMessage] {
277 &self.ctx
278 }
279
280 pub fn notify(&mut self, content: String) -> std::io::Result<AgentMessage> {
286 let message = AgentMessage::user_text(content);
287 if let Some(session) = &mut self.session {
288 session.append(SessionEntry::Message {
289 id: uuid::Uuid::new_v4().to_string(),
290 parent_id: None,
291 message: message.clone(),
292 })?;
293 }
294 self.ctx.push(message.clone());
295 Ok(message)
296 }
297
298 pub async fn side_ask(&self, text: &str) -> Result<SideAnswer, LoopError> {
311 let mut msgs = self.ctx.clone();
312 msgs.push(AgentMessage::user_text(frame_btw(text)));
313 let mut stream = (self.stream_fn)(&msgs, &self.system, &[], &self.llm);
314 let mut out = String::new();
315 let mut usage = None;
316 while let Some(ev) = stream.next().await {
317 match ev {
318 LlmStreamEvent::TextDelta(delta) => out.push_str(&delta),
319 LlmStreamEvent::Done { usage: u, .. } => {
320 usage = u;
321 break;
322 }
323 LlmStreamEvent::Error { message, .. } => return Err(LoopError::Stream(message)),
324 _ => {}
325 }
326 }
327 let text = out.trim().to_string();
328 if text.is_empty() {
329 return Err(LoopError::Stream("side answer produced no text".to_string()));
330 }
331 Ok(SideAnswer { text, usage })
332 }
333 pub async fn compact(&mut self, instructions: Option<&str>) -> Result<CompactOutcome, String> {
342 compaction::compact_ctx(
343 &mut self.ctx,
344 &self.compaction,
345 &self.stream_fn,
346 &self.llm,
347 instructions,
348 self.session.as_mut(),
349 )
350 .await
351 }
352
353 pub fn session_path(&self) -> Option<&Path> {
354 self.session.as_ref().and_then(|s| s.path())
355 }
356
357 pub fn session_id(&self) -> Option<String> {
360 self.session.as_ref().and_then(session_header_id)
361 }
362}
363
364#[derive(Clone, Debug, Default, PartialEq)]
367pub struct SideAnswer {
368 pub text: String,
369 pub usage: Option<Usage>,
370}
371
372fn frame_btw(q: &str) -> String {
375 format!("[side question — no tools available; answer briefly and directly from the conversation above]\n{q}")
376}
377
378fn default_working_dir() -> PathBuf {
380 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
381}
382
383fn session_header_id(session: &Session) -> Option<String> {
385 session.entries().iter().find_map(|e| match e {
386 SessionEntry::Header { id, .. } => Some(id.clone()),
387 _ => None,
388 })
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use crate::compaction::CompactionPolicy;
395 use crate::hooks::HooksSet;
396 use crate::streamfn::{LlmStream, StreamFn};
397
398 fn scripted(script: Vec<LlmStreamEvent>) -> StreamFn {
400 Arc::new(move |_ctx: &[AgentMessage], _system, _tools, _opts: &LlmOpts| {
401 Box::pin(futures::stream::iter(script.clone())) as LlmStream
402 })
403 }
404
405 fn make_agent(session: Option<Session>, stream_fn: StreamFn, context: Vec<AgentMessage>) -> Agent {
406 Agent::new(AgentConfig {
407 system: "sys".into(),
408 tools: vec![],
409 llm: LlmOpts {
410 model: "m1".into(),
411 ..LlmOpts::default()
412 },
413 stream_fn,
414 hooks: HooksSet::default(),
415 session,
416 context,
417 working_dir: PathBuf::new(),
418 max_turns: crate::loop_::DEFAULT_MAX_TURNS,
419 parallel_tools: true,
420 compaction: CompactionPolicy::default(),
421 plan_mode: PlanModeHandle::new(),
422 })
423 }
424
425 #[tokio::test]
426 async fn side_ask_leaves_ctx_and_session_untouched() {
427 let dir = tempfile::tempdir().unwrap();
428 let session = Session::create(dir.path()).unwrap();
429 let stream = scripted(vec![
430 LlmStreamEvent::TextDelta("an".into()),
431 LlmStreamEvent::TextDelta("swer".into()),
432 LlmStreamEvent::Done {
433 stop_reason: StopReason::Stop,
434 usage: None,
435 },
436 ]);
437 let agent = make_agent(Some(session), stream, vec![AgentMessage::user_text("earlier")]);
438
439 let ctx_before = agent.ctx.clone();
440 let path = agent.session.as_ref().unwrap().path().unwrap().to_path_buf();
441 let entries_before = agent.session.as_ref().unwrap().entries().to_vec();
442 let bytes_before = std::fs::read(&path).unwrap();
443
444 let answer = agent.side_ask("why?").await.unwrap();
445 assert_eq!(answer.text, "answer");
446
447 assert_eq!(agent.ctx, ctx_before, "ctx is unchanged");
448 assert_eq!(agent.messages(), &ctx_before[..]);
449 assert_eq!(
450 agent.session.as_ref().unwrap().entries(),
451 &entries_before[..],
452 "session entries unchanged"
453 );
454 assert_eq!(
455 std::fs::read(&path).unwrap(),
456 bytes_before,
457 "session file bytes unchanged"
458 );
459 }
460
461 #[tokio::test]
462 async fn side_ask_folds_deltas_and_captures_usage() {
463 let usage = Usage {
464 input_tokens: 7,
465 output_tokens: 3,
466 cache_read_tokens: Some(2),
467 cache_write_tokens: None,
468 };
469 let stream = scripted(vec![
470 LlmStreamEvent::TextDelta("he".into()),
471 LlmStreamEvent::TextDelta("llo".into()),
472 LlmStreamEvent::Done {
473 stop_reason: StopReason::Stop,
474 usage: Some(usage),
475 },
476 ]);
477 let agent = make_agent(None, stream, vec![]);
478 assert_eq!(
479 agent.side_ask("hi").await.unwrap(),
480 SideAnswer {
481 text: "hello".into(),
482 usage: Some(usage),
483 }
484 );
485 }
486
487 #[tokio::test]
488 async fn side_ask_surfaces_a_stream_error() {
489 let stream = scripted(vec![LlmStreamEvent::Error {
490 message: "boom".into(),
491 fatal: true,
492 }]);
493 let agent = make_agent(None, stream, vec![]);
494 let err = agent.side_ask("hi").await.unwrap_err();
495 assert!(err.to_string().contains("boom"), "{err}");
496 }
497
498 #[tokio::test]
499 async fn side_ask_errors_on_empty_output() {
500 let stream = scripted(vec![LlmStreamEvent::Done {
501 stop_reason: StopReason::Stop,
502 usage: None,
503 }]);
504 let agent = make_agent(None, stream, vec![]);
505 let err = agent.side_ask("hi").await.unwrap_err();
506 assert!(err.to_string().contains("no text"), "{err}");
507 }
508
509 #[test]
510 fn frame_btw_wraps_the_question() {
511 let framed = frame_btw("why?");
512 assert!(framed.starts_with("[side question"));
513 assert!(framed.ends_with("\nwhy?"));
514 }
515
516 #[test]
517 fn set_plan_mode_appends_and_restores_the_section() {
518 let mut agent = make_agent(None, scripted(vec![]), vec![]);
519 let base = agent.system.clone();
520
521 agent.set_plan_mode(true);
522 assert!(agent.plan_mode.get());
523 assert_eq!(agent.system, format!("{base}\n\n{PLAN_SECTION}"));
524 assert!(agent.system.contains("# Plan mode"));
525 assert!(
526 agent.system.contains("record it as a\ntodo list (status pending)"),
527 "the P2 plan\u{2192}todo instruction is present"
528 );
529
530 agent.set_plan_mode(false);
531 assert!(!agent.plan_mode.get());
532 assert_eq!(agent.system, base, "restored exactly");
533 }
534}