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}