Skip to main content

okbot_agent/
cli.rs

1//! One agent CLI as an [`AgentBackend`].
2
3use 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/// How to run one agent CLI.
16#[derive(Debug, Clone)]
17pub struct CliBackendConfig {
18    /// Which CLI.
19    pub provider: Provider,
20    /// Executable path; `None` looks it up on `PATH` and in the usual install directories
21    /// ([`crate::detect::SearchPaths::from_env`]), as does a path that no longer exists.
22    pub binary: Option<PathBuf>,
23    /// Model id in the CLI's format (`sonnet@high`); `None` uses the CLI's default.
24    pub model: Option<String>,
25    /// Environment variables kept away from the agent.
26    pub scrub: EnvScrub,
27    /// Extra environment for the CLI (for example a separate `HOME` with its own login).
28    pub env: Vec<(String, String)>,
29}
30
31/// An agent CLI run through agent-call. Each turn starts a fresh process.
32#[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    /// Finds the CLI and checks that it starts (`--version`).
44    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        // Found at each start, so a CLI that updates itself (a new versioned directory) or a bot
50        // moved to another machine keeps working.
51        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    /// The CLI this backend runs.
83    pub fn provider(&self) -> Provider {
84        self.cli.provider()
85    }
86
87    /// Disables native session persistence and resume, for isolated operator probes.
88    #[must_use]
89    pub fn without_sessions(mut self) -> Self {
90        self.sessions = false;
91        self
92    }
93}
94
95/// Maps agent-call errors to the port's error, deciding which ones justify trying another backend.
96fn 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        // agent-call may add variants; treat them as retryable failures.
134        #[allow(unreachable_patterns)]
135        other => AgentError::Failed {
136            message: other.to_string(),
137            retryable: true,
138        },
139    }
140}
141
142/// Forwards agent-call events as okbot progress.
143fn 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    /// Builds agent-call's request, using native features where this CLI enforces them.
162    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            // Only reached when the chain allows unenforced tools (development).
175            return RunRequest {
176                prompt: inline_system_prompt(&turn.system, &turn.prompt),
177                ..base
178            };
179        }
180        // Resume this backend's own session; otherwise start one (kept for the next turn).
181        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        // agent-call 0.4 enforces tool policies and per-run MCP servers for Claude and Codex
222        // only; Devin and OpenCode reject them, so they get the system prompt inlined and
223        // knowledge excerpts instead.
224        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            // The session is gone (expired, CLI data cleared): start over with the full history.
245            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}