Skip to content

Latest commit

 

History

History
677 lines (561 loc) · 28.7 KB

File metadata and controls

677 lines (561 loc) · 28.7 KB

Antigravity Harness Guide

genai_rs::antigravity is a native Rust client for the localharness agent runtime that ships with Google's google-antigravity Python SDK. The harness binary is the agent: model calls, streaming, history/compaction, built-in tools (shell, file edits, grep, web search, image generation), MCP, subagents, and trajectory persistence all run inside it. This module speaks its protocol directly — no Python in the loop — so your tools, hooks, and policies are ordinary Rust.

Note: All code blocks in this guide use rust,ignore because the antigravity feature is off by default and doctests run without it. The same snippets are exercised (compiled and run) by examples/antigravity/agent.rs and tests/antigravity_harness.rs.

Setup

Enable the feature:

[dependencies]
genai-rs = { version = "0.10", features = ["antigravity"] }

Install the harness binary (it ships inside the platform-specific wheel):

pip install google-antigravity==0.1.10

Version pinning

The harness wire protocol is internal to Google's SDK and changes across 0.1.x releases. Each genai-rs release is verified against exactly one wheel version, exposed as antigravity::SUPPORTED_HARNESS_VERSION (currently 0.1.10) — pin that version.

Unknown-value preservation is not the same as forward compatibility. Unrecognized events, fields, and enum values are preserved in Unknown variants and extra maps rather than erroring (the crate's Evergreen philosophy), so a newer harness will not crash the bridge. But when a renamed value is one the bridge matches on, preservation is exactly what makes the breakage silent: the match simply stops firing.

The 0.1.5 → 0.1.10 upgrade is the worked example. STATE_IDLE became STATE_FULLY_IDLE, and since only that value ends a turn, every turn ran to its timeout with no error, no failed parse, and a single warn! as the only evidence. usageMetadata likewise became usageUpdate, silently zeroing token accounting. Both old spellings are now accepted as aliases, so one build drives either revision — but the lesson generalizes: when moving to an unverified harness, run the integration suite (--run-ignored all -E 'binary(antigravity_harness)') rather than trusting that a clean parse means a working bridge, and see Debugging for the drift diagnostics that surface this class of mismatch.

Binary discovery

spawn() finds the binary in this order:

  1. AgentBuilder::with_harness_path(...) — explicit path.
  2. ANTIGRAVITY_HARNESS_PATH environment variable (the same variable the Python SDK honors).
  3. google/antigravity/bin/localharness inside python3's site-packages.
  4. localharness on PATH.

A miss returns AntigravityError::HarnessNotFound listing every location searched.

Quick start

use genai_rs::antigravity::{AntigravityAgent, policy};

let mut agent = AntigravityAgent::builder()
    .with_api_key(std::env::var("GEMINI_API_KEY")?)
    .with_model(genai_rs::DEFAULT_MODEL)
    .with_system_instructions("You are a code-review assistant.")
    .add_workspace("/path/to/repo")
    .add_policy(policy::deny_all())
    .add_policy(policy::allow("view_file"))
    .spawn()
    .await?;

let response = agent.chat("Summarize the layout of this repo.").await?;
println!("{}", response.text());

agent.shutdown().await?;   // graceful: the harness persists its trajectory

spawn() launches the harness, performs the stdio handshake, connects to its localhost WebSocket, and initializes the conversation. shutdown() closes the WebSocket and stdin (the harness's graceful-exit signal), then escalates to SIGTERM/SIGKILL only if it lingers. Dropping the agent without shutdown() kills the harness immediately — no zombie, but no trajectory persistence either.

Workspaces

add_workspace(path) (or with_workspace(path) to replace) points the harness's built-in tools at a directory. The harness does not tell the model the workspace path, so by default genai-rs announces the workspace root(s) to the model — it appends a short, clearly delimited note listing the configured root(s) to the effective system instructions at send time (your with_system_instructions string is never mutated). Without this, agents guess paths (/workdir, /workspace, …) and wander.

let agent = AntigravityAgent::builder()
    .add_workspace("/path/to/repo")
    .with_system_instructions("Audit this repo.")
    // workspace announcement is ON by default; turn it off to ground the
    // model yourself:
    .with_workspace_announcement(false)
    // ...
    ;

The same note is appended to every subagent's instructions (subagent trajectories do not inherit the parent's context), so subagents no longer need the workspace path spelled out in their own with_system_instructions. The wire protocol has no native workspace-announcement field (FilesystemWorkspace carries only the directory; enforce_workspace_validation governs enforcement, not disclosure), so this is prompt-level grounding.

Built-in tools (capabilities)

The harness executes its own tool suite; you choose which tools the agent sees. The default is the read-only set (list_directory, search_directory, find_file, view_file, finish), matching the Python SDK:

use genai_rs::antigravity::{BuiltinTool, Capabilities};

// Read-only plus shell access:
let caps = Capabilities::read_only().enable(BuiltinTool::RunCommand);

// Everything (requires a policy — see below):
let caps = Capabilities::all();

// Custom tools only:
let caps = Capabilities::none();

let builder = AntigravityAgent::builder().with_capabilities(caps);

Safety gate

Enabling any write-capable builtin (run_command, edit_file, create_file, generate_image, search_web, start_subagent, ask_question — everything outside the read-only set) or any MCP server without a policy or pre-tool hook is an error at spawn() time — the same guard the Python SDK enforces. Add policy::allow_all() for autonomous agents, or a deny-by-default rule set.

Policies

Policies are declarative allow/deny/confirm rules over tool names, evaluated in Rust before every dispatch decision — defense in depth on top of the harness's own enforcement:

use genai_rs::antigravity::policy;

let agent = AntigravityAgent::builder()
    // deny everything, then allow specific tools:
    .add_policy(policy::deny_all())
    .add_policy(policy::allow("view_file"))
    .add_policy(policy::allow("get_weather"))       // a custom tool
    .add_policy(policy::confirm("run_command"))     // defer to on_pre_tool
    .on_pre_tool(|call| {
        if call.args["commandLine"].as_str().unwrap_or("").contains("rm ") {
            genai_rs::antigravity::PreToolDecision::deny("no deletions")
        } else {
            genai_rs::antigravity::PreToolDecision::Allow
        }
    })
    // ...
    ;

Rules:

  • Exact-name rules beat wildcards ("*"), so registration order between deny_all() and allow("x") doesn't matter; within the same specificity tier, the first matching rule wins.
  • No matching rule = allow (default open, like the Python SDK), still subject to the on_pre_tool hook.
  • confirm(name) defers to on_pre_tool; with no hook configured the call is denied (fail closed).
  • Targets: builtin wire names (run_command, edit_file, ...), custom tool names, and MCP tools as mcp_<server>_<tool>.

Unrecognized tool confirmations

Harness-side builtins pause in a waiting step until the client confirms them; the pending action's identity comes solely from which action field the step carries (the confirmation request itself is an empty marker on the wire — verified against the pinned harness proto). Two edge cases:

  • Pre-request notifications (a step with no action payload at all) announce an upcoming host-side custom tool call. They are auto-approved regardless of policy, mirroring the reference SDK: the concrete call arrives separately and gets its own policy check, so nothing is bypassed.
  • Unknown actions (a step whose action landed in the Evergreen extra map — e.g. a builtin newer than this client) fail closed: the confirmation is that tool's only gate, so it is approved only when a policy rule matches (wildcard allow_all(), or an exact rule naming the unknown wire field, e.g. allow("deleteEverything")) or the on_pre_tool hook allows it. A warn! records the unknown field names and the decision either way. This is stricter than the reference SDK, which auto-approves anything it cannot map.

on_post_tool observes completed custom tool calls (and harness-side post-tool hook callbacks) for audit logging. ToolOutcome.result is the inner tool result, not the {"result": ...} wire envelope the harness receives: a scalar return X arrives as its string form (or X serialized when non-string), and an object return is passed through serialized. Failures populate ToolOutcome.error instead.

Answering agent questions

The ask_question builtin lets the agent pause a turn to ask the user questions (multiple-choice, optionally multi-select). It is off in the default read-only capability set — enable it explicitly, and note that it counts as write-capable for the safety gate, so a policy or pre-tool hook must also be registered. (The policy satisfies the gate but does not govern questions — question requests bypass the policy engine entirely; on_questions, or not enabling the builtin, is the only control.) Set an on_questions hook to answer the questions programmatically — route them to a CLI prompt, a chat message, or policy code:

use genai_rs::antigravity::{BuiltinTool, Capabilities, QuestionAnswer, QuestionReply, policy};

let agent = AntigravityAgent::builder()
    // ...
    // read_only() does not include AskQuestion — enable it explicitly.
    .with_capabilities(Capabilities::read_only().enable(BuiltinTool::AskQuestion))
    // AskQuestion is write-capable, so the spawn-time safety gate requires
    // a policy (or on_pre_tool hook) once it is enabled. Any rule
    // satisfies the gate (questions bypass the policy engine), so prefer
    // deny-by-default over allow_all() — it won't silently permit other
    // builtins you enable later.
    .add_policy(policy::deny_all())
    .on_questions(|questions| {
        // Answer each question: pick the first choice, but never guess on
        // an unmodeled question type or one with no rendered choices.
        QuestionReply::Answers(
            questions
                .iter()
                .map(|q| {
                    if q.is_unknown_type() || q.choices.is_empty() {
                        QuestionAnswer::Unanswered
                    } else {
                        QuestionAnswer::Choices { selected: vec![0], freeform: None }
                    }
                })
                .collect(),
        )
    })
    .spawn()
    .await?;

QuestionReply::Cancel sends the cancelled flag with no answers — "stop asking"; what the harness then does with the in-flight turn is harness-owned and not live-verified. A short answer list is padded with Unanswered. (Freeform(text) is the ergonomic spelling of a Choices answer with no selection and that freeform text — the two produce identical wire.) Without a hook every question is answered "unanswered" (with a warn!) so the harness never deadlocks — and since the builtin is off by default, simply not enabling it means the agent never asks.

A question whose type this crate doesn't model arrives with is_unknown_type() true, empty text/choices, and the raw payload in extra — prefer Cancel or Unanswered there over guessing (the is_unknown_type() check in the snippet above is what routes that case to Unanswered rather than selecting index 0 of an empty list). AgentQuestion::unknown(extra) builds that fixture for unit tests.

The hook is synchronous and runs inline in the harness event pump — don't block in it waiting for a human. For interactive flows, collect the answer out-of-band (chat reply, CLI prompt in another task) into a channel and answer from try_recv, returning Unanswered when nothing has arrived; the deadlock-avoidance fallback only covers the hookless case.

Custom tools — the same #[tool] functions as the Interactions API

Tool declarations are the crate's ordinary FunctionDeclaration; dispatch reuses the global #[tool] registry and ToolService:

use genai_rs::CallableFunction;
use genai_rs_macros::tool;

/// Returns the current weather for a city.
#[tool(city(description = "The city to get weather for"))]
fn get_weather(city: String) -> String {
    format!("Sunny and 22C in {city}")
}

let agent = AntigravityAgent::builder()
    .add_tool(GetWeatherCallable.declaration())     // #[tool] machinery
    .with_tool_service(my_service)                  // stateful ToolService
    // ...
    ;

When the model calls a custom tool, the crate checks policies, executes your function, and replies to the harness automatically. Failures become {"error": ...} results the model can react to — the turn is never deadlocked by a failing tool.

MCP servers

use genai_rs::antigravity::McpServer;

let agent = AntigravityAgent::builder()
    .add_mcp_server(McpServer::stdio("uvx", ["mcp-server-git"]).with_name("git"))
    .add_mcp_server(McpServer::http("http://localhost:8931/mcp").with_name("tickets"))
    .add_policy(policy::deny_all())
    .add_policy(policy::allow("mcp_git_status"))    // per-tool policy target
    // ...
    ;

The harness owns the MCP connections and tool execution; your policies see the calls as mcp_<server>_<tool>.

Subagents

Static subagents run in their own trajectory with their own instructions and tool set; the parent model delegates to them through the start_subagent builtin. That builtin is off in the default read-only capability set and is write-capable, so enabling it requires a policy or pre-tool hook (the spawn-time safety gate):

use genai_rs::CallableFunction;
use genai_rs::antigravity::{BuiltinTool, Capabilities, Subagent, policy};

let agent = AntigravityAgent::builder()
    .add_tool(SeverityClassifierCallable.declaration())   // parent registration
    .add_subagent(
        Subagent::new("auditor")
            .with_description("Audits one file for security issues.")
            .with_system_instructions("Focus on injection vectors.")
            .with_capabilities(Capabilities::read_only()) // the default
            .add_tool("severity_classifier"),             // reference by name
    )
    .with_capabilities(Capabilities::read_only().enable(BuiltinTool::StartSubagent))
    .add_policy(policy::allow_all())
    // ...
    ;

Rules (matching the reference SDK):

  • Custom tools are referenced by name and must also be registered on the parent agent (add_tool / with_tool_service) — subagent custom-tool calls dispatch through the parent's registry. spawn() validates the references (and name uniqueness) and fails with AntigravityError::Config on a dangling one.
  • Subagent with_system_instructions are appended to the harness's default subagent instructions, not a full replacement (unlike the parent's with_system_instructions).
  • Subagent capabilities default to the read-only builtin set; nested subagents are unsupported, so start_subagent is force-disabled inside a subagent.
  • Subagent activity surfaces in streams as AgentEvent::ToolAction with ToolAction::InvokeSubagent, plus the subagent trajectory's own deltas. The ToolAction event carries the subagent's trajectory_id so its actions can be told apart from the parent's. ToolAction::subagent_name() exposes the invoked subagent's name if the harness reports it — harness 0.1.5 emits an empty invokeSubagent action (verified via LOUD_WIRE), so it is None there; the typed field is future-proofing.

Streaming

use futures_util::StreamExt;
use genai_rs::antigravity::{AgentEvent, ErrorSeverity};

let mut stream = agent.send_streaming("Refactor src/lib.rs").await?;
while let Some(event) = stream.next().await {
    match event? {
        AgentEvent::TextDelta(t) => print!("{t}"),
        AgentEvent::ThinkingDelta(_) => {}
        AgentEvent::ToolAction { action, decision, trajectory_id } => {
            // `decision` distinguishes executed from policy/hook-denied
            // actions; `trajectory_id` tells parent and subagent actions apart.
            eprintln!("[harness tool] {action:?} ({decision:?}) traj={trajectory_id:?}");
        }
        AgentEvent::ToolCallDispatched { name, .. } => eprintln!("[custom tool] {name}"),
        AgentEvent::Finished(response) => { println!(); break; }
        // Transient errors are harness-internal noise (the turn continues);
        // only `Severe` ones matter. Turn-ending failures arrive as
        // `AntigravityError::Turn` from the call, not as this event.
        AgentEvent::Error { message, severity } => match severity {
            ErrorSeverity::Severe => eprintln!("[error] {message}"),
            _ => {} // ignore transient noise
        },
        _ => {} // non-exhaustive
    }
}

Event decisions, trajectory identity, and error severity

  • ToolAction { action, decision, trajectory_id }decision is a [ToolDecision] (Allowed, or Denied { reason }): a policy- or hook-blocked harness action is otherwise indistinguishable from an executed one. trajectory_id identifies the (sub)trajectory the action ran in, so parent and subagent actions can be told apart in the interleaved stream.
  • Error { message, severity }severity is an [ErrorSeverity]. Transient errors are harness-internal noise (retried internally; the turn continues) — essentially every error event today. Severe marks a serious mid-turn error (e.g. a fatal model-backend status that did not abort the turn); genuinely turn-ending failures surface as AntigravityError::Turn from chat/send_streaming, never as this event.

The stream borrows the agent mutably for the turn. To cancel from another task, take a handle first:

let cancel = agent.cancel_handle();
// ... later, from any task:
cancel.cancel().await?;   // the in-flight turn ends early, keeping partial output

What a cancelled turn returns: harness 0.1.10 answers a halt by taking the trajectory to STATE_FULLY_IDLE — the same terminal state as a natural completion, not STATE_CANCELLED. The turn therefore resolves normally with whatever partial output it had produced, rather than failing with AntigravityError::Turn. Treat cancel() as "stop early and keep what you have", and record the cancellation on your side if you need to distinguish a halted turn from a completed one. (AntigravityError::Turn is still the outcome when the harness cancels a turn of its own accord.) Verified live by test_antigravity_cancel_handle_halts_an_in_flight_turn.

with_turn_timeout(Duration) bounds each turn's wall-clock time. When the budget is exceeded, the crate halts the harness's still-running turn and drains its remaining events before returning AntigravityError::Timeout, so the next turn starts from a clean stream.

The deadline is absolute, stamped when the turn starts, and it covers the whole turn rather than the gaps between harness events. For send_streaming that includes time your code spends between polls of the stream — so a consumer that renders events interactively, or awaits a confirmation mid-turn, is spending the same budget the harness is. Raise it, or call without_turn_timeout(), for consumers that pause mid-turn; the Timeout error they would otherwise get carries a stall diagnosis pointing at the harness, which is the wrong place to look.

Turns are bounded by defaultDEFAULT_TURN_TIMEOUT, 300s — so you get an error rather than a hang without opting in. An unbounded turn does not fail when the harness stops signalling completion; it hangs, which is strictly less diagnosable than an error and looks identical to latency from the outside. That is not hypothetical: harness 0.1.10 renamed the terminal trajectory state, and every turn ran on with no error, no failed parse and nothing in the logs.

Raise it for agents that legitimately run long (deep subagent trees, many tool calls), lower it for interactive use where a stall should surface fast, or remove it deliberately:

let mut agent = AntigravityAgent::builder()
    .without_turn_timeout()  // runs until the harness ends the turn
    .spawn().await?;

Structured output

let mut agent = AntigravityAgent::builder()
    .with_response_schema(serde_json::json!({
        "type": "object",
        "properties": {"severity": {"type": "string"}},
        "required": ["severity"]
    }))
    // ...
    .spawn().await?;

let response = agent.chat("Audit this repo.").await?;
if let Some(value) = response.structured_output() {
    println!("severity = {}", value["severity"]);
}

Triggers

Triggers inject a message into the conversation on a fixed interval — without a user turn — via the protocol's automated_trigger event (mirroring the reference SDK's TriggerRunner):

use genai_rs::antigravity::TriggerConfig;
use std::time::Duration;

let agent = AntigravityAgent::builder()
    .add_trigger(TriggerConfig::new(
        "Check the queue for new items and summarize them.",
        Duration::from_secs(300),
    ))
    // ...
    .spawn().await?;

Delivery semantics (see antigravity::triggers for details):

  • The first firing happens after the first interval elapses, not immediately. Intervals must be non-zero (spawn() validates).
  • Give the conversation one real turn before a trigger can fire. On harness 0.1.10, a trigger delivered into a conversation with no history crashes the harness process — its pre-invocation hook asks for "tokens since the last checkpoint", finds no steps, and aborts the agent run (earliest step index is out of bounds: 0 vs 0). The session dies with it, so the next send fails on a closed socket or a broken pipe. One completed turn is enough. Reproduced by examples/antigravity/proactive_agent, which opens with a turn for exactly this reason.
  • A firing is delivered only while the agent is idle (no chat/send_streaming turn in flight). If it comes due mid-turn, it is deferred until the turn ends, and missed intervals collapse into a single delivery (no backlog after a long turn).
  • Trigger tasks stop cleanly on shutdown() and on drop — no zombie timers. A failed delivery (session closed) ends that trigger's task; other triggers and the session are unaffected.
  • A trigger delivered while idle starts a harness-side turn that runs unobserved. Its output is not surfaced: the next chat/send_streaming call halts the trigger's turn if it is still running and discards its events before sending your input, so a trigger turn can never surface as (or desync) your turn's response. The trigger's effects on conversation history (and any tool calls completed before the halt) persist; surfacing trigger-turn output through a dedicated consumer is a follow-up.

Session persistence and resume

// First run:
let agent = AntigravityAgent::builder()
    .with_save_dir("/var/lib/myapp/agent-sessions")
    .spawn().await?;
let id = agent.conversation_id().unwrap().to_string();
agent.shutdown().await?;   // shutdown() persists the trajectory

// Later:
let agent = AntigravityAgent::builder()
    .with_save_dir("/var/lib/myapp/agent-sessions")
    .with_conversation_id(id)
    .spawn().await?;
println!("restored {} steps", agent.initial_history().len());

Debugging

Diagnosing protocol drift

Three signals exist specifically for the failure mode described under Version pinning — a harness that speaks a dialect this build only partly understands, which otherwise presents as "the agent just didn't do anything":

Signal What it tells you
protocol::drift_report() Every unrecognized wire value seen, as "EnumName=WIRE_VALUE" -> count. Empty is healthy. Process-wide and cumulative; clear_drift_report() resets it.
The warn! on shutdown() The same aggregate, logged once at the natural end of a session, so it does not scroll past like the per-value warns do.
AntigravityError::Timeout When a turn times out having seen unrecognized main-trajectory states, the operation field names them and points at SUPPORTED_HARNESS_VERSION instead of reporting an undifferentiated stall.

Programmatic check, for anything long-running:

let drift = genai_rs::antigravity::protocol::drift_report();
if !drift.is_empty() {
    eprintln!("harness sent values this build does not model: {drift:?}");
}

CI runs a stronger version of this: test_antigravity_protocol_enums_have_not_drifted diffs the installed wheel's protobuf descriptor against the crate's wire enums and fails naming any value the harness can send that the crate does not model. That is the check that turns a renamed enum from a silent hang into a red test.

Wire inspection

The Antigravity client feeds the crate's canonical wire-inspection layer (genai_rs::wire). LOUD_WIRE=1 pretty-prints everything to stderr:

  • HARNESS <path> (pid N) — process spawn,
  • WS Send / WS Receive — every proto-JSON message,
  • STDERR: — every harness diagnostic line.
LOUD_WIRE=1 cargo run --example antigravity_agent --features antigravity

LOUD_WIRE=1 is the right default for a single HTTP request and the wrong one for a harness session: a few turns produce thousands of pretty-printed lines, and finding the message that matters means grepping raw JSON out of the scrollback. So LOUD_WIRE also takes a comma-separated filter.

Value Keeps
1, true, yes, on, all Everything (unchanged)
harness Spawn and stderr lines
ws Every WebSocket message
request, response, sse, upload The HTTP-side categories
any other token WebSocket messages whose top-level key matches
summary Modifier: one line per event instead of full bodies

The last two are what make a session readable. Selectors name the proto oneof arm — stepUpdate, toolCall, userInput, trajectoryStateUpdate — matched case-insensitively, and envelope bookkeeping (seqNum, timestampMicros) is ignored so it cannot match everything.

LOUD_WIRE=summary                  # the whole session, one line per message
LOUD_WIRE=toolCall,summary         # just the tool traffic, one line each
LOUD_WIRE=trajectoryStateUpdate    # why a turn will not finish
LOUD_WIRE=harness                  # spawn + stderr only, no protocol noise

LOUD_WIRE=summary renders each message as its payload keys, which is usually enough to see the shape and order of a turn; drop to a selector once you know which message you want in full. The same filtering is available programmatically via wire::WireFilter and LoudWirePrinter::with_filter.

For programmatic capture, register inspectors on the builder — the WireEvent variants are HarnessSpawn, WsSend, WsReceive, and HarnessStderr, sharing one correlation id per harness session:

use genai_rs::wire::TracingForwarder;
use std::sync::Arc;

let agent = AntigravityAgent::builder()
    .add_wire_inspector(Arc::new(TracingForwarder::new()))  // RUST_LOG=genai_rs::wire=debug
    // ...
    ;

Spawn- and init-time errors (HandshakeFailed, InitFailed, ConnectionClosed) carry the tail of the harness's stderr — that is where the harness explains itself (e.g. no text model configuration provided).

Errors

AntigravityError is structural (#[non_exhaustive], thiserror): match on variants, never on message text. Key variants: HarnessNotFound{searched}, HandshakeFailed/InitFailed/ConnectionClosed (with stderr), Config (spawn-time validation, including the safety gate), Turn (cancellation, pre-turn denial, fatal model-backend errors), Timeout, ToolDispatch, WebSocket, Protocol, Io, Json.

Current limitations (follow-ups)

  • User questions: the ask_question builtin is answered "unanswered" automatically (never deadlocks) unless an on_questions hook is set — see Answering agent questions.
  • Hooks are synchronous: on_pre_tool / on_post_tool / on_questions are sync closures; async hooks are a follow-up.
  • Trigger-turn output is not surfaced: turns started by add_trigger deliveries run unobserved and are halted/discarded by the next chat/send_streaming (see Triggers); a background consumer surfacing their events is a follow-up.
  • Vertex endpoints: wire types exist; the tested path is the Gemini API key endpoint.