16 KiB
Antigravity-native harness: RPC core rework
Status: Design (approved direction; pending implementation plan)
Date: 2026-06-22
Supersedes: the runtime core of PR #892 (antigravity-native). Periphery from #892 is reused.
Source of truth for wire shapes: live-verified against agy 1.0.10 — see memory agy-rpc-interaction-bridge.md.
1. Motivation
The current native harness (PR #892) drives the agy CLI through a tmux terminal with two indirect channels:
- Read path — tails agy's JSONL transcript (
~/.gemini/antigravity-cli/brain/<id>/.system_generated/logs/transcript_full.jsonl) and mirrors steps into the session. - Write path — types web turns into the agy TUI via tmux
send-keys.
This works for plain text turns but has a fragility class and functional gaps:
- Out-of-order / non-contiguous
step_indexin the JSONL forced a durable SET resume cursor, gap-free-prefix delivery, and audit gating — and still produced the live double-render (delta preview vs committed message) plus a user-message duplication (direct/eventspost vs the forwarder mirroring agy'sUSER_INPUT). Root-caused as inherent to the transcript-mirror approach; predates the out-of-order change (verified by running both revisions). - No interactive-prompt support. agy's
ask_question(multi-select) and toolrequest-reviewapprovals are TUI widgets with no web response path; the session sitsidlewhile agy blocks. - No real interrupt (
interrupt_sessionis stubbed toFalse).
A spike found agy exposes a structured connect-RPC surface that replaces all three channels cleanly. This design reworks the harness core onto that RPC, eliminating the transcript-mirror fragility and adding interactions + interrupt, while keeping the terminal-first UX (agy still runs in a tmux terminal).
2. Validated RPC surface
connect-RPC service exa.language_server_pb.LanguageServerService, TLS HTTP/2 on a loopback port, self-signed (verify=False), JSON accepted (Content-Type: application/json). All of the following are live-verified except where noted.
- Identity:
cascadeId == conversationId == brain-dir UUID(one id).GetCascadeId/GetCascadeStatus/ListCascadeIdsdo not exist (404). - Port discovery: reuse
omnigent/antigravity_native_rpc.py—discover_language_server_port(pid)/_candidate_agy_rpc_ports()+_conversation_matches(Heartbeat → 200;GetConversationMetadataechoesrootConversationId). - Read / detect:
GetCascadeTrajectorySteps {cascadeId}(unary) →steps[]— one-shot.StreamAgentStateUpdates {conversationId}(connect server-stream,application/connect+json, 5-byte[flag][BE-len]framing) → first frameupdate.mainTrajectoryUpdate.stepsUpdate.steps[], then long-polls — live.- (
StreamCascadeReactiveUpdatesis deprecated — returns{"error":{"message":"reactive state is deprecated"}}. Do not use.)
- Step shape:
status∈CORTEX_STEP_STATUS_{RUNNING,WAITING,DONE,ERROR};type∈CORTEX_STEP_TYPE_{PLANNER_RESPONSE,RUN_COMMAND,...};requestedInteraction(askQuestion|permission) whenWAITING;runCommand.{commandLine, proposedCommandLine, cwd, exitCode, combinedOutput.full};metadata.sourceTrajectoryStepInfo.{trajectoryId, stepIndex, cascadeId};completedInteractions[].response(echoes the delivered answer). - Answer interaction:
HandleCascadeUserInteraction {cascadeId, interaction:{trajectoryId, stepIndex, <variant>}}(unary) →200 {}.- Question:
interaction.askQuestion.responses:[{question:"<verbatim>", selectedOptionIds:["<option id>"]}](writeInResponsefor write-ins). Optionidis"1".."N", not the text. - Approval:
interaction.permission.{allow: true|false}. NoapprovalId— keyed solely bytrajectoryId+stepIndex(the binary-tagapprovalInteraction.{approvalId,approve}is a different approval kind, not the run_command path). trajectoryIdandstepIndexMUST be insideinteraction(proto-JSON silently drops top-level extras).
- Question:
- Interrupt:
CancelCascadeSteps(andForceStopCascadeTree). - Turn send (open):
SendAgentMessagerecords as aSYSTEM_MESSAGE, notUSER_INPUT— so RPC turn-sending mis-attributes the user message. Turns therefore stay on tmuxsend-keysunless a proper user-turn RPC is found (see §7).
2.1 Timeout gotcha (critical)
A WAITING interaction times out server-side (→ CORTEX_STEP_STATUS_ERROR), after which agy auto-retries with a fresh WAITING step at a higher stepIndex. Omnigent elicitations wait on a human (potentially slow), so:
- The bridge must re-read the freshest
WAITINGstep at delivery time — never trust thetrajectoryId/stepIndexcaptured at detection. - On a timeout-retry, the bridge must re-surface / update the elicitation against the new step.
HTTP 500 "input not registered for step N"is overloaded: it means either a missingtrajectoryIdor a step that already timed out. Disambiguate by checking the step'sstatusbefore treating it as a shape error.
3. Architecture
agy still runs in a runner-owned tmux terminal (terminal-first UX preserved). The transcript-tail reader and send-keys interaction path are replaced by RPC. New/changed units:
- RPC client (
antigravity_native_rpc.py, extended) — typed JSON wrappers:get_trajectory_steps(port, cascade_id),stream_agent_state_updates(port, conversation_id),handle_user_interaction(port, cascade_id, interaction),cancel_cascade_steps(port, cascade_id). Reuses the existing discovery/loopback/Heartbeat/GetConversationMetadata helpers. - Step → item mapper (pure, unit-testable) — trajectory
step→ omnigent conversation-item events (message,function_call,function_call_output, status edges). Replacesstep_to_eventsover JSONL. Because RPC steps are structured and carry stable ids + explicitstatus, this drops the JSONL parsing, theforwarded_stepsSET cursor, the gap-free-prefix logic, and the out-of-order handling — and fixes the double-render (one structured assistant item per step; no delta-vs-committed race). It also skipsUSER_INPUTsteps: the user turn is already persisted by the directPOST /eventsinput (authoritative), so re-emitting it from the trajectory is the source of the user-message duplication — the mapper must not mirror it (mirrors claude-native). - Read driver — polls
GetCascadeTrajectorySteps(or consumesStreamAgentStateUpdates) and posts mapped items; dedup bystepIndex/step identity. Replaces the transcript-tail forwarder loop. - Interaction bridge — on a
WAITINGstep, surface an omnigent elicitation (reuse the existing registry /response.elicitation_requestSSE //resolve/ web UI). On resolve, run the tight detect→deliver loop: re-read the freshestWAITINGstep, build theinteraction(askQuestionorpermission), POSTHandleCascadeUserInteraction; handle timeout/re-ask. - Executor —
run_turnkeeps tmuxsend-keysfor turns (§7);interrupt_session→CancelCascadeSteps(real interrupt). - Reused from #892 unchanged — onboarding/agy-auth + Gemini provider, harness registration/aliases, the runner-owned terminal infra + auto-create + reattach fixes, the Docker agy-version pin, the web picker/agent card, model catalog/override wiring.
4. Data flows
- Assistant output (read): read driver polls/streams steps → mapper → post
message/function_call/function_call_output+ status edges. Single structured item per step (no double-render). - User turn (write): executor
send-keystypes the turn into agy's TUI (records asUSER_INPUT). (Unchanged from #892 until §7 resolves.) - Interaction (question/approval): read driver sees a
WAITINGstep → interaction bridge surfaces an elicitation → user resolves in the web UI → bridge re-reads the freshestWAITINGstep and POSTsHandleCascadeUserInteraction→ agy proceeds (step →DONE,completedInteractions.responseechoes the answer). - Interrupt:
interrupt_session→CancelCascadeSteps {cascadeId}.
5. What is removed
- JSONL transcript tailing + partial-line buffering + UTF-8 hold-back.
- The durable
forwarded_stepsSET cursor, gap-free-prefix delivery, out-of-order suppression, the<=-floor legacy materialization. - The delta (
output_text_delta) + committed-message double-emission (source of the live double-render). - The forwarder mirroring of
USER_INPUTthat duplicated the direct/eventsuser post. - tmux
send-keysfor interactions (kept only for turns, pending §7).
6. What is reused (from #892)
Onboarding/auth, Gemini provider config, harness registration/aliases, runner-owned terminal + auto-create + the reattach/no-double-forward fixes, the Docker AGY_EXPECTED_VERSION pin, the web Antigravity picker/agent card, model catalog/override/effort wiring. The three review fixes already committed on the branch (1cd8f5aa, 874f8f5c, 708ee883) stay relevant (terminal/launch infra + test hygiene).
7. Open questions (resolve in the plan)
- Turn send. Keep tmux
send-keys(proven, recordsUSER_INPUT) vs. find a proper user-turn RPC (the obviousSendAgentMessagemis-records asSYSTEM_MESSAGE). Default: keep send-keys; small spike to look for a user-turn RPC (e.g. a queued-user-input method). - Poll vs stream for read.
StreamAgentStateUpdates(live, lower latency, connect-stream framing) vsGetCascadeTrajectoryStepspolling (simpler). Likely stream with poll fallback. - Elicitation ↔ agy-timeout reconciliation. Concrete policy for re-surfacing on timeout-retry and for the deny/cancel path (
permission.allow=false; multi-select; write-in). - #892 packaging. Evolve the existing branch (reuse periphery + fixes) vs a fresh PR cherry-picking the periphery. Lean: evolve the branch; keep it draft until the RPC core lands.
8. Testing
- Unit: step→item mapper from recorded RPC step fixtures (question, approval, run_command, planner, tool-output); interaction-builder shapes.
- Integration (live agy): question round-trip, approval round-trip, interrupt — mirroring the spikes (assert
200 {}, step→DONE,completedInteractions.response, trajectory growth / command output). - Timeout handling: simulate a timed-out
WAITINGstep (statusERROR) → bridge re-reads and delivers to the retry step; assert no spurious 500 propagation. - Parity / regression: adapt the existing native-harness suites; confirm the double-render and user-dup are gone (persisted + live single render).
9. Risks
- agy RPC is undocumented/unstable across versions — mitigated by the Docker version pin and the version-gated build.
- Port discovery timing (agy must be up + bound) — reuse the existing discovery + retry.
- The timeout gotcha (§2.1) is the main correctness-sensitive area — covered by the tight detect→deliver loop + tests.
- Terminal-first UX: agy still runs in the tmux terminal, so the TUI and RPC both drive the same cascade (verified compatible — TUI and RPC interaction delivery coexist).
10. Phase 2 — Full RPC parity with codex/claude (live-verified, agy 1.0.10)
Spikes (/tmp/agy-turnsend-spike.md, /tmp/agy-parity-feasibility.md; memory agy-rpc-interaction-bridge, agy-rpc-parity-streaming) confirmed agy exposes everything needed to reach full codex/claude parity over RPC. This resolves §7 open question 1 (turn-send) and adds streaming + telemetry. All shapes version-volatile — resolve model enums at runtime, never hardcode.
10.1 Turn-send (resolves §7-Q1; replaces tmux send-keys)
SendUserCascadeMessage {cascadeId, items:[{text:<turn>}], cascadeConfig:{plannerConfig:{planModel:<MODEL enum>}}} → 200 {}; records CORTEX_STEP_TYPE_USER_INPUT + metadata.source==CORTEX_STEP_SOURCE_USER_EXPLICIT + userInput.userResponse==<text> (byte-for-byte what the reader keys on). Gotchas: text in items[].text (NOT flat message); model REQUIRED per-turn in cascadeConfig.plannerConfig (omit → ERROR "neither PlanModel nor RequestedModel specified"). Resolve planModel at runtime via GetAvailableModels {} or by echoing the read-side userInput.userConfig.plannerConfig.requestedModel.model. SendAgentMessage=SYSTEM_MESSAGE (wrong); queue methods retired.
10.2 Streaming deltas (live typing — output_text_delta parity)
StreamAgentStateUpdates connect server-stream. Request body MUST be connect-enveloped: [flag=0x00][BE-uint32 len][{"conversationId":<conv>}], header Content-Type: application/connect+json. Response frames [flag][BE-len][json]: flag 0=data, flag 2=trailer.
- Partial text path:
update.mainTrajectoryUpdate.stepsUpdate.steps[]wheretype==PLANNER_RESPONSE, atplannerResponse.modifiedResponse— GROWS across frames whilestatus==CORTEX_STEP_STATUS_GENERATING.plannerResponse.thinkingstreams first (reasoning). Trap:plannerResponse.responseis ABSENT during generation, populated only on the DONE commit (whereresponse==modifiedResponse). (This validates Task 4'smodifiedResponsepreference.) - Discriminator = step
status: GENERATING ⇒ partial (emitoutput_text_delta=modifiedResponseminus last forwarded prefix, keyed bymetadata.sourceTrajectoryStepInfo.stepIndex); DONE ⇒ final committedmessage(fromresponse;metadata.modelUsageset). Frames are CUMULATIVE snapshots → harness owns prefix-diffing. On connect, a snapshot of prior steps replays (DONE) → dedup against already-forwarded committed steps. - Reconciler: deltas precede the committed item (flush-barrier style, like codex) so the SPA retires the live preview rather than double-rendering. Keep the unary
GetCascadeTrajectoryStepspoll as snapshot/reconnect fallback (sameplannerResponseshape).
10.3 Token usage (external_session_usage parity)
Per model call: step.metadata.modelUsage = {model, inputTokens, outputTokens, thinkingOutputTokens, responseOutputTokens, cacheReadTokens} (string ints; output=thinking+response). Cumulative context estimate (monotonic): trajectory.generatorMetadata[].chatModel.chatStartMetadata.contextWindowMetadata.estimatedTokensUsed. No top-level usage RPC. Per-turn = sum modelUsage over the turn's steps; emit as each PLANNER step hits DONE.
10.4 Model / effort (external_model_change parity)
Current model per-turn: step.userInput.userConfig.plannerConfig.requestedModel.model (+ step.metadata.{generatorModel, modelUsage.model}). Enum→displayName via GetAvailableModels {} → response.models[key].{model, displayName, recommended, supportsThinking, thinkingBudget}. Effort is NOT separate — it's encoded in the model enum (Gemini: M20/M132/M187 = Medium/High/Low) or a Thinking variant (Claude: M26 = Opus 4.6 Thinking). Detect change by diffing the per-turn enum vs previous (no "model-changed" step). LIVE-CONFIRMED: TUI /model switch flips the next turn's requestedModel/generatorModel.
10.5 New-conversation rotation (/clear → session rotation parity)
TUI /clear creates a NEW cascadeId/brain-dir UUID immediately; old conv retained (still reachable), not deleted. Detect via GetAllCascadeTrajectories {} → trajectorySummaries{<conv>:{status, stepCount, trajectoryId, lastUserInputTime, lastModifiedTime, lastUserInputStepIndex}} (a new convId key, or the bound conv going stale while a sibling advances); each stream frame's update.conversationId also names the active conv. On rotation, re-discover the active conv (max lastUserInputTime) and rotate the omnigent session (mirrors codex thread/started). StartCascade is the programmatic cold-start (not needed for human /clear).
10.6 Phase-2 task plan
- T-A RPC client:
send_user_cascade_message+ model resolution (get_available_models, resolve current enum). T-B executorrun_turn→ RPC turn-send (drop send-keys). - T-C RPC client:
stream_agent_state_updates(connect-stream framing). T-D reader streaming mode: deltas (modifiedResponse prefix-diff + thinking) + DONE commit + snapshot dedup + poll fallback. - T-E usage events, T-F model-change events, T-G /clear rotation — reader additions (read from step.metadata / GetAllCascadeTrajectories).
- Integrates with Task 10 (interrupt+T-B), Task 11 (runner wiring of streaming reader+bridge+rotation), Task 13 (e2e parity: streaming single-render, usage, model-change, rotation, turn-send).