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::tool::{BashTool, EditTool, GlobTool, GrepTool, ReadTool, WriteTool};
20
21/// Harness wiring config, tools, system prompt, hooks, and Agent.
22pub struct CodingHarness {
23    agent: Agent,
24    config: OpiConfig,
25    system_prompt: String,
26}
27
28impl CodingHarness {
29    /// Create a new harness with the given provider, model, config, and workspace root.
30    pub fn new(
31        provider: Box<dyn Provider>,
32        model: String,
33        config: OpiConfig,
34        workspace_root: PathBuf,
35    ) -> Self {
36        Self::new_with_hooks(
37            provider,
38            model,
39            config,
40            workspace_root,
41            Box::new(CodingAgentHooks),
42            None,
43        )
44    }
45
46    /// Create a new harness with custom hooks.
47    pub fn new_with_hooks(
48        provider: Box<dyn Provider>,
49        model: String,
50        config: OpiConfig,
51        workspace_root: PathBuf,
52        hooks: Box<dyn AgentHooks>,
53        user_system_prompt: Option<String>,
54    ) -> Self {
55        let tools = Self::build_tools(&workspace_root);
56        let tool_defs: Vec<_> = tools.iter().map(|t| t.definition()).collect();
57        let mut builder = SystemPromptBuilder::new().tools(tool_defs);
58        if let Some(content) = user_system_prompt {
59            builder = builder.user_system(content);
60        }
61        let system_prompt = builder.build();
62
63        let agent_config = AgentLoopConfig {
64            max_turns: config.defaults.max_iterations,
65            ..Default::default()
66        };
67
68        let agent = Agent::new(
69            provider,
70            tools,
71            model,
72            Some(system_prompt.clone()),
73            agent_config,
74            hooks,
75        );
76
77        Self {
78            agent,
79            config,
80            system_prompt,
81        }
82    }
83
84    /// Add an extra tool to the harness (for testing with mock tools).
85    pub fn add_tool(&mut self, tool: Box<dyn Tool>) {
86        self.agent.add_tool(tool);
87    }
88
89    /// Send a user prompt and run the agent loop.
90    pub async fn prompt(&mut self, text: &str) -> Result<Vec<AgentMessage>, AgentError> {
91        self.agent.prompt(text).await
92    }
93
94    /// Continue the conversation with an additional message.
95    pub async fn continue_(&mut self, text: &str) -> Result<Vec<AgentMessage>, AgentError> {
96        self.agent.continue_(text).await
97    }
98
99    /// Register an event subscriber.
100    pub fn subscribe(&mut self, callback: Box<dyn Fn(&AgentEvent) + Send + Sync>) {
101        self.agent.subscribe(callback);
102    }
103
104    /// Return the assembled system prompt (for testing).
105    pub fn system_prompt(&self) -> &str {
106        &self.system_prompt
107    }
108
109    /// Return a reference to the config.
110    pub fn config(&self) -> &OpiConfig {
111        &self.config
112    }
113
114    /// Cancel the running operation.
115    pub fn cancel(&self) {
116        self.agent.abort();
117    }
118
119    /// Return a clonable cancellation token for external cancellation.
120    pub fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
121        self.agent.cancel_token()
122    }
123
124    fn build_tools(workspace_root: &Path) -> Vec<Box<dyn Tool>> {
125        vec![
126            Box::new(ReadTool::new(workspace_root.to_path_buf())),
127            Box::new(WriteTool::new(workspace_root.to_path_buf())),
128            Box::new(EditTool::new(workspace_root.to_path_buf())),
129            Box::new(BashTool::new(workspace_root.to_path_buf())),
130            Box::new(GlobTool::new(workspace_root.to_path_buf())),
131            Box::new(GrepTool::new(workspace_root.to_path_buf())),
132        ]
133    }
134}
135
136// ---------------------------------------------------------------------------
137// Hooks
138// ---------------------------------------------------------------------------
139
140/// Default hooks for the coding agent — pass-through message conversion.
141struct CodingAgentHooks;
142
143impl AgentHooks for CodingAgentHooks {
144    fn convert_to_llm(&self, messages: &[AgentMessage]) -> Result<Vec<Message>, AgentError> {
145        let mut result = Vec::new();
146        for msg in messages {
147            if let AgentMessage::Llm(m) = msg {
148                result.push(m.clone());
149            }
150        }
151        Ok(result)
152    }
153}
154
155/// Interactive hooks that deny mutating tools unless auto-allowed.
156pub struct InteractiveCodingHooks {
157    pub allow_mutating: bool,
158}
159
160impl InteractiveCodingHooks {
161    pub fn new(allow_mutating: bool) -> Self {
162        Self { allow_mutating }
163    }
164
165    fn is_mutating_tool(name: &str) -> bool {
166        matches!(name, "write" | "edit" | "bash")
167    }
168}
169
170impl AgentHooks for InteractiveCodingHooks {
171    fn convert_to_llm(&self, messages: &[AgentMessage]) -> Result<Vec<Message>, AgentError> {
172        let mut result = Vec::new();
173        for msg in messages {
174            if let AgentMessage::Llm(m) = msg {
175                result.push(m.clone());
176            }
177        }
178        Ok(result)
179    }
180
181    fn before_tool_call(
182        &self,
183        ctx: opi_agent::hooks::BeforeToolCallContext,
184    ) -> std::pin::Pin<
185        Box<dyn std::future::Future<Output = opi_agent::hooks::BeforeToolCallResult> + Send>,
186    > {
187        use opi_agent::hooks::BeforeToolCallResult;
188        let allow = self.allow_mutating || !Self::is_mutating_tool(&ctx.tool_name);
189        Box::pin(async move {
190            if allow {
191                BeforeToolCallResult::Allow
192            } else {
193                BeforeToolCallResult::Deny {
194                    reason: format!(
195                        "mutating tool '{}' blocked in interactive mode (use --allow-mutating to override)",
196                        ctx.tool_name
197                    ),
198                }
199            }
200        })
201    }
202}