Skip to main content

antigravity_codes/
process.rs

1//! Finding, launching, and handshaking with the `localharness` binary.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5use std::process::Stdio;
6use std::sync::{Arc, Mutex};
7use std::time::Duration;
8
9use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader};
10use tokio::process::{Child, Command};
11
12use crate::error::{Error, Result};
13use crate::handshake::{decode_output_config, encode_input_config, frame};
14use crate::protocol::{
15    ClientInfo, CustomAgent, FilesystemWorkspace, GeminiAPIEndpoint, HarnessConfig,
16    HarnessConfigSessionContinuationMode, HarnessSideTools, InputConfig, LifecycleHook,
17    McpServerConfig, ModelConfig, ModelType, PolicyConfig, SystemInstructions, Tool,
18    VertexEndpoint, Workspace,
19};
20
21/// The environment variable the reference client checks first, and so do we.
22pub const HARNESS_PATH_ENV: &str = "ANTIGRAVITY_HARNESS_PATH";
23
24/// How many lines of harness stderr to retain for error reporting.
25const STDERR_TAIL_LINES: usize = 200;
26
27/// How long to let the harness finish explaining itself before giving up on it.
28///
29/// The harness reports fatal problems on stderr and *then* exits, so at the
30/// moment the socket breaks its explanation is typically still in the pipe.
31const STDERR_FLUSH_TIMEOUT: Duration = Duration::from_secs(2);
32
33/// Locates the `localharness` binary.
34///
35/// The binary is distributed **only** inside the platform wheels on PyPI — it
36/// has no standalone release and no package manager will place it on `PATH` —
37/// so discovery is, in order:
38///
39/// 1. `$ANTIGRAVITY_HARNESS_PATH`, if set.
40/// 2. `localharness` on `PATH`.
41///
42/// A Python install is deliberately *not* probed here; if the binary came from
43/// a wheel, point [`HarnessOptions::binary`] at
44/// `<site-packages>/google/antigravity/bin/localharness`, or extract it
45/// straight out of the wheel:
46///
47/// ```sh
48/// pip download google-antigravity --no-deps -d /tmp/ag
49/// unzip -j /tmp/ag/*.whl 'google/antigravity/bin/localharness' -d ~/.local/bin
50/// ```
51pub fn find_harness() -> Result<PathBuf> {
52    if let Some(path) = std::env::var_os(HARNESS_PATH_ENV) {
53        let path = PathBuf::from(path);
54        return match std::fs::metadata(&path) {
55            Ok(_) => Ok(path),
56            Err(source) => Err(Error::HarnessNotExecutable { path, source }),
57        };
58    }
59    which::which("localharness").map_err(|_| Error::HarnessNotFound)
60}
61
62/// A model endpoint for [`HarnessOptions::model`].
63///
64/// At least one model is **required**: a harness initialised without one exits
65/// immediately and drops the socket with no error frame.
66#[derive(Debug, Clone)]
67pub struct ModelBuilder(ModelConfig);
68
69impl ModelBuilder {
70    /// A model served by the Gemini Developer API, authenticated with an API key.
71    pub fn gemini(name: impl Into<String>, api_key: impl Into<String>) -> Self {
72        Self(ModelConfig {
73            name: Some(name.into()),
74            types: vec![ModelType::Text],
75            gemini_api_endpoint: Some(GeminiAPIEndpoint {
76                api_key: Some(api_key.into()),
77                ..Default::default()
78            }),
79            ..Default::default()
80        })
81    }
82
83    /// A model served by Gemini Enterprise (formerly Vertex AI), authenticated
84    /// with Application Default Credentials.
85    pub fn vertex(
86        name: impl Into<String>,
87        project: impl Into<String>,
88        location: impl Into<String>,
89    ) -> Self {
90        Self(ModelConfig {
91            name: Some(name.into()),
92            types: vec![ModelType::Text],
93            vertex_endpoint: Some(VertexEndpoint {
94                project: Some(project.into()),
95                location: Some(location.into()),
96                ..Default::default()
97            }),
98            ..Default::default()
99        })
100    }
101
102    /// Declares what this model is used for. Defaults to [`ModelType::Text`].
103    pub fn types(mut self, types: impl IntoIterator<Item = ModelType>) -> Self {
104        self.0.types = types.into_iter().collect();
105        self
106    }
107
108    /// Escape hatch for an endpoint shape this builder does not cover.
109    pub fn from_config(config: ModelConfig) -> Self {
110        Self(config)
111    }
112
113    /// The underlying wire config.
114    pub fn build(self) -> ModelConfig {
115        self.0
116    }
117}
118
119/// How to launch and configure a harness session.
120///
121/// This carries both halves of startup: the [`InputConfig`] written over stdio
122/// during the handshake, and the [`HarnessConfig`] sent as the first WebSocket
123/// frame. Most callers only touch the latter.
124#[derive(Debug, Clone)]
125pub struct HarnessOptions {
126    binary: Option<PathBuf>,
127    storage_directory: Option<PathBuf>,
128    env: HashMap<String, String>,
129    client_info: Option<ClientInfo>,
130    config: HarnessConfig,
131}
132
133impl Default for HarnessOptions {
134    fn default() -> Self {
135        Self {
136            binary: None,
137            storage_directory: None,
138            env: HashMap::new(),
139            client_info: None,
140            config: HarnessConfig {
141                // The harness runs *no* built-in tools unless told to, and an
142                // agent with none answers questions about a workspace by
143                // explaining that it cannot read it. The reference Python SDK
144                // defaults to the read-only set; so do we. Widen with
145                // `harness_side_tools(HarnessSideTools::all())`.
146                harness_side_tools: Some(HarnessSideTools::read_only()),
147                ..Default::default()
148            },
149        }
150    }
151}
152
153impl HarnessOptions {
154    /// Default options: read-only built-in tools, no models, no workspace.
155    ///
156    /// A workspace and at least one model still need setting — a harness with
157    /// no model exits during initialize.
158    pub fn new() -> Self {
159        Self::default()
160    }
161
162    /// Overrides binary discovery with an explicit path.
163    pub fn binary(mut self, path: impl Into<PathBuf>) -> Self {
164        self.binary = Some(path.into());
165        self
166    }
167
168    /// Where the harness persists conversation state between runs.
169    ///
170    /// Leave unset for an ephemeral session.
171    pub fn storage_directory(mut self, path: impl Into<PathBuf>) -> Self {
172        self.storage_directory = Some(path.into());
173        self
174    }
175
176    /// Adds a directory the agent is allowed to read and write.
177    pub fn workspace(mut self, directory: impl AsRef<Path>) -> Self {
178        self.config.workspaces.push(Workspace {
179            filesystem_workspace: Some(FilesystemWorkspace {
180                directory: Some(directory.as_ref().display().to_string()),
181            }),
182        });
183        self
184    }
185
186    /// Adds a model. Call more than once to register several.
187    pub fn model(mut self, model: ModelBuilder) -> Self {
188        self.config.models.push(model.build());
189        self
190    }
191
192    /// Sets the system prompt, replacing the harness's built-in identity.
193    pub fn system_instructions(mut self, instructions: SystemInstructions) -> Self {
194        self.config.system_instructions = Some(instructions);
195        self
196    }
197
198    /// Resumes, or creates, a conversation by id.
199    pub fn cascade_id(mut self, id: impl Into<String>) -> Self {
200        self.config.cascade_id = Some(id.into());
201        self.config.session_continuation_mode =
202            Some(HarnessConfigSessionContinuationMode::CreateOrResume);
203        self
204    }
205
206    /// Chooses how an existing `cascade_id` is treated.
207    pub fn continuation_mode(mut self, mode: HarnessConfigSessionContinuationMode) -> Self {
208        self.config.session_continuation_mode = Some(mode);
209        self
210    }
211
212    /// Declares a tool the *client* executes. The harness will send a
213    /// [`crate::protocol::ToolCall`] and wait for a
214    /// [`crate::protocol::ToolResponse`].
215    pub fn tool(mut self, tool: Tool) -> Self {
216        self.config.tools.push(tool);
217        self
218    }
219
220    /// Replaces the set of tools that run inside the harness itself.
221    ///
222    /// Defaults to [`HarnessSideTools::read_only`]. Use
223    /// [`HarnessSideTools::all`] to add shell execution and file writes, or
224    /// [`HarnessSideTools::none`] for an agent that only talks.
225    pub fn harness_side_tools(mut self, tools: HarnessSideTools) -> Self {
226        self.config.harness_side_tools = Some(tools);
227        self
228    }
229
230    /// Registers an MCP server for the harness to connect to.
231    pub fn mcp_server(mut self, server: McpServerConfig) -> Self {
232        self.config.mcp_servers.push(server);
233        self
234    }
235
236    /// Subscribes to a lifecycle hook. The harness will block the turn on a
237    /// [`crate::protocol::CallHookRequest`] until the client answers.
238    pub fn hook(mut self, hook: LifecycleHook) -> Self {
239        self.config.enabled_hooks.push(hook);
240        self
241    }
242
243    /// Registers a named subagent the model can delegate to.
244    pub fn subagent(mut self, agent: CustomAgent) -> Self {
245        self.config.custom_subagents.push(agent);
246        self
247    }
248
249    /// Installs tool-permission rules evaluated inside the harness.
250    pub fn policy(mut self, policy: PolicyConfig) -> Self {
251        self.config.policy_config = Some(policy);
252        self
253    }
254
255    /// Adds a directory of agent skills.
256    pub fn skills_path(mut self, path: impl AsRef<Path>) -> Self {
257        self.config
258            .skills_paths
259            .push(path.as_ref().display().to_string());
260        self
261    }
262
263    /// Where generated artifacts and media are written.
264    pub fn app_data_dir(mut self, path: impl AsRef<Path>) -> Self {
265        self.config.app_data_dir = Some(path.as_ref().display().to_string());
266        self
267    }
268
269    /// Sets an environment variable for the harness process.
270    pub fn env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
271        self.env.insert(key.into(), value.into());
272        self
273    }
274
275    /// Replaces the whole [`HarnessConfig`], for settings this builder does not
276    /// surface.
277    ///
278    /// Anything set by earlier builder calls is discarded — including the
279    /// default read-only tool set, so set
280    /// [`HarnessConfig::harness_side_tools`] yourself if you want any.
281    pub fn harness_config(mut self, config: HarnessConfig) -> Self {
282        self.config = config;
283        self
284    }
285
286    /// The [`HarnessConfig`] as built so far.
287    pub fn config(&self) -> &HarnessConfig {
288        &self.config
289    }
290
291    fn input_config(&self) -> InputConfig {
292        InputConfig {
293            storage_directory: self
294                .storage_directory
295                .as_ref()
296                .map(|p| p.display().to_string())
297                .or(Some(String::new())),
298            client_info: Some(self.client_info.clone().unwrap_or_else(default_client_info)),
299            env: self.env.clone(),
300            ..Default::default()
301        }
302    }
303}
304
305fn default_client_info() -> ClientInfo {
306    ClientInfo {
307        language: Some("rust".into()),
308        version: Some(env!("CARGO_PKG_VERSION").into()),
309        language_version: Some(String::new()),
310        os: Some(std::env::consts::OS.into()),
311        os_version: Some(String::new()),
312    }
313}
314
315/// A running `localharness` process that has completed its stdio handshake.
316///
317/// Dropping this kills the process.
318#[derive(Debug)]
319pub struct Harness {
320    child: Child,
321    port: u16,
322    api_key: String,
323    stderr: Arc<Mutex<Vec<String>>>,
324    /// Flips to `true` when the stderr pipe reaches EOF, which for this process
325    /// means it has exited and said everything it is going to say.
326    stderr_done: tokio::sync::watch::Receiver<bool>,
327    /// Both pipes are held open for the life of the session. The harness
328    /// treats EOF on stdin as "the client is gone" and exits — closing it after
329    /// the handshake makes the WebSocket it just advertised unreachable.
330    _stdin: tokio::process::ChildStdin,
331    _stdout: tokio::process::ChildStdout,
332}
333
334impl Harness {
335    /// Spawns the binary and performs the length-prefixed stdio handshake.
336    pub async fn launch(options: &HarnessOptions) -> Result<Self> {
337        let binary = match &options.binary {
338            Some(path) => path.clone(),
339            None => find_harness()?,
340        };
341        log::debug!("launching localharness at {}", binary.display());
342
343        let mut command = Command::new(&binary);
344        command
345            .stdin(Stdio::piped())
346            .stdout(Stdio::piped())
347            .stderr(Stdio::piped());
348        for (key, value) in &options.env {
349            command.env(key, value);
350        }
351        let mut child = command
352            .spawn()
353            .map_err(|source| Error::HarnessNotExecutable {
354                path: binary.clone(),
355                source,
356            })?;
357
358        let stderr = Arc::new(Mutex::new(Vec::new()));
359        let (done_tx, stderr_done) = tokio::sync::watch::channel(false);
360        match child.stderr.take() {
361            Some(pipe) => spawn_stderr_drain(pipe, Arc::clone(&stderr), done_tx),
362            // Nothing will ever arrive, so callers must not wait for it.
363            None => {
364                let _ = done_tx.send(true);
365            }
366        }
367
368        let mut stdin = child.stdin.take().expect("stdin was piped");
369        let body = encode_input_config(&options.input_config());
370        stdin.write_all(&frame(&body)).await?;
371        stdin.flush().await?;
372
373        let mut stdout = child.stdout.take().expect("stdout was piped");
374        let mut len = [0u8; 4];
375        if stdout.read_exact(&mut len).await.is_err() {
376            return Err(Error::HandshakeFailed {
377                stderr: drain_tail(&stderr),
378            });
379        }
380        let mut buf = vec![0u8; u32::from_le_bytes(len) as usize];
381        if stdout.read_exact(&mut buf).await.is_err() {
382            return Err(Error::HandshakeFailed {
383                stderr: drain_tail(&stderr),
384            });
385        }
386
387        let config = decode_output_config(&buf)?;
388        let port = config.port.unwrap_or_default();
389        let api_key = config.api_key.unwrap_or_default();
390        if port <= 0 || port > i32::from(u16::MAX) {
391            return Err(Error::HandshakeFailed {
392                stderr: format!("harness reported an unusable port {port}"),
393            });
394        }
395        log::debug!("harness listening on port {port}");
396
397        Ok(Self {
398            child,
399            port: port as u16,
400            api_key,
401            stderr,
402            stderr_done,
403            _stdin: stdin,
404            _stdout: stdout,
405        })
406    }
407
408    /// The loopback port the harness bound.
409    pub fn port(&self) -> u16 {
410        self.port
411    }
412
413    /// The per-process key the WebSocket upgrade must present.
414    pub fn api_key(&self) -> &str {
415        &self.api_key
416    }
417
418    /// The most recent harness stderr, which is where it reports model and
419    /// configuration failures that never reach the wire.
420    ///
421    /// This is a snapshot: output still in flight is not waited for. Use
422    /// [`Self::stderr_after_exit`] when diagnosing a failure.
423    pub fn stderr_tail(&self) -> String {
424        drain_tail(&self.stderr)
425    }
426
427    /// The harness's stderr, waited for.
428    ///
429    /// A harness that hits a fatal error writes the reason to stderr and then
430    /// exits, which breaks the socket. The client notices the broken socket
431    /// first — so reading stderr at that instant is a race, and losing it turns
432    /// a precise "API key not valid" into a bare "connection closed". This waits
433    /// (briefly, and only on failure paths) for the pipe to reach EOF, which
434    /// happens exactly when the process is gone and its output is all in.
435    pub async fn stderr_after_exit(&self) -> String {
436        let mut done = self.stderr_done.clone();
437        if !*done.borrow() {
438            let _ = tokio::time::timeout(STDERR_FLUSH_TIMEOUT, done.changed()).await;
439        }
440        drain_tail(&self.stderr)
441    }
442
443    /// Waits for the process to exit, having asked it to stop.
444    pub async fn shutdown(mut self) -> Result<()> {
445        self.child.start_kill()?;
446        self.child.wait().await?;
447        Ok(())
448    }
449}
450
451fn spawn_stderr_drain(
452    pipe: tokio::process::ChildStderr,
453    sink: Arc<Mutex<Vec<String>>>,
454    done: tokio::sync::watch::Sender<bool>,
455) {
456    tokio::spawn(async move {
457        use tokio::io::AsyncBufReadExt;
458        let mut lines = BufReader::new(pipe).lines();
459        while let Ok(Some(line)) = lines.next_line().await {
460            log::debug!("localharness: {line}");
461            if let Ok(mut buf) = sink.lock() {
462                if buf.len() == STDERR_TAIL_LINES {
463                    buf.remove(0);
464                }
465                buf.push(line);
466            }
467        }
468        let _ = done.send(true);
469    });
470}
471
472fn drain_tail(sink: &Arc<Mutex<Vec<String>>>) -> String {
473    sink.lock().map(|buf| buf.join("\n")).unwrap_or_default()
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    #[test]
481    fn input_config_carries_client_info_and_env() {
482        let options = HarnessOptions::new().env("FOO", "bar");
483        let config = options.input_config();
484        assert_eq!(config.env.get("FOO").map(String::as_str), Some("bar"));
485        assert_eq!(
486            config.client_info.unwrap().language.as_deref(),
487            Some("rust")
488        );
489    }
490
491    #[test]
492    fn builder_accumulates_workspaces_and_models() {
493        let options = HarnessOptions::new()
494            .workspace("/tmp/a")
495            .workspace("/tmp/b")
496            .model(ModelBuilder::gemini("gemini-flash-latest", "k"));
497        assert_eq!(options.config().workspaces.len(), 2);
498        assert_eq!(options.config().models.len(), 1);
499        assert_eq!(
500            options.config().workspaces[0]
501                .filesystem_workspace
502                .as_ref()
503                .unwrap()
504                .directory
505                .as_deref(),
506            Some("/tmp/a")
507        );
508    }
509
510    #[test]
511    fn cascade_id_implies_create_or_resume() {
512        let options = HarnessOptions::new().cascade_id("abc");
513        assert_eq!(
514            options.config().session_continuation_mode,
515            Some(HarnessConfigSessionContinuationMode::CreateOrResume)
516        );
517    }
518
519    #[test]
520    fn missing_binary_is_reported_as_not_found() {
521        let path = PathBuf::from("/nonexistent/localharness");
522        let err = std::fs::metadata(&path).unwrap_err();
523        let err = Error::HarnessNotExecutable { path, source: err };
524        assert!(err.to_string().contains("is not usable"));
525    }
526}