use std::path::PathBuf;
use std::time::Instant;
use agent_call::{AgentCli, McpServer, Provider, RunRequest, ToolPolicy as CoreToolPolicy};
use okbot_core::{
AgentBackend, AgentCaps, AgentError, AgentReply, AgentTurn, TokenUsage, ToolPolicy, async_trait,
};
use crate::env::EnvScrub;
use crate::prompt::inline_system_prompt;
use crate::provider_name;
#[derive(Debug, Clone)]
pub struct CliBackendConfig {
pub provider: Provider,
pub binary: Option<PathBuf>,
pub model: Option<String>,
pub scrub: EnvScrub,
pub env: Vec<(String, String)>,
}
#[derive(Debug)]
pub struct CliBackend {
cli: AgentCli,
name: String,
model: Option<String>,
scrub: EnvScrub,
env: Vec<(String, String)>,
sessions: bool,
}
impl CliBackend {
pub fn new(config: CliBackendConfig) -> Result<Self, AgentError> {
let provider = config.provider;
if let Some(model) = &config.model {
crate::validate_model(provider, model)?;
}
let binary = match config.binary {
Some(path) if path.exists() => Some(path),
configured => {
let found = crate::detect::SearchPaths::from_env().find(provider.executable());
if let (Some(old), Some(new)) = (&configured, &found) {
tracing::warn!(
configured = %old.display(),
found = %new.display(),
"the configured agent CLI is missing; using the one found instead \
(`okbot agent set {} --default-cli` drops the setting)",
provider_name(provider)
);
}
found.or(configured)
}
};
let cli = match &binary {
Some(path) => AgentCli::with_binary(provider, path.clone()),
None => AgentCli::discover(provider),
}
.map_err(|e| AgentError::Unavailable(format!("{}: {e}", provider_name(provider))))?;
Ok(Self {
cli,
name: provider_name(provider).to_string(),
model: config.model,
scrub: config.scrub,
env: config.env,
sessions: true,
})
}
pub fn provider(&self) -> Provider {
self.cli.provider()
}
#[must_use]
pub fn without_sessions(mut self) -> Self {
self.sessions = false;
self
}
}
fn map_error(error: agent_call::Error) -> AgentError {
use agent_call::Error as E;
match error {
E::NotFound(path) => AgentError::Unavailable(format!("not found: {}", path.display())),
E::VersionProbe(msg) | E::UnsupportedVersion(msg) => AgentError::Unavailable(msg),
E::InvalidRequest(msg) => AgentError::Failed {
message: format!("invalid request: {msg}"),
retryable: false,
},
E::Timeout(d) => AgentError::Timeout(d),
E::Exit {
status,
stderr_tail,
} => AgentError::Failed {
message: format!("exited with {status:?}: {}", last_line(&stderr_tail)),
retryable: true,
},
E::Io(e) => AgentError::Failed {
message: format!("I/O: {e}"),
retryable: true,
},
E::Agent(msg) => AgentError::Failed {
message: format!("agent error: {msg}"),
retryable: true,
},
E::Protocol(msg) => AgentError::Failed {
message: format!("unexpected output: {msg}"),
retryable: true,
},
E::EmptyResponse => AgentError::Failed {
message: "empty answer".into(),
retryable: true,
},
E::SessionNotFound(id) => AgentError::Failed {
message: format!("session {id} not found"),
retryable: true,
},
#[allow(unreachable_patterns)]
other => AgentError::Failed {
message: other.to_string(),
retryable: true,
},
}
}
fn events(progress: Option<&okbot_core::ProgressFn>) -> Option<agent_call::EventHandler> {
let progress = progress?.clone();
Some(agent_call::EventHandler::new(move |event| {
let mapped = match event {
agent_call::AgentEvent::TextDelta(t) => okbot_core::AgentProgress::TextDelta(t),
agent_call::AgentEvent::Text(t) => okbot_core::AgentProgress::Text(t),
agent_call::AgentEvent::ToolUse { name } => okbot_core::AgentProgress::ToolUse(name),
_ => return,
};
progress.report(mapped);
}))
}
fn last_line(text: &str) -> &str {
text.trim().lines().last().unwrap_or("")
}
impl CliBackend {
fn request(&self, turn: &AgentTurn) -> RunRequest {
let native = self.caps();
let base = RunRequest {
cwd: turn.workdir.clone(),
model: self.model.clone(),
timeout: turn.timeout,
env_remove: self.scrub.current(),
env: self.env.iter().map(|(k, v)| (k.into(), v.into())).collect(),
on_event: events(turn.progress.as_ref()),
..RunRequest::default()
};
if !native.enforces(turn.tools) {
return RunRequest {
prompt: inline_system_prompt(&turn.system, &turn.prompt),
..base
};
}
let resume = turn
.resume
.as_ref()
.filter(|r| self.sessions && r.backend == self.name);
RunRequest {
prompt: resume.map_or_else(|| turn.prompt.clone(), |r| r.prompt.clone()),
resume: resume.map(|r| r.session.clone()),
persist_session: native.resume && self.sessions,
system_prompt: Some(turn.system.clone()).filter(|s| !s.trim().is_empty()),
tool_policy: match turn.tools {
ToolPolicy::McpOnly => CoreToolPolicy::McpOnly,
_ => CoreToolPolicy::Web,
},
mcp_servers: turn
.mcp
.iter()
.map(|s| McpServer {
name: s.name.clone(),
url: s.url.clone(),
bearer_token_env: Some(s.bearer_token_env.clone()),
})
.collect(),
env: base
.env
.iter()
.cloned()
.chain(turn.env.iter().map(|(k, v)| (k.into(), v.expose().into())))
.collect(),
..base
}
}
}
#[async_trait]
impl AgentBackend for CliBackend {
fn name(&self) -> &str {
&self.name
}
fn caps(&self) -> AgentCaps {
match self.cli.provider() {
Provider::Claude | Provider::Codex => AgentCaps {
system_prompt: true,
mcp: true,
tool_policies: vec![ToolPolicy::Web, ToolPolicy::McpOnly],
resume: true,
stream: true,
},
_ => AgentCaps::default(),
}
}
async fn run(&self, turn: AgentTurn) -> Result<AgentReply, AgentError> {
std::fs::create_dir_all(&turn.workdir).map_err(|e| AgentError::Failed {
message: format!("cannot create {}: {e}", turn.workdir.display()),
retryable: false,
})?;
let request = self.request(&turn);
let started = Instant::now();
let result = match self.cli.run(request).await {
Err(agent_call::Error::SessionNotFound(id)) if self.sessions => {
tracing::info!(backend = %self.name, session = %id, "session not found; starting a new one");
let fresh = AgentTurn {
resume: None,
..turn.clone()
};
self.cli.run(self.request(&fresh)).await
}
other => other,
}
.map_err(map_error)?;
Ok(AgentReply {
text: result.text,
backend: self.name.clone(),
model: self.model.clone(),
session_id: result.session_id,
usage: result.usage.map(|u| TokenUsage {
input: u.input,
output: u.output,
reasoning: u.reasoning,
cache_read: u.cache_read,
cache_write: u.cache_write,
}),
duration: started.elapsed(),
})
}
}