Files
2026-07-13 13:12:00 +08:00

1606 lines
74 KiB
Python

"""connect-RPC client for a running native Antigravity (agy) process.
This is the **discovery / read / control helper** for the native agy paths: the
RPC read driver (:mod:`omnigent.antigravity_native_reader`, which polls/streams
trajectory steps to mirror agy's conversation) and the native executor
(:mod:`omnigent.inner.antigravity_native_executor`, which delivers web/mobile
turns). It provides, over agy's connect-RPC surface:
* **conversation-ownership discovery** (:func:`resolve_language_server_port` /
:func:`get_trajectory_steps`) — the read side the reader binds to;
* **turn delivery** via ``SendUserCascadeMessage``
(:func:`send_user_cascade_message`), which agy records as a real ``USER_INPUT``
turn — used by the executor instead of agy's ``SendAgentMessage`` RPC (which
agy records as a ``SYSTEM_MESSAGE``, "not actually sent by the user", and would
never mirror as a user turn) and instead of typing into the TUI over tmux;
* **turn interrupt** via ``CancelCascadeSteps`` (:func:`cancel_cascade_steps`),
used by the executor's interrupt path.
How agy exposes a control surface (verified end-to-end; see
``docs/claude/antigravity-sidecar-spike.md``):
* A running ``agy`` process opens **two** ephemeral ``127.0.0.1`` TCP LISTEN
ports. The **lower** one is a TLS HTTP/2 **connect-RPC** server hosting
``exa.language_server_pb.LanguageServerService``; the higher one is a plain
HTTP surface that 404s. The TLS cert is self-signed, so the client uses
``verify=False``.
* The ports are ephemeral and not configurable
(``ANTIGRAVITY_SIDECAR_WEB_PORT`` is a sidecar-plugin no-op), so they are
discovered from the loopback socket table — ``lsof`` per agy pid, falling back
to ``/proc/net/tcp`` on hosts where ``lsof`` cannot attribute the socket.
* Ownership probe: ``POST .../GetConversationMetadata`` with REQUEST body
``{"conversationId": "<id>"}`` returns HTTP 200 whose RESPONSE echoes that id at
``metadata.rootConversationId`` for a hosted conversation, and HTTP 500
("trajectory not found") for an unknown one — so a caller can confirm which
live agy owns a conversation before binding its port. (Request and response
shapes differ: the id is sent flat as ``conversationId`` and echoed nested
under ``metadata``.)
Port discovery is **port-first**: it enumerates candidate loopback connect-RPC
ports (see :func:`_candidate_agy_rpc_ports`) and, for each, checks whether its
server reports the target ``conversation_id`` via ``GetConversationMetadata`` —
so the right port is found even when several agy instances run. There is no pid
key: agy is launched under ``tmux_start_on_attach`` (CLI) and does not exist at
launch, and on some hosts agy's listening socket is owned by a backend that is
neither the agy pid nor ``lsof``-attributable. The conversation-ownership check
is what makes a discovered port trustworthy, rejecting a recycled/foreign port
that hosts a different agy.
Everything that touches the OS (``lsof``, process enumeration) or the network
(httpx) is funnelled through small module-level seams so the unit tests can mock
them without real subprocesses or sockets.
"""
from __future__ import annotations
import ipaddress
import json
import logging
import os
import struct
import subprocess
from collections.abc import AsyncIterator, Iterable
from pathlib import Path
from typing import NamedTuple
from urllib.parse import urlparse
import httpx
_logger = logging.getLogger(__name__)
# connect-RPC service + methods on agy's TLS port.
_LS_SERVICE = "exa.language_server_pb.LanguageServerService"
_METHOD_HEARTBEAT = "Heartbeat"
_METHOD_GET_CONVERSATION_METADATA = "GetConversationMetadata"
# Turn-cancel methods. The NAMES are verified present in the agy 1.0.8 binary
# (``strings`` → ``LanguageServerService/{CancelCascadeInvocation,
# CancelCascadeSteps,ForceStopCascadeTree}``), but their request CONTRACTS are
# NOT verified: the proto field tags show ``CancelCascadeInvocationRequest`` and
# friends key on an internal ``cascade_id`` / ``invocation_id`` (agy's per-turn
# identifiers) that the transcript forwarder does NOT have — it only knows the
# *conversation* id, not the live cascade/invocation id — and the stop semantics
# are unconfirmed on a live process. The best-effort interrupt
# (:func:`interrupt_turn`) is therefore wired OFF by default; see its TODO.
_METHOD_FORCE_STOP_CASCADE_TREE = "ForceStopCascadeTree"
_METHOD_GET_CASCADE_TRAJECTORY_STEPS = "GetCascadeTrajectorySteps"
_METHOD_CANCEL_CASCADE_STEPS = "CancelCascadeSteps"
_METHOD_HANDLE_CASCADE_USER_INTERACTION = "HandleCascadeUserInteraction"
_METHOD_SEND_USER_CASCADE_MESSAGE = "SendUserCascadeMessage"
_METHOD_START_CASCADE = "StartCascade"
_METHOD_GET_AVAILABLE_MODELS = "GetAvailableModels"
_METHOD_GET_ALL_CASCADE_TRAJECTORIES = "GetAllCascadeTrajectories"
_METHOD_STREAM_AGENT_STATE_UPDATES = "StreamAgentStateUpdates"
_LOOPBACK = "127.0.0.1"
# Hostnames that are unconditionally loopback. Any other host is checked
# numerically via :func:`ipaddress.ip_address` in :func:`_assert_loopback_url`.
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"})
# Timeout for the liveness/validation probes used during discovery (Heartbeat +
# GetConversationMetadata), kept tight so scanning several candidate ports stays
# fast.
_PROBE_TIMEOUT_S = 2.0
# Timeout for the FUNCTIONAL connect-RPCs (cold-start, turn delivery, interaction
# answer, step reads, cancel) — generous headroom over the tight discovery-probe
# budget. The functional calls hit a known, owned, loopback port, so the only
# reason one would exceed a couple of seconds is a momentarily busy agy; the tight
# ``_PROBE_TIMEOUT_S`` would then raise a transport ``TimeoutException`` that the
# caller does not retry — the interaction bridge only retries on "input not
# registered" (abandoning a human-answer delivery), and the cold-start does NOT
# retry ``StartCascade`` at all (a slow ack would leave the placeholder id
# deadlocked). This deadline still bounds a truly-hung agy so a functional call
# cannot block forever.
_RPC_CALL_TIMEOUT_S = 30.0
# Timeout policy for the persistent ``StreamAgentStateUpdates`` long-poll. The
# connect-stream stays open across the whole turn (and idles between turns), so
# the READ timeout is disabled (``None``) — a tight per-read deadline would abort
# the stream during normal think-time. The connect/write/pool deadlines stay
# short so a port that never accepts the connection fails fast (the reader is
# expected to reconnect / fall back to polling) rather than hanging the caller.
_STREAM_TIMEOUT_S = 10.0
_STREAM_TIMEOUT = httpx.Timeout(_STREAM_TIMEOUT_S, read=None)
# connect-protocol stream framing: each frame is ``[flag: 1B][len: 4B BE][payload]``.
# The 5-byte fixed header precedes a ``len``-byte payload. The flag is a bitfield:
# bit 0 (``0x01``) marks a COMPRESSED payload (agy sends uncompressed — a set bit
# means the JSON decode would be wrong, so the client raises); bit 1 (``0x02``)
# marks the end-of-stream TRAILER (stop iterating; the payload may carry trailer
# metadata or an error). ``flag == 0x00`` is a DATA message (payload is the JSON
# ``update`` object the generator yields).
_FRAME_HEADER_LEN = 5
_FRAME_FLAG_COMPRESSED = 0x01
_FRAME_FLAG_TRAILER = 0x02
# ``lsof`` flags: ``-a`` ANDs the filters (without it ``-p`` and ``-i`` are ORed
# and the pid filter is ignored), ``-nP`` skips name/port resolution (fast),
# ``-p`` restricts to the pid, ``-iTCP -sTCP:LISTEN`` selects TCP listeners.
_LSOF_TIMEOUT_S = 5.0
# Root of the Linux proc filesystem, scanned by ``_list_agy_pids_from_proc`` when
# ``pgrep`` is unavailable, and by ``_list_loopback_listen_ports`` for the socket
# table. A module constant so tests can repoint it at a fake tree without
# monkeypatching ``os``.
_PROC_FS = "/proc"
# Upper bound on how many loopback listeners ``_candidate_agy_rpc_ports`` will
# Heartbeat-probe in the ``/proc/net/tcp`` fallback, so a host with an unusual
# number of loopback services cannot make turn injection block for
# ``N * _PROBE_TIMEOUT_S``. Far above any real host's loopback listener count
# (the agy host pods have ~3); a breach is logged, never silent.
_MAX_FALLBACK_PROBE_PORTS = 32
# Cap on how many conversation ids are echoed into the ambiguity-refusal warning
# in ``conversation_id_owned_by_pid`` so a pathological candidate set cannot blow
# up a log line.
_MAX_LOGGED_AMBIGUOUS_IDS = 10
def _assert_loopback_url(url: str) -> None:
"""
Refuse any connect-RPC URL whose host is not loopback.
The connect-RPC clients disable TLS verification (agy's cert is self-signed),
which is only safe because the endpoint is loopback-only. The port is
discovered dynamically per session, so this guards every request against a
URL that ever resolved to a non-loopback host — there, ``verify=False`` would
silently trust any cert.
:param url: Full request URL, e.g. ``"https://127.0.0.1:52548/svc/Method"``.
:returns: None.
:raises ValueError: When the URL's host is not a loopback address.
"""
host = urlparse(url).hostname or ""
if host in _LOOPBACK_HOSTS:
return
try:
if ipaddress.ip_address(host).is_loopback:
return
except ValueError:
pass
raise ValueError(f"refusing non-loopback connect-RPC URL (verify is disabled): {url!r}")
def _rpc_url(port: int, method: str) -> str:
"""
Build the connect-RPC URL for a LanguageServerService method.
:param port: agy connect-RPC (TLS) port, e.g. ``52548``.
:param method: RPC method name, e.g. ``"SendAgentMessage"``.
:returns: Full ``https://127.0.0.1:<port>/<service>/<method>`` URL.
"""
return f"https://{_LOOPBACK}:{port}/{_LS_SERVICE}/{method}"
# httpx transport seam. ``None`` (production) lets httpx use its real loopback
# TLS transport with cert verification disabled (agy's cert is self-signed and
# the endpoint is loopback-only). Tests set this to an ``httpx.MockTransport``
# to assert the URL / headers / body of each RPC without a real socket.
#
# The sync seam backs the live discovery probes (Heartbeat +
# GetConversationMetadata). The async seam is reserved for :func:`interrupt_turn`'s
# future POST — that function is async (the forwarder ``await``s it) but is wired
# OFF pending request-contract verification, so ``_async_client`` has no live
# caller yet; the async seam is exercised by its guard test, which asserts no
# async RPC fires while the interrupt is off.
_HTTP_TRANSPORT: httpx.BaseTransport | None = None
_ASYNC_HTTP_TRANSPORT: httpx.AsyncBaseTransport | None = None
def _sync_client(timeout: float) -> httpx.Client:
"""
Build a sync httpx client for a connect-RPC probe.
Cert verification is disabled because agy's loopback cert is self-signed;
this is safe only because every request URL is checked by
:func:`_assert_loopback_url` before it is sent, so the client never trusts an
unverified cert from a non-loopback host.
:param timeout: Per-request timeout in seconds.
:returns: An ``httpx.Client`` with cert verification disabled (loopback,
self-signed) and the test transport when one is installed.
"""
return httpx.Client(verify=False, timeout=timeout, transport=_HTTP_TRANSPORT)
def _async_client(timeout: httpx.Timeout | float) -> httpx.AsyncClient:
"""
Build an async httpx client for a connect-RPC call.
Backs the :func:`stream_agent_state_updates` connect server-stream (a
persistent long-poll, which is why callers pass an :class:`httpx.Timeout`
with the read deadline disabled rather than a flat float). Cert verification
is disabled because agy's loopback cert is self-signed; this is safe only
because every request URL is checked by :func:`_assert_loopback_url` before
it is sent, so the client never trusts an unverified cert from a non-loopback
host.
:param timeout: Per-request timeout — a flat seconds float, or an
:class:`httpx.Timeout` for finer control (e.g. the streaming client
disables the read deadline so the long-poll is not aborted mid-turn).
:returns: An ``httpx.AsyncClient`` with cert verification disabled
(loopback, self-signed) and the test transport when one is installed.
"""
return httpx.AsyncClient(verify=False, timeout=timeout, transport=_ASYNC_HTTP_TRANSPORT)
def _run_lsof_listen_ports(pid: int) -> str:
"""
Return raw ``lsof`` output listing a pid's TCP LISTEN sockets.
Isolated as a seam so tests can stub the subprocess. A non-zero exit (e.g.
the process is gone) or a missing ``lsof`` yields ``""`` rather than raising
— discovery treats "no ports" the same as "lsof unavailable".
:param pid: agy process id, e.g. ``72753``.
:returns: ``lsof`` stdout, or ``""`` on any failure.
"""
try:
completed = subprocess.run(
["lsof", "-nP", "-a", "-p", str(pid), "-iTCP", "-sTCP:LISTEN"],
capture_output=True,
text=True,
timeout=_LSOF_TIMEOUT_S,
check=False,
)
except (OSError, subprocess.SubprocessError):
_logger.warning("lsof failed for agy pid=%s", pid, exc_info=True)
return ""
return completed.stdout
def _parse_loopback_listen_ports(lsof_output: str) -> list[int]:
"""
Parse ascending unique ``127.0.0.1`` LISTEN ports from ``lsof`` output.
Only IPv4 loopback (``127.0.0.1:<port>``) listeners are considered — agy
binds its connect-RPC + HTTP ports there. The NAME column looks like
``127.0.0.1:52548 (LISTEN)``.
:param lsof_output: Raw ``lsof -iTCP -sTCP:LISTEN`` stdout.
:returns: Sorted, de-duplicated loopback port numbers (lowest first).
"""
prefix = f"{_LOOPBACK}:"
ports: set[int] = set()
for line in lsof_output.splitlines():
for token in line.split():
if not token.startswith(prefix):
continue
port_text = token[len(prefix) :]
if port_text.isdigit():
ports.add(int(port_text))
return sorted(ports)
# /proc/net/tcp local-address column is the little-endian hex of the bound IPv4
# address. 127.0.0.1 -> bytes [7F,00,00,01] -> u32 0x0100007F -> "0100007F".
# Matched case-insensitively against this exact form (agy binds the loopback host
# on IPv4, never a 127.0.0.0/8 alias).
_LOOPBACK_HEX_V4 = "0100007F"
def _is_loopback_hex_addr(addr_hex: str) -> bool:
"""
Return whether a ``/proc/net/tcp`` local-address hex column is IPv4 loopback.
:param addr_hex: The hex local address, e.g. ``"0100007F"`` (127.0.0.1).
:returns: ``True`` for the IPv4 ``127.0.0.1`` encoding only.
"""
return addr_hex.upper() == _LOOPBACK_HEX_V4
def _list_loopback_listen_ports() -> list[int]:
"""
Return all IPv4 ``127.0.0.1`` TCP LISTEN ports from ``/proc/net/tcp``.
The robust fallback for :func:`_candidate_agy_rpc_ports` on hosts where
``lsof -p <pid>`` cannot attribute a listening socket to its owning pid —
e.g. the uid-1000 k8s pods where agy 1.0.10 holds its connect-RPC listener
in a backend the agy process does not own as a file descriptor, so neither
``lsof`` nor a ``/proc/<pid>/fd`` scan finds it. The kernel's
network-namespace socket table (``/proc/net/tcp``) lists every listener
regardless of fd ownership and needs no ptrace/fd permission.
IPv4 only: agy binds ``127.0.0.1`` and the connect-RPC client
(:func:`_rpc_url`) dials ``127.0.0.1``, so an IPv6 ``::1`` listener would be
unreachable regardless — enumerating it would only add a dead probe. Parses
state ``0A`` (``TCP_LISTEN``). A missing table (non-Linux) yields ``[]``.
:returns: Sorted, de-duplicated loopback LISTEN port numbers.
"""
ports: set[int] = set()
try:
with open(os.path.join(_PROC_FS, "net", "tcp"), "rb") as handle:
raw = handle.read().decode("ascii", "replace")
except OSError:
return [] # non-Linux, or no /proc
for line in raw.splitlines()[1:]: # row 0 is the column header
fields = line.split()
if len(fields) < 4 or fields[3] != "0A": # 0A == TCP_LISTEN
continue
addr_hex, _sep, port_hex = fields[1].partition(":")
if not _is_loopback_hex_addr(addr_hex):
continue
try:
ports.add(int(port_hex, 16))
except ValueError:
continue
return sorted(ports)
def _heartbeat_ok(port: int) -> bool:
"""
Return whether a port answers the connect-RPC ``Heartbeat`` with HTTP 200.
This is the canonical "is this the TLS connect-RPC port" probe: agy's lower
port returns 200 for ``Heartbeat {}``; the higher plain-HTTP port does not
(it 404s, or the TLS handshake fails). Any transport/TLS error counts as
"not it".
:param port: Candidate loopback port, e.g. ``52548``.
:returns: ``True`` only when ``Heartbeat`` returns HTTP 200.
"""
url = _rpc_url(port, _METHOD_HEARTBEAT)
_assert_loopback_url(url)
try:
with _sync_client(_PROBE_TIMEOUT_S) as client:
response = client.post(
url,
headers={"Content-Type": "application/json"},
content=b"{}",
)
except httpx.HTTPError:
return False
return response.status_code == 200
def _conversation_matches(port: int, conversation_id: str) -> bool:
"""
Return whether the agy on ``port`` owns ``conversation_id``.
Used to pick the right port when several agy instances are running (or no
pid was captured). For an id it hosts, agy's ``GetConversationMetadata``
returns HTTP 200 with a ``metadata`` object that **echoes the resolved id**
as ``metadata.rootConversationId`` (verified live); for an id it does not
host it returns HTTP 500 (``"trajectory not found"``).
The id echo — not merely a 200 with *some* metadata — is what makes the
port-first candidate set (which, in the ``/proc/net/tcp`` fallback, spans
every loopback listener, not just agy's) safe to write to: a non-agy service
that happened to answer ``Heartbeat`` 200, or an agy hosting a *different*
conversation, cannot echo this exact id and is rejected before any
``SendAgentMessage``. Fails closed (returns ``False``) on any shape it does
not recognize.
:param port: Validated connect-RPC port, e.g. ``52548``.
:param conversation_id: agy conversation id to look for, e.g.
``"90468e33-..."``.
:returns: ``True`` only when the server confirms it hosts ``conversation_id``
(200 + ``metadata.rootConversationId == conversation_id``).
"""
url = _rpc_url(port, _METHOD_GET_CONVERSATION_METADATA)
_assert_loopback_url(url)
try:
with _sync_client(_PROBE_TIMEOUT_S) as client:
response = client.post(
url,
headers={"Content-Type": "application/json"},
content=json.dumps({"conversationId": conversation_id}).encode("utf-8"),
)
except httpx.HTTPError:
return False
if response.status_code != 200:
return False
try:
body = response.json()
except ValueError:
return False
if not isinstance(body, dict):
return False
metadata = body.get("metadata")
if not isinstance(metadata, dict):
return False
return metadata.get("rootConversationId") == conversation_id
def get_trajectory_steps(port: int, cascade_id: str) -> list[dict[str, object]]:
"""
Return the trajectory steps for ``cascade_id`` from the agy on ``port``.
POSTs ``{"cascadeId": cascade_id}`` to ``GetCascadeTrajectorySteps`` and
returns the ``steps`` list from the response body (empty list when the key
is absent). Used by the read driver to poll incremental step progress.
:param port: Validated connect-RPC port, e.g. ``52548``.
:param cascade_id: agy cascade id (equal to the conversation id) to query.
:returns: List of step dicts, each containing at least ``stepIndex`` and
``status`` (may be empty when no steps have been recorded yet).
:raises httpx.HTTPError: On transport errors or non-2xx responses; the
Task 6 read driver is responsible for retry/backoff. Intentionally NOT
fail-open (unlike ``_conversation_matches`` which is a discovery probe);
a non-2xx here is a hard read failure, not a "not found" signal.
:raises ValueError: When the 2xx response body is not valid JSON (body is
decoded; the Task 6 driver catches broadly).
"""
url = _rpc_url(port, _METHOD_GET_CASCADE_TRAJECTORY_STEPS)
_assert_loopback_url(url)
with _sync_client(_RPC_CALL_TIMEOUT_S) as client:
response = client.post(
url,
headers={"Content-Type": "application/json"},
content=json.dumps({"cascadeId": cascade_id}).encode("utf-8"),
)
# Raises httpx.HTTPStatusError (subclass of httpx.HTTPError) on non-2xx so
# the body is never decoded on error paths (which may not be JSON).
response.raise_for_status()
body = response.json() # ValueError on non-JSON 200 propagates (documented)
steps = body.get("steps") if isinstance(body, dict) else None
return list(steps) if isinstance(steps, list) else []
def cancel_cascade_steps(port: int, cascade_id: str) -> bool:
"""
Request cancellation of the active cascade steps for ``cascade_id``.
POSTs ``{"cascadeId": cascade_id}`` to ``CancelCascadeSteps`` and returns
``True`` when the server responds with a non-error HTTP status (< 400).
Fails open (returns ``False``) on any transport error so the executor can
treat it as best-effort.
:param port: Validated connect-RPC port, e.g. ``52548``.
:param cascade_id: agy cascade id (equal to the conversation id) to cancel.
:returns: ``True`` when the server accepted the cancel (HTTP < 400),
``False`` on any error or rejection.
"""
url = _rpc_url(port, _METHOD_CANCEL_CASCADE_STEPS)
_assert_loopback_url(url)
try:
with _sync_client(_RPC_CALL_TIMEOUT_S) as client:
response = client.post(
url,
headers={"Content-Type": "application/json"},
content=json.dumps({"cascadeId": cascade_id}).encode("utf-8"),
)
except Exception: # deliberate fail-open: ssl.SSLError etc. outside httpx hierarchy
return False
return response.status_code < 400
class AntigravityRpcError(Exception):
"""
Raised by :func:`handle_user_interaction` when the server returns a
non-2xx status.
The raw response body text is the exception message so callers can detect
the overloaded ``"input not registered for step N"`` string that agy
returns when the interaction has not yet been registered for the step
(a race the Task 8 bridge must retry on).
"""
def _post_rpc_raising(port: int, method: str, body: dict[str, object]) -> None:
"""
POST a JSON body to a functional connect-RPC method, raising on any failure.
The shared request tail for the functional, raise-on-error RPCs
(:func:`handle_user_interaction`, :func:`send_user_cascade_message`,
:func:`start_cascade`): build + loopback-check the URL, POST ``body`` as JSON
on a :data:`_RPC_CALL_TIMEOUT_S` sync client, and normalize failures so every
caller has a single :class:`AntigravityRpcError` type to catch — transport
errors are wrapped, and a non-2xx raises with the RAW response body text (NOT
``raise_for_status()``) so callers can detect agy's overloaded error strings
(e.g. ``"input not registered for step N"``).
:param port: Validated connect-RPC port, e.g. ``52548``.
:param method: LanguageServerService method name, e.g.
``"SendUserCascadeMessage"``.
:param body: The request object to JSON-encode and POST.
:returns: ``None`` on success (HTTP < 400).
:raises AntigravityRpcError: On a transport error (wrapped) or any HTTP
status >= 400 (message is the raw response body text).
"""
url = _rpc_url(port, method)
_assert_loopback_url(url)
try:
with _sync_client(_RPC_CALL_TIMEOUT_S) as client:
response = client.post(
url,
headers={"Content-Type": "application/json"},
content=json.dumps(body).encode("utf-8"),
)
except httpx.HTTPError as e:
raise AntigravityRpcError(f"transport error contacting agy: {e}") from e
if response.status_code >= 400:
raise AntigravityRpcError(response.text)
def handle_user_interaction(
port: int,
cascade_id: str,
*,
trajectory_id: str,
step_index: int,
payload: dict[str, object],
) -> None:
"""
Deliver an interaction answer (question response / approval) to agy.
POSTs ``{"cascadeId": cascade_id, "interaction": {"trajectoryId":
trajectory_id, "stepIndex": step_index, **payload}}`` to
``HandleCascadeUserInteraction``. The ``trajectoryId`` and ``stepIndex``
are nested inside ``interaction`` because the proto-JSON encoding drops
top-level extras — they must be co-located with the payload variant dict.
``cascade_id`` is identical to the conversation id (agy uses the same
UUID for both).
:param port: Validated connect-RPC port, e.g. ``52548``.
:param cascade_id: agy cascade id (equal to the conversation id) to
address.
:param trajectory_id: agy trajectory id identifying the active trajectory.
:param step_index: Step index the interaction targets.
:param payload: Variant dict, e.g. ``{"permission": {"allow": True}}`` or
``{"askQuestion": {...}}``.
:raises AntigravityRpcError: On transport errors (e.g. connection refused)
or any HTTP status >= 400. Transport errors are wrapped so the bridge
has one exception type to catch, regardless of whether the failure was
on the wire or at the application layer. On non-2xx, the raw response
body text is the message (NOT ``raise_for_status()``) so callers can
detect the overloaded ``"input not registered for step N"`` string.
"""
body: dict[str, object] = {
"cascadeId": cascade_id,
"interaction": {"trajectoryId": trajectory_id, "stepIndex": step_index, **payload},
}
_post_rpc_raising(port, _METHOD_HANDLE_CASCADE_USER_INTERACTION, body)
def send_user_cascade_message(
port: int,
cascade_id: str,
text: str,
*,
plan_model: str,
) -> None:
"""
Send a user turn to agy via connect-RPC (replaces tmux send-keys).
POSTs ``{"cascadeId": cascade_id, "items": [{"text": text}],
"cascadeConfig": {"plannerConfig": {"planModel": plan_model}}}`` to
``SendUserCascadeMessage``. This records the turn as
``CORTEX_STEP_TYPE_USER_INPUT`` with
``metadata.source==CORTEX_STEP_SOURCE_USER_EXPLICIT``, which the read
driver keys on — in contrast to ``SendAgentMessage`` which records as a
``SYSTEM_MESSAGE`` and is therefore unsuitable.
Two shape constraints (live-verified against agy 1.0.10, see §10.1 of the
design doc):
* The turn text MUST be in ``items[].text`` (a list of objects) — a flat
``"message"`` field is not the correct schema.
* The ``plan_model`` MUST be present at
``cascadeConfig.plannerConfig.planModel``; omitting it causes agy to
error "neither PlanModel nor RequestedModel specified".
:param port: Validated connect-RPC port, e.g. ``52548``.
:param cascade_id: agy cascade id (equal to the conversation id) to send
the turn to.
:param text: The turn text to send (the user's message).
:param plan_model: agy model enum string, resolved at runtime via
:func:`get_available_models` or echoed from the read-side
``userInput.userConfig.plannerConfig.requestedModel.model``. Must not
be empty or omitted.
:returns: ``None`` on success (HTTP 200).
:raises AntigravityRpcError: On transport errors (e.g. connection refused)
or any HTTP status >= 400. Transport errors are wrapped so the executor
has one exception type to catch. On non-2xx, the raw response body text
is the message (NOT ``raise_for_status()``) so callers can surface agy
model/validation errors (e.g. "neither PlanModel nor RequestedModel
specified") directly. Mirrors :func:`handle_user_interaction`.
"""
body: dict[str, object] = {
"cascadeId": cascade_id,
"items": [{"text": text}],
"cascadeConfig": {"plannerConfig": {"planModel": plan_model}},
}
_post_rpc_raising(port, _METHOD_SEND_USER_CASCADE_MESSAGE, body)
def start_cascade(
port: int,
cascade_id: str,
*,
source: str = "CORTEX_TRAJECTORY_SOURCE_CLI",
) -> None:
"""
Cold-start (create) an agy conversation over connect-RPC.
POSTs ``{"cascadeId": cascade_id, "source": source}`` to ``StartCascade``,
creating agy's brain dir (named after *cascade_id*) so the runner owns the
conversation id from turn-1 instead of waiting for the TUI to lazily mint one
on its first typed turn (live-verified against agy 1.0.10; see
``agy-rpc-interaction-bridge`` / ``/tmp/agy-coldstart-spike.md``).
Two contract facts (live-verified):
* ``source`` is the ONLY required field. ``cascadeId`` is optional — when
omitted agy mints and returns one — but the runner PROVIDES it so it owns
the id (the response echoes the same id back). No model is needed here; the
model is selected per-turn by :func:`send_user_cascade_message`.
* An RPC-created conversation does NOT surface in the agy TUI (it stays on the
empty ``>`` banner); the brain dir / backend is the source of truth, which
is correct for the headless RPC runner.
:param port: Validated connect-RPC port, e.g. ``52548``.
:param cascade_id: Runner-generated conversation/cascade id (a ``uuid4``) the
new conversation will own.
:param source: agy trajectory-source enum string. Defaults to
``"CORTEX_TRAJECTORY_SOURCE_CLI"`` (the headless-CLI source); kept a
parameter so a future enum rename is a one-line caller change.
:returns: ``None`` on success (HTTP 200).
:raises AntigravityRpcError: On transport errors (e.g. connection refused) or
any HTTP status >= 400. Transport errors are wrapped so the runner has one
exception type to catch. On non-2xx, the raw response body text is the
message (NOT ``raise_for_status()``) so the runner can surface agy's
error verbatim. Mirrors :func:`send_user_cascade_message`.
"""
body: dict[str, object] = {"cascadeId": cascade_id, "source": source}
_post_rpc_raising(port, _METHOD_START_CASCADE, body)
def get_available_models(port: int) -> dict[str, object]:
"""
Return the agy model catalog from ``GetAvailableModels``.
POSTs ``{}`` to ``GetAvailableModels`` and returns the catalog object
(unwrapped from the response envelope — see below). Used to resolve a model
enum string at runtime for :func:`send_user_cascade_message` (the model is
required per-turn and must not be hardcoded — ``planModel`` values are
version-volatile enums).
The response shape (live-verified, agy 1.0.10) wraps the catalog under a
top-level ``"response"`` key::
{"response": {
"models": {
"<key>": {
"model": "<enum-string>",
"displayName": "<human label>",
"recommended": <bool>,
"supportsThinking": <bool>,
"thinkingBudget": <int>
},
...
},
"defaultAgentModelId": "<enum-string>",
"tieredModelIds": {...},
...
}}
This function unwraps that envelope and returns the inner object, so callers
see ``{"models": ..., "defaultAgentModelId": ...}`` at the top level and can
key on ``catalog["models"]``. Pick the desired entry by ``recommended ==
True`` or by matching the ``displayName`` / ``model`` string the user
selected. The echo-from-read-side shortcut
(``userInput.userConfig.plannerConfig.planModel``) is preferred when the
current model is already known from a prior turn's step data — call this only
when no prior model is available.
:param port: Validated connect-RPC port, e.g. ``52548``.
:returns: The unwrapped catalog (a dict, at minimum ``{"models": {...}}``):
``body["response"]`` when the 200 body is a dict carrying a dict under
``"response"``; otherwise the body itself (defensive, for a future agy
that drops the envelope). Returns ``{}`` if the 200 body is not a dict
(guards against a future agy returning a non-object 200).
:raises httpx.HTTPError: On transport errors or non-2xx responses; the
executor is responsible for retry/backoff. Intentionally NOT fail-open
(mirrors :func:`get_trajectory_steps`): a non-2xx is a hard read
failure, not a "not found" signal.
:raises ValueError: When the 2xx response body is not valid JSON (body is
decoded; the caller catches broadly).
"""
url = _rpc_url(port, _METHOD_GET_AVAILABLE_MODELS)
_assert_loopback_url(url)
with _sync_client(_RPC_CALL_TIMEOUT_S) as client:
response = client.post(
url,
headers={"Content-Type": "application/json"},
content=b"{}",
)
# Raises httpx.HTTPStatusError (subclass of httpx.HTTPError) on non-2xx so
# the body is never decoded on error paths (which may not be JSON).
response.raise_for_status()
body = response.json() # ValueError on non-JSON 200 propagates (documented)
if not isinstance(body, dict):
return {}
# Live agy wraps the catalog under "response"; unwrap so callers read
# ``catalog["models"]`` at the top level. Fall back to the body itself if a
# future agy ever drops the envelope (defensive — keeps callers working).
inner = body.get("response")
return inner if isinstance(inner, dict) else body
def get_all_cascade_trajectories(port: int) -> dict[str, object]:
"""
Return agy's cascade-trajectory summaries from ``GetAllCascadeTrajectories``.
POSTs ``{}`` to ``GetAllCascadeTrajectories`` and returns the parsed response
body. This is the PRIMARY ``/clear``-rotation signal for the Task T-G reader: a
``StreamAgentStateUpdates`` stream is bound to ONE cascade and only ever
reports THAT cascade's id, so it cannot observe a sibling conversation; this
surface, by contrast, lists EVERY live root cascade, so a freshly ``/clear``-
minted conversation becomes visible here as a new, more-recently-active entry.
The response shape (live-verified, agy 1.0.10) keys each summary by its ROOT
conversation id::
{"trajectorySummaries": {
"<rootConversationId>": {
"trajectoryId": "<uuid>",
"status": "CASCADE_RUN_STATUS_IDLE",
"createdTime": "2026-06-23T17:18:24.402333Z",
"lastModifiedTime": "2026-06-23T17:50:32.565300Z",
"lastUserInputTime": "2026-06-23T17:50:29.232919Z",
"lastUserInputStepIndex": 8,
"stepCount": 10,
"summary": "<human label>",
"trajectoryType": "CORTEX_TRAJECTORY_TYPE_CASCADE",
"trajectoryMetadata": {
"createdAt": "...",
"rootConversationId": "<rootConversationId>",
...
}
},
...
}}
A freshly ``/clear``-minted cascade appears with ``lastUserInputTime`` /
``lastModifiedTime`` ABSENT (``null``) until it is actually used — so the
reader's :func:`~omnigent.antigravity_native_reader._detect_rotated_cascade`
treats a never-used sibling as not-yet-the-current-conversation (no premature
rotation). The caller is responsible for selecting the current cascade from
these summaries; this function only fetches them.
:param port: Validated connect-RPC port, e.g. ``52548``.
:returns: The parsed response body (at minimum ``{"trajectorySummaries":
{...}}``), or ``{}`` when the 200 body is not a dict (guards against a
future agy returning a non-object 200).
:raises httpx.HTTPError: On transport errors or non-2xx responses; the Task
T-G reader is responsible for retry/backoff. Intentionally NOT fail-open
(mirrors :func:`get_trajectory_steps` / :func:`get_available_models`): a
non-2xx is a hard read failure, not a "no rotation" signal.
:raises ValueError: When the 2xx response body is not valid JSON (body is
decoded; the caller catches broadly).
"""
url = _rpc_url(port, _METHOD_GET_ALL_CASCADE_TRAJECTORIES)
_assert_loopback_url(url)
with _sync_client(_RPC_CALL_TIMEOUT_S) as client:
response = client.post(
url,
headers={"Content-Type": "application/json"},
content=b"{}",
)
# Raises httpx.HTTPStatusError (subclass of httpx.HTTPError) on non-2xx so
# the body is never decoded on error paths (which may not be JSON).
response.raise_for_status()
body = response.json() # ValueError on non-JSON 200 propagates (documented)
return body if isinstance(body, dict) else {}
def _encode_connect_envelope(payload: dict[str, object]) -> bytes:
"""
Encode a single connect-protocol request message.
The connect server-stream request body is ONE enveloped message:
``[flag: 1B = 0x00][length: 4B big-endian uint32][payload]`` where the
payload is the JSON request object and ``length`` is its byte count. This is
the same 5-byte ``[flag][BE-len]`` framing used on the response side, with an
uncompressed (flag ``0x00``) payload.
:param payload: The request object, e.g. ``{"conversationId": "<id>"}``.
:returns: The framed request body bytes.
"""
raw = json.dumps(payload).encode("utf-8")
return bytes([0x00]) + struct.pack(">I", len(raw)) + raw
def _connect_trailer_error(payload: bytes) -> object | None:
"""
Return the connect end-of-stream trailer's error, or ``None`` for a clean EOS.
A connect server-stream reports a mid-stream failure in the TRAILER PAYLOAD
as ``{"error": {...}}`` (the HTTP 200 + headers were already flushed, so the
status cannot carry it). This extracts that error so the stream client can
raise instead of silently truncating the turn.
Fails SAFE toward a clean stop: an empty payload (the common normal EOS), a
payload that is not valid JSON, a non-object body, or one whose ``error`` is
absent/empty/falsy all return ``None`` (no error). Only a non-empty ``error``
value is returned.
:param payload: The raw trailer frame payload bytes (may be ``b""``).
:returns: The trailer's ``error`` value when present and non-empty; otherwise
``None`` (treat as a clean end-of-stream).
"""
if not payload:
return None
try:
parsed = json.loads(payload)
except json.JSONDecodeError:
return None
if not isinstance(parsed, dict):
return None
error = parsed.get("error")
return error if error else None
async def stream_agent_state_updates(
port: int, conversation_id: str
) -> AsyncIterator[dict[str, object]]:
"""
Stream agent-state updates for ``conversation_id`` over connect server-stream.
Opens a persistent ``StreamAgentStateUpdates`` POST (connect protocol,
``Content-Type: application/connect+json``) and yields each DATA frame's
logical update payload as it arrives, stopping cleanly on the end-of-stream
trailer. Each DATA frame is a connect envelope ``{"update": {...}}`` (live-
verified, agy 1.0.10); this generator unwraps it and yields the inner
``update`` object, so the reader receives
``{"conversationId", "trajectoryId", "status", "mainTrajectoryUpdate", ...}``
at the top level. The first frame carries a snapshot
(``update.mainTrajectoryUpdate.stepsUpdate.steps[]``); subsequent frames are
cumulative snapshots that grow while a step is generating (the Task T-D
reader owns prefix-diffing for ``output_text_delta`` parity).
Framing (live-verified, agy 1.0.10 — see design §10.2). The request body is a
single connect-enveloped message (see :func:`_encode_connect_envelope`). The
response is a byte stream of frames, each ``[flag: 1B][length: 4B BE][payload:
length B]``:
* ``flag == 0x00`` — DATA: ``payload`` is the JSON envelope ``{"update":
{...}}``; the unwrapped ``update`` object is yielded.
* ``flag & 0x02`` (:data:`_FRAME_FLAG_TRAILER`) — end-of-stream trailer:
iteration stops; the payload is NOT yielded. A trailer carrying a connect
``{"error": {...}}`` (how a mid-stream failure is reported once the 200 +
headers have been flushed) raises :class:`AntigravityRpcError`; an empty /
non-error / unparseable trailer is a clean stop.
* ``flag & 0x01`` (:data:`_FRAME_FLAG_COMPRESSED`) — compressed payload: agy
sends uncompressed, so a set bit means a decode mismatch — raises
:class:`AntigravityRpcError` rather than feeding compressed bytes to the
JSON parser.
Reassembly is buffer-based because frames can be split across network chunks
AND several frames can arrive in one chunk: a ``bytearray`` accumulates bytes
and, each pass, a frame is sliced out only when the buffer holds the full
5-byte header AND the full declared payload — otherwise the loop awaits more
bytes. One chunk is never assumed to equal one frame.
The stream uses :data:`_STREAM_TIMEOUT` (no read deadline) because the
long-poll idles between turns; a tight per-read timeout would abort it during
normal think-time.
:param port: Validated connect-RPC port, e.g. ``52548``.
:param conversation_id: agy conversation id (equal to the cascade id) to
stream updates for.
:returns: An async iterator over DATA frames' parsed JSON dicts, in arrival
order, ending when the trailer frame is seen or the stream closes.
:raises AntigravityRpcError: On a non-2xx HTTP status (httpx ``stream()`` does
not raise on its own, so an unframed error body would otherwise look like
a clean empty stream and reconnect forever), a compressed frame
(unexpected from agy), a malformed-JSON DATA frame, or a connect
end-of-stream trailer error (``{"error": ...}``). All route into the Task
T-D reader's poll-fallback. Transport errors propagate as
``httpx.HTTPError`` from the underlying stream (also poll-fallback).
"""
url = _rpc_url(port, _METHOD_STREAM_AGENT_STATE_UPDATES)
_assert_loopback_url(url)
body = _encode_connect_envelope({"conversationId": conversation_id})
async with (
_async_client(_STREAM_TIMEOUT) as client,
client.stream(
"POST",
url,
headers={"Content-Type": "application/connect+json"},
content=body,
) as response,
):
# httpx ``client.stream()`` does NOT raise on a non-2xx status; a 4xx/5xx
# body is not connect-framed, so the frame loop below would yield nothing
# and return cleanly — which the reader treats as a normal end-of-stream
# and reconnects every backoff forever. Surface it instead so it routes
# into the reader's poll-fallback. ``status_code`` is available without
# reading the (lazy) streaming body, so this does not consume the stream.
if response.status_code >= 400:
raise AntigravityRpcError(f"agy connect-stream returned HTTP {response.status_code}")
buffer = bytearray()
async for chunk in response.aiter_bytes():
buffer.extend(chunk)
while len(buffer) >= _FRAME_HEADER_LEN:
flag = buffer[0]
(length,) = struct.unpack(">I", buffer[1:_FRAME_HEADER_LEN])
frame_end = _FRAME_HEADER_LEN + length
if len(buffer) < frame_end:
break # full payload not arrived yet — await more bytes
payload = bytes(buffer[_FRAME_HEADER_LEN:frame_end])
del buffer[:frame_end]
if flag & _FRAME_FLAG_COMPRESSED:
raise AntigravityRpcError(
f"agy sent a compressed connect-stream frame (flag={flag:#04x}); "
"compression is unsupported"
)
if flag & _FRAME_FLAG_TRAILER:
# End-of-stream trailer. In connect streaming a mid-stream
# failure is reported HERE as ``{"error": {...}}`` (not via
# HTTP status — the 200 + headers were already flushed), so a
# trailer error must be raised rather than treated as a clean
# stop (which would silently truncate the turn). An empty /
# non-error / unparseable trailer is a normal end-of-stream.
error = _connect_trailer_error(payload)
if error is not None:
raise AntigravityRpcError(f"agy connect-stream error: {error}")
return
try:
parsed = json.loads(payload)
except json.JSONDecodeError as e:
raise AntigravityRpcError(
f"malformed JSON in agy connect-stream DATA frame: {e}"
) from e
if isinstance(parsed, dict):
# Each DATA frame is a connect envelope ``{"update": {...}}``;
# unwrap it so the reader's ``_frame_steps`` sees
# ``mainTrajectoryUpdate`` at the top level. Fall back to the
# parsed dict if a future agy ever drops the envelope
# (defensive — keeps the reader working).
inner = parsed.get("update")
yield inner if isinstance(inner, dict) else parsed
def _list_agy_pids() -> list[int]:
"""
Return pids of running ``agy`` processes (best-effort).
Isolated as a seam so tests can stub it. Matches the agy binary path
(``.../bin/agy``) to avoid matching unrelated commands that merely mention
"agy". Prefers ``pgrep -f`` (portable across Linux + macOS); when ``pgrep``
is unavailable — e.g. a minimal container image without ``procps`` — falls
back to a ``/proc`` cmdline scan (:func:`_list_agy_pids_from_proc`) so
discovery still works rather than silently yielding no candidates (which
surfaces to the user as the misleading "is the agy terminal still open?").
:returns: Candidate agy pids, newest-not-guaranteed order.
"""
try:
completed = subprocess.run(
["pgrep", "-f", r"bin/agy"],
capture_output=True,
text=True,
timeout=_LSOF_TIMEOUT_S,
check=False,
)
except (OSError, subprocess.SubprocessError):
# ``pgrep`` missing (no procps), hung past its timeout, or otherwise
# unusable — fall back to a /proc scan instead of giving up, so a turn
# can still be injected. ``exc_info`` records the real cause.
_logger.debug("pgrep failed; falling back to /proc scan for agy pids", exc_info=True)
return _list_agy_pids_from_proc()
return [int(line) for line in completed.stdout.split() if line.isdigit()]
def _list_agy_pids_from_proc() -> list[int]:
"""
Enumerate agy pids by scanning ``/proc/<pid>/cmdline`` (no ``pgrep`` needed).
The Linux-only fallback for :func:`_list_agy_pids`. Mirrors ``pgrep -f
bin/agy``: a process matches when its full (NUL-joined) command line
contains ``bin/agy``, which the launcher always satisfies because
:func:`omnigent.antigravity_native_launch.agy_binary_path` resolves to an
absolute ``.../bin/agy`` path. Unreadable or vanished ``/proc`` entries are
skipped; a missing ``/proc`` (non-Linux) yields ``[]``.
:returns: Candidate agy pids.
"""
pids: list[int] = []
try:
entries = os.listdir(_PROC_FS)
except OSError:
return []
for entry in entries:
if not entry.isdigit():
continue
try:
with open(os.path.join(_PROC_FS, entry, "cmdline"), "rb") as handle:
cmdline = handle.read()
except OSError:
# Process exited between listdir and open, or cmdline is unreadable.
continue
if b"bin/agy" in cmdline.replace(b"\0", b" "):
pids.append(int(entry))
return pids
def discover_language_server_port(pid: int) -> int | None:
"""
Resolve the connect-RPC (TLS) port for a known agy pid.
``lsof``-es the pid's ``127.0.0.1`` TCP LISTEN ports and returns the first
(lowest) one that answers ``Heartbeat`` with HTTP 200 — agy's lower port is
the TLS connect-RPC surface and the higher one is plain HTTP that fails the
probe. Probing lowest-first means the connect-RPC port is found without
assuming the two ports are exactly adjacent.
:param pid: agy process id, e.g. ``72753``.
:returns: The validated connect-RPC port, or ``None`` when the process has
no loopback listeners or none answer ``Heartbeat`` (e.g. agy has exited
or has not finished binding).
"""
ports = _parse_loopback_listen_ports(_run_lsof_listen_ports(pid))
for port in ports:
if _heartbeat_ok(port):
_logger.debug("agy connect-RPC port resolved: pid=%s port=%s", pid, port)
return port
return None
# Bound on how deep the pane->agy descendant walk descends, so a pathological or
# cyclic process table cannot make the walk run unbounded. The real pane subtree
# is shallow (pane shell -> [sandbox launcher -> bwrap ->] agy), far under this.
_MAX_PANE_SUBTREE_DEPTH = 32
def _pane_pid(socket_path: Path, tmux_target: str) -> int | None:
"""
Return the pid of the process running in a tmux pane (best-effort).
Runs ``tmux -S <socket> display-message -p -t <target> '#{pane_pid}'`` (the
same socket-scoped ``tmux`` invocation style as the bridge capture helpers)
and parses the single pid it prints. Isolated as a seam so tests can stub the
subprocess.
The pane pid is the process tmux ``exec``-ed into the pane, which is agy
itself on the simple ``exec agy`` launch and the sandbox launcher (an agy
ancestor) on a sandboxed launch — :func:`_agy_pid_in_pane_subtree` resolves
the actual agy pid from it.
:param socket_path: Private tmux socket path for this session's terminal.
:param tmux_target: Tmux target (session name), e.g. ``"main"``.
:returns: The pane's pid, or ``None`` when ``tmux`` is missing, the pane is
gone (non-zero exit), or the output is not a single integer.
"""
try:
completed = subprocess.run(
[
"tmux",
"-S",
str(socket_path),
"display-message",
"-p",
"-t",
tmux_target,
"#{pane_pid}",
],
capture_output=True,
text=True,
timeout=_LSOF_TIMEOUT_S,
check=False,
)
except (OSError, subprocess.SubprocessError):
_logger.debug(
"tmux display-message failed resolving pane pid for target=%s socket=%s",
tmux_target,
socket_path,
exc_info=True,
)
return None
if completed.returncode != 0:
return None
text = completed.stdout.strip()
return int(text) if text.isdigit() else None
def _child_pids(pid: int) -> list[int]:
"""
Return the direct child pids of ``pid`` (best-effort).
Prefers ``pgrep -P <pid>`` (portable across Linux + macOS); on a host without
``pgrep`` (e.g. a minimal container) falls back to scanning
``/proc/<child>/stat`` for a matching ``PPid``. Mirrors the dual-path
strategy of :func:`_list_agy_pids`. A failure yields ``[]`` (the subtree walk
then simply finds no agy via this branch).
:param pid: Parent process id.
:returns: Direct child pids, or ``[]`` when none / unresolvable.
"""
try:
completed = subprocess.run(
["pgrep", "-P", str(pid)],
capture_output=True,
text=True,
timeout=_LSOF_TIMEOUT_S,
check=False,
)
except (OSError, subprocess.SubprocessError):
_logger.debug("pgrep -P failed; falling back to /proc for children of %s", pid)
return _child_pids_from_proc(pid)
return [int(line) for line in completed.stdout.split() if line.isdigit()]
def _child_pids_from_proc(pid: int) -> list[int]:
"""
Enumerate direct child pids of ``pid`` via ``/proc/<child>/stat`` (no pgrep).
The Linux-only fallback for :func:`_child_pids`. ``/proc/<child>/stat`` field
4 is the PPid; a child matches when its PPid equals ``pid``. The comm field
(field 2) is parenthesized and may contain spaces, so PPid is read relative to
the LAST ``)`` rather than by naive whitespace split. Unreadable or vanished
entries are skipped; a missing ``/proc`` (non-Linux) yields ``[]``.
:param pid: Parent process id.
:returns: Direct child pids, or ``[]``.
"""
children: list[int] = []
try:
entries = os.listdir(_PROC_FS)
except OSError:
return []
for entry in entries:
if not entry.isdigit():
continue
try:
with open(os.path.join(_PROC_FS, entry, "stat"), encoding="ascii") as handle:
stat = handle.read()
except OSError:
continue # process exited between listdir and open
close_paren = stat.rfind(")")
if close_paren == -1:
continue
fields = stat[close_paren + 1 :].split()
# After the ``)``, fields are: state(0) ppid(1) ... so ppid is index 1.
if len(fields) > 1 and fields[1].isdigit() and int(fields[1]) == pid:
children.append(int(entry))
return children
def _agy_pid_in_pane_subtree(pane_pid: int) -> int | None:
"""
Return the agy pid running at or beneath a tmux pane's process (best-effort).
The pane process is agy itself on the simple ``exec agy`` launch (CLI /
non-sandbox) and a wrapper (sandbox launcher -> bwrap -> ... -> agy) on a
sandboxed launch. So: if the pane pid is itself a live agy pid, return it;
otherwise breadth-first walk the pane pid's descendants and return the first
that is a live agy pid. The walk is intersected with :func:`_list_agy_pids`
(the same ``bin/agy`` cmdline match used everywhere) so a non-agy descendant
is never mistaken for agy, and is depth- and visited-bounded so a cyclic or
pathological process table cannot hang it.
:param pane_pid: The tmux pane's process id (from :func:`_pane_pid`).
:returns: The agy pid in the pane's subtree, or ``None`` when none is found
(the caller then falls back to the host-wide candidate scan).
"""
agy_pids = set(_list_agy_pids())
if not agy_pids:
return None
if pane_pid in agy_pids:
return pane_pid
# Breadth-first descent, bounded by depth and visited-set so a cyclic ppid
# graph (or a re-parented pid) cannot loop forever.
visited: set[int] = {pane_pid}
frontier = [pane_pid]
for _depth in range(_MAX_PANE_SUBTREE_DEPTH):
if not frontier:
break
next_frontier: list[int] = []
for parent in frontier:
for child in _child_pids(parent):
if child in visited:
continue
if child in agy_pids:
return child
visited.add(child)
next_frontier.append(child)
frontier = next_frontier
return None
class PaneAgyResolution(NamedTuple):
"""
The outcome of scoping a cold-start port to a tmux pane's own agy.
Distinguishes the THREE states the cold-start must treat differently — a bare
``int | None`` cannot, because "agy is up but its port is not lsof-attributable"
and "no agy is running in this pane yet" both collapse to "no port" yet demand
opposite fallbacks (candidate scan vs. keep polling). See
:func:`resolve_pane_agy_rpc_port_state`.
:param agy_found: Whether an agy process was found at/under the pane pid.
:param port: That agy's validated connect-RPC port, or ``None`` when agy was
found but its port is not yet resolvable (or no agy was found).
"""
agy_found: bool
port: int | None
def resolve_pane_agy_rpc_port_state(socket_path: Path, tmux_target: str) -> PaneAgyResolution:
"""
Resolve THIS session's own agy connect-RPC port via its tmux pane (3-state).
The cold-start's disambiguation seam: before any conversation exists there is
nothing to confirm ownership with (:func:`resolve_language_server_port` needs
a conversation id), so on a host running several agy instances the lowest
Heartbeat-answering candidate (:func:`_candidate_agy_rpc_ports`) could be a
FOREIGN agy. Scoping the port to the agy process actually running under this
session's tmux pane removes that ambiguity: pane -> pane pid
(:func:`_pane_pid`) -> agy pid in its subtree (:func:`_agy_pid_in_pane_subtree`)
-> that pid's Heartbeat-validated connect-RPC port
(:func:`discover_language_server_port`).
Returns a :class:`PaneAgyResolution` so the caller can tell the three outcomes
apart (best-effort, non-raising):
* ``(agy_found=True, port=<int>)`` — our agy is up and its port is resolved.
* ``(agy_found=True, port=None)`` — our agy IS in the pane subtree but its port
is not (yet) lsof-attributable (e.g. a restricted ``/proc`` where the
listener is owned by a backend the agy pid does not hold as an fd). The
caller may safely use the candidate scan: agy exists here, so on the
one-agy-per-pod hosts where this happens the lone candidate is ours.
* ``(agy_found=False, port=None)`` — no agy is running in this pane yet (no
pane pid, or agy has not been ``exec``-ed — the CLI ``tmux_start_on_attach``
early-poll window). The caller MUST keep polling rather than fall back to
candidates, because a FOREIGN agy could be the only candidate and binding it
would durably cross-bind the session.
:param socket_path: Private tmux socket path for this session's terminal.
:param tmux_target: Tmux target (session name), e.g. ``"main"``.
:returns: The :class:`PaneAgyResolution` for the three states above.
"""
pane_pid = _pane_pid(socket_path, tmux_target)
if pane_pid is None:
# No pane pid (tmux gone / target absent) — agy is not reachable here.
return PaneAgyResolution(agy_found=False, port=None)
agy_pid = _agy_pid_in_pane_subtree(pane_pid)
if agy_pid is None:
_logger.debug(
"no agy process found in tmux pane subtree (pane_pid=%s, target=%s)",
pane_pid,
tmux_target,
)
return PaneAgyResolution(agy_found=False, port=None)
port = discover_language_server_port(agy_pid)
if port is not None:
_logger.info(
"agy connect-RPC port scoped to pane: target=%s agy_pid=%s port=%s",
tmux_target,
agy_pid,
port,
)
return PaneAgyResolution(agy_found=True, port=port)
def resolve_pane_agy_rpc_port(socket_path: Path, tmux_target: str) -> int | None:
"""
Resolve THIS session's own agy connect-RPC port via its tmux pane.
Thin ``port``-only view of :func:`resolve_pane_agy_rpc_port_state` for callers
that do not need the agy-found / no-agy distinction.
:param socket_path: Private tmux socket path for this session's terminal.
:param tmux_target: Tmux target (session name), e.g. ``"main"``.
:returns: The pane agy's validated connect-RPC port, or ``None`` when it
cannot be scoped.
"""
return resolve_pane_agy_rpc_port_state(socket_path, tmux_target).port
def resolve_cold_start_agy_rpc_port(
tmux_socket: Path | None,
tmux_target: str | None,
) -> int | None:
"""
Pick the connect-RPC port a cold-start should ``StartCascade`` onto.
Cold-start runs BEFORE any conversation exists, so the conversation-ownership
check that normally disambiguates several agys
(:func:`resolve_language_server_port`) is not yet usable. To avoid binding a
FOREIGN agy on a host running several (sub-agent fan-out / shared runner /
``omnigent run --server`` multi-session), this scopes the port to THIS
session's own agy via its tmux pane
(:func:`resolve_pane_agy_rpc_port_state`), distinguishing three outcomes:
1. **Pane present, our agy found, port resolved** → that scoped port.
2. **Pane present, our agy found, port not resolvable** (e.g. restricted
``/proc`` where lsof cannot attribute the listener) → the lowest candidate
(:func:`_candidate_agy_rpc_ports`). agy IS up in this pane, and the hosts
where this happens run one agy per pod, so the lone candidate is ours.
3. **Pane present, NO agy found yet** (the CLI ``tmux_start_on_attach``
early-poll window: the pane is still the shell, agy not yet ``exec``-ed) →
``None``, so the caller keeps polling. It must NOT fall back to candidates
here: a foreign agy could be the only candidate, and binding it would
durably cross-bind this session — the exact defect this scoping prevents.
With **no pane supplied** (``tmux_socket``/``tmux_target`` is ``None`` — e.g. a
remote runner with no local socket) there is nothing to scope to, so this
falls back to the lowest candidate (best-effort; cannot disambiguate). The
candidate fallback is logged so a wrong-agy bind on a multi-agy host is
diagnosable.
:param tmux_socket: This session's tmux socket path, or ``None`` when no local
pane is reachable (remote runner).
:param tmux_target: This session's tmux target, or ``None`` as above.
:returns: The port to ``StartCascade`` onto, or ``None`` when the port is not
resolvable yet (the caller keeps polling until its deadline).
"""
if tmux_socket is not None and tmux_target is not None:
resolution = resolve_pane_agy_rpc_port_state(tmux_socket, tmux_target)
if resolution.port is not None:
return resolution.port # state 1: our agy, scoped port
if not resolution.agy_found:
# state 3: our agy is not up yet (CLI early-poll). Keep polling — do
# NOT fall back to candidates, where a foreign agy could be the only
# one and would cross-bind this session.
return None
# state 2: our agy IS up but its port is not lsof-attributable (restricted
# /proc; one-agy-per-pod) — the candidate scan below is safe and necessary.
_logger.debug(
"agy cold-start: pane agy found for target=%s but its port is not "
"lsof-attributable; using the host-wide candidate scan (safe — agy is "
"up in this pane)",
tmux_target,
)
candidates = _candidate_agy_rpc_ports()
if not candidates:
return None
port = candidates[0]
if tmux_socket is None or tmux_target is None:
_logger.debug(
"agy cold-start: no local tmux pane to scope to; using the lowest "
"candidate connect-RPC port %s (single-agy hosts and remote runners "
"are unaffected; a multi-agy host risks a wrong-agy bind)",
port,
)
return port
def _candidate_agy_rpc_ports() -> list[int]:
"""
Return every live agy connect-RPC port, validated by ``Heartbeat``.
Primary path: ``lsof`` each running agy pid's loopback LISTEN ports (precise
— scopes to agy). Fallback: when ``lsof`` attributes no ports — a restricted
``/proc`` where agy's listening socket is not in its pid's fd table (verified
on uid-1000 k8s pods, where agy 1.0.10 holds the listener in a backend the
agy process does not own as an fd) — enumerate every loopback LISTEN port
from ``/proc/net/tcp`` via :func:`_list_loopback_listen_ports`, which needs
no fd/ptrace access.
The fallback fires only when agy IS running but lsof saw none of its ports —
not when no agy is running at all — so a turn-injection attempt against a
dead session does not heartbeat every unrelated loopback service. The scan is
also capped at :data:`_MAX_FALLBACK_PROBE_PORTS` (lowest-first), with the drop
logged, to bound the probe count on a host with many loopback listeners.
Either way the candidates are ``Heartbeat``-filtered, so only agy's TLS
connect-RPC port(s) survive (agy's plain-HTTP port and unrelated loopback
listeners fail the probe). Callers additionally confirm conversation
ownership before injecting, so a stray non-agy port can never be written to.
:returns: Sorted connect-RPC ports that answer ``Heartbeat`` with HTTP 200.
"""
agy_pids = _list_agy_pids()
ports: set[int] = set()
for pid in agy_pids:
ports.update(_parse_loopback_listen_ports(_run_lsof_listen_ports(pid)))
if agy_pids and not ports:
loopback = _list_loopback_listen_ports()
if len(loopback) > _MAX_FALLBACK_PROBE_PORTS:
_logger.warning(
"agy port discovery fallback: %d loopback listeners exceed the "
"%d-probe cap; probing the lowest %d only",
len(loopback),
_MAX_FALLBACK_PROBE_PORTS,
_MAX_FALLBACK_PROBE_PORTS,
)
loopback = loopback[:_MAX_FALLBACK_PROBE_PORTS]
ports.update(loopback)
return [port for port in sorted(ports) if _heartbeat_ok(port)]
def conversation_id_owned_by_pid(pid: int, candidate_ids: Iterable[str]) -> str | None:
"""
Return which candidate conversation id a specific agy pid owns.
The deterministic counterpart to :func:`resolve_language_server_port`: that
function answers "which port owns this *known* id"; this one answers "which
of these candidate ids does *this* process own", binding discovery to a
specific agy pid (e.g. the one running under this session's tmux pane).
agy exposes no method to *list* its conversation id, only
``GetConversationMetadata`` which confirms a given id. So this resolves the
pid's own connect-RPC port via :func:`discover_language_server_port` and asks
that port to confirm each candidate (``GetConversationMetadata`` returns
metadata only for an id that server hosts), eliminating the newest-dir guess,
the cross-launch ambiguity, and the resulting livelock.
Correctness over liveness, mirroring the forwarder's
``_discover_conversation_id``: a pid's connect-RPC server can confirm more
than one candidate brain-dir id, so every candidate is tested and the result
is bound only when *exactly one* matches. Zero or multiple matches return
``None`` (the multi-match case is refused rather than guessed, since
first-match would depend on ``candidate_ids`` order and could bind the wrong
transcript / external_session_id).
On a host where ``lsof`` cannot attribute the socket to *pid* (restricted
``/proc``), the resolve falls back to checking the candidates against EVERY
live agy connect-RPC port rather than just *pid*'s. Because the conversation
id is globally unique this never mis-binds; but two concurrent same-host
sessions sharing the HOME-global brain dir can put both their ids in
``candidate_ids``, in which case both are confirmed (by different ports) and
the call refuses (returns ``None``) instead of guessing — the forwarder then
retries. So the fallback trades the pid-scoped *liveness* of the binding for
the same safety, never correctness. (The executor's write path,
:func:`resolve_language_server_port`, is unaffected: it already holds the
target conversation id, so a single port resolves unambiguously.)
:param pid: agy process id whose conversation to resolve, e.g. ``72753``.
:param candidate_ids: agy conversation ids to test (e.g. the in-window
brain-dir names).
:returns: The candidate id this pid's connect-RPC server confirms it hosts
when exactly one matches; ``None`` when the port cannot be resolved (agy
not bound yet / exited), no candidate matches, or — refusing to guess —
more than one candidate matches.
"""
candidates = list(candidate_ids)
# Prefer the pid-scoped port (lsof — precise). When lsof cannot attribute the
# socket to the pid (restricted /proc; see _candidate_agy_rpc_ports), fall
# back to every live agy connect-RPC port: the conversation id is globally
# unique, so a candidate is confirmed only by the port that actually hosts
# it — the binding stays correct without the pid scoping.
scoped = discover_language_server_port(pid)
ports = [scoped] if scoped is not None else _candidate_agy_rpc_ports()
if not ports:
_logger.debug("agy pid=%s has no resolvable connect-RPC port yet", pid)
return None
matched = [
candidate
for candidate in candidates
if any(_conversation_matches(port, candidate) for port in ports)
]
if len(matched) == 1:
_logger.info(
"agy conversation resolved by pid ownership: pid=%s ports=%s conversation=%s",
pid,
ports,
matched[0],
)
return matched[0]
if not matched:
_logger.debug(
"agy pid=%s (ports=%s) owns none of the %d candidate conversation ids",
pid,
ports,
len(candidates),
)
return None
_logger.warning(
"agy pid=%s (ports=%s) confirmed %d candidate conversation ids; refusing "
"to guess which it owns: %s",
pid,
ports,
len(matched),
matched[:_MAX_LOGGED_AMBIGUOUS_IDS],
)
return None
def resolve_language_server_port(conversation_id: str) -> int | None:
"""
Resolve agy's connect-RPC port for a conversation by validated discovery.
Enumerates candidate agy connect-RPC ports (:func:`_candidate_agy_rpc_ports`
— ``lsof`` per agy pid, or ``/proc/net/tcp`` where ``lsof`` cannot attribute
the socket to a pid) and returns the first that owns ``conversation_id`` via
``GetConversationMetadata``. With one agy this is unambiguous; with several
the conversation check picks the one actually hosting this conversation.
Discovery is port-first (not pid-first): agy is launched under
``tmux_start_on_attach`` so the launcher never captures a pid, and on some
hosts agy's listening socket is owned by a backend that is neither the agy
pid nor ``lsof``-attributable — so a pid is not a reliable key. The
``GetConversationMetadata`` ownership check is what makes a port safe to use:
a recycled/foreign port (a different live agy) is rejected because it does
not host ``conversation_id``.
:param conversation_id: agy conversation id the turn targets, e.g.
``"90468e33-..."``. Used to disambiguate when multiple agy processes
run.
:returns: A validated connect-RPC port that hosts ``conversation_id``, or
``None`` when no running agy could be resolved.
"""
for port in _candidate_agy_rpc_ports():
if _conversation_matches(port, conversation_id):
_logger.info(
"agy connect-RPC port resolved by conversation match: port=%s conversation=%s",
port,
conversation_id,
)
return port
_logger.warning(
"could not resolve an agy connect-RPC port for conversation=%s",
conversation_id,
)
return None
async def interrupt_turn(port: int, conversation_id: str) -> bool:
"""
Best-effort interrupt of an in-flight agy turn via connect-RPC (FAIL-OPEN).
Intended to back the post-hoc audit's "decline → stop the turn" on a policy
DENY/ASK (see :mod:`omnigent.antigravity_native_audit`). It is **best-effort
and fail-open**: the audit warning is always surfaced regardless of whether
this succeeds, and the offending tool has already run (agy writes a step only
at ``DONE``), so a cancel can at most stop *subsequent* tools in the same
turn — it never prevents the violation.
.. warning:: **Wired OFF — request contract unverified.**
agy 1.0.8 exposes ``ForceStopCascadeTree`` / ``CancelCascadeInvocation`` /
``CancelCascadeSteps`` on the connect-RPC surface (method names verified in
the binary), but the request CONTRACT is **not** verified: the proto field
tags show these key on an internal ``cascade_id`` / ``invocation_id`` —
agy's per-turn identifiers, which are NOT exposed in the transcript and
which this forwarder does not hold (it only knows the *conversation* id).
The stop semantics are also unconfirmed against a live process. Until the
request shape (and the cascade-id source) is verified end-to-end, this
function does not issue an RPC: it logs and returns ``False`` so the gated
caller treats the interrupt as unavailable and relies on the audit warning
alone.
TODO(antigravity-interrupt): verify ``ForceStopCascadeTreeRequest`` (does
it accept the conversation id, or does it require a cascade id obtained
from ``GetCascadeTrajectorySteps`` / a metadata RPC?) on a live agy, then
implement the POST (a loopback connect-RPC call like the
``GetConversationMetadata`` probe above) and flip
``_INTERRUPT_ON_AUDIT_DENY`` (forwarder) to opt-in.
:param port: agy connect-RPC (TLS) port, e.g. ``52548``.
:param conversation_id: agy conversation id whose turn should be stopped.
:returns: ``True`` when agy accepted the cancel; ``False`` when the interrupt
is unavailable (currently always, pending contract verification) or the
call failed. Never raises — fail-open.
"""
# Intentionally not issuing an RPC: the request contract is unverified and
# the forwarder lacks agy's internal cascade/invocation id. Returning False
# keeps the caller fail-open (the audit warning still surfaces). The method
# constant is referenced so the verified name is not dead and a future
# implementation has the anchor.
del port # unused until the contract is verified (see warning above)
_logger.debug(
"agy turn interrupt requested but unavailable (RPC %s contract unverified); "
"relying on the audit warning only: conversation=%s",
_METHOD_FORCE_STOP_CASCADE_TREE,
conversation_id,
)
return False