1use std::path::PathBuf;
4use std::time::Instant;
5
6use agent_call::{AgentCli, McpServer, Provider, RunRequest, ToolPolicy as CoreToolPolicy};
7use okbot_core::{
8 AgentBackend, AgentCaps, AgentError, AgentReply, AgentTurn, TokenUsage, ToolPolicy, async_trait,
9};
10
11use crate::env::EnvScrub;
12use crate::prompt::inline_system_prompt;
13use crate::provider_name;
14
15#[derive(Debug, Clone)]
17pub struct CliBackendConfig {
18 pub provider: Provider,
20 pub binary: Option<PathBuf>,
23 pub model: Option<String>,
25 pub scrub: EnvScrub,
27 pub env: Vec<(String, String)>,
29}
30
31#[derive(Debug)]
33pub struct CliBackend {
34 cli: AgentCli,
35 name: String,
36 model: Option<String>,
37 scrub: EnvScrub,
38 env: Vec<(String, String)>,
39 sessions: bool,
40}
41
42impl CliBackend {
43 pub fn new(config: CliBackendConfig) -> Result<Self, AgentError> {
45 let provider = config.provider;
46 if let Some(model) = &config.model {
47 crate::validate_model(provider, model)?;
48 }
49 let binary = match config.binary {
52 Some(path) if path.exists() => Some(path),
53 configured => {
54 let found = crate::detect::SearchPaths::from_env().find(provider.executable());
55 if let (Some(old), Some(new)) = (&configured, &found) {
56 tracing::warn!(
57 configured = %old.display(),
58 found = %new.display(),
59 "the configured agent CLI is missing; using the one found instead \
60 (`okbot agent set {} --default-cli` drops the setting)",
61 provider_name(provider)
62 );
63 }
64 found.or(configured)
65 }
66 };
67 let cli = match &binary {
68 Some(path) => AgentCli::with_binary(provider, path.clone()),
69 None => AgentCli::discover(provider),
70 }
71 .map_err(|e| AgentError::Unavailable(format!("{}: {e}", provider_name(provider))))?;
72 Ok(Self {
73 cli,
74 name: provider_name(provider).to_string(),
75 model: config.model,
76 scrub: config.scrub,
77 env: config.env,
78 sessions: true,
79 })
80 }
81
82 pub fn provider(&self) -> Provider {
84 self.cli.provider()
85 }
86
87 #[must_use]
89 pub fn without_sessions(mut self) -> Self {
90 self.sessions = false;
91 self
92 }
93}
94
95fn map_error(error: agent_call::Error) -> AgentError {
97 use agent_call::Error as E;
98 match error {
99 E::NotFound(path) => AgentError::Unavailable(format!("not found: {}", path.display())),
100 E::VersionProbe(msg) | E::UnsupportedVersion(msg) => AgentError::Unavailable(msg),
101 E::InvalidRequest(msg) => AgentError::Failed {
102 message: format!("invalid request: {msg}"),
103 retryable: false,
104 },
105 E::Timeout(d) => AgentError::Timeout(d),
106 E::Exit {
107 status,
108 stderr_tail,
109 } => AgentError::Failed {
110 message: format!("exited with {status:?}: {}", last_line(&stderr_tail)),
111 retryable: true,
112 },
113 E::Io(e) => AgentError::Failed {
114 message: format!("I/O: {e}"),
115 retryable: true,
116 },
117 E::Agent(msg) => AgentError::Failed {
118 message: format!("agent error: {msg}"),
119 retryable: true,
120 },
121 E::Protocol(msg) => AgentError::Failed {
122 message: format!("unexpected output: {msg}"),
123 retryable: true,
124 },
125 E::EmptyResponse => AgentError::Failed {
126 message: "empty answer".into(),
127 retryable: true,
128 },
129 E::SessionNotFound(id) => AgentError::Failed {
130 message: format!("session {id} not found"),
131 retryable: true,
132 },
133 #[allow(unreachable_patterns)]
135 other => AgentError::Failed {
136 message: other.to_string(),
137 retryable: true,
138 },
139 }
140}
141
142fn events(progress: Option<&okbot_core::ProgressFn>) -> Option<agent_call::EventHandler> {
144 let progress = progress?.clone();
145 Some(agent_call::EventHandler::new(move |event| {
146 let mapped = match event {
147 agent_call::AgentEvent::TextDelta(t) => okbot_core::AgentProgress::TextDelta(t),
148 agent_call::AgentEvent::Text(t) => okbot_core::AgentProgress::Text(t),
149 agent_call::AgentEvent::ToolUse { name } => okbot_core::AgentProgress::ToolUse(name),
150 _ => return,
151 };
152 progress.report(mapped);
153 }))
154}
155
156fn last_line(text: &str) -> &str {
157 text.trim().lines().last().unwrap_or("")
158}
159
160impl CliBackend {
161 fn request(&self, turn: &AgentTurn) -> RunRequest {
163 let native = self.caps();
164 let base = RunRequest {
165 cwd: turn.workdir.clone(),
166 model: self.model.clone(),
167 timeout: turn.timeout,
168 env_remove: self.scrub.current(),
169 env: self.env.iter().map(|(k, v)| (k.into(), v.into())).collect(),
170 on_event: events(turn.progress.as_ref()),
171 ..RunRequest::default()
172 };
173 if !native.enforces(turn.tools) {
174 return RunRequest {
176 prompt: inline_system_prompt(&turn.system, &turn.prompt),
177 ..base
178 };
179 }
180 let resume = turn
182 .resume
183 .as_ref()
184 .filter(|r| self.sessions && r.backend == self.name);
185 RunRequest {
186 prompt: resume.map_or_else(|| turn.prompt.clone(), |r| r.prompt.clone()),
187 resume: resume.map(|r| r.session.clone()),
188 persist_session: native.resume && self.sessions,
189 system_prompt: Some(turn.system.clone()).filter(|s| !s.trim().is_empty()),
190 tool_policy: match turn.tools {
191 ToolPolicy::McpOnly => CoreToolPolicy::McpOnly,
192 _ => CoreToolPolicy::Web,
193 },
194 mcp_servers: turn
195 .mcp
196 .iter()
197 .map(|s| McpServer {
198 name: s.name.clone(),
199 url: s.url.clone(),
200 bearer_token_env: Some(s.bearer_token_env.clone()),
201 })
202 .collect(),
203 env: base
204 .env
205 .iter()
206 .cloned()
207 .chain(turn.env.iter().map(|(k, v)| (k.into(), v.expose().into())))
208 .collect(),
209 ..base
210 }
211 }
212}
213
214#[async_trait]
215impl AgentBackend for CliBackend {
216 fn name(&self) -> &str {
217 &self.name
218 }
219
220 fn caps(&self) -> AgentCaps {
221 match self.cli.provider() {
225 Provider::Claude | Provider::Codex => AgentCaps {
226 system_prompt: true,
227 mcp: true,
228 tool_policies: vec![ToolPolicy::Web, ToolPolicy::McpOnly],
229 resume: true,
230 stream: true,
231 },
232 _ => AgentCaps::default(),
233 }
234 }
235
236 async fn run(&self, turn: AgentTurn) -> Result<AgentReply, AgentError> {
237 std::fs::create_dir_all(&turn.workdir).map_err(|e| AgentError::Failed {
238 message: format!("cannot create {}: {e}", turn.workdir.display()),
239 retryable: false,
240 })?;
241 let request = self.request(&turn);
242 let started = Instant::now();
243 let result = match self.cli.run(request).await {
244 Err(agent_call::Error::SessionNotFound(id)) if self.sessions => {
246 tracing::info!(backend = %self.name, session = %id, "session not found; starting a new one");
247 let fresh = AgentTurn {
248 resume: None,
249 ..turn.clone()
250 };
251 self.cli.run(self.request(&fresh)).await
252 }
253 other => other,
254 }
255 .map_err(map_error)?;
256 Ok(AgentReply {
257 text: result.text,
258 backend: self.name.clone(),
259 model: self.model.clone(),
260 session_id: result.session_id,
261 usage: result.usage.map(|u| TokenUsage {
262 input: u.input,
263 output: u.output,
264 reasoning: u.reasoning,
265 cache_read: u.cache_read,
266 cache_write: u.cache_write,
267 }),
268 duration: started.elapsed(),
269 })
270 }
271}