Skip to main content

custom_harness/
custom_harness.rs

1//! Bring your own harness — implement [`Harness`] by *composing* the published
2//! building blocks, no fork required.
3//!
4//! `cargo run --example custom_harness`
5//!
6//! This toy harness "answers" by echoing the prompt: it spawns `printf` to
7//! emit a couple of JSON "wire" lines, then decodes them with its own parser.
8//! The point is the composition — it reuses the framework's engine
9//! ([`spawn_streaming`]), the neutral types ([`ParsedLine`] / [`RunEvent`]),
10//! and the canonical [`run_events_from_parsed`] expansion, and registers
11//! itself in a [`Registry`] alongside the built-ins. A real provider swaps
12//! `printf` for its CLI (or an HTTP API) and `parse_line` for that wire format.
13//!
14//! Two patterns this demonstrates:
15//!   * **new provider** — `impl Harness` from scratch, reusing the pieces;
16//!   * **stateful parser** — hold per-run state (here: announce the session
17//!     once) and call `run_events_from_parsed` so the `ParsedLine → RunEvent`
18//!     ordering stays canonical. (For a *stateless* parser, the one-liner
19//!     `normalize_process_event(event, my_fn)` does the same without the Mutex.)
20
21use std::path::PathBuf;
22use std::sync::{mpsc::sync_channel, Arc, Mutex};
23
24use harness::{
25    run_events_from_parsed, spawn_streaming, CredentialSpec, Harness, HarnessCapabilities,
26    HarnessError, HarnessInfo, HarnessReadiness, InstallCallback, ParsedLine, ProcessEvent,
27    RunCallback, RunEvent, RunHandle, RunMode, RunRequest, RunTuning, Registry, SessionInfo,
28};
29use serde_json::Value;
30
31const ECHO_ID: &str = "echo";
32
33/// A minimal third-party harness. Cheap to construct (holds config, not
34/// connections), so the registry can hand out fresh instances.
35struct EchoHarness;
36
37impl Harness for EchoHarness {
38    fn info(&self) -> HarnessInfo {
39        HarnessInfo {
40            id: ECHO_ID.to_owned(),
41            display_name: "Echo".to_owned(),
42            description: "A toy harness that echoes the prompt — a template for your own."
43                .to_owned(),
44            requires_install: false,
45            capabilities: HarnessCapabilities {
46                credential_required: false,
47                previews_edits: false,
48                models: Vec::new(),
49                allows_custom_model: false,
50                supports_effort: false,
51                supports_max_turns: false,
52                supports_login: false,
53            },
54        }
55    }
56
57    fn readiness(&self) -> HarnessReadiness {
58        HarnessReadiness {
59            harness_id: ECHO_ID.to_owned(),
60            ready: true,
61            installed: true,
62            version: Some("0.0.0".to_owned()),
63            auth_configured: true,
64            error: None,
65            details: Value::Null,
66        }
67    }
68
69    fn install(&self, _on_event: InstallCallback) -> Result<(), HarnessError> {
70        Ok(()) // nothing to install
71    }
72
73    fn credential(&self) -> CredentialSpec {
74        CredentialSpec {
75            label: "none".to_owned(),
76            keychain_service: String::new(),
77            keychain_account: String::new(),
78            required: false,
79        }
80    }
81
82    fn run(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, HarnessError> {
83        // A real harness spawns its CLI here. We spawn `printf` to emit two
84        // JSON lines: an init (→ Session) and the answer (→ Text).
85        let answer = format!(r#"{{"text":"echo: {}"}}"#, request.prompt.replace('"', "'"));
86        let args = vec![
87            "%s\n".to_owned(),
88            r#"{"type":"init","model":"echo-1"}"#.to_owned(),
89            answer,
90        ];
91        let cwd = request
92            .cwd
93            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
94
95        // One parser per run. The engine's callback is `Fn + Send + Sync`
96        // (invoked from reader threads), so per-run state lives behind an
97        // `Arc<Mutex>` — the same shape the built-in bob/codex adapters use.
98        let parser = Arc::new(Mutex::new(EchoParser::default()));
99        let handle = spawn_streaming(
100            PathBuf::from("printf"),
101            args,
102            Vec::new(),
103            cwd,
104            request.run_id,
105            move |event| {
106                let mut parser = parser.lock().expect("echo parser mutex");
107                for ev in parser.on_process_event(event) {
108                    (*on_event)(ev);
109                }
110            },
111        )
112        .map_err(HarnessError::spawn)?;
113        Ok(Box::new(handle))
114    }
115}
116
117/// A tiny stateful parser: announce the session once (from the init line),
118/// then map each `{"text":…}` line to assistant text. Lifecycle events are
119/// neutral; stdout is decoded here and expanded via [`run_events_from_parsed`]
120/// so the event ordering matches every other harness.
121#[derive(Default)]
122struct EchoParser {
123    announced: bool,
124}
125
126impl EchoParser {
127    fn on_process_event(&mut self, event: ProcessEvent) -> Vec<RunEvent> {
128        match event {
129            ProcessEvent::Started { run_id } => vec![RunEvent::Started { run_id }],
130            ProcessEvent::Exited {
131                run_id,
132                exit_code,
133                cancelled,
134            } => vec![RunEvent::Exited {
135                run_id,
136                exit_code,
137                cancelled,
138            }],
139            ProcessEvent::Error { run_id, message } => vec![RunEvent::Error { run_id, message }],
140            ProcessEvent::Stderr { .. } => Vec::new(),
141            ProcessEvent::Stdout { run_id, line } => {
142                run_events_from_parsed(&run_id, self.parse_line(&line))
143            }
144            // `ProcessEvent` is #[non_exhaustive]; ignore any future variant.
145            _ => Vec::new(),
146        }
147    }
148
149    fn parse_line(&mut self, line: &str) -> ParsedLine {
150        let value = serde_json::from_str::<Value>(line.trim()).unwrap_or(Value::Null);
151        // The first line announces the session (stateful: only once).
152        if !self.announced {
153            if let Some(model) = value.get("model").and_then(Value::as_str) {
154                self.announced = true;
155                return ParsedLine {
156                    session: Some(SessionInfo {
157                        session_id: None,
158                        model: Some(model.to_owned()),
159                    }),
160                    ..ParsedLine::default()
161                };
162            }
163        }
164        if let Some(text) = value.get("text").and_then(Value::as_str) {
165            return ParsedLine {
166                text: Some(text.to_owned()),
167                ..ParsedLine::default()
168            };
169        }
170        ParsedLine::default()
171    }
172}
173
174fn main() -> Result<(), String> {
175    // Register your harness alongside (or instead of) the built-ins — no fork.
176    let reg = Registry::new().register(EchoHarness);
177    let h = reg.by_id(ECHO_ID).expect("registered");
178    println!("harness: {} — {}", h.info().display_name, h.info().description);
179
180    let (tx, rx) = sync_channel::<RunEvent>(64);
181    let on_event: RunCallback = Arc::new(move |ev| {
182        let _ = tx.send(ev);
183    });
184    let _handle = h.run(
185        RunRequest {
186            run_id: "demo".into(),
187            prompt: "hello".into(),
188            cwd: None,
189            mode: RunMode::Ask,
190            tuning: RunTuning::default(),
191        },
192        on_event,
193    )
194    .map_err(|e| e.to_string())?;
195
196    // One normalized stream — same as for any built-in harness.
197    for ev in rx {
198        match ev {
199            RunEvent::Session { model, .. } => println!("[session] model={model:?}"),
200            RunEvent::Text { delta, .. } => println!("[answer] {delta}"),
201            RunEvent::Exited { exit_code, .. } => {
202                println!("[exited] {exit_code:?}");
203                break;
204            }
205            other => println!("{other:?}"),
206        }
207    }
208    Ok(())
209}