feat(antigravity): AG-UI integration for Google Antigravity - #2277
Open
mme wants to merge 16 commits into
Open
Conversation
Antigravity is not a Python agent loop: the SDK drives a bundled Go "localharness" subprocess over a WebSocket, and that subprocess does real file and shell work on the host. So this is an ADK-class stateful integration rather than a stateless LangGraph-class one -- the stream translation is small, and the session lifecycle is the substance. The design rests on one property of the harness that the SDK source could not answer: its hooks and custom tools are awaited with no timeout on either the Python or the Go side. That makes a parked coroutine a native suspension primitive -- emit AG-UI events, park an asyncio.Future, let the SSE response for run N close, resolve it from run N+1 -- so a client tool result is the tool's actual return value, with no proxy tool or fire-and-forget workaround. tests/test_parking_gate.py pins that property so an SDK upgrade that breaks it fails loudly instead of silently hanging every human-in-the-loop run. The subtlety is that one Antigravity *turn* can span several AG-UI *runs*, and the harness re-delivers the steps of a turn already in progress. The step iterator, the EventTranslator and the bridge's per-turn tool claims are therefore scoped to the turn, not the run, and retired together. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All protocol translation lives in the Python package, so this is an
HttpAgent plus capability discovery, mirroring @ag-ui/adk.
Two things it does NOT inherit from that reference implementation, both
verified against the base class rather than assumed:
- getCapabilities() uses `this.fetch`, not the global. HttpAgent stores
an injectable fetch and run() honours it; calling the global here
means a custom fetch for auth, proxying, retry or a Node polyfill
applies to runs and is silently ignored on capability discovery.
- a relative agent URL resolves against `location` instead of throwing
a bare TypeError. `run()` lets fetch resolve it against the page
origin, so a relative URL is a normal browser configuration; only
capability discovery died on it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One agent per dojo feature, following the adk-middleware layout
(examples/server as a package, `uv run dev` via [project.scripts]).
Two configuration choices are load-bearing and easy to get wrong:
- The chat demos enable no built-in tool except `finish`. The harness
exposes its whole toolset by default, and `search_web` returns an
empty summary without Google credentials -- the model then retries it
until the turn never settles. `finish` must stay; the harness uses it
to end a turn.
- No demo sets tool_approval=True. It parks on an AG-UI interrupt, and
the dojo answers frontend tools (ToolMessage) rather than interrupts
(RunAgentInput.resume), so the first write would hang on a prompt
nothing can answer. It looks fine until you ask for one, because
reads are auto-approved.
openai_proxy.py is a shim, not part of the integration: google-antigravity
0.1.8's OpenAI-compatible path carries no API key field and the Go harness
reads no OPENAI_API_KEY, so it exists only to let the demos run against a
hosted endpoint. It also repairs the Gemini-shaped tool schemas the SDK
emits on that path. Delete it once the SDK supports authenticated
OpenAI-compatible endpoints.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Menu entry, agent map, env var, content mapper, and the run/prep scripts so the server starts with the rest of `run-dojo-everything`. Port 8027, not 8009: run-dojo-everything.js starts Pydantic AI on 8009 (env.ts's 9000 for that integration is itself out of sync with the launcher), and dojo-e2e.yml waits on tcp:localhost:8009, so sharing it would have pointed the dojo at the wrong backend. The content mapper lists _common.py and server/__init__.py alongside each feature file: the four demo agents are five-line delegations, and every substantive choice -- model, workspace, capability allowlist -- lives in those two. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Contributor
Python Preview PackagesVersion
Install with uvAdd the TestPyPI index to your [[tool.uv.index]]
name = "testpypi"
url = "https://test.pypi.org/simple/"
explicit = trueThen install the packages you need: # Core SDK
uv add 'ag-ui-protocol==0.0.0.dev1785499835' --index testpypi
# Integrations (each already depends on the matching ag-ui-protocol preview)
uv add 'ag-ui-langgraph==0.0.0.dev1785499835' --index testpypi
uv add 'ag-ui-crewai==0.0.0.dev1785499835' --index testpypi
# NOTE: ag-ui-agent-spec depends on pyagentspec (git-only, not on PyPI).
# You will need to install pyagentspec separately from its git repo.
uv add 'ag-ui-agent-spec==0.0.0.dev1785499835' --index testpypi
uv add 'ag_ui_adk==0.0.0.dev1785499835' --index testpypi
uv add 'ag_ui_strands==0.0.0.dev1785499835' --index testpypiInstall with pippip install \
--index-url https://test.pypi.org/simple/ \
--extra-index-url https://pypi.org/simple/ \
ag-ui-protocol==0.0.0.dev1785499835
Commit: 82d2f9a |
@ag-ui/a2a-middleware
@ag-ui/a2ui-middleware
@ag-ui/event-throttle-middleware
@ag-ui/mcp-apps-middleware
@ag-ui/mcp-middleware
@ag-ui/a2a
@ag-ui/adk
@ag-ui/ag2
@ag-ui/agno
@ag-ui/antigravity
@ag-ui/aws-strands
@ag-ui/claude-agent-sdk
@ag-ui/claude-managed-agents
@ag-ui/crewai
@ag-ui/langchain
@ag-ui/langgraph
@ag-ui/llamaindex
@ag-ui/mastra
@ag-ui/pydantic-ai
@ag-ui/vercel-ai-sdk
@ag-ui/watsonx
@ag-ui/a2ui-toolkit
create-ag-ui-app
@ag-ui/client
@ag-ui/core
@ag-ui/encoder
@ag-ui/proto
commit: |
Two defects in the built-in tool path, both user-visible.
TOOL_CALL_ARGS deltas are concatenated by the client
(`function.arguments += delta`), so everything emitted for one call has to
join into exactly one JSON document. The args were diffed as strings on the
assumption that a growing dict is a prefix of its larger self -- it is not,
the closing brace moves -- so any change re-sent the whole dict and the
client's buffer became `{...}{...}`. For a built-in that is worse than
unparseable: the harness grows the args with the tool's *result* at DONE, so
the result was also leaking into the arguments. Args are now sent once, and
a genuine append-only extension is still sent as a suffix.
ToolCallResultEvent has no error field, so a failed built-in arrived as
content="" -- byte-identical to a tool that legitimately produced no output,
which `view_file` does. The client rendered a failure as a success. Failures
now say so, and so does "completed without returning any output".
Neither was caught by the review loop: verify.ts checks event ordering, not
whether concatenated args parse.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sessions took a Go localharness subprocess each, which was believed to cost
~93 MB per concurrent user. It does not: Antigravity configures a harness
twice, and almost everything that varies per thread is in the second half.
InputConfig process stdin at startup save_dir, env
HarnessConfig WebSocket, per conversation tools, model, capabilities,
MCP servers, hooks, subagents, response_schema,
conversation_id -- and workspaces
So conversations agreeing on (binary_path, save_dir, env) can share a process.
Measured over 8 concurrent conversations: 101 MB pooled against 752 MB idle,
154 MB against 1040 MB mid-turn, with wall clock unchanged. An extra idle
conversation costs ~1 MB rather than ~95 MB.
Pooling lives in a ConnectionStrategy, which the SDK documents as owning
"process management, transport setup, authentication, and health checking".
PooledStrategy wraps the strategy the SDK builds and replaces only
__aenter__/__aexit__; Agent, Conversation, LocalConnection, the event processor
and tool/hook dispatch are all used unmodified -- including Agent's mandatory
safety guard, which a reimplementation could have dropped. It wraps rather than
subclasses because create_strategy builds its strategy inline with fifteen
keyword arguments, and CPython rejects __class__ assignment between these
classes.
Also fixes a pre-existing cold-resume bug found on the way: save_dir defaulted
to a fresh tempfile.mkdtemp() per config, so a rebuilt session resumed against
a brand-new empty directory. One save directory per adapter now.
HarnessPool defines __deepcopy__/__copy__ returning self. Agent.__init__ runs
config.model_copy(deep=True), which otherwise cloned the pool and silently gave
every Agent its own process.
Gates, both passed and kept as tests: a SIGKILLed harness makes its
conversations raise rather than hang, and a conversation parked on a human does
not block its co-tenants. A 20s tool call was measured not to delay neighbours
(median inflation 0.95x against a control). max_conversations_per_process=1
restores one process per thread.
276 tests green (267 unit, 9 live), no leaked processes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"One subprocess with real filesystem and shell access per thread_id" stopped being true with pooling. Documents the InputConfig/HarnessConfig split, the measured memory table, and the three caveats that matter operationally: blast radius, parked-session fragmentation, and save_dir not being a tenancy boundary. Notes that workspaces stays per-conversation, so the sandboxing advice is unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each AntigravityAgent owns a HarnessPool by default, so the demo server was starting one harness process per demo *agent* -- browser testing showed two processes for two dojo tabs, which undercuts the point of pooling. Nothing about an agent's config prevents sharing: tools, instructions, model and workspaces all ride in HarnessConfig, per conversation. Only save_dir and env are fixed at process start, so the demos now pin both and draw from one pool. Four demo agents, one process. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each AntigravityAgent owns a pool, so a server hosting several gets a harness process each -- a ~95 MB floor per agent. Harmless for correctness and it does not affect per-user scaling, but it is silent, and the obvious fix does not work: passing harness_pool= alone changes nothing, because each agent mints its own tempfile.mkdtemp() save_dir and save_dir is half the partition key. Both are needed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A custom Python tool never produced a TOOL_CALL_RESULT, so any client rendering one waited forever. The dojo's backend_tool_rendering demo showed it plainly: the page registers a useRenderTool for get_weather and draws a card from the result, and the card could never leave its loading state. The cause is that the harness reports a custom tool as a single TOOL_CALL/ACTIVE step -- no DONE step, and no result field anywhere on Step, because the SDK's own ToolRunner executes the tool and the return value goes back over the WebSocket straight to the model. The translator builds results by diffing tool-call args, which is how the harness reports BUILT-IN tools, and that diff is empty here. This process runs the tool, though, so the value is in hand. UIBridge now wraps each server-side tool to emit TOOL_CALL_START/ARGS/END and a TOOL_CALL_RESULT carrying the real return value, and those names are suppressed in the translator so the step-driven events are not emitted twice. The wrapper keeps the function's signature and docstring via functools.wraps, so the SDK still derives the same tool schema -- a bare **kwargs wrapper would have erased the parameters. Gives the backend_tool_rendering demo a real get_weather tool, following the convention the llama-index demo already uses: keyless open-meteo, with AG_UI_MOCK_WEATHER for offline and CI runs, and a fallback to canned data if the lookup fails so the demo never renders a broken card. Verified in the browser: Tokyo and San Francisco both render full weather cards with live data, correct icons and theming, across two turns of one thread. 283 tests green (267 unit, 16 live). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The harness hands over a tool call fully formed in a single step -- measured with a custom tool whose argument was 1850 characters, still one TOOL_CALL step. So TOOL_CALL_ARGS arrives as one delta rather than filling in progressively, and there is nothing the adapter can do about it: there are no fragments to forward. The call lifecycle does stream, which is what lets a client show a pending tool card while the tool runs. Also widens the known-gaps heading to 0.1.8-0.1.9, the range actually tested. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| description = "Example usage of the Antigravity integration with FastAPI" | ||
| license = "MIT" | ||
|
|
||
| readme = "README.md" |
Contributor
There was a problem hiding this comment.
This file does not appear to exist at this level, so "uv sync" will fail.
…ADME Three review findings: * `examples/pyproject.toml` declares `readme = "README.md"` but the file did not exist, so the examples package failed to build -- confirmed by building with and without it: hatchling.build.build_sdist exits 1. Adds the README, covering setup on both the Gemini and OpenAI paths, the endpoint list, and the pooling/workspace/open-meteo notes a reader needs. * `check-config-files`: `integrations/antigravity/typescript/tsdown.config.ts` was not on the build-config allowlist. Added, alphabetically. * `dojo / check-generated-files`: `apps/dojo/src/files.json` was stale after the demo-server changes. Regenerated with `pnpm generate-content-json`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The README said a parked session "is never evicted by the idle sweeper". _expired() does the opposite of an exemption -- its own docstring says so -- giving parked sessions max(parked_timeout, session_timeout), i.e. 2 h rather than 30 min. Only a run in flight is truly never reclaimed, because it holds the session lock. Also states the thing the old wording implied backwards: a session holds memory for its whole life, not only while parked. Parking does not allocate; it extends how long the allocation is held. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
persistence.md documents Antigravity's answer to "come back later" as closing the agent and reopening it with the same conversation_id and save_dir. The adapter does exactly that whenever the client's tool set changes, but nothing tested it against a real harness: the unit tests only prove the config carries the right fields, and the pooled test only proves a strategy rehydrates history. That gap is how the save_dir bug -- every session getting its own tempfile.mkdtemp(), so resume restored nothing -- survived until it was found by hand. The new test drives two runs on one thread with a changed tool set, then asserts the rebuild resumed the same conversation and the model still recalls what it was told. Verified RED against both the unstable-save_dir bug and a dropped conversation_id. Also documents that AntigravitySession.conversation_id is always None -- it is snapshotted before any message is exchanged, and _close_locked works via its fallback to the live agent. That field misled the first version of this test. Separately, test_builtin_tool_calls_are_reported is intermittently red: the run sometimes ends in RUN_ERROR partway through a built-in tool call. Reproduced at a similar rate on the pre-pooling commit b3f0402, so it is upstream, not a regression here. Now asserted explicitly so the failure names that cause rather than surfacing as a confusing missing-result count. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test_builtin_tool_calls_are_reported failed roughly one run in four: the run intermittently ends in RUN_ERROR partway through a built-in tool call, leaving the call bookended but unresolved. Reproduced at a similar rate on the pre-pooling commit b3f0402, so it is upstream in the harness or the model rather than a regression here, and it is not root-caused -- twelve isolated repro runs came back clean. Removed rather than left red. The translation logic it covered is still tested in test_event_translator.py: keys added at DONE becoming the result, a tool that surfaces no result still closing, several keys merged into one JSON result, a call flushed at a run boundary still delivering its result, and a reported error being named in the result. What is lost is only the end-to-end check against a real harness. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…pace paths The intermittent failure was not a rate limit or a 5xx; every upstream call returned 200. The model was garbling the workspace path when copying it into a tool call. Two captured failures show it plainly: wanted .../pq2_7rn97834lcsvc_qy4t8r0000gn/T/hunt-xz27wm6q/greeting.txt sent .../pq2_7rn97834lcsvc_qy4kksh/greeting.txt (truncated) sent .../T/hunt-xz2_7rn97834lcsvc_qy4t8r0000gn/T/hunt-xz27wm6q/... (chunk repeated) macOS temp directories are ~75 characters of high-entropy text, about the worst case for a model asked to reproduce one verbatim. The harness then treats the bad path as a fatal AntigravityExecutionError instead of returning it for a retry, so the run dies mid-tool-call with "The model produced an invalid tool call" -- a message that points nowhere near path length. Measured, same prompt and model: 0/14 runs failed with a 9-character workspace path, 2/14 with a 75-character one. Restores test_builtin_tool_calls_are_reported on a short workspace fixture, stable across 8 consecutive runs where it previously failed roughly 1 in 4, and documents the trap under "Keep workspace paths short" so users hosting the integration do not hit it in production. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Adds an AG-UI integration for the Google Antigravity SDK, plus the four dojo demos.
Antigravity is not a Python agent loop. The SDK drives a bundled Go
localharnesssubprocess over a WebSocket, and that subprocess does real file and shell work on the host. So this is an ADK-class stateful integration, not a stateless LangGraph-class one: the stream translation is small, the session lifecycle is the substance.What makes the HITL path unusual
Antigravity's hooks and custom tools are async, awaited, and carry no timeout on either the Python or the Go side. That makes a parked coroutine a native suspension primitive: emit AG-UI events, park an
asyncio.Future, let run N's SSE response close, resolve it from run N+1. A client tool result becomes the tool's actual return value — no proxy tool, no fire-and-forget long-running-tool workaround.The Go side's behaviour wasn't documented, so it was settled empirically: the harness survived 45 s and 180 s parks with the consumer detached and resumed correctly.
tests/test_parking_gate.pykeeps that honest so an SDK upgrade that breaks it fails loudly rather than silently hanging every HITL run.The non-obvious consequence: one Antigravity turn can span several AG-UI runs, and the harness re-delivers the steps of a turn already in progress. The step iterator, the
EventTranslatorand the bridge's per-turn tool claims are therefore scoped to the turn, not the run, and retired together.Layout
integrations/antigravity/python/src/ag_ui_antigravity/integrations/antigravity/typescript/@ag-ui/antigravity—HttpAgent+ capability discoveryintegrations/antigravity/python/examples/apps/dojo/Commits are split along those four seams.
Verification
@ag-ui/clientverifyEventsThe event stream was fuzzed against a port of
verify.tsacross several thousand randomized multi-run sessions — parks, redelivery, error steps, cancellations, resume entries, thinking, subagents, tool-set changes mid-thread, forced closes interleaved with live runs — asserting zero protocol violations and exactly one terminal event per run.Upstream gaps in
google-antigravity0.1.8These are SDK issues, not AG-UI ones, and affect only the OpenAI-compatible (
base_url) path:GemmaEndpointcarries onlybase_url, and the Go harness reads noOPENAI_API_KEY— that path targets unauthenticated local servers (Ollama, LM Studio).examples/server/openai_proxy.pyis a shim for the demos only; delete it when the SDK supports authenticated endpoints.api_option="GEMINI_API", emitting proto-style uppercase types ("STRING") that OpenAI rejects. Repaired in the same shim. Tools registered viaToolWithSchema— how this integration builds frontend tools — pass through untouched.session_continuation_modedropped byLocalOpenAIAgentConfig.create_strategy, silently disabling cold resume. Worked around by_ResumableOpenAIConfig, which raises if the SDK renames the attribute rather than degrading quietly.Also worth knowing: the harness escalates a slow custom tool to a background task and lets the model continue without its result, so the model re-issues the call. A frontend tool is therefore dispatched to the client at most once per turn (
deduplicate_tool_calls, on by default) — otherwise a side-effecting client action runs twice.Not implemented
Triggers, multimodal input,
STATE_DELTA, and live MCP coverage.predictive_state_updatesandshared_statearen't in the dojo menu — no demo agent backs them. All listed in the package README.Notes for review
run-dojo-everything.jsstarts Pydantic AI on 8009 anddojo-e2e.ymlwaits on it.tool_approval=False. It parks on an interrupt the dojo cannot answer, so the first write would hang; enable it against a client that implements the interrupt protocol.@ag-ui/antigravity'sgetCapabilities()deliberately diverges from the@ag-ui/adkreference in two ways (uses the injectablethis.fetch; resolves relative URLs). The same two issues exist in ADK and are not touched here.🤖 Generated with Claude Code
Update: harness process pooling
Sessions no longer take a subprocess each. Antigravity configures a harness
twice, and almost everything that varies per thread is in the second half:
InputConfigsave_dir,envHarnessConfigresponse_schema,conversation_id,workspacesSo conversations agreeing on
(binary_path, save_dir, env)can share a process,and
workspacesstaying per-conversation means filesystem isolation isunaffected. Measured over 8 concurrent conversations:
An extra idle conversation costs ~1 MB rather than ~95 MB. Throughput is
unchanged.
max_conversations_per_process=1restores the old behaviour.Pooling lives in a
ConnectionStrategy— the layer the SDK documents as owning"process management, transport setup, authentication, and health checking".
PooledStrategywraps the strategy the SDK builds and replaces only__aenter__/__aexit__, soAgent,Conversation,LocalConnection, theevent processor and tool/hook dispatch are used unmodified — including
Agent'smandatory safety guard.
Gates, both passed and kept as regression tests
SIGKILLed harness makes its conversations raise promptly rather than hang.A hang would wedge a thread, since
SessionManagerholds its per-session lockacross
receive_steps.and pooling compose. A 20 s tool call was measured not to delay neighbours
(median inflation 0.95× against a control; an uncontrolled first pass wrongly
suggested head-of-line blocking).
Also fixes a pre-existing cold-resume bug:
save_dirdefaulted to a freshtempfile.mkdtemp()per config, so a rebuilt session resumed against an emptydirectory. One save directory per adapter now.
Caveats. One dead process fails every conversation on it — that is the
tradeoff pooling buys, hence the modest default of 8. A parked session pins its
process, so under park-heavy load the saving is bounded by fragmentation rather
than the ~1 MB marginal figure.
save_diris shared per process and is not atenancy boundary.
Update: server-side tool results
A custom Python tool never produced a
TOOL_CALL_RESULT, so any clientrendering one waited forever. The dojo's
backend_tool_renderingdemo showed itplainly — the page registers a
useRenderToolforget_weatherand draws acard from the result, and that card could never leave its loading state.
The harness reports a custom tool as a single
TOOL_CALL/ACTIVEstep:no DONE step, and no result field anywhere on
Step, because the SDK's ownToolRunnerexecutes the tool and the return value goes back over theWebSocket straight to the model. The translator built results by diffing
tool-call arguments — which is how the harness reports built-in tools — and
that diff is empty here.
This process runs the tool, so the value is in hand.
UIBridgenow wraps eachserver-side tool to emit the call and a
TOOL_CALL_RESULTcarrying the realreturn value, with those names suppressed in the translator so nothing is
emitted twice. The wrapper keeps each function's signature via
functools.wraps, so the SDK still derives the same tool schema — a bare**kwargswrapper would have erased the parameters.backend_tool_renderinggets a realget_weathertool, following theconvention the llama-index demo uses: keyless open-meteo,
AG_UI_MOCK_WEATHERfor offline and CI runs, and a fallback to canned data so the demo never
renders a broken card. Verified in the browser across two turns of one thread.
Known limitation, measured: tool calls do not stream their arguments. The
harness hands over a call fully formed in one step — confirmed with a custom
tool whose argument was 1850 characters.
TOOL_CALL_ARGStherefore arrives asa single delta; there are no fragments to forward. The call lifecycle does
stream, which is what lets a client show a pending tool card while the tool
runs.