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