agent-harness 0.4.0-alpha.4

Drive LLM coding agents — Claude Code, OpenAI Codex, and local Ollama / OpenAI-compatible models — from Rust behind one trait, with a normalized streaming event vocabulary. Bring your own agent too.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
//! ACP-client adapter: drive an external **Agent Client Protocol** agent
//! (OpenCode, Gemini CLI, Goose, …) over JSON-RPC/stdio and normalize its
//! `session/update` stream into [`crate::RunEvent`]s — the third adapter
//! archetype (the CLI-wrapping adapters parse a stdout stream; the
//! OpenAI-compatible adapter owns the loop; this one relays an ACP agent).
//!
//! Built on Zed's `agent-client-protocol` crate (we are the *client*; the
//! external process is the *agent*). It uses the 0.14 role/builder model: a
//! `Client.builder()` registers handlers for the agent's incoming
//! requests/notifications, then `connect_with` spawns the agent (an [`AcpAgent`]
//! stdio transport) and runs the session (`initialize` → `new_session` →
//! `prompt`). The connection is async + runtime-agnostic, so `run()` drives it
//! on a `smol` executor inside the worker thread it spawns — keeping the same
//! thread+callback shape as the other adapters.
//!
//! Opt-in behind the `acp` feature (it pulls the ACP crate + a small async
//! runtime and spawns external agents).
//!
//! [`AcpAgent`]: agent_client_protocol::AcpAgent

use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::Duration;

use agent_client_protocol as acp;
use serde_json::Value;
use smol::Timer;

use crate::{
    CredentialSpec, Harness, HarnessCapabilities, HarnessError, HarnessInfo, HarnessModel,
    HarnessReadiness, InstallCallback, RunCallback, RunControl, RunEvent, RunHandle, RunMode,
    RunRequest,
};

mod translate;

/// An ACP agent driven as a [`Harness`]. The vendor is configuration (the
/// command to spawn), not a type — `::opencode()` and `::custom(...)` are
/// constructors over one adapter.
pub struct AcpHarness {
    id: String,
    display_name: String,
    description: String,
    /// Program to spawn (e.g. `opencode`) …
    command: String,
    /// … and the args that put it in ACP mode (e.g. `["acp"]`).
    args: Vec<String>,
    /// How this vendor exposes launch-time model selection — ACP itself carries
    /// no model, so listing + selecting a model happen out-of-band. `None` → a
    /// generic ACP agent: no model list, and the per-run model
    /// ([`RunTuning`](crate::RunTuning)) is ignored.
    model_control: Option<ModelControl>,
}

/// How a config-file ACP agent (opencode) exposes a launch-time model. ACP has
/// no model field, so we **list** models via a vendor CLI subcommand and
/// **select** one by writing a JSON config file and pointing an env var at it
/// just before spawn. Set by [`AcpHarness::opencode`]; absent on a generic
/// [`AcpHarness::custom`], which then has no model picker.
struct ModelControl {
    /// CLI subcommand that prints one `provider/model` per line, used by
    /// [`AcpHarness::list_models`]. opencode: `["models"]` → `opencode models`.
    list_subcommand: Vec<String>,
    /// Env var the agent reads its JSON config path from. opencode: `OPENCODE_CONFIG`.
    config_env: String,
    /// JSON field in that config that selects the model. opencode: `model`.
    config_field: String,
}

/// Configuration for [`AcpHarness::custom`] — named fields rather than a
/// positional list, so a call site reads unambiguously. Derives `Default`.
#[derive(Clone, Debug, Default)]
pub struct AcpHarnessConfig {
    /// Stable id used in the registry / picker (e.g. `"gemini"`).
    pub id: String,
    /// Human-readable name shown in the UI (e.g. `"Gemini"`).
    pub display_name: String,
    /// Program to spawn (e.g. `"gemini"`).
    pub command: String,
    /// Args that launch it in ACP mode (e.g. `["--experimental-acp"]`).
    pub args: Vec<String>,
}

impl AcpHarness {
    /// OpenCode over ACP — spawns `opencode acp`. opencode reads a JSON config
    /// file named by `$OPENCODE_CONFIG`; its `model` field (a `provider/model`
    /// id) selects the model, and `opencode models` lists the available ids — so
    /// this constructor wires launch-time model selection (`list_models` + the
    /// per-run [`RunTuning`](crate::RunTuning) model).
    pub fn opencode() -> Self {
        let mut harness = Self::custom(AcpHarnessConfig {
            id: "opencode".to_owned(),
            display_name: "OpenCode".to_owned(),
            command: "opencode".to_owned(),
            args: vec!["acp".to_owned()],
        });
        harness.model_control = Some(ModelControl {
            list_subcommand: vec!["models".to_owned()],
            config_env: "OPENCODE_CONFIG".to_owned(),
            config_field: "model".to_owned(),
        });
        harness
    }

    /// Any ACP agent, configured by an [`AcpHarnessConfig`]: `command` + `args`
    /// that launch it as an ACP server over stdio, with named fields so the call
    /// site reads clearly.
    pub fn custom(config: AcpHarnessConfig) -> Self {
        let AcpHarnessConfig { id, display_name, command, args } = config;
        Self {
            id,
            description: format!("{display_name} via the Agent Client Protocol."),
            display_name,
            command,
            args,
            model_control: None,
        }
    }
}

impl Harness for AcpHarness {
    fn info(&self) -> HarnessInfo {
        HarnessInfo {
            id: self.id.clone(),
            display_name: self.display_name.clone(),
            description: self.description.clone(),
            // The ACP agent is user-provided; we don't install it.
            requires_install: false,
            capabilities: HarnessCapabilities {
                credential_required: false,
                previews_edits: false,
                // Models are discovered live via `list_models()` (opencode lists
                // its own; a generic ACP agent has none), so the static list is
                // empty; a free-text model id is also accepted.
                models: Vec::new(),
                allows_custom_model: true,
                supports_effort: false,
                supports_max_turns: false,
                supports_login: false,
                supports_custom_instructions: false,
            },
        }
    }

    fn readiness(&self) -> HarnessReadiness {
        let installed = probe_command(&self.command);
        HarnessReadiness {
            harness_id: self.id.clone(),
            ready: installed,
            installed,
            version: None,
            auth_configured: installed,
            error: if installed {
                None
            } else {
                Some(format!(
                    "`{}` is not installed or not on PATH (needed to run {} over ACP).",
                    self.command, self.display_name
                ))
            },
            details: Value::Null,
        }
    }

    fn install(&self, _on_event: InstallCallback) -> Result<(), HarnessError> {
        // No install — the ACP agent is provided by the user.
        Ok(())
    }

    fn run(&self, request: RunRequest, on_event: RunCallback) -> Result<RunHandle, HarnessError> {
        // resume (session/load) is a follow-up; this first cut runs a fresh
        // session. `attachments` ignored: a text prompt only.
        let RunRequest { run_id, prompt, cwd, mode, tuning, resume: _, attachments: _ } = request;
        // ACP carries no model. For a config-file vendor (opencode), select the
        // chosen model out-of-band: write a temp JSON config `{ <field>: <model> }`
        // and point the agent's config env var at it for this spawn. Other tuning
        // knobs (effort, max_turns, …) have no ACP equivalent and are ignored.
        let (env, model_config_file) = match (&self.model_control, tuning.model) {
            (Some(mc), Some(model)) => {
                let path = write_model_config(&run_id, &mc.config_field, &model)
                    .map_err(HarnessError::spawn)?;
                (vec![(mc.config_env.clone(), path.to_string_lossy().into_owned())], Some(path))
            }
            _ => (Vec::new(), None),
        };
        let cfg = AcpRunCfg {
            command: self.command.clone(),
            args: self.args.clone(),
            run_id,
            prompt,
            cwd: cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_default()),
            mode,
            env,
            model_config_file,
        };
        let cancel = Arc::new(AtomicBool::new(false));
        let thread_cancel = Arc::clone(&cancel);
        // Same thread+callback shape as the other adapters: the async ACP
        // connection is driven on a smol executor on a worker thread, so run()
        // returns immediately.
        std::thread::spawn(move || run_acp(cfg, thread_cancel, on_event));
        Ok(Box::new(AcpRun { cancel }))
    }

    fn credential(&self) -> CredentialSpec {
        CredentialSpec {
            label: format!("{} (manages its own auth)", self.display_name),
            keychain_service: self.id.clone(),
            keychain_account: String::new(),
            required: false,
        }
    }

    fn list_models(&self) -> Result<Vec<HarnessModel>, HarnessError> {
        // ACP exposes no model list; a config-file vendor (opencode) lists via
        // its own CLI subcommand. A generic ACP agent has none → empty (the host
        // hides the picker). PATH is augmented so a packaged `.app` finds the CLI.
        let Some(mc) = &self.model_control else {
            return Ok(Vec::new());
        };
        let output = Command::new(&self.command)
            .args(&mc.list_subcommand)
            .env("PATH", crate::augmented_node_path())
            .output()
            .map_err(|e| {
                HarnessError::spawn(format!(
                    "`{} {}` failed: {e}",
                    self.command,
                    mc.list_subcommand.join(" ")
                ))
            })?;
        // A non-zero exit (agent offline / unauthenticated) isn't fatal here —
        // surface no list rather than failing the picker.
        if !output.status.success() {
            return Ok(Vec::new());
        }
        let models = String::from_utf8_lossy(&output.stdout)
            .lines()
            .map(str::trim)
            .filter(|line| !line.is_empty())
            .map(|line| HarnessModel { value: line.to_owned(), label: line.to_owned() })
            .collect();
        Ok(models)
    }
}

/// Whether `command` is runnable on the (augmented) PATH — `<command> --version`
/// exits without a spawn error. Augmented PATH so a packaged `.app` finds a
/// CLI installed via nvm / Homebrew / etc.
fn probe_command(command: &str) -> bool {
    Command::new(command)
        .arg("--version")
        .env("PATH", crate::augmented_node_path())
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

/// Write a one-off JSON config selecting `model` (under `field`) to a temp file
/// keyed by `run_id`, returning its path. An ACP agent that reads a config file
/// (opencode, via `$OPENCODE_CONFIG`) picks the model from it — the out-of-band
/// way to choose a model the protocol itself can't carry. Removed when the run
/// ends (see [`run_acp`]).
fn write_model_config(run_id: &str, field: &str, model: &str) -> Result<PathBuf, String> {
    let path = std::env::temp_dir().join(format!("harness-acp-model-{run_id}.json"));
    let body = serde_json::json!({ field: model }).to_string();
    std::fs::write(&path, body)
        .map_err(|e| format!("writing ACP model config to {}: {e}", path.display()))?;
    Ok(path)
}

/// Everything `run_acp` needs, assembled by `run()` from the `RunRequest`.
struct AcpRunCfg {
    command: String,
    args: Vec<String>,
    run_id: String,
    prompt: String,
    cwd: PathBuf,
    mode: RunMode,
    /// Extra env for the spawned agent — e.g. opencode's `OPENCODE_CONFIG`
    /// pointing at the temp model-config file. Empty when no model was chosen.
    env: Vec<(String, String)>,
    /// Temp model-config file to delete when the run ends, if one was written.
    model_config_file: Option<PathBuf>,
}

/// Cancel handle for an in-flight ACP run. Cooperative: a watcher future races
/// the connection and, on cancel, drops it (tearing down the agent process).
struct AcpRun {
    cancel: Arc<AtomicBool>,
}

impl RunControl for AcpRun {
    fn cancel(&self) -> Result<(), HarnessError> {
        self.cancel.store(true, Ordering::SeqCst);
        Ok(())
    }
    fn was_cancelled(&self) -> bool {
        self.cancel.load(Ordering::SeqCst)
    }
}

/// Drive the ACP connection to completion on a `smol` executor, emitting the
/// normalized event stream. Always ends with exactly one `RunEvent::Exited`.
fn run_acp(cfg: AcpRunCfg, cancel: Arc<AtomicBool>, on_event: RunCallback) {
    (*on_event)(RunEvent::Started { run_id: cfg.run_id.clone() });

    let perm_mode = cfg.mode;
    let notif_on_event = on_event.clone();
    let notif_rid = cfg.run_id.clone();
    let prompt = cfg.prompt.clone();
    let cwd = cfg.cwd.clone();

    // Transport: spawn `command args…` as a stdio ACP agent. The session's
    // working directory is carried in `new_session`, not the spawn dir.
    let env_vars: Vec<acp::schema::EnvVariable> = cfg
        .env
        .iter()
        .map(|(name, value)| acp::schema::EnvVariable::new(name.clone(), value.clone()))
        .collect();
    let server = acp::schema::McpServer::Stdio(
        acp::schema::McpServerStdio::new(cfg.command.clone(), cfg.command.clone())
            .args(cfg.args.clone())
            .env(env_vars),
    );
    let agent = acp::AcpAgent::new(server);

    // The connection: register handlers for the agent's incoming
    // requests/notifications, then run the session inside `connect_with`.
    let connect = async move {
        acp::Client
            .builder()
            .name("openai-compatible")
            .on_receive_request(
                move |req: acp::schema::RequestPermissionRequest,
                      responder: acp::Responder<acp::schema::RequestPermissionResponse>,
                      _cx: acp::ConnectionTo<acp::Agent>| {
                    let mode = perm_mode;
                    async move {
                        // Edit mode → allow; Ask mode (read-only) → reject. Pick
                        // an option of the matching kind, else the first offered.
                        let allow = matches!(mode, RunMode::Edit);
                        let pick =
                            req.options.iter().find(|o| is_allow(&o.kind) == allow).or_else(|| req.options.first());
                        let outcome = match pick {
                            Some(o) => acp::schema::RequestPermissionOutcome::Selected(
                                acp::schema::SelectedPermissionOutcome::new(o.option_id.clone()),
                            ),
                            None => acp::schema::RequestPermissionOutcome::Cancelled,
                        };
                        responder.respond(acp::schema::RequestPermissionResponse::new(outcome))
                    }
                },
                acp::on_receive_request!(),
            )
            .on_receive_notification(
                move |notif: acp::schema::SessionNotification, _cx: acp::ConnectionTo<acp::Agent>| {
                    let on_event = notif_on_event.clone();
                    let rid = notif_rid.clone();
                    async move {
                        for event in translate::session_update_to_events(&rid, notif.update) {
                            (*on_event)(event);
                        }
                        Ok(())
                    }
                },
                acp::on_receive_notification!(),
            )
            .connect_with(agent, move |cx: acp::ConnectionTo<acp::Agent>| async move {
                cx.send_request(acp::schema::InitializeRequest::new(acp::schema::ProtocolVersion::LATEST))
                    .block_task()
                    .await?;
                let session =
                    cx.send_request(acp::schema::NewSessionRequest::new(cwd.clone())).block_task().await?;
                let resp = cx
                    .send_request(acp::schema::PromptRequest::new(session.session_id, vec![prompt.clone().into()]))
                    .block_task()
                    .await?;
                Ok(resp.stop_reason)
            })
            .await
            .map_err(|e| format!("ACP run failed: {e}"))
    };

    // Cooperative cancel: race the connection against the cancel flag. If cancel
    // wins, the connection future is dropped, tearing down the agent process.
    let cancel_fut = {
        let cancel = Arc::clone(&cancel);
        async move {
            loop {
                if cancel.load(Ordering::SeqCst) {
                    return Err("cancelled".to_owned());
                }
                Timer::after(Duration::from_millis(50)).await;
            }
        }
    };

    let outcome: Result<acp::schema::StopReason, String> =
        smol::block_on(futures_lite::future::or(connect, cancel_fut));

    let run_id = cfg.run_id;
    // Remove the temp model-config file (if one was written for this run).
    if let Some(path) = cfg.model_config_file {
        let _ = std::fs::remove_file(path);
    }
    match outcome {
        Ok(stop) => {
            let cancelled =
                cancel.load(Ordering::SeqCst) || matches!(stop, acp::schema::StopReason::Cancelled);
            (*on_event)(RunEvent::Exited { run_id, exit_code: Some(0), cancelled });
        }
        Err(_) if cancel.load(Ordering::SeqCst) => {
            // The error is just the agent being torn down by the cancel race.
            (*on_event)(RunEvent::Exited { run_id, exit_code: None, cancelled: true });
        }
        Err(message) => {
            (*on_event)(RunEvent::Error { run_id: run_id.clone(), message });
            (*on_event)(RunEvent::Exited { run_id, exit_code: Some(1), cancelled: false });
        }
    }
}

/// Whether a permission option allows the action (vs. rejects it).
fn is_allow(kind: &acp::schema::PermissionOptionKind) -> bool {
    matches!(
        kind,
        acp::schema::PermissionOptionKind::AllowOnce | acp::schema::PermissionOptionKind::AllowAlways
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Harness;

    #[test]
    fn generic_acp_agent_lists_no_models_without_shelling_out() {
        // A `custom` agent has no model_control, so list_models() short-circuits
        // to empty — it never spawns the command (here a bogus one) — and the
        // host hides the picker on the *absence* of models.
        let harness = AcpHarness::custom(AcpHarnessConfig {
            id: "x".to_owned(),
            display_name: "X".to_owned(),
            command: "definitely-not-a-real-command".to_owned(),
            args: vec!["acp".to_owned()],
        });
        assert!(harness.list_models().expect("ok").is_empty());
        let caps = harness.info().capabilities;
        assert!(caps.models.is_empty());
        assert!(caps.allows_custom_model, "ACP agents accept a free-text model");
    }

    #[test]
    fn write_model_config_emits_field_and_model_keyed_by_run_id() {
        let path = write_model_config("run-abc", "model", "opencode/big-pickle")
            .expect("writes the config file");
        assert!(
            path.to_string_lossy().contains("run-abc"),
            "temp file is keyed by run_id: {path:?}"
        );
        let json: serde_json::Value =
            serde_json::from_str(&std::fs::read_to_string(&path).expect("read back"))
                .expect("valid JSON");
        assert_eq!(json["model"], "opencode/big-pickle");
        let _ = std::fs::remove_file(&path);
    }
}