Skip to main content

opi_coding_agent/
harness.rs

1//! Interactive CLI harness (S8.4).
2//!
3//! Wires together config, tools, system prompt, hooks, and Agent into a
4//! single entry point for the interactive coding agent.
5
6use std::path::{Path, PathBuf};
7
8use opi_agent::Agent;
9use opi_agent::event::AgentEvent;
10use opi_agent::hooks::AgentHooks;
11use opi_agent::loop_types::{AgentError, AgentLoopConfig};
12use opi_agent::message::AgentMessage;
13use opi_agent::tool::Tool;
14use opi_ai::message::Message;
15use opi_ai::provider::Provider;
16
17use crate::config::OpiConfig;
18use crate::context_files;
19use crate::policy::{RunMode, ToolRuntimeConfig, ToolSelection};
20use crate::prompt::SystemPromptBuilder;
21use crate::session_coordinator::{SessionCoordinator, to_wire_result};
22use crate::tool::{BashTool, EditTool, FindTool, GlobTool, GrepTool, LsTool, ReadTool, WriteTool};
23
24/// Optional pre-existing session the harness can adopt instead of creating
25/// a new JSONL file. Produced by `--resume` flows.
26pub struct ResumeInfo {
27    pub path: PathBuf,
28    pub session_id: String,
29    pub entries: Vec<opi_agent::session::SessionEntry>,
30    /// The workspace cwd recorded in the session header. Used to restore the
31    /// correct workspace root when resuming from a different directory.
32    pub original_cwd: PathBuf,
33}
34
35/// Harness wiring config, tools, system prompt, hooks, and Agent.
36pub struct CodingHarness {
37    agent: Agent,
38    config: OpiConfig,
39    system_prompt: String,
40    session: Option<SessionCoordinator>,
41    /// Message count before the current turn — used to slice only new messages for persistence.
42    turn_offset: usize,
43    /// Images queued from --image CLI flag, injected into the first prompt.
44    pending_images: Vec<opi_ai::message::InputContent>,
45}
46
47impl CodingHarness {
48    /// Create a new harness with the given provider, model, config, and workspace root.
49    pub fn new(
50        provider: Box<dyn Provider>,
51        model: String,
52        config: OpiConfig,
53        workspace_root: PathBuf,
54    ) -> Self {
55        Self::new_with_hooks(
56            provider,
57            model,
58            config,
59            workspace_root,
60            Box::new(CodingAgentHooks),
61            None,
62            Vec::new(),
63            ToolSelection::Default,
64        )
65    }
66
67    /// Create a new harness with an explicit tool selection.
68    pub fn new_with_selection(
69        provider: Box<dyn Provider>,
70        model: String,
71        config: OpiConfig,
72        workspace_root: PathBuf,
73        tool_selection: ToolSelection,
74    ) -> Self {
75        Self::new_with_hooks(
76            provider,
77            model,
78            config,
79            workspace_root,
80            Box::new(CodingAgentHooks),
81            None,
82            Vec::new(),
83            tool_selection,
84        )
85    }
86
87    /// Create a new harness with already resolved tool runtime config.
88    pub fn new_with_tool_config(
89        provider: Box<dyn Provider>,
90        model: String,
91        config: OpiConfig,
92        workspace_root: PathBuf,
93        tool_config: ToolRuntimeConfig,
94    ) -> Self {
95        Self::new_with_hooks_and_resume_tool_config(
96            provider,
97            model,
98            config,
99            workspace_root,
100            Box::new(CodingAgentHooks),
101            None,
102            Vec::new(),
103            None,
104            tool_config,
105        )
106    }
107
108    /// Create a new harness with custom hooks.
109    #[allow(clippy::too_many_arguments)]
110    pub fn new_with_hooks(
111        provider: Box<dyn Provider>,
112        model: String,
113        config: OpiConfig,
114        workspace_root: PathBuf,
115        hooks: Box<dyn AgentHooks>,
116        user_system_prompt: Option<String>,
117        initial_messages: Vec<AgentMessage>,
118        tool_selection: ToolSelection,
119    ) -> Self {
120        Self::new_with_hooks_and_resume(
121            provider,
122            model,
123            config,
124            workspace_root,
125            hooks,
126            user_system_prompt,
127            initial_messages,
128            None,
129            tool_selection,
130        )
131    }
132
133    /// Create a new harness, optionally adopting an existing session (resume).
134    #[allow(clippy::too_many_arguments)]
135    pub fn new_with_hooks_and_resume(
136        provider: Box<dyn Provider>,
137        model: String,
138        config: OpiConfig,
139        workspace_root: PathBuf,
140        hooks: Box<dyn AgentHooks>,
141        user_system_prompt: Option<String>,
142        initial_messages: Vec<AgentMessage>,
143        resume: Option<ResumeInfo>,
144        tool_selection: ToolSelection,
145    ) -> Self {
146        let tool_config = ToolRuntimeConfig::resolve(RunMode::Interactive, true, tool_selection)
147            .expect("interactive tool config should be valid");
148        Self::new_with_hooks_and_resume_tool_config(
149            provider,
150            model,
151            config,
152            workspace_root,
153            hooks,
154            user_system_prompt,
155            initial_messages,
156            resume,
157            tool_config,
158        )
159    }
160
161    /// Create a new harness, optionally adopting an existing session (resume),
162    /// with already resolved tool runtime config.
163    #[allow(clippy::too_many_arguments)]
164    pub fn new_with_hooks_and_resume_tool_config(
165        provider: Box<dyn Provider>,
166        model: String,
167        config: OpiConfig,
168        workspace_root: PathBuf,
169        hooks: Box<dyn AgentHooks>,
170        user_system_prompt: Option<String>,
171        initial_messages: Vec<AgentMessage>,
172        resume: Option<ResumeInfo>,
173        tool_config: ToolRuntimeConfig,
174    ) -> Self {
175        Self::new_with_global_config_dir_tool_config(
176            provider,
177            model,
178            config,
179            workspace_root,
180            hooks,
181            user_system_prompt,
182            initial_messages,
183            resume,
184            tool_config,
185            None,
186        )
187    }
188
189    /// Create a new harness with an explicit global config directory override.
190    ///
191    /// When `global_config_dir` is `None`, uses the platform default from
192    /// [`crate::config::user_config_dir`]. Pass `Some(path)` in tests to
193    /// isolate global context file discovery from the real user config dir.
194    #[allow(clippy::too_many_arguments)]
195    pub fn new_with_global_config_dir(
196        provider: Box<dyn Provider>,
197        model: String,
198        config: OpiConfig,
199        workspace_root: PathBuf,
200        hooks: Box<dyn AgentHooks>,
201        user_system_prompt: Option<String>,
202        initial_messages: Vec<AgentMessage>,
203        resume: Option<ResumeInfo>,
204        tool_selection: ToolSelection,
205        global_config_dir: Option<PathBuf>,
206    ) -> Self {
207        let tool_config = ToolRuntimeConfig::resolve(RunMode::Interactive, true, tool_selection)
208            .expect("interactive tool config should be valid");
209        Self::new_with_global_config_dir_tool_config(
210            provider,
211            model,
212            config,
213            workspace_root,
214            hooks,
215            user_system_prompt,
216            initial_messages,
217            resume,
218            tool_config,
219            global_config_dir,
220        )
221    }
222
223    /// Create a new harness with an explicit global config directory override
224    /// and already resolved tool runtime config.
225    #[allow(clippy::too_many_arguments)]
226    pub fn new_with_global_config_dir_tool_config(
227        provider: Box<dyn Provider>,
228        model: String,
229        config: OpiConfig,
230        workspace_root: PathBuf,
231        hooks: Box<dyn AgentHooks>,
232        user_system_prompt: Option<String>,
233        initial_messages: Vec<AgentMessage>,
234        resume: Option<ResumeInfo>,
235        tool_config: ToolRuntimeConfig,
236        global_config_dir: Option<PathBuf>,
237    ) -> Self {
238        let tools = Self::build_tools(&workspace_root, &tool_config);
239        let tool_defs: Vec<_> = tools.iter().map(|t| t.definition()).collect();
240        let mut builder = SystemPromptBuilder::new().tools(tool_defs);
241        if let Some(content) = user_system_prompt {
242            builder = builder.user_system(content);
243        }
244        let resolved_global_dir = global_config_dir.unwrap_or_else(crate::config::user_config_dir);
245        let context = context_files::discover_context_files(
246            &workspace_root,
247            Some(resolved_global_dir.as_path()),
248        );
249        if !context.content.is_empty() {
250            builder = builder.context_files(context.content);
251        }
252        let system_prompt = builder.build();
253
254        let agent_config = AgentLoopConfig {
255            max_turns: config.defaults.max_iterations,
256            retry: Some(config.retry.clone()),
257            thinking: if config.thinking.enabled {
258                Some(opi_ai::provider::ThinkingConfig {
259                    enabled: true,
260                    budget_tokens: Some(config.thinking.budget_tokens as u64),
261                })
262            } else {
263                None
264            },
265            ..Default::default()
266        };
267
268        let mut agent = Agent::new(
269            provider,
270            tools,
271            model.clone(),
272            Some(system_prompt.clone()),
273            agent_config,
274            hooks,
275        );
276
277        let initial_len = initial_messages.len();
278        if !initial_messages.is_empty() {
279            agent.set_initial_messages(initial_messages);
280        }
281
282        let cwd = if let Some(ref info) = resume {
283            // When resuming, use the workspace cwd from the session header so
284            // tools operate in the correct workspace even if the process was
285            // launched from a different directory.
286            info.original_cwd.to_string_lossy().into_owned()
287        } else {
288            std::env::current_dir()
289                .unwrap_or_default()
290                .to_string_lossy()
291                .into_owned()
292        };
293        let compaction_config = opi_agent::compaction::CompactionConfig {
294            enabled: config.compaction.enabled,
295            threshold_tokens: config.compaction.threshold_tokens,
296        };
297
298        let session = if let Some(info) = resume {
299            SessionCoordinator::open_existing(
300                info.path,
301                info.session_id,
302                &info.entries,
303                initial_len,
304                compaction_config,
305                model.clone(),
306            )
307            .ok()
308        } else {
309            let session_dir = crate::session_cli::session_dir();
310            SessionCoordinator::new(&session_dir, &cwd, compaction_config, model.clone()).ok()
311        };
312
313        Self {
314            agent,
315            config,
316            system_prompt,
317            session,
318            turn_offset: initial_len,
319            pending_images: Vec::new(),
320        }
321    }
322
323    /// Add an extra tool to the harness (for testing with mock tools).
324    pub fn add_tool(&mut self, tool: Box<dyn Tool>) {
325        self.agent.add_tool(tool);
326    }
327
328    /// Queue images to be injected into the next prompt.
329    pub fn queue_images(&mut self, images: Vec<opi_ai::message::InputContent>) {
330        self.pending_images.extend(images);
331    }
332
333    /// Take and clear queued images.
334    pub fn take_pending_images(&mut self) -> Vec<opi_ai::message::InputContent> {
335        std::mem::take(&mut self.pending_images)
336    }
337
338    /// Return model picker items from the active provider.
339    pub fn model_picker_items(&self) -> Vec<opi_tui::SelectItem> {
340        crate::picker::model_picker_items_from_provider(self.agent.provider())
341    }
342
343    /// Change the model used by subsequent prompts.
344    pub fn set_model(&mut self, model: String) {
345        self.agent.set_model(model);
346    }
347
348    /// Resume an existing session by ID into this harness.
349    pub fn resume_session_id(&mut self, session_id: &str) -> Result<usize, String> {
350        let dir = crate::session_cli::session_dir();
351        let session =
352            crate::session_cli::resume_session(&dir, session_id).map_err(|e| e.to_string())?;
353        let messages = crate::session_cli::reconstruct_context(&session.entries);
354        let message_count = messages.len();
355        self.agent.replace_messages(messages);
356
357        let compaction_config = opi_agent::compaction::CompactionConfig {
358            enabled: self.config.compaction.enabled,
359            threshold_tokens: self.config.compaction.threshold_tokens,
360        };
361        self.session = SessionCoordinator::open_existing(
362            session.path,
363            session.header.id,
364            &session.entries,
365            message_count,
366            compaction_config,
367            self.agent.model().to_string(),
368        )
369        .ok();
370        self.turn_offset = message_count;
371        Ok(message_count)
372    }
373
374    /// Send a user prompt and run the agent loop.
375    pub async fn prompt(&mut self, text: &str) -> Result<Vec<AgentMessage>, AgentError> {
376        let offset = self.turn_offset;
377        let messages = self.agent.prompt(text).await?;
378        let new = &messages[offset..];
379        self.persist_turn(new, offset);
380        let final_messages = self.current_messages();
381        self.turn_offset = final_messages.len();
382        Ok(final_messages)
383    }
384
385    /// Send a user message with arbitrary content (text + images) and run the
386    /// agent loop.
387    pub async fn prompt_with_content(
388        &mut self,
389        content: Vec<opi_ai::message::InputContent>,
390    ) -> Result<Vec<AgentMessage>, AgentError> {
391        let offset = self.turn_offset;
392        let messages = self.agent.prompt_with_content(content).await?;
393        let new = &messages[offset..];
394        self.persist_turn(new, offset);
395        let final_messages = self.current_messages();
396        self.turn_offset = final_messages.len();
397        Ok(final_messages)
398    }
399
400    /// Continue the conversation with an additional message.
401    pub async fn continue_(&mut self, text: &str) -> Result<Vec<AgentMessage>, AgentError> {
402        let offset = self.turn_offset;
403        let messages = self.agent.continue_(text).await?;
404        let new = &messages[offset..];
405        self.persist_turn(new, offset);
406        let final_messages = self.current_messages();
407        self.turn_offset = final_messages.len();
408        Ok(final_messages)
409    }
410
411    /// Sum usage across every assistant message produced during a turn.
412    ///
413    /// A single user prompt can drive multiple provider calls (e.g.
414    /// tool-call response followed by a final response). Each emitted
415    /// assistant message carries its own `usage`; the cumulative session
416    /// total must include all of them, not just the last one.
417    fn aggregate_turn_usage(messages: &[AgentMessage]) -> opi_ai::stream::Usage {
418        let mut total = opi_ai::stream::Usage::default();
419        for m in messages {
420            if let AgentMessage::Llm(Message::Assistant(a)) = m {
421                total.input_tokens = total.input_tokens.saturating_add(a.usage.input_tokens);
422                total.output_tokens = total.output_tokens.saturating_add(a.usage.output_tokens);
423                total.cache_read_tokens = total
424                    .cache_read_tokens
425                    .saturating_add(a.usage.cache_read_tokens);
426                total.cache_write_tokens = total
427                    .cache_write_tokens
428                    .saturating_add(a.usage.cache_write_tokens);
429            }
430        }
431        total
432    }
433
434    /// Aggregate usage across all assistant messages in a turn and persist.
435    ///
436    /// If compaction was triggered during persistence, this also rewrites
437    /// the Agent's message buffer to `[summary, ...kept]` so subsequent
438    /// provider calls no longer carry the compacted history. Emits
439    /// `CompactionStart`/`CompactionEnd` events for subscribers.
440    fn persist_turn(&mut self, messages: &[AgentMessage], turn_start_agent_index: usize) {
441        if let Some(session) = &mut self.session {
442            let usage = Self::aggregate_turn_usage(messages);
443            let compaction_reason =
444                match session.on_turn_end(messages, &usage, turn_start_agent_index) {
445                    Ok(reason) => reason,
446                    Err(e) => {
447                        self.agent.emit_event(AgentEvent::SessionPersistError {
448                            message: format!("session write failed: {e}"),
449                        });
450                        return;
451                    }
452                };
453
454            if let Some(reason) = compaction_reason {
455                self.agent
456                    .emit_event(AgentEvent::CompactionStart { reason });
457                match session.execute_compaction(reason) {
458                    Ok(Some(out)) => {
459                        let wire = to_wire_result(&out);
460                        self.agent.replace_messages(out.new_agent_messages);
461                        self.agent.emit_event(AgentEvent::CompactionEnd {
462                            reason,
463                            result: Some(wire),
464                            aborted: false,
465                            error_message: None,
466                        });
467                    }
468                    Ok(None) => {
469                        self.agent.emit_event(AgentEvent::CompactionEnd {
470                            reason,
471                            result: None,
472                            aborted: true,
473                            error_message: Some("compaction produced no output".into()),
474                        });
475                    }
476                    Err(e) => {
477                        // Compaction marker failed to persist — leave in-memory
478                        // state un-compacted (SessionCoordinator already skipped
479                        // the mutation) and surface the error to subscribers.
480                        self.agent.emit_event(AgentEvent::CompactionEnd {
481                            reason,
482                            result: None,
483                            aborted: true,
484                            error_message: Some(format!("compaction persist failed: {e}")),
485                        });
486                        self.agent.emit_event(AgentEvent::SessionPersistError {
487                            message: format!("compaction write failed: {e}"),
488                        });
489                    }
490                }
491            }
492        }
493    }
494
495    /// Return the current message buffer (after any compaction).
496    fn current_messages(&self) -> Vec<AgentMessage> {
497        // The Agent's `set_initial_messages` / `replace_messages` API doesn't
498        // expose a getter, so we re-derive the buffer from what was returned
499        // by the loop plus any post-loop mutation. Simplest correct option:
500        // ask the Agent via a new getter.
501        self.agent.messages_snapshot()
502    }
503
504    /// Register an event subscriber.
505    pub fn subscribe(&mut self, callback: Box<dyn Fn(&AgentEvent) + Send + Sync>) {
506        self.agent.subscribe(callback);
507    }
508
509    /// Return the assembled system prompt (for testing).
510    pub fn system_prompt(&self) -> &str {
511        &self.system_prompt
512    }
513
514    /// Return a reference to the config.
515    pub fn config(&self) -> &OpiConfig {
516        &self.config
517    }
518
519    /// Cancel the running operation.
520    pub fn cancel(&self) {
521        self.agent.abort();
522    }
523
524    /// Return a clonable cancellation token for external cancellation.
525    pub fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
526        self.agent.cancel_token()
527    }
528
529    /// Return the session coordinator, if active.
530    pub fn session(&self) -> Option<&SessionCoordinator> {
531        self.session.as_ref()
532    }
533
534    fn build_tools(workspace_root: &Path, tool_config: &ToolRuntimeConfig) -> Vec<Box<dyn Tool>> {
535        let read_policy = match tool_config.run_mode {
536            RunMode::Interactive => crate::tool::PathPolicy::AllowOutsideWorkspace,
537            RunMode::NonInteractive => crate::tool::PathPolicy::WorkspaceOnly,
538        };
539
540        let mut tools: Vec<(&str, Box<dyn Tool>)> = vec![
541            (
542                "read",
543                Box::new(ReadTool::new_with_policy(
544                    workspace_root.to_path_buf(),
545                    read_policy,
546                )),
547            ),
548            (
549                "write",
550                Box::new(WriteTool::new(workspace_root.to_path_buf())),
551            ),
552            (
553                "edit",
554                Box::new(EditTool::new(workspace_root.to_path_buf())),
555            ),
556            (
557                "bash",
558                Box::new(BashTool::new(workspace_root.to_path_buf())),
559            ),
560            (
561                "grep",
562                Box::new(GrepTool::new(workspace_root.to_path_buf())),
563            ),
564            (
565                "find",
566                Box::new(FindTool::new(workspace_root.to_path_buf())),
567            ),
568            ("ls", Box::new(LsTool::new(workspace_root.to_path_buf()))),
569            (
570                "glob",
571                Box::new(GlobTool::new(workspace_root.to_path_buf())),
572            ),
573        ];
574
575        tools
576            .drain(..)
577            .filter(|(name, _)| {
578                tool_config
579                    .active_tool_names
580                    .iter()
581                    .any(|active| active == name)
582            })
583            .map(|(_, tool)| tool)
584            .collect()
585    }
586}
587
588// ---------------------------------------------------------------------------
589// Hooks
590// ---------------------------------------------------------------------------
591
592/// Shared conversion of agent-level messages to the provider-facing Message
593/// stream. Used by every hook in this crate so resume/compaction semantics
594/// stay consistent between interactive and non-interactive paths.
595///
596/// - `AgentMessage::Llm` is forwarded directly.
597/// - `AgentMessage::CompactionSummary` is rendered as a synthetic user
598///   message so the provider sees a textual marker for context that was
599///   compacted away.
600/// - Other variants (`BranchSummary`, `Custom`) are dropped — they have no
601///   provider-facing representation yet.
602pub(crate) fn agent_messages_to_llm(messages: &[AgentMessage]) -> Vec<Message> {
603    let mut result = Vec::with_capacity(messages.len());
604    for msg in messages {
605        match msg {
606            AgentMessage::Llm(m) => result.push(m.clone()),
607            AgentMessage::CompactionSummary(summary) => {
608                result.push(Message::User(opi_ai::message::UserMessage {
609                    content: vec![opi_ai::message::InputContent::Text {
610                        text: format!(
611                            "[Context was compacted. Summary of earlier conversation: {}]",
612                            summary.summary
613                        ),
614                    }],
615                    timestamp_ms: 0,
616                }));
617            }
618            _ => {}
619        }
620    }
621    result
622}
623
624/// Default hooks for the coding agent -- pass-through message conversion.
625pub struct CodingAgentHooks;
626
627impl AgentHooks for CodingAgentHooks {
628    fn convert_to_llm(&self, messages: &[AgentMessage]) -> Result<Vec<Message>, AgentError> {
629        Ok(agent_messages_to_llm(messages))
630    }
631}
632
633/// Interactive hooks for the coding agent.
634///
635/// Tool safety is controlled by active tool selection and extension hooks, not
636/// by a core interactive permission popup.
637pub struct InteractiveCodingHooks;
638
639impl InteractiveCodingHooks {
640    pub fn new(_allow_mutating: bool) -> Self {
641        Self
642    }
643}
644
645impl AgentHooks for InteractiveCodingHooks {
646    fn convert_to_llm(&self, messages: &[AgentMessage]) -> Result<Vec<Message>, AgentError> {
647        Ok(agent_messages_to_llm(messages))
648    }
649}