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,ignorebecause theantigravityfeature is off by default and doctests run without it. The same snippets are exercised (compiled and run) byexamples/antigravity/agent.rsandtests/antigravity_harness.rs.
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.10The 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.
spawn() finds the binary in this order:
AgentBuilder::with_harness_path(...)— explicit path.ANTIGRAVITY_HARNESS_PATHenvironment variable (the same variable the Python SDK honors).google/antigravity/bin/localharnessinsidepython3's site-packages.localharnessonPATH.
A miss returns AntigravityError::HarnessNotFound listing every location
searched.
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 trajectoryspawn() 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.
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.
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);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 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 betweendeny_all()andallow("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_toolhook. confirm(name)defers toon_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 asmcp_<server>_<tool>.
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
extramap — 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 (wildcardallow_all(), or an exact rule naming the unknown wire field, e.g.allow("deleteEverything")) or theon_pre_toolhook allows it. Awarn!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.
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.
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.
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>.
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 withAntigravityError::Configon a dangling one. - Subagent
with_system_instructionsare appended to the harness's default subagent instructions, not a full replacement (unlike the parent'swith_system_instructions). - Subagent capabilities default to the read-only builtin set; nested
subagents are unsupported, so
start_subagentis force-disabled inside a subagent. - Subagent activity surfaces in streams as
AgentEvent::ToolActionwithToolAction::InvokeSubagent, plus the subagent trajectory's own deltas. TheToolActionevent carries the subagent'strajectory_idso 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 emptyinvokeSubagentaction (verified viaLOUD_WIRE), so it isNonethere; the typed field is future-proofing.
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
}
}ToolAction { action, decision, trajectory_id }—decisionis a [ToolDecision] (Allowed, orDenied { reason }): a policy- or hook-blocked harness action is otherwise indistinguishable from an executed one.trajectory_ididentifies the (sub)trajectory the action ran in, so parent and subagent actions can be told apart in the interleaved stream.Error { message, severity }—severityis an [ErrorSeverity]. Transient errors are harness-internal noise (retried internally; the turn continues) — essentially every error event today.Severemarks a serious mid-turn error (e.g. a fatal model-backend status that did not abort the turn); genuinely turn-ending failures surface asAntigravityError::Turnfromchat/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 outputWhat 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 default — DEFAULT_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?;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 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 byexamples/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_streamingturn 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_streamingcall 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.
// 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());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.
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 antigravityLOUD_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 noiseLOUD_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).
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.
- User questions: the
ask_questionbuiltin is answered "unanswered" automatically (never deadlocks) unless anon_questionshook is set — see Answering agent questions. - Hooks are synchronous:
on_pre_tool/on_post_tool/on_questionsare sync closures; async hooks are a follow-up. - Trigger-turn output is not surfaced: turns started by
add_triggerdeliveries run unobserved and are halted/discarded by the nextchat/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.