use crate::agent_session::{AgentSession, AgentSessionHandle, ScopedModel};
use crate::auth_storage::AuthStorage;
use crate::model_registry::ModelRegistry;
use crate::resource_loader::ResourceLoader;
use crate::session::SessionManager;
use crate::session_cwd::{assert_session_cwd_exists, SessionCwdSource};
use crate::settings::{Settings, ThinkingLevel};
use anyhow::Result;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[derive(Debug, Clone)]
pub struct AgentSessionRuntimeDiagnostic {
pub severity: DiagnosticSeverity,
pub message: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiagnosticSeverity {
Info,
Warning,
Error,
}
impl std::fmt::Display for DiagnosticSeverity {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DiagnosticSeverity::Info => write!(f, "info"),
DiagnosticSeverity::Warning => write!(f, "warning"),
DiagnosticSeverity::Error => write!(f, "error"),
}
}
}
pub struct AgentSessionServices {
pub cwd: PathBuf,
pub agent_dir: PathBuf,
pub auth_storage: Arc<AuthStorage>,
pub settings: Arc<Settings>,
pub model_registry: Arc<ModelRegistry>,
pub resource_loader: Arc<ResourceLoader>,
pub diagnostics: Vec<AgentSessionRuntimeDiagnostic>,
}
pub struct CreateAgentSessionServicesOptions {
pub cwd: PathBuf,
pub agent_dir: Option<PathBuf>,
pub auth_storage: Option<Arc<AuthStorage>>,
pub settings: Option<Arc<Settings>>,
pub model_registry: Option<Arc<ModelRegistry>>,
pub resource_loader: Option<Arc<ResourceLoader>>,
}
impl CreateAgentSessionServicesOptions {
pub fn new(cwd: PathBuf) -> Self {
Self {
cwd,
agent_dir: None,
auth_storage: None,
settings: None,
model_registry: None,
resource_loader: None,
}
}
}
pub fn get_default_agent_dir() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".oxi"))
.unwrap_or_else(|| PathBuf::from(".oxi"))
}
pub fn create_agent_session_services(
options: CreateAgentSessionServicesOptions,
) -> Result<AgentSessionServices> {
let cwd = options.cwd;
let agent_dir = options.agent_dir.unwrap_or_else(get_default_agent_dir);
let auth_storage = options
.auth_storage
.unwrap_or_else(|| Arc::new(AuthStorage::new()));
let settings = options.settings.unwrap_or_else(|| {
let s = Settings::load_from(&cwd).unwrap_or_default();
Arc::new(s)
});
let model_registry = options.model_registry.unwrap_or_else(|| {
Arc::new(ModelRegistry::create(
AuthStorage::new(),
Some(agent_dir.join("models.json")),
))
});
let resource_loader = options.resource_loader.unwrap_or_else(|| {
Arc::new(ResourceLoader::with_paths(agent_dir.clone(), cwd.clone()))
});
Ok(AgentSessionServices {
cwd,
agent_dir,
auth_storage,
settings,
model_registry,
resource_loader,
diagnostics: Vec::new(),
})
}
pub struct CreateAgentSessionFromServicesOptions {
pub services: Arc<AgentSessionServices>,
pub session_manager: SessionManager,
pub model_id: Option<String>,
pub thinking_level: Option<ThinkingLevel>,
pub scoped_models: Vec<ScopedModel>,
pub tool_registry: Option<Arc<oxi_agent::ToolRegistry>>,
}
pub struct CreateAgentSessionResult {
pub session: AgentSession,
pub model_fallback_message: Option<String>,
}
pub fn create_agent_session_from_services(
options: CreateAgentSessionFromServicesOptions,
) -> Result<CreateAgentSessionResult> {
let services = &options.services;
let settings = services.settings.as_ref();
let cwd = services.cwd.to_string_lossy().to_string();
let model_id = match options
.model_id
.or_else(|| settings.effective_model(None))
{
Some(id) if !id.is_empty() => {
tracing::info!("Model resolved: {} (default_model={:?}, last_used={:?})", id, settings.default_model, settings.last_used_model);
id
}
other => {
tracing::warn!("No model configured: effective_model={:?}", settings.effective_model(None));
match other {
Some(id) => id,
None => String::new(),
}
}
};
let thinking_level = options.thinking_level.unwrap_or(settings.thinking_level);
if model_id.is_empty() {
let config = oxi_agent::AgentConfig {
name: "oxi".to_string(),
description: Some("oxi CLI agent".to_string()),
model_id: String::new(),
system_prompt: Some(build_system_prompt(thinking_level)),
max_iterations: 10,
timeout_seconds: settings.tool_timeout_seconds,
temperature: settings.effective_temperature(),
max_tokens: settings.effective_max_tokens(),
compaction_strategy: if settings.auto_compaction {
oxi_ai::CompactionStrategy::Threshold(0.8)
} else {
oxi_ai::CompactionStrategy::Disabled
},
compaction_instruction: None,
context_window: 128_000,
api_key: None,
};
let provider = oxi_ai::get_provider("anthropic")
.ok_or_else(|| anyhow::anyhow!("No provider available"))?;
let agent = Arc::new(oxi_agent::Agent::new(Arc::from(provider), config));
let session = AgentSession::new(agent, settings.clone(), options.session_manager, cwd);
return Ok(CreateAgentSessionResult {
session,
model_fallback_message: None,
});
}
let (provider_name, _model_name) = parse_model_id(&model_id);
let provider = oxi_ai::get_provider(&provider_name)
.ok_or_else(|| anyhow::anyhow!("Provider '{}' not found", provider_name))?;
let system_prompt = build_system_prompt(thinking_level);
let compaction_strategy = if settings.auto_compaction {
oxi_ai::CompactionStrategy::Threshold(0.8)
} else {
oxi_ai::CompactionStrategy::Disabled
};
let api_key = services.auth_storage.get_api_key(&provider_name);
let config = oxi_agent::AgentConfig {
name: "oxi".to_string(),
description: Some("oxi CLI agent".to_string()),
model_id: model_id.clone(),
system_prompt: Some(system_prompt),
max_iterations: 10,
timeout_seconds: settings.tool_timeout_seconds,
temperature: settings.effective_temperature(),
max_tokens: settings.effective_max_tokens(),
compaction_strategy,
compaction_instruction: None,
context_window: 128_000,
api_key,
};
let agent = Arc::new(oxi_agent::Agent::new(Arc::from(provider), config));
let registry = options.tool_registry.unwrap_or_else(|| {
Arc::new(oxi_agent::ToolRegistry::with_builtins_cwd(
PathBuf::from(&cwd)
))
});
let agent_tools = agent.tools();
for name in registry.names() {
if let Some(tool) = registry.get(&name) {
agent_tools.register_arc(tool);
}
}
let session = AgentSession::new(agent, settings.clone(), options.session_manager, cwd);
if !options.scoped_models.is_empty() {
session.set_scoped_models(options.scoped_models);
}
Ok(CreateAgentSessionResult {
session,
model_fallback_message: None,
})
}
pub struct CreateAgentSessionRuntimeResult {
pub session: AgentSession,
pub services: Arc<AgentSessionServices>,
pub diagnostics: Vec<AgentSessionRuntimeDiagnostic>,
pub model_fallback_message: Option<String>,
}
pub type CreateRuntimeFactory =
dyn Fn(CreateRuntimeOptions) -> Result<CreateAgentSessionRuntimeResult> + Send + Sync;
pub struct CreateRuntimeOptions {
pub cwd: PathBuf,
pub agent_dir: PathBuf,
pub session_manager: SessionManager,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionSwitchReason {
New,
Resume,
Fork,
Import,
Quit,
}
#[derive(Debug)]
pub struct SessionImportFileNotFoundError {
pub file_path: PathBuf,
}
impl std::fmt::Display for SessionImportFileNotFoundError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "File not found: {}", self.file_path.display())
}
}
impl std::error::Error for SessionImportFileNotFoundError {}
pub struct AgentSessionRuntime {
session: AgentSessionHandle,
services: Arc<AgentSessionServices>,
diagnostics: Vec<AgentSessionRuntimeDiagnostic>,
model_fallback_message: Option<String>,
create_runtime: Arc<CreateRuntimeFactory>,
}
impl AgentSessionRuntime {
pub fn new(
session: AgentSession,
services: Arc<AgentSessionServices>,
create_runtime: Arc<CreateRuntimeFactory>,
diagnostics: Vec<AgentSessionRuntimeDiagnostic>,
model_fallback_message: Option<String>,
) -> Self {
Self {
session: session.clone_handle(),
services,
diagnostics,
model_fallback_message,
create_runtime,
}
}
pub fn services(&self) -> &AgentSessionServices {
&self.services
}
pub fn session(&self) -> &AgentSessionHandle {
&self.session
}
pub fn cwd(&self) -> &Path {
&self.services.cwd
}
pub fn diagnostics(&self) -> &[AgentSessionRuntimeDiagnostic] {
&self.diagnostics
}
pub fn model_fallback_message(&self) -> Option<&str> {
self.model_fallback_message.as_deref()
}
pub fn switch_session(
&mut self,
session_path: &str,
cwd_override: Option<&str>,
) -> Result<()> {
let session_manager = SessionManager::open(session_path, None, cwd_override);
let cwd = session_manager.get_cwd();
let adapter = SessionManagerCwdAdapter(&session_manager);
assert_session_cwd_exists(&adapter, &cwd).map_err(|e| anyhow::anyhow!("{}", e))?;
self.teardown_current(SessionSwitchReason::Resume);
let result = (self.create_runtime)(CreateRuntimeOptions {
cwd: PathBuf::from(&cwd),
agent_dir: self.services.agent_dir.clone(),
session_manager,
})?;
self.apply(result);
Ok(())
}
pub fn new_session(&mut self) -> Result<()> {
let session_dir = get_default_session_dir();
let session_manager = SessionManager::create(
&self.services.cwd.to_string_lossy(),
Some(&session_dir),
);
self.teardown_current(SessionSwitchReason::New);
let result = (self.create_runtime)(CreateRuntimeOptions {
cwd: self.services.cwd.clone(),
agent_dir: self.services.agent_dir.clone(),
session_manager,
})?;
self.apply(result);
Ok(())
}
pub fn fork(&mut self, entry_id: &str, _position: ForkPosition) -> Result<()> {
let session_dir = get_default_session_dir();
let cwd_str = self.services.cwd.to_string_lossy().to_string();
let mut session_manager = {
let sm = SessionManager::create(&cwd_str, Some(&session_dir));
sm
};
if let Err(e) = session_manager.branch(entry_id) {
tracing::warn!("Branch to entry {} failed: {}", entry_id, e);
}
self.teardown_current(SessionSwitchReason::Fork);
let result = (self.create_runtime)(CreateRuntimeOptions {
cwd: self.services.cwd.clone(),
agent_dir: self.services.agent_dir.clone(),
session_manager,
})?;
self.apply(result);
Ok(())
}
pub fn import_from_jsonl(
&mut self,
input_path: &Path,
cwd_override: Option<&str>,
) -> Result<()> {
let resolved = if input_path.is_absolute() {
input_path.to_path_buf()
} else {
std::env::current_dir()?.join(input_path)
};
if !resolved.exists() {
return Err(SessionImportFileNotFoundError {
file_path: resolved,
}
.into());
}
let session_dir = get_default_session_dir();
let dest_dir = Path::new(&session_dir);
if !dest_dir.exists() {
std::fs::create_dir_all(dest_dir)?;
}
let file_name = resolved.file_name().unwrap_or_default();
let destination = dest_dir.join(file_name);
if destination != resolved {
std::fs::copy(&resolved, &destination)?;
}
let session_manager = SessionManager::open(
&destination.to_string_lossy(),
Some(&session_dir),
cwd_override,
);
let cwd = session_manager.get_cwd();
let adapter = SessionManagerCwdAdapter(&session_manager);
assert_session_cwd_exists(&adapter, &cwd).map_err(|e| anyhow::anyhow!("{}", e))?;
self.teardown_current(SessionSwitchReason::Import);
let result = (self.create_runtime)(CreateRuntimeOptions {
cwd: PathBuf::from(&cwd),
agent_dir: self.services.agent_dir.clone(),
session_manager,
})?;
self.apply(result);
Ok(())
}
pub fn dispose(&mut self) {
self.teardown_current(SessionSwitchReason::Quit);
}
fn teardown_current(&mut self, _reason: SessionSwitchReason) {
self.session.reset();
}
fn apply(&mut self, result: CreateAgentSessionRuntimeResult) {
self.session = result.session.clone_handle();
self.services = result.services;
self.diagnostics = result.diagnostics;
self.model_fallback_message = result.model_fallback_message;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ForkPosition {
At,
Before,
}
struct SessionManagerCwdAdapter<'a>(&'a SessionManager);
impl SessionCwdSource for SessionManagerCwdAdapter<'_> {
fn get_cwd(&self) -> Option<String> {
let cwd = self.0.get_cwd();
if cwd.is_empty() {
None
} else {
Some(cwd)
}
}
fn get_session_file(&self) -> Option<String> {
self.0.get_session_file()
}
}
pub fn create_agent_session_runtime(
create_runtime: Arc<CreateRuntimeFactory>,
options: CreateRuntimeOptions,
) -> Result<AgentSessionRuntime> {
let adapter = SessionManagerCwdAdapter(&options.session_manager);
let cwd = options.session_manager.get_cwd();
assert_session_cwd_exists(&adapter, &cwd).map_err(|e| anyhow::anyhow!("{}", e))?;
let result = create_runtime(options)?;
Ok(AgentSessionRuntime::new(
result.session,
result.services,
create_runtime,
result.diagnostics,
result.model_fallback_message,
))
}
fn parse_model_id(model_id: &str) -> (String, String) {
let parts: Vec<&str> = model_id.splitn(2, '/').collect();
if parts.len() >= 2 {
(parts[0].to_string(), parts[1].to_string())
} else {
("anthropic".to_string(), model_id.to_string())
}
}
fn build_system_prompt(thinking_level: ThinkingLevel) -> String {
let custom_prompt = match thinking_level {
ThinkingLevel::None => {
Some("You are a helpful AI assistant. Provide direct, concise answers.".to_string())
}
ThinkingLevel::Minimal => {
Some("You are a helpful AI assistant. Provide clear and helpful answers.".to_string())
}
ThinkingLevel::Standard => Some(
"You are a helpful AI coding assistant. Think through problems \
step by step when helpful, but keep responses focused and actionable."
.to_string(),
),
ThinkingLevel::Thorough => Some(
"You are an expert AI coding assistant. Take time to thoroughly \
analyze problems, consider edge cases, and provide comprehensive \
solutions with explanations. Think deeply before responding."
.to_string(),
),
};
let mut tool_snippets = std::collections::HashMap::new();
tool_snippets.insert("read".into(), "Read file contents (text or image)".into());
tool_snippets.insert("bash".into(), "Execute bash commands".into());
tool_snippets.insert("edit".into(), "Edit files with exact text replacement".into());
tool_snippets.insert("write".into(), "Write content to files".into());
tool_snippets.insert("grep".into(), "Search file contents with regex".into());
tool_snippets.insert("find".into(), "Find files by name/pattern".into());
tool_snippets.insert("ls".into(), "List directory contents".into());
tool_snippets.insert("web_search".into(), "Search the web (DuckDuckGo, Wikipedia, Bing, Brave)".into());
let options = crate::system_prompt::BuildSystemPromptOptions {
custom_prompt,
cwd: std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default(),
selected_tools: vec![
"read".into(), "bash".into(), "edit".into(), "write".into(),
"grep".into(), "find".into(), "ls".into(), "web_search".into(),
],
tool_snippets,
..Default::default()
};
crate::system_prompt::build_system_prompt(&options)
}
fn get_default_session_dir() -> String {
format!(
"{}/sessions",
get_default_agent_dir().to_string_lossy()
)
}
pub fn default_create_runtime_factory() -> Arc<CreateRuntimeFactory> {
Arc::new(|options: CreateRuntimeOptions| {
let services = create_agent_session_services(
CreateAgentSessionServicesOptions {
cwd: options.cwd.clone(),
agent_dir: Some(options.agent_dir.clone()),
auth_storage: None,
settings: None,
model_registry: None,
resource_loader: None,
},
)?;
let services = Arc::new(services);
let result = create_agent_session_from_services(
CreateAgentSessionFromServicesOptions {
services: services.clone(),
session_manager: options.session_manager,
model_id: None,
thinking_level: None,
scoped_models: Vec::new(),
tool_registry: None,
},
)?;
Ok(CreateAgentSessionRuntimeResult {
session: result.session,
services,
diagnostics: Vec::new(),
model_fallback_message: result.model_fallback_message,
})
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_model_id_with_provider() {
let (provider, model) = parse_model_id("anthropic/claude-sonnet-4-20250514");
assert_eq!(provider, "anthropic");
assert_eq!(model, "claude-sonnet-4-20250514");
}
#[test]
fn test_parse_model_id_without_provider() {
let (provider, model) = parse_model_id("claude-sonnet-4-20250514");
assert_eq!(provider, "anthropic");
assert_eq!(model, "claude-sonnet-4-20250514");
}
#[test]
fn test_parse_model_id_nested() {
let (provider, model) = parse_model_id("openai/gpt-4o");
assert_eq!(provider, "openai");
assert_eq!(model, "gpt-4o");
}
#[test]
fn test_build_system_prompt() {
let prompt = build_system_prompt(ThinkingLevel::None);
assert!(prompt.contains("concise"));
let prompt = build_system_prompt(ThinkingLevel::Standard);
assert!(prompt.contains("coding"));
let prompt = build_system_prompt(ThinkingLevel::Thorough);
assert!(prompt.contains("comprehensive"));
}
#[test]
fn test_diagnostic_severity_display() {
assert_eq!(format!("{}", DiagnosticSeverity::Info), "info");
assert_eq!(format!("{}", DiagnosticSeverity::Warning), "warning");
assert_eq!(format!("{}", DiagnosticSeverity::Error), "error");
}
#[test]
fn test_session_import_file_not_found_error() {
let err = SessionImportFileNotFoundError {
file_path: PathBuf::from("/tmp/nonexistent.jsonl"),
};
assert!(err.to_string().contains("not found"));
}
#[test]
fn test_get_default_agent_dir() {
let dir = get_default_agent_dir();
assert!(dir.to_string_lossy().contains(".oxi"));
}
#[test]
fn test_fork_position() {
assert_eq!(ForkPosition::At, ForkPosition::At);
assert_ne!(ForkPosition::At, ForkPosition::Before);
}
#[test]
fn test_session_switch_reason() {
assert_eq!(SessionSwitchReason::New, SessionSwitchReason::New);
assert_ne!(SessionSwitchReason::New, SessionSwitchReason::Resume);
assert_ne!(SessionSwitchReason::Fork, SessionSwitchReason::Import);
assert_ne!(SessionSwitchReason::Import, SessionSwitchReason::Quit);
}
#[test]
fn test_create_agent_session_services_options() {
let opts = CreateAgentSessionServicesOptions::new(PathBuf::from("/tmp"));
assert_eq!(opts.cwd, PathBuf::from("/tmp"));
assert!(opts.agent_dir.is_none());
assert!(opts.auth_storage.is_none());
}
}