1use std::path::{Path, PathBuf};
7
8use opi_agent::Agent;
9use opi_agent::event::AgentEvent;
10use opi_agent::extension::ExtensionRegistry;
11use opi_agent::hooks::AgentHooks;
12use opi_agent::loop_types::{AgentError, AgentLoopConfig};
13use opi_agent::message::AgentMessage;
14use opi_agent::tool::Tool;
15use opi_ai::message::Message;
16use opi_ai::provider::{EventStream, ModelInfo, Provider, ThinkingConfig};
17
18use crate::config::OpiConfig;
19use crate::context_files;
20use crate::policy::{RunMode, ToolRuntimeConfig, ToolSelection};
21use crate::prompt::SystemPromptBuilder;
22use crate::resource::{ExplicitResourcePaths, ResourceDiscoveryLayers, standard_discovery_layers};
23use crate::session_coordinator::{SessionCoordinator, to_wire_result};
24use crate::tool::{BashTool, EditTool, FindTool, GlobTool, GrepTool, LsTool, ReadTool, WriteTool};
25
26pub struct ResumeInfo {
29 pub path: PathBuf,
30 pub session_id: String,
31 pub entries: Vec<opi_agent::session::SessionEntry>,
32 pub original_cwd: PathBuf,
35}
36
37pub struct CodingHarness {
39 agent: Agent,
40 config: OpiConfig,
41 system_prompt: String,
42 resources: HarnessResources,
43 model_registry: opi_ai::ProviderRegistry,
44 session: Option<SessionCoordinator>,
45 turn_offset: usize,
47 pending_images: Vec<opi_ai::message::InputContent>,
49}
50
51pub struct RuntimeThinkingState {
52 pub level: String,
53 pub enabled: bool,
54 pub budget_tokens: Option<u64>,
55}
56
57#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
59pub struct DiscoveredResourceMetadata {
60 pub extensions: Vec<ResourceMetadataEntry>,
61 pub packages: Vec<ResourceMetadataEntry>,
62 pub skills: Vec<ResourceMetadataEntry>,
63 pub fragments: Vec<ResourceMetadataEntry>,
64 pub themes: Vec<ResourceMetadataEntry>,
65 pub diagnostics: Vec<String>,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
70pub struct ResourceMetadataEntry {
71 pub name: String,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub description: Option<String>,
74 #[serde(skip_serializing_if = "Option::is_none")]
75 pub version: Option<String>,
76}
77
78#[derive(Debug, Clone, Default)]
79struct HarnessResources {
80 metadata: DiscoveredResourceMetadata,
81 theme_resources: Vec<crate::theme_discovery::ThemeResource>,
82}
83
84struct MetadataProvider {
85 id: String,
86 models: Vec<ModelInfo>,
87}
88
89impl MetadataProvider {
90 fn from_provider(provider: &dyn Provider) -> Self {
91 Self {
92 id: provider.id().to_owned(),
93 models: provider.models().to_vec(),
94 }
95 }
96}
97
98impl Provider for MetadataProvider {
99 fn id(&self) -> &str {
100 &self.id
101 }
102
103 fn models(&self) -> &[ModelInfo] {
104 &self.models
105 }
106
107 fn stream(&self, _request: opi_ai::provider::Request) -> EventStream {
108 Box::pin(futures_util::stream::empty())
109 }
110}
111
112impl DiscoveredResourceMetadata {
113 fn format_for_system_prompt(&self) -> String {
114 let mut sections = Vec::new();
115 push_metadata_section(&mut sections, "Discovered packages", &self.packages);
116 push_metadata_section(&mut sections, "Discovered extensions", &self.extensions);
117 push_metadata_section(&mut sections, "Discovered skills", &self.skills);
118 push_metadata_section(
119 &mut sections,
120 "Discovered prompt fragments",
121 &self.fragments,
122 );
123 push_metadata_section(&mut sections, "Discovered themes", &self.themes);
124 if !self.diagnostics.is_empty() {
125 sections.push(format!(
126 "Resource discovery diagnostics:\n{}",
127 self.diagnostics
128 .iter()
129 .map(|diagnostic| format!("- {diagnostic}"))
130 .collect::<Vec<_>>()
131 .join("\n")
132 ));
133 }
134 sections.join("\n\n")
135 }
136
137 pub fn to_rpc_json(&self) -> serde_json::Value {
138 serde_json::json!({
139 "extensions": metadata_names(&self.extensions),
140 "packages": metadata_names(&self.packages),
141 "skills": metadata_names(&self.skills),
142 "fragments": metadata_names(&self.fragments),
143 "themes": metadata_names(&self.themes),
144 "diagnostics": self.diagnostics.clone(),
145 })
146 }
147
148 fn add_extension_name(&mut self, name: String) {
149 if self.extensions.iter().any(|entry| entry.name == name) {
150 return;
151 }
152 self.extensions.push(ResourceMetadataEntry {
153 name,
154 description: None,
155 version: None,
156 });
157 self.extensions.sort_by(|a, b| a.name.cmp(&b.name));
158 }
159}
160
161fn metadata_names(entries: &[ResourceMetadataEntry]) -> Vec<&str> {
162 entries.iter().map(|entry| entry.name.as_str()).collect()
163}
164
165fn push_metadata_section(
166 sections: &mut Vec<String>,
167 title: &str,
168 entries: &[ResourceMetadataEntry],
169) {
170 if entries.is_empty() {
171 return;
172 }
173 let lines = entries
174 .iter()
175 .map(|entry| {
176 let mut line = format!("- {}", entry.name);
177 if let Some(description) = &entry.description {
178 line.push_str(": ");
179 line.push_str(description);
180 }
181 if let Some(version) = &entry.version {
182 line.push_str(" v");
183 line.push_str(version);
184 }
185 line
186 })
187 .collect::<Vec<_>>()
188 .join("\n");
189 sections.push(format!("{title}:\n{lines}"));
190}
191
192fn filter_extension_tools(
193 tools: Vec<Box<dyn Tool>>,
194 selection: &ToolSelection,
195) -> Vec<Box<dyn Tool>> {
196 match selection {
197 ToolSelection::Default | ToolSelection::NoBuiltin => tools,
198 ToolSelection::Disabled => Vec::new(),
199 ToolSelection::Allowlist(names) => tools
200 .into_iter()
201 .filter(|tool| {
202 let name = tool.definition().name;
203 names.iter().any(|allowed| allowed == &name)
204 })
205 .collect(),
206 }
207}
208
209pub struct CodingHarnessBuilder {
212 provider: Box<dyn Provider>,
213 model: String,
214 config: OpiConfig,
215 workspace_root: PathBuf,
216 hooks: Option<Box<dyn AgentHooks>>,
217 user_system_prompt: Option<String>,
218 initial_messages: Vec<AgentMessage>,
219 resume: Option<ResumeInfo>,
220 tool_config: Option<ToolRuntimeConfig>,
221 tool_selection: ToolSelection,
222 global_config_dir: Option<PathBuf>,
223 extension_registry: Option<ExtensionRegistry>,
224 resource_layers: Option<ResourceDiscoveryLayers>,
225 resource_metadata: Option<DiscoveredResourceMetadata>,
226}
227
228impl CodingHarnessBuilder {
229 fn new(
230 provider: Box<dyn Provider>,
231 model: String,
232 config: OpiConfig,
233 workspace_root: PathBuf,
234 ) -> Self {
235 Self {
236 provider,
237 model,
238 config,
239 workspace_root,
240 hooks: None,
241 user_system_prompt: None,
242 initial_messages: Vec::new(),
243 resume: None,
244 tool_config: None,
245 tool_selection: ToolSelection::Default,
246 global_config_dir: None,
247 extension_registry: None,
248 resource_layers: None,
249 resource_metadata: None,
250 }
251 }
252
253 pub fn hooks(mut self, hooks: Box<dyn AgentHooks>) -> Self {
254 self.hooks = Some(hooks);
255 self
256 }
257
258 pub fn user_system_prompt(mut self, prompt: impl Into<String>) -> Self {
259 self.user_system_prompt = Some(prompt.into());
260 self
261 }
262
263 pub fn initial_messages(mut self, messages: Vec<AgentMessage>) -> Self {
264 self.initial_messages = messages;
265 self
266 }
267
268 pub fn resume(mut self, resume: ResumeInfo) -> Self {
269 self.resume = Some(resume);
270 self
271 }
272
273 pub fn tool_selection(mut self, selection: ToolSelection) -> Self {
274 self.tool_selection = selection;
275 self
276 }
277
278 pub fn tool_config(mut self, config: ToolRuntimeConfig) -> Self {
279 self.tool_config = Some(config);
280 self
281 }
282
283 pub fn global_config_dir(mut self, dir: PathBuf) -> Self {
284 self.global_config_dir = Some(dir);
285 self
286 }
287
288 pub fn extension_registry(mut self, registry: ExtensionRegistry) -> Self {
289 self.extension_registry = Some(registry);
290 self
291 }
292
293 pub fn resource_layers(mut self, layers: ResourceDiscoveryLayers) -> Self {
294 self.resource_layers = Some(layers);
295 self
296 }
297
298 pub fn resource_metadata(mut self, metadata: DiscoveredResourceMetadata) -> Self {
299 self.resource_metadata = Some(metadata);
300 self
301 }
302
303 pub fn build(self) -> CodingHarness {
304 let tool_selection = self.tool_selection;
305 let tool_config = self.tool_config.unwrap_or_else(|| {
306 ToolRuntimeConfig::resolve(RunMode::Interactive, true, tool_selection.clone())
307 .expect("interactive tool config should be valid")
308 });
309 CodingHarness::new_with_build_options(
310 self.provider,
311 self.model,
312 self.config,
313 self.workspace_root,
314 self.hooks.unwrap_or_else(|| Box::new(CodingAgentHooks)),
315 self.user_system_prompt,
316 self.initial_messages,
317 self.resume,
318 tool_config,
319 self.global_config_dir,
320 HarnessBuildOptions {
321 extension_registry: self.extension_registry,
322 resource_layers: self.resource_layers,
323 resource_metadata: self.resource_metadata,
324 tool_selection,
325 },
326 )
327 }
328}
329
330struct HarnessBuildOptions {
331 extension_registry: Option<ExtensionRegistry>,
332 resource_layers: Option<ResourceDiscoveryLayers>,
333 resource_metadata: Option<DiscoveredResourceMetadata>,
334 tool_selection: ToolSelection,
335}
336
337impl Default for HarnessBuildOptions {
338 fn default() -> Self {
339 Self {
340 extension_registry: None,
341 resource_layers: None,
342 resource_metadata: None,
343 tool_selection: ToolSelection::Default,
344 }
345 }
346}
347
348impl CodingHarness {
349 pub fn builder(
351 provider: Box<dyn Provider>,
352 model: String,
353 config: OpiConfig,
354 workspace_root: PathBuf,
355 ) -> CodingHarnessBuilder {
356 CodingHarnessBuilder::new(provider, model, config, workspace_root)
357 }
358
359 pub fn new(
361 provider: Box<dyn Provider>,
362 model: String,
363 config: OpiConfig,
364 workspace_root: PathBuf,
365 ) -> Self {
366 Self::new_with_hooks(
367 provider,
368 model,
369 config,
370 workspace_root,
371 Box::new(CodingAgentHooks),
372 None,
373 Vec::new(),
374 ToolSelection::Default,
375 )
376 }
377
378 pub fn new_with_selection(
380 provider: Box<dyn Provider>,
381 model: String,
382 config: OpiConfig,
383 workspace_root: PathBuf,
384 tool_selection: ToolSelection,
385 ) -> Self {
386 Self::new_with_hooks(
387 provider,
388 model,
389 config,
390 workspace_root,
391 Box::new(CodingAgentHooks),
392 None,
393 Vec::new(),
394 tool_selection,
395 )
396 }
397
398 pub fn new_with_tool_config(
400 provider: Box<dyn Provider>,
401 model: String,
402 config: OpiConfig,
403 workspace_root: PathBuf,
404 tool_config: ToolRuntimeConfig,
405 ) -> Self {
406 Self::new_with_hooks_and_resume_tool_config(
407 provider,
408 model,
409 config,
410 workspace_root,
411 Box::new(CodingAgentHooks),
412 None,
413 Vec::new(),
414 None,
415 tool_config,
416 )
417 }
418
419 #[allow(clippy::too_many_arguments)]
421 pub fn new_with_hooks(
422 provider: Box<dyn Provider>,
423 model: String,
424 config: OpiConfig,
425 workspace_root: PathBuf,
426 hooks: Box<dyn AgentHooks>,
427 user_system_prompt: Option<String>,
428 initial_messages: Vec<AgentMessage>,
429 tool_selection: ToolSelection,
430 ) -> Self {
431 Self::new_with_hooks_and_resume(
432 provider,
433 model,
434 config,
435 workspace_root,
436 hooks,
437 user_system_prompt,
438 initial_messages,
439 None,
440 tool_selection,
441 )
442 }
443
444 #[allow(clippy::too_many_arguments)]
446 pub fn new_with_hooks_and_resume(
447 provider: Box<dyn Provider>,
448 model: String,
449 config: OpiConfig,
450 workspace_root: PathBuf,
451 hooks: Box<dyn AgentHooks>,
452 user_system_prompt: Option<String>,
453 initial_messages: Vec<AgentMessage>,
454 resume: Option<ResumeInfo>,
455 tool_selection: ToolSelection,
456 ) -> Self {
457 let tool_config = ToolRuntimeConfig::resolve(RunMode::Interactive, true, tool_selection)
458 .expect("interactive tool config should be valid");
459 Self::new_with_hooks_and_resume_tool_config(
460 provider,
461 model,
462 config,
463 workspace_root,
464 hooks,
465 user_system_prompt,
466 initial_messages,
467 resume,
468 tool_config,
469 )
470 }
471
472 #[allow(clippy::too_many_arguments)]
475 pub fn new_with_hooks_and_resume_tool_config(
476 provider: Box<dyn Provider>,
477 model: String,
478 config: OpiConfig,
479 workspace_root: PathBuf,
480 hooks: Box<dyn AgentHooks>,
481 user_system_prompt: Option<String>,
482 initial_messages: Vec<AgentMessage>,
483 resume: Option<ResumeInfo>,
484 tool_config: ToolRuntimeConfig,
485 ) -> Self {
486 Self::new_with_global_config_dir_tool_config(
487 provider,
488 model,
489 config,
490 workspace_root,
491 hooks,
492 user_system_prompt,
493 initial_messages,
494 resume,
495 tool_config,
496 None,
497 )
498 }
499
500 #[allow(clippy::too_many_arguments)]
506 pub fn new_with_global_config_dir(
507 provider: Box<dyn Provider>,
508 model: String,
509 config: OpiConfig,
510 workspace_root: PathBuf,
511 hooks: Box<dyn AgentHooks>,
512 user_system_prompt: Option<String>,
513 initial_messages: Vec<AgentMessage>,
514 resume: Option<ResumeInfo>,
515 tool_selection: ToolSelection,
516 global_config_dir: Option<PathBuf>,
517 ) -> Self {
518 let tool_config = ToolRuntimeConfig::resolve(RunMode::Interactive, true, tool_selection)
519 .expect("interactive tool config should be valid");
520 Self::new_with_global_config_dir_tool_config(
521 provider,
522 model,
523 config,
524 workspace_root,
525 hooks,
526 user_system_prompt,
527 initial_messages,
528 resume,
529 tool_config,
530 global_config_dir,
531 )
532 }
533
534 #[allow(clippy::too_many_arguments)]
537 pub fn new_with_global_config_dir_tool_config(
538 provider: Box<dyn Provider>,
539 model: String,
540 config: OpiConfig,
541 workspace_root: PathBuf,
542 hooks: Box<dyn AgentHooks>,
543 user_system_prompt: Option<String>,
544 initial_messages: Vec<AgentMessage>,
545 resume: Option<ResumeInfo>,
546 tool_config: ToolRuntimeConfig,
547 global_config_dir: Option<PathBuf>,
548 ) -> Self {
549 Self::new_with_build_options(
550 provider,
551 model,
552 config,
553 workspace_root,
554 hooks,
555 user_system_prompt,
556 initial_messages,
557 resume,
558 tool_config,
559 global_config_dir,
560 HarnessBuildOptions::default(),
561 )
562 }
563
564 #[allow(clippy::too_many_arguments)]
565 fn new_with_build_options(
566 provider: Box<dyn Provider>,
567 model: String,
568 config: OpiConfig,
569 workspace_root: PathBuf,
570 hooks: Box<dyn AgentHooks>,
571 user_system_prompt: Option<String>,
572 initial_messages: Vec<AgentMessage>,
573 resume: Option<ResumeInfo>,
574 tool_config: ToolRuntimeConfig,
575 global_config_dir: Option<PathBuf>,
576 build_options: HarnessBuildOptions,
577 ) -> Self {
578 let mut hooks = hooks;
579 let mut extension_tools = Vec::new();
580 let mut injected_extension_names = Vec::new();
581 let mut extension_event_registry = None;
582 let extension_registry = build_options.extension_registry;
583 let (model_registry, model_registry_diagnostics) =
584 Self::build_model_registry(provider.as_ref(), extension_registry.as_ref());
585 if let Some(registry) = extension_registry {
586 extension_event_registry = Some(registry.clone());
587 injected_extension_names = registry
588 .names()
589 .into_iter()
590 .map(str::to_owned)
591 .collect::<Vec<_>>();
592 extension_tools =
593 filter_extension_tools(registry.collect_tools(), &build_options.tool_selection);
594 hooks = registry.wrap_hooks(hooks);
595 }
596
597 let mut tools = Self::build_tools(&workspace_root, &tool_config);
598 tools.extend(extension_tools);
599 let tool_defs: Vec<_> = tools.iter().map(|t| t.definition()).collect();
600 let mut builder = SystemPromptBuilder::new().tools(tool_defs);
601 if let Some(content) = user_system_prompt {
602 builder = builder.user_system(content);
603 }
604 let resolved_global_dir = global_config_dir.unwrap_or_else(crate::config::user_config_dir);
605 let mut resources = match build_options.resource_metadata {
606 Some(metadata) => HarnessResources {
607 metadata,
608 theme_resources: Vec::new(),
609 },
610 None => Self::discover_resources(
611 &workspace_root,
612 &config,
613 Some(resolved_global_dir.as_path()),
614 build_options.resource_layers,
615 ),
616 };
617 resources
618 .metadata
619 .diagnostics
620 .extend(model_registry_diagnostics);
621 for name in injected_extension_names {
622 resources.metadata.add_extension_name(name);
623 }
624
625 let context = context_files::discover_context_files(
626 &workspace_root,
627 Some(resolved_global_dir.as_path()),
628 );
629 let resource_prompt = resources.metadata.format_for_system_prompt();
630 let mut context_content = context.content;
631 if !resource_prompt.is_empty() {
632 if !context_content.is_empty() {
633 context_content.push_str("\n\n");
634 }
635 context_content.push_str(&resource_prompt);
636 }
637 if !context_content.is_empty() {
638 builder = builder.context_files(context_content);
639 }
640 let system_prompt = builder.build();
641
642 let (thinking, max_tokens) =
643 initial_thinking_request_config(&model_registry, &model, &config);
644 let agent_config = AgentLoopConfig {
645 max_turns: config.defaults.max_iterations,
646 max_tokens,
647 retry: Some(config.retry.clone()),
648 thinking,
649 ..Default::default()
650 };
651
652 let mut agent = Agent::new(
653 provider,
654 tools,
655 model.clone(),
656 Some(system_prompt.clone()),
657 agent_config,
658 hooks,
659 );
660 if let Some(registry) = extension_event_registry {
661 agent.subscribe(Box::new(move |event| registry.dispatch_event(event)));
662 }
663
664 let initial_len = initial_messages.len();
665 if !initial_messages.is_empty() {
666 agent.set_initial_messages(initial_messages);
667 }
668
669 let cwd = if let Some(ref info) = resume {
670 info.original_cwd.to_string_lossy().into_owned()
674 } else {
675 std::env::current_dir()
676 .unwrap_or_default()
677 .to_string_lossy()
678 .into_owned()
679 };
680 let compaction_config = opi_agent::compaction::CompactionConfig {
681 enabled: config.compaction.enabled,
682 threshold_tokens: config.compaction.threshold_tokens,
683 };
684
685 let session = if let Some(info) = resume {
686 SessionCoordinator::open_existing(
687 info.path,
688 info.session_id,
689 &info.entries,
690 initial_len,
691 compaction_config,
692 model.clone(),
693 )
694 .ok()
695 } else {
696 let session_dir = crate::session_cli::session_dir();
697 SessionCoordinator::new(&session_dir, &cwd, compaction_config, model.clone()).ok()
698 };
699
700 Self {
701 agent,
702 config,
703 system_prompt,
704 resources,
705 model_registry,
706 session,
707 turn_offset: initial_len,
708 pending_images: Vec::new(),
709 }
710 }
711
712 pub fn add_tool(&mut self, tool: Box<dyn Tool>) {
714 self.agent.add_tool(tool);
715 }
716
717 pub fn queue_images(&mut self, images: Vec<opi_ai::message::InputContent>) {
719 self.pending_images.extend(images);
720 }
721
722 pub fn take_pending_images(&mut self) -> Vec<opi_ai::message::InputContent> {
724 std::mem::take(&mut self.pending_images)
725 }
726
727 pub fn model_picker_items(&self) -> Vec<opi_tui::SelectItem> {
729 let current_provider = self.agent.provider().id();
730 crate::picker::model_picker_items(&self.model_registry)
731 .into_iter()
732 .filter(|item| item.metadata == current_provider)
733 .collect()
734 }
735
736 pub fn set_model(&mut self, model: String) {
738 self.agent.set_model(model);
739 }
740
741 pub fn set_model_validated(&mut self, model: String) -> Result<&str, String> {
743 let (requested_provider, requested_model) = parse_model_spec(&model)?;
744 let current_provider = self.agent.provider().id();
745 if requested_provider != current_provider {
746 return Err(format!(
747 "cannot switch provider from {current_provider} to {requested_provider} at runtime"
748 ));
749 }
750
751 let requested_model_info = self.model_info(requested_model);
752 let Some(requested_model_info) = requested_model_info else {
753 return Err(format!(
754 "unknown model '{requested_model}' for provider '{requested_provider}'"
755 ));
756 };
757
758 self.validate_current_thinking_for_model(&requested_model_info)?;
759
760 self.agent.set_model(model);
761 Ok(self.agent.model())
762 }
763
764 pub fn set_thinking_level(&mut self, level: &str) -> Result<RuntimeThinkingState, String> {
766 let default_budget = self.config.thinking.budget_tokens as u64;
767 let budget_tokens = match level {
768 "off" => None,
769 "low" => Some(2_048),
770 "medium" => Some(default_budget),
771 "high" => Some(default_budget.max(20_000)),
772 _ => {
773 return Err(format!(
774 "invalid thinking level '{level}': expected off, low, medium, or high"
775 ));
776 }
777 };
778
779 let (thinking, max_tokens) = match budget_tokens {
780 Some(budget_tokens) => {
781 let (thinking, max_tokens) = request_config_for_thinking_budget(budget_tokens)?;
782 if let Some(model) = self.active_model_info() {
783 validate_thinking_budget_for_model(&model, budget_tokens, max_tokens)?;
784 }
785 (Some(thinking), Some(max_tokens))
786 }
787 None => (None, None),
788 };
789
790 self.agent.set_max_tokens(max_tokens);
791 self.agent.set_thinking_config(thinking);
792 let state = self.agent.thinking_config();
793 Ok(RuntimeThinkingState {
794 level: level.to_owned(),
795 enabled: state.enabled,
796 budget_tokens: state.budget_tokens,
797 })
798 }
799
800 fn active_model_info(&self) -> Option<ModelInfo> {
801 let Ok((provider_id, model_id)) = parse_model_spec(self.agent.model()) else {
802 return None;
803 };
804 if provider_id != self.agent.provider().id() {
805 return None;
806 }
807 self.model_info(model_id)
808 }
809
810 fn model_info(&self, model_id: &str) -> Option<ModelInfo> {
811 let spec = format!("{}:{model_id}", self.agent.provider().id());
812 self.model_registry
813 .resolve(&spec)
814 .ok()
815 .map(|(_, model)| model.clone())
816 }
817
818 fn validate_current_thinking_for_model(&self, model: &ModelInfo) -> Result<(), String> {
819 let thinking = self.agent.thinking_config();
820 if !thinking.enabled {
821 return Ok(());
822 }
823 let Some(budget_tokens) = thinking.budget_tokens else {
824 return Ok(());
825 };
826 let max_tokens = max_tokens_for_thinking_budget(budget_tokens)?;
827 validate_thinking_budget_for_model(model, budget_tokens, max_tokens)
828 }
829
830 pub fn resume_session_id(&mut self, session_id: &str) -> Result<usize, String> {
832 let dir = crate::session_cli::session_dir();
833 let session =
834 crate::session_cli::resume_session(&dir, session_id).map_err(|e| e.to_string())?;
835 let messages = crate::session_cli::reconstruct_context(&session.entries);
836 let message_count = messages.len();
837 self.agent.replace_messages(messages);
838
839 let compaction_config = opi_agent::compaction::CompactionConfig {
840 enabled: self.config.compaction.enabled,
841 threshold_tokens: self.config.compaction.threshold_tokens,
842 };
843 self.session = SessionCoordinator::open_existing(
844 session.path,
845 session.header.id,
846 &session.entries,
847 message_count,
848 compaction_config,
849 self.agent.model().to_string(),
850 )
851 .ok();
852 self.turn_offset = message_count;
853 Ok(message_count)
854 }
855
856 pub fn branch_picker_items(&self) -> Result<Vec<opi_tui::SelectItem>, String> {
858 let session = self
859 .session
860 .as_ref()
861 .ok_or_else(|| "no active session".to_owned())?;
862 let (_, entries) = opi_agent::session::SessionReader::read_all(session.session_path())
863 .map_err(|e| format!("failed to read session: {e}"))?;
864 let tree = opi_agent::session_branch::SessionTree::from_entries(&entries);
865 Ok(crate::picker::branch_picker_items(&tree))
866 }
867
868 pub fn resume_session_branch_tip(&mut self, tip_id: &str) -> Result<usize, String> {
870 let session = self
871 .session
872 .as_mut()
873 .ok_or_else(|| "no active session".to_owned())?;
874 let path = session.session_path().to_path_buf();
875 let session_id = session.session_id().to_owned();
876 let (_, entries) = opi_agent::session::SessionReader::read_all(&path)
877 .map_err(|e| format!("failed to read session: {e}"))?;
878 let tree = opi_agent::session_branch::SessionTree::from_entries(&entries);
879 if !tree.branches().iter().any(|branch| branch.tip_id == tip_id) {
880 return Err(format!("unknown branch tip: {tip_id}"));
881 }
882
883 session
884 .append_leaf(tip_id)
885 .map_err(|e| format!("failed to select branch: {e}"))?;
886 let (_, entries) = opi_agent::session::SessionReader::read_all(&path)
887 .map_err(|e| format!("failed to read selected branch: {e}"))?;
888 let messages = crate::session_cli::reconstruct_context(&entries);
889 let message_count = messages.len();
890 self.agent.replace_messages(messages);
891
892 let compaction_config = opi_agent::compaction::CompactionConfig {
893 enabled: self.config.compaction.enabled,
894 threshold_tokens: self.config.compaction.threshold_tokens,
895 };
896 self.session = Some(
897 SessionCoordinator::open_existing(
898 path,
899 session_id,
900 &entries,
901 message_count,
902 compaction_config,
903 self.agent.model().to_string(),
904 )
905 .map_err(|e| format!("failed to reopen selected branch: {e}"))?,
906 );
907 self.turn_offset = message_count;
908 Ok(message_count)
909 }
910
911 pub async fn prompt(&mut self, text: &str) -> Result<Vec<AgentMessage>, AgentError> {
913 let offset = self.turn_offset;
914 let messages = self.agent.prompt(text).await?;
915 let new = &messages[offset..];
916 self.persist_turn(new, offset);
917 let final_messages = self.current_messages();
918 self.turn_offset = final_messages.len();
919 Ok(final_messages)
920 }
921
922 pub async fn prompt_with_content(
925 &mut self,
926 content: Vec<opi_ai::message::InputContent>,
927 ) -> Result<Vec<AgentMessage>, AgentError> {
928 let offset = self.turn_offset;
929 let messages = self.agent.prompt_with_content(content).await?;
930 let new = &messages[offset..];
931 self.persist_turn(new, offset);
932 let final_messages = self.current_messages();
933 self.turn_offset = final_messages.len();
934 Ok(final_messages)
935 }
936
937 pub async fn continue_(&mut self, text: &str) -> Result<Vec<AgentMessage>, AgentError> {
939 let offset = self.turn_offset;
940 let messages = self.agent.continue_(text).await?;
941 let new = &messages[offset..];
942 self.persist_turn(new, offset);
943 let final_messages = self.current_messages();
944 self.turn_offset = final_messages.len();
945 Ok(final_messages)
946 }
947
948 fn aggregate_turn_usage(messages: &[AgentMessage]) -> opi_ai::stream::Usage {
955 let mut total = opi_ai::stream::Usage::default();
956 for m in messages {
957 if let AgentMessage::Llm(Message::Assistant(a)) = m {
958 total.input_tokens = total.input_tokens.saturating_add(a.usage.input_tokens);
959 total.output_tokens = total.output_tokens.saturating_add(a.usage.output_tokens);
960 total.cache_read_tokens = total
961 .cache_read_tokens
962 .saturating_add(a.usage.cache_read_tokens);
963 total.cache_write_tokens = total
964 .cache_write_tokens
965 .saturating_add(a.usage.cache_write_tokens);
966 }
967 }
968 total
969 }
970
971 fn persist_turn(&mut self, messages: &[AgentMessage], turn_start_agent_index: usize) {
978 if let Some(session) = &mut self.session {
979 let usage = Self::aggregate_turn_usage(messages);
980 let compaction_reason =
981 match session.on_turn_end(messages, &usage, turn_start_agent_index) {
982 Ok(reason) => reason,
983 Err(e) => {
984 self.agent.emit_event(AgentEvent::SessionPersistError {
985 message: format!("session write failed: {e}"),
986 });
987 return;
988 }
989 };
990
991 if let Some(reason) = compaction_reason {
992 self.agent
993 .emit_event(AgentEvent::CompactionStart { reason });
994 match session.execute_compaction(reason) {
995 Ok(Some(out)) => {
996 let wire = to_wire_result(&out);
997 self.agent.replace_messages(out.new_agent_messages);
998 self.agent.emit_event(AgentEvent::CompactionEnd {
999 reason,
1000 result: Some(wire),
1001 aborted: false,
1002 error_message: None,
1003 });
1004 }
1005 Ok(None) => {
1006 self.agent.emit_event(AgentEvent::CompactionEnd {
1007 reason,
1008 result: None,
1009 aborted: true,
1010 error_message: Some("compaction produced no output".into()),
1011 });
1012 }
1013 Err(e) => {
1014 self.agent.emit_event(AgentEvent::CompactionEnd {
1018 reason,
1019 result: None,
1020 aborted: true,
1021 error_message: Some(format!("compaction persist failed: {e}")),
1022 });
1023 self.agent.emit_event(AgentEvent::SessionPersistError {
1024 message: format!("compaction write failed: {e}"),
1025 });
1026 }
1027 }
1028 }
1029 }
1030 }
1031
1032 fn current_messages(&self) -> Vec<AgentMessage> {
1034 self.agent.messages_snapshot()
1039 }
1040
1041 pub fn model(&self) -> &str {
1043 self.agent.model()
1044 }
1045
1046 pub fn steer(&self, message: String) {
1048 self.agent.steer(message);
1049 }
1050
1051 pub fn follow_up(&self, message: String) {
1053 self.agent.follow_up(message);
1054 }
1055
1056 pub fn subscribe(&mut self, callback: Box<dyn Fn(&AgentEvent) + Send + Sync>) {
1058 self.agent.subscribe(callback);
1059 }
1060
1061 pub fn system_prompt(&self) -> &str {
1063 &self.system_prompt
1064 }
1065
1066 pub fn resource_metadata(&self) -> &DiscoveredResourceMetadata {
1068 &self.resources.metadata
1069 }
1070
1071 pub fn resource_metadata_json(&self) -> serde_json::Value {
1073 self.resources.metadata.to_rpc_json()
1074 }
1075
1076 pub fn resolve_theme(
1078 &self,
1079 name: &str,
1080 ) -> Result<opi_tui::Theme, crate::theme_discovery::ThemeDiscoveryError> {
1081 crate::theme_discovery::ThemeRegistry::from_resources(
1082 self.resources.theme_resources.clone(),
1083 )
1084 .resolve_theme(name)
1085 }
1086
1087 pub fn config(&self) -> &OpiConfig {
1089 &self.config
1090 }
1091
1092 pub fn cancel(&self) {
1094 self.agent.abort();
1095 }
1096
1097 pub fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
1099 self.agent.cancel_token()
1100 }
1101
1102 pub fn control_handle(&self) -> opi_agent::agent::AgentControl {
1104 self.agent.control_handle()
1105 }
1106
1107 pub fn reset_cancel_if_cancelled(&mut self) {
1109 self.agent.reset_cancel_if_cancelled();
1110 }
1111
1112 pub fn session(&self) -> Option<&SessionCoordinator> {
1114 self.session.as_ref()
1115 }
1116
1117 pub fn compact(
1121 &mut self,
1122 reason: opi_agent::session_event::CompactionReason,
1123 ) -> Result<Option<opi_agent::session_event::CompactionResult>, String> {
1124 let session = match &mut self.session {
1125 Some(s) => s,
1126 None => return Err("no active session".into()),
1127 };
1128 let result = session
1129 .execute_compaction(reason)
1130 .map_err(|e| format!("compaction failed: {e}"))?;
1131 if let Some(out) = &result {
1132 self.agent.replace_messages(out.new_agent_messages.clone());
1133 }
1134 Ok(result.map(|out| crate::session_coordinator::to_wire_result(&out)))
1135 }
1136
1137 fn build_tools(workspace_root: &Path, tool_config: &ToolRuntimeConfig) -> Vec<Box<dyn Tool>> {
1138 let read_policy = match tool_config.run_mode {
1139 RunMode::Interactive => crate::tool::PathPolicy::AllowOutsideWorkspace,
1140 RunMode::NonInteractive => crate::tool::PathPolicy::WorkspaceOnly,
1141 };
1142
1143 let mut tools: Vec<(&str, Box<dyn Tool>)> = vec![
1144 (
1145 "read",
1146 Box::new(ReadTool::new_with_policy(
1147 workspace_root.to_path_buf(),
1148 read_policy,
1149 )),
1150 ),
1151 (
1152 "write",
1153 Box::new(WriteTool::new(workspace_root.to_path_buf())),
1154 ),
1155 (
1156 "edit",
1157 Box::new(EditTool::new(workspace_root.to_path_buf())),
1158 ),
1159 (
1160 "bash",
1161 Box::new(BashTool::new(workspace_root.to_path_buf())),
1162 ),
1163 (
1164 "grep",
1165 Box::new(GrepTool::new(workspace_root.to_path_buf())),
1166 ),
1167 (
1168 "find",
1169 Box::new(FindTool::new(workspace_root.to_path_buf())),
1170 ),
1171 ("ls", Box::new(LsTool::new(workspace_root.to_path_buf()))),
1172 (
1173 "glob",
1174 Box::new(GlobTool::new(workspace_root.to_path_buf())),
1175 ),
1176 ];
1177
1178 tools
1179 .drain(..)
1180 .filter(|(name, _)| {
1181 tool_config
1182 .active_tool_names
1183 .iter()
1184 .any(|active| active == name)
1185 })
1186 .map(|(_, tool)| tool)
1187 .collect()
1188 }
1189
1190 fn discover_resources(
1191 workspace_root: &Path,
1192 config: &OpiConfig,
1193 user_config_dir: Option<&Path>,
1194 resource_layers: Option<ResourceDiscoveryLayers>,
1195 ) -> HarnessResources {
1196 let explicit = ExplicitResourcePaths {
1197 extensions: config.extensions.paths.clone(),
1198 packages: config.packages.paths.clone(),
1199 ..Default::default()
1200 };
1201 let mut layers = resource_layers.unwrap_or_else(|| {
1202 standard_discovery_layers(workspace_root, user_config_dir, explicit)
1203 });
1204 let mut metadata = DiscoveredResourceMetadata::default();
1205
1206 let packages = match crate::package_discovery::discover_packages(&layers.packages) {
1207 Ok(packages) => packages,
1208 Err(e) => {
1209 metadata
1210 .diagnostics
1211 .push(format!("package discovery failed: {e}"));
1212 Vec::new()
1213 }
1214 };
1215 metadata.packages = packages
1216 .iter()
1217 .map(|package| ResourceMetadataEntry {
1218 name: package.manifest.name.clone(),
1219 description: Some(package.manifest.description.clone()),
1220 version: package.manifest.version.clone(),
1221 })
1222 .collect();
1223
1224 let package_layers = crate::package_discovery::package_composed_resource_layers(&packages);
1225 metadata.diagnostics.extend(package_layers.diagnostics);
1226 layers.extensions.extend(package_layers.extensions);
1227 layers.skills.extend(package_layers.skills);
1228 layers.fragments.extend(package_layers.fragments);
1229 layers.themes.extend(package_layers.themes);
1230
1231 match crate::resource::discover_extension_resources(&layers.extensions) {
1232 Ok(extensions) => {
1233 metadata.extensions = extensions
1234 .iter()
1235 .map(|extension| ResourceMetadataEntry {
1236 name: extension.manifest.name.clone(),
1237 description: extension.manifest.description.clone(),
1238 version: extension.manifest.version.clone(),
1239 })
1240 .collect();
1241 }
1242 Err(e) => metadata
1243 .diagnostics
1244 .push(format!("extension discovery failed: {e}")),
1245 }
1246
1247 match crate::skill::discover_skills(&layers.skills) {
1248 Ok(skills) => {
1249 metadata.skills = skills
1250 .iter()
1251 .map(|skill| ResourceMetadataEntry {
1252 name: skill.manifest.name.clone(),
1253 description: Some(skill.manifest.description.clone()),
1254 version: None,
1255 })
1256 .collect();
1257 }
1258 Err(e) => metadata
1259 .diagnostics
1260 .push(format!("skill discovery failed: {e}")),
1261 }
1262
1263 match crate::prompt_fragment::discover_fragments(&layers.fragments) {
1264 Ok(fragments) => {
1265 metadata.fragments = fragments
1266 .iter()
1267 .map(|fragment| ResourceMetadataEntry {
1268 name: fragment.manifest.name.clone(),
1269 description: Some(fragment.manifest.description.clone()),
1270 version: None,
1271 })
1272 .collect();
1273 }
1274 Err(e) => metadata
1275 .diagnostics
1276 .push(format!("fragment discovery failed: {e}")),
1277 }
1278
1279 let theme_resources = match crate::theme_discovery::discover_themes(&layers.themes) {
1280 Ok(themes) => {
1281 metadata.themes = themes
1282 .iter()
1283 .map(|theme| ResourceMetadataEntry {
1284 name: theme.manifest.name.clone(),
1285 description: Some(theme.manifest.description.clone()),
1286 version: None,
1287 })
1288 .collect();
1289 themes
1290 }
1291 Err(e) => {
1292 metadata
1293 .diagnostics
1294 .push(format!("theme discovery failed: {e}"));
1295 Vec::new()
1296 }
1297 };
1298
1299 HarnessResources {
1300 metadata,
1301 theme_resources,
1302 }
1303 }
1304
1305 fn build_model_registry(
1306 provider: &dyn Provider,
1307 extension_registry: Option<&ExtensionRegistry>,
1308 ) -> (opi_ai::ProviderRegistry, Vec<String>) {
1309 let mut registry = opi_ai::ProviderRegistry::new();
1310 let mut diagnostics = Vec::new();
1311
1312 if let Some(extension_registry) = extension_registry {
1313 for provider in extension_registry.collect_providers() {
1314 if let Err(e) = registry.register_provider(provider) {
1315 diagnostics.push(format!("extension provider registration failed: {e}"));
1316 }
1317 }
1318 }
1319
1320 if let Err(e) =
1321 registry.register_provider(Box::new(MetadataProvider::from_provider(provider)))
1322 {
1323 diagnostics.push(format!("active provider metadata registration failed: {e}"));
1324 }
1325
1326 if let Some(extension_registry) = extension_registry {
1327 for (provider_id, model) in extension_registry.collect_model_overrides() {
1328 if let Err(e) = registry.register_model(&provider_id, model) {
1329 diagnostics.push(format!("extension model override registration failed: {e}"));
1330 }
1331 }
1332 }
1333
1334 (registry, diagnostics)
1335 }
1336}
1337
1338fn parse_model_spec(spec: &str) -> Result<(&str, &str), String> {
1339 let Some((provider, model)) = spec.split_once(':') else {
1340 return Err("invalid model spec: expected provider:model".into());
1341 };
1342 if provider.is_empty() || model.is_empty() {
1343 return Err("invalid model spec: expected provider:model".into());
1344 }
1345 Ok((provider, model))
1346}
1347
1348fn initial_thinking_request_config(
1349 registry: &opi_ai::ProviderRegistry,
1350 model: &str,
1351 config: &OpiConfig,
1352) -> (Option<ThinkingConfig>, Option<u64>) {
1353 if !config.thinking.enabled {
1354 return (None, None);
1355 }
1356
1357 let budget_tokens = config.thinking.budget_tokens as u64;
1358 let Ok((mut thinking, mut max_tokens)) = request_config_for_thinking_budget(budget_tokens)
1359 else {
1360 return (None, None);
1361 };
1362
1363 if let Ok((_, model)) = registry.resolve(model) {
1364 if !model.supports_thinking {
1365 return (None, None);
1366 }
1367 if max_tokens > model.max_output_tokens {
1368 if model.max_output_tokens <= 1 {
1369 return (None, None);
1370 }
1371 let adjusted_budget = model.max_output_tokens - 1;
1372 let Ok((adjusted_thinking, adjusted_max_tokens)) =
1373 request_config_for_thinking_budget(adjusted_budget)
1374 else {
1375 return (None, None);
1376 };
1377 thinking = adjusted_thinking;
1378 max_tokens = adjusted_max_tokens;
1379 }
1380 }
1381
1382 (Some(thinking), Some(max_tokens))
1383}
1384
1385fn request_config_for_thinking_budget(budget_tokens: u64) -> Result<(ThinkingConfig, u64), String> {
1386 let max_tokens = max_tokens_for_thinking_budget(budget_tokens)?;
1387 Ok((
1388 ThinkingConfig {
1389 enabled: true,
1390 budget_tokens: Some(budget_tokens),
1391 },
1392 max_tokens,
1393 ))
1394}
1395
1396fn max_tokens_for_thinking_budget(budget_tokens: u64) -> Result<u64, String> {
1397 budget_tokens.checked_add(1).ok_or_else(|| {
1398 format!("thinking budget {budget_tokens} cannot fit a valid max_tokens value")
1399 })
1400}
1401
1402fn validate_thinking_budget_for_model(
1403 model: &ModelInfo,
1404 budget_tokens: u64,
1405 max_tokens: u64,
1406) -> Result<(), String> {
1407 if !model.supports_thinking {
1408 return Err(model_does_not_support_thinking(&model.id));
1409 }
1410 if max_tokens > model.max_output_tokens {
1411 return Err(thinking_budget_exceeds_model_limit(
1412 budget_tokens,
1413 max_tokens,
1414 model.max_output_tokens,
1415 &model.id,
1416 ));
1417 }
1418 Ok(())
1419}
1420
1421fn model_does_not_support_thinking(model_id: &str) -> String {
1422 format!("model '{model_id}' does not support thinking")
1423}
1424
1425fn thinking_budget_exceeds_model_limit(
1426 budget_tokens: u64,
1427 max_tokens: u64,
1428 max_output_tokens: u64,
1429 model_id: &str,
1430) -> String {
1431 format!(
1432 "thinking budget {budget_tokens} requires max_tokens {max_tokens}, exceeding max output tokens {max_output_tokens} for model '{model_id}'"
1433 )
1434}
1435
1436pub(crate) fn agent_messages_to_llm(messages: &[AgentMessage]) -> Vec<Message> {
1451 let mut result = Vec::with_capacity(messages.len());
1452 for msg in messages {
1453 match msg {
1454 AgentMessage::Llm(m) => result.push(m.clone()),
1455 AgentMessage::CompactionSummary(summary) => {
1456 result.push(Message::User(opi_ai::message::UserMessage {
1457 content: vec![opi_ai::message::InputContent::Text {
1458 text: format!(
1459 "[Context was compacted. Summary of earlier conversation: {}]",
1460 summary.summary
1461 ),
1462 }],
1463 timestamp_ms: 0,
1464 }));
1465 }
1466 _ => {}
1467 }
1468 }
1469 result
1470}
1471
1472pub struct CodingAgentHooks;
1474
1475impl AgentHooks for CodingAgentHooks {
1476 fn convert_to_llm(&self, messages: &[AgentMessage]) -> Result<Vec<Message>, AgentError> {
1477 Ok(agent_messages_to_llm(messages))
1478 }
1479}
1480
1481pub struct InteractiveCodingHooks;
1486
1487impl InteractiveCodingHooks {
1488 pub fn new(_allow_mutating: bool) -> Self {
1489 Self
1490 }
1491}
1492
1493impl AgentHooks for InteractiveCodingHooks {
1494 fn convert_to_llm(&self, messages: &[AgentMessage]) -> Result<Vec<Message>, AgentError> {
1495 Ok(agent_messages_to_llm(messages))
1496 }
1497}