Skip to main content

harness/bob/
mod.rs

1//! `bob` CLI as a [`Harness`].
2//!
3//! The bob adapter: wraps the standalone [`bob_rs`] SDK (detection,
4//! install, keychain, spawn) behind the neutral [`crate::Harness`]
5//! trait, and parses bob's `--output-format stream-json` stdout into the
6//! shared [`crate::RunEvent`] vocabulary via the [`parser`] module.
7//!
8//! Auth: Compose stores bob's API key (in the OS keychain via `bob_rs`),
9//! so `credential().required` is `true` and `supports_login` is `false` —
10//! unlike the Claude/Codex adapters, which own their CLI's login.
11
12use std::sync::{Arc, Mutex};
13
14use bob_rs::{
15    get_readiness, install_bob, spawn_bob, BobApprovalMode, BobChatMode, RunBobOptions,
16    KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE,
17};
18use crate::{
19    normalize_process_event, CredentialSpec, Harness, HarnessCapabilities, HarnessError,
20    HarnessInfo, HarnessReadiness, InstallCallback, RunCallback, RunHandle, RunMode, RunRequest,
21};
22
23pub mod parser;
24
25pub use parser::{bob_tool_kind, normalize_bob_event, parse_bob_line, BobStreamParser};
26
27/// Registry id for the bob harness.
28pub const BOB_HARNESS_ID: &str = "bob";
29
30/// `bob` CLI as a [`Harness`]. Delegates to the [`bob_rs`] SDK;
31/// this is just the neutral face over it.
32#[derive(Debug, Default, Clone)]
33pub struct BobHarness;
34
35impl BobHarness {
36    pub fn new() -> Self {
37        Self
38    }
39}
40
41impl Harness for BobHarness {
42    fn info(&self) -> HarnessInfo {
43        HarnessInfo {
44            id: BOB_HARNESS_ID.to_owned(),
45            display_name: "Bob".to_owned(),
46            description: "IBM's bob agent CLI. Runs locally via Node.js.".to_owned(),
47            requires_install: true,
48            capabilities: HarnessCapabilities {
49                // Compose stores bob's API key. bob runs edit-capable in
50                // `auto_edit` (see `run`), so it writes files directly like
51                // Claude/Codex — the host reviews via its edit gate, not an
52                // in-stream preview. It exposes no model/effort/turn-cap knobs.
53                credential_required: true,
54                previews_edits: false,
55                models: Vec::new(),
56                allows_custom_model: false,
57                supports_effort: false,
58                supports_max_turns: false,
59                supports_login: false,
60            },
61        }
62    }
63
64    fn readiness(&self) -> HarnessReadiness {
65        let snapshot = get_readiness();
66        // Preserve the rich bob probe for the UI while presenting a
67        // neutral top-level shape. Serialization can't realistically
68        // fail for this owned struct; fall back to null if it does.
69        let details = serde_json::to_value(&snapshot).unwrap_or(serde_json::Value::Null);
70        HarnessReadiness {
71            harness_id: BOB_HARNESS_ID.to_owned(),
72            ready: snapshot.ready,
73            installed: snapshot.bob.installed,
74            version: snapshot.bob.version.clone(),
75            auth_configured: snapshot.auth.configured,
76            error: snapshot.bob.error.clone(),
77            details,
78        }
79    }
80
81    fn install(&self, on_event: InstallCallback) -> Result<(), HarnessError> {
82        // The closure captures only the `Arc` (Clone + Send + Sync +
83        // 'static), so it satisfies `install_bob`'s `F: FnMut + Send
84        // + Sync + Clone + 'static` bound. bob-rs reports failures as a typed
85        // `BobError`; carry it as the install error's source.
86        install_bob(move |event| (*on_event)(event)).map_err(HarnessError::install)
87    }
88
89    fn run(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, HarnessError> {
90        let opts = RunBobOptions {
91            prompt: request.prompt,
92            chat_mode: match request.mode {
93                RunMode::Ask => BobChatMode::Ask,
94                // "Edit" maps onto bob's code mode — the one that
95                // proposes file changes.
96                RunMode::Edit => BobChatMode::Code,
97            },
98            // Edit mode auto-approves edit tools (bob's `auto_edit`, the
99            // analogue of Claude's `acceptEdits`) so the agent writes directly
100            // and the host reviews via its edit gate. Ask is read-only, so
101            // `default` is fine. A host wanting full bypass passes
102            // `--approval-mode yolo` through `extra_args` (last-wins).
103            approval_mode: match request.mode {
104                RunMode::Edit => BobApprovalMode::AutoEdit,
105                RunMode::Ask => BobApprovalMode::Default,
106            },
107            max_coins: 30,
108            cwd: request.cwd,
109            bob_executable: None,
110            // Host passthrough — the same RunTuning.extra_args the claude/codex
111            // adapters honor, so a client applies a flag uniformly across all
112            // three harnesses (bob appends it to its own argv).
113            extra_args: request.tuning.extra_args,
114            // Continue a prior session instead of replaying history — the same
115            // RunRequest.resume the claude/codex adapters honor (`bob -r <id>`).
116            resume: request.resume,
117        };
118        // bob emits its own process events (lifecycle + raw stream-json
119        // stdout lines). Normalize each into zero or more harness-neutral
120        // `RunEvent`s here, so the consumer only ever sees the normalized
121        // shape — the keystone of the abstraction. bob streams its
122        // reasoning inline as `<thinking>…</thinking>` and its answer via
123        // the `attempt_completion` tool, across many lines — so parsing is
124        // stateful. Hold one `BobStreamParser` for the whole run; the
125        // stdout reader thread drives it sequentially, the `Mutex` just
126        // satisfies the `Fn + Send + Sync` callback bound.
127        // In Edit mode bob runs `auto_edit` (above), so it *applies* edits
128        // rather than proposing them. Drop the parser's SuggestedEdits in that
129        // mode: an applied write is not a proposal, so it must not surface as
130        // an accept/reject suggestion (that would double with the host's edit
131        // gate / diff). The same `write_to_file`'s ToolStart/ToolEnd (Write) is
132        // retained, so it still shows as a file-op — consistent with how
133        // Claude/Codex applied writes appear. (Ask mode is read-only — no edits
134        // anyway — so the flag only ever matters in Edit mode.)
135        let edits_are_applied = matches!(request.mode, RunMode::Edit);
136        let parser = Arc::new(Mutex::new(BobStreamParser::default()));
137        let handle = spawn_bob(opts, request.run_id, move |event| {
138            // Recover a poisoned lock rather than panic on the reader thread —
139            // parsing is total, so the held parser is never mid-corruption.
140            let mut parser = parser.lock().unwrap_or_else(|p| p.into_inner());
141            for normalized in normalize_process_event(event, |line| {
142                let mut parsed = parser.parse_line(line);
143                if edits_are_applied {
144                    parsed.edits.clear();
145                }
146                parsed
147            }) {
148                (*on_event)(normalized);
149            }
150        })
151        .map_err(HarnessError::spawn)?;
152        Ok(Box::new(handle))
153    }
154
155    fn credential(&self) -> CredentialSpec {
156        CredentialSpec {
157            label: "Bob API key".to_owned(),
158            keychain_service: KEYCHAIN_SERVICE.to_owned(),
159            keychain_account: KEYCHAIN_ACCOUNT.to_owned(),
160            required: true,
161        }
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn bob_info_requires_install() {
171        let info = BobHarness::new().info();
172        assert_eq!(info.id, BOB_HARNESS_ID);
173        assert!(info.requires_install);
174    }
175
176    #[test]
177    fn bob_credential_points_at_the_shared_keychain_slot() {
178        let cred = BobHarness::new().credential();
179        assert_eq!(cred.keychain_service, KEYCHAIN_SERVICE);
180        assert_eq!(cred.keychain_account, KEYCHAIN_ACCOUNT);
181        assert!(cred.required);
182        // `credential_required` capability must agree with the spec — the
183        // frontend gates its preflight on the capability, so they can't drift.
184        assert_eq!(
185            BobHarness::new().info().capabilities.credential_required,
186            cred.required
187        );
188    }
189
190    #[test]
191    fn bob_default_login_is_unsupported() {
192        // bob authenticates via its stored API key, not an interactive
193        // CLI sign-in, so the default `login` stays unsupported.
194        let cb: InstallCallback = Arc::new(|_| {});
195        assert!(BobHarness::new().login(cb).is_err());
196        assert!(!BobHarness::new().info().capabilities.supports_login);
197    }
198}