use std::path::{Path, PathBuf};
use opi_agent::Agent;
use opi_agent::event::AgentEvent;
use opi_agent::extension::ExtensionRegistry;
use opi_agent::hooks::AgentHooks;
use opi_agent::loop_types::{AgentError, AgentLoopConfig};
use opi_agent::message::AgentMessage;
use opi_agent::tool::Tool;
use opi_ai::message::Message;
use opi_ai::provider::{EventStream, ModelInfo, Provider, ThinkingConfig};
use crate::config::OpiConfig;
use crate::context_files;
use crate::policy::{RunMode, ToolRuntimeConfig, ToolSelection};
use crate::prompt::SystemPromptBuilder;
use crate::resource::{ExplicitResourcePaths, ResourceDiscoveryLayers, standard_discovery_layers};
use crate::session_coordinator::{SessionCoordinator, to_wire_result};
use crate::tool::{BashTool, EditTool, FindTool, GlobTool, GrepTool, LsTool, ReadTool, WriteTool};
pub struct ResumeInfo {
pub path: PathBuf,
pub session_id: String,
pub entries: Vec<opi_agent::session::SessionEntry>,
pub original_cwd: PathBuf,
}
pub struct CodingHarness {
agent: Agent,
config: OpiConfig,
system_prompt: String,
resources: HarnessResources,
model_registry: opi_ai::ProviderRegistry,
session: Option<SessionCoordinator>,
turn_offset: usize,
pending_images: Vec<opi_ai::message::InputContent>,
}
pub struct RuntimeThinkingState {
pub level: String,
pub enabled: bool,
pub budget_tokens: Option<u64>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize)]
pub struct DiscoveredResourceMetadata {
pub extensions: Vec<ResourceMetadataEntry>,
pub packages: Vec<ResourceMetadataEntry>,
pub skills: Vec<ResourceMetadataEntry>,
pub fragments: Vec<ResourceMetadataEntry>,
pub themes: Vec<ResourceMetadataEntry>,
pub diagnostics: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct ResourceMetadataEntry {
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Clone, Default)]
struct HarnessResources {
metadata: DiscoveredResourceMetadata,
theme_resources: Vec<crate::theme_discovery::ThemeResource>,
}
struct MetadataProvider {
id: String,
models: Vec<ModelInfo>,
}
impl MetadataProvider {
fn from_provider(provider: &dyn Provider) -> Self {
Self {
id: provider.id().to_owned(),
models: provider.models().to_vec(),
}
}
}
impl Provider for MetadataProvider {
fn id(&self) -> &str {
&self.id
}
fn models(&self) -> &[ModelInfo] {
&self.models
}
fn stream(&self, _request: opi_ai::provider::Request) -> EventStream {
Box::pin(futures_util::stream::empty())
}
}
impl DiscoveredResourceMetadata {
fn format_for_system_prompt(&self) -> String {
let mut sections = Vec::new();
push_metadata_section(&mut sections, "Discovered packages", &self.packages);
push_metadata_section(&mut sections, "Discovered extensions", &self.extensions);
push_metadata_section(&mut sections, "Discovered skills", &self.skills);
push_metadata_section(
&mut sections,
"Discovered prompt fragments",
&self.fragments,
);
push_metadata_section(&mut sections, "Discovered themes", &self.themes);
if !self.diagnostics.is_empty() {
sections.push(format!(
"Resource discovery diagnostics:\n{}",
self.diagnostics
.iter()
.map(|diagnostic| format!("- {diagnostic}"))
.collect::<Vec<_>>()
.join("\n")
));
}
sections.join("\n\n")
}
pub fn to_rpc_json(&self) -> serde_json::Value {
serde_json::json!({
"extensions": metadata_names(&self.extensions),
"packages": metadata_names(&self.packages),
"skills": metadata_names(&self.skills),
"fragments": metadata_names(&self.fragments),
"themes": metadata_names(&self.themes),
"diagnostics": self.diagnostics.clone(),
})
}
fn add_extension_name(&mut self, name: String) {
if self.extensions.iter().any(|entry| entry.name == name) {
return;
}
self.extensions.push(ResourceMetadataEntry {
name,
description: None,
version: None,
});
self.extensions.sort_by(|a, b| a.name.cmp(&b.name));
}
}
fn metadata_names(entries: &[ResourceMetadataEntry]) -> Vec<&str> {
entries.iter().map(|entry| entry.name.as_str()).collect()
}
fn push_metadata_section(
sections: &mut Vec<String>,
title: &str,
entries: &[ResourceMetadataEntry],
) {
if entries.is_empty() {
return;
}
let lines = entries
.iter()
.map(|entry| {
let mut line = format!("- {}", entry.name);
if let Some(description) = &entry.description {
line.push_str(": ");
line.push_str(description);
}
if let Some(version) = &entry.version {
line.push_str(" v");
line.push_str(version);
}
line
})
.collect::<Vec<_>>()
.join("\n");
sections.push(format!("{title}:\n{lines}"));
}
fn filter_extension_tools(
tools: Vec<Box<dyn Tool>>,
selection: &ToolSelection,
) -> Vec<Box<dyn Tool>> {
match selection {
ToolSelection::Default | ToolSelection::NoBuiltin => tools,
ToolSelection::Disabled => Vec::new(),
ToolSelection::Allowlist(names) => tools
.into_iter()
.filter(|tool| {
let name = tool.definition().name;
names.iter().any(|allowed| allowed == &name)
})
.collect(),
}
}
pub struct CodingHarnessBuilder {
provider: Box<dyn Provider>,
model: String,
config: OpiConfig,
workspace_root: PathBuf,
hooks: Option<Box<dyn AgentHooks>>,
user_system_prompt: Option<String>,
initial_messages: Vec<AgentMessage>,
resume: Option<ResumeInfo>,
tool_config: Option<ToolRuntimeConfig>,
tool_selection: ToolSelection,
global_config_dir: Option<PathBuf>,
extension_registry: Option<ExtensionRegistry>,
resource_layers: Option<ResourceDiscoveryLayers>,
resource_metadata: Option<DiscoveredResourceMetadata>,
}
impl CodingHarnessBuilder {
fn new(
provider: Box<dyn Provider>,
model: String,
config: OpiConfig,
workspace_root: PathBuf,
) -> Self {
Self {
provider,
model,
config,
workspace_root,
hooks: None,
user_system_prompt: None,
initial_messages: Vec::new(),
resume: None,
tool_config: None,
tool_selection: ToolSelection::Default,
global_config_dir: None,
extension_registry: None,
resource_layers: None,
resource_metadata: None,
}
}
pub fn hooks(mut self, hooks: Box<dyn AgentHooks>) -> Self {
self.hooks = Some(hooks);
self
}
pub fn user_system_prompt(mut self, prompt: impl Into<String>) -> Self {
self.user_system_prompt = Some(prompt.into());
self
}
pub fn initial_messages(mut self, messages: Vec<AgentMessage>) -> Self {
self.initial_messages = messages;
self
}
pub fn resume(mut self, resume: ResumeInfo) -> Self {
self.resume = Some(resume);
self
}
pub fn tool_selection(mut self, selection: ToolSelection) -> Self {
self.tool_selection = selection;
self
}
pub fn tool_config(mut self, config: ToolRuntimeConfig) -> Self {
self.tool_config = Some(config);
self
}
pub fn global_config_dir(mut self, dir: PathBuf) -> Self {
self.global_config_dir = Some(dir);
self
}
pub fn extension_registry(mut self, registry: ExtensionRegistry) -> Self {
self.extension_registry = Some(registry);
self
}
pub fn resource_layers(mut self, layers: ResourceDiscoveryLayers) -> Self {
self.resource_layers = Some(layers);
self
}
pub fn resource_metadata(mut self, metadata: DiscoveredResourceMetadata) -> Self {
self.resource_metadata = Some(metadata);
self
}
pub fn build(self) -> CodingHarness {
let tool_selection = self.tool_selection;
let tool_config = self.tool_config.unwrap_or_else(|| {
ToolRuntimeConfig::resolve(RunMode::Interactive, true, tool_selection.clone())
.expect("interactive tool config should be valid")
});
CodingHarness::new_with_build_options(
self.provider,
self.model,
self.config,
self.workspace_root,
self.hooks.unwrap_or_else(|| Box::new(CodingAgentHooks)),
self.user_system_prompt,
self.initial_messages,
self.resume,
tool_config,
self.global_config_dir,
HarnessBuildOptions {
extension_registry: self.extension_registry,
resource_layers: self.resource_layers,
resource_metadata: self.resource_metadata,
tool_selection,
},
)
}
}
struct HarnessBuildOptions {
extension_registry: Option<ExtensionRegistry>,
resource_layers: Option<ResourceDiscoveryLayers>,
resource_metadata: Option<DiscoveredResourceMetadata>,
tool_selection: ToolSelection,
}
impl Default for HarnessBuildOptions {
fn default() -> Self {
Self {
extension_registry: None,
resource_layers: None,
resource_metadata: None,
tool_selection: ToolSelection::Default,
}
}
}
impl CodingHarness {
pub fn builder(
provider: Box<dyn Provider>,
model: String,
config: OpiConfig,
workspace_root: PathBuf,
) -> CodingHarnessBuilder {
CodingHarnessBuilder::new(provider, model, config, workspace_root)
}
pub fn new(
provider: Box<dyn Provider>,
model: String,
config: OpiConfig,
workspace_root: PathBuf,
) -> Self {
Self::new_with_hooks(
provider,
model,
config,
workspace_root,
Box::new(CodingAgentHooks),
None,
Vec::new(),
ToolSelection::Default,
)
}
pub fn new_with_selection(
provider: Box<dyn Provider>,
model: String,
config: OpiConfig,
workspace_root: PathBuf,
tool_selection: ToolSelection,
) -> Self {
Self::new_with_hooks(
provider,
model,
config,
workspace_root,
Box::new(CodingAgentHooks),
None,
Vec::new(),
tool_selection,
)
}
pub fn new_with_tool_config(
provider: Box<dyn Provider>,
model: String,
config: OpiConfig,
workspace_root: PathBuf,
tool_config: ToolRuntimeConfig,
) -> Self {
Self::new_with_hooks_and_resume_tool_config(
provider,
model,
config,
workspace_root,
Box::new(CodingAgentHooks),
None,
Vec::new(),
None,
tool_config,
)
}
#[allow(clippy::too_many_arguments)]
pub fn new_with_hooks(
provider: Box<dyn Provider>,
model: String,
config: OpiConfig,
workspace_root: PathBuf,
hooks: Box<dyn AgentHooks>,
user_system_prompt: Option<String>,
initial_messages: Vec<AgentMessage>,
tool_selection: ToolSelection,
) -> Self {
Self::new_with_hooks_and_resume(
provider,
model,
config,
workspace_root,
hooks,
user_system_prompt,
initial_messages,
None,
tool_selection,
)
}
#[allow(clippy::too_many_arguments)]
pub fn new_with_hooks_and_resume(
provider: Box<dyn Provider>,
model: String,
config: OpiConfig,
workspace_root: PathBuf,
hooks: Box<dyn AgentHooks>,
user_system_prompt: Option<String>,
initial_messages: Vec<AgentMessage>,
resume: Option<ResumeInfo>,
tool_selection: ToolSelection,
) -> Self {
let tool_config = ToolRuntimeConfig::resolve(RunMode::Interactive, true, tool_selection)
.expect("interactive tool config should be valid");
Self::new_with_hooks_and_resume_tool_config(
provider,
model,
config,
workspace_root,
hooks,
user_system_prompt,
initial_messages,
resume,
tool_config,
)
}
#[allow(clippy::too_many_arguments)]
pub fn new_with_hooks_and_resume_tool_config(
provider: Box<dyn Provider>,
model: String,
config: OpiConfig,
workspace_root: PathBuf,
hooks: Box<dyn AgentHooks>,
user_system_prompt: Option<String>,
initial_messages: Vec<AgentMessage>,
resume: Option<ResumeInfo>,
tool_config: ToolRuntimeConfig,
) -> Self {
Self::new_with_global_config_dir_tool_config(
provider,
model,
config,
workspace_root,
hooks,
user_system_prompt,
initial_messages,
resume,
tool_config,
None,
)
}
#[allow(clippy::too_many_arguments)]
pub fn new_with_global_config_dir(
provider: Box<dyn Provider>,
model: String,
config: OpiConfig,
workspace_root: PathBuf,
hooks: Box<dyn AgentHooks>,
user_system_prompt: Option<String>,
initial_messages: Vec<AgentMessage>,
resume: Option<ResumeInfo>,
tool_selection: ToolSelection,
global_config_dir: Option<PathBuf>,
) -> Self {
let tool_config = ToolRuntimeConfig::resolve(RunMode::Interactive, true, tool_selection)
.expect("interactive tool config should be valid");
Self::new_with_global_config_dir_tool_config(
provider,
model,
config,
workspace_root,
hooks,
user_system_prompt,
initial_messages,
resume,
tool_config,
global_config_dir,
)
}
#[allow(clippy::too_many_arguments)]
pub fn new_with_global_config_dir_tool_config(
provider: Box<dyn Provider>,
model: String,
config: OpiConfig,
workspace_root: PathBuf,
hooks: Box<dyn AgentHooks>,
user_system_prompt: Option<String>,
initial_messages: Vec<AgentMessage>,
resume: Option<ResumeInfo>,
tool_config: ToolRuntimeConfig,
global_config_dir: Option<PathBuf>,
) -> Self {
Self::new_with_build_options(
provider,
model,
config,
workspace_root,
hooks,
user_system_prompt,
initial_messages,
resume,
tool_config,
global_config_dir,
HarnessBuildOptions::default(),
)
}
#[allow(clippy::too_many_arguments)]
fn new_with_build_options(
provider: Box<dyn Provider>,
model: String,
config: OpiConfig,
workspace_root: PathBuf,
hooks: Box<dyn AgentHooks>,
user_system_prompt: Option<String>,
initial_messages: Vec<AgentMessage>,
resume: Option<ResumeInfo>,
tool_config: ToolRuntimeConfig,
global_config_dir: Option<PathBuf>,
build_options: HarnessBuildOptions,
) -> Self {
let mut hooks = hooks;
let mut extension_tools = Vec::new();
let mut injected_extension_names = Vec::new();
let mut extension_event_registry = None;
let extension_registry = build_options.extension_registry;
let (model_registry, model_registry_diagnostics) =
Self::build_model_registry(provider.as_ref(), extension_registry.as_ref());
if let Some(registry) = extension_registry {
extension_event_registry = Some(registry.clone());
injected_extension_names = registry
.names()
.into_iter()
.map(str::to_owned)
.collect::<Vec<_>>();
extension_tools =
filter_extension_tools(registry.collect_tools(), &build_options.tool_selection);
hooks = registry.wrap_hooks(hooks);
}
let mut tools = Self::build_tools(&workspace_root, &tool_config);
tools.extend(extension_tools);
let tool_defs: Vec<_> = tools.iter().map(|t| t.definition()).collect();
let mut builder = SystemPromptBuilder::new().tools(tool_defs);
if let Some(content) = user_system_prompt {
builder = builder.user_system(content);
}
let resolved_global_dir = global_config_dir.unwrap_or_else(crate::config::user_config_dir);
let mut resources = match build_options.resource_metadata {
Some(metadata) => HarnessResources {
metadata,
theme_resources: Vec::new(),
},
None => Self::discover_resources(
&workspace_root,
&config,
Some(resolved_global_dir.as_path()),
build_options.resource_layers,
),
};
resources
.metadata
.diagnostics
.extend(model_registry_diagnostics);
for name in injected_extension_names {
resources.metadata.add_extension_name(name);
}
let context = context_files::discover_context_files(
&workspace_root,
Some(resolved_global_dir.as_path()),
);
let resource_prompt = resources.metadata.format_for_system_prompt();
let mut context_content = context.content;
if !resource_prompt.is_empty() {
if !context_content.is_empty() {
context_content.push_str("\n\n");
}
context_content.push_str(&resource_prompt);
}
if !context_content.is_empty() {
builder = builder.context_files(context_content);
}
let system_prompt = builder.build();
let (thinking, max_tokens) =
initial_thinking_request_config(&model_registry, &model, &config);
let agent_config = AgentLoopConfig {
max_turns: config.defaults.max_iterations,
max_tokens,
retry: Some(config.retry.clone()),
thinking,
..Default::default()
};
let mut agent = Agent::new(
provider,
tools,
model.clone(),
Some(system_prompt.clone()),
agent_config,
hooks,
);
if let Some(registry) = extension_event_registry {
agent.subscribe(Box::new(move |event| registry.dispatch_event(event)));
}
let initial_len = initial_messages.len();
if !initial_messages.is_empty() {
agent.set_initial_messages(initial_messages);
}
let cwd = if let Some(ref info) = resume {
info.original_cwd.to_string_lossy().into_owned()
} else {
std::env::current_dir()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
};
let compaction_config = opi_agent::compaction::CompactionConfig {
enabled: config.compaction.enabled,
threshold_tokens: config.compaction.threshold_tokens,
};
let session = if let Some(info) = resume {
SessionCoordinator::open_existing(
info.path,
info.session_id,
&info.entries,
initial_len,
compaction_config,
model.clone(),
)
.ok()
} else {
let session_dir = crate::session_cli::session_dir();
SessionCoordinator::new(&session_dir, &cwd, compaction_config, model.clone()).ok()
};
Self {
agent,
config,
system_prompt,
resources,
model_registry,
session,
turn_offset: initial_len,
pending_images: Vec::new(),
}
}
pub fn add_tool(&mut self, tool: Box<dyn Tool>) {
self.agent.add_tool(tool);
}
pub fn queue_images(&mut self, images: Vec<opi_ai::message::InputContent>) {
self.pending_images.extend(images);
}
pub fn take_pending_images(&mut self) -> Vec<opi_ai::message::InputContent> {
std::mem::take(&mut self.pending_images)
}
pub fn model_picker_items(&self) -> Vec<opi_tui::SelectItem> {
let current_provider = self.agent.provider().id();
crate::picker::model_picker_items(&self.model_registry)
.into_iter()
.filter(|item| item.metadata == current_provider)
.collect()
}
pub fn set_model(&mut self, model: String) {
self.agent.set_model(model);
}
pub fn set_model_validated(&mut self, model: String) -> Result<&str, String> {
let (requested_provider, requested_model) = parse_model_spec(&model)?;
let current_provider = self.agent.provider().id();
if requested_provider != current_provider {
return Err(format!(
"cannot switch provider from {current_provider} to {requested_provider} at runtime"
));
}
let requested_model_info = self.model_info(requested_model);
let Some(requested_model_info) = requested_model_info else {
return Err(format!(
"unknown model '{requested_model}' for provider '{requested_provider}'"
));
};
self.validate_current_thinking_for_model(&requested_model_info)?;
self.agent.set_model(model);
Ok(self.agent.model())
}
pub fn set_thinking_level(&mut self, level: &str) -> Result<RuntimeThinkingState, String> {
let default_budget = self.config.thinking.budget_tokens as u64;
let budget_tokens = match level {
"off" => None,
"low" => Some(2_048),
"medium" => Some(default_budget),
"high" => Some(default_budget.max(20_000)),
_ => {
return Err(format!(
"invalid thinking level '{level}': expected off, low, medium, or high"
));
}
};
let (thinking, max_tokens) = match budget_tokens {
Some(budget_tokens) => {
let (thinking, max_tokens) = request_config_for_thinking_budget(budget_tokens)?;
if let Some(model) = self.active_model_info() {
validate_thinking_budget_for_model(&model, budget_tokens, max_tokens)?;
}
(Some(thinking), Some(max_tokens))
}
None => (None, None),
};
self.agent.set_max_tokens(max_tokens);
self.agent.set_thinking_config(thinking);
let state = self.agent.thinking_config();
Ok(RuntimeThinkingState {
level: level.to_owned(),
enabled: state.enabled,
budget_tokens: state.budget_tokens,
})
}
fn active_model_info(&self) -> Option<ModelInfo> {
let Ok((provider_id, model_id)) = parse_model_spec(self.agent.model()) else {
return None;
};
if provider_id != self.agent.provider().id() {
return None;
}
self.model_info(model_id)
}
fn model_info(&self, model_id: &str) -> Option<ModelInfo> {
let spec = format!("{}:{model_id}", self.agent.provider().id());
self.model_registry
.resolve(&spec)
.ok()
.map(|(_, model)| model.clone())
}
fn validate_current_thinking_for_model(&self, model: &ModelInfo) -> Result<(), String> {
let thinking = self.agent.thinking_config();
if !thinking.enabled {
return Ok(());
}
let Some(budget_tokens) = thinking.budget_tokens else {
return Ok(());
};
let max_tokens = max_tokens_for_thinking_budget(budget_tokens)?;
validate_thinking_budget_for_model(model, budget_tokens, max_tokens)
}
pub fn resume_session_id(&mut self, session_id: &str) -> Result<usize, String> {
let dir = crate::session_cli::session_dir();
let session =
crate::session_cli::resume_session(&dir, session_id).map_err(|e| e.to_string())?;
let messages = crate::session_cli::reconstruct_context(&session.entries);
let message_count = messages.len();
self.agent.replace_messages(messages);
let compaction_config = opi_agent::compaction::CompactionConfig {
enabled: self.config.compaction.enabled,
threshold_tokens: self.config.compaction.threshold_tokens,
};
self.session = SessionCoordinator::open_existing(
session.path,
session.header.id,
&session.entries,
message_count,
compaction_config,
self.agent.model().to_string(),
)
.ok();
self.turn_offset = message_count;
Ok(message_count)
}
pub fn branch_picker_items(&self) -> Result<Vec<opi_tui::SelectItem>, String> {
let session = self
.session
.as_ref()
.ok_or_else(|| "no active session".to_owned())?;
let (_, entries) = opi_agent::session::SessionReader::read_all(session.session_path())
.map_err(|e| format!("failed to read session: {e}"))?;
let tree = opi_agent::session_branch::SessionTree::from_entries(&entries);
Ok(crate::picker::branch_picker_items(&tree))
}
pub fn resume_session_branch_tip(&mut self, tip_id: &str) -> Result<usize, String> {
let session = self
.session
.as_mut()
.ok_or_else(|| "no active session".to_owned())?;
let path = session.session_path().to_path_buf();
let session_id = session.session_id().to_owned();
let (_, entries) = opi_agent::session::SessionReader::read_all(&path)
.map_err(|e| format!("failed to read session: {e}"))?;
let tree = opi_agent::session_branch::SessionTree::from_entries(&entries);
if !tree.branches().iter().any(|branch| branch.tip_id == tip_id) {
return Err(format!("unknown branch tip: {tip_id}"));
}
session
.append_leaf(tip_id)
.map_err(|e| format!("failed to select branch: {e}"))?;
let (_, entries) = opi_agent::session::SessionReader::read_all(&path)
.map_err(|e| format!("failed to read selected branch: {e}"))?;
let messages = crate::session_cli::reconstruct_context(&entries);
let message_count = messages.len();
self.agent.replace_messages(messages);
let compaction_config = opi_agent::compaction::CompactionConfig {
enabled: self.config.compaction.enabled,
threshold_tokens: self.config.compaction.threshold_tokens,
};
self.session = Some(
SessionCoordinator::open_existing(
path,
session_id,
&entries,
message_count,
compaction_config,
self.agent.model().to_string(),
)
.map_err(|e| format!("failed to reopen selected branch: {e}"))?,
);
self.turn_offset = message_count;
Ok(message_count)
}
pub async fn prompt(&mut self, text: &str) -> Result<Vec<AgentMessage>, AgentError> {
let offset = self.turn_offset;
let messages = self.agent.prompt(text).await?;
let new = &messages[offset..];
self.persist_turn(new, offset);
let final_messages = self.current_messages();
self.turn_offset = final_messages.len();
Ok(final_messages)
}
pub async fn prompt_with_content(
&mut self,
content: Vec<opi_ai::message::InputContent>,
) -> Result<Vec<AgentMessage>, AgentError> {
let offset = self.turn_offset;
let messages = self.agent.prompt_with_content(content).await?;
let new = &messages[offset..];
self.persist_turn(new, offset);
let final_messages = self.current_messages();
self.turn_offset = final_messages.len();
Ok(final_messages)
}
pub async fn continue_(&mut self, text: &str) -> Result<Vec<AgentMessage>, AgentError> {
let offset = self.turn_offset;
let messages = self.agent.continue_(text).await?;
let new = &messages[offset..];
self.persist_turn(new, offset);
let final_messages = self.current_messages();
self.turn_offset = final_messages.len();
Ok(final_messages)
}
fn aggregate_turn_usage(messages: &[AgentMessage]) -> opi_ai::stream::Usage {
let mut total = opi_ai::stream::Usage::default();
for m in messages {
if let AgentMessage::Llm(Message::Assistant(a)) = m {
total.input_tokens = total.input_tokens.saturating_add(a.usage.input_tokens);
total.output_tokens = total.output_tokens.saturating_add(a.usage.output_tokens);
total.cache_read_tokens = total
.cache_read_tokens
.saturating_add(a.usage.cache_read_tokens);
total.cache_write_tokens = total
.cache_write_tokens
.saturating_add(a.usage.cache_write_tokens);
}
}
total
}
fn persist_turn(&mut self, messages: &[AgentMessage], turn_start_agent_index: usize) {
if let Some(session) = &mut self.session {
let usage = Self::aggregate_turn_usage(messages);
let compaction_reason =
match session.on_turn_end(messages, &usage, turn_start_agent_index) {
Ok(reason) => reason,
Err(e) => {
self.agent.emit_event(AgentEvent::SessionPersistError {
message: format!("session write failed: {e}"),
});
return;
}
};
if let Some(reason) = compaction_reason {
self.agent
.emit_event(AgentEvent::CompactionStart { reason });
match session.execute_compaction(reason) {
Ok(Some(out)) => {
let wire = to_wire_result(&out);
self.agent.replace_messages(out.new_agent_messages);
self.agent.emit_event(AgentEvent::CompactionEnd {
reason,
result: Some(wire),
aborted: false,
error_message: None,
});
}
Ok(None) => {
self.agent.emit_event(AgentEvent::CompactionEnd {
reason,
result: None,
aborted: true,
error_message: Some("compaction produced no output".into()),
});
}
Err(e) => {
self.agent.emit_event(AgentEvent::CompactionEnd {
reason,
result: None,
aborted: true,
error_message: Some(format!("compaction persist failed: {e}")),
});
self.agent.emit_event(AgentEvent::SessionPersistError {
message: format!("compaction write failed: {e}"),
});
}
}
}
}
}
fn current_messages(&self) -> Vec<AgentMessage> {
self.agent.messages_snapshot()
}
pub fn model(&self) -> &str {
self.agent.model()
}
pub fn steer(&self, message: String) {
self.agent.steer(message);
}
pub fn follow_up(&self, message: String) {
self.agent.follow_up(message);
}
pub fn subscribe(&mut self, callback: Box<dyn Fn(&AgentEvent) + Send + Sync>) {
self.agent.subscribe(callback);
}
pub fn system_prompt(&self) -> &str {
&self.system_prompt
}
pub fn resource_metadata(&self) -> &DiscoveredResourceMetadata {
&self.resources.metadata
}
pub fn resource_metadata_json(&self) -> serde_json::Value {
self.resources.metadata.to_rpc_json()
}
pub fn resolve_theme(
&self,
name: &str,
) -> Result<opi_tui::Theme, crate::theme_discovery::ThemeDiscoveryError> {
crate::theme_discovery::ThemeRegistry::from_resources(
self.resources.theme_resources.clone(),
)
.resolve_theme(name)
}
pub fn config(&self) -> &OpiConfig {
&self.config
}
pub fn cancel(&self) {
self.agent.abort();
}
pub fn cancel_token(&self) -> tokio_util::sync::CancellationToken {
self.agent.cancel_token()
}
pub fn control_handle(&self) -> opi_agent::agent::AgentControl {
self.agent.control_handle()
}
pub fn reset_cancel_if_cancelled(&mut self) {
self.agent.reset_cancel_if_cancelled();
}
pub fn session(&self) -> Option<&SessionCoordinator> {
self.session.as_ref()
}
pub fn compact(
&mut self,
reason: opi_agent::session_event::CompactionReason,
) -> Result<Option<opi_agent::session_event::CompactionResult>, String> {
let session = match &mut self.session {
Some(s) => s,
None => return Err("no active session".into()),
};
let result = session
.execute_compaction(reason)
.map_err(|e| format!("compaction failed: {e}"))?;
if let Some(out) = &result {
self.agent.replace_messages(out.new_agent_messages.clone());
}
Ok(result.map(|out| crate::session_coordinator::to_wire_result(&out)))
}
fn build_tools(workspace_root: &Path, tool_config: &ToolRuntimeConfig) -> Vec<Box<dyn Tool>> {
let read_policy = match tool_config.run_mode {
RunMode::Interactive => crate::tool::PathPolicy::AllowOutsideWorkspace,
RunMode::NonInteractive => crate::tool::PathPolicy::WorkspaceOnly,
};
let mut tools: Vec<(&str, Box<dyn Tool>)> = vec![
(
"read",
Box::new(ReadTool::new_with_policy(
workspace_root.to_path_buf(),
read_policy,
)),
),
(
"write",
Box::new(WriteTool::new(workspace_root.to_path_buf())),
),
(
"edit",
Box::new(EditTool::new(workspace_root.to_path_buf())),
),
(
"bash",
Box::new(BashTool::new(workspace_root.to_path_buf())),
),
(
"grep",
Box::new(GrepTool::new(workspace_root.to_path_buf())),
),
(
"find",
Box::new(FindTool::new(workspace_root.to_path_buf())),
),
("ls", Box::new(LsTool::new(workspace_root.to_path_buf()))),
(
"glob",
Box::new(GlobTool::new(workspace_root.to_path_buf())),
),
];
tools
.drain(..)
.filter(|(name, _)| {
tool_config
.active_tool_names
.iter()
.any(|active| active == name)
})
.map(|(_, tool)| tool)
.collect()
}
fn discover_resources(
workspace_root: &Path,
config: &OpiConfig,
user_config_dir: Option<&Path>,
resource_layers: Option<ResourceDiscoveryLayers>,
) -> HarnessResources {
let explicit = ExplicitResourcePaths {
extensions: config.extensions.paths.clone(),
packages: config.packages.paths.clone(),
..Default::default()
};
let mut layers = resource_layers.unwrap_or_else(|| {
standard_discovery_layers(workspace_root, user_config_dir, explicit)
});
let mut metadata = DiscoveredResourceMetadata::default();
let packages = match crate::package_discovery::discover_packages(&layers.packages) {
Ok(packages) => packages,
Err(e) => {
metadata
.diagnostics
.push(format!("package discovery failed: {e}"));
Vec::new()
}
};
metadata.packages = packages
.iter()
.map(|package| ResourceMetadataEntry {
name: package.manifest.name.clone(),
description: Some(package.manifest.description.clone()),
version: package.manifest.version.clone(),
})
.collect();
let package_layers = crate::package_discovery::package_composed_resource_layers(&packages);
metadata.diagnostics.extend(package_layers.diagnostics);
layers.extensions.extend(package_layers.extensions);
layers.skills.extend(package_layers.skills);
layers.fragments.extend(package_layers.fragments);
layers.themes.extend(package_layers.themes);
match crate::resource::discover_extension_resources(&layers.extensions) {
Ok(extensions) => {
metadata.extensions = extensions
.iter()
.map(|extension| ResourceMetadataEntry {
name: extension.manifest.name.clone(),
description: extension.manifest.description.clone(),
version: extension.manifest.version.clone(),
})
.collect();
}
Err(e) => metadata
.diagnostics
.push(format!("extension discovery failed: {e}")),
}
match crate::skill::discover_skills(&layers.skills) {
Ok(skills) => {
metadata.skills = skills
.iter()
.map(|skill| ResourceMetadataEntry {
name: skill.manifest.name.clone(),
description: Some(skill.manifest.description.clone()),
version: None,
})
.collect();
}
Err(e) => metadata
.diagnostics
.push(format!("skill discovery failed: {e}")),
}
match crate::prompt_fragment::discover_fragments(&layers.fragments) {
Ok(fragments) => {
metadata.fragments = fragments
.iter()
.map(|fragment| ResourceMetadataEntry {
name: fragment.manifest.name.clone(),
description: Some(fragment.manifest.description.clone()),
version: None,
})
.collect();
}
Err(e) => metadata
.diagnostics
.push(format!("fragment discovery failed: {e}")),
}
let theme_resources = match crate::theme_discovery::discover_themes(&layers.themes) {
Ok(themes) => {
metadata.themes = themes
.iter()
.map(|theme| ResourceMetadataEntry {
name: theme.manifest.name.clone(),
description: Some(theme.manifest.description.clone()),
version: None,
})
.collect();
themes
}
Err(e) => {
metadata
.diagnostics
.push(format!("theme discovery failed: {e}"));
Vec::new()
}
};
HarnessResources {
metadata,
theme_resources,
}
}
fn build_model_registry(
provider: &dyn Provider,
extension_registry: Option<&ExtensionRegistry>,
) -> (opi_ai::ProviderRegistry, Vec<String>) {
let mut registry = opi_ai::ProviderRegistry::new();
let mut diagnostics = Vec::new();
if let Some(extension_registry) = extension_registry {
for provider in extension_registry.collect_providers() {
if let Err(e) = registry.register_provider(provider) {
diagnostics.push(format!("extension provider registration failed: {e}"));
}
}
}
if let Err(e) =
registry.register_provider(Box::new(MetadataProvider::from_provider(provider)))
{
diagnostics.push(format!("active provider metadata registration failed: {e}"));
}
if let Some(extension_registry) = extension_registry {
for (provider_id, model) in extension_registry.collect_model_overrides() {
if let Err(e) = registry.register_model(&provider_id, model) {
diagnostics.push(format!("extension model override registration failed: {e}"));
}
}
}
(registry, diagnostics)
}
}
fn parse_model_spec(spec: &str) -> Result<(&str, &str), String> {
let Some((provider, model)) = spec.split_once(':') else {
return Err("invalid model spec: expected provider:model".into());
};
if provider.is_empty() || model.is_empty() {
return Err("invalid model spec: expected provider:model".into());
}
Ok((provider, model))
}
fn initial_thinking_request_config(
registry: &opi_ai::ProviderRegistry,
model: &str,
config: &OpiConfig,
) -> (Option<ThinkingConfig>, Option<u64>) {
if !config.thinking.enabled {
return (None, None);
}
let budget_tokens = config.thinking.budget_tokens as u64;
let Ok((mut thinking, mut max_tokens)) = request_config_for_thinking_budget(budget_tokens)
else {
return (None, None);
};
if let Ok((_, model)) = registry.resolve(model) {
if !model.supports_thinking {
return (None, None);
}
if max_tokens > model.max_output_tokens {
if model.max_output_tokens <= 1 {
return (None, None);
}
let adjusted_budget = model.max_output_tokens - 1;
let Ok((adjusted_thinking, adjusted_max_tokens)) =
request_config_for_thinking_budget(adjusted_budget)
else {
return (None, None);
};
thinking = adjusted_thinking;
max_tokens = adjusted_max_tokens;
}
}
(Some(thinking), Some(max_tokens))
}
fn request_config_for_thinking_budget(budget_tokens: u64) -> Result<(ThinkingConfig, u64), String> {
let max_tokens = max_tokens_for_thinking_budget(budget_tokens)?;
Ok((
ThinkingConfig {
enabled: true,
budget_tokens: Some(budget_tokens),
},
max_tokens,
))
}
fn max_tokens_for_thinking_budget(budget_tokens: u64) -> Result<u64, String> {
budget_tokens.checked_add(1).ok_or_else(|| {
format!("thinking budget {budget_tokens} cannot fit a valid max_tokens value")
})
}
fn validate_thinking_budget_for_model(
model: &ModelInfo,
budget_tokens: u64,
max_tokens: u64,
) -> Result<(), String> {
if !model.supports_thinking {
return Err(model_does_not_support_thinking(&model.id));
}
if max_tokens > model.max_output_tokens {
return Err(thinking_budget_exceeds_model_limit(
budget_tokens,
max_tokens,
model.max_output_tokens,
&model.id,
));
}
Ok(())
}
fn model_does_not_support_thinking(model_id: &str) -> String {
format!("model '{model_id}' does not support thinking")
}
fn thinking_budget_exceeds_model_limit(
budget_tokens: u64,
max_tokens: u64,
max_output_tokens: u64,
model_id: &str,
) -> String {
format!(
"thinking budget {budget_tokens} requires max_tokens {max_tokens}, exceeding max output tokens {max_output_tokens} for model '{model_id}'"
)
}
pub(crate) fn agent_messages_to_llm(messages: &[AgentMessage]) -> Vec<Message> {
let mut result = Vec::with_capacity(messages.len());
for msg in messages {
match msg {
AgentMessage::Llm(m) => result.push(m.clone()),
AgentMessage::CompactionSummary(summary) => {
result.push(Message::User(opi_ai::message::UserMessage {
content: vec![opi_ai::message::InputContent::Text {
text: format!(
"[Context was compacted. Summary of earlier conversation: {}]",
summary.summary
),
}],
timestamp_ms: 0,
}));
}
_ => {}
}
}
result
}
pub struct CodingAgentHooks;
impl AgentHooks for CodingAgentHooks {
fn convert_to_llm(&self, messages: &[AgentMessage]) -> Result<Vec<Message>, AgentError> {
Ok(agent_messages_to_llm(messages))
}
}
pub struct InteractiveCodingHooks;
impl InteractiveCodingHooks {
pub fn new(_allow_mutating: bool) -> Self {
Self
}
}
impl AgentHooks for InteractiveCodingHooks {
fn convert_to_llm(&self, messages: &[AgentMessage]) -> Result<Vec<Message>, AgentError> {
Ok(agent_messages_to_llm(messages))
}
}