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