Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
70d643c
feat(proto): regenerate against upstream 0.1.9 (WP-1)
claude Aug 2, 2026
c1f7747
test(mock): answer the handshake; prove the sandbox end to end (WP-2)
claude Aug 2, 2026
549b036
docs: add the executable backlog
claude Aug 2, 2026
ac74416
feat(connection): read the handshake reply; session continuation mode…
claude Aug 2, 2026
67b48cc
fix(connection): ordered shutdown; clear the question dedup set (WP-6…
claude Aug 2, 2026
a11fd75
docs: update the backlog after the WP-6 core
claude Aug 2, 2026
a2e4665
feat(policy): add safe_defaults (S13)
claude Aug 2, 2026
d4bd2ec
docs: break the backlog into shippable batches
claude Aug 2, 2026
c3fda7d
fix(connection): drive idle from the main trajectory alone (A1)
claude Aug 2, 2026
4f5ad1a
docs: add the Phase A implementation plan
claude Aug 2, 2026
47b5930
fix(connection): stop dropping steps queued behind an idle event (A2,…
claude Aug 2, 2026
e7d7753
ci: compile the wasm target, the doctests and the directory examples …
claude Aug 2, 2026
36fc146
refactor(connection): make the idle marker a channel variant (A2, par…
claude Aug 2, 2026
834e570
feat(connection): surface cancelled turns and turn-level errors (A3, A4)
claude Aug 2, 2026
3227a89
fix(connection): action-level error fallback; sanitize prompts (A4, A5)
claude Aug 2, 2026
3ec9615
fix(hooks): contain a failing on_tool_error hook (H9)
claude Aug 2, 2026
18783eb
fix(policy): generated MCP policy names use approve_, not allow_ (S15)
claude Aug 2, 2026
dbecf0f
fix(connection): answer the question the user was actually asked
claude Aug 2, 2026
aa92771
feat(policy): compose policy groups in the builder (N8)
claude Aug 2, 2026
c2d6158
fix(hooks): dispatch session_end on disconnect (H1c)
claude Aug 2, 2026
5c49f04
feat(agent): validate the prompt and the conversation id
claude Aug 2, 2026
ab34855
fix(connection): lift the websocket message size caps
claude Aug 2, 2026
e325718
fix(conversation): ChatResponse.steps carries the turn, not the session
claude Aug 2, 2026
5e9acef
feat(tools): atomic ToolContext::update_state
claude Aug 2, 2026
4cb5ddf
docs: add docs/policy.md; fix selectors that can never match
claude Aug 2, 2026
ecc891e
fix(policy): expose the edit itself to EDIT_FILE predicates
claude Aug 2, 2026
0242af9
feat(connection): cancellation and harness-crash diagnostics (A3, A4)
claude Aug 2, 2026
88c6d69
feat(session): seed resumed history, env passthrough, save_dir, conne…
claude Aug 2, 2026
7e32c0a
fix(connection): single-consumer step stream; ASK_QUESTION as a real …
claude Aug 2, 2026
a984ead
fix(security): pre-tool gating fails closed (S2/D1)
claude Aug 2, 2026
3e9efa1
feat(hooks): pre_turn gating and subagent completion (B2 core, B3)
claude Aug 2, 2026
0b54c38
feat: FINISH as a tool call, per-turn usage, empty tool arguments (D4…
claude Aug 2, 2026
247e549
feat(tools): reject duplicate names, preserve order, run batches conc…
claude Aug 2, 2026
405efff
feat(triggers): narrow the trigger surface, add stop, callback-based …
claude Aug 2, 2026
93ef37a
feat(tools): construct and inject the ToolContext (T1+T9)
claude Aug 2, 2026
a806072
fix(hooks): on_tool_error rewords a failure, it cannot clear it (H4, D3)
claude Aug 2, 2026
98b89f6
feat(hooks): dispatch post_turn and on_compaction (D8)
claude Aug 2, 2026
40d2f62
feat(tools): one ToolResponse builder, server_name and structured fai…
claude Aug 2, 2026
a50c118
feat(tools): coerce model arguments to the tool's declared schema (T4)
claude Aug 2, 2026
69feb98
docs(examples): exercise a context-aware tool (X19)
claude Aug 2, 2026
1327b83
feat(conversation): drain the previous turn on send; add wait_for_idl…
claude Aug 2, 2026
698c0ad
docs: B2 is complete — H1c and H9 landed earlier in this branch
claude Aug 2, 2026
4fae969
feat(models): the model target type graph, merge algorithm and env ro…
claude Aug 2, 2026
1da0f9f
feat(mcp): emit configured MCP servers on the wire (C4)
claude Aug 2, 2026
749e734
feat(capabilities): web tools and named subagents (C6, C7)
claude Aug 2, 2026
4b69f7c
feat(hooks): hook kind registry and a shared state store (E1, E2)
claude Aug 2, 2026
bf2a863
ci: detect upstream drift before it becomes a migration (E7)
claude Aug 2, 2026
3cef379
feat(hooks): the harness-side hook router, and turn on enabled_hooks …
claude Aug 2, 2026
07d5868
fix(connection): a fresh connection starts idle (C2, A2)
claude Aug 2, 2026
9687755
feat(session): seed resumed history on the wasm transport too (A5)
claude Aug 2, 2026
f803f1d
feat(connection): the session-end handshake on disconnect (B7)
claude Aug 2, 2026
7501adb
feat(hooks): structured results for harness-executed built-ins (N3, B5)
claude Aug 2, 2026
8dfe08f
test(wasm): make the in-file mock deterministic
claude Aug 2, 2026
338125d
feat(hooks): thread a HookContext through every hook method (E3)
claude Aug 2, 2026
052bab3
feat(api): multimodal prompts and slash commands reach the harness (E6)
claude Aug 2, 2026
fc90bc1
feat(config): retry policy and tool-output truncation (C5)
claude Aug 2, 2026
c0dd667
fix(policy): tool-call args carry arguments, not results
claude Aug 2, 2026
a707e36
docs: record A2's actual enum shape, not the planned one
claude Aug 2, 2026
f350171
release: 0.2.0, with a changelog naming the breaks
claude Aug 2, 2026
050f6fb
docs: the second Hook break landed here, it is not still coming
claude Aug 2, 2026
5bd1a10
fix(scripts): install the harness version the SDK actually speaks
claude Aug 2, 2026
993a5f2
docs: mark 0.2.0 unreleased
claude Aug 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
The diff you're trying to view is too large. We only load the first 3000 changed files.
50 changes: 50 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ on:
branches: [ main ]
pull_request:
branches: [ main ]
schedule:
# Weekly, Monday 07:00 UTC. Upstream drift is not urgent, but three minor
# versions of it went unnoticed because nothing ever looked.
- cron: '0 7 * * 1'

env:
CARGO_TERM_COLOR: always
Expand All @@ -26,6 +30,9 @@ jobs:
uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
# src/wasm.rs is a fork of src/local.rs and is compiled by nothing
# else: `--all-targets` builds it only as a host test.
targets: wasm32-unknown-unknown

- name: Cache dependencies
uses: swatinem/rust-cache@v2
Expand All @@ -38,3 +45,46 @@ jobs:

- name: Run Tests
run: cargo test --all-targets --all-features

# `--all-targets` excludes doctests, so every ``` block in the crate docs
# is currently uncompiled.
- name: Run doctests
run: cargo test --doc

# The wasm transport duplicates most of the native one. Without this step
# a change can land in src/local.rs, miss its src/wasm.rs mirror, and go
# unnoticed -- which has already happened once.
- name: Check wasm target
run: cargo check --target wasm32-unknown-unknown --lib

# Each of these declares its own workspace, so the root build never sees
# them and an API change does not break them until a user hits it.
- name: Check directory examples
run: |
for manifest in examples/*/Cargo.toml; do
echo "::group::${manifest}"
cargo check --manifest-path "${manifest}" --all-targets
echo "::endgroup::"
done

# The whole reason this crate drifted three minor versions behind upstream is
# that nothing was watching. This job fails when a newer google-antigravity
# release exists than the one proto/localharness.proto was generated from, and
# when the generated proto no longer matches what that release actually ships.
upstream-drift:
name: Upstream drift
runs-on: ubuntu-latest
# Advisory on a pull request; a scheduled run is where it should page.
continue-on-error: true
steps:
- name: Checkout sources
uses: actions/checkout@v4

- name: Install Python dependencies
run: |
python3 -m pip install --quiet --upgrade pip
python3 -m pip install --quiet protobuf requests

- name: Compare the pinned harness against the newest release
run: python3 scripts/check_upstream_drift.py

183 changes: 183 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# Changelog

## 0.2.0 — unreleased

Not yet published to crates.io. Publishing waits on one full turn against a
live 0.1.9 harness, which needs credentials; the release is triggered by
pushing a `v*` tag, so merging this does not publish it.

Migrates the wire format from upstream 0.1.1 to **0.1.9** and clears the defect
backlog recorded in `docs/upstream-parity.md` and
`docs/fix-plan-current-defects.md`.

This release **breaks source compatibility**. The breaks are batched here
deliberately so downstream code adapts once — including both `Hook` breaks, the
second of which was pulled forward for exactly that reason. See
[On the two `Hook` breaks in this release](#on-the-two-hook-breaks-in-this-release).

### Why the wire changes matter

`proto/localharness.proto` had been hand-transcribed from 0.1.1 and had drifted
eight releases. It is now generated from the descriptor embedded in the upstream
wheel (`scripts/gen_proto.py`). The transport is protojson, which matches on
field and enum **names**, so a rename is as fatal as a renumber and fails
silently: the frame is dropped as unknown rather than rejected.

Two examples this fixed, both of which the suite was previously certifying as
working:

- `HarnessConfig.gemini_config` was **removed upstream in 0.1.4**. The harness
rejected the frame and closed the socket. The crate now sends the
`repeated ModelConfig models` list that replaced it.
- `STATE_IDLE` was renamed `STATE_FULLY_IDLE` in 0.1.9. The idle event was
discarded as an unknown variant, so a turn never ended.

CI now regenerates the proto from the live upstream wheel on a schedule and
fails if the checked-in schema disagrees, so the next rename surfaces as a
build failure rather than a silent hang.

### Breaking changes

**Hooks**

- Every `Hook` method takes a `&HookContext` as its final parameter, giving
hooks access to session state and conversation metadata.
- `post_turn` receives `&str` (the response text), not `&ChatResponse`.
- `on_compaction` receives `&Step`, not `&str`.
- `on_tool_error` returns `Result<Option<String>>` — a replacement *message*.
It could previously return a value that **cleared** the error, which reported
a failed tool to the model as a genuine success.
- **`pre_tool_call` now fails closed.** A hook that returns `Err` denies the
call; it was previously treated as "no objection" and the tool ran, so any
hook bug was an open gate. A hook with a tolerable failure mode must catch it
and return `allow: true` explicitly.
- Implementors may declare which kinds they handle via `declares()`, which is
what drives the harness-side `enabled_hooks`.

**Tools**

- `ToolCall` gains `server_name`; `ToolResult` gains `server_name` and a
structured `exception`.
- Registering two tools with the same name is now an error rather than a silent
overwrite; registry order is preserved.
- Model-supplied arguments are coerced against the tool's declared JSON Schema.
Only unambiguous conversions are performed, so a real type error still reads
as one.
- `ToolContext` is constructed and injected. It previously existed but was
never built, so context-aware tools did not work at all.
- Absent or empty `arguments_json` is `{}` rather than an error.

**Types and responses**

- `ChatResponse.usage_metadata` is `Option<UsageMetadata>` and reports **this
turn**, not the session total. The running total remains on
`Conversation::total_usage`.
- `ChatResponse.steps` carries the turn, not the whole session.
- `UsageMetadata`'s counters are `u64`, matching the harness, which has declared
`uint64` since 0.1.1.
- `BuiltinTools::AskQuestion` (`ASK_QUESTION`) exists and drives
`user_questions.enabled`, which was hardcoded on. **A caller passing an
explicit `enabled_tools` list must add `ASK_QUESTION` to keep the question
panel.**
- `BuiltinTools::read_only()` includes `FINISH`; an agent that cannot finish
cannot terminate a turn or emit structured output.

**Triggers**

- `Trigger::run` takes a one-method `TriggerContext` instead of the full
connection.
- `every()` invokes a callback and rejects a non-positive interval.
- `TriggerRunner::stop` exists and `Agent::stop` calls it. Triggers previously
outlived the agent.

**Connection**

- `receive_steps()` is single-consumer. Two live streams shared one receiver and
each took roughly half the steps, silently; a second subscriber now gets an
error. The claim is released when the stream is dropped, so the per-turn call
still works.
- Prompts accept multimodal parts and slash commands.

**Policy**

- Workspace scoping is applied **unconditionally**, including alongside
`allow_all()` — which upstream documents as the way to get autonomous shell
access *while* file tools stay scoped. The opt-out is `workspaces(vec![])`,
not a policy.
- Generated MCP policy names use `approve_`, not `allow_`.

### Added

- Session resumption: the handshake reply is read, and `Conversation` is seeded
with the replayed history on both transports.
- `SessionContinuationMode` on the config and builder, with upstream's RESUME
validation.
- Cancellation: `Conversation::cancel()`. The harness answers a halt with an
ordinary idle, so a caller-initiated halt is tracked client-side and surfaces
as `AntigravityError::Cancelled` rather than looking like a completed turn.
- Harness-crash diagnostics: the last 20 stderr lines are retained and attached
when the socket closes mid-turn. A crash previously ended the stream in
silence.
- The harness-side hook channel: `CallHookRequest`/`Response`, the router, and
`enabled_hooks`.
- Model configuration: `ModelTarget` / `ModelEndpoint` / `GeminiModelOptions`,
the explicit → shorthand → default merge, and `GOOGLE_GENAI_USE_VERTEXAI` /
`GOOGLE_CLOUD_PROJECT` / `GOOGLE_CLOUD_LOCATION` routing. The explicit list is
`GeminiConfig::model_targets`, since `models` was already the crate's
shorthand — a deliberate divergence from upstream's naming.
- MCP servers on the wire, with stdio `env` and `timeout_seconds`.
- `search_web` and `read_url_content`; named custom subagents.
- `RetryConfig` and `ToolOutputTruncation`.
- `Conversation::wait_for_idle`; `send` drains the previous turn into history.
- `safe_defaults()`, `workspace_only_for()`, and policy group composition via
`AgentBuilder::policy_groups`.
- `docs/policy.md`.

### Fixed

- **`scripts/install_harness.sh` installed harness 0.1.1**, whose wire format
this SDK no longer speaks — a turn against it never ends, because `STATE_IDLE`
was renamed and protojson drops the unknown variant. It now installs 0.1.9,
and the drift job fails if that pin ever disagrees with the version the proto
was generated from.

- **Workspace sandbox escape.** Containment is decided after resolution — `..`
is collapsed and symlinks are followed before comparison — and resolution
failure is treated as outside. It fails closed.
- The workspace root no longer falls back to `/tmp/.gemini/antigravity` when
`HOME` is unset.
- `Agent::start` routes through `policy::enforce()`; it previously bypassed it.
- Question answers are matched to the question actually asked, by index.
- Websocket message size caps are lifted; tool results and file contents
routinely exceed tungstenite's defaults, and hitting the cap killed the
connection mid-turn.
- A subagent going idle no longer ends the caller's turn.
- Steps queued behind an idle event are no longer dropped.
- A fresh connection reports idle, so send-then-receive cannot race.
- `on_session_end` hooks are dispatched on disconnect; they previously never ran.
- A failing `on_tool_error` hook is contained.
- Shutdown is ordered — stdin is closed first, so the harness runs its cleanup
and persists the trajectory instead of being killed outright.
- `EDIT_FILE` policy predicates can see `diff_block`, so a rule can inspect the
change and not only the path.
- Tool-call arguments carry arguments, not post-execution results.
- The removed DuckDuckGo/`python3` scraper.

### On the two `Hook` breaks in this release

`Hook` is broken **twice** here, deliberately, so that downstream code adapts
once rather than across two releases:

1. Signatures — `post_turn` takes `&str`, `on_compaction` takes `&Step`,
`on_tool_error` returns `Result<Option<String>>`.
2. A `&HookContext` parameter on all nine methods.

The second was originally planned for a later release, which would have broken
every implementation a second time. It was pulled forward instead. No further
`Hook` break is on the roadmap — though this is a pre-1.0 crate and that is not
a stability guarantee.

### Out of scope

`DebugConfig` has no field in the 0.1.9 proto and is dropped rather than
invented.
2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "antigravity-sdk-rust"
version = "0.1.14"
version = "0.2.0"
edition = "2024"
license = "MIT"
description = "Rust SDK for Google Antigravity and Gemini to build autonomous, stateful, and secure AI agents"
Expand Down
13 changes: 13 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ fn main() {
pbjson_build::Builder::new()
.register_descriptors(&descriptor_set)
.unwrap()
// Tolerate fields this crate's schema does not know about. A newer
// harness always adds fields before we regenerate, and pbjson's default
// is to fail the whole message on the first unknown one — which drops
// the entire event rather than the field. Note this does NOT cover
// unknown *enum variants*: pbjson emits `unknown_variant` regardless,
// so an enum rename still has to be caught by regenerating.
.ignore_unknown_fields()
.build(&[".antigravity.localharness"])
.unwrap();

// So ClientInfo.language_version reports something real instead of "unknown".
println!(
"cargo:rustc-env=RUSTC_VERSION={}",
std::env::var("RUSTC").unwrap_or_else(|_| "rustc".to_string())
);
}
12 changes: 7 additions & 5 deletions docs/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ use antigravity_sdk_rust::types::ChatResponse;
// text: String, // Combined model text output
// thinking: String, // Combined reasoning/thinking text
// steps: Vec<Step>, // All intermediate execution steps
// usage_metadata: UsageMetadata, // Token consumption stats
// usage_metadata: Option<UsageMetadata>, // This turn's token consumption
// }
```

Expand Down Expand Up @@ -378,7 +378,9 @@ async fn main() -> Result<(), anyhow::Error> {

let response = agent.chat("Explain Rust's ownership model in 3 sentences.").await?;
println!("{}", response.text);
println!("Tokens used: {}", response.usage_metadata.total_token_count);
if let Some(usage) = &response.usage_metadata {
println!("Tokens used: {}", usage.total_token_count);
}

agent.stop().await?;
Ok(())
Expand Down Expand Up @@ -434,13 +436,13 @@ impl Tool for WeatherTool {
struct AuditHook;

impl Hook for AuditHook {
async fn pre_tool_call(&self, tool_call: &ToolCall) -> Result<HookResult, anyhow::Error> {
async fn pre_tool_call(&self, tool_call: &ToolCall, ctx: &HookContext) -> Result<HookResult, anyhow::Error> {
println!("[AUDIT] Tool called: {} with args: {}", tool_call.name, tool_call.args);
Ok(HookResult { allow: true, message: String::new() })
}

async fn post_turn(&self, response: &ChatResponse) -> Result<(), anyhow::Error> {
println!("[AUDIT] Turn complete. Tokens: {}", response.usage_metadata.total_token_count);
async fn post_turn(&self, response: &str, ctx: &HookContext) -> Result<(), anyhow::Error> {
println!("[AUDIT] Turn complete, {} chars", response.len());
Ok(())
}
}
Expand Down
51 changes: 49 additions & 2 deletions docs/conversation.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,10 @@ let response = conversation.chat_to_completion("What is 2 + 2?").await?;
println!("Response: {}", response.text);
println!("Thinking: {}", response.thinking);
println!("Steps: {}", response.steps.len());
println!("Total tokens: {}", response.usage_metadata.total_token_count);
// This turn only; `conversation.total_usage()` is the session total.
if let Some(usage) = &response.usage_metadata {
println!("Turn tokens: {}", usage.total_token_count);
}
```

**Signature:**
Expand Down Expand Up @@ -330,7 +333,7 @@ pub struct ChatResponse {
/// All steps executed during this turn.
pub steps: Vec<Step>,
/// Cumulative token usage metrics.
pub usage_metadata: UsageMetadata,
pub usage_metadata: Option<UsageMetadata>,
}
```

Expand Down Expand Up @@ -406,3 +409,47 @@ pub struct Step {
> **Key difference:** In the Rust SDK, all state-querying methods are `async` because the
> internal state is protected by a `tokio::sync::Mutex`. In Python, these are synchronous
> properties protected by the GIL.

## `send` drains the previous turn

Steps still queued from the previous turn are drained into history before a new
prompt goes out. A caller who stopped reading mid-turn used to lose those steps
entirely, and the next turn's boundary was recorded at the wrong index.

The drain only runs once a turn has actually been sent — a freshly connected
session reports not-idle until the harness says otherwise, and draining there
would block on a stream with nothing to deliver.

`wait_for_idle()` resolves when the turn in flight finishes, returning
immediately if none is running. It is watch-backed, so it notices the moment
the harness reports idle rather than on the next tick of a poll loop.

## Multimodal prompts and slash commands

`Content` carries text, attachments and slash commands, and goes out as the
harness's `complex_user_input` — the plain prompt field is a bare string and can
carry none of them. The types existed in this crate and reached nothing: a
caller could build a `Content` and had no way to send it.

```rust,no_run
# use antigravity_sdk_rust::types::{Content, ContentPrimitive, Media, MimeType, ImageMime};
# async fn demo(agent: &antigravity_sdk_rust::agent::Agent<antigravity_sdk_rust::agent::Started>) -> Result<(), anyhow::Error> {
let prompt = Content::Multi(vec![
ContentPrimitive::Text("what changed in this screenshot?".to_string()),
ContentPrimitive::Media(Media {
data: std::fs::read("before.png")?,
mime_type: MimeType::Image(ImageMime::Png),
description: None,
}),
]);
let response = agent.chat_content(&prompt).await?;
# Ok(()) }
```

Text parts go through the same control-character strip as a plain prompt — a
multimodal path that skipped it would be a way around it. An empty prompt is
rejected before it reaches the harness, whichever form it takes.

`last_structured_output()` returns the most recent `FINISH` payload, which is
what a `response_schema` produces; reaching it previously meant walking
`history()` backwards looking for the right step type.
6 changes: 6 additions & 0 deletions docs/fix-plan-current-defects.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Fix plan — defects in this crate today

> **Historical — frozen at 0.2.0.** Every item planned here has shipped. This
> document is kept for the reasoning behind the changes, not as a status board:
> it describes what was *planned*, and in places the delivery differed or a
> decision superseded it. **`CHANGELOG.md` is the record of what shipped**, and
> the only document that tracks current state.

Companion to `docs/upstream-parity.md`. That document audits the whole crate
against upstream 0.1.9 and plans the wire migration (WP-1 … WP-11). **This
document plans only the subset that is wrong right now, against the harness
Expand Down
Loading
Loading