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::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
24pub struct ResumeInfo {
27 pub path: PathBuf,
28 pub session_id: String,
29 pub entries: Vec<opi_agent::session::SessionEntry>,
30 pub original_cwd: PathBuf,
33}
34
35pub struct CodingHarness {
37 agent: Agent,
38 config: OpiConfig,
39 system_prompt: String,
40 session: Option<SessionCoordinator>,
41 turn_offset: usize,
43 pending_images: Vec<opi_ai::message::InputContent>,
45}
46
47impl CodingHarness {
48 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 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 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 #[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 #[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 #[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 #[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 #[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 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 pub fn add_tool(&mut self, tool: Box<dyn Tool>) {
325 self.agent.add_tool(tool);
326 }
327
328 pub fn queue_images(&mut self, images: Vec<opi_ai::message::InputContent>) {
330 self.pending_images.extend(images);
331 }
332
333 pub fn take_pending_images(&mut self) -> Vec<opi_ai::message::InputContent> {
335 std::mem::take(&mut self.pending_images)
336 }
337
338 pub fn model_picker_items(&self) -> Vec<opi_tui::SelectItem> {
340 crate::picker::model_picker_items_from_provider(self.agent.provider())
341 }
342
343 pub fn set_model(&mut self, model: String) {
345 self.agent.set_model(model);
346 }
347
348 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 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 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 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 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 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 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 fn current_messages(&self) -> Vec<AgentMessage> {
497 self.agent.messages_snapshot()
502 }
503
504 pub fn subscribe(&mut self, callback: Box<dyn Fn(&AgentEvent) + Send + Sync>) {
506 self.agent.subscribe(callback);
507 }
508
509 pub fn system_prompt(&self) -> &str {
511 &self.system_prompt
512 }
513
514 pub fn config(&self) -> &OpiConfig {
516 &self.config
517 }
518
519 pub fn cancel(&self) {
521 self.agent.abort();
522 }
523
524 pub fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
526 self.agent.cancel_token()
527 }
528
529 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
588pub(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
624pub 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
633pub 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}