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`.
21    pub binary: Option<PathBuf>,
22    /// Model id in the CLI's format (`sonnet@high`); `None` uses the CLI's default.
23    pub model: Option<String>,
24    /// Environment variables kept away from the agent.
25    pub scrub: EnvScrub,
26    /// Extra environment for the CLI (for example a separate `HOME` with its own login).
27    pub env: Vec<(String, String)>,
28}
29
30/// An agent CLI run through agent-call. Each turn starts a fresh process.
31#[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    /// Finds the CLI and checks that it starts (`--version`).
43    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    /// The CLI this backend runs.
64    pub fn provider(&self) -> Provider {
65        self.cli.provider()
66    }
67
68    /// Disables native session persistence and resume, for isolated operator probes.
69    #[must_use]
70    pub fn without_sessions(mut self) -> Self {
71        self.sessions = false;
72        self
73    }
74}
75
76/// Maps agent-call errors to the port's error, deciding which ones justify trying another backend.
77fn 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        // agent-call may add variants; treat them as retryable failures.
115        #[allow(unreachable_patterns)]
116        other => AgentError::Failed {
117            message: other.to_string(),
118            retryable: true,
119        },
120    }
121}
122
123/// Forwards agent-call events as okbot progress.
124fn 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    /// Builds agent-call's request, using native features where this CLI enforces them.
143    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            // Only reached when the chain allows unenforced tools (development).
156            return RunRequest {
157                prompt: inline_system_prompt(&turn.system, &turn.prompt),
158                ..base
159            };
160        }
161        // Resume this backend's own session; otherwise start one (kept for the next turn).
162        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        // agent-call 0.4 enforces tool policies and per-run MCP servers for Claude and Codex
203        // only; Devin and OpenCode reject them, so they get the system prompt inlined and
204        // knowledge excerpts instead.
205        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            // The session is gone (expired, CLI data cleared): start over with the full history.
226            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}