Files
omnigent-ai--omnigent/omnigent/antigravity_native_reader.py
2026-07-13 13:12:00 +08:00

2758 lines
126 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""RPC read driver for a native Antigravity (agy) session.
This is the read-path driver that replaced the retired transcript-tail forwarder
(``omnigent.antigravity_native_forwarder``, deleted in the Task 12 cutover).
Instead of tailing agy's plaintext JSONL transcript, it polls agy's connect-RPC
``GetCascadeTrajectorySteps`` surface for trajectory steps, maps each new step to
Omnigent conversation items, POSTs them, emits ``external_session_status`` edges
on turn transitions, and hands ``WAITING`` steps (questions / permission asks) to
the Task 8 interaction bridge through an injected callback.
How it differs from the transcript forwarder it supersedes:
* **Read transport is the RPC, not the file.** Steps come from
:func:`omnigent.antigravity_native_rpc.get_trajectory_steps` rather than a byte
tail. The RPC returns the *full* trajectory step list on every call (a
snapshot), so the driver de-dups *within the run* by ``(trajectory_id,
step_index)`` identity and posts only steps it has not yet seen.
* **No durable cursor.** The transcript forwarder persisted a ``forwarded_steps``
resume cursor to bridge state so a restart did not re-mirror the whole file.
This driver keeps an *in-memory* seen-set only; the durable cursor (and its
JSONL) is retired in the Task 12 cutover. A restart re-reads from the start —
acceptable because the reader is recreated per session by the Task 11 runner,
not crash-restarted mid-conversation, and the mapper's USER_INPUT-skip plus the
server's own item handling bound the blast radius.
* **The mapper carries the item logic.** :func:`map_step_to_events` is the pure,
no-delta, skip-USER_INPUT mapping layer (Task 4). It deliberately does NOT emit
status edges — that was always the stateful parser's job. This driver is now
that stateful layer: it replicates the transcript parser's RUNNING/IDLE
transition emission (a turn opens on a USER_INPUT step and closes on an
assistant-text PLANNER_RESPONSE that issues no tool calls), deduped so an edge
fires only on a real transition.
Discovery mirrors the forwarder's discipline — *poll until ready, never guess*:
1. **Cascade id.** agy mints its own conversation UUID (it ignores the launcher's
``ANTIGRAVITY_CONVERSATION_ID``) and the launcher seeds bridge state with an
``agy_conv_*`` placeholder until the real id is discovered and persisted. The
reader polls :func:`read_bridge_state` until ``conversation_id`` is present and
is NOT a placeholder; that real id is the cascade id (agy uses one UUID for
both the conversation and the cascade).
2. **RPC port.** The reader enumerates candidate agy connect-RPC ports
(:func:`_candidate_agy_rpc_ports`) and binds the one that confirms it hosts the
cascade id (:func:`_conversation_matches`). It keeps polling until a port
confirms ownership — a recycled/foreign port is rejected, never written to.
Everything that touches the network (the RPC client) or the clock (sleeps) is
funnelled through module-level seams so the unit tests drive the loop with a
scripted step source and a captured post sink, no real agy and no real sockets.
The loop is finite under test via an injectable ``stop`` predicate.
"""
from __future__ import annotations
import asyncio
import contextlib
import logging
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
import httpx
from omnigent._native_post_delivery import post_session_event_with_retry
from omnigent.antigravity_native_bridge import (
ANTIGRAVITY_NATIVE_BRIDGE_ID_LABEL_KEY,
AntigravityNativeBridgeState,
is_placeholder_conversation_id,
read_bridge_state,
write_bridge_state,
)
from omnigent.antigravity_native_rpc import (
AntigravityRpcError,
_candidate_agy_rpc_ports,
_conversation_matches,
get_all_cascade_trajectories,
get_available_models,
get_trajectory_steps,
stream_agent_state_updates,
)
# ``OutboundEvent`` + ``_ToolCallIdAllocator`` live in the mapper module since the
# Task 12 cutover (relocated from the retired transcript forwarder). The reader
# reuses the SAME event shape and allocator so the mapped events post identically.
from omnigent.antigravity_native_steps import (
OutboundEvent,
PendingInteraction,
_execution_discriminator,
_step_index,
_ToolCallIdAllocator,
_trajectory_id,
map_step_to_events,
output_reasoning_delta_event,
output_text_delta_event,
pending_interaction,
)
from omnigent.claude_native_bridge import url_component
from omnigent.entities.session_resources import terminal_resource_id
from omnigent.server.schemas import ElicitationRequestParams, ElicitationResult
_logger = logging.getLogger(__name__)
# Default seconds between RPC polls. The RPC returns a full snapshot each call
# and steps finalize only at DONE (no token streaming), so a sub-second cadence
# keeps the mirror responsive without hammering the loopback server.
_DEFAULT_POLL_INTERVAL_S = 0.25
# Default seconds between Task T-G ``/clear``-rotation checks
# (``GetAllCascadeTrajectories``). Coarse on purpose: a ``/clear`` is a rare,
# human-initiated event, so a few seconds of detection latency is fine and keeps
# this off the hot path — far slower than the per-turn poll cadence so it does not
# hammer the loopback RPC.
_DEFAULT_ROTATION_INTERVAL_S = 3.0
# Backoff between connect-stream re-entries in :func:`_stream_loop`. In steady
# state the re-opened stream blocks awaiting frames, so this delay is paid only
# once per turn-settle (negligible). It bounds a busy-spin if agy ever returns an
# immediate clean trailer repeatedly (plausible right after cold-start before any
# turn, or a transient non-streamable state), which would otherwise re-POST the
# stream at zero delay and pin a CPU — the poll fallback only triggers on an
# exception, never a clean immediate return.
_STREAM_REENTRY_BACKOFF_S = 0.5
# Teardown drain passes for the interaction bridge + chained re-scan tasks. Cancelling
# a bridge stops it scheduling a re-scan and vice versa, so the chain collapses fast;
# a few extra passes give slack without risking an unbounded loop.
_INTERACTION_DRAIN_PASSES = 4
# Re-scan poll retry budget. The bridge-clear re-scan is the sole backstop on the
# healthy-stream path (agy emits no frame while parked on a deferred gate and the poll
# loop is only the failure fallback), so a single swallowed error would strand the gate
# forever. Retry a bounded number of times before giving up.
_INTERACTION_RESCAN_POLL_ATTEMPTS = 3
_INTERACTION_RESCAN_POLL_BACKOFF_S = 0.2
# POST retry policy, kept identical to the transcript forwarder's so mirrored
# items are delivered with the same transient-retry semantics. Conversation
# items persist with a random primary key and are NOT deduped server-side, so an
# ambiguous transport failure is not retried (handled inside
# :func:`post_session_event_with_retry`).
_POST_MAX_ATTEMPTS = 3
_POST_RETRY_DELAY_SECONDS = 0.1
_POST_RETRY_STATUS_CODES = frozenset({408, 409, 425, 429, 500, 502, 503, 504})
# Session-status edge values (mirror the transcript forwarder's vocabulary).
_STATUS_RUNNING = "running"
_STATUS_IDLE = "idle"
# Terminal-failure session status (a valid ``external_session_status``; see the
# ``Literal["idle", "running", "failed"]`` schema). Emitted when an agy turn
# closes on a model/turn ERROR so the web UI shows the turn FAILED instead of a
# clean idle with a silent empty reply (#6).
_STATUS_FAILED = "failed"
# RPC step type/status constants needed for the status-transition heuristic. The
# item-mapping constants live in the mapper; the driver only needs the few it
# keys turn transitions on.
_TYPE_USER_INPUT = "CORTEX_STEP_TYPE_USER_INPUT"
_TYPE_PLANNER_RESPONSE = "CORTEX_STEP_TYPE_PLANNER_RESPONSE"
# Root-cascade trajectory type in ``GetAllCascadeTrajectories`` summaries. Only a
# root cascade is a rotation candidate — a subagent/child trajectory carries a
# different ``trajectoryType`` and must never be rotated to (its turns are not the
# user's top-level conversation). Live-verified value (agy 1.0.10; see the
# ``get_all_cascade_trajectories`` capture).
_TRAJECTORY_TYPE_CASCADE = "CORTEX_TRAJECTORY_TYPE_CASCADE"
# Step status the STREAM path keys partial text on (Task T-D). A PLANNER_RESPONSE
# step carries its growing partial at ``plannerResponse.modifiedResponse`` while
# ``status == CORTEX_STEP_STATUS_GENERATING`` (``response`` is absent until DONE,
# where ``response == modifiedResponse``). The reader emits incremental
# ``output_text_delta`` events during GENERATING; the committed ``message`` is
# left to the mapper (it gates on DONE itself) once the step settles. The DONE
# constant is intentionally not duplicated here — the mapper owns that gate. See
# design §10.2.
_STATUS_GENERATING = "CORTEX_STEP_STATUS_GENERATING"
# Terminal step statuses — a step in one of these will not produce further
# content, so its identity is safe to record in the de-dup set (see
# :func:`_is_settled`). DONE carries the committed output; ERROR means the step
# failed before producing any. PENDING/RUNNING/WAITING/GENERATING are NOT
# terminal: a tool-result step passes through them before DONE, so recording it
# early would dedup and drop the eventual DONE output.
_STATUS_DONE = "CORTEX_STEP_STATUS_DONE"
_STATUS_ERROR = "CORTEX_STEP_STATUS_ERROR"
_TERMINAL_STATUSES = frozenset({_STATUS_DONE, _STATUS_ERROR})
# Dedup key for a step within a run. ``step_index`` is ``None`` for USER_INPUT
# (no trajectory slot) and proto-omitted (treated as ``None`` here) for step 0;
# pairing it with ``trajectory_id`` keeps the key stable per step. The third
# element is a per-turn discriminator used ONLY for a step that has no
# ``step_index``: a USER_INPUT step has a per-conversation-stable
# ``trajectory_id`` and no index, so ``(trajectory_id, None)`` would collide
# across every turn and silently dedup turns ≥2 — folding ``executionId`` /
# ``createdAt`` in keeps each turn distinct (see :func:`_step_key`).
_StepKey = tuple[str | None, int | None, str | None]
# Telemetry event types (design §10.3 + §10.4).
_EXTERNAL_SESSION_USAGE = "external_session_usage"
_EXTERNAL_MODEL_CHANGE = "external_model_change"
# Event that WITHDRAWS a surfaced elicitation whose WAITING step left WAITING
# out-of-band (answered directly in the agy TUI, or agy timed out / auto-resolved
# it). Posting it sets the server's parked-future ``resolved_elsewhere`` flag,
# which (a) returns ``None`` from any in-flight ``request_elicitation`` long-poll
# — so ``bridge_interaction`` does NOT then deliver a stale verdict — and (b)
# clears the web approval card so the chat stops showing "Respond to the pending
# request above to continue." Mirrors cursor-native's
# ``_post_external_elicitation_resolved`` (#1200, direction 2). The reader's own
# web→TUI delivery, by contrast, does NOT post this: the WAITING step there leaves
# WAITING because the bridge ALREADY resolved the card (the human's verdict), so
# the reader must not double-resolve it — see :func:`_maybe_withdraw_interaction`.
_EXTERNAL_ELICITATION_RESOLVED = "external_elicitation_resolved"
# Async callback handed each distinct WAITING interaction. It receives the SAME
# ``cascade_id`` + connect-RPC ``port`` the reader discovered and is using, so the
# Task 8 interaction bridge it drives targets agy's live conversation WITHOUT
# re-discovering them (re-discovery could bind a recycled/foreign port). Args:
# ``(cascade_id, port, pending)``.
OnPendingInteraction = Callable[[str, int, PendingInteraction], Awaitable[None]]
StopPredicate = Callable[[], bool]
# Elicitation hook long-poll budget (shared by the runner + CLI reader wiring).
# The ``antigravity-elicitation-request`` hook is a request/reply that blocks on a
# human, so the request timeout is intentionally long (just over a day); a severed
# long-poll (proxy idle cut, restarting server) is re-POSTed within this budget so
# the SAME elicitation re-parks server-side rather than abandoning the approval
# card. The first retry lands inside the server's re-park grace; later retries back
# off. Mirrors the codex forwarder's elicitation re-POST policy.
_AGY_ELICITATION_REQUEST_TIMEOUT_SECONDS = 86405.0
_AGY_ELICITATION_CONNECT_TIMEOUT_SECONDS = 30.0
_AGY_ELICITATION_RETRY_INITIAL_BACKOFF_SECONDS = 1.0
_AGY_ELICITATION_RETRY_MAX_BACKOFF_SECONDS = 30.0
# Omnigent client timeout for the reader's event-POST + telemetry traffic (NOT the
# elicitation long-poll, which sets its own per-request timeout above).
_READER_CLIENT_TIMEOUT_SECONDS = 30.0
def _model_usage_from_step(step: dict[str, object]) -> dict[str, object] | None:
"""
Extract ``modelUsage`` from a PLANNER_RESPONSE DONE step.
Returns ``None`` when the step has no usable usage data (wrong type, wrong
status, missing field, or all zero/invalid values). The design (§10.3)
specifies that agy encodes all usage counts as STRING ints; we parse them
defensively — a missing or non-numeric value is treated as 0 and excluded
from the output unless it contributes.
:param step: One RPC step dict.
:returns: A dict with any of ``cumulative_input_tokens`` /
``cumulative_output_tokens`` / ``cumulative_cache_read_input_tokens`` /
``model`` (raw enum), or ``None`` when the step carries no usage.
"""
if step.get("type") != _TYPE_PLANNER_RESPONSE or step.get("status") != _STATUS_DONE:
return None
metadata = step.get("metadata")
if not isinstance(metadata, dict):
return None
raw_usage = metadata.get("modelUsage")
if not isinstance(raw_usage, dict):
return None
def _to_int(val: object) -> int:
"""Parse a string-encoded int defensively; return 0 on failure."""
if isinstance(val, int):
return val
if isinstance(val, str):
try:
return int(val)
except ValueError:
return 0
return 0
data: dict[str, object] = {}
input_tokens = _to_int(raw_usage.get("inputTokens"))
output_tokens = _to_int(raw_usage.get("outputTokens"))
cache_read = _to_int(raw_usage.get("cacheReadTokens"))
model_enum = raw_usage.get("model")
if input_tokens > 0:
data["cumulative_input_tokens"] = input_tokens
if output_tokens > 0:
data["cumulative_output_tokens"] = output_tokens
if cache_read > 0:
data["cumulative_cache_read_input_tokens"] = cache_read
if isinstance(model_enum, str) and model_enum:
data["model"] = model_enum # resolved to displayName by caller
if not data:
return None
return data
def _requested_model_enum_from_step(step: dict[str, object]) -> str | None:
"""
Extract the model enum from a USER_INPUT step's plannerConfig.
The live wire (agy 1.0.10) carries the enum as a STRING at
``step.userInput.userConfig.plannerConfig.planModel`` (design §10.4) — the
same field :func:`send_user_cascade_message` sends. A TUI-origin step using
the older ``requestedModel.model`` (dict) shape is supported as a fallback.
Returns ``None`` when the field is absent or the step is not a USER_INPUT.
:param step: One RPC step dict.
:returns: The model enum string, e.g. ``"MODEL_PLACEHOLDER_M20"``, or
``None`` when absent.
"""
if step.get("type") != _TYPE_USER_INPUT:
return None
user_input = step.get("userInput")
if not isinstance(user_input, dict):
return None
user_config = user_input.get("userConfig")
if not isinstance(user_config, dict):
return None
planner_config = user_config.get("plannerConfig")
if not isinstance(planner_config, dict):
return None
plan_model = planner_config.get("planModel")
if isinstance(plan_model, str) and plan_model:
return plan_model
requested_model = planner_config.get("requestedModel")
if not isinstance(requested_model, dict):
return None
model = requested_model.get("model")
return model if isinstance(model, str) and model else None
def _resolve_display_name(model_enum: str, catalog: dict[str, object]) -> str:
"""
Resolve a model enum to its human-readable ``displayName``.
Iterates the ``catalog["models"]`` dict (live shape from
:func:`get_available_models`) and returns the first entry whose ``model``
field matches ``model_enum``. Falls back to the raw enum when the catalog
is absent, malformed, or does not contain the enum — so an unknown enum is
always reported rather than silently dropped.
:param model_enum: agy model enum string, e.g. ``"MODEL_PLACEHOLDER_M20"``.
:param catalog: Parsed response from ``GetAvailableModels``.
:returns: The ``displayName`` string, e.g. ``"Gemini 2.5 Flash"``, or the
raw enum as a fallback.
"""
models = catalog.get("models")
if not isinstance(models, dict):
return model_enum
for entry in models.values():
if not isinstance(entry, dict):
continue
if entry.get("model") == model_enum:
display = entry.get("displayName")
if isinstance(display, str) and display:
return display
return model_enum
def _parse_activity_timestamp(value: object) -> datetime | None:
"""
Parse one ISO-8601 activity timestamp from a cascade summary, robustly.
agy emits ``lastUserInputTime`` / ``lastModifiedTime`` as ISO-8601 strings
with a trailing ``Z`` (UTC), e.g. ``"2026-06-23T17:50:29.232919Z"``.
:func:`datetime.fromisoformat` did not accept a literal ``Z`` before Python
3.11, so the ``Z`` is normalised to ``+00:00`` first. A naive parse (no
offset, e.g. a future agy dropping the suffix) is pinned to UTC so all
comparisons in :func:`_detect_rotated_cascade` are offset-aware and never
raise ``TypeError`` on a naive/aware mix.
A missing (``None``), non-string, or malformed value yields ``None`` — the
caller treats such an entry as having NO activity (not a rotation candidate,
and a bound entry with no parseable activity blocks rotation), so a parse
failure can never spuriously rotate.
:param value: The raw timestamp field from a trajectory summary.
:returns: An offset-aware UTC :class:`datetime`, or ``None`` when absent or
unparseable.
"""
if not isinstance(value, str) or not value:
return None
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
try:
parsed = datetime.fromisoformat(normalized)
except ValueError:
return None
return parsed if parsed.tzinfo is not None else parsed.replace(tzinfo=timezone.utc)
def _summary_activity(summary: dict[str, object]) -> datetime | None:
"""
Return a cascade summary's newest activity time, or ``None`` if never used.
Activity is the user's last turn (``lastUserInputTime``) when present, else
the cascade's ``lastModifiedTime``. A freshly ``/clear``-minted cascade has
BOTH absent (``null``) until its first turn runs, so it reports ``None`` and
is not yet a rotation candidate — the rotation only fires once the new
conversation has actually been used, never on the bare mint.
:param summary: One ``trajectorySummaries`` entry.
:returns: The newest offset-aware activity :class:`datetime`, or ``None``
when the cascade has no parseable activity timestamp yet.
"""
return _parse_activity_timestamp(
summary.get("lastUserInputTime")
) or _parse_activity_timestamp(summary.get("lastModifiedTime"))
def _detect_rotated_cascade(summaries: dict[str, object], bound_cascade_id: str) -> str | None:
"""
Return the id of a newer-active root cascade than the bound one, else ``None``.
The pure core of Task T-G ``/clear``-rotation detection. A TUI ``/clear`` mints
a NEW root cascade and leaves the bound one idle; the new cascade is invisible
to the bound ``StreamAgentStateUpdates`` stream (bound to one cascade) but
appears here in ``GetAllCascadeTrajectories`` as a more-recently-active sibling.
Selection:
* Consider ONLY root cascades (``trajectoryType ==
CORTEX_TRAJECTORY_TYPE_CASCADE``) — a subagent/child trajectory is never a
rotation target (rotating to it would mirror a sub-conversation, not the
user's top-level one).
* The "current" cascade is the one with the newest activity timestamp
(:func:`_summary_activity`: ``lastUserInputTime`` preferred, else
``lastModifiedTime``). An entry with NO parseable activity (a bare ``/clear``
mint, both timestamps ``null``) is not a candidate.
* Rotate ONLY when the current cascade is BOTH a different id than the bound
one AND strictly more recently active than the bound cascade's OWN activity.
The bound entry's activity is looked up from ``summaries`` itself; if the
bound entry is absent (we cannot prove the bound conversation is staler), we
do NOT rotate — returning ``None`` keeps the reader on its current binding
rather than chasing a sibling on incomplete information.
Deterministic on ties: a sibling whose activity merely EQUALS the bound
cascade's (not strictly newer) is not a rotation, so a steady state never
flaps. Selection of the newest among multiple siblings is by ``>`` so the
single most-recent wins.
:param summaries: The ``trajectorySummaries`` map from
:func:`~omnigent.antigravity_native_rpc.get_all_cascade_trajectories`
(keyed by root conversation id).
:param bound_cascade_id: The conversation id this reader is currently bound to.
:returns: The newer-active root cascade's id when a rotation is warranted,
else ``None``.
"""
bound_summary = summaries.get(bound_cascade_id)
if not isinstance(bound_summary, dict):
# The bound conversation is not in the summary map — we cannot prove it is
# staler than any sibling, so we must not rotate blindly. Stay bound.
return None
bound_activity = _summary_activity(bound_summary)
best_id: str | None = None
best_activity: datetime | None = None
for cascade_id, summary in summaries.items():
if not isinstance(summary, dict):
continue
if summary.get("trajectoryType") != _TRAJECTORY_TYPE_CASCADE:
continue # never rotate to a subagent/child trajectory
if cascade_id == bound_cascade_id:
continue
activity = _summary_activity(summary)
if activity is None:
continue # a never-used (e.g. bare /clear-minted) cascade is not current
if best_activity is None or activity > best_activity:
best_id, best_activity = cascade_id, activity
if best_id is None or best_activity is None:
return None
# Rotate only when the most-recent sibling is STRICTLY newer than the bound
# cascade's own activity. A bound cascade that itself has no parseable activity
# (bound_activity is None) is treated as the oldest, so any active sibling wins
# — this is the /clear-before-first-turn case: a freshly-bound cascade that
# never took a turn, then a sibling the user actually used, MUST rotate (else
# the reader stays on the dead pre-/clear cascade). Siblings with no activity
# are already excluded above, so this only fires for a genuinely-active sibling.
if bound_activity is None or best_activity > bound_activity:
return best_id
return None
async def _sleep(seconds: float) -> None:
"""
Stubbable indirection for the poll/backoff sleep.
Exists so tests can drive the loop without real delays without patching
``asyncio.sleep`` through the imported module singleton.
:param seconds: Delay in seconds.
:returns: None after the sleep completes.
"""
await asyncio.sleep(seconds)
def _step_key(step: dict[str, object]) -> _StepKey:
"""
Build the within-run dedup key for one RPC step.
Reuses the mapper's ``trajectory_id`` + ``step_index`` extraction so the key
is identical to the identity :func:`pending_interaction` keys on — a step is
"the same step" for de-dup, status, and interaction purposes consistently.
:param step: One step dict from ``GetCascadeTrajectorySteps``.
:returns: A ``(trajectory_id, step_index, discriminator)`` identity tuple.
The first two elements may be ``None``; the third is ``None`` for any
step that has a ``step_index`` (its ``(trajectory_id, step_index)``
pair is already unique) and a per-turn-unique discriminator only for a
step that lacks one.
"""
idx = _step_index(step)
# A step WITH a step_index keeps its (trajectory_id, step_index) identity
# (discriminator None) — unchanged dedup. A step withOUT one (USER_INPUT)
# would otherwise collide on (trajectory_id, None) across every turn and be
# silently de-duped after turn 1, dropping per-turn status + model-change;
# fold a per-turn-unique discriminator so each turn keys distinctly.
discriminator = None if idx is not None else _execution_discriminator(step)
return (_trajectory_id(step), idx, discriminator)
def _is_user_turn_step(step: dict[str, object]) -> bool:
"""
Return whether a step opens a turn (a USER_INPUT step).
The RPC equivalent of the transcript forwarder's
:func:`_is_turn_boundary_running`: a user input step starts a turn (agy then
runs the model + tools).
:param step: One RPC step dict.
:returns: ``True`` for a ``CORTEX_STEP_TYPE_USER_INPUT`` step.
"""
return step.get("type") == _TYPE_USER_INPUT
def _is_assistant_text_close_step(step: dict[str, object]) -> bool:
"""
Return whether a step closes a turn (assistant text, no further tool calls).
The RPC equivalent of the transcript forwarder's
:func:`_is_assistant_text_step`: a PLANNER_RESPONSE that carries assistant
text (``modifiedResponse`` or ``response``) and issues NO tool calls is the
closing edge of a turn — agy answered and stopped. A planner step that only
invokes a tool does not close the turn (the tool result, and possibly more
planner steps, follow).
:param step: One RPC step dict.
:returns: ``True`` when the step is a DONE PLANNER_RESPONSE with non-empty
text and an empty/absent ``toolCalls`` list.
"""
# The turn-close edge must fire only on the DONE closing step. A GENERATING
# planner frame already carries growing ``modifiedResponse`` text with no
# ``toolCalls`` yet, so without this gate ``_emit_step`` would fire the IDLE
# status edge mid-response (the spinner closes early) on the stream path.
if step.get("status") != _STATUS_DONE:
return False
if step.get("type") != _TYPE_PLANNER_RESPONSE:
return False
planner = step.get("plannerResponse")
if not isinstance(planner, dict):
return False
modified = planner.get("modifiedResponse")
response = planner.get("response")
text = modified if isinstance(modified, str) and modified else response
if not isinstance(text, str) or not text.strip():
return False
tool_calls = planner.get("toolCalls")
return not (isinstance(tool_calls, list) and tool_calls)
def _is_turn_close_step(step: dict[str, object]) -> bool:
"""
Return whether a step ends the current turn (fire the IDLE edge).
A turn opens on USER_INPUT and stays open until agy stops working. The
canonical close is a DONE PLANNER_RESPONSE that carries assistant text and
dispatches no further tool calls (:func:`_is_assistant_text_close_step`).
But a turn can also END without that clean closing text step, and those
paths must fire IDLE too — otherwise ``turn_active`` sticks True forever:
the spinner never clears AND the next turn's USER_INPUT can't re-open
RUNNING (it is gated on ``not turn_active``). The additional closes:
* a terminal-ERROR PLANNER_RESPONSE — agy's model step failed, so no tool
result or recovery planner follows; and
* a DONE PLANNER_RESPONSE that dispatches no tool call and carries no usable
text — a degenerate end with nothing more to do.
A PLANNER_RESPONSE that DOES dispatch a tool call is never a close: the tool
result (and possibly more planner steps) follow. Non-planner steps never
close a turn here (a tool result is followed by a recovery/answer planner;
closing on it would pre-empt that planner).
:param step: One RPC step dict.
:returns: ``True`` when this step ends the turn.
"""
if _is_assistant_text_close_step(step):
return True
if step.get("type") != _TYPE_PLANNER_RESPONSE:
return False
status = step.get("status")
if status == _STATUS_ERROR:
return True
if status != _STATUS_DONE:
return False
# A DONE planner that dispatches a tool call is a continuation, not a close.
planner = step.get("plannerResponse")
tool_calls = planner.get("toolCalls") if isinstance(planner, dict) else None
return not (isinstance(tool_calls, list) and tool_calls)
def _status_event(status: str) -> OutboundEvent:
"""
Build an ``external_session_status`` edge.
``step_index`` is unused by the RPC read path (there is no durable per-step
cursor to advance — that was retired with the transcript forwarder), so it is
stamped 0; the field is retained only because :class:`OutboundEvent` is shared
with the transcript path.
:param status: Session status, e.g. ``"running"`` or ``"idle"``.
:returns: One ``external_session_status`` event.
"""
return OutboundEvent(
event_type="external_session_status",
data={"status": status},
step_index=0,
)
async def _post_event(
client: httpx.AsyncClient,
session_id: str,
event: OutboundEvent,
) -> None:
"""
POST one mapped event with the shared bounded-retry delivery loop.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param event: The mapped event to deliver.
:returns: None. Delivery failures are logged inside the retry loop; an
ambiguous conversation-item failure is intentionally not retried (a
re-post would duplicate the item).
"""
url = f"/v1/sessions/{url_component(session_id)}/events"
payload: dict[str, object] = {"type": event.event_type, "data": event.data}
await post_session_event_with_retry(
client=client,
url=url,
payload=payload,
event_type=event.event_type,
max_attempts=_POST_MAX_ATTEMPTS,
retry_status_codes=_POST_RETRY_STATUS_CODES,
sleep=_sleep,
retry_delay=lambda attempt: _POST_RETRY_DELAY_SECONDS * attempt,
logger_name=__name__,
)
def _resolve_cascade_id(bridge_dir: Path) -> str | None:
"""
Return agy's real cascade id from bridge state, or ``None`` if not ready.
The launcher seeds bridge state's ``conversation_id`` with an ``agy_conv_*``
placeholder until the forwarder/executor discovers and persists agy's real
UUID; a placeholder means "not ready yet" (it never names a live cascade), so
it is rejected here. agy uses one UUID for both the conversation and the
cascade, so the resolved conversation id IS the cascade id.
:param bridge_dir: Native Antigravity bridge directory.
:returns: The real cascade id, or ``None`` when bridge state is missing or
still holds the placeholder.
"""
state = read_bridge_state(bridge_dir)
if state is None:
return None
if is_placeholder_conversation_id(state.conversation_id):
return None
return state.conversation_id
def _resolve_rpc_port(cascade_id: str) -> int | None:
"""
Return the agy connect-RPC port that hosts ``cascade_id``, or ``None``.
Mirrors :func:`omnigent.antigravity_native_rpc.resolve_language_server_port`'s
port-first discipline: enumerate every live agy connect-RPC port and bind the
one whose ``GetConversationMetadata`` confirms it hosts this cascade id. A
recycled/foreign port (a different live agy) is rejected because it cannot
echo this id.
:param cascade_id: agy cascade id (equal to the conversation id) to locate.
:returns: A validated connect-RPC port hosting ``cascade_id``, or ``None``
when no running agy could be matched yet.
"""
for port in _candidate_agy_rpc_ports():
if _conversation_matches(port, cascade_id):
return port
return None
async def _discover(
bridge_dir: Path,
*,
poll_interval_s: float,
stop: StopPredicate,
) -> tuple[str, int] | None:
"""
Resolve ``(cascade_id, port)``, polling until ready or asked to stop.
Two stages, each "poll until ready, never guess": first the real cascade id
from bridge state (past the launcher placeholder), then the connect-RPC port
that confirms ownership of that cascade. Discovery work (file read + blocking
httpx TLS probes) runs in a worker thread so the event loop stays responsive.
Readiness is checked BEFORE ``stop`` each round, so a discovery that resolves
immediately consumes none of the caller's poll budget — ``stop`` is a
"give up while still waiting" valve (the runner owns restart), not a cost the
happy path pays. Discovery therefore always attempts at least one resolution.
:param bridge_dir: Native Antigravity bridge directory.
:param poll_interval_s: Seconds to wait between discovery polls.
:param stop: Predicate consulted only when a round did NOT resolve; when it
returns ``True`` the discovery loop gives up (the runner owns restart).
:returns: ``(cascade_id, port)`` once both resolve, or ``None`` if ``stop``
fired before discovery completed.
"""
while True:
cascade_id = await asyncio.to_thread(_resolve_cascade_id, bridge_dir)
if cascade_id is not None:
port = await asyncio.to_thread(_resolve_rpc_port, cascade_id)
if port is not None:
_logger.info(
"agy RPC reader bound: bridge_dir=%s cascade=%s port=%s",
bridge_dir,
cascade_id,
port,
)
return cascade_id, port
if stop():
return None
await _sleep(poll_interval_s)
async def _watch_for_rotation(
*,
port: int,
bound_cascade_id: str,
interval_s: float,
skip_cascade_ids: frozenset[str],
on_rotation: Callable[[str], None],
) -> None:
"""
Poll ``GetAllCascadeTrajectories`` for a ``/clear`` rotation, then signal once.
The Task T-G rotation DETECTOR, run as a background task alongside the
stream/poll reader body (which is bound to one cascade and so cannot itself
observe a sibling conversation). Every ``interval_s`` it fetches the cascade
summaries and asks :func:`_detect_rotated_cascade` whether a newer-active root
cascade exists; the FIRST time one does, it invokes ``on_rotation`` with the
new cascade id and returns (one rotation per detector — the caller tears the
reader body down, rotates the session, and starts a fresh detector on rebind).
``skip_cascade_ids`` are cascades a PRIOR rotation attempt already failed to
bind (e.g. the Omnigent session-create errored); ignoring them here is what
prevents a hot re-detect/re-fail loop when rotation cannot complete — the
reader then keeps serving the old binding without this detector flapping.
Termination is by CANCELLATION only (``supervise_reader``'s ``finally`` always
cancels this task): the detector deliberately does NOT consult the reader's
``stop`` predicate, so it never races the bounded body loop's shared
iteration counter under test. Best-effort: a fetch failure
(``httpx.HTTPError`` / ``ValueError``) is logged and skipped (the next tick
retries) so a transient agy fault never kills the detector. A benign
``httpx.ConnectError`` (connection refused — the port is already gone during
teardown/rotation/shutdown before this task is cancelled) is logged at DEBUG
rather than WARNING to avoid spamming the log every tick; every other
``httpx.HTTPError`` (e.g. a hung-but-listening port raising ``ReadTimeout``)
still WARNs because it signals a real fault.
:param port: Validated connect-RPC port (the bound reader's port).
:param bound_cascade_id: The cascade id the reader is currently bound to.
:param interval_s: Seconds between rotation checks (kept coarse — a few seconds
— so the loopback RPC is not hammered).
:param skip_cascade_ids: Cascade ids a prior rotation attempt failed to bind;
never re-signalled.
:param on_rotation: Called once with the detected new cascade id.
:returns: None.
"""
while True:
await _sleep(interval_s)
try:
body = await asyncio.to_thread(get_all_cascade_trajectories, port)
except httpx.ConnectError as exc:
# Connection refused is benign here: the agy port is gone because the
# terminal is being torn down / rotated / shut down before this
# detector task is cancelled. No spawn or leak — the next tick retries
# and the supervisor cancels us — so log at DEBUG to avoid spam.
_logger.debug(
"agy rotation detector: GetAllCascadeTrajectories connect refused "
"(port likely gone during teardown); retrying: "
"bound_cascade=%s port=%s error=%r",
bound_cascade_id,
port,
exc,
)
continue
except (httpx.HTTPError, ValueError) as exc:
_logger.warning(
"agy rotation detector: GetAllCascadeTrajectories failed; retrying: "
"bound_cascade=%s port=%s error=%r",
bound_cascade_id,
port,
exc,
)
continue
summaries = body.get("trajectorySummaries")
if not isinstance(summaries, dict):
continue
new_cascade_id = _detect_rotated_cascade(summaries, bound_cascade_id)
if new_cascade_id is None or new_cascade_id in skip_cascade_ids:
continue
_logger.info(
"agy rotation detector: bound conversation %s was rotated away (newer-active "
"cascade %s, likely a TUI /clear); signalling rebind",
bound_cascade_id,
new_cascade_id,
)
on_rotation(new_cascade_id)
return
async def supervise_reader(
bridge_dir: Path,
session_id: str,
*,
client: httpx.AsyncClient,
on_pending_interaction: OnPendingInteraction,
poll_interval_s: float = _DEFAULT_POLL_INTERVAL_S,
stop: StopPredicate | None = None,
detect_rotation_interval_s: float = _DEFAULT_ROTATION_INTERVAL_S,
skip_cascade_ids: frozenset[str] = frozenset(),
committed_steps_out: list[int] | None = None,
) -> str | None:
"""
Poll agy's RPC for trajectory steps and mirror them into the Omnigent session.
The read-path driver: it discovers the cascade id + connect-RPC port (polling
until ready), then on each poll reads the full trajectory step snapshot, and
for every step it has not seen before this run:
* maps the step to conversation-item events (:func:`map_step_to_events`) and
POSTs each one (USER_INPUT maps to ``[]`` so it posts nothing — the user
turn is already persisted by the direct ``POST /events`` hook);
* emits an ``external_session_status`` RUNNING edge when a user turn opens and
an IDLE edge when an assistant-text step closes it, each only on a real
transition (deduped via an in-memory turn-active flag);
* when the step is ``WAITING`` for user interaction, invokes
``on_pending_interaction`` exactly once for that interaction (the Task 8
bridge drives the elicitation + answer).
De-dup is by ``(trajectory_id, step_index)`` identity in an in-memory
seen-set (no durable cursor — retired in Task 12), so re-reading the same
snapshot posts nothing. A single :class:`_ToolCallIdAllocator` is reused
across polls so fallback ids stay stable; real agy tool-call ids (used by the
mapper) make invocation↔output pairing order-independent regardless.
Error handling: an RPC failure on a poll — ``httpx.HTTPError`` (transport AND
non-2xx both raise it) or a ``ValueError`` (a non-JSON 200 body) — is logged
and swallowed so a transient fault never kills the loop; the next poll
recovers.
Task T-G ``/clear`` rotation: the stream/poll reader body runs as a CANCELLABLE
task and, ALONGSIDE it, a :func:`_watch_for_rotation` background task polls
``GetAllCascadeTrajectories`` every ``detect_rotation_interval_s`` (the stream
is bound to one cascade and cannot see a sibling). When it detects a
newer-active root cascade (a TUI ``/clear`` mints one) it records the new id and
CANCELS the body task — necessary because after a ``/clear`` the bound cascade
goes idle and the stream blocks inside a deadline-less ``aiter_bytes`` read,
where the cooperative ``stop`` checkpoint is never reached; cancellation
interrupts that wedged read. This function then RETURNS the new cascade id so
:func:`run_reader_with_bridge` can rotate the Omnigent session and rebind. When
the body ends on its own (the ``stop`` predicate fired, or the stream+poll both
exited) it returns ``None``; an EXTERNAL cancellation of this coroutine (no
rotation recorded) propagates rather than being mistaken for a rotation.
:param bridge_dir: Native Antigravity bridge directory (identifies the
session whose agy conversation to mirror).
:param session_id: Omnigent conversation id to mirror into, e.g.
``"conv_abc123"``.
:param client: HTTP client for Omnigent event posts.
:param on_pending_interaction: Async callback handed each distinct WAITING
interaction (the Task 8 interaction bridge), as
``(cascade_id, port, pending)`` — the SAME cascade id + connect-RPC port
the reader discovered, so the bridge targets agy's live conversation
without re-discovering. Invoked at most once per
``(trajectory_id, step_index)``.
:param poll_interval_s: Seconds between RPC polls (and discovery polls).
:param stop: Optional predicate consulted once per loop iteration; when it
returns ``True`` the loop exits. ``None`` (production) loops until the
task is cancelled. Provided so tests drive a bounded number of
iterations.
:param detect_rotation_interval_s: Seconds between ``/clear``-rotation checks
(kept coarse so the loopback RPC is not hammered).
:param skip_cascade_ids: Cascade ids a prior rotation attempt failed to bind;
the rotation detector never re-signals them (prevents a hot
detect/rotate-fail loop — the reader keeps the old binding instead).
:returns: The detected new cascade id when a ``/clear`` rotation was observed
(the caller rotates + rebinds), or ``None`` when the reader body ended on
its own (``stop`` fired / both paths exited).
"""
should_stop: StopPredicate = stop if stop is not None else (lambda: False)
discovered = await _discover(bridge_dir, poll_interval_s=poll_interval_s, stop=should_stop)
if discovered is None:
return None
cascade_id, port = discovered
# One allocator + one set of cross-poll/cross-frame trackers per reader run,
# shared by BOTH the stream path and the poll fallback so a fall-through after
# a partial stream does not re-post already-mirrored steps or re-open turns.
state = _ReaderState(
allocator=_ToolCallIdAllocator(conversation_id=cascade_id),
seen=set(),
interacted=set(),
port=port,
)
# Task T-G: the rotation detector records the new cascade id here and the
# ``stop`` predicate the reader body consults flips to True so the body exits
# cleanly at its next checkpoint (between stream re-entries / poll iterations).
# That checkpoint, however, is never reached while the stream is wedged on a
# deadline-less idle read (after a ``/clear`` the bound cascade goes IDLE and
# ``aiter_bytes`` blocks with no frame and no trailer), so the detector ALSO
# cancels the body task below — cancellation interrupts the wedged read where a
# cooperative ``stop`` re-check cannot. One-shot per supervise_reader run; the
# caller rebinds with a fresh detector.
rotation_holder: list[str] = []
# The reader body runs as a cancellable task (created below) so a detected
# rotation can interrupt a stream blocked inside ``aiter_bytes`` — a plain
# ``await`` of the body would never return, since neither ``_stream_loop``'s
# outer ``while`` nor its post-``async for`` ``stop`` checkpoint is reached
# while the read is wedged. ``_on_rotation`` references the task through this
# holder so it is safe even though the task is created AFTER the detector's
# callback is defined (the detector cannot fire before its task is scheduled
# and runs its first ``await``, by which point ``body_holder`` is populated).
body_holder: list[asyncio.Task[None]] = []
def _on_rotation(new_cascade_id: str) -> None:
if not rotation_holder:
rotation_holder.append(new_cascade_id)
# Interrupt a stream wedged on a deadline-less idle read; a cooperative
# ``_body_should_stop`` re-check would never run while it blocks.
if body_holder:
body_holder[0].cancel()
def _body_should_stop() -> bool:
# The reader body stops either on the caller's stop OR once a rotation was
# detected (so it does not keep mirroring the now-dead conversation). This
# covers the cooperative exits (between stream re-entries / poll iterations);
# a stream blocked mid-read is interrupted by ``_on_rotation``'s cancel.
return should_stop() or bool(rotation_holder)
async def _run_body() -> None:
# STREAM-primary (Task T-D): consume the connect server-stream for live
# ``output_text_delta`` typing parity. On a stream error (transport
# ``httpx.HTTPError`` or a connect-trailer ``AntigravityRpcError``) fall
# back to the committed-only poll loop — graceful degradation to Phase-1
# behaviour — rather than letting the error kill the reader. The shared
# ``state`` makes the fallback idempotent against whatever the stream
# already delivered. Both paths consult ``_body_should_stop`` so a detected
# rotation ends them cooperatively; a rotation that lands while the stream
# is blocked instead cancels this task.
try:
await _stream_loop(
port=port,
cascade_id=cascade_id,
client=client,
session_id=session_id,
on_pending_interaction=on_pending_interaction,
state=state,
stop=_body_should_stop,
)
except (httpx.HTTPError, AntigravityRpcError) as exc:
_logger.warning(
"agy RPC reader stream failed; falling back to poll (committed-only, "
"no live deltas): cascade=%s port=%s error=%r",
cascade_id,
port,
exc,
)
await _poll_loop(
port=port,
cascade_id=cascade_id,
client=client,
session_id=session_id,
on_pending_interaction=on_pending_interaction,
state=state,
poll_interval_s=poll_interval_s,
stop=_body_should_stop,
)
body_task = asyncio.create_task(_run_body(), name="antigravity-reader-body")
body_holder.append(body_task)
# Started AFTER ``body_task`` exists so ``_on_rotation`` can never fire before
# the task is available to cancel (the holder is already populated).
rotation_task = asyncio.create_task(
_watch_for_rotation(
port=port,
bound_cascade_id=cascade_id,
interval_s=detect_rotation_interval_s,
skip_cascade_ids=skip_cascade_ids,
on_rotation=_on_rotation,
),
name="antigravity-rotation-detector",
)
try:
await body_task
except asyncio.CancelledError:
# A rotation cancels the body on purpose (``_on_rotation`` set
# ``rotation_holder`` before cancelling), so fall through to return the new
# cascade id. An EXTERNAL cancellation of ``supervise_reader`` (shutdown)
# arrives with NO rotation recorded — re-raise so it propagates rather than
# being swallowed as a phantom rotation.
if not rotation_holder:
raise
finally:
# Stop the rotation detector (it may still be sleeping between ticks) and
# the off-loop interaction bridge before returning, so a cancelled/stopped
# reader leaks neither task. Order: detector first (it owns no human-facing
# state), then the interaction task (may be awaiting a day-long verdict).
rotation_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await rotation_task
# Ensure the body task is finalized on EVERY exit path (notably an external
# cancel, where ``await body_task`` re-raised above before it could be
# awaited to completion) so no task leaks. A rotation/normal exit leaves it
# already done, making this a no-op.
if not body_task.done():
body_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await body_task
# Drain the bridge and any chained re-scan tasks. Yield once per pass so
# a normally-completed bridge's pending ``_clear_slot`` callback lands in
# ``interaction_rescans`` before the snapshot — otherwise it escapes the
# drain and runs post-teardown. Suppress all exceptions (including
# CancelledError) to avoid aborting the drain with orphaned tasks.
for _ in range(_INTERACTION_DRAIN_PASSES):
await asyncio.sleep(0)
inflight = [
pending
for pending in (state.interaction_task, *state.interaction_rescans)
if pending is not None and not pending.done()
]
if not inflight:
break
for pending in inflight:
pending.cancel()
for pending in inflight:
with contextlib.suppress(asyncio.CancelledError, Exception):
await pending
# Report how many committed steps (turns) this run mirrored for the bound
# cascade. The caller uses a count of 0 to distinguish "first TUI-minted
# cascade adoption" (the cold-start StartCascade phantom never took a turn)
# from a genuine ``/clear`` (the bound cascade HAD turns) — see
# :func:`run_reader_with_bridge`.
if committed_steps_out is not None:
committed_steps_out.append(len(state.seen))
return rotation_holder[0] if rotation_holder else None
@dataclass
class _ReaderState:
"""
Per-run trackers shared across the stream loop and the poll fallback.
Kept in one object so a fall-through from a partially consumed stream to the
poll loop reuses the same de-dup/turn/interaction state — a step already
mirrored over the stream is not re-posted by the poll loop, and an open turn
is not re-opened.
:param allocator: Per-run fallback tool-call id allocator (real agy ids are
preferred by the mapper; this only covers resume-mid-turn results that
lack ``metadata.toolCall.id``).
:param seen: :func:`_step_key` identities whose COMMITTED items have been
posted, so the on-connect snapshot replay (and steady-state re-reads)
post nothing.
:param interacted: Identities whose WAITING interaction was already handed to
the bridge, so a re-sent WAITING frame does not re-fire it.
:param prefixes: Per-PLANNER ``step_index`` → the length of ``modifiedResponse``
already forwarded as deltas, so each frame emits only the NEW suffix.
Cleared for a step when its committed ``message`` is posted (stream path
only; the poll path never populates it).
:param reasoning_prefixes: Per-PLANNER ``step_index`` → the ``thinking`` text
already forwarded as reasoning deltas, mirroring ``prefixes`` for the
reasoning stream (design §10.2). A step's first entry doubles as the
"reasoning started" marker: absent ⇒ the next reasoning delta is the
step's first (carries ``started=True``). Cleared with ``prefixes`` when the
committed ``message`` is posted (stream path only).
:param turn_active: Whether a turn is currently considered open (a RUNNING
edge fired and no closing IDLE edge yet).
:param posted_model_enum: The last model enum already mirrored via
``external_model_change``. ``None`` = none posted yet. Tracks the raw
enum (NOT the displayName) so de-dup comparison is enum-stable.
:param model_catalog: Cached result of ``GetAvailableModels`` for this
reader run (fetched once on first model-change detection; ``None``
until needed).
:param port: Validated connect-RPC port used for lazy catalog fetch.
:param cumulative_input_tokens: Running session total of input tokens
accumulated across all PLANNER_RESPONSE DONE steps this run. The server
treats ``external_session_usage.cumulative_input_tokens`` as a SET
(new value = current total), so we must sum, not emit per-call values.
Reset to 0 at the start of each reader run; a T-G /clear rotation rebinds
with a FRESH ``_ReaderState`` (``supervise_reader`` is re-entered), so the
new conversation's cost badge starts from 0 automatically.
:param cumulative_output_tokens: Running session total of output tokens.
:param cumulative_cache_read_input_tokens: Running session total of
cache-read input tokens.
:param interaction_task: The single in-flight interaction-bridge task, or
``None`` when none is running. The bridge runs OFF the reader loop so
streaming/mirroring continues while a human answers (a long-poll can last
a day); at most one runs at a time because the in-flight bridge owns agy's
WAITING-timeout retries (a second would double-fire). Cancelled on reader
teardown.
:param surfaced_elicitations: ``_StepKey`` → the deterministic elicitation id
published for that WAITING step. Populated when an interaction is handed to
the bridge; consumed by :func:`_maybe_withdraw_interaction` when the step
is later seen NO LONGER WAITING (answered in the agy TUI, or agy timed
out) to WITHDRAW the still-parked web card (#1200, direction 2). An entry
is removed once withdrawn so the withdraw posts at most once.
:param interaction_rescans: In-flight re-scan tasks scheduled by a bridge's
done-callback to surface a WAITING gate deferred while the bridge ran.
Held as strong refs so they are not GC'd mid-run; cancelled on teardown.
"""
allocator: _ToolCallIdAllocator
seen: set[_StepKey]
interacted: set[_StepKey]
prefixes: dict[int, str] = field(default_factory=dict)
reasoning_prefixes: dict[int, str] = field(default_factory=dict)
turn_active: bool = False
posted_model_enum: str | None = None
model_catalog: dict[str, object] | None = None
port: int = 0
cumulative_input_tokens: int = 0
cumulative_output_tokens: int = 0
cumulative_cache_read_input_tokens: int = 0
interaction_task: asyncio.Task[None] | None = None
surfaced_elicitations: dict[_StepKey, str] = field(default_factory=dict)
interaction_rescans: set[asyncio.Task[None]] = field(default_factory=set)
async def _poll_loop(
*,
port: int,
cascade_id: str,
client: httpx.AsyncClient,
session_id: str,
on_pending_interaction: OnPendingInteraction,
state: _ReaderState,
poll_interval_s: float,
stop: StopPredicate,
) -> None:
"""
Poll ``GetCascadeTrajectorySteps`` and mirror new committed steps.
The Phase-1 read path (committed-only, no live deltas) — now also the
graceful fallback when the stream errors. On each poll it reads the full
snapshot and, for every not-yet-seen step, emits the committed items + status
edges via :func:`_emit_step` and hands a WAITING step to the bridge once.
A poll failure — ``httpx.HTTPError`` (transport AND non-2xx) or ``ValueError``
(a non-JSON 200 body) — is logged and swallowed so a transient fault never
kills the loop; the next poll recovers.
:param port: Validated connect-RPC port.
:param cascade_id: agy cascade id (equal to the conversation id).
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id to mirror into.
:param on_pending_interaction: Async callback for a distinct WAITING
interaction.
:param state: Per-run shared trackers (de-dup, turn, interactions).
:param poll_interval_s: Seconds between polls.
:param stop: Predicate consulted once per iteration; ``True`` exits.
:returns: None.
"""
while not stop():
try:
steps = await asyncio.to_thread(get_trajectory_steps, port, cascade_id)
except httpx.HTTPError as exc:
_logger.warning(
"agy RPC reader poll failed (transport/status); retrying: "
"cascade=%s port=%s error=%r",
cascade_id,
port,
exc,
)
await _sleep(poll_interval_s)
continue
except ValueError as exc:
# A 2xx whose body was not valid JSON. Treat as transient like an
# HTTP error: log and keep polling rather than crash the loop.
_logger.warning(
"agy RPC reader poll returned a non-JSON body; retrying: "
"cascade=%s port=%s error=%r",
cascade_id,
port,
exc,
)
await _sleep(poll_interval_s)
continue
for step in steps:
await _process_committed_step(
step,
client=client,
session_id=session_id,
cascade_id=cascade_id,
state=state,
on_pending_interaction=on_pending_interaction,
)
await _sleep(poll_interval_s)
async def _stream_loop(
*,
port: int,
cascade_id: str,
client: httpx.AsyncClient,
session_id: str,
on_pending_interaction: OnPendingInteraction,
state: _ReaderState,
stop: StopPredicate,
) -> None:
"""
Consume ``StreamAgentStateUpdates`` for live deltas + committed items.
For each frame's ``mainTrajectoryUpdate.stepsUpdate.steps[]`` (design §10.2):
* A PLANNER_RESPONSE step with ``status == GENERATING`` → compute the NEW
suffix of ``plannerResponse.modifiedResponse`` past the per-``step_index``
forwarded prefix and, when non-empty, emit one incremental
``external_output_text_delta`` (stable per-step ``message_id``,
``final=False``). The prefix tracker advances so the next frame emits only
the next suffix — deltas never overlap or duplicate.
* Any step reaching a committed state (DONE / non-planner result) → emit its
committed items via :func:`map_step_to_events`, deduped by
``(trajectory_id, step_index)`` so the on-connect snapshot replay and the
cumulative re-sends do not double-post. For the planner step this is the
committed ``message`` (its deltas already preceded it, satisfying the
flush-barrier reconciliation contract); its prefix tracker is then cleared.
* A WAITING step → handed to the bridge exactly once.
The stream is consumed and then RE-ENTERED while ``stop`` stays falsy (a real
connect stream returns when the turn settles, so re-entry resumes live updates
for the next turn). ``stop`` is consulted once per re-entry, mirroring the
poll loop, and a small :data:`_STREAM_REENTRY_BACKOFF_S` sleep separates
re-entries so an immediate clean trailer cannot busy-spin re-POSTing the
stream at zero delay (the steady-state stream blocks awaiting frames, so the
delay is paid only once per turn-settle). A transport ``httpx.HTTPError`` or a
connect-trailer ``AntigravityRpcError`` propagates to the caller, which falls
back to the poll loop.
:param port: Validated connect-RPC port.
:param cascade_id: agy cascade id (equal to the conversation id).
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id to mirror into.
:param on_pending_interaction: Async callback for a distinct WAITING
interaction.
:param state: Per-run shared trackers (de-dup, turn, interactions, prefixes).
:param stop: Predicate consulted once per stream (re-)entry; ``True`` exits.
:returns: None.
:raises httpx.HTTPError: On a stream transport failure (caller falls back).
:raises AntigravityRpcError: On a connect end-of-stream trailer error (caller
falls back).
"""
while not stop():
async for frame in stream_agent_state_updates(port, cascade_id):
# NOTE on /clear rotation: this stream is bound to ONE cascade and only
# ever reports THAT cascade's id, so it can NEVER observe a sibling
# conversation — a per-frame "did the conversation change?" guard here
# would be a guaranteed no-op. ``/clear`` rotation is handled OUT OF
# BAND by :func:`_watch_for_rotation` (via ``GetAllCascadeTrajectories``,
# which lists every live root cascade); on detection ``supervise_reader``
# flips this loop's ``stop`` and returns the new cascade id so
# :func:`run_reader_with_bridge` rotates the Omnigent session + rebinds.
for step in _frame_steps(frame):
await _process_stream_step(
step,
client=client,
session_id=session_id,
cascade_id=cascade_id,
state=state,
on_pending_interaction=on_pending_interaction,
)
# Backoff before re-opening the stream so an immediate clean trailer
# (no frames) cannot busy-spin re-POSTing at zero delay. Skipped when
# asked to stop so a shutdown is not delayed by the settle backoff.
if not stop():
await _sleep(_STREAM_REENTRY_BACKOFF_S)
def _frame_steps(frame: dict[str, object]) -> list[dict[str, object]]:
"""
Extract the trajectory steps from one ``StreamAgentStateUpdates`` frame.
The steps live at ``mainTrajectoryUpdate.stepsUpdate.steps[]`` (design
§10.2). A frame without that path (e.g. a non-trajectory update) yields no
steps. Only dict entries are returned so a malformed step never crashes the
loop.
:param frame: One parsed DATA-frame ``update`` dict from the stream.
:returns: The frame's step dicts (possibly empty).
"""
main = frame.get("mainTrajectoryUpdate")
if not isinstance(main, dict):
return []
steps_update = main.get("stepsUpdate")
if not isinstance(steps_update, dict):
return []
steps = steps_update.get("steps")
if not isinstance(steps, list):
return []
return [step for step in steps if isinstance(step, dict)]
async def _process_committed_step(
step: dict[str, object],
*,
client: httpx.AsyncClient,
session_id: str,
cascade_id: str,
state: _ReaderState,
on_pending_interaction: OnPendingInteraction,
) -> None:
"""
Emit one step's committed items + status edges + interaction (poll path).
De-dups by :func:`_step_key` against ``state.seen`` so a re-read posts
nothing, then emits via :func:`_emit_step` and hands a WAITING step to the
bridge once. This is the committed-only path (no deltas).
De-dup is only RECORDED once the step is *settled* (:func:`_is_settled`): a
tool-result step is observed non-contiguously through PENDING/RUNNING/WAITING
before DONE (verified in the live fixtures), and the mapper emits its output
only at DONE. Marking it ``seen`` on a pre-DONE sighting (where the mapper
returns ``[]``) would dedup the later DONE and silently DROP the output —
likelier on the stream path, which observes every intermediate status frame
than on the coarse poll. So a not-yet-settled step is re-emitted (a safe
no-op: it maps to ``[]`` and fires no status edge) until it settles, and the
interaction is still handed off via its own ``interacted`` dedup meanwhile.
:param step: One RPC step dict.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id to mirror into.
:param cascade_id: agy cascade id (namespaces ids).
:param state: Per-run shared trackers.
:param on_pending_interaction: Async callback for a distinct interaction.
:returns: None.
"""
key = _step_key(step)
if key not in state.seen:
if _is_settled(step):
state.seen.add(key)
state.turn_active = await _emit_step(
step,
client=client,
session_id=session_id,
cascade_id=cascade_id,
allocator=state.allocator,
turn_active=state.turn_active,
)
# Telemetry: model-change detection on USER_INPUT (design §10.4).
await _maybe_emit_model_change(
step,
client=client,
session_id=session_id,
state=state,
)
# Telemetry: token usage on PLANNER_RESPONSE DONE (design §10.3).
await _maybe_emit_session_usage(
step,
client=client,
session_id=session_id,
state=state,
)
_maybe_handle_interaction(
step,
key=key,
cascade_id=cascade_id,
state=state,
on_pending_interaction=on_pending_interaction,
)
# Inverse of the above (#1200, direction 2): if a step we previously surfaced
# is now NO LONGER WAITING (answered in the agy TUI, or agy timed out), withdraw
# the parked web card so it does not linger. Runs unconditionally — including
# for an already-``seen`` step — because a WAITING step is not ``seen`` until it
# settles, so its terminal transition arrives as a fresh (not-yet-seen) step
# here; the dedup lives in ``surfaced_elicitations`` (popped on first withdraw).
await _maybe_withdraw_interaction(
step,
key=key,
client=client,
session_id=session_id,
state=state,
)
def _is_settled(step: dict[str, object]) -> bool:
"""
Return whether a step has reached a state safe to record as de-duped.
A step is settled when re-emitting it can produce nothing new, so recording
its identity in ``seen`` will not drop later content:
* a USER_INPUT step (always terminal; maps to ``[]`` permanently — the user
turn is persisted by the direct ``POST /events``);
* any step whose ``status`` is terminal — DONE (the mapper emits its content)
or ERROR (the command failed before producing output, so none is coming).
A PENDING/RUNNING/WAITING/GENERATING step is NOT settled: its content (if
any) only appears at DONE, so it must stay re-evaluable until then.
:param step: One RPC step dict.
:returns: ``True`` when the step is terminal or a USER_INPUT.
"""
if step.get("type") == _TYPE_USER_INPUT:
return True
return step.get("status") in _TERMINAL_STATUSES
async def _process_stream_step(
step: dict[str, object],
*,
client: httpx.AsyncClient,
session_id: str,
cascade_id: str,
state: _ReaderState,
on_pending_interaction: OnPendingInteraction,
) -> None:
"""
Emit one streamed step: incremental deltas, then committed items on DONE.
Dispatch by step ``status`` (design §10.2 discriminator):
* A GENERATING PLANNER_RESPONSE emits the NEW suffix of its growing reasoning
(``plannerResponse.thinking``) as an ``output_reasoning_delta`` and then the
NEW suffix of its growing text (``modifiedResponse``) as an
``output_text_delta`` — reasoning FIRST (§10.2: thinking streams before the
response). No committed item yet; the step is NOT added to ``state.seen`` so
its eventual DONE frame still commits.
* Any other step is routed through the committed path
(:func:`_process_committed_step`): DONE planner → the committed ``message``
(deltas already preceded it); tool-result DONE → ``function_call_output``;
WAITING → the bridge. Committing a planner step clears its text + reasoning
prefix trackers.
:param step: One RPC step dict from a stream frame.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id to mirror into.
:param cascade_id: agy cascade id (namespaces ids + message ids).
:param state: Per-run shared trackers (incl. the per-step prefix trackers).
:param on_pending_interaction: Async callback for a distinct interaction.
:returns: None.
"""
if _is_generating_planner(step):
# Reasoning precedes the response (§10.2), so emit its delta first.
await _emit_partial_reasoning_delta(
step,
client=client,
session_id=session_id,
reasoning_prefixes=state.reasoning_prefixes,
)
await _emit_partial_delta(
step,
client=client,
session_id=session_id,
cascade_id=cascade_id,
prefixes=state.prefixes,
)
return
await _process_committed_step(
step,
client=client,
session_id=session_id,
cascade_id=cascade_id,
state=state,
on_pending_interaction=on_pending_interaction,
)
# Once committed, the live block is retired by the committed message; drop both
# prefix trackers so a later same-index step (e.g. an agy timeout-retry reusing
# the slot) starts a fresh delta stream rather than diffing against stale text.
idx = _step_index(step)
if idx is not None:
state.prefixes.pop(idx, None)
state.reasoning_prefixes.pop(idx, None)
def _is_generating_planner(step: dict[str, object]) -> bool:
"""
Return whether a step is a PLANNER_RESPONSE still generating its text.
Only such a step contributes incremental ``output_text_delta`` events; every
other status/type is handled by the committed path.
:param step: One RPC step dict.
:returns: ``True`` for a ``CORTEX_STEP_TYPE_PLANNER_RESPONSE`` whose
``status`` is ``CORTEX_STEP_STATUS_GENERATING``.
"""
return step.get("type") == _TYPE_PLANNER_RESPONSE and step.get("status") == _STATUS_GENERATING
def _partial_planner_text(step: dict[str, object]) -> str | None:
"""
Extract the growing partial assistant text from a GENERATING planner step.
The partial lives at ``plannerResponse.modifiedResponse`` (design §10.2);
``response`` is absent during generation. Returns ``None`` when the planner
block or the partial field is missing.
:param step: A GENERATING PLANNER_RESPONSE step dict.
:returns: The current cumulative ``modifiedResponse`` text, or ``None``.
"""
planner = step.get("plannerResponse")
if not isinstance(planner, dict):
return None
modified = planner.get("modifiedResponse")
return modified if isinstance(modified, str) else None
def _partial_planner_thinking(step: dict[str, object]) -> str | None:
"""
Extract the growing reasoning text from a GENERATING planner step.
Gemini Thinking-model variants stream chain-of-thought at
``plannerResponse.thinking`` (design §10.2), which grows across frames like
``modifiedResponse``. Non-thinking models omit the field. Returns ``None``
when the planner block or the ``thinking`` field is missing (no reasoning to
surface — the no-regression case for plain text streaming).
:param step: A GENERATING PLANNER_RESPONSE step dict.
:returns: The current cumulative ``thinking`` text, or ``None``.
"""
planner = step.get("plannerResponse")
if not isinstance(planner, dict):
return None
thinking = planner.get("thinking")
return thinking if isinstance(thinking, str) else None
async def _emit_partial_delta(
step: dict[str, object],
*,
client: httpx.AsyncClient,
session_id: str,
cascade_id: str,
prefixes: dict[int, str],
) -> None:
"""
Emit the NEW suffix of a GENERATING planner step's partial text as a delta.
Frames are cumulative snapshots, so the reader prefix-diffs: the delta is
``modifiedResponse`` minus the prefix already forwarded for this step's
``step_index``. When the new cumulative text does not extend the forwarded
prefix (a no-growth re-send, or a non-extending rewrite), nothing is emitted.
The tracker then advances to the full cumulative text so subsequent frames
emit only further growth — deltas never overlap or duplicate, and they
concatenate to the full text.
:param step: A GENERATING PLANNER_RESPONSE step dict.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id to mirror into.
:param cascade_id: agy cascade id (namespaces the stable message id).
:param prefixes: Per-``step_index`` forwarded-prefix tracker (mutated here).
:returns: None.
"""
idx = _step_index(step)
if idx is None:
return
text = _partial_planner_text(step)
if text is None:
return
forwarded = prefixes.get(idx, "")
# Only forward growth that extends what we already sent. A frame that does not
# start with the forwarded prefix (an unexpected non-monotonic rewrite) or
# that has not grown yields no delta; we still re-anchor the tracker to the
# latest cumulative text so we never re-emit the overlap.
if text.startswith(forwarded) and len(text) > len(forwarded):
suffix = text[len(forwarded) :]
await _post_event(
client,
session_id,
output_text_delta_event(
conversation_id=cascade_id,
step_idx=idx,
delta=suffix,
final=False,
),
)
prefixes[idx] = text
async def _emit_partial_reasoning_delta(
step: dict[str, object],
*,
client: httpx.AsyncClient,
session_id: str,
reasoning_prefixes: dict[int, str],
) -> None:
"""
Emit the NEW suffix of a GENERATING planner step's ``thinking`` as a delta.
The reasoning analogue of :func:`_emit_partial_delta`: ``thinking`` is a
cumulative snapshot per frame, so the reader prefix-diffs against the suffix
already forwarded for this step's ``step_index`` and emits only the growth.
A no-growth re-send (or a non-extending rewrite) emits nothing; the tracker
still advances so deltas never overlap and concatenate to the full reasoning.
The step's FIRST reasoning delta carries ``started=True`` (keyed off the
prefix tracker being absent for the step) so the server precedes it with one
``response.reasoning.started``; later deltas pass ``False``. A planner with no
``thinking`` field never enters the emit branch — no reasoning events, no
regression to the text stream. (No cascade id is needed: the SPA reasoning
block is not keyed by a per-step id the way the text deltas' ``message_id``
is — see :func:`output_reasoning_delta_event`.)
:param step: A GENERATING PLANNER_RESPONSE step dict.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id to mirror into.
:param reasoning_prefixes: Per-``step_index`` forwarded-prefix tracker
(mutated here); a step's first emitted delta also records its membership.
:returns: None.
"""
idx = _step_index(step)
if idx is None:
return
text = _partial_planner_thinking(step)
if text is None:
return
# Absent ⇒ this is the step's first reasoning delta (carries ``started``).
started = idx not in reasoning_prefixes
forwarded = reasoning_prefixes.get(idx, "")
# Mirror the text path: only forward growth that extends the forwarded prefix.
# A no-growth / non-extending frame emits nothing but still re-anchors the
# tracker. ``started`` is only consumed when an actual delta is emitted, so a
# first frame with empty ``thinking`` does not waste the marker.
if text.startswith(forwarded) and len(text) > len(forwarded):
suffix = text[len(forwarded) :]
await _post_event(
client,
session_id,
output_reasoning_delta_event(
step_idx=idx,
delta=suffix,
started=started,
),
)
# Re-anchor unconditionally (mirrors the text path's ``prefixes[idx] = text``):
# a non-monotonic rewrite emits no delta but must still advance the tracker, or
# reasoning deltas freeze permanently for this step once a rewrite occurs.
reasoning_prefixes[idx] = text
async def _emit_step(
step: dict[str, object],
*,
client: httpx.AsyncClient,
session_id: str,
cascade_id: str,
allocator: _ToolCallIdAllocator,
turn_active: bool,
) -> bool:
"""
Emit one new step's status edges + mapped conversation items.
Replicates the transcript parser's ordering: a RUNNING status edge (when this
step opens a turn) is posted BEFORE the step's items, and an IDLE edge (when
this step closes the turn) AFTER them. Status edges fire only on a real
transition, deduped via the ``turn_active`` flag threaded through the loop.
:param step: One new (not-yet-seen) RPC step dict.
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id to mirror into.
:param cascade_id: agy cascade id (namespaces response/call ids).
:param allocator: Per-run tool-call id allocator (fallback ids only).
:param turn_active: Whether a turn is currently considered open on entry.
:returns: The updated ``turn_active`` flag after this step.
"""
if _is_user_turn_step(step) and not turn_active:
turn_active = True
await _post_event(client, session_id, _status_event(_STATUS_RUNNING))
for event in map_step_to_events(step, conversation_id=cascade_id, allocator=allocator):
await _post_event(client, session_id, event)
if _is_turn_close_step(step) and turn_active:
turn_active = False
# An ERROR planner closes the turn as FAILED, not a clean idle: a model /
# safety-policy / rate-limit / provider-overload error must surface as a
# failed turn (alongside the error item the mapper now emits), not a
# silent empty success that looks identical to a normal reply (#6).
close_status = _STATUS_FAILED if _step_is_error_planner(step) else _STATUS_IDLE
await _post_event(client, session_id, _status_event(close_status))
return turn_active
def _step_is_error_planner(step: dict[str, object]) -> bool:
"""
Return whether a step is a PLANNER_RESPONSE that ended in ERROR.
Used to close the turn as ``failed`` (not ``idle``) so a model/turn error is
not mistaken for a clean empty reply.
:param step: One RPC step dict.
:returns: ``True`` for an ERROR-status planner step.
"""
return step.get("type") == _TYPE_PLANNER_RESPONSE and step.get("status") == _STATUS_ERROR
def _maybe_handle_interaction(
step: dict[str, object],
*,
key: _StepKey,
cascade_id: str,
state: _ReaderState,
on_pending_interaction: OnPendingInteraction,
) -> None:
"""
Spawn the bridge for a WAITING step's interaction OFF the reader loop.
A non-WAITING step yields no interaction. A WAITING step is handed to the
callback only the first time its ``(trajectory_id, step_index)`` is seen as
pending, so a re-read of the same WAITING snapshot does not re-fire it.
The callback (the Task 8 bridge) runs as a tracked background task rather than
inline so the reader keeps streaming/mirroring while a human answers — the
elicitation long-poll can last up to a day, and an inline ``await`` here would
freeze the whole stream/poll loop for that duration (no deltas, no tool-output,
no status edges, and the idle HTTP stream could be severed).
SINGLE-IN-FLIGHT GUARD: at most one interaction task runs at a time. agy
re-issues a timed-out WAITING step at a HIGHER ``step_index``; the in-flight
``bridge_interaction`` already owns those retries via its own freshest-WAITING
re-read, so spawning a second task for a retry step would surface a duplicate
elicitation and a competing delivery. Subsequent WAITING steps are skipped
while a task is active; its done-callback then clears the slot AND re-scans the
freshest steps (:func:`_resurface_pending_interaction`) so a genuinely-NEW gate
deferred during that window — e.g. the next segment of a chained ``a && b``
command, each gated separately — is surfaced even when no further stream frame
will carry it (agy stays parked on that gate, emitting none) (#1472).
The callback gets the SAME ``cascade_id`` + ``port`` (from ``state``) the
reader discovered, so the bridge targets agy's live conversation without
re-discovering (which could bind a recycled/foreign port).
:param step: One new RPC step dict.
:param key: The step's identity key (already computed by the caller).
:param cascade_id: agy cascade id (equal to the conversation id) bound here.
:param state: Per-run reader state — ``interacted`` (dedup) and the single
``interaction_task`` slot are mutated here; ``port`` is read.
:param on_pending_interaction: Async callback for a distinct interaction.
:returns: None.
"""
pending = pending_interaction(step)
if pending is None:
return
if key in state.interacted:
return
active = state.interaction_task
if active is not None and not active.done():
# An interaction is already being handled off-loop; its bridge owns agy's
# WAITING-timeout retries, so don't double-fire on the retry step.
return
state.interacted.add(key)
# Record the deterministic elicitation id this WAITING step surfaces, so that
# if the step later leaves WAITING out-of-band (answered in the agy TUI, or
# agy timed it out) :func:`_maybe_withdraw_interaction` can WITHDRAW the parked
# web card (#1200, direction 2). ``agy_elicitation_id`` is lazily imported —
# like ``bridge_interaction`` — so the reader stays importable from the
# lightweight CLI process without eagerly pulling the server-route stack.
from omnigent.antigravity_native_interactions import agy_elicitation_id
state.surfaced_elicitations[key] = agy_elicitation_id(
cascade_id, pending["trajectory_id"], pending["step_index"]
)
async def _run_bridge() -> None:
await on_pending_interaction(cascade_id, state.port, pending)
def _clear_rescan(done: asyncio.Task[None]) -> None:
state.interaction_rescans.discard(done)
if not done.cancelled():
exc = done.exception()
if exc is not None:
_logger.warning(
"agy interaction re-scan task failed (cascade=%s): %r",
cascade_id,
exc,
)
def _clear_slot(completed: asyncio.Task[None]) -> None:
if state.interaction_task is completed:
state.interaction_task = None
if completed.cancelled():
# Reader teardown cancelled the bridge — the run is ending, so do NOT
# spawn a re-scan (teardown drains these tasks; a fresh one would race it).
return
exc = completed.exception()
if exc is not None:
_logger.warning(
"agy interaction bridge task failed (cascade=%s): %r",
cascade_id,
exc,
)
# Re-scan for a WAITING gate the single-in-flight guard deferred while this
# bridge ran (e.g. the next segment of a chained ``a && b`` command). agy
# emits no frame while parked on that gate, so without the re-scan it hangs.
# ``state.interacted`` makes already-surfaced steps no-ops.
rescan = asyncio.create_task(
_resurface_pending_interaction(
cascade_id=cascade_id,
state=state,
on_pending_interaction=on_pending_interaction,
),
name="antigravity-interaction-rescan",
)
state.interaction_rescans.add(rescan)
rescan.add_done_callback(_clear_rescan)
task = asyncio.create_task(_run_bridge(), name="antigravity-interaction-bridge")
state.interaction_task = task
task.add_done_callback(_clear_slot)
async def _resurface_pending_interaction(
*,
cascade_id: str,
state: _ReaderState,
on_pending_interaction: OnPendingInteraction,
) -> None:
"""
Re-surface a WAITING interaction the single-in-flight guard deferred.
Scheduled by ``_clear_slot`` after a bridge finishes. Re-reads the freshest
trajectory snapshot and re-dispatches every step through
:func:`_maybe_handle_interaction`; ``state.interacted`` makes already-surfaced
steps no-ops, so only the deferred gate fires. That gate spawns the next bridge,
whose clear re-scans again, draining a chain of sequential gates one at a time.
The snapshot read is retried up to :data:`_INTERACTION_RESCAN_POLL_ATTEMPTS` times
because this is the sole backstop on the healthy-stream path (agy emits no frame
while parked on the deferred gate; the poll loop is only the failure fallback).
:param cascade_id: agy cascade id (equal to the conversation id).
:param state: Per-run reader state.
:param on_pending_interaction: Async callback for a distinct interaction.
"""
steps: list[dict[str, object]] | None = None
for attempt in range(_INTERACTION_RESCAN_POLL_ATTEMPTS):
try:
steps = await asyncio.to_thread(get_trajectory_steps, state.port, cascade_id)
break
except (httpx.HTTPError, ValueError) as exc:
last = attempt == _INTERACTION_RESCAN_POLL_ATTEMPTS - 1
_logger.warning(
"agy interaction re-scan poll failed (cascade=%s, port=%s, attempt=%d/%d)%s: %r",
cascade_id,
state.port,
attempt + 1,
_INTERACTION_RESCAN_POLL_ATTEMPTS,
"; giving up — the poll fallback or a later frame must catch the deferred gate"
if last
else "; retrying",
exc,
)
if last:
return
await _sleep(_INTERACTION_RESCAN_POLL_BACKOFF_S)
if steps is None: # pragma: no cover - the loop returns on the last failure
return
for step in steps:
_maybe_handle_interaction(
step,
key=_step_key(step),
cascade_id=cascade_id,
state=state,
on_pending_interaction=on_pending_interaction,
)
async def _maybe_withdraw_interaction(
step: dict[str, object],
*,
key: _StepKey,
client: httpx.AsyncClient,
session_id: str,
state: _ReaderState,
) -> None:
"""
Withdraw a surfaced elicitation whose WAITING step left WAITING (#1200, dir 2).
The inverse of :func:`_maybe_handle_interaction`. The reader publishes an
elicitation when it first sees a WAITING permission/ask_question step; but a
step can stop being WAITING without the WEB card being answered — the user
types the answer directly in the agy TUI pane, or agy times out / auto-resolves
the interaction. Without this, the parked web card LINGERS forever, showing
"Respond to the pending request above to continue" while the agent has already
moved on.
So: when a step the reader previously surfaced is, on a later poll/frame, NO
LONGER WAITING, POST ``external_elicitation_resolved`` for its elicitation id.
Server-side this sets the parked future's ``resolved_elsewhere`` flag, which
(a) clears the web card, and (b) makes any in-flight ``request_elicitation``
long-poll return ``None`` — so a racing :func:`bridge_interaction` does NOT
then deliver a stale verdict (the await short-circuits cleanly). Mirrors
cursor-native's ``_post_external_elicitation_resolved``.
Idempotency / no double-resolve: the entry is popped from
``surfaced_elicitations`` so the withdraw posts AT MOST ONCE per step. This is
safe even when the step left WAITING because the WEB verdict resolved it (the
bridge delivered): the server already consumed that parked future, so the
withdraw finds none and merely tombstones the id (harmless) — it can never
re-deliver, because the bridge's ``request_elicitation`` already returned the
real verdict and the elicitation id is unique per ``step_index``.
:param step: One RPC step dict (any status).
:param key: The step's identity key (already computed by the caller).
:param client: HTTP client for the Omnigent event POST.
:param session_id: Omnigent conversation id whose card to withdraw.
:param state: Per-run reader state — ``surfaced_elicitations`` is read/mutated.
:returns: None.
"""
elicitation_id = state.surfaced_elicitations.get(key)
if elicitation_id is None:
return
# Still WAITING → the interaction is live; nothing to withdraw yet.
if pending_interaction(step) is not None:
return
# Left WAITING out-of-band (or the web verdict already advanced it): withdraw
# the card exactly once.
state.surfaced_elicitations.pop(key, None)
await _post_external_elicitation_resolved(client, session_id, elicitation_id)
async def _post_external_elicitation_resolved(
client: httpx.AsyncClient,
session_id: str,
elicitation_id: str,
) -> None:
"""
Tell the server a surfaced agy elicitation was resolved/withdrawn out-of-band.
POSTs ``external_elicitation_resolved`` so the parked web card clears and any
in-flight ``request_elicitation`` long-poll returns ``None``. Best-effort: a
transport error or a non-2xx is logged, never raised — a failed withdraw must
not crash the reader loop (the next signal, or agy's own timeout, recovers).
Mirrors cursor-native's ``_post_external_elicitation_resolved``.
:param client: HTTP client for Omnigent event posts (the reader's client).
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param elicitation_id: The deterministic agy elicitation id to withdraw.
:returns: None.
"""
try:
response = await client.post(
f"/v1/sessions/{url_component(session_id)}/events",
json={
"type": _EXTERNAL_ELICITATION_RESOLVED,
"data": {"elicitation_id": elicitation_id},
},
)
except httpx.HTTPError:
_logger.warning(
"agy external_elicitation_resolved POST failed; the web card may linger "
"until agy's own timeout: session=%s elicitation_id=%s",
session_id,
elicitation_id,
exc_info=True,
)
return
if response.status_code >= 400:
_logger.warning(
"agy external_elicitation_resolved rejected: session=%s elicitation_id=%s "
"status=%s body=%s",
session_id,
elicitation_id,
response.status_code,
response.text[:512],
)
return
_logger.info(
"agy withdrew a surfaced elicitation resolved out-of-band (TUI answer / "
"timeout): session=%s elicitation_id=%s",
session_id,
elicitation_id,
)
async def _maybe_emit_session_usage(
step: dict[str, object],
*,
client: httpx.AsyncClient,
session_id: str,
state: _ReaderState,
) -> None:
"""
Emit ``external_session_usage`` when a PLANNER_RESPONSE DONE carries usage.
The server treats ``cumulative_input_tokens`` / ``cumulative_output_tokens``
/ ``cumulative_cache_read_input_tokens`` as SET semantics — the posted value
IS the new session total, and the server prices the per-turn delta as
(new old). agy's ``step.metadata.modelUsage`` fields are PER-MODEL-CALL
(not cumulative), so we accumulate them in ``state`` and emit the running
totals. This matches codex's behaviour (``tokenUsage.total`` is a cumulative
thread-wide counter, forwarded as SET values by
:class:`~omnigent.codex_native_forwarder._SessionUsageCoalescer`).
NOTE: ``state.cumulative_*`` accumulators are zeroed at reader-run start; a
T-G /clear rotation rebinds with a fresh ``_ReaderState`` (``supervise_reader``
is re-entered), so the new session's cost badge starts from 0 automatically.
De-dup is via ``state.seen``: this function is only called inside the
``key not in state.seen`` branch of :func:`_process_committed_step`, so
a replay of the same DONE step (already in ``seen``) never reaches here.
:param step: One RPC step dict (must be a PLANNER_RESPONSE DONE).
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id.
:param state: Per-run trackers (accumulators + port + model_catalog).
:returns: None.
"""
per_call = _model_usage_from_step(step)
if not per_call:
return
def _int_field(d: dict[str, object], key: str) -> int:
"""Return ``d[key]`` as an int, or 0 when absent / not an int."""
val = d.get(key, 0)
return val if isinstance(val, int) else 0
# Accumulate per-call values into running session totals (SET semantics).
state.cumulative_input_tokens += _int_field(per_call, "cumulative_input_tokens")
state.cumulative_output_tokens += _int_field(per_call, "cumulative_output_tokens")
state.cumulative_cache_read_input_tokens += _int_field(
per_call, "cumulative_cache_read_input_tokens"
)
# Resolve the raw model enum to a displayName if the catalog is available.
model_enum = per_call.get("model")
display_name: str | None = None
if isinstance(model_enum, str) and model_enum:
catalog = await _ensure_catalog(state)
display_name = _resolve_display_name(model_enum, catalog)
# Build the cumulative payload (SET-semantics running totals).
payload: dict[str, object] = {}
if state.cumulative_input_tokens > 0:
payload["cumulative_input_tokens"] = state.cumulative_input_tokens
if state.cumulative_output_tokens > 0:
payload["cumulative_output_tokens"] = state.cumulative_output_tokens
if state.cumulative_cache_read_input_tokens > 0:
payload["cumulative_cache_read_input_tokens"] = state.cumulative_cache_read_input_tokens
if display_name is not None:
payload["model"] = display_name
if not payload:
return
step_idx = _step_index(step) or 0
await _post_event(
client,
session_id,
OutboundEvent(
event_type=_EXTERNAL_SESSION_USAGE,
data=payload,
step_index=step_idx,
),
)
async def _maybe_emit_model_change(
step: dict[str, object],
*,
client: httpx.AsyncClient,
session_id: str,
state: _ReaderState,
) -> None:
"""
Emit ``external_model_change`` when a USER_INPUT step carries a new model enum.
Tracks the per-run ``state.posted_model_enum`` baseline; emits only when the
turn's requested model differs from the last-emitted enum (design §10.4).
Effort is encoded in the model enum (no separate field), so one change event
covers both. The catalog is fetched once per run and cached in ``state``.
De-dup: this function is only called inside the ``key not in state.seen``
branch, so a replayed USER_INPUT (already in ``seen``) never reaches here.
The enum-vs-posted_enum comparison further deduplicates same-model turns.
:param step: One RPC step dict (must be a USER_INPUT).
:param client: HTTP client for Omnigent event posts.
:param session_id: Omnigent conversation id.
:param state: Per-run trackers (posted_model_enum + model_catalog + port).
:returns: None.
"""
model_enum = _requested_model_enum_from_step(step)
if model_enum is None:
return
if model_enum == state.posted_model_enum:
return
catalog = await _ensure_catalog(state)
display_name = _resolve_display_name(model_enum, catalog)
step_idx = _step_index(step) or 0
await _post_event(
client,
session_id,
OutboundEvent(
event_type=_EXTERNAL_MODEL_CHANGE,
data={"model": display_name},
step_index=step_idx,
),
)
state.posted_model_enum = model_enum
async def _ensure_catalog(state: _ReaderState) -> dict[str, object]:
"""
Return the cached ``GetAvailableModels`` catalog, fetching it when needed.
Fetched at most once per reader run; stored in ``state.model_catalog``.
Falls back to an empty dict on error so a catalog failure never kills the
telemetry path (the display-name resolver falls back to the raw enum).
:param state: Per-run shared trackers.
:returns: The catalog dict (possibly empty on fetch failure).
"""
if state.model_catalog is not None:
return state.model_catalog
try:
catalog = await asyncio.to_thread(get_available_models, state.port)
except Exception:
_logger.warning(
"agy RPC reader: GetAvailableModels failed; "
"model display names will fall back to raw enums",
exc_info=True,
)
catalog = {}
state.model_catalog = catalog
return catalog
# ── Shared reader wiring (elicitation bridge + supervise_reader spawn) ────────
#
# The runner's host-spawned web path and the CLI's ``omnigent antigravity``
# attach fallback both run the SAME thing once agy is live: an Omnigent HTTP
# client, a ``supervise_reader`` loop, and an ``on_pending_interaction`` callback
# wired to the Task 8 interaction bridge (real-time elicitation over the Task 9
# hook + RPC step reads + the default deliver). This wiring lives here so the two
# callers share one definition instead of duplicating the bridge/elicitation
# plumbing. (The retired transcript forwarder used a post-hoc policy audit instead
# of real-time elicitation; that audit is gone — agy exposes no firing PreToolUse
# hook, so a tool cannot be blocked before it runs, and the live elicitation card
# is the honest enforcement surface.)
async def _agy_elicitation_retry_sleep(seconds: float) -> None:
"""
Indirection over :func:`asyncio.sleep` for the agy elicitation re-POST
backoff, so tests can stub it without clobbering the process-global
``asyncio.sleep``. Mirrors
:func:`omnigent.codex_native_forwarder._elicitation_retry_sleep`.
:param seconds: Seconds to sleep, e.g. ``1.0``.
:returns: None.
"""
await asyncio.sleep(seconds)
async def _post_agy_elicitation_request(
client: httpx.AsyncClient,
session_id: str,
*,
elicitation_id: str,
params: ElicitationRequestParams,
) -> httpx.Response | None:
"""
POST one agy elicitation to the Omnigent hook, re-POSTing across severed
long-polls.
Mirrors :func:`omnigent.codex_native_forwarder._post_codex_elicitation_request`:
the hook is a long-poll request/reply that blocks on a human, so a single
failed POST must not abandon the prompt. The elicitation id is deterministic
per ``(cascade_id, trajectory_id, step_index)`` (built by
:func:`omnigent.antigravity_native_interactions.agy_elicitation_id`), so a
re-POST of the same body re-parks the SAME elicitation server-side (keeping
the approval card alive) and can collect a verdict that landed between
attempts. Transport errors and 5xx responses are retried within the
``_AGY_ELICITATION_REQUEST_TIMEOUT_SECONDS`` budget; 2xx and 4xx are final.
:param client: HTTP client for Omnigent hook posts (the reader's client).
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param elicitation_id: Deterministic agy elicitation id, e.g.
``"elicit_agy_<digest>"``.
:param params: The web-renderable elicitation params.
:returns: The final hook response, or ``None`` when the retry budget ran
out — the caller surfaces no verdict (agy's own WAITING timeout reclaims
the step).
"""
url = f"/v1/sessions/{url_component(session_id)}/hooks/antigravity-elicitation-request"
body: dict[str, object] = {
"elicitation_id": elicitation_id,
"params": params.model_dump(),
}
timeout = httpx.Timeout(
_AGY_ELICITATION_REQUEST_TIMEOUT_SECONDS,
connect=_AGY_ELICITATION_CONNECT_TIMEOUT_SECONDS,
)
loop = asyncio.get_running_loop()
deadline = loop.time() + _AGY_ELICITATION_REQUEST_TIMEOUT_SECONDS
backoff_s = _AGY_ELICITATION_RETRY_INITIAL_BACKOFF_SECONDS
while True:
response: httpx.Response | None = None
try:
response = await client.post(url, json=body, timeout=timeout)
except httpx.HTTPError:
_logger.warning(
"Antigravity elicitation hook POST failed; retrying: elicitation_id=%s",
elicitation_id,
exc_info=True,
)
if response is not None and response.status_code < 500:
return response
if response is not None:
# 5xx = proxy gateway error on a severed long-poll, or a restarting
# server — the verdict may still be pending, so re-POST.
_logger.warning(
"Antigravity elicitation hook returned %s; retrying: elicitation_id=%s",
response.status_code,
elicitation_id,
)
if loop.time() + backoff_s >= deadline:
_logger.warning(
"Antigravity elicitation hook retry budget exhausted: elicitation_id=%s",
elicitation_id,
)
return None
await _agy_elicitation_retry_sleep(backoff_s)
backoff_s = min(backoff_s * 2, _AGY_ELICITATION_RETRY_MAX_BACKOFF_SECONDS)
async def _request_agy_elicitation(
client: httpx.AsyncClient,
session_id: str,
*,
elicitation_id: str,
params: ElicitationRequestParams,
) -> ElicitationResult | None:
"""
Production ``request_elicitation`` for the agy interaction bridge.
Publishes the elicitation under ``elicitation_id`` via the Task 9 hook
(``POST /v1/sessions/{id}/hooks/antigravity-elicitation-request``) and
long-poll-awaits the human verdict. Mirrors codex's
``_codex_elicitation_hook_result`` body handling:
* a 2xx with a body → parse it as :class:`ElicitationResult`;
* a 2xx with an EMPTY body → ``None`` (the server timed out / saw the
upstream disconnect — agy's own WAITING timeout reclaims the step);
* a 4xx, an exhausted-retry ``None`` response, or a non-JSON / non-object
body → ``None`` (logged; no verdict delivered).
:param client: HTTP client for Omnigent hook posts (the reader's client).
:param session_id: Omnigent conversation id, e.g. ``"conv_abc123"``.
:param elicitation_id: Deterministic agy elicitation id.
:param params: The web-renderable elicitation params.
:returns: The parsed :class:`ElicitationResult`, or ``None`` on
timeout / rejection / malformed body.
"""
response = await _post_agy_elicitation_request(
client,
session_id,
elicitation_id=elicitation_id,
params=params,
)
if response is None:
return None
if response.status_code >= 400:
_logger.warning(
"Antigravity elicitation hook rejected request (likely a misconfigured "
"elicitation hook — a 4xx is a client/config error, not transient): "
"status=%s elicitation_id=%s body=%s",
response.status_code,
elicitation_id,
response.text[:512],
)
return None
if not response.content:
_logger.info(
"Antigravity elicitation hook returned empty body; no verdict: elicitation_id=%s",
elicitation_id,
)
return None
try:
return ElicitationResult.model_validate(response.json())
except ValueError:
_logger.warning(
"Antigravity elicitation hook returned a non-ElicitationResult body: "
"elicitation_id=%s body=%s",
elicitation_id,
response.text[:512],
)
return None
# ── Task T-G: /clear-rotation session rotation ───────────────────────────────
#
# A TUI ``/clear`` mints a NEW agy root cascade ON THE SAME live agy process and
# leaves the bound one idle. The reader detects it via ``GetAllCascadeTrajectories``
# (see :func:`_detect_rotated_cascade`) and then must move Omnigent ownership onto a
# fresh conversation bound to the NEW cascade — otherwise web turns keep targeting
# the old (now-dead) conversation and streaming appears to end. This mirrors the
# claude forwarder's ``_create_clear_replacement_session`` session-rotation
# sequence: agy, like claude, is a SINGLE long-lived process hosting many cascades,
# so the rotation TRANSFERS the existing terminal onto the replacement session
# (it does NOT re-spawn agy) and inherits the OLD session's bridge-id label (so the
# replacement resolves to the SAME ``bridge_dir`` the reader is already using — agy's
# bridge_dir is keyed off the launcher's bridge-id, not the session id), then
# rewrites bridge state with the new conversation id so the reader rebinds.
#
# THE LOOP BUG this fixes (found by live e2e): the prior implementation also PATCHed
# the replacement session's ``external_session_id=new_cascade``. But POST
# /v1/sessions for an antigravity-native session makes the runner auto-cold-start a
# brand-new agy (``_auto_create_antigravity_terminal`` fired for EVERY such session),
# which minted its OWN cascade AND set the new session's external_session_id; the
# rotation's external_session_id PATCH then hit that already-set, set-once-immutable
# field → 400 → rotation aborted, but the cold-start had already rebound the reader
# to its fresh cascade → the detector re-fired → an infinite session-spawn loop. The
# fix: drop the external_session_id PATCH entirely (claude never does it) AND gate
# the runner's antigravity auto-create on an inbound-transfer check (mirroring
# claude's ``_terminal_inbound`` guard) so the cold-start is skipped for a rotation
# target and the existing agy is transferred instead.
# Deterministic agy terminal resource id (matches
# ``antigravity_native._TERMINAL_NAME`` / ``_TERMINAL_SESSION_KEY``). Single
# terminal per agy session ("main"), transferred old→new on rotation so the live
# tmux pane keeps running under the new conversation. Built from the lightweight
# ``session_resources`` helper (as the codex forwarder does) rather than importing
# the heavy ``antigravity_native`` runner module into the reader.
_AGY_TERMINAL_RESOURCE_ID = terminal_resource_id("antigravity", "main")
async def _fetch_session_snapshot(client: httpx.AsyncClient, session_id: str) -> dict[str, object]:
"""
Fetch an Omnigent session snapshot for agy ``/clear`` rotation.
Mirrors :func:`omnigent.codex_native_forwarder._fetch_session_snapshot`: the
rotation needs the old session's ``agent_id`` (required to create the
replacement), ``runner_id`` (to re-bind it to the same runner), and ``labels``
(to inherit the bridge-id so the new session resolves to the same bridge_dir).
:param client: Omnigent HTTP client (the reader's client).
:param session_id: Omnigent session id, e.g. ``"conv_abc123"``.
:returns: Decoded JSON session snapshot.
:raises httpx.HTTPStatusError: If Omnigent rejects the request.
:raises RuntimeError: If the response body is not a JSON object.
"""
resp = await client.get(f"/v1/sessions/{url_component(session_id)}")
resp.raise_for_status()
payload = resp.json()
if not isinstance(payload, dict):
raise RuntimeError("Antigravity session snapshot response was not an object")
return payload
def _adopt_cascade_in_place(bridge_dir: Path, session_id: str, new_cascade_id: str) -> None:
"""
Rebind the reader to a newly-minted cascade WITHOUT forking the session.
The cold-start ``StartCascade`` cascade is a headless placeholder the agy TUI
never displays; the agy TUI mints its OWN cascade on the first typed turn
(web turns are typed into the TUI — see
:meth:`omnigent.inner.antigravity_native_executor.AntigravityNativeExecutor._deliver`).
The first transition off a never-used bound cascade is therefore the
conversation STARTING, not a ``/clear`` — so adopt the new cascade in the
SAME Omnigent session by rewriting bridge state's conversation id (the reader
rebinds to it on the next :func:`supervise_reader` loop). No new session, no
terminal transfer — the user's current session simply starts mirroring the
turn they sent, instead of being stranded empty while a forked session fills.
A genuine ``/clear`` (the bound cascade HAD committed turns) still forks a
replacement session via :func:`_rotate_session_for_cascade`.
:param bridge_dir: The agy bridge directory the reader is using.
:param session_id: The Omnigent session to keep mirroring into.
:param new_cascade_id: agy's freshly TUI-minted cascade/conversation id.
"""
write_bridge_state(
bridge_dir,
AntigravityNativeBridgeState(
session_id=session_id,
conversation_id=new_cascade_id,
),
)
_logger.info(
"agy reader adopted the first TUI-minted cascade in place (no fork): "
"session=%s new_cascade=%s",
session_id,
new_cascade_id,
)
async def _record_external_session_id(
client: httpx.AsyncClient, session_id: str, cascade_id: str
) -> None:
"""
Best-effort record the agy cascade id as the session's ``external_session_id``.
So a later ``omnigent antigravity --resume`` / omnigent server restart
relaunches agy with ``--conversation <cascade_id>`` and continues THIS
conversation. Called on first-cascade adoption with the TUI-minted cascade.
The cold-start no longer records its headless ``StartCascade`` phantom (which
the agy TUI never displays) — that was the data-loss bug: a resume launched
``--conversation <phantom>`` and loaded an EMPTY conversation, silently losing
the entire chat. ``external_session_id`` is set-once in the store; an overwrite
attempt (e.g. a second adoption) returns 400 and is logged, not raised — the
chat mirror does not depend on it.
:param client: The reader's Omnigent HTTP client.
:param session_id: Omnigent conversation id to record onto.
:param cascade_id: agy's adopted (TUI-minted) cascade/conversation id.
"""
try:
resp = await client.patch(
f"/v1/sessions/{url_component(session_id)}",
json={"external_session_id": cascade_id},
)
except httpx.HTTPError:
_logger.warning(
"agy adopt: failed to record external_session_id=%s on session %s; "
"a later --resume will cold-start fresh.",
cascade_id,
session_id,
exc_info=True,
)
return
if resp.status_code >= 400:
_logger.info(
"agy adopt: external_session_id PATCH returned %s (likely already set); "
"session=%s cascade=%s",
resp.status_code,
session_id,
cascade_id,
)
async def _rotate_session_for_cascade(
*,
client: httpx.AsyncClient,
old_session_id: str,
new_cascade_id: str,
bridge_dir: Path,
) -> str | None:
"""
Create + activate a replacement Omnigent session bound to a new agy cascade.
The Task T-G rotation effect: after a TUI ``/clear`` mints ``new_cascade_id`` on
the SAME live agy process, this moves Omnigent ownership onto a fresh
conversation bound to that cascade, MIRRORING claude's
``_create_clear_replacement_session`` (verified against
``omnigent/claude_native_forwarder.py``). agy — like claude — is ONE long-lived
process hosting multiple cascades; a ``/clear`` mints a new cascade on that same
process, so the rotation TRANSFERS the existing terminal onto the new session
(it does NOT re-spawn agy) and rewrites bridge state so the reader rebinds to the
new cascade on the SAME process:
1. GET the old session snapshot (``agent_id`` / ``runner_id`` / ``labels``).
2. POST ``/v1/sessions`` with the old ``agent_id`` + inherited ``labels`` — the
labels carry agy's bridge-id (:data:`ANTIGRAVITY_NATIVE_BRIDGE_ID_LABEL_KEY`),
so the NEW session resolves to the SAME ``bridge_dir`` the reader uses (agy's
bridge_dir is keyed off the launcher's bridge-id, NOT the session id — so we
inherit the old label, exactly as claude carries its ``BRIDGE_ID_LABEL_KEY``).
3. PATCH the new session's ``runner_id`` to the old runner (so the same runner
owns it), when the old session had one.
4. POST the terminal ``/transfer`` to move the live agy tmux pane old→new (the
pane — the SAME agy process — keeps running under the new conversation).
NO ``external_session_id`` PATCH is made: unlike a resume launch, the new
cascade ``new_cascade_id`` is ALREADY live on the existing agy and reached via
the rewritten bridge state below, not via a later ``--resume``. (The old code
PATCHed it, which 400'd on the auto-cold-started session's already-set,
set-once-immutable field and looped the rotation — see the module header.)
5. Rewrite agy bridge state in ``bridge_dir`` with the new session id + new
conversation id (the reader re-reads this on rebind to bind the new cascade).
6. PATCH the old session's ``runner_id`` to ``""`` to release it (best-effort;
a failure is logged, not raised — the new session is already live).
Best-effort: ANY failure (snapshot, create, bind, transfer, state write) is
logged at WARNING and yields ``None``, and the caller keeps serving the OLD
binding rather than crashing or losing the reader. The bridge-state rewrite is
performed only AFTER the new session is created and bound, so a mid-sequence
failure never leaves bridge state pointing at a session that does not exist —
and, mirroring claude, it lands AFTER the transfer so the runner's auto-create
guard still sees the OLD session owning the terminal while the new session binds
(see :func:`omnigent.runner.app._antigravity_native_terminal_arrives_via_transfer`).
:param client: Omnigent HTTP client (the reader's client).
:param old_session_id: The Omnigent session being rotated away from.
:param new_cascade_id: agy's freshly ``/clear``-minted cascade/conversation id.
:param bridge_dir: The agy bridge directory the reader is using (rewritten in
place so the new session shares it).
:returns: The new Omnigent session id on success, or ``None`` on any failure
(the caller then stays on the old binding).
"""
try:
old = await _fetch_session_snapshot(client, old_session_id)
agent_id = old.get("agent_id")
if not isinstance(agent_id, str) or not agent_id:
raise RuntimeError(f"session {old_session_id!r} has no agent_id")
runner_id = old.get("runner_id")
raw_labels = old.get("labels")
labels = (
{str(key): str(value) for key, value in raw_labels.items()}
if isinstance(raw_labels, dict)
else {}
)
# Inherit the old session's bridge-id so the new session resolves to the
# SAME bridge_dir; only fall back to stamping the old session id when the
# label is somehow absent (keeps a deterministic, non-empty bridge id).
if ANTIGRAVITY_NATIVE_BRIDGE_ID_LABEL_KEY not in labels:
labels[ANTIGRAVITY_NATIVE_BRIDGE_ID_LABEL_KEY] = old_session_id
create_resp = await client.post(
"/v1/sessions",
json={"agent_id": agent_id, "labels": labels},
)
create_resp.raise_for_status()
created = create_resp.json()
new_session_id = created.get("id") if isinstance(created, dict) else None
if not isinstance(new_session_id, str) or not new_session_id:
raise RuntimeError("Antigravity session replacement response did not include id")
if isinstance(runner_id, str) and runner_id:
bind_resp = await client.patch(
f"/v1/sessions/{url_component(new_session_id)}",
json={"runner_id": runner_id},
)
bind_resp.raise_for_status()
transfer_resp = await client.post(
(
f"/v1/sessions/{url_component(old_session_id)}"
f"/resources/terminals/{url_component(_AGY_TERMINAL_RESOURCE_ID)}/transfer"
),
json={"target_session_id": new_session_id},
)
transfer_resp.raise_for_status()
except (httpx.HTTPError, RuntimeError, ValueError) as exc:
_logger.warning(
"agy /clear rotation failed to create the replacement session; staying on "
"the old binding (old_session=%s new_cascade=%s): %r",
old_session_id,
new_cascade_id,
exc,
)
return None
# The new session is live + bound; commit bridge state so the reader's rebind
# discovers the new cascade. Done last so a mid-sequence failure above never
# points bridge state at a half-created session.
write_bridge_state(
bridge_dir,
AntigravityNativeBridgeState(
session_id=new_session_id,
conversation_id=new_cascade_id,
),
)
# Release the old session's runner binding (best-effort): the new session is
# already serving, so a failure here is logged, not raised.
try:
clear_resp = await client.patch(
f"/v1/sessions/{url_component(old_session_id)}",
json={"runner_id": ""},
)
if clear_resp.status_code >= 400:
_logger.warning(
"agy /clear rotation: failed to release old runner binding; "
"old_session=%s new_session=%s status=%s body=%s",
old_session_id,
new_session_id,
clear_resp.status_code,
clear_resp.text,
)
except httpx.HTTPError:
_logger.warning(
"agy /clear rotation: error releasing old runner binding; "
"old_session=%s new_session=%s",
old_session_id,
new_session_id,
exc_info=True,
)
_logger.info(
"agy reader rotated Omnigent session after /clear: old_session=%s new_session=%s "
"new_cascade=%s",
old_session_id,
new_session_id,
new_cascade_id,
)
return new_session_id
async def run_reader_with_bridge(
*,
base_url: str,
headers: dict[str, str],
auth: httpx.Auth | None,
session_id: str,
bridge_dir: Path,
) -> None:
"""
Run the agy RPC streaming reader + interaction bridge for one session.
The single, shared read-path entry point used by BOTH host-spawned (runner)
and CLI-fallback launches. It owns the long-lived Omnigent HTTP client (the
reader takes a client but does NOT own its lifecycle) and runs
:func:`supervise_reader`, wiring its ``on_pending_interaction`` callback to the
Task 8 interaction bridge:
* ``request_elicitation`` → :func:`_request_agy_elicitation` (POSTs the Task 9
``antigravity-elicitation-request`` hook and long-poll-awaits the human),
mirroring codex's ``_handle_codex_elicitation_request``;
* ``get_steps`` → ``get_trajectory_steps(port, cascade_id)`` offloaded to a
worker thread (the RPC is synchronous);
* ``deliver`` → the bridge default (``handle_user_interaction`` in a thread).
The reader discovers the cascade id + connect-RPC port and hands BOTH to the
callback, so the bridge targets agy's live conversation without
re-discovering (which could bind a recycled/foreign port).
Task T-G ``/clear`` rotation: this LOOPS. :func:`supervise_reader` returns the
new cascade id when it detects a TUI ``/clear`` (via
:func:`_watch_for_rotation`); this then rotates the Omnigent session onto a
fresh conversation bound to the new cascade (:func:`_rotate_session_for_cascade`,
which rewrites bridge state in ``bridge_dir``) and re-enters
:func:`supervise_reader` — which rediscovers the new cascade from the same
``bridge_dir`` with a fresh :class:`_ReaderState`. If the session rotation
FAILS, the old binding is kept (the failed cascade is added to ``skip``) so the
reader keeps serving rather than being lost; if rotation succeeds, ``session_id``
advances so the interaction-bridge elicitation hook targets the new session.
The loop ends only when ``supervise_reader`` returns ``None`` (the reader was
cancelled / its bounded ``stop`` fired).
:param base_url: Omnigent server base URL, e.g. ``"http://127.0.0.1:6767"``.
:param headers: Auth headers for the Omnigent client (best-effort static
bearer; ``auth`` carries any refresh-capable flow).
:param auth: Refresh-capable httpx auth flow, or ``None`` when unauthenticated
(the local-server runner path and the CLI attach fallback, which have no
token to refresh — the bearer in ``headers``, if any, is used as-is).
:param session_id: Omnigent conversation id to mirror into, e.g.
``"conv_abc123"``.
:param bridge_dir: Native Antigravity bridge directory for this session.
:returns: None. Runs until cancelled.
"""
# Lazy import: the interaction bridge pulls server-route handlers; keeping it
# out of module import keeps the reader importable from the lightweight CLI
# process without eagerly loading the server stack.
from omnigent.antigravity_native_interactions import bridge_interaction
# Mutable current session id: rotation advances it, and the elicitation hook
# closure below reads it through this holder so a post-rotation interaction
# POSTs to the NEW session (a closure over the bare ``session_id`` argument
# would keep targeting the rotated-away session).
current = {"session_id": session_id}
async with httpx.AsyncClient(
base_url=base_url,
headers=headers,
auth=auth,
timeout=httpx.Timeout(_READER_CLIENT_TIMEOUT_SECONDS),
) as client:
async def _on_pending(cascade_id: str, port: int, pending: PendingInteraction) -> None:
"""Drive the interaction bridge for one WAITING interaction."""
async def _request_elicitation(
eid: str, params: ElicitationRequestParams
) -> ElicitationResult | None:
return await _request_agy_elicitation(
client, current["session_id"], elicitation_id=eid, params=params
)
async def _get_steps() -> list[dict[str, object]]:
return await asyncio.to_thread(get_trajectory_steps, port, cascade_id)
await bridge_interaction(
cascade_id,
pending,
port=port,
get_steps=_get_steps,
request_elicitation=_request_elicitation,
)
# Cascades a prior rotation attempt failed to bind — the detector skips them
# so a persistent rotation failure does not hot-loop detect→fail→detect.
failed_rotations: set[str] = set()
while True:
committed_steps_out: list[int] = []
new_cascade_id = await supervise_reader(
bridge_dir,
current["session_id"],
client=client,
on_pending_interaction=_on_pending,
skip_cascade_ids=frozenset(failed_rotations),
committed_steps_out=committed_steps_out,
)
if new_cascade_id is None:
# The reader body ended on its own (cancelled / bounded stop): done.
return
bound_committed_turns = committed_steps_out[0] if committed_steps_out else 0
if bound_committed_turns == 0:
# FIRST-CASCADE ADOPTION (not a /clear): the bound cascade was the
# cold-start StartCascade phantom (or any binding that never
# committed a turn), and the agy TUI minted its OWN cascade on the
# first typed turn. Adopt that cascade in the SAME Omnigent session
# so the user's current session starts mirroring — forking here
# would strand the current session empty while the turn filled a
# new one (the web/mobile UI watches the current session). The agy
# TUI and the web mirror then share ONE cascade (#1156/#1158).
_adopt_cascade_in_place(bridge_dir, current["session_id"], new_cascade_id)
# Record the adopted (TUI-minted) cascade as the session's
# external_session_id so a later --resume / omnigent server
# restart relaunches agy with --conversation <this cascade> and
# continues THIS conversation — NOT the headless cold-start
# StartCascade phantom the cold-start used to record, which a
# resume loaded as an EMPTY conversation (the whole chat silently
# vanished). external_session_id is set-once; the cold-start no
# longer records the phantom, so this first adoption sets it.
await _record_external_session_id(client, current["session_id"], new_cascade_id)
continue
# GENUINE /clear (the bound cascade HAD turns): move Omnigent ownership
# onto a fresh conversation bound to the new cascade, then rebind by
# re-entering supervise_reader (which rediscovers from bridge state).
new_session_id = await _rotate_session_for_cascade(
client=client,
old_session_id=current["session_id"],
new_cascade_id=new_cascade_id,
bridge_dir=bridge_dir,
)
if new_session_id is None:
# Rotation failed — keep serving the old binding, but never re-fire
# on this same cascade (it would just fail again every few seconds).
failed_rotations.add(new_cascade_id)
continue
current["session_id"] = new_session_id