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::prompt::SystemPromptBuilder;
19use crate::session_coordinator::{SessionCoordinator, to_wire_result};
20use crate::tool::{BashTool, EditTool, GlobTool, GrepTool, ReadTool, WriteTool};
21
22/// Optional pre-existing session the harness can adopt instead of creating
23/// a new JSONL file. Produced by `--resume` flows.
24pub struct ResumeInfo {
25    pub path: PathBuf,
26    pub session_id: String,
27    pub entries: Vec<opi_agent::session::SessionEntry>,
28    /// The workspace cwd recorded in the session header. Used to restore the
29    /// correct workspace root when resuming from a different directory.
30    pub original_cwd: PathBuf,
31}
32
33/// Harness wiring config, tools, system prompt, hooks, and Agent.
34pub struct CodingHarness {
35    agent: Agent,
36    config: OpiConfig,
37    system_prompt: String,
38    session: Option<SessionCoordinator>,
39    /// Message count before the current turn — used to slice only new messages for persistence.
40    turn_offset: usize,
41}
42
43impl CodingHarness {
44    /// Create a new harness with the given provider, model, config, and workspace root.
45    pub fn new(
46        provider: Box<dyn Provider>,
47        model: String,
48        config: OpiConfig,
49        workspace_root: PathBuf,
50    ) -> Self {
51        Self::new_with_hooks(
52            provider,
53            model,
54            config,
55            workspace_root,
56            Box::new(CodingAgentHooks),
57            None,
58            Vec::new(),
59        )
60    }
61
62    /// Create a new harness with custom hooks.
63    pub fn new_with_hooks(
64        provider: Box<dyn Provider>,
65        model: String,
66        config: OpiConfig,
67        workspace_root: PathBuf,
68        hooks: Box<dyn AgentHooks>,
69        user_system_prompt: Option<String>,
70        initial_messages: Vec<AgentMessage>,
71    ) -> Self {
72        Self::new_with_hooks_and_resume(
73            provider,
74            model,
75            config,
76            workspace_root,
77            hooks,
78            user_system_prompt,
79            initial_messages,
80            None,
81        )
82    }
83
84    /// Create a new harness, optionally adopting an existing session (resume).
85    #[allow(clippy::too_many_arguments)]
86    pub fn new_with_hooks_and_resume(
87        provider: Box<dyn Provider>,
88        model: String,
89        config: OpiConfig,
90        workspace_root: PathBuf,
91        hooks: Box<dyn AgentHooks>,
92        user_system_prompt: Option<String>,
93        initial_messages: Vec<AgentMessage>,
94        resume: Option<ResumeInfo>,
95    ) -> Self {
96        let tools = Self::build_tools(&workspace_root);
97        let tool_defs: Vec<_> = tools.iter().map(|t| t.definition()).collect();
98        let mut builder = SystemPromptBuilder::new().tools(tool_defs);
99        if let Some(content) = user_system_prompt {
100            builder = builder.user_system(content);
101        }
102        let system_prompt = builder.build();
103
104        let agent_config = AgentLoopConfig {
105            max_turns: config.defaults.max_iterations,
106            retry: Some(config.retry.clone()),
107            thinking: if config.thinking.enabled {
108                Some(opi_ai::provider::ThinkingConfig {
109                    enabled: true,
110                    budget_tokens: Some(config.thinking.budget_tokens as u64),
111                })
112            } else {
113                None
114            },
115            ..Default::default()
116        };
117
118        let mut agent = Agent::new(
119            provider,
120            tools,
121            model.clone(),
122            Some(system_prompt.clone()),
123            agent_config,
124            hooks,
125        );
126
127        let initial_len = initial_messages.len();
128        if !initial_messages.is_empty() {
129            agent.set_initial_messages(initial_messages);
130        }
131
132        let cwd = if let Some(ref info) = resume {
133            // When resuming, use the workspace cwd from the session header so
134            // tools operate in the correct workspace even if the process was
135            // launched from a different directory.
136            info.original_cwd.to_string_lossy().into_owned()
137        } else {
138            std::env::current_dir()
139                .unwrap_or_default()
140                .to_string_lossy()
141                .into_owned()
142        };
143        let compaction_config = opi_agent::compaction::CompactionConfig {
144            enabled: config.compaction.enabled,
145            threshold_tokens: config.compaction.threshold_tokens,
146        };
147
148        let session = if let Some(info) = resume {
149            SessionCoordinator::open_existing(
150                info.path,
151                info.session_id,
152                &info.entries,
153                initial_len,
154                compaction_config,
155                model.clone(),
156            )
157            .ok()
158        } else {
159            let session_dir = crate::session_cli::session_dir();
160            SessionCoordinator::new(&session_dir, &cwd, compaction_config, model.clone()).ok()
161        };
162
163        Self {
164            agent,
165            config,
166            system_prompt,
167            session,
168            turn_offset: initial_len,
169        }
170    }
171
172    /// Add an extra tool to the harness (for testing with mock tools).
173    pub fn add_tool(&mut self, tool: Box<dyn Tool>) {
174        self.agent.add_tool(tool);
175    }
176
177    /// Send a user prompt and run the agent loop.
178    pub async fn prompt(&mut self, text: &str) -> Result<Vec<AgentMessage>, AgentError> {
179        let offset = self.turn_offset;
180        let messages = self.agent.prompt(text).await?;
181        let new = &messages[offset..];
182        self.persist_turn(new, offset);
183        let final_messages = self.current_messages();
184        self.turn_offset = final_messages.len();
185        Ok(final_messages)
186    }
187
188    /// Continue the conversation with an additional message.
189    pub async fn continue_(&mut self, text: &str) -> Result<Vec<AgentMessage>, AgentError> {
190        let offset = self.turn_offset;
191        let messages = self.agent.continue_(text).await?;
192        let new = &messages[offset..];
193        self.persist_turn(new, offset);
194        let final_messages = self.current_messages();
195        self.turn_offset = final_messages.len();
196        Ok(final_messages)
197    }
198
199    /// Sum usage across every assistant message produced during a turn.
200    ///
201    /// A single user prompt can drive multiple provider calls (e.g.
202    /// tool-call response followed by a final response). Each emitted
203    /// assistant message carries its own `usage`; the cumulative session
204    /// total must include all of them, not just the last one.
205    fn aggregate_turn_usage(messages: &[AgentMessage]) -> opi_ai::stream::Usage {
206        let mut total = opi_ai::stream::Usage::default();
207        for m in messages {
208            if let AgentMessage::Llm(Message::Assistant(a)) = m {
209                total.input_tokens = total.input_tokens.saturating_add(a.usage.input_tokens);
210                total.output_tokens = total.output_tokens.saturating_add(a.usage.output_tokens);
211                total.cache_read_tokens = total
212                    .cache_read_tokens
213                    .saturating_add(a.usage.cache_read_tokens);
214                total.cache_write_tokens = total
215                    .cache_write_tokens
216                    .saturating_add(a.usage.cache_write_tokens);
217            }
218        }
219        total
220    }
221
222    /// Aggregate usage across all assistant messages in a turn and persist.
223    ///
224    /// If compaction was triggered during persistence, this also rewrites
225    /// the Agent's message buffer to `[summary, ...kept]` so subsequent
226    /// provider calls no longer carry the compacted history. Emits
227    /// `CompactionStart`/`CompactionEnd` events for subscribers.
228    fn persist_turn(&mut self, messages: &[AgentMessage], turn_start_agent_index: usize) {
229        if let Some(session) = &mut self.session {
230            let usage = Self::aggregate_turn_usage(messages);
231            let compaction_reason =
232                match session.on_turn_end(messages, &usage, turn_start_agent_index) {
233                    Ok(reason) => reason,
234                    Err(e) => {
235                        self.agent.emit_event(AgentEvent::SessionPersistError {
236                            message: format!("session write failed: {e}"),
237                        });
238                        return;
239                    }
240                };
241
242            if let Some(reason) = compaction_reason {
243                self.agent
244                    .emit_event(AgentEvent::CompactionStart { reason });
245                match session.execute_compaction(reason) {
246                    Ok(Some(out)) => {
247                        let wire = to_wire_result(&out);
248                        self.agent.replace_messages(out.new_agent_messages);
249                        self.agent.emit_event(AgentEvent::CompactionEnd {
250                            reason,
251                            result: Some(wire),
252                            aborted: false,
253                            error_message: None,
254                        });
255                    }
256                    Ok(None) => {
257                        self.agent.emit_event(AgentEvent::CompactionEnd {
258                            reason,
259                            result: None,
260                            aborted: true,
261                            error_message: Some("compaction produced no output".into()),
262                        });
263                    }
264                    Err(e) => {
265                        // Compaction marker failed to persist — leave in-memory
266                        // state un-compacted (SessionCoordinator already skipped
267                        // the mutation) and surface the error to subscribers.
268                        self.agent.emit_event(AgentEvent::CompactionEnd {
269                            reason,
270                            result: None,
271                            aborted: true,
272                            error_message: Some(format!("compaction persist failed: {e}")),
273                        });
274                        self.agent.emit_event(AgentEvent::SessionPersistError {
275                            message: format!("compaction write failed: {e}"),
276                        });
277                    }
278                }
279            }
280        }
281    }
282
283    /// Return the current message buffer (after any compaction).
284    fn current_messages(&self) -> Vec<AgentMessage> {
285        // The Agent's `set_initial_messages` / `replace_messages` API doesn't
286        // expose a getter, so we re-derive the buffer from what was returned
287        // by the loop plus any post-loop mutation. Simplest correct option:
288        // ask the Agent via a new getter.
289        self.agent.messages_snapshot()
290    }
291
292    /// Register an event subscriber.
293    pub fn subscribe(&mut self, callback: Box<dyn Fn(&AgentEvent) + Send + Sync>) {
294        self.agent.subscribe(callback);
295    }
296
297    /// Return the assembled system prompt (for testing).
298    pub fn system_prompt(&self) -> &str {
299        &self.system_prompt
300    }
301
302    /// Return a reference to the config.
303    pub fn config(&self) -> &OpiConfig {
304        &self.config
305    }
306
307    /// Cancel the running operation.
308    pub fn cancel(&self) {
309        self.agent.abort();
310    }
311
312    /// Return a clonable cancellation token for external cancellation.
313    pub fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
314        self.agent.cancel_token()
315    }
316
317    /// Return the session coordinator, if active.
318    pub fn session(&self) -> Option<&SessionCoordinator> {
319        self.session.as_ref()
320    }
321
322    fn build_tools(workspace_root: &Path) -> Vec<Box<dyn Tool>> {
323        vec![
324            Box::new(ReadTool::new(workspace_root.to_path_buf())),
325            Box::new(WriteTool::new(workspace_root.to_path_buf())),
326            Box::new(EditTool::new(workspace_root.to_path_buf())),
327            Box::new(BashTool::new(workspace_root.to_path_buf())),
328            Box::new(GlobTool::new(workspace_root.to_path_buf())),
329            Box::new(GrepTool::new(workspace_root.to_path_buf())),
330        ]
331    }
332}
333
334// ---------------------------------------------------------------------------
335// Hooks
336// ---------------------------------------------------------------------------
337
338/// Shared conversion of agent-level messages to the provider-facing Message
339/// stream. Used by every hook in this crate so resume/compaction semantics
340/// stay consistent between interactive and non-interactive paths.
341///
342/// - `AgentMessage::Llm` is forwarded directly.
343/// - `AgentMessage::CompactionSummary` is rendered as a synthetic user
344///   message so the provider sees a textual marker for context that was
345///   compacted away.
346/// - Other variants (`BranchSummary`, `Custom`) are dropped — they have no
347///   provider-facing representation yet.
348pub(crate) fn agent_messages_to_llm(messages: &[AgentMessage]) -> Vec<Message> {
349    let mut result = Vec::with_capacity(messages.len());
350    for msg in messages {
351        match msg {
352            AgentMessage::Llm(m) => result.push(m.clone()),
353            AgentMessage::CompactionSummary(summary) => {
354                result.push(Message::User(opi_ai::message::UserMessage {
355                    content: vec![opi_ai::message::InputContent::Text {
356                        text: format!(
357                            "[Context was compacted. Summary of earlier conversation: {}]",
358                            summary.summary
359                        ),
360                    }],
361                    timestamp_ms: 0,
362                }));
363            }
364            _ => {}
365        }
366    }
367    result
368}
369
370/// Default hooks for the coding agent -- pass-through message conversion.
371struct CodingAgentHooks;
372
373impl AgentHooks for CodingAgentHooks {
374    fn convert_to_llm(&self, messages: &[AgentMessage]) -> Result<Vec<Message>, AgentError> {
375        Ok(agent_messages_to_llm(messages))
376    }
377}
378
379/// Interactive hooks that deny mutating tools unless auto-allowed.
380pub struct InteractiveCodingHooks {
381    pub allow_mutating: bool,
382}
383
384impl InteractiveCodingHooks {
385    pub fn new(allow_mutating: bool) -> Self {
386        Self { allow_mutating }
387    }
388
389    fn is_mutating_tool(name: &str) -> bool {
390        matches!(name, "write" | "edit" | "bash")
391    }
392}
393
394impl AgentHooks for InteractiveCodingHooks {
395    fn convert_to_llm(&self, messages: &[AgentMessage]) -> Result<Vec<Message>, AgentError> {
396        Ok(agent_messages_to_llm(messages))
397    }
398
399    fn before_tool_call(
400        &self,
401        ctx: opi_agent::hooks::BeforeToolCallContext,
402    ) -> std::pin::Pin<
403        Box<dyn std::future::Future<Output = opi_agent::hooks::BeforeToolCallResult> + Send>,
404    > {
405        use opi_agent::hooks::BeforeToolCallResult;
406        let allow = self.allow_mutating || !Self::is_mutating_tool(&ctx.tool_name);
407        Box::pin(async move {
408            if allow {
409                BeforeToolCallResult::Allow
410            } else {
411                BeforeToolCallResult::Deny {
412                    reason: format!(
413                        "mutating tool '{}' blocked in interactive mode (use --allow-mutating to override)",
414                        ctx.tool_name
415                    ),
416                }
417            }
418        })
419    }
420}