1use std::sync::{Arc, Mutex, RwLock};
24
25use crate::agent::{FinishReason, PlanningMode};
26use crate::checkpoint::{CheckpointId, ShadowRepo};
27use crate::checkpoint_log::{CheckpointLogWriter, CheckpointRecord, TouchedVia};
28use crate::error::Result;
29use crate::event::{AgentEvent, EventSink, NullSink};
30use crate::hooks::{HookEvent, HookRegistry};
31use crate::kernel::{AgentKernel, AgentKernelBuilder, TurnContext, TurnOutcome};
32use crate::llm::{LlmProvider, TokenUsage};
33use crate::message::Message;
34use crate::tools::plan_mode::{
35 EnterPlanModeTool, ExitPlanModeTool, PlanApprovalGate, PlanModeRequestGate, RequestPlanModeTool,
36};
37use crate::tools::PermissionHook;
38use crate::tools::{TodoItem, TodoWriteTool, ToolRegistry, TouchedFiles};
39use crate::Compactor;
40
41pub use crate::runtime_goal::{GoalEvaluator, GoalState, GoalStatus, GoalVerdict};
48
49#[derive(Debug, Clone)]
59pub struct RuntimeOutcome {
60 pub final_text: Option<String>,
62 pub finish_reason: FinishReason,
64 pub total_usage: TokenUsage,
66 pub steps: usize,
68 pub llm_latency_ms: u64,
70 pub checkpoint_id: Option<CheckpointId>,
73}
74
75impl From<TurnOutcome> for RuntimeOutcome {
76 fn from(t: TurnOutcome) -> Self {
77 Self {
78 final_text: t.final_text,
79 finish_reason: t.finish_reason,
80 total_usage: t.usage,
81 steps: t.steps,
82 llm_latency_ms: t.llm_latency_ms,
83 checkpoint_id: None,
84 }
85 }
86}
87
88struct CheckpointState {
97 shadow: Option<Arc<ShadowRepo>>,
98 session_id: Option<String>,
99 turn_index: usize,
101 writer: Option<CheckpointLogWriter>,
102 touched_files: Option<Arc<Mutex<TouchedFiles>>>,
103}
104
105impl CheckpointState {
106 fn disabled() -> Self {
107 Self {
108 shadow: None,
109 session_id: None,
110 turn_index: 0,
111 writer: None,
112 touched_files: None,
113 }
114 }
115
116 fn enabled(&self) -> bool {
117 self.shadow.is_some() && self.session_id.is_some()
118 }
119
120 fn snapshot_pre_turn(&self, user_text: &str) -> Option<CheckpointId> {
123 let (repo, sid) = (self.shadow.as_ref()?, self.session_id.as_ref()?);
124 let label = format!(
125 "turn {} pre: {}",
126 self.turn_index,
127 truncate_label(user_text)
128 );
129 match repo.snapshot_for_session(sid, &label) {
130 Ok(id) => Some(id),
131 Err(e) => {
132 tracing::warn!("checkpoint pre-snapshot failed: {e}");
133 None
134 }
135 }
136 }
137
138 fn snapshot_post_turn(
141 &self,
142 user_text: &str,
143 pre: Option<&CheckpointId>,
144 started_at: i64,
145 ) -> Option<CheckpointId> {
146 let (repo, sid) = (self.shadow.as_ref()?, self.session_id.as_ref()?);
147 let label = format!(
148 "turn {} post: {}",
149 self.turn_index,
150 truncate_label(user_text)
151 );
152 let post = match repo.snapshot_for_session(sid, &label) {
153 Ok(id) => id,
154 Err(e) => {
155 tracing::warn!("checkpoint post-snapshot failed: {e}");
156 return None;
157 }
158 };
159
160 let (mut paths, saw_shell) = match &self.touched_files {
161 Some(slot) => match slot.lock() {
162 Ok(t) => (t.paths_sorted(), t.saw_shell),
163 Err(_) => (vec![], false),
164 },
165 None => (vec![], true),
166 };
167
168 let mut via = TouchedVia::Structured;
169 if saw_shell {
170 via = TouchedVia::ShellDiff;
171 if let Some(pre_id) = pre {
172 if let Ok(diff_paths) = repo.changed_paths(pre_id, &post) {
173 let mut set: std::collections::HashSet<String> = paths.into_iter().collect();
174 for p in diff_paths {
175 set.insert(p);
176 }
177 let mut v: Vec<String> = set.into_iter().collect();
178 v.sort();
179 paths = v;
180 }
181 }
182 }
183
184 if let Some(writer) = &self.writer {
185 let rec = CheckpointRecord {
186 turn: self.turn_index,
187 pre: pre.cloned(),
188 post: post.clone(),
189 touched_files: paths,
190 touched_via: via,
191 started_at,
192 finished_at: unix_now(),
193 };
194 if let Err(e) = writer.append(&rec) {
195 tracing::warn!("checkpoint log append failed: {e}");
196 }
197 }
198
199 Some(post)
200 }
201}
202
203pub struct AgentRuntime {
214 kernel: AgentKernel,
216 transcript: Vec<Message>,
218 event_sink: Arc<dyn EventSink>,
220 pending_plan_calls: Option<Vec<crate::llm::ToolCall>>,
222 plan_confirmed: bool,
224 streaming: bool,
226 permission_hook: Option<Arc<dyn PermissionHook>>,
228 planning_mode: PlanningMode,
230 compactor: Option<Compactor>,
232 checkpoints: CheckpointState,
235 todo_list: Arc<RwLock<Vec<TodoItem>>>,
238 plan_approval_gate: Arc<PlanApprovalGate>,
241 plan_mode_request_gate: Arc<PlanModeRequestGate>,
244 pub goal_state: Arc<RwLock<Option<GoalState>>>,
246 message_queue: std::collections::VecDeque<String>,
252 deferred_turn_finished: Option<AgentEvent>,
255}
256
257impl std::fmt::Debug for AgentRuntime {
258 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
259 f.debug_struct("AgentRuntime")
260 .field("kernel", &self.kernel)
261 .field("transcript", &self.transcript)
262 .field("event_sink", &"<EventSink>")
263 .field("streaming", &self.streaming)
264 .field(
265 "permission_hook",
266 &self.permission_hook.as_ref().map(|_| "<hook>"),
267 )
268 .field("planning_mode", &self.planning_mode)
269 .field(
270 "todo_list",
271 &self.todo_list.read().map(|l| l.len()).unwrap_or(0),
272 )
273 .field(
274 "goal_state",
275 &self
276 .goal_state
277 .read()
278 .ok()
279 .and_then(|g| g.as_ref().map(|s| s.condition.clone())),
280 )
281 .field(
282 "deferred_turn_finished",
283 &self.deferred_turn_finished.as_ref().map(|_| "<event>"),
284 )
285 .finish()
286 }
287}
288
289impl AgentRuntime {
290 pub fn builder() -> AgentRuntimeBuilder {
292 AgentRuntimeBuilder::new()
293 }
294
295 pub async fn run(&mut self, user_text: impl Into<String>) -> Result<RuntimeOutcome> {
300 let user_text = user_text.into();
301
302 tracing::Span::current().record(
303 "session_id",
304 self.checkpoints.session_id.as_deref().unwrap_or(""),
305 );
306 tracing::debug!(
307 session_id = self.checkpoints.session_id.as_deref().unwrap_or(""),
308 turn = self.checkpoints.turn_index,
309 "agent.turn: starting"
310 );
311
312 let started_at = unix_now();
313 let pre_id = self.checkpoints.snapshot_pre_turn(&user_text);
314 self.reset_touched_files();
315 self.append_user_message(&user_text).await;
316 self.maybe_compact_cross_turn().await?;
317
318 let turn_outcome = self.execute_kernel_turn().await?;
319 self.update_plan_state(&turn_outcome);
320 self.emit_turn_messages(&turn_outcome).await;
321
322 let mut outcome: RuntimeOutcome = turn_outcome.into();
323 outcome.checkpoint_id =
324 self.checkpoints
325 .snapshot_post_turn(&user_text, pre_id.as_ref(), started_at);
326
327 tracing::info!(
328 steps = outcome.steps,
329 finish_reason = ?outcome.finish_reason,
330 "agent.turn: finished"
331 );
332 self.checkpoints.turn_index += 1;
333
334 Ok(outcome)
335 }
336
337 fn reset_touched_files(&self) {
339 if let Some(slot) = &self.checkpoints.touched_files {
340 if let Ok(mut t) = slot.lock() {
341 *t = TouchedFiles::new();
342 }
343 }
344 }
345
346 async fn append_user_message(&mut self, user_text: &str) {
348 let user_msg = Message::user(user_text.to_string());
349 self.transcript.push(user_msg.clone());
350 self.event_sink
351 .emit(AgentEvent::MessageAppended {
352 message: user_msg,
353 parent_uuid: None,
354 usage: None,
355 })
356 .await;
357 }
358
359 async fn maybe_compact_cross_turn(&mut self) -> Result<()> {
366 let Some(ref compactor) = self.compactor else {
367 return Ok(());
368 };
369 let chars = Compactor::estimate_chars(&self.transcript);
370 if chars < compactor.threshold_chars {
371 return Ok(());
372 }
373 self.kernel.hooks().dispatch(HookEvent::PreCompact {
374 transcript_len: chars,
375 });
376 let Some((removed, summary_chars)) = compactor
377 .apply_to_transcript(self.kernel.llm().as_ref(), &mut self.transcript)
378 .await?
379 else {
380 return Ok(());
381 };
382 self.kernel.hooks().dispatch(HookEvent::PostCompact {
383 removed,
384 summary_chars,
385 });
386 self.event_sink
387 .emit(AgentEvent::CompactionBoundary {
388 turn: self.checkpoints.turn_index as u32,
389 compacted_count: removed,
390 summary_uuid: None,
391 })
392 .await;
393 if let Some(summary) = self.transcript.first().cloned() {
394 self.event_sink
395 .emit(AgentEvent::MessageAppended {
396 message: summary,
397 parent_uuid: None,
398 usage: None,
399 })
400 .await;
401 }
402 Ok(())
403 }
404
405 async fn execute_kernel_turn(&mut self) -> Result<crate::kernel::TurnOutcome> {
411 let (event_tx, mut event_rx) =
412 tokio::sync::mpsc::unbounded_channel::<crate::event::AgentEvent>();
413 let sink = self.event_sink.clone();
414 let forwarder = tokio::spawn(async move {
415 let mut deferred_finished: Option<crate::event::AgentEvent> = None;
416 while let Some(ev) = event_rx.recv().await {
417 if matches!(ev, AgentEvent::TurnFinished { .. }) {
418 deferred_finished = Some(ev);
419 continue;
420 }
421 sink.emit(ev).await;
422 }
423 deferred_finished
424 });
425
426 let ctx = TurnContext {
427 messages: self.transcript.clone(),
428 tool_specs: self.kernel.tools().specs(),
429 step_events_tx: Some(event_tx.clone()),
430 plan_confirmed: self.plan_confirmed,
431 plan_buffer: self.pending_plan_calls.clone(),
432 streaming: self.streaming,
433 permission_hook: self.permission_hook.clone(),
434 planning_mode: self.planning_mode.clone(),
435 exploring_plan_mode: self.plan_approval_gate.exploring_plan_mode.clone(),
436 permission_mode: self.kernel.tools().permission_mode(),
437 mailbox: None,
438 };
439
440 let turn_outcome = self.kernel.run(ctx).await?;
441 drop(event_tx);
442 self.deferred_turn_finished = forwarder.await.ok().flatten();
444 Ok(turn_outcome)
445 }
446
447 fn update_plan_state(&mut self, outcome: &crate::kernel::TurnOutcome) {
449 if outcome.finish_reason == crate::agent::FinishReason::PlanPending {
450 self.pending_plan_calls = outcome.plan_buffer.clone();
451 }
452 let was_confirmed = self.plan_confirmed;
453 self.plan_confirmed = false;
454 if was_confirmed {
455 self.pending_plan_calls = None;
456 }
457 }
458
459 async fn emit_turn_messages(&mut self, outcome: &crate::kernel::TurnOutcome) {
463 let new_messages = outcome.new_messages.clone();
464 let turn_usage = crate::session::UsageMeta::from_token_usage(&outcome.usage);
465 let mut tool_audits = outcome.tool_audits.clone();
466 self.transcript.extend(new_messages.iter().cloned());
467 for msg in &new_messages {
468 let event = if msg.role == crate::message::Role::Tool {
469 if let Some(tcid) = &msg.tool_call_id {
470 if let Some(audit) = tool_audits.remove(tcid) {
471 AgentEvent::MessageAppendedWithAudit {
472 message: msg.clone(),
473 audit,
474 }
475 } else {
476 AgentEvent::MessageAppended {
477 message: msg.clone(),
478 parent_uuid: None,
479 usage: None,
480 }
481 }
482 } else {
483 AgentEvent::MessageAppended {
484 message: msg.clone(),
485 parent_uuid: None,
486 usage: None,
487 }
488 }
489 } else {
490 let usage = if matches!(msg.role, crate::message::Role::Assistant) {
491 Some(turn_usage.clone())
492 } else {
493 None
494 };
495 AgentEvent::MessageAppended {
496 message: msg.clone(),
497 parent_uuid: None,
498 usage,
499 }
500 };
501 self.event_sink.emit(event).await;
502 }
503 if let Some(ev) = self.deferred_turn_finished.take() {
505 self.event_sink.emit(ev).await;
506 }
507 }
508
509 pub async fn enqueue(&mut self, text: impl Into<String>) -> Result<Option<RuntimeOutcome>> {
531 self.message_queue.push_back(text.into());
532 self.drain_queue().await
533 }
534
535 async fn drain_queue(&mut self) -> Result<Option<RuntimeOutcome>> {
540 let mut last: Option<RuntimeOutcome> = None;
541 while let Some(msg) = self.message_queue.pop_front() {
542 last = Some(self.run(msg).await?);
543 }
544 Ok(last)
545 }
546
547 pub fn queue_len(&self) -> usize {
551 self.message_queue.len()
552 }
553
554 pub fn transcript(&self) -> &[Message] {
558 &self.transcript
559 }
560
561 pub fn set_transcript(&mut self, transcript: Vec<Message>) {
563 self.transcript = transcript;
564 }
565
566 pub fn truncate_transcript(&mut self, len: usize) {
570 self.transcript.truncate(len);
571 }
572
573 pub fn kernel(&self) -> &AgentKernel {
575 &self.kernel
576 }
577
578 pub fn event_sink(&self) -> &dyn EventSink {
580 self.event_sink.as_ref()
581 }
582
583 pub fn set_interrupt_token(&mut self, token: tokio_util::sync::CancellationToken) {
590 self.kernel.shutdown_token = Some(token);
591 }
592
593 pub fn set_session_id(&mut self, id: impl Into<String>) {
600 self.checkpoints.session_id = Some(id.into());
601 }
602
603 pub fn set_event_sink(&mut self, sink: Arc<dyn EventSink>) {
605 self.event_sink = sink.clone();
606 self.kernel
609 .tools_mut()
610 .register_mut(Arc::new(TodoWriteTool::new(
611 self.todo_list.clone(),
612 sink.clone(),
613 )));
614 self.kernel
617 .tools_mut()
618 .register_mut(Arc::new(ExitPlanModeTool::new(
619 self.plan_approval_gate.clone(),
620 sink,
621 )));
622 }
623
624 pub fn current_todos(&self) -> Vec<TodoItem> {
629 self.todo_list.read().map(|l| l.clone()).unwrap_or_default()
630 }
631
632 pub fn set_permission_hook(&mut self, hook: Arc<dyn crate::tools::PermissionHook>) {
636 self.kernel.tools_mut().set_permission_hook(hook);
637 }
638
639 pub fn plan_approval_gate(&self) -> Arc<PlanApprovalGate> {
645 self.plan_approval_gate.clone()
646 }
647
648 pub fn confirm_plan(&mut self) {
653 self.plan_confirmed = true;
654 self.plan_approval_gate.approve();
655 }
656
657 pub async fn compact_now(&mut self) -> Result<()> {
665 let Some(ref compactor) = self.compactor else {
666 return Ok(());
667 };
668 compactor
669 .apply_to_transcript(self.kernel.llm().as_ref(), &mut self.transcript)
670 .await?;
671 Ok(())
672 }
673
674 pub fn set_planning_mode(&mut self, mode: PlanningMode) {
678 self.planning_mode = mode;
679 }
680
681 pub fn approve_plan_mode_request(&self) {
686 self.plan_mode_request_gate.approve();
687 }
688
689 pub fn reject_plan_mode_request(&self, reason: &str) {
694 self.plan_mode_request_gate.reject(reason);
695 }
696
697 pub fn reject_plan(&mut self, reason: &str) {
702 self.pending_plan_calls = None;
704 self.plan_confirmed = false;
705 let rejection_msg = Message::user(format!("Plan rejected: {}", reason));
706 self.transcript.push(rejection_msg);
707
708 self.plan_approval_gate.reject(reason);
710 }
711
712 pub fn current_goal(&self) -> Option<GoalState> {
716 self.goal_state.read().ok().and_then(|g| g.clone())
717 }
718
719 pub async fn set_goal(&self, condition: String, max_turns: u32) {
721 let state = GoalState {
722 condition: condition.clone(),
723 status: GoalStatus::Pursuing,
724 turns: 0,
725 max_turns,
726 last_reason: None,
727 };
728 if let Ok(mut g) = self.goal_state.write() {
729 *g = Some(state);
730 }
731 self.event_sink
732 .emit(AgentEvent::GoalSet {
733 condition,
734 max_turns,
735 })
736 .await;
737 }
738
739 pub async fn clear_goal(&self) {
741 if let Ok(mut g) = self.goal_state.write() {
742 if let Some(ref mut s) = *g {
743 s.status = GoalStatus::Cleared;
744 }
745 *g = None;
746 }
747 self.event_sink.emit(AgentEvent::GoalCleared).await;
748 }
749
750 pub async fn run_goal_loop(
761 &mut self,
762 initial_prompt: impl Into<String>,
763 condition: impl Into<String>,
764 max_turns: u32,
765 ) -> Result<Vec<RuntimeOutcome>> {
766 let condition = condition.into();
767 self.set_goal(condition.clone(), max_turns).await;
768
769 let evaluator = GoalEvaluator::new(self.kernel.llm().clone());
770 let mut outcomes = Vec::new();
771 let mut next_prompt = initial_prompt.into();
772
773 loop {
774 let active = self
776 .goal_state
777 .read()
778 .ok()
779 .and_then(|g| g.clone())
780 .map(|g| g.status == GoalStatus::Pursuing)
781 .unwrap_or(false);
782 if !active {
783 break;
784 }
785
786 let outcome = self.run(&next_prompt).await?;
787 outcomes.push(outcome);
788
789 let turns = {
791 let mut guard = self.goal_state.write().ok();
792 if let Some(ref mut guard) = guard {
793 if let Some(ref mut gs) = **guard {
794 gs.turns += 1;
795 gs.turns
796 } else {
797 break; }
799 } else {
800 break;
801 }
802 };
803
804 if turns >= max_turns {
806 if let Ok(mut g) = self.goal_state.write() {
807 if let Some(ref mut gs) = *g {
808 gs.status = GoalStatus::Cleared;
809 }
810 *g = None;
811 }
812 self.event_sink.emit(AgentEvent::GoalCleared).await;
813 tracing::warn!(
814 "goal loop: turn budget of {max_turns} exceeded without achieving condition"
815 );
816 break;
817 }
818
819 let verdict = evaluator.evaluate(&condition, self.transcript()).await?;
821 if verdict.achieved {
822 if let Ok(mut g) = self.goal_state.write() {
823 if let Some(ref mut gs) = *g {
824 gs.status = GoalStatus::Achieved;
825 gs.last_reason = Some(verdict.reason.clone());
826 }
827 *g = None;
828 }
829 self.event_sink
830 .emit(AgentEvent::GoalAchieved {
831 condition: condition.clone(),
832 turns,
833 })
834 .await;
835 break;
836 } else {
837 if let Ok(mut g) = self.goal_state.write() {
839 if let Some(ref mut gs) = *g {
840 gs.last_reason = Some(verdict.reason.clone());
841 }
842 }
843 self.event_sink
844 .emit(AgentEvent::GoalContinuing {
845 reason: verdict.reason.clone(),
846 turns,
847 })
848 .await;
849
850 next_prompt = format!(
851 "(Goal: {condition})\n\nPrevious attempt reason: {}\n\nContinue.",
852 verdict.reason
853 );
854 }
855 }
856
857 Ok(outcomes)
858 }
859
860 pub async fn run_loop(
868 &mut self,
869 initial_goal: impl Into<String>,
870 wakeup_slot: &crate::tools::WakeupSlot,
871 ) -> Result<Vec<RuntimeOutcome>> {
872 let mut outcomes = Vec::new();
873 let mut next_goal = initial_goal.into();
874
875 loop {
876 let outcome = self.run(&next_goal).await?;
877 outcomes.push(outcome);
878
879 let wakeup = wakeup_slot.lock().ok().and_then(|mut slot| slot.take());
881
882 match wakeup {
883 Some(req) => {
884 tokio::time::sleep(req.delay).await;
885 next_goal = req.prompt;
886 }
887 None => break,
888 }
889 }
890 Ok(outcomes)
891 }
892
893 pub async fn run_event_loop(
903 &mut self,
904 initial_goal: impl Into<String>,
905 wakeup_slot: &crate::tools::WakeupSlot,
906 bg_manager: Option<&tokio::sync::Mutex<crate::tools::BackgroundJobManager>>,
907 ) -> Result<Vec<RuntimeOutcome>> {
908 let mut outcomes = Vec::new();
909 let mut next_goal = initial_goal.into();
910
911 loop {
912 let outcome = self.run(&next_goal).await?;
913 outcomes.push(outcome);
914
915 let wakeup = wakeup_slot.lock().ok().and_then(|mut slot| slot.take());
917 if let Some(req) = wakeup {
918 tokio::time::sleep(req.delay).await;
919 next_goal = req.prompt;
920 continue;
921 }
922
923 if let Some(mgr) = bg_manager {
925 if let Ok(mut mgr) = mgr.try_lock() {
926 if let Some((id, output)) = mgr.take_completed() {
927 next_goal = format!("Background job '{}' completed:\n{}", id, output);
928 continue;
929 }
930 }
931 }
932
933 break;
935 }
936 Ok(outcomes)
937 }
938
939 pub fn enable_checkpoints(
959 &mut self,
960 shadow: Arc<ShadowRepo>,
961 session_id: impl Into<String>,
962 log_path: std::path::PathBuf,
963 touched_slot: Option<Arc<Mutex<TouchedFiles>>>,
964 ) -> Result<()> {
965 let writer = CheckpointLogWriter::open(&log_path)?;
966 let session_id = session_id.into();
967
968 let tool_repo = Arc::new(Mutex::new(ShadowRepo::clone(&shadow)));
973 let ctx = crate::tools::CheckpointToolCtx {
974 repo: tool_repo,
975 session_id: session_id.clone(),
976 };
977 let tools = self.kernel.tools_mut();
978 tools.register_mut(Arc::new(crate::tools::CheckpointList::new(ctx.clone())));
979 tools.register_mut(Arc::new(crate::tools::CheckpointDiff::new(ctx)));
980
981 self.checkpoints.shadow = Some(shadow);
982 self.checkpoints.session_id = Some(session_id);
983 self.checkpoints.writer = Some(writer);
984 self.checkpoints.touched_files = touched_slot;
985 Ok(())
986 }
987
988 pub fn checkpoints_enabled(&self) -> bool {
990 self.checkpoints.enabled()
991 }
992
993 pub fn turn_index(&self) -> usize {
996 self.checkpoints.turn_index
997 }
998}
999
1000fn unix_now() -> i64 {
1002 std::time::SystemTime::now()
1003 .duration_since(std::time::UNIX_EPOCH)
1004 .map(|d| d.as_secs() as i64)
1005 .unwrap_or(0)
1006}
1007
1008fn truncate_label(s: &str) -> String {
1010 const MAX: usize = 120;
1011 let trimmed: String = s.lines().next().unwrap_or("").chars().take(MAX).collect();
1012 if trimmed.len() < s.len() {
1013 format!("{trimmed}…")
1014 } else {
1015 trimmed
1016 }
1017}
1018
1019pub struct AgentRuntimeBuilder {
1030 kernel_builder: AgentKernelBuilder,
1031 system_prompt: Option<String>,
1032 seed: Vec<Message>,
1033 streaming: bool,
1034 permission_hook: Option<Arc<dyn PermissionHook>>,
1035 planning_mode: PlanningMode,
1036 saved_event_sink: Option<Arc<dyn EventSink>>,
1037 compactor: Option<Compactor>,
1038 pub parent_agent_last_uuid: Option<String>,
1044}
1045
1046impl std::fmt::Debug for AgentRuntimeBuilder {
1047 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1048 f.debug_struct("AgentRuntimeBuilder")
1049 .field("kernel_builder", &self.kernel_builder)
1050 .field("system_prompt", &self.system_prompt)
1051 .field("seed", &self.seed)
1052 .field("streaming", &self.streaming)
1053 .field(
1054 "permission_hook",
1055 &self.permission_hook.as_ref().map(|_| "<hook>"),
1056 )
1057 .field("planning_mode", &self.planning_mode)
1058 .field(
1059 "event_sink",
1060 &self.saved_event_sink.as_ref().map(|_| "<EventSink>"),
1061 )
1062 .finish()
1063 }
1064}
1065
1066impl Default for AgentRuntimeBuilder {
1067 fn default() -> Self {
1068 Self::new()
1069 }
1070}
1071
1072impl AgentRuntimeBuilder {
1073 pub fn new() -> Self {
1075 Self {
1076 kernel_builder: AgentKernelBuilder::default(),
1077 system_prompt: None,
1078 seed: Vec::new(),
1079 streaming: false,
1080 permission_hook: None,
1081 planning_mode: PlanningMode::Immediate,
1082 saved_event_sink: None,
1083 compactor: None,
1084 parent_agent_last_uuid: None,
1085 }
1086 }
1087
1088 pub fn parent_agent_last_uuid(mut self, uuid: impl Into<String>) -> Self {
1095 self.parent_agent_last_uuid = Some(uuid.into());
1096 self
1097 }
1098
1099 pub fn llm(mut self, llm: Arc<dyn LlmProvider>) -> Self {
1101 self.kernel_builder = self.kernel_builder.llm(llm);
1102 self
1103 }
1104
1105 pub fn tools(mut self, tools: ToolRegistry) -> Self {
1107 self.kernel_builder = self.kernel_builder.tools(tools);
1108 self
1109 }
1110
1111 pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
1115 self.system_prompt = Some(prompt.into());
1116 self
1117 }
1118
1119 pub fn max_steps(mut self, n: usize) -> Self {
1121 self.kernel_builder = self.kernel_builder.max_steps(n);
1122 self
1123 }
1124
1125 pub fn max_transcript_chars(mut self, n: usize) -> Self {
1127 self.kernel_builder = self.kernel_builder.max_transcript_chars(n);
1128 self
1129 }
1130
1131 pub fn compactor(mut self, compactor: Compactor) -> Self {
1133 self.kernel_builder = self.kernel_builder.compactor(compactor.clone());
1137 self.compactor = Some(compactor);
1138 self
1139 }
1140
1141 pub fn streaming(mut self, enabled: bool) -> Self {
1143 self.streaming = enabled;
1144 self
1145 }
1146
1147 pub fn planning_mode(mut self, mode: PlanningMode) -> Self {
1149 self.planning_mode = mode;
1150 self
1151 }
1152
1153 pub fn hooks(mut self, hooks: HookRegistry) -> Self {
1155 self.kernel_builder = self.kernel_builder.hooks(hooks);
1156 self
1157 }
1158
1159 pub fn seed_transcript(mut self, messages: Vec<Message>) -> Self {
1164 self.seed = messages;
1165 self
1166 }
1167
1168 pub fn permission_hook(mut self, hook: Arc<dyn PermissionHook>) -> Self {
1170 self.permission_hook = Some(hook);
1171 self
1172 }
1173
1174 pub fn event_sink(mut self, sink: Arc<dyn EventSink>) -> Self {
1176 self.saved_event_sink = Some(sink);
1177 self
1178 }
1179
1180 pub fn shutdown_token(mut self, token: tokio_util::sync::CancellationToken) -> Self {
1186 self.kernel_builder = self.kernel_builder.shutdown_token(token);
1187 self
1188 }
1189
1190 pub fn build(self) -> Result<AgentRuntime> {
1194 let mut kernel = self.kernel_builder.build()?;
1195
1196 let mut transcript = Vec::new();
1197 if let Some(sys) = self.system_prompt {
1198 transcript.push(Message::system(sys));
1199 }
1200 transcript.extend(self.seed);
1201
1202 let event_sink: Arc<dyn EventSink> =
1203 self.saved_event_sink.unwrap_or_else(|| Arc::new(NullSink));
1204
1205 let todo_list = Arc::new(RwLock::new(Vec::<TodoItem>::new()));
1208 kernel.tools_mut().register_mut(Arc::new(TodoWriteTool::new(
1209 todo_list.clone(),
1210 event_sink.clone(),
1211 )));
1212
1213 let plan_approval_gate = Arc::new(PlanApprovalGate::new());
1215 let permissions_arc = kernel.tools().permissions_config().map(Arc::new);
1218 kernel.tools_mut().register_mut({
1219 let mut tool = EnterPlanModeTool::new(plan_approval_gate.clone());
1220 if let Some(ref perms) = permissions_arc {
1221 tool = tool.with_permissions(perms.clone());
1222 }
1223 Arc::new(tool)
1224 });
1225 kernel.tools_mut().register_mut({
1226 let mut tool = ExitPlanModeTool::new(plan_approval_gate.clone(), event_sink.clone());
1227 if let Some(ref perms) = permissions_arc {
1228 tool = tool.with_permissions(perms.clone());
1229 }
1230 Arc::new(tool)
1231 });
1232
1233 let plan_mode_request_gate = Arc::new(PlanModeRequestGate::new());
1236 kernel
1237 .tools_mut()
1238 .register_mut(Arc::new(RequestPlanModeTool::new(
1239 plan_mode_request_gate.clone(),
1240 event_sink.clone(),
1241 )));
1242
1243 Ok(AgentRuntime {
1244 kernel,
1245 transcript,
1246 event_sink,
1247 pending_plan_calls: None,
1248 plan_confirmed: false,
1249 streaming: self.streaming,
1250 permission_hook: self.permission_hook,
1251 planning_mode: self.planning_mode,
1252 compactor: self.compactor,
1253 checkpoints: CheckpointState::disabled(),
1254 todo_list,
1255 plan_approval_gate,
1256 plan_mode_request_gate,
1257 goal_state: Arc::new(RwLock::new(None)),
1258 message_queue: std::collections::VecDeque::new(),
1259 deferred_turn_finished: None,
1260 })
1261 }
1262}
1263
1264#[cfg(test)]
1269mod tests {
1270 use super::*;
1271 use crate::llm::{Completion, MockProvider};
1272 use crate::tools::Tool;
1273 use async_trait::async_trait;
1274 use serde_json::{json, Value};
1275
1276 struct Adder;
1277
1278 #[async_trait]
1279 impl Tool for Adder {
1280 fn spec(&self) -> crate::llm::ToolSpec {
1281 crate::llm::ToolSpec {
1282 name: "add".into(),
1283 description: "add two numbers".into(),
1284 parameters: json!({"type":"object","properties":{"a":{"type":"integer"},"b":{"type":"integer"}}}),
1285 }
1286 }
1287 async fn execute(&self, args: Value) -> crate::error::Result<String> {
1288 let a = args["a"].as_i64().unwrap_or(0);
1289 let b = args["b"].as_i64().unwrap_or(0);
1290 Ok((a + b).to_string())
1291 }
1292 }
1293
1294 #[tokio::test]
1297 async fn single_turn_no_tools() {
1298 let llm = Arc::new(MockProvider::new(vec![Completion {
1299 content: "Hello!".into(),
1300 tool_calls: vec![],
1301 finish_reason: Some("stop".into()),
1302 usage: None,
1303 reasoning_content: None,
1304 }]));
1305 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1306 let out = rt.run("hi").await.unwrap();
1307 assert_eq!(out.final_text.as_deref(), Some("Hello!"));
1308 assert_eq!(out.steps, 1);
1309 assert_eq!(rt.transcript().len(), 2); }
1311
1312 #[tokio::test]
1313 async fn turn_with_tool() {
1314 let llm = Arc::new(MockProvider::new(vec![
1315 Completion {
1316 content: "Let me check...".into(),
1317 tool_calls: vec![crate::llm::ToolCall {
1318 id: "c1".into(),
1319 name: "add".into(),
1320 arguments: json!({"a": 3, "b": 4}),
1321 }],
1322 finish_reason: Some("tool_calls".into()),
1323 usage: None,
1324 reasoning_content: None,
1325 },
1326 Completion {
1327 content: "7".into(),
1328 tool_calls: vec![],
1329 finish_reason: Some("stop".into()),
1330 usage: None,
1331 reasoning_content: None,
1332 },
1333 ]));
1334 let tools = ToolRegistry::local().register(Arc::new(Adder));
1335 let mut rt = AgentRuntime::builder()
1336 .llm(llm)
1337 .tools(tools)
1338 .build()
1339 .unwrap();
1340 let out = rt.run("3+4?").await.unwrap();
1341 assert_eq!(out.final_text.as_deref(), Some("7"));
1342 assert_eq!(out.steps, 2);
1343 assert_eq!(rt.transcript().len(), 4); }
1345
1346 #[tokio::test]
1349 async fn multi_turn_transcript_grows() {
1350 let llm = Arc::new(MockProvider::new(vec![
1351 Completion {
1352 content: "First reply".into(),
1353 tool_calls: vec![],
1354 finish_reason: Some("stop".into()),
1355 usage: None,
1356 reasoning_content: None,
1357 },
1358 Completion {
1359 content: "Second reply".into(),
1360 tool_calls: vec![],
1361 finish_reason: Some("stop".into()),
1362 usage: None,
1363 reasoning_content: None,
1364 },
1365 ]));
1366 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1367
1368 let o1 = rt.run("turn 1").await.unwrap();
1369 assert_eq!(o1.final_text.as_deref(), Some("First reply"));
1370 assert_eq!(rt.transcript().len(), 2);
1371
1372 let o2 = rt.run("turn 2").await.unwrap();
1373 assert_eq!(o2.final_text.as_deref(), Some("Second reply"));
1374 assert_eq!(rt.transcript().len(), 4);
1375 }
1376
1377 #[tokio::test]
1380 async fn system_prompt_is_prepended() {
1381 let llm = Arc::new(MockProvider::new(vec![Completion {
1382 content: "ok".into(),
1383 tool_calls: vec![],
1384 finish_reason: Some("stop".into()),
1385 usage: None,
1386 reasoning_content: None,
1387 }]));
1388 let mut rt = AgentRuntime::builder()
1389 .llm(llm)
1390 .system_prompt("Be helpful.")
1391 .build()
1392 .unwrap();
1393 rt.run("hello").await.unwrap();
1394 assert_eq!(rt.transcript()[0].role, crate::message::Role::System);
1395 assert_eq!(rt.transcript()[0].content, "Be helpful.");
1396 }
1397
1398 #[tokio::test]
1399 async fn seed_transcript_is_included() {
1400 let seed = vec![
1401 Message::user("old Q".to_string()),
1402 Message::assistant("old A".to_string()),
1403 ];
1404 let llm = Arc::new(MockProvider::new(vec![Completion {
1405 content: "fresh".into(),
1406 tool_calls: vec![],
1407 finish_reason: Some("stop".into()),
1408 usage: None,
1409 reasoning_content: None,
1410 }]));
1411 let mut rt = AgentRuntime::builder()
1412 .llm(llm)
1413 .seed_transcript(seed)
1414 .build()
1415 .unwrap();
1416 rt.run("new Q").await.unwrap();
1417 assert_eq!(rt.transcript().len(), 4);
1419 assert_eq!(rt.transcript()[0].content, "old Q");
1420 assert_eq!(rt.transcript()[1].content, "old A");
1421 assert_eq!(rt.transcript()[2].content, "new Q");
1422 assert_eq!(rt.transcript()[3].content, "fresh");
1423 }
1424
1425 #[tokio::test]
1426 async fn system_and_seed_ordering() {
1427 let seed = vec![Message::user("seeded user".to_string())];
1428 let llm = Arc::new(MockProvider::new(vec![Completion {
1429 content: "r".into(),
1430 tool_calls: vec![],
1431 finish_reason: Some("stop".into()),
1432 usage: None,
1433 reasoning_content: None,
1434 }]));
1435 let mut rt = AgentRuntime::builder()
1436 .llm(llm)
1437 .system_prompt("sys prompt")
1438 .seed_transcript(seed)
1439 .build()
1440 .unwrap();
1441 rt.run("real").await.unwrap();
1442 assert_eq!(rt.transcript()[0].role, crate::message::Role::System);
1443 assert_eq!(rt.transcript()[0].content, "sys prompt");
1444 assert_eq!(rt.transcript()[1].content, "seeded user");
1445 assert_eq!(rt.transcript()[2].content, "real");
1446 }
1447
1448 #[tokio::test]
1451 async fn set_transcript_replaces() {
1452 let llm = Arc::new(MockProvider::new(vec![Completion {
1453 content: "ok".into(),
1454 tool_calls: vec![],
1455 finish_reason: Some("stop".into()),
1456 usage: None,
1457 reasoning_content: None,
1458 }]));
1459 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1460 rt.set_transcript(vec![Message::user("custom".to_string())]);
1461 assert_eq!(rt.transcript().len(), 1);
1462 assert_eq!(rt.transcript()[0].content, "custom");
1463 }
1464
1465 #[tokio::test]
1466 async fn kernel_accessor_works() {
1467 let llm = Arc::new(MockProvider::new(vec![Completion {
1468 content: "ok".into(),
1469 tool_calls: vec![],
1470 finish_reason: Some("stop".into()),
1471 usage: None,
1472 reasoning_content: None,
1473 }]));
1474 let rt = AgentRuntime::builder().llm(llm).build().unwrap();
1475 let _kernel = rt.kernel(); }
1477
1478 #[tokio::test]
1481 async fn defaults_are_sensible() {
1482 let llm = Arc::new(MockProvider::new(vec![Completion {
1483 content: "done".into(),
1484 tool_calls: vec![],
1485 finish_reason: Some("stop".into()),
1486 usage: None,
1487 reasoning_content: None,
1488 }]));
1489 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1490 let out = rt.run("test").await.unwrap();
1491 assert_eq!(out.finish_reason, FinishReason::NoMoreToolCalls);
1492 assert_eq!(rt.transcript().len(), 2);
1493 }
1494
1495 fn has_git() -> bool {
1498 std::process::Command::new("git")
1499 .arg("--version")
1500 .output()
1501 .is_ok()
1502 }
1503
1504 struct ShadowWs {
1508 workspace: tempfile::TempDir,
1509 shadow: tempfile::TempDir,
1510 }
1511
1512 impl ShadowWs {
1513 fn path(&self) -> &std::path::Path {
1514 self.workspace.path()
1515 }
1516 fn shadow_dir(&self) -> std::path::PathBuf {
1517 self.shadow.path().join("shadow-git")
1518 }
1519 }
1520
1521 fn shadow_ws() -> ShadowWs {
1522 ShadowWs {
1523 workspace: tempfile::tempdir().expect("workspace tempdir"),
1524 shadow: tempfile::tempdir().expect("shadow tempdir"),
1525 }
1526 }
1527
1528 #[tokio::test]
1529 async fn runtime_snapshots_at_turn_boundaries() {
1530 if !has_git() {
1531 return;
1532 }
1533 let dir = shadow_ws();
1534 std::fs::write(dir.path().join("seed.txt"), "v0").unwrap();
1535
1536 let llm = Arc::new(MockProvider::new(vec![
1537 Completion {
1538 content: "ok".into(),
1539 tool_calls: vec![],
1540 finish_reason: Some("stop".into()),
1541 usage: None,
1542 reasoning_content: None,
1543 },
1544 Completion {
1545 content: "ok2".into(),
1546 tool_calls: vec![],
1547 finish_reason: Some("stop".into()),
1548 usage: None,
1549 reasoning_content: None,
1550 },
1551 ]));
1552 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1553
1554 let shadow = Arc::new(crate::ShadowRepo::open_at(dir.path(), dir.shadow_dir()).unwrap());
1555 let log_path = dir.path().join("checkpoints.jsonl");
1556 rt.enable_checkpoints(shadow.clone(), "sess", log_path.clone(), None)
1557 .unwrap();
1558
1559 let o1 = rt.run("turn 0").await.unwrap();
1560 assert!(o1.checkpoint_id.is_some());
1561 let o2 = rt.run("turn 1").await.unwrap();
1562 assert!(o2.checkpoint_id.is_some());
1563 assert_ne!(o1.checkpoint_id, o2.checkpoint_id);
1564
1565 let recs = crate::read_checkpoint_log(&log_path).unwrap();
1566 assert_eq!(recs.len(), 2);
1567 assert_eq!(recs[0].turn, 0);
1568 assert_eq!(recs[1].turn, 1);
1569 assert!(recs[0].pre.is_some());
1571 assert!(recs[1].pre.is_some());
1572 }
1573
1574 #[tokio::test]
1575 async fn runtime_records_touched_files_for_write_file() {
1576 if !has_git() {
1577 return;
1578 }
1579 let dir = shadow_ws();
1580 let llm = Arc::new(MockProvider::new(vec![
1582 Completion {
1583 content: "writing".into(),
1584 tool_calls: vec![crate::llm::ToolCall {
1585 id: "c1".into(),
1586 name: "write_file".into(),
1587 arguments: json!({"path": "out.txt", "contents": "hello"}),
1588 }],
1589 finish_reason: Some("tool_calls".into()),
1590 usage: None,
1591 reasoning_content: None,
1592 },
1593 Completion {
1594 content: "done".into(),
1595 tool_calls: vec![],
1596 finish_reason: Some("stop".into()),
1597 usage: None,
1598 reasoning_content: None,
1599 },
1600 ]));
1601
1602 let touched = Arc::new(Mutex::new(TouchedFiles::new()));
1603 let tools = ToolRegistry::local()
1604 .register(Arc::new(crate::tools::WriteFile::new(dir.path())))
1605 .with_touched_files(touched.clone());
1606
1607 let mut rt = AgentRuntime::builder()
1608 .llm(llm)
1609 .tools(tools)
1610 .build()
1611 .unwrap();
1612 let shadow = Arc::new(crate::ShadowRepo::open_at(dir.path(), dir.shadow_dir()).unwrap());
1613 let log_path = dir.path().join("checkpoints.jsonl");
1614 rt.enable_checkpoints(shadow, "sess", log_path.clone(), Some(touched))
1615 .unwrap();
1616
1617 let _ = rt.run("please write").await.unwrap();
1618
1619 let recs = crate::read_checkpoint_log(&log_path).unwrap();
1620 assert_eq!(recs.len(), 1);
1621 assert_eq!(recs[0].touched_files, vec!["out.txt".to_string()]);
1622 assert_eq!(recs[0].touched_via, crate::TouchedVia::Structured);
1623 }
1624
1625 #[tokio::test]
1626 #[cfg_attr(target_os = "windows", ignore)]
1627 async fn runtime_falls_back_to_diff_for_run_shell() {
1628 if !has_git() {
1629 return;
1630 }
1631 let dir = shadow_ws();
1632
1633 let llm = Arc::new(MockProvider::new(vec![
1634 Completion {
1635 content: "shelling".into(),
1636 tool_calls: vec![crate::llm::ToolCall {
1637 id: "c1".into(),
1638 name: "run_shell".into(),
1639 arguments: json!({"command": "echo created > made.txt"}),
1640 }],
1641 finish_reason: Some("tool_calls".into()),
1642 usage: None,
1643 reasoning_content: None,
1644 },
1645 Completion {
1646 content: "done".into(),
1647 tool_calls: vec![],
1648 finish_reason: Some("stop".into()),
1649 usage: None,
1650 reasoning_content: None,
1651 },
1652 ]));
1653
1654 let touched = Arc::new(Mutex::new(TouchedFiles::new()));
1655 let tools = ToolRegistry::local()
1656 .register(Arc::new(crate::tools::RunShell::new(dir.path())))
1657 .with_touched_files(touched.clone());
1658
1659 let mut rt = AgentRuntime::builder()
1660 .llm(llm)
1661 .tools(tools)
1662 .build()
1663 .unwrap();
1664 let shadow = Arc::new(crate::ShadowRepo::open_at(dir.path(), dir.shadow_dir()).unwrap());
1665 let log_path = dir.path().join("checkpoints.jsonl");
1666 rt.enable_checkpoints(shadow, "sess", log_path.clone(), Some(touched))
1667 .unwrap();
1668
1669 let _ = rt.run("please make a file").await.unwrap();
1670
1671 let recs = crate::read_checkpoint_log(&log_path).unwrap();
1672 assert_eq!(recs.len(), 1);
1673 assert_eq!(recs[0].touched_via, crate::TouchedVia::ShellDiff);
1674 assert!(
1676 recs[0].touched_files.iter().any(|p| p == "made.txt"),
1677 "expected made.txt in touched_files, got {:?}",
1678 recs[0].touched_files
1679 );
1680 }
1681
1682 #[tokio::test]
1683 async fn runtime_works_when_checkpoints_disabled() {
1684 let llm = Arc::new(MockProvider::new(vec![Completion {
1687 content: "ok".into(),
1688 tool_calls: vec![],
1689 finish_reason: Some("stop".into()),
1690 usage: None,
1691 reasoning_content: None,
1692 }]));
1693 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1694 let out = rt.run("hi").await.unwrap();
1695 assert!(out.checkpoint_id.is_none());
1696 assert!(!rt.checkpoints_enabled());
1697 }
1698
1699 #[tokio::test]
1702 async fn compact_now_invokes_compactor() {
1703 let llm = Arc::new(MockProvider::new(vec![
1706 Completion {
1707 content: "first reply".into(),
1708 tool_calls: vec![],
1709 finish_reason: Some("stop".into()),
1710 usage: None,
1711 reasoning_content: None,
1712 },
1713 Completion {
1714 content: "second reply".into(),
1715 tool_calls: vec![],
1716 finish_reason: Some("stop".into()),
1717 usage: None,
1718 reasoning_content: None,
1719 },
1720 Completion {
1721 content: "compacted summary".into(),
1722 tool_calls: vec![],
1723 finish_reason: Some("stop".into()),
1724 usage: None,
1725 reasoning_content: None,
1726 },
1727 ]));
1728 let compactor = crate::compact::Compactor::new(usize::MAX).keep_recent_n(1);
1732 let mut rt = AgentRuntime::builder()
1733 .llm(llm)
1734 .compactor(compactor)
1735 .build()
1736 .unwrap();
1737 rt.run("turn 1").await.unwrap();
1738 rt.run("turn 2").await.unwrap();
1739 let len_before = rt.transcript().len();
1740 assert!(len_before >= 3, "expected ≥3 messages, got {len_before}");
1741
1742 rt.compact_now().await.unwrap();
1743 assert_eq!(rt.transcript().len(), 2);
1746 assert_eq!(rt.transcript()[0].role, crate::message::Role::System);
1747 assert!(rt.transcript()[0].content.starts_with("[compacted:"));
1748 }
1749
1750 #[tokio::test]
1751 async fn compact_now_is_noop_without_compactor() {
1752 let llm = Arc::new(MockProvider::new(vec![Completion {
1753 content: "x".into(),
1754 tool_calls: vec![],
1755 finish_reason: Some("stop".into()),
1756 usage: None,
1757 reasoning_content: None,
1758 }]));
1759 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1760 rt.run("hi").await.unwrap();
1761 let before = rt.transcript().len();
1762 rt.compact_now().await.unwrap();
1763 assert_eq!(rt.transcript().len(), before);
1764 }
1765
1766 #[tokio::test]
1767 async fn set_planning_mode_updates_field() {
1768 let llm = Arc::new(MockProvider::new(vec![Completion {
1769 content: "ok".into(),
1770 tool_calls: vec![],
1771 finish_reason: Some("stop".into()),
1772 usage: None,
1773 reasoning_content: None,
1774 }]));
1775 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1776 assert_eq!(rt.planning_mode, PlanningMode::Immediate);
1778 rt.set_planning_mode(PlanningMode::PlanFirst);
1779 assert_eq!(rt.planning_mode, PlanningMode::PlanFirst);
1780 rt.set_planning_mode(PlanningMode::Immediate);
1781 assert_eq!(rt.planning_mode, PlanningMode::Immediate);
1782 }
1783
1784 #[tokio::test]
1787 async fn set_goal_stores_state() {
1788 let llm = Arc::new(MockProvider::new(vec![]));
1789 let rt = AgentRuntime::builder().llm(llm).build().unwrap();
1790 assert!(rt.current_goal().is_none());
1791 rt.set_goal("task is done".to_string(), 10).await;
1792 let g = rt.current_goal().expect("goal should be set");
1793 assert_eq!(g.condition, "task is done");
1794 assert_eq!(g.max_turns, 10);
1795 assert_eq!(g.turns, 0);
1796 assert_eq!(g.status, GoalStatus::Pursuing);
1797 }
1798
1799 #[tokio::test]
1800 async fn clear_goal_removes_state() {
1801 let llm = Arc::new(MockProvider::new(vec![]));
1802 let rt = AgentRuntime::builder().llm(llm).build().unwrap();
1803 rt.set_goal("anything".to_string(), 5).await;
1804 assert!(rt.current_goal().is_some());
1805 rt.clear_goal().await;
1806 assert!(rt.current_goal().is_none());
1807 }
1808
1809 #[tokio::test]
1810 async fn goal_status_default_is_pursuing() {
1811 let g = GoalState {
1812 condition: "done".to_string(),
1813 status: GoalStatus::Pursuing,
1814 turns: 0,
1815 max_turns: 20,
1816 last_reason: None,
1817 };
1818 assert_eq!(g.status, GoalStatus::Pursuing);
1819 assert_eq!(g.turns, 0);
1820 assert!(g.last_reason.is_none());
1821 }
1822
1823 #[tokio::test]
1824 async fn goal_evaluator_returns_achieved_on_yes_response() {
1825 let llm = Arc::new(MockProvider::new(vec![Completion {
1827 content: "YES\nLooks complete.".into(),
1828 tool_calls: vec![],
1829 finish_reason: Some("stop".into()),
1830 usage: None,
1831 reasoning_content: None,
1832 }]));
1833 let evaluator = GoalEvaluator::new(llm);
1834 let msgs = vec![crate::message::Message::user("I completed the task.")];
1835 let verdict = evaluator
1836 .evaluate("task is done", &msgs)
1837 .await
1838 .expect("evaluate should succeed");
1839 assert!(verdict.achieved);
1840 assert!(!verdict.reason.is_empty());
1841 }
1842
1843 #[tokio::test]
1844 async fn goal_evaluator_returns_not_achieved_on_no_response() {
1845 let llm = Arc::new(MockProvider::new(vec![Completion {
1846 content: "NO\nStill in progress.".into(),
1847 tool_calls: vec![],
1848 finish_reason: Some("stop".into()),
1849 usage: None,
1850 reasoning_content: None,
1851 }]));
1852 let evaluator = GoalEvaluator::new(llm);
1853 let msgs = vec![crate::message::Message::user("I started the task.")];
1854 let verdict = evaluator
1855 .evaluate("task is done", &msgs)
1856 .await
1857 .expect("evaluate should succeed");
1858 assert!(!verdict.achieved);
1859 }
1860
1861 #[tokio::test]
1862 async fn goal_evaluator_tolerates_empty_transcript() {
1863 let llm = Arc::new(MockProvider::new(vec![Completion {
1864 content: "YES\nEmpty transcript but condition trivially met.".into(),
1865 tool_calls: vec![],
1866 finish_reason: Some("stop".into()),
1867 usage: None,
1868 reasoning_content: None,
1869 }]));
1870 let evaluator = GoalEvaluator::new(llm);
1871 let verdict = evaluator
1872 .evaluate("anything", &[])
1873 .await
1874 .expect("should not error on empty transcript");
1875 assert!(verdict.achieved);
1876 }
1877
1878 #[tokio::test]
1879 async fn run_goal_loop_stops_when_achieved() {
1880 let llm = Arc::new(MockProvider::new(vec![
1882 Completion {
1883 content: "I wrote the greeting.".into(),
1884 tool_calls: vec![],
1885 finish_reason: Some("stop".into()),
1886 usage: None,
1887 reasoning_content: None,
1888 },
1889 Completion {
1890 content: "YES\nGreeting was written.".into(),
1891 tool_calls: vec![],
1892 finish_reason: Some("stop".into()),
1893 usage: None,
1894 reasoning_content: None,
1895 },
1896 ]));
1897 let null_sink = Arc::new(crate::event::NullSink);
1898 let mut rt = AgentRuntime::builder()
1899 .llm(llm)
1900 .event_sink(null_sink)
1901 .build()
1902 .unwrap();
1903 let _ = rt
1904 .run_goal_loop("write a greeting", "write a greeting", 5)
1905 .await;
1906 assert!(rt.current_goal().is_none());
1908 }
1909
1910 #[tokio::test]
1911 async fn run_goal_loop_stops_at_max_turns() {
1912 let completions: Vec<Completion> = (0..20)
1914 .flat_map(|_| {
1915 vec![
1916 Completion {
1917 content: "still working".into(),
1918 tool_calls: vec![],
1919 finish_reason: Some("stop".into()),
1920 usage: None,
1921 reasoning_content: None,
1922 },
1923 Completion {
1924 content: "NO\nNot done yet.".into(),
1925 tool_calls: vec![],
1926 finish_reason: Some("stop".into()),
1927 usage: None,
1928 reasoning_content: None,
1929 },
1930 ]
1931 })
1932 .collect();
1933 let llm = Arc::new(MockProvider::new(completions));
1934 let null_sink = Arc::new(crate::event::NullSink);
1935 let mut rt = AgentRuntime::builder()
1936 .llm(llm)
1937 .event_sink(null_sink)
1938 .build()
1939 .unwrap();
1940 let _ = rt
1942 .run_goal_loop("start on impossible task", "impossible task", 2)
1943 .await;
1944 assert!(rt.current_goal().is_none());
1946 }
1947
1948 #[tokio::test]
1949 async fn goal_serde_round_trip() {
1950 let g = GoalState {
1951 condition: "file written".to_string(),
1952 status: GoalStatus::Achieved,
1953 turns: 3,
1954 max_turns: 10,
1955 last_reason: Some("File was created.".to_string()),
1956 };
1957 let json = serde_json::to_string(&g).expect("serialize");
1958 let g2: GoalState = serde_json::from_str(&json).expect("deserialize");
1959 assert_eq!(g2.condition, g.condition);
1960 assert_eq!(g2.status, GoalStatus::Achieved);
1961 assert_eq!(g2.turns, 3);
1962 assert_eq!(g2.last_reason, Some("File was created.".to_string()));
1963 }
1964
1965 #[tokio::test]
1966 async fn multiple_set_goal_calls_overwrite_state() {
1967 let llm = Arc::new(MockProvider::new(vec![]));
1968 let rt = AgentRuntime::builder().llm(llm).build().unwrap();
1969 rt.set_goal("first goal".to_string(), 5).await;
1970 rt.set_goal("second goal".to_string(), 15).await;
1971 let g = rt.current_goal().unwrap();
1972 assert_eq!(g.condition, "second goal");
1973 assert_eq!(g.max_turns, 15);
1974 }
1975
1976 #[tokio::test]
1979 async fn enqueue_processes_single_message() {
1980 let llm = Arc::new(MockProvider::new(vec![Completion {
1981 content: "queued reply".into(),
1982 tool_calls: vec![],
1983 finish_reason: Some("stop".into()),
1984 usage: None,
1985 reasoning_content: None,
1986 }]));
1987 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
1988 let out = rt.enqueue("hello from queue").await.unwrap();
1989 assert!(out.is_some());
1990 assert_eq!(out.unwrap().final_text.as_deref(), Some("queued reply"));
1991 assert_eq!(rt.transcript().len(), 2);
1992 }
1993
1994 #[tokio::test]
1995 async fn enqueue_drains_multiple_messages_in_order() {
1996 let llm = Arc::new(MockProvider::new(vec![
1997 Completion {
1998 content: "reply A".into(),
1999 tool_calls: vec![],
2000 finish_reason: Some("stop".into()),
2001 usage: None,
2002 reasoning_content: None,
2003 },
2004 Completion {
2005 content: "reply B".into(),
2006 tool_calls: vec![],
2007 finish_reason: Some("stop".into()),
2008 usage: None,
2009 reasoning_content: None,
2010 },
2011 ]));
2012 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
2013 rt.message_queue.push_back("msg A".into());
2015 rt.message_queue.push_back("msg B".into());
2016 let last = rt.drain_queue().await.unwrap();
2017 assert_eq!(last.unwrap().final_text.as_deref(), Some("reply B"));
2018 assert_eq!(rt.transcript().len(), 4);
2020 }
2021
2022 #[test]
2023 fn queue_len_reflects_pending_messages() {
2024 let llm = Arc::new(MockProvider::new(vec![]));
2025 let mut rt = AgentRuntime::builder().llm(llm).build().unwrap();
2026 assert_eq!(rt.queue_len(), 0);
2027 rt.message_queue.push_back("pending".into());
2028 assert_eq!(rt.queue_len(), 1);
2029 rt.message_queue.push_back("also pending".into());
2030 assert_eq!(rt.queue_len(), 2);
2031 }
2032
2033 #[test]
2036 fn runtime_builder_has_plan_mode_tools() {
2037 let llm = Arc::new(MockProvider::new(vec![]));
2041 let rt = AgentRuntime::builder().llm(llm).build().unwrap();
2042 let tools = rt.kernel.tools();
2043 assert!(
2044 tools.get("enter_plan_mode").is_some(),
2045 "enter_plan_mode must be registered by AgentRuntimeBuilder"
2046 );
2047 assert!(
2048 tools.get("exit_plan_mode").is_some(),
2049 "exit_plan_mode must be registered by AgentRuntimeBuilder"
2050 );
2051 }
2052}