1use 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
22pub struct ResumeInfo {
25 pub path: PathBuf,
26 pub session_id: String,
27 pub entries: Vec<opi_agent::session::SessionEntry>,
28 pub original_cwd: PathBuf,
31}
32
33pub struct CodingHarness {
35 agent: Agent,
36 config: OpiConfig,
37 system_prompt: String,
38 session: Option<SessionCoordinator>,
39 turn_offset: usize,
41}
42
43impl CodingHarness {
44 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 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 #[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 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 pub fn add_tool(&mut self, tool: Box<dyn Tool>) {
174 self.agent.add_tool(tool);
175 }
176
177 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 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 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 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 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 fn current_messages(&self) -> Vec<AgentMessage> {
285 self.agent.messages_snapshot()
290 }
291
292 pub fn subscribe(&mut self, callback: Box<dyn Fn(&AgentEvent) + Send + Sync>) {
294 self.agent.subscribe(callback);
295 }
296
297 pub fn system_prompt(&self) -> &str {
299 &self.system_prompt
300 }
301
302 pub fn config(&self) -> &OpiConfig {
304 &self.config
305 }
306
307 pub fn cancel(&self) {
309 self.agent.abort();
310 }
311
312 pub fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
314 self.agent.cancel_token()
315 }
316
317 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
334pub(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
370struct 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
379pub 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}