From 73fb92c97ab06049cb3590aa0dbef56391f7afe5 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:19:31 +0530 Subject: [PATCH 01/34] feat(antigravity): add first-class antigravity-app host surface, config skills, schedule loop, and sidecar Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/agent_onboarding.py | 12 + loopx/agy_goal_mode/__init__.py | 355 +++++++++++++++--- loopx/bootstrap_command_pack.py | 2 + loopx/cli_commands/slash_commands.py | 21 ++ .../cli_commands/support_control_heartbeat.py | 1 + loopx/cli_commands/workflow_skills.py | 15 +- loopx/control_plane/goals/start_contract.py | 1 + loopx/control_plane/heartbeat/host.py | 1 + .../scheduler/execution_context.py | 44 ++- .../turn_driver/codex_context.py | 5 +- loopx/doctor.py | 81 ++++ loopx/host_loop_activation.py | 21 +- loopx/hosts/skill_facade.py | 29 +- loopx/slash_command_install.py | 186 ++++++++- loopx/slash_commands.py | 1 + loopx/workflow_skill_install.py | 19 +- tests/test_agy_host_surface.py | 167 ++++++++ 17 files changed, 883 insertions(+), 78 deletions(-) diff --git a/loopx/agent_onboarding.py b/loopx/agent_onboarding.py index 19eafcee5e..7bacb923ab 100644 --- a/loopx/agent_onboarding.py +++ b/loopx/agent_onboarding.py @@ -64,6 +64,8 @@ def _surface_install_command(agent_type: str, cli_bin: str, project: str) -> str return f"{host_command(cli_bin)} slash-commands --install --surface zcode" if agent_type == "agy": return f"{host_command(cli_bin)} slash-commands --install --surface agy" + if agent_type == "antigravity-app": + return f"{host_command(cli_bin)} slash-commands --install --surface antigravity-app" if agent_type == "kiro-cli": return f"{host_command(cli_bin)} slash-commands --install --surface kiro-cli" if agent_type == "pi": @@ -297,6 +299,7 @@ def _bootstrap_pack_command( "cursor-agent": "cursor-agent", "zcode": "zcode", "agy": "agy", + "antigravity-app": "antigravity-app", "kiro-cli": "kiro-cli", "deepseek-harness": "deepseek-harness", "deepseek-harness-native": "deepseek-harness-native", @@ -373,6 +376,15 @@ def _start_instruction(agent_type: str) -> str: "wake Prompt; recurring via MaxIterations) when quota allows more " "work." ) + if agent_type == "antigravity-app": + return ( + "Invoke the LoopX skill from `~/.gemini/config/skills` via `/loopx `; " + "after todo writeback, start every turn with `quota should-run`, suspend " + "directly via the native `schedule(DurationSeconds=..., Prompt=...)` tool " + "when `waiting_external` or CI checks are pending (instead of polling), " + "and optionally configure `~/.gemini/config/sidecar.json` for background " + "periodic heartbeats." + ) if agent_type == "kiro-cli": return ( f"Run `/loopx ` (the LoopX skill installed in " diff --git a/loopx/agy_goal_mode/__init__.py b/loopx/agy_goal_mode/__init__.py index 0d7301f3cb..e3037f27b2 100644 --- a/loopx/agy_goal_mode/__init__.py +++ b/loopx/agy_goal_mode/__init__.py @@ -1,26 +1,46 @@ +"""Antigravity CLI (``agy``) & Antigravity App (``antigravity-app``) host-surface constants and helpers. + +Supports both: +- Legacy Antigravity CLI (``agy`` 1.x) flat skills in ``~/.gemini/antigravity-cli/skills/.md`` with native ``/goal`` and ``schedule`` tools. +- Antigravity 2.0 / Antigravity App (``antigravity-app``) directory skills in ``~/.gemini/config/skills//SKILL.md``, in-session ``schedule(DurationSeconds=..., Prompt=...)`` closed-loop timer suspension (for ``waiting_external`` / CI waits), background ``sidecar.json`` periodic heartbeat template, and optional ``mcp_config.json`` + ``hooks.json`` continuation gate registration. +""" + from __future__ import annotations +import os +import shlex from pathlib import Path from typing import Any AGY_INSTALL_SURFACE = "agy" -DEFAULT_AGY_HOME = ".gemini/antigravity-cli" -SKILLS_SUBDIR = "skills" -SKILLS_ROOT_LABEL = "~/.gemini/antigravity-cli/skills" -AGY_ACCEPTED_INPUTS = ( +ANTIGRAVITY_APP_INSTALL_SURFACE = "antigravity-app" +ANTIGRAVITY_APP_AGENT_TYPE = "antigravity-app" + +# Every token normalize_agent_type and the slash-commands installer accept for +# the Antigravity CLI surface. +AGY_ACCEPTED_INPUTS: tuple[str, ...] = ( "agy", "antigravity", "antigravity-cli", "antigravity_cli", "antigravity cli", + "google-antigravity", "google antigravity", ) -# Native in-session automation primitives agy ships (verified against agy -# 1.1.18 live; no external driver involved). The `schedule` tool takes -# DurationSeconds + a wake Prompt, supports recurring wakes via MaxIterations -# and one-shot early-termination conditions. -AGY_NATIVE_WAKE_TOOLS = ( +ANTIGRAVITY_APP_ACCEPTED_INPUTS: tuple[str, ...] = ( + "antigravity-app", + "antigravity_app", + "antigravity app", + "agy-app", + "agy_app", + "agy app", + "antigravity-2", + "antigravity 2.0", +) + +# Native in-session automation primitives agy and Antigravity App ship. +AGY_NATIVE_WAKE_TOOLS: tuple[str, ...] = ( "schedule", "manage_task", "invoke_subagent", @@ -28,38 +48,147 @@ "manage_inbox", ) -AGY_NATIVE_WAKE_FACTS = ( - "native `schedule` tool: DurationSeconds + Prompt wake message, recurring " - "wakes via MaxIterations, one-shot early-termination conditions", - "background tasks (`manage_task`) and async subagents " - "(`invoke_subagent`/`send_message`) wake a live session without an " - "external driver", - "`hooks.json` (user or plugin) runs PostToolUse/Stop/PostInvocation " - "automation on tool events", +AGY_NATIVE_WAKE_FACTS: tuple[str, ...] = ( + "The built-in `schedule` tool arms a one-shot timer (`DurationSeconds` + " + "wake `Prompt`; optional `TimerCondition`: `never`, `any`, or a sender ID) " + "or a recurring cron (`CronExpression` + `Prompt`; optional `MaxIterations`, " + "`IsDaemon`, `ExactTiming`) that delivers its `Prompt` as a high-priority " + "wakeup notification when it fires; `manage_task` lists or kills active " + "schedules.", + "When a LoopX turn enters `waiting_external` (for example CI checks or PR " + "review waits), call `schedule(DurationSeconds=, " + "Prompt=...)` and end the turn immediately so the host wakes the session " + "reactively instead of running shell sleep polling loops.", + "Background `run_command` tasks and `invoke_subagent` runs also resume the " + "session reactively on completion or message arrival, so the agent does " + "not need a polling loop inside a turn.", + "All native wakes live inside the running Antigravity process; for " + "background periodic heartbeats in Antigravity 2.0 (`antigravity-app`), " + "configure `sidecar.json` and optional `hooks.json` continuation gates in " + "`~/.gemini/config`.", ) # Native goal primitive (built into the agy binary; live-verified on 1.1.18 in -# both the interactive TUI and headless `-p` print mode). The host injects a -# forced-continuation contract that audits work until the model emits the -# completion token; `GoalState` persists across the session. +# both the TUI and headless `agy -p`). AGY_GOAL_COMMAND = "/goal" -AGY_GOAL_COMMAND_DESCRIPTION = "Run until the specified goal is completely finished." +AGY_GOAL_COMMAND_DESCRIPTION = ( + "Pursue a goal relentlessly until it is completely finished" +) AGY_GOAL_COMPLETE_TOKEN = "" AGY_GOAL_CANCELLED_TOKEN = "" +DEFAULT_AGY_HOME = ".gemini/antigravity-cli" +DEFAULT_ANTIGRAVITY_CONFIG_HOME = ".gemini/config" +ANTIGRAVITY_SKILLS_ROOT_LABEL = "~/.gemini/config/skills" +ANTIGRAVITY_LEGACY_SKILLS_ROOT_LABEL = "~/.gemini/antigravity-cli/skills" +ANTIGRAVITY_MCP_CONFIG_FILENAME = "mcp_config.json" +ANTIGRAVITY_HOOKS_CONFIG_FILENAME = "hooks.json" +ANTIGRAVITY_SIDECAR_FILENAME = "sidecar.json" +ANTIGRAVITY_MCP_CONFIG_LABEL = "~/.gemini/config/mcp_config.json" +ANTIGRAVITY_HOOKS_CONFIG_LABEL = "~/.gemini/config/hooks.json" +ANTIGRAVITY_SIDECAR_CONFIG_LABEL = "~/.gemini/config/sidecar.json" + +ANTIGRAVITY_APP_AGENT_TYPE_CATALOG_ENTRY: dict[str, Any] = { + "display_name": "Antigravity App", + "host_loop": ( + "Antigravity App native schedule timer closed loop + optional background " + "sidecar.json heartbeat and hooks.json continuation gate" + ), + "entry": ( + f"the LoopX skill installed in {ANTIGRAVITY_SKILLS_ROOT_LABEL} " + f"(compat: {ANTIGRAVITY_LEGACY_SKILLS_ROOT_LABEL})" + ), + "accepted_inputs": list(ANTIGRAVITY_APP_ACCEPTED_INPUTS), +} + + +def build_antigravity_sidecar_template( + *, + goal_id: str = "", + agent_id: str | None = None, + cli_bin: str = "loopx", + interval_seconds: int = 300, +) -> dict[str, Any]: + """Build a public-safe Antigravity 2.0 ``sidecar.json`` periodic heartbeat template.""" + quota_args = [ + cli_bin, + "--format", + "json", + "quota", + "should-run", + "--goal-id", + goal_id, + "--runtime-profile", + "antigravity_app", + ] + if agent_id: + quota_args.extend(["--agent-id", agent_id]) + heartbeat_args = [ + cli_bin, + "--format", + "json", + "heartbeat-prompt", + "--goal-id", + goal_id, + "--thin", + "--runtime-profile", + "antigravity_app", + ] + if agent_id: + heartbeat_args.extend(["--agent-id", agent_id]) + return { + "schema_version": "loopx_antigravity_sidecar_template_v0", + "config_path": ANTIGRAVITY_SIDECAR_CONFIG_LABEL, + "sidecar": { + "loopx-heartbeat": { + "description": ( + f"LoopX periodic quota-gated heartbeat for goal {goal_id}" + ), + "interval_seconds": int(interval_seconds), + "gate_command": shlex.join(quota_args), + "wake_when_json_field_true": "should_run", + "prompt_command": shlex.join(heartbeat_args), + "prompt_json_field": "task_body", + "suspend_on_waiting_external": { + "tool": "schedule", + "duration_field": "scheduler_hint.poll_after_seconds", + "default_duration_seconds": int(interval_seconds), + }, + } + }, + } + + +def build_antigravity_hooks_template( + *, + cli_bin: str = "loopx", +) -> dict[str, Any]: + """Build a public-safe ``hooks.json`` continuation gate entry for Antigravity 2.0.""" + return { + "loopx": { + "schema_version": "loopx_antigravity_hooks_gate_v0", + "event": "before_continuation", + "command": cli_bin, + "args": [ + "--format", + "json", + "quota", + "should-run", + "--runtime-profile", + "antigravity_app", + ], + "require_json_field_true": "should_run", + "on_waiting_external": "suspend_via_schedule_timer", + } + } + def agy_activation_extras() -> dict[str, Any]: """Keyword overrides for ``agy_cli_activation``'s facade call. Keeps the agy host facts (goal command, tokens, wake tools, gate text, activation steps) in the agy package instead of growing - ``host_loop_activation.py`` past its module metric budget. agy ships BOTH - halves of a goal-mode host, verified live on 1.1.18: a native goal - primitive — ``/goal `` ("Run until the specified goal is completely - finished"), whose host-side forced continuation audits work until the - model emits ```` — and a native in-session - scheduler (``schedule`` tool plus background-task/subagent wakes). The - loop and wakes live and die with the session; no cross-session daemon. + ``host_loop_activation.py`` past its module metric budget. """ return { "activation_method": "bind_native_goal_with_advisory_quota_entry", @@ -71,48 +200,158 @@ def agy_activation_extras() -> dict[str, Any]: "goal_complete_token": AGY_GOAL_COMPLETE_TOKEN, "goal_cancelled_token": AGY_GOAL_CANCELLED_TOKEN, "native_wake_tools": list(AGY_NATIVE_WAKE_TOOLS), + "native_wake_facts": list(AGY_NATIVE_WAKE_FACTS), + "skills_roots": [ + ANTIGRAVITY_SKILLS_ROOT_LABEL, + ANTIGRAVITY_LEGACY_SKILLS_ROOT_LABEL, + ], "missing_host_tool_gate": ( "agy's /goal loop and native wakes fire only while the CLI " - "session is alive; there is no cross-session daemon. LoopX has " - "no host hook intercepting native continuations, so quota " - "pacing is advisory: the facade instructs, it cannot enforce. " - "If the session ends before the goal is done, show the exact " - "heartbeat-prompt command for the user to run and do not claim " - "unattended heartbeat support." + "session stays alive (there is no background daemon), and " + "agy exposes no hook to intercept `/goal`'s forced-" + "continuation audit, so `quota should-run` is advisory " + "guidance rather than a host-enforced gate. If the session " + "cannot keep entering through `quota should-run`, show the " + "exact heartbeat-prompt command for the user to run and do " + "not claim unattended heartbeat support." ), }, "extra_activation_steps": [ - "Bind the objective with the native goal command: " + "Bind the objective to agy's native goal loop by running " "`/goal ` — agy's forced continuation audits the work " - "until completion is emitted; `` ends the " - "goal loop and `` cancels it.", - "Start every turn, native wake, and audit-continuation with " - "`quota should-run` and honor a stop/throttle decision before any " - "delivery work — instructed pacing, not a host-enforced gate.", - "When a turn ends with work remaining and quota allows more, arm " - "the next bounded segment with the native `schedule` tool " - "(recurring wakes bounded by MaxIterations); the wake re-enters " - "through `quota should-run` on the same advisory basis.", + "at the end of each turn and keeps the agent going until every " + f"aspect is verified and `{AGY_GOAL_COMPLETE_TOKEN}` is emitted " + f"(or `{AGY_GOAL_CANCELLED_TOKEN}` to abort).", + "At the start of every turn, wake and `/goal` audit-continuation, " + "run `quota should-run` first; when it says stop, emit " + f"`{AGY_GOAL_CANCELLED_TOKEN}` (or `{AGY_GOAL_COMPLETE_TOKEN}` " + "when the LoopX goal is genuinely complete) so the native goal " + "loop terminates instead of free-running past the quota.", + "When `quota should-run` allows continuation and more work or a " + "deferred check remains (such as `waiting_external` or CI checks), " + "arm the next wakeup in-session with agy's built-in `schedule` tool " + "(`DurationSeconds` + wake `Prompt`, or `CronExpression` with " + "`MaxIterations` for bounded recurring checks) instead of blind " + "polling; cancel obsolete timers via `manage_task` before stopping.", + ], + "host_scheduler_note": ( + "use the built-in `schedule` tool (`DurationSeconds` or " + "`CronExpression` with `MaxIterations`) for in-session wakes, and " + f"emit `{AGY_GOAL_COMPLETE_TOKEN}` / `{AGY_GOAL_CANCELLED_TOKEN}` " + "to end the native `/goal` loop (neither survives after the agy " + "process exits)." + ), + } + + +def antigravity_app_activation_extras( + *, + goal_id: str = "", + agent_id: str | None = None, + cli_bin: str = "loopx", +) -> dict[str, Any]: + """Keyword overrides for ``antigravity_app_activation``'s facade call.""" + sidecar_template = build_antigravity_sidecar_template( + goal_id=goal_id, + agent_id=agent_id, + cli_bin=cli_bin, + ) + hooks_template = build_antigravity_hooks_template(cli_bin=cli_bin) + wake_prompt = ( + f"Run `{cli_bin} --format json quota should-run --goal-id {goal_id}" + + (f" --agent-id {agent_id}" if agent_id else "") + + " --runtime-profile antigravity_app` and continue the active LoopX Todo when allowed." + ) + return { + "activation_method": "bind_antigravity_schedule_loop_and_sidecar", + "extra_host_mutation": { + "host_loop_primitive": "antigravity-schedule-tool-and-sidecar", + "loop_driver": "antigravity_app_schedule_timer_and_sidecar_loop", + "quota_gate_enforcement": "hooks_or_advisory_quota_entry", + "native_goal_command": AGY_GOAL_COMMAND, + "goal_complete_token": AGY_GOAL_COMPLETE_TOKEN, + "goal_cancelled_token": AGY_GOAL_CANCELLED_TOKEN, + "native_wake_tools": list(AGY_NATIVE_WAKE_TOOLS), + "native_wake_facts": list(AGY_NATIVE_WAKE_FACTS), + "skills_roots": [ + ANTIGRAVITY_SKILLS_ROOT_LABEL, + ANTIGRAVITY_LEGACY_SKILLS_ROOT_LABEL, + ], + "host_mcp_server": "loopx", + "host_mcp_config": ANTIGRAVITY_MCP_CONFIG_LABEL, + "host_hooks_config": ANTIGRAVITY_HOOKS_CONFIG_LABEL, + "host_sidecar_config": ANTIGRAVITY_SIDECAR_CONFIG_LABEL, + "in_session_schedule_call": { + "tool": "schedule", + "arguments": { + "DurationSeconds": 180, + "Prompt": wake_prompt, + "TimerCondition": "any", + }, + "when_to_use": ( + "Call `schedule(DurationSeconds=..., Prompt=...)` and end the turn " + "whenever `waiting_external`, CI checks, or PR review gates block " + "immediate completion; never use shell sleep loops." + ), + }, + "sidecar_template": sidecar_template, + "hooks_template": hooks_template, + "missing_host_tool_gate": ( + "Antigravity App in-session `schedule(DurationSeconds=...)` timers fire " + "while the app session is active; for unattended background ticks, " + f"configure `sidecar.json` in `{ANTIGRAVITY_SIDECAR_CONFIG_LABEL}` " + f"and optional `hooks.json` in `{ANTIGRAVITY_HOOKS_CONFIG_LABEL}`." + ), + }, + "extra_activation_steps": [ + f"Install or refresh the Antigravity skill and optional MCP/hooks surface (`{cli_bin} slash-commands --install --surface antigravity-app --with-agy-mcp --with-agy-hooks` and `{cli_bin} workflow-skills --install --host-surface antigravity-app`).", + "When a turn enters `waiting_external` or waits on CI/review checks, suspend directly with the native `schedule(DurationSeconds=, Prompt=...)` tool and end the turn instead of polling in a shell loop.", + f"For background periodic heartbeats, apply the generated `sidecar_template` in `{ANTIGRAVITY_SIDECAR_CONFIG_LABEL}` so each tick gates on `quota should-run` before waking the agent.", ], "host_scheduler_note": ( - "the native `/goal` loop and `schedule` tool drive this session; " - "quota should-run entry is advisory guidance in the facade, not a " - "host-enforced gate." + "use the native `schedule(DurationSeconds=..., Prompt=...)` tool to suspend " + "and wake on `waiting_external` / CI waits, or configure `sidecar.json` for " + "background periodic heartbeats." ), } def agy_home(value: str | None = None) -> Path: - """The fixed Antigravity CLI home: ``~/.gemini/antigravity-cli``. - - agy discovers global skills from ``~/.gemini/antigravity-cli/skills`` - using the documented flat layout — one markdown file per skill - (``skills/.md`` with front matter). The official CLI docs do not - document any home override, so LoopX exposes none either: installs target - exactly this path (HOME-relative, which keeps tests hermetic), and the - root is shared with no other host (for example, Gemini CLI reads - ``~/.gemini/skills``). ``value`` is an internal injection point - for tests, not a public override. + """Resolve the legacy Antigravity CLI user home directory (``~/.gemini/antigravity-cli``). + + Honours an explicit ``--agy-home`` override or ``AGY_CLI_HOME`` environment + variable while defaulting to ``~/.gemini/antigravity-cli`` for flat + ``skills/.md`` compatibility. + """ + raw = value or os.environ.get("AGY_CLI_HOME") + if raw: + return Path(raw).expanduser() + return (Path.home() / DEFAULT_AGY_HOME).expanduser() + + +def antigravity_config_home(value: str | None = None) -> Path: + """Resolve the Antigravity 2.0 / Antigravity App config home (``~/.gemini/config``). + + Honours an explicit ``--agy-home`` override or ``AGY_HOME`` / ``ANTIGRAVITY_HOME`` + environment variables, defaulting to ``~/.gemini/config`` where + ``skills//SKILL.md``, ``mcp_config.json``, ``hooks.json``, and + ``sidecar.json`` live. """ - raw = value or str(Path.home() / DEFAULT_AGY_HOME) + raw = ( + value + or os.environ.get("AGY_HOME") + or os.environ.get("ANTIGRAVITY_HOME") + or str(Path.home() / DEFAULT_ANTIGRAVITY_CONFIG_HOME) + ) return Path(raw).expanduser() + + +def antigravity_skill_roots(value: str | None = None) -> tuple[Path, ...]: + """Return Antigravity skill roots in discovery order (2.0 config first, then legacy CLI).""" + if value: + base = Path(value).expanduser() + return (base / "skills",) + return ( + antigravity_config_home() / "skills", + agy_home() / "skills", + ) diff --git a/loopx/bootstrap_command_pack.py b/loopx/bootstrap_command_pack.py index 2b93c9a335..7aeb3742c7 100644 --- a/loopx/bootstrap_command_pack.py +++ b/loopx/bootstrap_command_pack.py @@ -84,6 +84,7 @@ "cursor-agent", "zcode", "agy", + "antigravity-app", "kiro-cli", "deepseek-harness", "deepseek-harness-native", @@ -373,6 +374,7 @@ def build_start_goal_host_surface_selection_packet( "cursor-agent": "cursor-agent driving its own loop through the LoopX skill facade and MCP server", "zcode": "ZCode loop via the LoopX skill facade", "agy": "agy session loop via the LoopX skill facade; native /goal + schedule wakes while the session lives", + "antigravity-app": "Antigravity App loop via ~/.gemini/config/skills; native schedule timer closed loop + sidecar.json heartbeat", "kiro-cli": "Kiro CLI loop via the LoopX skill facade; native /goal iteration budget", "deepseek-harness": "DeepSeek Harness automation loop through loopx.dsh_goal_mode (compat: scripts/dsh_turn_host_adapter.py)", "deepseek-harness-native": "DeepSeek Harness same-session LoopX skill and plugin driver", diff --git a/loopx/cli_commands/slash_commands.py b/loopx/cli_commands/slash_commands.py index 79a0f94b47..ccd6021d23 100644 --- a/loopx/cli_commands/slash_commands.py +++ b/loopx/cli_commands/slash_commands.py @@ -71,6 +71,7 @@ def register_slash_commands_command( "z-code", "agy", "antigravity", + "antigravity-app", "kiro", "kiro-cli", "pi", @@ -116,6 +117,23 @@ def register_slash_commands_command( dest="zcode_home", help=argparse.SUPPRESS, ) + parser.add_argument( + "--agy-home", + help=( + "Antigravity config home for skill, MCP, and hooks installation. " + "Defaults to AGY_HOME or ~/.gemini/config (and ~/.gemini/antigravity-cli for legacy CLI)." + ), + ) + parser.add_argument( + "--with-agy-mcp", + action="store_true", + help="Optionally register the LoopX MCP server in Antigravity mcp_config.json.", + ) + parser.add_argument( + "--with-agy-hooks", + action="store_true", + help="Optionally register the LoopX continuation gate in Antigravity hooks.json.", + ) parser.add_argument( "--opencode-home", help="OpenCode config directory. Defaults to OPENCODE_CONFIG_DIR or ~/.config/opencode.", @@ -159,6 +177,8 @@ def handle_slash_commands_command( execute=bool((args.install or args.uninstall) and not args.dry_run), uninstall=bool(args.uninstall), with_goal_bridge=bool(args.with_goal_bridge), + with_agy_mcp=bool(getattr(args, "with_agy_mcp", False)), + with_agy_hooks=bool(getattr(args, "with_agy_hooks", False)), surfaces=args.surface, cli_bin=args.cli_bin, include_legacy_aliases=not bool(args.no_legacy_aliases), @@ -168,6 +188,7 @@ def handle_slash_commands_command( gemini_home=args.gemini_home, cursor_home=args.cursor_home, zcode_home=getattr(args, "zcode_home", None), + agy_home=getattr(args, "agy_home", None), pi_project=args.pi_project, pi_scope=args.pi_scope, ) diff --git a/loopx/cli_commands/support_control_heartbeat.py b/loopx/cli_commands/support_control_heartbeat.py index ffb47f3065..d30e934d00 100644 --- a/loopx/cli_commands/support_control_heartbeat.py +++ b/loopx/cli_commands/support_control_heartbeat.py @@ -223,6 +223,7 @@ def handle_heartbeat_prompt_command( None, SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT.value, SchedulerRuntimeProfile.TRAE_APP.value, + SchedulerRuntimeProfile.ANTIGRAVITY_APP.value, SchedulerRuntimeProfile.GENERIC_CLI_AGENT_LOOP.value, # Kiro CLI moved off generic_cli onto its own profile; its facade # still reads the thin Agent input, so keep that output shape. diff --git a/loopx/cli_commands/workflow_skills.py b/loopx/cli_commands/workflow_skills.py index 08f317cfc0..0bc9c4cdaf 100644 --- a/loopx/cli_commands/workflow_skills.py +++ b/loopx/cli_commands/workflow_skills.py @@ -48,9 +48,21 @@ def register_workflow_skills_command( ) parser.add_argument( "--host-surface", - choices=["ark-managed-agent", "deepseek-harness-native"], + choices=[ + "ark-managed-agent", + "deepseek-harness-native", + "agy", + "antigravity-app", + ], help="Generate the managed $loopx entry for one exact host surface.", ) + parser.add_argument( + "--agy-home", + help=( + "Antigravity config home (defaults to AGY_HOME or ~/.gemini/config); " + "selects /skills when --skills-dir is omitted." + ), + ) parser.add_argument( "--dry-run", action="store_true", @@ -72,6 +84,7 @@ def handle_workflow_skills_command( uninstall=bool(args.uninstall), cli_bin=args.cli_bin, host_surface=args.host_surface, + agy_home=getattr(args, "agy_home", None), ) print_payload( payload, diff --git a/loopx/control_plane/goals/start_contract.py b/loopx/control_plane/goals/start_contract.py index 040fb3e1cb..0236c8a5af 100644 --- a/loopx/control_plane/goals/start_contract.py +++ b/loopx/control_plane/goals/start_contract.py @@ -120,6 +120,7 @@ def build_goal_start_contract( "cursor-agent": "agent-driven cursor-agent loop; every turn enters through quota should-run", "zcode": "agent-driven ZCode loop; turns enter through quota should-run", "agy": "agy native /goal loop with schedule wakes; quota entry advisory", + "antigravity-app": "Antigravity App native schedule timer closed loop + sidecar.json heartbeat", "kiro-cli": "Kiro CLI native /goal loop; quota entry advisory", "deepseek-harness-native": "DeepSeek Harness same-session plugin driver; every turn enters through quota should-run", "ark-managed-agent": "one-shot Goal", diff --git a/loopx/control_plane/heartbeat/host.py b/loopx/control_plane/heartbeat/host.py index 78796997bf..0900fe27e0 100644 --- a/loopx/control_plane/heartbeat/host.py +++ b/loopx/control_plane/heartbeat/host.py @@ -137,6 +137,7 @@ def resolve_exact_heartbeat_turn_identity( SchedulerRuntimeProfile.GENERIC_CLI_AGENT_LOOP, SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT, SchedulerRuntimeProfile.TRAE_APP, + SchedulerRuntimeProfile.ANTIGRAVITY_APP, }: raise ValueError( "--turn-instance-id requires runtime-profile generic_cli or " diff --git a/loopx/control_plane/scheduler/execution_context.py b/loopx/control_plane/scheduler/execution_context.py index 54b0b30420..0c9da7822f 100644 --- a/loopx/control_plane/scheduler/execution_context.py +++ b/loopx/control_plane/scheduler/execution_context.py @@ -17,6 +17,7 @@ class HostSurface(str, Enum): CODEX_CLI = "codex_cli" CODEX_APP_SERVER = "codex_app_server" TRAE_APP = "trae_app" + ANTIGRAVITY_APP = "antigravity_app" GENERIC_CLI = "generic_cli" CLAUDE_CODE = "claude_code" KUNLUNCODE = "kunluncode" @@ -44,6 +45,7 @@ class SchedulerRuntimeProfile(str, Enum): CODEX_APP_SSH_VISIBLE = "codex_app_ssh_goal" CODEX_CLI_VISIBLE = "codex_cli" TRAE_APP = "trae_app" + ANTIGRAVITY_APP = "antigravity_app" CLAUDE_CODE_VISIBLE = "claude_code" KUNLUNCODE_VISIBLE = "kunluncode" KIRO_CLI_VISIBLE = "kiro_cli" @@ -84,6 +86,7 @@ class SchedulerRuntimeProfile(str, Enum): { SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT, SchedulerRuntimeProfile.TRAE_APP, + SchedulerRuntimeProfile.ANTIGRAVITY_APP, SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE, } ) @@ -97,6 +100,7 @@ class SchedulerRuntimeProfile(str, Enum): { SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT, SchedulerRuntimeProfile.TRAE_APP, + SchedulerRuntimeProfile.ANTIGRAVITY_APP, } ) @@ -127,6 +131,11 @@ class SchedulerRuntimeProfile(str, Enum): SchedulerOwner.HOST_AUTOMATION, ExecutionMode.HOSTED_AUTOMATION, ), + SchedulerRuntimeProfile.ANTIGRAVITY_APP: ( + HostSurface.ANTIGRAVITY_APP, + SchedulerOwner.HOST_AUTOMATION, + ExecutionMode.HOSTED_AUTOMATION, + ), SchedulerRuntimeProfile.CLAUDE_CODE_VISIBLE: ( HostSurface.CLAUDE_CODE, SchedulerOwner.AGENT_CLI_LOOP, @@ -185,7 +194,12 @@ def codex_app_applicable(self) -> bool: @property def app_automation_applicable(self) -> bool: return ( - self.host_surface in {HostSurface.CODEX_APP, HostSurface.TRAE_APP} + self.host_surface + in { + HostSurface.CODEX_APP, + HostSurface.TRAE_APP, + HostSurface.ANTIGRAVITY_APP, + } and self.scheduler_owner is SchedulerOwner.HOST_AUTOMATION and self.execution_mode is ExecutionMode.HOSTED_AUTOMATION ) @@ -200,11 +214,13 @@ def projection(self) -> dict[str, Any]: "valid": True, } # Keep existing non-Trae projections byte-compatible. The neutral App - # field is needed to identify Trae's independent automation contract, - # but a second negative applicability flag adds no information for CLI - # and Goal runtimes. - if self.host_surface is HostSurface.TRAE_APP: - projection["app_automation_applicability"] = "applicable" + # field is needed to identify Trae/Antigravity's independent automation + # contract, but a second negative applicability flag adds no information + # for CLI and Goal runtimes. + if self.host_surface in {HostSurface.TRAE_APP, HostSurface.ANTIGRAVITY_APP}: + projection["app_automation_applicability"] = ( + "applicable" if self.app_automation_applicable else "not_applicable" + ) else: projection["codex_app_applicability"] = ( "applicable" if self.codex_app_applicable else "not_applicable" @@ -235,7 +251,10 @@ def projection(self) -> dict[str, Any]: "valid": False, "errors": list(self.errors), } - if supplied.get("host_surface") == HostSurface.TRAE_APP.value: + if supplied.get("host_surface") in { + HostSurface.TRAE_APP.value, + HostSurface.ANTIGRAVITY_APP.value, + }: projection["app_automation_applicability"] = "blocked_invalid_context" else: projection["codex_app_applicability"] = "blocked_invalid_context" @@ -257,8 +276,17 @@ def _validation_errors(context: SchedulerExecutionContext) -> list[str]: HostSurface.KUNLUNCODE, HostSurface.KIRO_CLI, } - if context.host_surface in {HostSurface.CODEX_APP, HostSurface.TRAE_APP}: + if context.host_surface in { + HostSurface.CODEX_APP, + HostSurface.TRAE_APP, + HostSurface.ANTIGRAVITY_APP, + }: host_name = context.host_surface.value + if context.host_surface is HostSurface.ANTIGRAVITY_APP and ( + context.scheduler_owner is SchedulerOwner.AGENT_CLI_LOOP + and context.execution_mode is ExecutionMode.INTERACTIVE + ): + return errors if context.scheduler_owner is not SchedulerOwner.HOST_AUTOMATION: errors.append(f"{host_name} requires scheduler_owner=host_automation") if context.execution_mode is not ExecutionMode.HOSTED_AUTOMATION: diff --git a/loopx/control_plane/turn_driver/codex_context.py b/loopx/control_plane/turn_driver/codex_context.py index a1bbbf292c..7da0b9791c 100644 --- a/loopx/control_plane/turn_driver/codex_context.py +++ b/loopx/control_plane/turn_driver/codex_context.py @@ -80,8 +80,9 @@ def context_delivery_activation(setting: str, *, runtime_profile: str | None = N # rejects them. Unknown/conflicting declarations never bypass this # adapter, and generic_cli still needs the benchmark declaration. if codex_machine_preference and context.host_surface in { - HostSurface.ARK_MANAGED_AGENT, HostSurface.TRAE_APP, HostSurface.CLAUDE_CODE, - HostSurface.KUNLUNCODE, HostSurface.KIRO_CLI, HostSurface.LOCAL_SCHEDULER, + HostSurface.ARK_MANAGED_AGENT, HostSurface.TRAE_APP, HostSurface.ANTIGRAVITY_APP, + HostSurface.CLAUDE_CODE, HostSurface.KUNLUNCODE, HostSurface.KIRO_CLI, + HostSurface.LOCAL_SCHEDULER, }: return context_delivery_activation("off") if (context.host_surface is HostSurface.CODEX_APP diff --git a/loopx/doctor.py b/loopx/doctor.py index fbbec1fd20..18f945932d 100644 --- a/loopx/doctor.py +++ b/loopx/doctor.py @@ -772,6 +772,23 @@ def collect_doctor( "loopx slash-commands --install --surface zcode " f"--zcode-home {home_arg}" ) + elif canonical_agent_type in {"agy", "antigravity-app"}: + from .agy_goal_mode import antigravity_config_home + from .slash_command_install import inspect_skill_facades + + agy_cfg_skills = antigravity_config_home() / "skills" + if (agy_cfg_skills / "loopx-project" / "SKILL.md").exists(): + skill_roots = (agy_cfg_skills,) + skills = installed_skill_summary(skill_roots) + project_skill = skills["loopx-project"] + elif (agy_cfg_skills / "loopx" / "SKILL.md").exists(): + skill_roots = (agy_cfg_skills,) + skills = inspect_skill_facades(agy_cfg_skills) + project_skill = skills["loopx"] + else: + skill_roots = (agy_cfg_skills, *codex_skill_roots()) + skills = installed_skill_summary(skill_roots) + project_skill = skills["loopx-project"] else: skill_roots = codex_skill_roots() skills = installed_skill_summary(skill_roots) @@ -1242,6 +1259,70 @@ def collect_doctor( payload["fix"] = skill_fix else: payload["fix"] += "\nAfter restoring the LoopX installation/runtime, " + skill_fix + if canonical_agent_type in {"agy", "antigravity-app"}: + from .agy_goal_mode import ( + ANTIGRAVITY_HOOKS_CONFIG_FILENAME, + ANTIGRAVITY_MCP_CONFIG_FILENAME, + ANTIGRAVITY_SIDECAR_FILENAME, + agy_home, + antigravity_config_home, + ) + from .slash_command_install import inspect_skill_facades + + agy_cfg_home = antigravity_config_home() + legacy_agy_home = agy_home() + agy_workflow_readback = inspect_skill_install_readback( + skills_dir=agy_cfg_home / "skills", + required_skill_ids=ARK_MANAGED_AGENT_REQUIRED_SKILL_IDS, + source_root=repo_root, + ) + agy_facades = inspect_skill_facades(agy_cfg_home / "skills") + agy_facades_ready = all( + item.get("required_phrases") for item in agy_facades.values() + ) + legacy_loopx_exists = (legacy_agy_home / "skills" / "loopx.md").is_file() + agy_ready = bool( + agy_workflow_readback.get("ready") + or agy_facades_ready + or legacy_loopx_exists + ) + payload["antigravity"] = { + "config_home": str(agy_cfg_home), + "legacy_cli_home": str(legacy_agy_home), + "skills_dir": str(agy_cfg_home / "skills"), + "legacy_skills_dir": str(legacy_agy_home / "skills"), + "workflow_skills_readback": agy_workflow_readback, + "command_facades": agy_facades, + "command_facades_ready": agy_facades_ready, + "legacy_cli_loopx_skill_exists": legacy_loopx_exists, + "mcp_config_path": str(agy_cfg_home / ANTIGRAVITY_MCP_CONFIG_FILENAME), + "mcp_config_exists": ( + agy_cfg_home / ANTIGRAVITY_MCP_CONFIG_FILENAME + ).is_file(), + "hooks_config_path": str(agy_cfg_home / ANTIGRAVITY_HOOKS_CONFIG_FILENAME), + "hooks_config_exists": ( + agy_cfg_home / ANTIGRAVITY_HOOKS_CONFIG_FILENAME + ).is_file(), + "sidecar_config_path": str(agy_cfg_home / ANTIGRAVITY_SIDECAR_FILENAME), + "sidecar_config_exists": ( + agy_cfg_home / ANTIGRAVITY_SIDECAR_FILENAME + ).is_file(), + "repair_commands": [ + "loopx workflow-skills --install --host-surface antigravity-app", + "loopx slash-commands --install --surface antigravity-app --with-agy-mcp --with-agy-hooks", + ], + } + checks.append( + { + "id": "antigravity_host_surface_ready", + "required": False, + "ok": agy_ready, + "applicable": True, + "detail": ( + f"{agy_cfg_home / 'skills'} (ready={agy_ready})" + ), + } + ) if deep_validation: payload["release_candidate"] = deep_validation return payload diff --git a/loopx/host_loop_activation.py b/loopx/host_loop_activation.py index 5e71612b99..3ecd9255f8 100644 --- a/loopx/host_loop_activation.py +++ b/loopx/host_loop_activation.py @@ -5,10 +5,14 @@ from .zcode_goal_mode import native_goal_activation from .agent_registry import normalize_registered_agents -from .agy_goal_mode import AGY_ACCEPTED_INPUTS +from .agy_goal_mode import ( + AGY_ACCEPTED_INPUTS, + ANTIGRAVITY_APP_AGENT_TYPE_CATALOG_ENTRY, +) from .control_plane.scheduler.execution_context import SchedulerRuntimeProfile from .hosts.skill_facade import ( agy_cli_activation, + antigravity_app_activation, cursor_agent_activation, gemini_cli_activation, kiro_cli_activation, @@ -61,6 +65,7 @@ def scheduler_command_binding_for_agent_type( "cursor-agent": SchedulerRuntimeProfile.GENERIC_CLI_AGENT_LOOP, "zcode": SchedulerRuntimeProfile.GENERIC_CLI_AGENT_LOOP, "agy": SchedulerRuntimeProfile.GENERIC_CLI_AGENT_LOOP, + "antigravity-app": SchedulerRuntimeProfile.ANTIGRAVITY_APP, "kiro-cli": SchedulerRuntimeProfile.KIRO_CLI_VISIBLE, "deepseek-harness": SchedulerRuntimeProfile.GENERIC_CLI_AGENT_LOOP, "deepseek-harness-native": SchedulerRuntimeProfile.GENERIC_CLI_AGENT_LOOP, @@ -91,6 +96,7 @@ def agent_type_uses_host_managed_skills(agent_type: str) -> bool: "cursor-agent", "zcode", "agy", + "antigravity-app", "kiro-cli", "deepseek-harness", "deepseek-harness-native", @@ -282,6 +288,7 @@ def agent_type_uses_host_managed_skills(agent_type: str) -> bool: "entry": "the LoopX skill installed in ~/.gemini/antigravity-cli/skills", "accepted_inputs": list(AGY_ACCEPTED_INPUTS), }, + "antigravity-app": ANTIGRAVITY_APP_AGENT_TYPE_CATALOG_ENTRY, "kiro-cli": KIRO_CLI_AGENT_TYPE_CATALOG_ENTRY, "deepseek-harness": { "display_name": "DeepSeek Harness", @@ -389,6 +396,10 @@ def to_payload(self) -> dict[str, Any]: "agy": "agy", "antigravity": "agy", "antigravity-cli": "agy", + "antigravity-app": "antigravity-app", + "antigravity_app": "antigravity-app", + "agy-app": "antigravity-app", + "agy_app": "antigravity-app", "kiro-cli": "kiro-cli", "kiro": "kiro-cli", "deepseek-harness": "deepseek-harness", @@ -532,6 +543,7 @@ def _heartbeat_commands( "cursor-agent": "Cursor Agent CLI loop gated by LoopX", "zcode": "ZCode agent loop gated by LoopX", "agy": "Antigravity CLI agent loop with advisory LoopX quota pacing", + "antigravity-app": "Antigravity App schedule timer & sidecar heartbeat loop", "kiro-cli": "Kiro CLI native /goal loop with advisory LoopX quota pacing", "deepseek-harness": "DeepSeek Harness automation loop gated by LoopX", "deepseek-harness-native": "DeepSeek Harness same-session plugin loop gated by LoopX", @@ -1306,6 +1318,13 @@ def build_host_loop_activation_packet( ) elif canonical == "agy": surface = agy_cli_activation(commands, cli_bin) + elif canonical == "antigravity-app": + surface = antigravity_app_activation( + commands, + cli_bin, + goal_id=goal_id, + agent_id=str(selected_agent_id) if selected_agent_id else None, + ) elif canonical == "kiro-cli": surface = kiro_cli_activation(commands, cli_bin) elif canonical == "deepseek-harness": diff --git a/loopx/hosts/skill_facade.py b/loopx/hosts/skill_facade.py index 7033ecd262..58172bb047 100644 --- a/loopx/hosts/skill_facade.py +++ b/loopx/hosts/skill_facade.py @@ -16,7 +16,12 @@ from typing import Any -from ..agy_goal_mode import agy_activation_extras +from ..agy_goal_mode import ( + ANTIGRAVITY_APP_INSTALL_SURFACE, + ANTIGRAVITY_SKILLS_ROOT_LABEL, + agy_activation_extras, + antigravity_app_activation_extras, +) from ..kiro_cli_goal_mode import ( KIRO_CLI_INSTALL_SURFACE, SKILLS_ROOT_LABEL as KIRO_CLI_SKILLS_ROOT_LABEL, @@ -159,6 +164,28 @@ def agy_cli_activation(commands: dict[str, str], cli_bin: str) -> dict[str, Any] ) +def antigravity_app_activation( + commands: dict[str, str], + cli_bin: str, + *, + goal_id: str = "", + agent_id: str | None = None, +) -> dict[str, Any]: + return skill_facade_cli_activation( + commands, + cli_bin, + host_label="Antigravity App", + host_surface="antigravity_app_loop", + install_surface=ANTIGRAVITY_APP_INSTALL_SURFACE, + skills_root=ANTIGRAVITY_SKILLS_ROOT_LABEL, + **antigravity_app_activation_extras( + goal_id=goal_id, + agent_id=agent_id, + cli_bin=cli_bin, + ), + ) + + def kiro_cli_activation(commands: dict[str, str], cli_bin: str) -> dict[str, Any]: return skill_facade_cli_activation( commands, diff --git a/loopx/slash_command_install.py b/loopx/slash_command_install.py index 5688093f64..9527ea42a8 100644 --- a/loopx/slash_command_install.py +++ b/loopx/slash_command_install.py @@ -11,7 +11,13 @@ from pathlib import Path from typing import Any -from .agy_goal_mode import agy_home as _agy_home +from .agy_goal_mode import ( + ANTIGRAVITY_HOOKS_CONFIG_FILENAME, + ANTIGRAVITY_MCP_CONFIG_FILENAME, + agy_home as _agy_home, + antigravity_config_home as _antigravity_config_home, + build_antigravity_hooks_template, +) from .kiro_cli_goal_mode import ( KIRO_CLI_MCP_CONFIG_SUBPATH as _KIRO_MCP_CONFIG_SUBPATH, SKILLS_ROOT_LABEL as _KIRO_SKILLS_ROOT_LABEL, @@ -422,6 +428,8 @@ def materialize_loopx_entry_skill( None, "ark-managed-agent", "deepseek-harness-native", + "agy", + "antigravity-app", }: raise ValueError(f"unsupported fixed LoopX entry host surface: {host_surface}") spec = next( @@ -701,6 +709,8 @@ def _normalize_surfaces(surfaces: list[str] | None) -> list[str]: candidates = ["zcode"] elif surface in {"agy", "antigravity", "antigravity-cli"}: candidates = ["agy"] + elif surface in {"antigravity-app", "antigravity_app", "agy-app", "agy_app"}: + candidates = ["antigravity-app"] elif surface in {"kiro", "kiro-cli", "kirocli"}: candidates = ["kiro-cli"] else: @@ -718,6 +728,9 @@ def _normalize_surfaces(surfaces: list[str] | None) -> list[str]: CURSOR_MCP_MARKER_NAME = ".loopx-managed-mcp.json" CURSOR_MCP_MARKER_SCHEMA_VERSION = "loopx_managed_cursor_mcp_v0" KIRO_CLI_MCP_MARKER_SCHEMA_VERSION = "loopx_managed_kiro_cli_mcp_v0" +ANTIGRAVITY_MCP_MARKER_SCHEMA_VERSION = "loopx_managed_antigravity_mcp_v0" +ANTIGRAVITY_HOOKS_MARKER_NAME = ".loopx-managed-hooks.json" +ANTIGRAVITY_HOOKS_MARKER_SCHEMA_VERSION = "loopx_managed_antigravity_hooks_v0" McpCommandFactory = Callable[[], tuple[str, str] | None] @@ -901,11 +914,86 @@ def _merge_managed_mcp_json( return "written" +def _merge_managed_hooks_json( + *, + config_path: Path, + cli_bin: str, + uninstall: bool, + execute: bool, +) -> str: + """Add or remove the ``loopx`` continuation gate in Antigravity ``hooks.json``.""" + marker_path = config_path.parent / ANTIGRAVITY_HOOKS_MARKER_NAME + try: + raw = ( + json.loads(config_path.read_text(encoding="utf-8")) + if config_path.exists() + else {} + ) + except (OSError, json.JSONDecodeError): + return "blocked_invalid_antigravity_hooks_json" + if not isinstance(raw, dict): + return "blocked_invalid_antigravity_hooks_json" + hooks = raw.get("hooks") + if hooks is None: + hooks = {} + if not isinstance(hooks, dict): + return "blocked_invalid_antigravity_hooks_json" + + existing = hooks.get(MANAGED_MCP_KEY) + try: + marker_payload = json.loads(marker_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + marker_payload = {} + recorded_hooks = ( + marker_payload.get("hooks") + if isinstance(marker_payload, dict) and isinstance(marker_payload.get("hooks"), dict) + else {} + ) + recorded = recorded_hooks.get(MANAGED_MCP_KEY) + loopx_owned = existing is not None and existing == recorded + if execute and recorded is not None and not loopx_owned: + marker_path.unlink(missing_ok=True) + + if uninstall: + if existing is None: + return "absent" + if not loopx_owned: + return "skipped_user_owned_hooks_entry" + if not execute: + return "would_retire" + hooks.pop(MANAGED_MCP_KEY, None) + raw["hooks"] = hooks + _write_json_atomic(config_path, raw) + marker_path.unlink(missing_ok=True) + return "retired" + + if existing is not None and not loopx_owned: + return "skipped_user_owned_hooks_entry" + entry = build_antigravity_hooks_template(cli_bin=cli_bin)[MANAGED_MCP_KEY] + if not execute: + return "unchanged" if existing == entry else "would_write" + if existing == entry: + return "unchanged" + hooks[MANAGED_MCP_KEY] = entry + raw["hooks"] = hooks + _write_json_atomic(config_path, raw) + _write_json_atomic( + marker_path, + { + "schema_version": ANTIGRAVITY_HOOKS_MARKER_SCHEMA_VERSION, + "hooks": {MANAGED_MCP_KEY: entry}, + }, + ) + return "written" + + def install_slash_commands( *, execute: bool, uninstall: bool = False, with_goal_bridge: bool = False, + with_agy_mcp: bool = False, + with_agy_hooks: bool = False, surfaces: list[str] | None = None, cli_bin: str = "loopx", include_legacy_aliases: bool = True, @@ -934,6 +1022,7 @@ def install_slash_commands( cursor_root = _cursor_home(cursor_home) zcode_root = _zcode_home(zcode_home or zcode_agents_home) agy_root = _agy_home(agy_home) + agy_config_root = _antigravity_config_home(agy_home) kiro_root = _kiro_home(kiro_home) if pi_scope not in {"project", "user"}: raise ValueError("pi_scope must be 'project' or 'user'") @@ -1146,10 +1235,10 @@ def install_slash_commands( if "agy" in effective_surfaces: # Antigravity CLI discovers global skills from the fixed # ~/.gemini/antigravity-cli/skills root using the documented flat - # layout (one .md per skill). The official docs describe no home - # override, so LoopX offers none: installs target exactly that path, - # and the root belongs to agy alone (Gemini CLI reads ~/.gemini/skills), - # so the managed skill surfaces never collide across different hosts. + # layout (one .md per skill). When --agy-home is not explicitly + # overriding the root, also populate Antigravity 2.0's directory-based + # ~/.gemini/config/skills//SKILL.md root so both Antigravity App + # and legacy CLI work out of the box. _install_skill_facade( specs=facade_specs, installed=installed, @@ -1161,6 +1250,73 @@ def install_slash_commands( uninstall=uninstall, flat=True, ) + if agy_home is None and "antigravity-app" not in effective_surfaces: + _install_skill_facade( + specs=facade_specs, + installed=installed, + skills_dir=agy_config_root / "skills", + surface="antigravity-app", + host_surfaces=["antigravity-app", "agy"], + mechanism="antigravity_app_skills", + execute=execute, + uninstall=uninstall, + invoke_prefix="/", + ) + + if "antigravity-app" in effective_surfaces: + _install_skill_facade( + specs=facade_specs, + installed=installed, + skills_dir=agy_config_root / "skills", + surface="antigravity-app", + host_surfaces=["antigravity-app", "agy"], + mechanism="antigravity_app_skills", + execute=execute, + uninstall=uninstall, + invoke_prefix="/", + ) + + if ("antigravity-app" in effective_surfaces or "agy" in effective_surfaces) and with_agy_mcp: + agy_mcp_path = agy_config_root / ANTIGRAVITY_MCP_CONFIG_FILENAME + mcp_status = _merge_managed_mcp_json( + config_path=agy_mcp_path, + marker_schema_version=ANTIGRAVITY_MCP_MARKER_SCHEMA_VERSION, + invalid_status="blocked_invalid_antigravity_mcp_json", + command_factory=lambda: _loopx_mcp_command(), + uninstall=uninstall, + execute=execute, + ) + installed.append( + { + "surface": "antigravity-app", + "host_surfaces": ["antigravity-app", "agy"], + "mechanism": "antigravity_mcp_server", + "command": "loopx", + "path": str(agy_mcp_path), + "status": mcp_status, + "invoke_as": [], + } + ) + + if ("antigravity-app" in effective_surfaces or "agy" in effective_surfaces) and with_agy_hooks: + agy_hooks_path = agy_config_root / ANTIGRAVITY_HOOKS_CONFIG_FILENAME + hooks_status = _merge_managed_hooks_json( + config_path=agy_hooks_path, + cli_bin=cli_bin, + uninstall=uninstall, + execute=execute, + ) + installed.append( + { + "surface": "antigravity-app", + "host_surfaces": ["antigravity-app", "agy"], + "mechanism": "antigravity_hooks_gate", + "command": "loopx", + "path": str(agy_hooks_path), + "status": hooks_status, + "invoke_as": [], + } + ) if "kiro-cli" in effective_surfaces: # Kiro CLI discovers global skills from /skills//SKILL.md @@ -1562,6 +1718,26 @@ def install_slash_commands( "cursor_mcp_path": str(cursor_root / "mcp.json") if "cursor" in effective_surfaces else None, "zcode_skill_dir": str(zcode_root / "skills") if "zcode" in effective_surfaces else None, "agy_skill_dir": str(agy_root / "skills") if "agy" in effective_surfaces else None, + "antigravity_app_skill_dir": ( + str(agy_config_root / "skills") + if ( + "antigravity-app" in effective_surfaces + or ("agy" in effective_surfaces and agy_home is None) + ) + else None + ), + "antigravity_mcp_path": ( + str(agy_config_root / ANTIGRAVITY_MCP_CONFIG_FILENAME) + if with_agy_mcp + and ("antigravity-app" in effective_surfaces or "agy" in effective_surfaces) + else None + ), + "antigravity_hooks_path": ( + str(agy_config_root / ANTIGRAVITY_HOOKS_CONFIG_FILENAME) + if with_agy_hooks + and ("antigravity-app" in effective_surfaces or "agy" in effective_surfaces) + else None + ), "kiro_cli_skill_dir": str(kiro_root / "skills") if "kiro-cli" in effective_surfaces else None, "kiro_cli_mcp_path": str(kiro_root / _KIRO_MCP_CONFIG_SUBPATH) if "kiro-cli" in effective_surfaces else None, "opencode_skill_dir": str(opencode_root / "skills") if "opencode" in effective_surfaces else None, diff --git a/loopx/slash_commands.py b/loopx/slash_commands.py index 023a08c079..13d23f2a01 100644 --- a/loopx/slash_commands.py +++ b/loopx/slash_commands.py @@ -77,6 +77,7 @@ def build_slash_command_catalog( "cursor-agent": "drive the loop from the agent's own turns; enter every turn through quota should-run", "zcode": "drive the loop from the agent's turns; enter through quota should-run", "agy": "native /goal + schedule wakes; quota entry advisory", + "antigravity-app": "native schedule(DurationSeconds=...) timer closed loop + sidecar.json heartbeat", "kiro-cli": "native /goal loop; quota entry advisory", "manual": "wire an external scheduler or run quota/status manually", "other-agent": "use the custom host loop driver declared by `loopx agent-onboard`", diff --git a/loopx/workflow_skill_install.py b/loopx/workflow_skill_install.py index c28940c889..11ca74240e 100644 --- a/loopx/workflow_skill_install.py +++ b/loopx/workflow_skill_install.py @@ -11,6 +11,7 @@ from typing import Any, Iterator, Mapping from . import __version__ +from .agy_goal_mode import antigravity_config_home from .file_lock import exclusive_file_lock from .python_install_owner import interpreter_invocation from .skill_install_readback import ( @@ -150,7 +151,14 @@ def resolve_workflow_skill_source() -> dict[str, Any]: } -def default_workflow_skills_dir(env: Mapping[str, str] | None = None) -> Path: +def default_workflow_skills_dir( + env: Mapping[str, str] | None = None, + *, + host_surface: str | None = None, + agy_home: str | None = None, +) -> Path: + if agy_home or host_surface in {"agy", "antigravity-app"}: + return antigravity_config_home(agy_home) / "skills" source_env = os.environ if env is None else env configured = str(source_env.get("CODEX_HOME") or "").strip() codex_root = Path(configured).expanduser() if configured else Path.home() / ".codex" @@ -280,8 +288,15 @@ def workflow_skill_install( uninstall: bool = False, cli_bin: str = "loopx", host_surface: str | None = None, + agy_home: str | None = None, ) -> dict[str, Any]: - target_root = (skills_dir or default_workflow_skills_dir()).expanduser().resolve() + target_root = ( + skills_dir + or default_workflow_skills_dir( + host_surface=host_surface, + agy_home=agy_home, + ) + ).expanduser().resolve() source = resolve_workflow_skill_source() source_root = Path(source["source_root"]) frozen_source_revision = ( diff --git a/tests/test_agy_host_surface.py b/tests/test_agy_host_surface.py index cf79d3a713..bc4d9d857d 100644 --- a/tests/test_agy_host_surface.py +++ b/tests/test_agy_host_surface.py @@ -28,6 +28,16 @@ scheduler_command_binding_for_agent_type, ) from loopx.slash_command_install import install_slash_commands +from loopx.workflow_skill_install import workflow_skill_install +from loopx.doctor import collect_doctor +from loopx.control_plane.scheduler.execution_context import ( + ExecutionMode, + HostSurface, + SchedulerOwner, + SchedulerRuntimeProfile, + resolve_scheduler_execution_context, + scheduler_execution_context_for_runtime_profile, +) from loopx.agy_goal_mode import ( AGY_GOAL_CANCELLED_TOKEN, AGY_GOAL_COMMAND, @@ -35,7 +45,11 @@ AGY_GOAL_COMPLETE_TOKEN, AGY_NATIVE_WAKE_FACTS, AGY_NATIVE_WAKE_TOOLS, + ANTIGRAVITY_APP_AGENT_TYPE, agy_home, + antigravity_config_home, + build_antigravity_hooks_template, + build_antigravity_sidecar_template, ) HOST_SURFACE = "agy" @@ -251,3 +265,156 @@ def test_native_goal_and_wake_facts_match_the_live_probes() -> None: "send_message", "manage_inbox", } + + +def test_antigravity_app_first_class_host_surface_and_schedule_sidecar_contract( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Antigravity 2.0 (antigravity-app) is a first-class host surface alongside agy.""" + start_dir = tmp_path / "start" + start_dir.mkdir() + payload = start_goal_accepts_surface(ANTIGRAVITY_APP_AGENT_TYPE, start_dir) + assert ( + payload["command_pack"]["host_loop_activation"]["host_surface"] + == "antigravity_app_loop" + ) + activation = build_host_loop_activation_packet( + agent_type=ANTIGRAVITY_APP_AGENT_TYPE, + goal_id="surface-goal", + agent_id="probe-agent", + registered_agents=["probe-agent"], + ) + assert activation["host_surface"] == "antigravity_app_loop" + assert ( + activation["activation_method"] + == "bind_antigravity_schedule_loop_and_sidecar" + ) + mutation = activation["host_mutation"] + assert mutation["host_loop_primitive"] == "antigravity-schedule-tool-and-sidecar" + assert mutation["in_session_schedule_call"]["tool"] == "schedule" + assert "DurationSeconds" in mutation["in_session_schedule_call"]["arguments"] + assert "waiting_external" in mutation["in_session_schedule_call"]["when_to_use"] + assert ( + mutation["sidecar_template"]["schema_version"] + == "loopx_antigravity_sidecar_template_v0" + ) + assert ( + "antigravity_app" + in mutation["sidecar_template"]["sidecar"]["loopx-heartbeat"]["gate_command"] + ) + assert ( + mutation["hooks_template"]["loopx"]["schema_version"] + == "loopx_antigravity_hooks_gate_v0" + ) + + gate_dir = tmp_path / "gate" + gate_dir.mkdir() + selection_gate_offers_surface(ANTIGRAVITY_APP_AGENT_TYPE, gate_dir) + + assert normalize_agent_type("antigravity-app") == ANTIGRAVITY_APP_AGENT_TYPE + assert normalize_agent_type("agy-app") == ANTIGRAVITY_APP_AGENT_TYPE + assert normalize_agent_type("antigravity 2.0") == ANTIGRAVITY_APP_AGENT_TYPE + assert scheduler_command_binding_for_agent_type(ANTIGRAVITY_APP_AGENT_TYPE) == { + "runtime_profile": "antigravity_app" + } + + # Verify both hosted automation (sidecar.json) and interactive (in-session schedule) execution contexts + hosted_ctx = scheduler_execution_context_for_runtime_profile( + SchedulerRuntimeProfile.ANTIGRAVITY_APP + ) + assert hosted_ctx.ok + assert hosted_ctx.context is not None + assert hosted_ctx.context.app_automation_applicable is True + assert ( + hosted_ctx.projection()["app_automation_applicability"] == "applicable" + ) + + interactive_ctx = resolve_scheduler_execution_context( + { + "host_surface": HostSurface.ANTIGRAVITY_APP.value, + "scheduler_owner": SchedulerOwner.AGENT_CLI_LOOP.value, + "execution_mode": ExecutionMode.INTERACTIVE.value, + } + ) + assert interactive_ctx.ok + + +def test_antigravity_dual_skills_mcp_hooks_workflow_skills_and_doctor( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify ~/.gemini/config/skills//SKILL.md dual compatibility, + --agy-home override, optional mcp_config.json + hooks.json registration, + workflow-skills --install, and doctor checks.""" + import loopx.slash_command_install as sci + + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.delenv("AGY_HOME", raising=False) + monkeypatch.delenv("ANTIGRAVITY_HOME", raising=False) + monkeypatch.setattr( + sci, + "_loopx_mcp_command", + lambda: ("/usr/bin/python3", "/opt/loopx/mcp_server.py"), + ) + + # 1. Default `agy` surface installs BOTH legacy flat ~/.gemini/antigravity-cli/skills/.md + # AND Antigravity 2.0 ~/.gemini/config/skills//SKILL.md + dual_payload = install_slash_commands( + execute=True, + surfaces=["agy"], + ) + assert dual_payload["ok"] is True + assert ( + tmp_path / "home" / ".gemini" / "antigravity-cli" / "skills" / "loopx.md" + ).is_file() + assert ( + tmp_path / "home" / ".gemini" / "config" / "skills" / "loopx" / "SKILL.md" + ).is_file() + + # 2. Explicit --agy-home with --with-agy-mcp and --with-agy-hooks + custom_agy_home = tmp_path / "custom-agy-config" + app_payload = install_slash_commands( + execute=True, + surfaces=["antigravity-app"], + agy_home=str(custom_agy_home), + with_agy_mcp=True, + with_agy_hooks=True, + ) + assert app_payload["ok"] is True + assert (custom_agy_home / "skills" / "loopx" / "SKILL.md").is_file() + assert (custom_agy_home / "mcp_config.json").is_file() + assert (custom_agy_home / "hooks.json").is_file() + + # Uninstall retires managed MCP, hooks, and skill files cleanly + uninstall_payload = install_slash_commands( + execute=True, + uninstall=True, + surfaces=["antigravity-app"], + agy_home=str(custom_agy_home), + with_agy_mcp=True, + with_agy_hooks=True, + ) + assert uninstall_payload["ok"] is True + assert not (custom_agy_home / "skills" / "loopx" / "SKILL.md").exists() + + # 3. workflow-skills --install --host-surface antigravity-app --agy-home + wf_payload = workflow_skill_install( + execute=True, + host_surface="antigravity-app", + agy_home=str(custom_agy_home), + ) + assert wf_payload["ok"] is True + assert (custom_agy_home / "skills" / "loopx-project" / "SKILL.md").is_file() + assert (custom_agy_home / "skills" / "loopx" / "SKILL.md").is_file() + + # 4. doctor --agent-type antigravity-app checks the Antigravity config home + monkeypatch.setenv("AGY_HOME", str(custom_agy_home)) + doc = collect_doctor(agent_type="antigravity-app") + assert "antigravity" in doc + assert doc["antigravity"]["config_home"] == str(custom_agy_home) + assert doc["antigravity"]["workflow_skills_readback"]["ready"] is True + agy_check = next( + c for c in doc["checks"] if c["id"] == "antigravity_host_surface_ready" + ) + assert agy_check["ok"] is True From 0f196e4c71da2205df0e40f50fb04ba1a3c3faa1 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:24:49 +0530 Subject: [PATCH 02/34] slash-commands: treat empty mcp_config.json and hooks.json as empty object Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/slash_command_install.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/loopx/slash_command_install.py b/loopx/slash_command_install.py index 9527ea42a8..feb04fce5f 100644 --- a/loopx/slash_command_install.py +++ b/loopx/slash_command_install.py @@ -843,11 +843,8 @@ def _merge_managed_mcp_json( """ marker_path = config_path.parent / CURSOR_MCP_MARKER_NAME try: - raw = ( - json.loads(config_path.read_text(encoding="utf-8")) - if config_path.exists() - else {} - ) + text = config_path.read_text(encoding="utf-8") if config_path.exists() else "" + raw = json.loads(text) if text.strip() else {} except (OSError, json.JSONDecodeError): return invalid_status if not isinstance(raw, dict): @@ -924,11 +921,8 @@ def _merge_managed_hooks_json( """Add or remove the ``loopx`` continuation gate in Antigravity ``hooks.json``.""" marker_path = config_path.parent / ANTIGRAVITY_HOOKS_MARKER_NAME try: - raw = ( - json.loads(config_path.read_text(encoding="utf-8")) - if config_path.exists() - else {} - ) + text = config_path.read_text(encoding="utf-8") if config_path.exists() else "" + raw = json.loads(text) if text.strip() else {} except (OSError, json.JSONDecodeError): return "blocked_invalid_antigravity_hooks_json" if not isinstance(raw, dict): From c971f53964a016cee041ed08f5410fd076216c94 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:26:42 +0530 Subject: [PATCH 03/34] doctor: accept materialized workflow skills for antigravity facade readiness Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/doctor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/loopx/doctor.py b/loopx/doctor.py index 18f945932d..c446c11808 100644 --- a/loopx/doctor.py +++ b/loopx/doctor.py @@ -1277,8 +1277,12 @@ def collect_doctor( source_root=repo_root, ) agy_facades = inspect_skill_facades(agy_cfg_home / "skills") + workflow_skill_ids = set( + agy_workflow_readback.get("materialized_skill_ids") or () + ) agy_facades_ready = all( - item.get("required_phrases") for item in agy_facades.values() + item.get("required_phrases") or name in workflow_skill_ids + for name, item in agy_facades.items() ) legacy_loopx_exists = (legacy_agy_home / "skills" / "loopx.md").is_file() agy_ready = bool( From 1fa9ea43b76097cd12d63e666ff49c636af8eb4f Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:45:42 +0530 Subject: [PATCH 04/34] fix(agy): clean ruff unused imports and preserve read-only should-run context delivery Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/cli_commands/context_machine.py | 5 +++++ tests/test_agy_host_surface.py | 3 --- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/loopx/cli_commands/context_machine.py b/loopx/cli_commands/context_machine.py index 5a2854344a..df9d522f54 100644 --- a/loopx/cli_commands/context_machine.py +++ b/loopx/cli_commands/context_machine.py @@ -108,6 +108,11 @@ def apply_context_machine_options( value = machine_registry.resolve("context_delivery").public_descriptor()["configuration_template"] if value["mode"] == "off" or (using_default and os.name != "posix"): return + if command == "should-run" and not ( + getattr(args, "turn_instance_id", None) or bool(getattr(args, "begin_turn", False)) + ): + args.context_delivery = "off" + return # Recovery does not activate a run mode or create a missing session. if command != "context-read": args.context_delivery = "on" diff --git a/tests/test_agy_host_surface.py b/tests/test_agy_host_surface.py index bc4d9d857d..4fc43c2d6b 100644 --- a/tests/test_agy_host_surface.py +++ b/tests/test_agy_host_surface.py @@ -47,9 +47,6 @@ AGY_NATIVE_WAKE_TOOLS, ANTIGRAVITY_APP_AGENT_TYPE, agy_home, - antigravity_config_home, - build_antigravity_hooks_template, - build_antigravity_sidecar_template, ) HOST_SURFACE = "agy" From 35c3f8c93ae2d9aab0fd37ddc1dc7329993fbdf1 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:00:47 +0530 Subject: [PATCH 05/34] fix(antigravity): bind antigravity_app to visible-goal settlement, fix hooks/sidecar args, and pass CLI budget differential Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/agy_goal_mode/__init__.py | 114 ++++++++++++------ loopx/bootstrap_command_pack.py | 4 +- loopx/cli_commands/quota_registration.py | 1 + .../scheduler/execution_context.py | 38 ++---- loopx/host_loop_activation.py | 1 + loopx/hosts/skill_facade.py | 2 + loopx/slash_commands.py | 2 +- tests/test_agy_host_surface.py | 30 ++++- 8 files changed, 121 insertions(+), 71 deletions(-) diff --git a/loopx/agy_goal_mode/__init__.py b/loopx/agy_goal_mode/__init__.py index e3037f27b2..4a179c4a42 100644 --- a/loopx/agy_goal_mode/__init__.py +++ b/loopx/agy_goal_mode/__init__.py @@ -106,34 +106,39 @@ def build_antigravity_sidecar_template( *, goal_id: str = "", agent_id: str | None = None, + runtime_root: str | None = None, cli_bin: str = "loopx", interval_seconds: int = 300, ) -> dict[str, Any]: """Build a public-safe Antigravity 2.0 ``sidecar.json`` periodic heartbeat template.""" - quota_args = [ - cli_bin, - "--format", - "json", - "quota", - "should-run", - "--goal-id", - goal_id, - "--runtime-profile", - "antigravity_app", - ] + quota_args = [cli_bin, "--format", "json"] + if runtime_root: + quota_args.extend(["--runtime-root", runtime_root]) + quota_args.extend( + [ + "quota", + "should-run", + "--goal-id", + goal_id, + "--runtime-profile", + "antigravity_app", + ] + ) if agent_id: quota_args.extend(["--agent-id", agent_id]) - heartbeat_args = [ - cli_bin, - "--format", - "json", - "heartbeat-prompt", - "--goal-id", - goal_id, - "--thin", - "--runtime-profile", - "antigravity_app", - ] + heartbeat_args = [cli_bin, "--format", "json"] + if runtime_root: + heartbeat_args.extend(["--runtime-root", runtime_root]) + heartbeat_args.extend( + [ + "heartbeat-prompt", + "--goal-id", + goal_id, + "--thin", + "--runtime-profile", + "antigravity_app", + ] + ) if agent_id: heartbeat_args.extend(["--agent-id", agent_id]) return { @@ -151,7 +156,9 @@ def build_antigravity_sidecar_template( "prompt_json_field": "task_body", "suspend_on_waiting_external": { "tool": "schedule", - "duration_field": "scheduler_hint.poll_after_seconds", + "interval_minutes_field": ( + "scheduler_hint.local_scheduler.recommended_interval_minutes" + ), "default_duration_seconds": int(interval_seconds), }, } @@ -162,22 +169,51 @@ def build_antigravity_sidecar_template( def build_antigravity_hooks_template( *, cli_bin: str = "loopx", + goal_id: str | None = None, + agent_id: str | None = None, + runtime_root: str | None = None, ) -> dict[str, Any]: - """Build a public-safe ``hooks.json`` continuation gate entry for Antigravity 2.0.""" + """Build a public-safe ``hooks.json`` continuation gate entry for Antigravity 2.0. + + When bound to a concrete ``goal_id``, invokes ``quota should-run --goal-id ...``; + when installed globally without a goal binding, invokes ``quota plan --runtime-profile antigravity_app`` + so the command succeeds without ``--goal-id``. + """ + args: list[str] = ["--format", "json"] + if runtime_root: + args.extend(["--runtime-root", runtime_root]) + bound_goal = bool(goal_id and goal_id != "") + if bound_goal: + args.extend( + [ + "quota", + "should-run", + "--goal-id", + str(goal_id), + "--runtime-profile", + "antigravity_app", + ] + ) + if agent_id: + args.extend(["--agent-id", str(agent_id)]) + require_field = "should_run" + else: + args.extend( + [ + "quota", + "plan", + "--runtime-profile", + "antigravity_app", + ] + ) + require_field = "ok" return { "loopx": { "schema_version": "loopx_antigravity_hooks_gate_v0", "event": "before_continuation", "command": cli_bin, - "args": [ - "--format", - "json", - "quota", - "should-run", - "--runtime-profile", - "antigravity_app", - ], - "require_json_field_true": "should_run", + "args": args, + "require_json_field_true": require_field, "on_waiting_external": "suspend_via_schedule_timer", } } @@ -248,17 +284,25 @@ def antigravity_app_activation_extras( *, goal_id: str = "", agent_id: str | None = None, + runtime_root: str | None = None, cli_bin: str = "loopx", ) -> dict[str, Any]: """Keyword overrides for ``antigravity_app_activation``'s facade call.""" sidecar_template = build_antigravity_sidecar_template( goal_id=goal_id, agent_id=agent_id, + runtime_root=runtime_root, + cli_bin=cli_bin, + ) + hooks_template = build_antigravity_hooks_template( cli_bin=cli_bin, + goal_id=goal_id, + agent_id=agent_id, + runtime_root=runtime_root, ) - hooks_template = build_antigravity_hooks_template(cli_bin=cli_bin) + runtime_arg = f" --runtime-root {shlex.quote(runtime_root)}" if runtime_root else "" wake_prompt = ( - f"Run `{cli_bin} --format json quota should-run --goal-id {goal_id}" + f"Run `{cli_bin} --format json{runtime_arg} quota should-run --goal-id {goal_id}" + (f" --agent-id {agent_id}" if agent_id else "") + " --runtime-profile antigravity_app` and continue the active LoopX Todo when allowed." ) diff --git a/loopx/bootstrap_command_pack.py b/loopx/bootstrap_command_pack.py index 7aeb3742c7..440711f17c 100644 --- a/loopx/bootstrap_command_pack.py +++ b/loopx/bootstrap_command_pack.py @@ -373,8 +373,8 @@ def build_start_goal_host_surface_selection_packet( "gemini-cli": "Gemini CLI driving its own loop through the LoopX skill facade", "cursor-agent": "cursor-agent driving its own loop through the LoopX skill facade and MCP server", "zcode": "ZCode loop via the LoopX skill facade", - "agy": "agy session loop via the LoopX skill facade; native /goal + schedule wakes while the session lives", - "antigravity-app": "Antigravity App loop via ~/.gemini/config/skills; native schedule timer closed loop + sidecar.json heartbeat", + "agy": "agy CLI loop (/goal + schedule wakes)", + "antigravity-app": "Antigravity App (schedule + sidecar)", "kiro-cli": "Kiro CLI loop via the LoopX skill facade; native /goal iteration budget", "deepseek-harness": "DeepSeek Harness automation loop through loopx.dsh_goal_mode (compat: scripts/dsh_turn_host_adapter.py)", "deepseek-harness-native": "DeepSeek Harness same-session LoopX skill and plugin driver", diff --git a/loopx/cli_commands/quota_registration.py b/loopx/cli_commands/quota_registration.py index d6ce128f8d..b5d09bbc41 100644 --- a/loopx/cli_commands/quota_registration.py +++ b/loopx/cli_commands/quota_registration.py @@ -154,6 +154,7 @@ def register_quota_command( "codex_app_ssh", "codex_cli", "trae_app", + "antigravity_app", "generic_cli", "claude_code", "kiro_cli", diff --git a/loopx/control_plane/scheduler/execution_context.py b/loopx/control_plane/scheduler/execution_context.py index 0c9da7822f..9da45866bf 100644 --- a/loopx/control_plane/scheduler/execution_context.py +++ b/loopx/control_plane/scheduler/execution_context.py @@ -79,6 +79,7 @@ class SchedulerRuntimeProfile(str, Enum): *NATIVE_GOAL_RUNTIME_PROFILES, SchedulerRuntimeProfile.CLAUDE_CODE_VISIBLE, SchedulerRuntimeProfile.KIRO_CLI_VISIBLE, + SchedulerRuntimeProfile.ANTIGRAVITY_APP, } ) @@ -100,7 +101,6 @@ class SchedulerRuntimeProfile(str, Enum): { SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT, SchedulerRuntimeProfile.TRAE_APP, - SchedulerRuntimeProfile.ANTIGRAVITY_APP, } ) @@ -133,8 +133,8 @@ class SchedulerRuntimeProfile(str, Enum): ), SchedulerRuntimeProfile.ANTIGRAVITY_APP: ( HostSurface.ANTIGRAVITY_APP, - SchedulerOwner.HOST_AUTOMATION, - ExecutionMode.HOSTED_AUTOMATION, + SchedulerOwner.AGENT_CLI_LOOP, + ExecutionMode.INTERACTIVE, ), SchedulerRuntimeProfile.CLAUDE_CODE_VISIBLE: ( HostSurface.CLAUDE_CODE, @@ -194,12 +194,7 @@ def codex_app_applicable(self) -> bool: @property def app_automation_applicable(self) -> bool: return ( - self.host_surface - in { - HostSurface.CODEX_APP, - HostSurface.TRAE_APP, - HostSurface.ANTIGRAVITY_APP, - } + self.host_surface in {HostSurface.CODEX_APP, HostSurface.TRAE_APP} and self.scheduler_owner is SchedulerOwner.HOST_AUTOMATION and self.execution_mode is ExecutionMode.HOSTED_AUTOMATION ) @@ -214,10 +209,10 @@ def projection(self) -> dict[str, Any]: "valid": True, } # Keep existing non-Trae projections byte-compatible. The neutral App - # field is needed to identify Trae/Antigravity's independent automation - # contract, but a second negative applicability flag adds no information - # for CLI and Goal runtimes. - if self.host_surface in {HostSurface.TRAE_APP, HostSurface.ANTIGRAVITY_APP}: + # field is needed to identify Trae's independent automation contract, + # but a second negative applicability flag adds no information for CLI + # and Goal runtimes. + if self.host_surface is HostSurface.TRAE_APP: projection["app_automation_applicability"] = ( "applicable" if self.app_automation_applicable else "not_applicable" ) @@ -251,10 +246,7 @@ def projection(self) -> dict[str, Any]: "valid": False, "errors": list(self.errors), } - if supplied.get("host_surface") in { - HostSurface.TRAE_APP.value, - HostSurface.ANTIGRAVITY_APP.value, - }: + if supplied.get("host_surface") == HostSurface.TRAE_APP.value: projection["app_automation_applicability"] = "blocked_invalid_context" else: projection["codex_app_applicability"] = "blocked_invalid_context" @@ -275,18 +267,10 @@ def _validation_errors(context: SchedulerExecutionContext) -> list[str]: HostSurface.CLAUDE_CODE, HostSurface.KUNLUNCODE, HostSurface.KIRO_CLI, - } - if context.host_surface in { - HostSurface.CODEX_APP, - HostSurface.TRAE_APP, HostSurface.ANTIGRAVITY_APP, - }: + } + if context.host_surface in {HostSurface.CODEX_APP, HostSurface.TRAE_APP}: host_name = context.host_surface.value - if context.host_surface is HostSurface.ANTIGRAVITY_APP and ( - context.scheduler_owner is SchedulerOwner.AGENT_CLI_LOOP - and context.execution_mode is ExecutionMode.INTERACTIVE - ): - return errors if context.scheduler_owner is not SchedulerOwner.HOST_AUTOMATION: errors.append(f"{host_name} requires scheduler_owner=host_automation") if context.execution_mode is not ExecutionMode.HOSTED_AUTOMATION: diff --git a/loopx/host_loop_activation.py b/loopx/host_loop_activation.py index 3ecd9255f8..903214ff0f 100644 --- a/loopx/host_loop_activation.py +++ b/loopx/host_loop_activation.py @@ -1324,6 +1324,7 @@ def build_host_loop_activation_packet( cli_bin, goal_id=goal_id, agent_id=str(selected_agent_id) if selected_agent_id else None, + runtime_root=runtime_root, ) elif canonical == "kiro-cli": surface = kiro_cli_activation(commands, cli_bin) diff --git a/loopx/hosts/skill_facade.py b/loopx/hosts/skill_facade.py index 58172bb047..ef32d65f9e 100644 --- a/loopx/hosts/skill_facade.py +++ b/loopx/hosts/skill_facade.py @@ -170,6 +170,7 @@ def antigravity_app_activation( *, goal_id: str = "", agent_id: str | None = None, + runtime_root: str | None = None, ) -> dict[str, Any]: return skill_facade_cli_activation( commands, @@ -181,6 +182,7 @@ def antigravity_app_activation( **antigravity_app_activation_extras( goal_id=goal_id, agent_id=agent_id, + runtime_root=runtime_root, cli_bin=cli_bin, ), ) diff --git a/loopx/slash_commands.py b/loopx/slash_commands.py index 13d23f2a01..3957044243 100644 --- a/loopx/slash_commands.py +++ b/loopx/slash_commands.py @@ -77,7 +77,7 @@ def build_slash_command_catalog( "cursor-agent": "drive the loop from the agent's own turns; enter every turn through quota should-run", "zcode": "drive the loop from the agent's turns; enter through quota should-run", "agy": "native /goal + schedule wakes; quota entry advisory", - "antigravity-app": "native schedule(DurationSeconds=...) timer closed loop + sidecar.json heartbeat", + "antigravity-app": "schedule timer + sidecar heartbeat", "kiro-cli": "native /goal loop; quota entry advisory", "manual": "wire an external scheduler or run quota/status manually", "other-agent": "use the custom host loop driver declared by `loopx agent-onboard`", diff --git a/tests/test_agy_host_surface.py b/tests/test_agy_host_surface.py index 4fc43c2d6b..133594086b 100644 --- a/tests/test_agy_host_surface.py +++ b/tests/test_agy_host_surface.py @@ -316,15 +316,20 @@ def test_antigravity_app_first_class_host_surface_and_schedule_sidecar_contract( "runtime_profile": "antigravity_app" } - # Verify both hosted automation (sidecar.json) and interactive (in-session schedule) execution contexts - hosted_ctx = scheduler_execution_context_for_runtime_profile( + # Verify antigravity_app runtime profile maps to interactive AGENT_CLI_LOOP + # (so todo complete / refresh-state use turn-scoped visible-goal settlement + # instead of emitting Codex App -A / --ack-applied-rrule commands). + runtime_ctx = scheduler_execution_context_for_runtime_profile( SchedulerRuntimeProfile.ANTIGRAVITY_APP ) - assert hosted_ctx.ok - assert hosted_ctx.context is not None - assert hosted_ctx.context.app_automation_applicable is True + assert runtime_ctx.ok + assert runtime_ctx.context is not None + assert runtime_ctx.context.host_surface is HostSurface.ANTIGRAVITY_APP + assert runtime_ctx.context.scheduler_owner is SchedulerOwner.AGENT_CLI_LOOP + assert runtime_ctx.context.execution_mode is ExecutionMode.INTERACTIVE + assert runtime_ctx.context.app_automation_applicable is False assert ( - hosted_ctx.projection()["app_automation_applicability"] == "applicable" + runtime_ctx.projection()["codex_app_applicability"] == "not_applicable" ) interactive_ctx = resolve_scheduler_execution_context( @@ -336,6 +341,19 @@ def test_antigravity_app_first_class_host_surface_and_schedule_sidecar_contract( ) assert interactive_ctx.ok + # Verify hooks_template includes --goal-id and --agent-id when bound, and sidecar uses + # scheduler_hint.local_scheduler.recommended_interval_minutes. + assert "--goal-id" in mutation["hooks_template"]["loopx"]["args"] + assert "surface-goal" in mutation["hooks_template"]["loopx"]["args"] + assert "--agent-id" in mutation["hooks_template"]["loopx"]["args"] + assert "probe-agent" in mutation["hooks_template"]["loopx"]["args"] + assert ( + mutation["sidecar_template"]["sidecar"]["loopx-heartbeat"][ + "suspend_on_waiting_external" + ]["interval_minutes_field"] + == "scheduler_hint.local_scheduler.recommended_interval_minutes" + ) + def test_antigravity_dual_skills_mcp_hooks_workflow_skills_and_doctor( tmp_path: Path, From b3980148db85c99b7db257031e99670c4dfb2258 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:17:01 +0530 Subject: [PATCH 06/34] fix(antigravity): use 3-minute default cadence and canonical LoopX managed heartbeat bootstrap v2 Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/agy_goal_mode/__init__.py | 23 ++++++++++++------- .../heartbeat/bootstrap_prompt.py | 2 +- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/loopx/agy_goal_mode/__init__.py b/loopx/agy_goal_mode/__init__.py index 4a179c4a42..db0c5ba381 100644 --- a/loopx/agy_goal_mode/__init__.py +++ b/loopx/agy_goal_mode/__init__.py @@ -108,7 +108,7 @@ def build_antigravity_sidecar_template( agent_id: str | None = None, runtime_root: str | None = None, cli_bin: str = "loopx", - interval_seconds: int = 300, + interval_seconds: int = 180, ) -> dict[str, Any]: """Build a public-safe Antigravity 2.0 ``sidecar.json`` periodic heartbeat template.""" quota_args = [cli_bin, "--format", "json"] @@ -132,15 +132,23 @@ def build_antigravity_sidecar_template( heartbeat_args.extend( [ "heartbeat-prompt", + "--thin", "--goal-id", goal_id, - "--thin", "--runtime-profile", "antigravity_app", ] ) if agent_id: heartbeat_args.extend(["--agent-id", agent_id]) + bootstrap_prompt = ( + "LoopX managed heartbeat bootstrap v2\n" + "每次唤醒先执行:\n" + f"```sh\n{shlex.join(heartbeat_args)}\n```\n" + "读取完整结果;仅 ok=true 时按本次 task_body 推进,不复用旧指令。" + "一次操作不代表结束;通知与执行分开,等待按当前调度契约(遇到 waiting_external / CI 等待时使用原生 schedule(DurationSeconds=..., TimerCondition=\"any\") 挂起,不跑 shell sleep 空查)。" + "入口异常先做权限内恢复;契约仍不可用时不执行任务或记账,并报告阻塞。" + ) return { "schema_version": "loopx_antigravity_sidecar_template_v0", "config_path": ANTIGRAVITY_SIDECAR_CONFIG_LABEL, @@ -149,11 +157,13 @@ def build_antigravity_sidecar_template( "description": ( f"LoopX periodic quota-gated heartbeat for goal {goal_id}" ), + "cron": "*/3 * * * *", "interval_seconds": int(interval_seconds), "gate_command": shlex.join(quota_args), "wake_when_json_field_true": "should_run", "prompt_command": shlex.join(heartbeat_args), "prompt_json_field": "task_body", + "bootstrap_prompt": bootstrap_prompt, "suspend_on_waiting_external": { "tool": "schedule", "interval_minutes_field": ( @@ -300,12 +310,7 @@ def antigravity_app_activation_extras( agent_id=agent_id, runtime_root=runtime_root, ) - runtime_arg = f" --runtime-root {shlex.quote(runtime_root)}" if runtime_root else "" - wake_prompt = ( - f"Run `{cli_bin} --format json{runtime_arg} quota should-run --goal-id {goal_id}" - + (f" --agent-id {agent_id}" if agent_id else "") - + " --runtime-profile antigravity_app` and continue the active LoopX Todo when allowed." - ) + wake_prompt = sidecar_template["sidecar"]["loopx-heartbeat"]["bootstrap_prompt"] return { "activation_method": "bind_antigravity_schedule_loop_and_sidecar", "extra_host_mutation": { @@ -328,6 +333,8 @@ def antigravity_app_activation_extras( "in_session_schedule_call": { "tool": "schedule", "arguments": { + "CronExpression": "*/3 * * * *", + "IsDaemon": True, "DurationSeconds": 180, "Prompt": wake_prompt, "TimerCondition": "any", diff --git a/loopx/control_plane/heartbeat/bootstrap_prompt.py b/loopx/control_plane/heartbeat/bootstrap_prompt.py index 0f43fee53c..21bceb8c46 100644 --- a/loopx/control_plane/heartbeat/bootstrap_prompt.py +++ b/loopx/control_plane/heartbeat/bootstrap_prompt.py @@ -99,7 +99,7 @@ def goal_bootstrap(args: Any, *, registry: Path) -> str: args.codex_app or getattr(args, "trae_app", False) or getattr(args, "runtime_profile", None) - in {"codex_app_heartbeat", "trae_app"} + in {"codex_app_heartbeat", "trae_app", "antigravity_app"} ) command = _goal_command(args, registry=registry, legacy=not heartbeat) if heartbeat: From 3a6ed30f88419c64131c2054ffdf97b7d365640d Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:49:02 +0530 Subject: [PATCH 07/34] feat(antigravity): wire antigravity_app into first-class app_automation stateful backoff Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/agy_goal_mode/__init__.py | 110 ++++++++++ loopx/cli_commands/quota_action_selection.py | 1 + loopx/cli_commands/quota_context.py | 30 ++- loopx/cli_commands/quota_registration.py | 5 + .../cli_commands/support_control_heartbeat.py | 10 +- .../support_control_heartbeat_registration.py | 6 + .../heartbeat/bootstrap_prompt.py | 8 +- loopx/control_plane/heartbeat/task_body.py | 8 +- loopx/control_plane/quota/scheduler_ack.py | 2 +- .../scheduler/execution_context.py | 34 ++- .../scheduler/heartbeat_followup_cli.ts | 10 + loopx/control_plane/scheduler/hint.ts | 2 +- .../control_plane/scheduler/hint_followup.ts | 3 +- .../turn_driver/codex_context.py | 3 +- loopx/entrypoint.py | 1 + .../test_scheduler_execution_context.py | 30 ++- tests/test_agy_host_surface.py | 203 +++++++++++++++++- 17 files changed, 421 insertions(+), 45 deletions(-) diff --git a/loopx/agy_goal_mode/__init__.py b/loopx/agy_goal_mode/__init__.py index db0c5ba381..083a2a669c 100644 --- a/loopx/agy_goal_mode/__init__.py +++ b/loopx/agy_goal_mode/__init__.py @@ -167,15 +167,125 @@ def build_antigravity_sidecar_template( "suspend_on_waiting_external": { "tool": "schedule", "interval_minutes_field": ( + "scheduler_hint.app_automation.recommended_interval_minutes" + ), + "fallback_interval_minutes_field": ( "scheduler_hint.local_scheduler.recommended_interval_minutes" ), "default_duration_seconds": int(interval_seconds), }, + "stateful_backoff": { + "state_key": "scheduler_hint.app_automation.stateful_backoff", + "recommended_rrule_field": ( + "scheduler_hint.app_automation.recommended_rrule" + ), + "recommended_interval_minutes_field": ( + "scheduler_hint.app_automation.recommended_interval_minutes" + ), + "ack_hint_field": ( + "scheduler_hint.app_automation.ack_hint.cli_args" + ), + "failure_hint_field": ( + "scheduler_hint.app_automation.failure_hint.cli_args" + ), + }, } }, } +def cron_for_interval_minutes(interval_minutes: int) -> str: + """Convert a LoopX ``recommended_interval_minutes`` into a 5-field cron expression.""" + minutes = max(1, int(interval_minutes)) + if minutes >= 60: + return "0 * * * *" + return f"*/{minutes} * * * *" + + +def build_antigravity_backoff_schedule_action( + scheduler_hint: dict[str, Any], + *, + current_task_id: str | None = None, + prompt: str = "", +) -> dict[str, Any]: + """Translate ``scheduler_hint.app_automation`` stateful backoff into Antigravity ``schedule`` / ``manage_task`` actions.""" + app = ( + scheduler_hint.get("app_automation") + if isinstance(scheduler_hint.get("app_automation"), dict) + else {} + ) + backoff = ( + app.get("stateful_backoff") + if isinstance(app.get("stateful_backoff"), dict) + else {} + ) + host_action = str(app.get("host_action") or "none") + if host_action == "pause_or_delete_current_heartbeat": + return { + "action": "pause_or_delete_current_heartbeat", + "apply_needed": bool(app.get("host_action_required", True)), + "ack_needed": False, + "manage_task_kill": ( + {"Action": "kill", "TaskId": current_task_id} + if current_task_id + else None + ), + "schedule_call": None, + "ack_cli_args": None, + "failure_cli_args": None, + } + apply_needed = bool(backoff.get("apply_needed")) + ack_needed = bool(backoff.get("ack_needed")) + interval = int( + app.get("recommended_interval_minutes") + or backoff.get("current_interval_minutes") + or 3 + ) + cron_expr = cron_for_interval_minutes(interval) + ack_hint = app.get("ack_hint") if isinstance(app.get("ack_hint"), dict) else {} + failure_hint = ( + app.get("failure_hint") if isinstance(app.get("failure_hint"), dict) else {} + ) + return { + "action": ( + "update_current_heartbeat_rrule" + if apply_needed + else "ack_current_heartbeat_rrule" + if ack_needed + else "noop" + ), + "apply_needed": apply_needed, + "ack_needed": ack_needed, + "recommended_interval_minutes": interval, + "recommended_rrule": app.get("recommended_rrule"), + "cron_expression": cron_expr, + "manage_task_kill": ( + {"Action": "kill", "TaskId": current_task_id} + if apply_needed and current_task_id + else None + ), + "schedule_call": ( + { + "CronExpression": cron_expr, + "IsDaemon": True, + "Prompt": prompt, + } + if apply_needed + else None + ), + "ack_cli_args": ( + list(ack_hint.get("cli_args", [])) + if isinstance(ack_hint.get("cli_args"), list) + else None + ), + "failure_cli_args": ( + list(failure_hint.get("cli_args", [])) + if isinstance(failure_hint.get("cli_args"), list) + else None + ), + } + + def build_antigravity_hooks_template( *, cli_bin: str = "loopx", diff --git a/loopx/cli_commands/quota_action_selection.py b/loopx/cli_commands/quota_action_selection.py index 735351d931..6120de9f00 100644 --- a/loopx/cli_commands/quota_action_selection.py +++ b/loopx/cli_commands/quota_action_selection.py @@ -73,6 +73,7 @@ def _requested_quota_action_todo_id( bool(getattr(args, "turn_instance_id", None)) or bool(args.codex_app) or bool(getattr(args, "trae_app", False)) + or bool(getattr(args, "antigravity_app", False)) or args.runtime_profile in {profile.value for profile in GUIDED_START_TURN_RUNTIME_PROFILES} ): diff --git a/loopx/cli_commands/quota_context.py b/loopx/cli_commands/quota_context.py index bb4213b48a..e7b84085f9 100644 --- a/loopx/cli_commands/quota_context.py +++ b/loopx/cli_commands/quota_context.py @@ -74,10 +74,11 @@ def _scheduler_execution_context_from_args( ) codex_app = bool(getattr(args, "codex_app", False)) trae_app = bool(getattr(args, "trae_app", False)) - app_alias_count = int(codex_app) + int(trae_app) + antigravity_app = bool(getattr(args, "antigravity_app", False)) + app_alias_count = int(codex_app) + int(trae_app) + int(antigravity_app) if app_alias_count > 1: raise QuotaCommandValidationError( - "--codex-app and --trae_app are mutually exclusive" + "--codex-app, --trae_app, and --antigravity_app are mutually exclusive" ) if app_alias_count and (args.runtime_profile or any(explicit_scheduler_fields)): raise QuotaCommandValidationError( @@ -94,6 +95,8 @@ def _scheduler_execution_context_from_args( if codex_app else SchedulerRuntimeProfile.TRAE_APP.value if trae_app + else SchedulerRuntimeProfile.ANTIGRAVITY_APP.value + if antigravity_app else args.runtime_profile ) if runtime_profile: @@ -200,10 +203,16 @@ def validate_quota_command_context_request( legacy_codex_rrule and resolved_scheduler_context.ok and resolved_scheduler_context.context is not None - and resolved_scheduler_context.context.host_surface is HostSurface.TRAE_APP + and resolved_scheduler_context.context.host_surface + in {HostSurface.TRAE_APP, HostSurface.ANTIGRAVITY_APP} ): + app_label = ( + "Trae App" + if resolved_scheduler_context.context.host_surface is HostSurface.TRAE_APP + else "Antigravity App" + ) raise QuotaCommandValidationError( - "Trae App uses --app-automation-current-rrule, not the Codex compatibility alias" + f"{app_label} uses --app-automation-current-rrule, not the Codex compatibility alias" ) args.app_automation_current_rrule = neutral_rrule or legacy_codex_rrule or None if command in QUOTA_SCHEDULER_FOLLOWUP_COMMANDS: @@ -225,16 +234,23 @@ def validate_quota_command_context_request( ).strip() default_state_key = ( APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY - if selected_surface == HostSurface.TRAE_APP.value + if selected_surface + in {HostSurface.TRAE_APP.value, HostSurface.ANTIGRAVITY_APP.value} else None ) if ( - selected_surface == HostSurface.TRAE_APP.value + selected_surface + in {HostSurface.TRAE_APP.value, HostSurface.ANTIGRAVITY_APP.value} and supplied_state_key and supplied_state_key != APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY ): + app_label = ( + "Trae App" + if selected_surface == HostSurface.TRAE_APP.value + else "Antigravity App" + ) raise QuotaCommandValidationError( - "Trae App scheduler follow-up requires the app_automation state key" + f"{app_label} scheduler follow-up requires the app_automation state key" ) args.state_key = supplied_state_key or default_state_key validate_quota_command_request(args) diff --git a/loopx/cli_commands/quota_registration.py b/loopx/cli_commands/quota_registration.py index b5d09bbc41..8119f6bea1 100644 --- a/loopx/cli_commands/quota_registration.py +++ b/loopx/cli_commands/quota_registration.py @@ -145,6 +145,11 @@ def register_quota_command( "runtime or execution context." ), ) + quota_parser.add_argument( + "--antigravity_app", + action="store_true", + help=argparse.SUPPRESS, + ) quota_parser.add_argument( "-H", "--host-surface", diff --git a/loopx/cli_commands/support_control_heartbeat.py b/loopx/cli_commands/support_control_heartbeat.py index d30e934d00..148b6ab141 100644 --- a/loopx/cli_commands/support_control_heartbeat.py +++ b/loopx/cli_commands/support_control_heartbeat.py @@ -46,6 +46,8 @@ def handle_heartbeat_prompt_command( if args.codex_app else SchedulerRuntimeProfile.TRAE_APP.value if getattr(args, "trae_app", False) + else SchedulerRuntimeProfile.ANTIGRAVITY_APP.value + if getattr(args, "antigravity_app", False) else args.runtime_profile ) try: @@ -101,12 +103,14 @@ def handle_heartbeat_prompt_command( args.scheduler_owner, args.execution_mode, ) - app_alias_count = int(bool(args.codex_app)) + int( - bool(getattr(args, "trae_app", False)) + app_alias_count = ( + int(bool(args.codex_app)) + + int(bool(getattr(args, "trae_app", False))) + + int(bool(getattr(args, "antigravity_app", False))) ) if app_alias_count > 1: raise ValueError( - "--codex-app and --trae_app are mutually exclusive" + "--codex-app, --trae_app, and --antigravity_app are mutually exclusive" ) if app_alias_count and ( args.runtime_profile or any(explicit_scheduler_fields) diff --git a/loopx/cli_commands/support_control_heartbeat_registration.py b/loopx/cli_commands/support_control_heartbeat_registration.py index 9b92ca2a5d..4ac448ef69 100644 --- a/loopx/cli_commands/support_control_heartbeat_registration.py +++ b/loopx/cli_commands/support_control_heartbeat_registration.py @@ -92,6 +92,11 @@ def register_heartbeat_control_commands( "trae_app in generated heartbeat commands." ), ) + heartbeat_prompt_parser.add_argument( + "--antigravity_app", + action="store_true", + help=argparse.SUPPRESS, + ) heartbeat_prompt_parser.add_argument( "--visible-goal-host", choices=["traex-cli"], @@ -109,6 +114,7 @@ def register_heartbeat_control_commands( "codex_app_ssh", "codex_cli", "trae_app", + "antigravity_app", "generic_cli", "claude_code", "kunluncode", diff --git a/loopx/control_plane/heartbeat/bootstrap_prompt.py b/loopx/control_plane/heartbeat/bootstrap_prompt.py index 21bceb8c46..183b5812cc 100644 --- a/loopx/control_plane/heartbeat/bootstrap_prompt.py +++ b/loopx/control_plane/heartbeat/bootstrap_prompt.py @@ -60,6 +60,8 @@ def _goal_command(args: Any, *, registry: Path, legacy: bool) -> list[str]: command.append("--codex-app") if getattr(args, "trae_app", False): command.append("--trae_app") + if getattr(args, "antigravity_app", False): + command.append("--antigravity_app") command += ["--goal-id", args.goal_id] for field, flag in ( ("agent_id", "--agent-id"), ("active_state", "--active-state"), @@ -89,6 +91,8 @@ def _goal_command(args: Any, *, registry: Path, legacy: bool) -> list[str]: command.append("--codex-app") if getattr(args, "trae_app", False): command.append("--trae_app") + if getattr(args, "antigravity_app", False): + command.append("--antigravity_app") command.append("--" + mode) return command @@ -98,6 +102,7 @@ def goal_bootstrap(args: Any, *, registry: Path) -> str: heartbeat = ( args.codex_app or getattr(args, "trae_app", False) + or getattr(args, "antigravity_app", False) or getattr(args, "runtime_profile", None) in {"codex_app_heartbeat", "trae_app", "antigravity_app"} ) @@ -117,7 +122,7 @@ def host_bootstrap_binding(prompt: str) -> dict[str, Any] | None: if command[1:3] != ["--format", "json"]: return None values: dict[str, Any] = dict(cli_bin=command[0], turn_instance_id=None, codex_app=False, - trae_app=False, + trae_app=False, antigravity_app=False, full=False, compact=False, brief=False, thin=False, turn_envelope=False, visible_goal_host=None, available_capabilities=[], agent_scopes=[], runtime_root=None) @@ -130,6 +135,7 @@ def host_bootstrap_binding(prompt: str) -> dict[str, Any] | None: booleans = { "--codex-app": "codex_app", "--trae_app": "trae_app", + "--antigravity_app": "antigravity_app", "--turn-envelope": "turn_envelope", **{"--" + name.replace("_", "-"): name for name in ("full", "compact", "brief", "thin")}, } diff --git a/loopx/control_plane/heartbeat/task_body.py b/loopx/control_plane/heartbeat/task_body.py index acf7f7d924..f50db9f9ea 100644 --- a/loopx/control_plane/heartbeat/task_body.py +++ b/loopx/control_plane/heartbeat/task_body.py @@ -842,7 +842,13 @@ def render_heartbeat_prompt_markdown(payload: dict[str, Any]) -> str: else: style = "" runtime_profile = str(payload.get("runtime_profile") or "") - host_label = "Trae App" if runtime_profile == "trae_app" else "Codex App" + host_label = ( + "Trae App" + if runtime_profile == "trae_app" + else "Antigravity App" + if runtime_profile == "antigravity_app" + else "Codex App" + ) return f"""# Heartbeat Automation Prompt Copy this {style}task body into a {host_label} heartbeat automation. diff --git a/loopx/control_plane/quota/scheduler_ack.py b/loopx/control_plane/quota/scheduler_ack.py index e4eb795b81..eaef19f94c 100644 --- a/loopx/control_plane/quota/scheduler_ack.py +++ b/loopx/control_plane/quota/scheduler_ack.py @@ -32,7 +32,7 @@ def _scheduler_packet( if isinstance(before.get("scheduler_hint"), dict) else {} ) - if surface == "trae_app": + if surface in {"trae_app", "antigravity_app"}: packet_key = "app_automation" elif ( surface == CODEX_APP_SURFACE diff --git a/loopx/control_plane/scheduler/execution_context.py b/loopx/control_plane/scheduler/execution_context.py index 9da45866bf..149b448d50 100644 --- a/loopx/control_plane/scheduler/execution_context.py +++ b/loopx/control_plane/scheduler/execution_context.py @@ -101,6 +101,7 @@ class SchedulerRuntimeProfile(str, Enum): { SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT, SchedulerRuntimeProfile.TRAE_APP, + SchedulerRuntimeProfile.ANTIGRAVITY_APP, } ) @@ -133,8 +134,8 @@ class SchedulerRuntimeProfile(str, Enum): ), SchedulerRuntimeProfile.ANTIGRAVITY_APP: ( HostSurface.ANTIGRAVITY_APP, - SchedulerOwner.AGENT_CLI_LOOP, - ExecutionMode.INTERACTIVE, + SchedulerOwner.HOST_AUTOMATION, + ExecutionMode.HOSTED_AUTOMATION, ), SchedulerRuntimeProfile.CLAUDE_CODE_VISIBLE: ( HostSurface.CLAUDE_CODE, @@ -194,7 +195,12 @@ def codex_app_applicable(self) -> bool: @property def app_automation_applicable(self) -> bool: return ( - self.host_surface in {HostSurface.CODEX_APP, HostSurface.TRAE_APP} + self.host_surface + in { + HostSurface.CODEX_APP, + HostSurface.TRAE_APP, + HostSurface.ANTIGRAVITY_APP, + } and self.scheduler_owner is SchedulerOwner.HOST_AUTOMATION and self.execution_mode is ExecutionMode.HOSTED_AUTOMATION ) @@ -209,10 +215,10 @@ def projection(self) -> dict[str, Any]: "valid": True, } # Keep existing non-Trae projections byte-compatible. The neutral App - # field is needed to identify Trae's independent automation contract, - # but a second negative applicability flag adds no information for CLI - # and Goal runtimes. - if self.host_surface is HostSurface.TRAE_APP: + # field is needed to identify Trae/Antigravity's independent automation + # contract, but a second negative applicability flag adds no information + # for CLI and Goal runtimes. + if self.host_surface in {HostSurface.TRAE_APP, HostSurface.ANTIGRAVITY_APP}: projection["app_automation_applicability"] = ( "applicable" if self.app_automation_applicable else "not_applicable" ) @@ -246,7 +252,10 @@ def projection(self) -> dict[str, Any]: "valid": False, "errors": list(self.errors), } - if supplied.get("host_surface") == HostSurface.TRAE_APP.value: + if supplied.get("host_surface") in { + HostSurface.TRAE_APP.value, + HostSurface.ANTIGRAVITY_APP.value, + }: projection["app_automation_applicability"] = "blocked_invalid_context" else: projection["codex_app_applicability"] = "blocked_invalid_context" @@ -267,9 +276,12 @@ def _validation_errors(context: SchedulerExecutionContext) -> list[str]: HostSurface.CLAUDE_CODE, HostSurface.KUNLUNCODE, HostSurface.KIRO_CLI, - HostSurface.ANTIGRAVITY_APP, } - if context.host_surface in {HostSurface.CODEX_APP, HostSurface.TRAE_APP}: + if context.host_surface in { + HostSurface.CODEX_APP, + HostSurface.TRAE_APP, + HostSurface.ANTIGRAVITY_APP, + }: host_name = context.host_surface.value if context.scheduler_owner is not SchedulerOwner.HOST_AUTOMATION: errors.append(f"{host_name} requires scheduler_owner=host_automation") @@ -437,6 +449,8 @@ def _render_scheduler_runtime_profile_args( return " --codex-app" if profile is SchedulerRuntimeProfile.TRAE_APP: return " --trae_app" + if profile is SchedulerRuntimeProfile.ANTIGRAVITY_APP: + return " --antigravity_app" return f" --runtime-profile {shlex.quote(profile.value)}" diff --git a/loopx/control_plane/scheduler/heartbeat_followup_cli.ts b/loopx/control_plane/scheduler/heartbeat_followup_cli.ts index d51ef570e3..683ba0dd08 100644 --- a/loopx/control_plane/scheduler/heartbeat_followup_cli.ts +++ b/loopx/control_plane/scheduler/heartbeat_followup_cli.ts @@ -80,6 +80,7 @@ function parseArgs(argv: string[]): ParsedArgs { "-A", "--codex-app", "--trae_app", + "--antigravity_app", "--execute", "--dry-run", "--host-match-observed", @@ -248,6 +249,15 @@ function requestFromArgs(argv: string[]): { request: Record; fo ); } } + if (parsed.booleans.has("--antigravity_app")) { + matchingText(facts, "surface", "antigravity_app", "--antigravity_app"); + if (legacyCodexObservedRrule) { + throw new EffectRuntimeRequestError( + "Antigravity App cannot use the Codex RRULE compatibility alias", + "scheduler_host_facts_identity_mismatch", + ); + } + } if (parsed.booleans.has("-A") || parsed.booleans.has("--codex-app")) { matchingText(facts, "surface", "codex_app", "--codex-app"); } diff --git a/loopx/control_plane/scheduler/hint.ts b/loopx/control_plane/scheduler/hint.ts index 14296ba829..31a7958620 100644 --- a/loopx/control_plane/scheduler/hint.ts +++ b/loopx/control_plane/scheduler/hint.ts @@ -408,7 +408,7 @@ function invalidContextHint(context: JsonObject): JsonObject { unchanged_poll: {local_scheduler: "stop_until_context_repaired", codex_cli_tui: "stop_until_context_repaired", [SSH]: "stop_until_context_repaired", claude_code_loop: "stop_until_context_repaired", final_quota_replan_check_enabled: false, spend_policy: spend}, consistency_error: {source: "scheduler_execution_context", errors: context.errors}, - [stripPythonWhitespace(String(context.host_surface ?? "")) === "trae_app" ? "app_automation" : "codex_app"]: { + [["trae_app", "antigravity_app"].includes(stripPythonWhitespace(String(context.host_surface ?? ""))) ? "app_automation" : "codex_app"]: { applicability: "blocked_invalid_context", apply: "none", host_action: "none", ack_required: false}}; } diff --git a/loopx/control_plane/scheduler/hint_followup.ts b/loopx/control_plane/scheduler/hint_followup.ts index 279dd3aae1..0edf044235 100644 --- a/loopx/control_plane/scheduler/hint_followup.ts +++ b/loopx/control_plane/scheduler/hint_followup.ts @@ -18,8 +18,9 @@ function capabilities(raw: unknown): string[] { function followupArgs(request: JsonObject, command: string): unknown[] { const surface = text(request.surface === undefined ? "codex_app" : request.surface); const stateKey = text(request.state_key === undefined ? APP_STATE : request.state_key); + const surfaceFlag = surface === "trae_app" ? "--trae_app" : surface === "antigravity_app" ? "--antigravity_app" : "-A"; const args: unknown[] = ["quota", command, "--goal-id", text(request.goal_id), - "--agent-id", text(request.agent_id), surface === "trae_app" ? "--trae_app" : "-A"]; + "--agent-id", text(request.agent_id), surfaceFlag]; for (const capability of capabilities(request.available_capabilities)) args.push("--available-capability", capability); if (jsonObject(request.scheduler_host_facts)) { // The Python adapter performs only the persisted zlib/base64 wire codec. diff --git a/loopx/control_plane/turn_driver/codex_context.py b/loopx/control_plane/turn_driver/codex_context.py index 7da0b9791c..2f4a6f36fb 100644 --- a/loopx/control_plane/turn_driver/codex_context.py +++ b/loopx/control_plane/turn_driver/codex_context.py @@ -40,7 +40,8 @@ def context_delivery_for_arguments(args: Any, *, codex_machine_preference: bool fields = {key: getattr(args, key, None) for key in ("host_surface", "scheduler_owner", "execution_mode")} profiles = [value for value in (getattr(args, "runtime_profile", None), "codex_app_heartbeat" if getattr(args, "codex_app", False) else None, - "trae_app" if getattr(args, "trae_app", False) else None) if value is not None] + "trae_app" if getattr(args, "trae_app", False) else None, + "antigravity_app" if getattr(args, "antigravity_app", False) else None) if value is not None] setting = getattr(args, "context_delivery", None) setting = "off" if setting is None else setting if len(profiles) > 1: diff --git a/loopx/entrypoint.py b/loopx/entrypoint.py index 4018032ec7..9017b22404 100644 --- a/loopx/entrypoint.py +++ b/loopx/entrypoint.py @@ -105,6 +105,7 @@ def _native_scheduler_followup_argv(raw_argv: list[str]) -> list[str] | None: if positionals[1] == "scheduler-ack-current" and ( "--host-match-observed" in raw_argv and "--trae_app" not in raw_argv + and "--antigravity_app" not in raw_argv ): # The bound hint predates the host update. Read the App again; only the # typed commit owner may decide whether that observation proves an ACK. diff --git a/tests/control_plane/test_scheduler_execution_context.py b/tests/control_plane/test_scheduler_execution_context.py index 21063780c0..fc41341d91 100644 --- a/tests/control_plane/test_scheduler_execution_context.py +++ b/tests/control_plane/test_scheduler_execution_context.py @@ -42,6 +42,7 @@ ("ark_managed_agent", "goal_runtime", "interactive"), ("codex_app", "host_automation", "hosted_automation"), ("trae_app", "host_automation", "hosted_automation"), + ("antigravity_app", "host_automation", "hosted_automation"), ("codex_app_ssh", "agent_cli_loop", "interactive"), ("local_scheduler", "host_automation", "hosted_automation"), *{ @@ -79,6 +80,11 @@ ("trae_app", "host_automation", "hosted_automation"), " --trae_app", ), + ( + SchedulerRuntimeProfile.ANTIGRAVITY_APP, + ("antigravity_app", "host_automation", "hosted_automation"), + " --antigravity_app", + ), ( SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE, ("codex_app_ssh", "agent_cli_loop", "interactive"), @@ -214,7 +220,11 @@ def test_scheduler_execution_context_decision_table( assert resolution.ok is (values in VALID_COMBINATIONS) context_projection = resolution.projection() - if host_surface is HostSurface.TRAE_APP: + neutral_app_surface = host_surface in { + HostSurface.TRAE_APP, + HostSurface.ANTIGRAVITY_APP, + } + if neutral_app_surface: assert context_projection["app_automation_applicability"] == ( "applicable" if resolution.ok else "blocked_invalid_context" ) @@ -229,17 +239,16 @@ def test_scheduler_execution_context_decision_table( if values not in VALID_COMBINATIONS: assert hint["execution_phase"]["disposition"] == "contract_error" assert hint["execution_phase"]["completed"] is False - packet_key = ( - "app_automation" if host_surface is HostSurface.TRAE_APP else "codex_app" - ) + packet_key = "app_automation" if neutral_app_surface else "codex_app" assert hint[packet_key]["applicability"] == "blocked_invalid_context" - assert ("app_automation" in hint) is (host_surface is HostSurface.TRAE_APP) - assert ("codex_app" in hint) is (host_surface is not HostSurface.TRAE_APP) + assert ("app_automation" in hint) is neutral_app_surface + assert ("codex_app" in hint) is (not neutral_app_surface) return app_expected = values in { ("codex_app", "host_automation", "hosted_automation"), ("trae_app", "host_automation", "hosted_automation"), + ("antigravity_app", "host_automation", "hosted_automation"), } packet_key = "app_automation" if app_expected else "codex_app" assert hint[packet_key]["applicability"] == ( @@ -247,7 +256,7 @@ def test_scheduler_execution_context_decision_table( ) assert ("stateful_backoff" in hint[packet_key]) is app_expected assert ("app_automation" in hint) is app_expected - assert ("codex_app" in hint) is (host_surface is not HostSurface.TRAE_APP) + assert ("codex_app" in hint) is (not neutral_app_surface) if app_expected: assert "execution_context" not in hint assert "execution_phase" not in hint @@ -1594,6 +1603,7 @@ def test_stale_unbound_guard_converges_after_profile_regeneration( app_profile = profile in { SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT, SchedulerRuntimeProfile.TRAE_APP, + SchedulerRuntimeProfile.ANTIGRAVITY_APP, } packet_key = "app_automation" if app_profile else "codex_app" assert regenerated_hint[packet_key]["applicability"] == ( @@ -1601,7 +1611,11 @@ def test_stale_unbound_guard_converges_after_profile_regeneration( ) assert ("app_automation" in regenerated_hint) is app_profile assert ("codex_app" in regenerated_hint) is ( - profile is not SchedulerRuntimeProfile.TRAE_APP + profile + not in { + SchedulerRuntimeProfile.TRAE_APP, + SchedulerRuntimeProfile.ANTIGRAVITY_APP, + } ) diff --git a/tests/test_agy_host_surface.py b/tests/test_agy_host_surface.py index 133594086b..3a477f9486 100644 --- a/tests/test_agy_host_surface.py +++ b/tests/test_agy_host_surface.py @@ -316,33 +316,133 @@ def test_antigravity_app_first_class_host_surface_and_schedule_sidecar_contract( "runtime_profile": "antigravity_app" } - # Verify antigravity_app runtime profile maps to interactive AGENT_CLI_LOOP - # (so todo complete / refresh-state use turn-scoped visible-goal settlement - # instead of emitting Codex App -A / --ack-applied-rrule commands). + from loopx.agy_goal_mode import build_antigravity_backoff_schedule_action + from loopx.cli import build_parser + from loopx.cli_commands.quota_context import validate_quota_command_context_request + from loopx.control_plane.scheduler.state import ( + APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, + ) + from tests.control_plane.scheduler_arbitration_fixture import ( + build_native_scheduler_hint, + ) + + # Verify antigravity_app runtime profile maps to HOST_AUTOMATION + HOSTED_AUTOMATION + # with first-class app_automation stateful backoff (matching Codex App & Trae App), + # while emitting --antigravity_app (never -A / --codex-app). runtime_ctx = scheduler_execution_context_for_runtime_profile( SchedulerRuntimeProfile.ANTIGRAVITY_APP ) assert runtime_ctx.ok assert runtime_ctx.context is not None assert runtime_ctx.context.host_surface is HostSurface.ANTIGRAVITY_APP - assert runtime_ctx.context.scheduler_owner is SchedulerOwner.AGENT_CLI_LOOP - assert runtime_ctx.context.execution_mode is ExecutionMode.INTERACTIVE - assert runtime_ctx.context.app_automation_applicable is False + assert runtime_ctx.context.scheduler_owner is SchedulerOwner.HOST_AUTOMATION + assert runtime_ctx.context.execution_mode is ExecutionMode.HOSTED_AUTOMATION + assert runtime_ctx.context.app_automation_applicable is True + assert "codex_app_applicability" not in runtime_ctx.projection() assert ( - runtime_ctx.projection()["codex_app_applicability"] == "not_applicable" + runtime_ctx.projection()["app_automation_applicability"] == "applicable" ) - interactive_ctx = resolve_scheduler_execution_context( + # Invalid owner/mode combinations on antigravity_app are rejected cleanly + invalid_ctx = resolve_scheduler_execution_context( { "host_surface": HostSurface.ANTIGRAVITY_APP.value, "scheduler_owner": SchedulerOwner.AGENT_CLI_LOOP.value, "execution_mode": ExecutionMode.INTERACTIVE.value, } ) - assert interactive_ctx.ok + assert not invalid_ctx.ok + assert ( + invalid_ctx.projection()["app_automation_applicability"] + == "blocked_invalid_context" + ) + + # Verify build_scheduler_hint projects scheduler_hint.app_automation with + # --antigravity_app and --surface antigravity_app (and no -A / codex_app) + hint = build_native_scheduler_hint( + { + "goal_id": "surface-goal", + "agent_identity": {"agent_id": "probe-agent"}, + "should_run": True, + "effective_action": "normal_run", + "recommended_action": "Advance the public fixture.", + "heartbeat_recommendation": { + "recommended_mode": "normal_run", + "spend_policy": "spend only after validated writeback", + }, + "execution_obligation": { + "must_attempt_work": True, + "spend_policy": "spend only after validated writeback", + }, + "automation_liveness": { + "automation_action": "execute_bounded_work", + "spend_policy": "spend only after validated writeback", + }, + "interaction_contract": { + "schema_version": "loopx_interaction_contract_v0", + "mode": "normal_run", + "user_channel": {"action_required": False, "notify": "DONT_NOTIFY"}, + "agent_channel": { + "must_attempt": True, + "delivery_allowed": True, + "quiet_noop_allowed": False, + }, + "cli_channel": {"next_cli_actions": [], "spend_allowed_now": False}, + }, + }, + include_detail=True, + scheduler_execution_context=runtime_ctx, + ) + assert "codex_app" not in hint + app_hint = hint["app_automation"] + assert app_hint["host_surface"] == "antigravity_app" + assert ( + app_hint["stateful_backoff"]["state_key"] + == APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY + ) + assert app_hint["recommended_interval_minutes"] == 3 + ack_cli_args = app_hint["ack_hint"]["cli_args"] + assert "--antigravity_app" in ack_cli_args + assert "-A" not in ack_cli_args + assert "--codex-app" not in ack_cli_args + assert "--surface" in ack_cli_args + assert "antigravity_app" in ack_cli_args + + schedule_action = build_antigravity_backoff_schedule_action( + hint, + current_task_id="task-1750", + prompt="LoopX managed heartbeat bootstrap v2", + ) + assert schedule_action["action"] == "update_current_heartbeat_rrule" + assert schedule_action["cron_expression"] == "*/3 * * * *" + assert schedule_action["manage_task_kill"] == { + "Action": "kill", + "TaskId": "task-1750", + } + assert schedule_action["schedule_call"]["CronExpression"] == "*/3 * * * *" + assert "--antigravity_app" in schedule_action["ack_cli_args"] + + # Verify quota scheduler follow-up parser defaults to antigravity_app + app_automation state key + followup_args = build_parser().parse_args( + [ + "quota", + "scheduler-fail-current", + "--goal-id", + "surface-goal", + "--agent-id", + "probe-agent", + "--antigravity_app", + "--failed-rrule", + "FREQ=MINUTELY;INTERVAL=3", + "--execute", + ] + ) + validate_quota_command_context_request(followup_args) + assert followup_args.surface == "antigravity_app" + assert followup_args.state_key == APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY # Verify hooks_template includes --goal-id and --agent-id when bound, and sidecar uses - # scheduler_hint.local_scheduler.recommended_interval_minutes. + # scheduler_hint.app_automation.recommended_interval_minutes. assert "--goal-id" in mutation["hooks_template"]["loopx"]["args"] assert "surface-goal" in mutation["hooks_template"]["loopx"]["args"] assert "--agent-id" in mutation["hooks_template"]["loopx"]["args"] @@ -351,7 +451,13 @@ def test_antigravity_app_first_class_host_surface_and_schedule_sidecar_contract( mutation["sidecar_template"]["sidecar"]["loopx-heartbeat"][ "suspend_on_waiting_external" ]["interval_minutes_field"] - == "scheduler_hint.local_scheduler.recommended_interval_minutes" + == "scheduler_hint.app_automation.recommended_interval_minutes" + ) + assert ( + mutation["sidecar_template"]["sidecar"]["loopx-heartbeat"][ + "stateful_backoff" + ]["state_key"] + == APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY ) @@ -433,3 +539,78 @@ def test_antigravity_dual_skills_mcp_hooks_workflow_skills_and_doctor( c for c in doc["checks"] if c["id"] == "antigravity_host_surface_ready" ) assert agy_check["ok"] is True + + +def test_antigravity_app_real_cli_ack_and_stateful_backoff_identity( + tmp_path: Path, +) -> None: + """Verify Antigravity App (`--antigravity_app`) runs real CLI `quota should-run`, + `quota scheduler-ack-current`, and `quota scheduler-fail-current` with independent + `antigravity_app` state persistence and zero `-A` / `--codex-app` leakage.""" + from examples.control_plane.quota_plan_fixtures import ( + SCOPED_AGENT_ID, + write_cli_fixture, + ) + from loopx.control_plane.scheduler.state import ( + APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, + load_scheduler_state, + ) + from loopx.control_plane.testing.canary_harness import ( + run_json_cli, + run_json_cli_result, + ) + + goal_id = "needs-operator" + registry_path, runtime_root, project = write_cli_fixture( + tmp_path / "fixture", + scoped_agents=True, + ) + _returncode, first = run_json_cli_result( + "quota", + "should-run", + "--goal-id", + goal_id, + "--agent-id", + SCOPED_AGENT_ID, + "--antigravity_app", + registry_path=registry_path, + runtime_root=runtime_root, + cwd=project, + ) + assert "codex_app" not in first["scheduler_hint"] + app = first["scheduler_hint"]["app_automation"] + assert app["host_surface"] == "antigravity_app" + assert ( + app["stateful_backoff"]["state_key"] + == APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY + ) + assert "--antigravity_app" in app["ack_hint"]["cli_args"] + assert "-A" not in app["ack_hint"]["cli_args"] + + ack = run_json_cli( + *app["ack_hint"]["cli_args"], + registry_path=registry_path, + runtime_root=runtime_root, + cwd=project, + ) + assert ack["scheduler_state_mutated"] is True + assert ack["surface"] == "antigravity_app" + assert ack["state_key"] == APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY + persisted = load_scheduler_state( + runtime_root, + goal_id=goal_id, + agent_id=SCOPED_AGENT_ID, + surface="antigravity_app", + state_key=APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, + ) + assert persisted is not None + assert ( + load_scheduler_state( + runtime_root, + goal_id=goal_id, + agent_id=SCOPED_AGENT_ID, + surface="codex_app", + state_key=APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, + ) + is None + ) From 67ba8cca6e165a6fdcc9907f623a77f62486cd30 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:57:25 +0530 Subject: [PATCH 08/34] fix(antigravity): support receipt-bound native scheduler ACK without codex rrule readback Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/control_plane/scheduler/heartbeat_followup_cli.ts | 2 ++ tests/test_agy_host_surface.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/loopx/control_plane/scheduler/heartbeat_followup_cli.ts b/loopx/control_plane/scheduler/heartbeat_followup_cli.ts index 683ba0dd08..d7c448debf 100644 --- a/loopx/control_plane/scheduler/heartbeat_followup_cli.ts +++ b/loopx/control_plane/scheduler/heartbeat_followup_cli.ts @@ -302,6 +302,8 @@ function requestFromArgs(argv: string[]): { request: Record; fo Object.hasOwn(values, "--codex-app-current-rrule") ) ? { observed_host_rrule: neutralObservedRrule || legacyCodexObservedRrule } + : expectedOperation === "ack" && facts.surface !== "codex_app" + ? { observed_host_rrule: values["--applied-rrule"] ?? facts.applied_rrule } : {}), operation: expectedOperation, runtime_root: parsed.runtime_root, diff --git a/tests/test_agy_host_surface.py b/tests/test_agy_host_surface.py index 3a477f9486..cbcc364531 100644 --- a/tests/test_agy_host_surface.py +++ b/tests/test_agy_host_surface.py @@ -573,6 +573,8 @@ def test_antigravity_app_real_cli_ack_and_stateful_backoff_identity( "--agent-id", SCOPED_AGENT_ID, "--antigravity_app", + "--turn-instance-id", + "2026-11-02T04:00:00Z", registry_path=registry_path, runtime_root=runtime_root, cwd=project, From 686714846e6e3bb2ba291a946db0d70a4eca2057 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:06:20 +0530 Subject: [PATCH 09/34] fix(antigravity): remove antigravity_app from visible_goal_settlement profiles to match heartbeat spend source Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/control_plane/scheduler/execution_context.py | 1 - tests/test_agy_host_surface.py | 13 +++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/loopx/control_plane/scheduler/execution_context.py b/loopx/control_plane/scheduler/execution_context.py index 149b448d50..c2773729da 100644 --- a/loopx/control_plane/scheduler/execution_context.py +++ b/loopx/control_plane/scheduler/execution_context.py @@ -79,7 +79,6 @@ class SchedulerRuntimeProfile(str, Enum): *NATIVE_GOAL_RUNTIME_PROFILES, SchedulerRuntimeProfile.CLAUDE_CODE_VISIBLE, SchedulerRuntimeProfile.KIRO_CLI_VISIBLE, - SchedulerRuntimeProfile.ANTIGRAVITY_APP, } ) diff --git a/tests/test_agy_host_surface.py b/tests/test_agy_host_surface.py index cbcc364531..bd94178bb2 100644 --- a/tests/test_agy_host_surface.py +++ b/tests/test_agy_host_surface.py @@ -329,6 +329,19 @@ def test_antigravity_app_first_class_host_surface_and_schedule_sidecar_contract( # Verify antigravity_app runtime profile maps to HOST_AUTOMATION + HOSTED_AUTOMATION # with first-class app_automation stateful backoff (matching Codex App & Trae App), # while emitting --antigravity_app (never -A / --codex-app). + from loopx.control_plane.scheduler.execution_context import ( + APP_HEARTBEAT_SETTLEMENT_RUNTIME_PROFILES, + VISIBLE_GOAL_SETTLEMENT_RUNTIME_PROFILES, + ) + + assert ( + SchedulerRuntimeProfile.ANTIGRAVITY_APP + in APP_HEARTBEAT_SETTLEMENT_RUNTIME_PROFILES + ) + assert ( + SchedulerRuntimeProfile.ANTIGRAVITY_APP + not in VISIBLE_GOAL_SETTLEMENT_RUNTIME_PROFILES + ) runtime_ctx = scheduler_execution_context_for_runtime_profile( SchedulerRuntimeProfile.ANTIGRAVITY_APP ) From 42921aea616ebf989ee6adb5c05e86e2b1fc4bc1 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:52:24 +0530 Subject: [PATCH 10/34] fix(antigravity): emit native builtin schedule sidecar.json args and restart_policy Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/agy_goal_mode/__init__.py | 26 +++++++++++++++++++++++++- tests/test_agy_host_surface.py | 13 +++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/loopx/agy_goal_mode/__init__.py b/loopx/agy_goal_mode/__init__.py index 083a2a669c..b223b2a80c 100644 --- a/loopx/agy_goal_mode/__init__.py +++ b/loopx/agy_goal_mode/__init__.py @@ -149,15 +149,39 @@ def build_antigravity_sidecar_template( "一次操作不代表结束;通知与执行分开,等待按当前调度契约(遇到 waiting_external / CI 等待时使用原生 schedule(DurationSeconds=..., TimerCondition=\"any\") 挂起,不跑 shell sleep 空查)。" "入口异常先做权限内恢复;契约仍不可用时不执行任务或记账,并报告阻塞。" ) + interval_minutes = max(1, int(interval_seconds) // 60) + cron_expr = cron_for_interval_minutes(interval_minutes) return { "schema_version": "loopx_antigravity_sidecar_template_v0", "config_path": ANTIGRAVITY_SIDECAR_CONFIG_LABEL, + "sidecar_config_path": "~/.gemini/config/sidecars/loopx-antigravity-heartbeat/sidecar.json", + "sidecar_json": { + "builtin": "schedule", + "args": [ + f"CRON_TZ=UTC {cron_expr}", + "agentapi", + "new-conversation", + "--model=pro", + "--", + bootstrap_prompt, + ], + "restart_policy": "always", + "display_name": "LoopX Antigravity Heartbeat", + "description": ( + f"LoopX periodic quota-gated heartbeat for goal {goal_id}" + ), + "agent_permissions": { + "access_grants": [ + f"command({cli_bin})", + ] + }, + }, "sidecar": { "loopx-heartbeat": { "description": ( f"LoopX periodic quota-gated heartbeat for goal {goal_id}" ), - "cron": "*/3 * * * *", + "cron": cron_expr, "interval_seconds": int(interval_seconds), "gate_command": shlex.join(quota_args), "wake_when_json_field_true": "should_run", diff --git a/tests/test_agy_host_surface.py b/tests/test_agy_host_surface.py index bd94178bb2..c6a06a5cc2 100644 --- a/tests/test_agy_host_surface.py +++ b/tests/test_agy_host_surface.py @@ -296,6 +296,19 @@ def test_antigravity_app_first_class_host_surface_and_schedule_sidecar_contract( mutation["sidecar_template"]["schema_version"] == "loopx_antigravity_sidecar_template_v0" ) + assert ( + mutation["sidecar_template"]["sidecar_config_path"] + == "~/.gemini/config/sidecars/loopx-antigravity-heartbeat/sidecar.json" + ) + sidecar_json = mutation["sidecar_template"]["sidecar_json"] + assert sidecar_json["builtin"] == "schedule" + assert sidecar_json["restart_policy"] == "always" + assert sidecar_json["args"][1:5] == [ + "agentapi", + "new-conversation", + "--model=pro", + "--", + ] assert ( "antigravity_app" in mutation["sidecar_template"]["sidecar"]["loopx-heartbeat"]["gate_command"] From 9d34b895b3274f0a233c986b9efcb77299579ed0 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:14:23 +0530 Subject: [PATCH 11/34] fix(antigravity): preserve native retries and bind bounded Stop recovery Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/agy_goal_mode/__init__.py | 136 +++++++++------ loopx/agy_goal_mode/host_runtime.py | 163 ++++++++++++++++++ .../native_chat/antigravity_adapter.py | 82 +++++---- loopx/doctor.py | 4 +- loopx/slash_command_install.py | 40 +++-- tests/test_agy_host_surface.py | 13 +- tests/test_antigravity_host_runtime.py | 161 +++++++++++++++++ tests/test_antigravity_steward_adapter.py | 53 +++++- 8 files changed, 541 insertions(+), 111 deletions(-) create mode 100644 loopx/agy_goal_mode/host_runtime.py create mode 100644 tests/test_antigravity_host_runtime.py diff --git a/loopx/agy_goal_mode/__init__.py b/loopx/agy_goal_mode/__init__.py index 083a2a669c..466b2ab82d 100644 --- a/loopx/agy_goal_mode/__init__.py +++ b/loopx/agy_goal_mode/__init__.py @@ -2,13 +2,15 @@ Supports both: - Legacy Antigravity CLI (``agy`` 1.x) flat skills in ``~/.gemini/antigravity-cli/skills/.md`` with native ``/goal`` and ``schedule`` tools. -- Antigravity 2.0 / Antigravity App (``antigravity-app``) directory skills in ``~/.gemini/config/skills//SKILL.md``, in-session ``schedule(DurationSeconds=..., Prompt=...)`` closed-loop timer suspension (for ``waiting_external`` / CI waits), background ``sidecar.json`` periodic heartbeat template, and optional ``mcp_config.json`` + ``hooks.json`` continuation gate registration. +- Antigravity 2.0 / Antigravity App (``antigravity-app``) directory skills in ``~/.gemini/config/skills//SKILL.md``, in-session ``schedule(DurationSeconds=..., Prompt=...)`` closed-loop timer suspension (for ``waiting_external`` / CI waits), background ``sidecar.json`` periodic heartbeat template, and optional ``mcp_config.json`` + ``hooks.json`` bounded Stop recovery registration. """ from __future__ import annotations import os import shlex +import shutil +import sys from pathlib import Path from typing import Any @@ -64,7 +66,7 @@ "not need a polling loop inside a turn.", "All native wakes live inside the running Antigravity process; for " "background periodic heartbeats in Antigravity 2.0 (`antigravity-app`), " - "configure `sidecar.json` and optional `hooks.json` continuation gates in " + "configure `sidecar.json` and optional `hooks.json` bound Stop recovery in " "`~/.gemini/config`.", ) @@ -86,13 +88,15 @@ ANTIGRAVITY_SIDECAR_FILENAME = "sidecar.json" ANTIGRAVITY_MCP_CONFIG_LABEL = "~/.gemini/config/mcp_config.json" ANTIGRAVITY_HOOKS_CONFIG_LABEL = "~/.gemini/config/hooks.json" -ANTIGRAVITY_SIDECAR_CONFIG_LABEL = "~/.gemini/config/sidecar.json" +ANTIGRAVITY_SIDECAR_CONFIG_LABEL = ( + "~/.gemini/config/sidecars/loopx-antigravity-heartbeat/sidecar.json" +) ANTIGRAVITY_APP_AGENT_TYPE_CATALOG_ENTRY: dict[str, Any] = { "display_name": "Antigravity App", "host_loop": ( "Antigravity App native schedule timer closed loop + optional background " - "sidecar.json heartbeat and hooks.json continuation gate" + "sidecar.json heartbeat and hooks.json bound Stop recovery" ), "entry": ( f"the LoopX skill installed in {ANTIGRAVITY_SKILLS_ROOT_LABEL} " @@ -146,18 +150,40 @@ def build_antigravity_sidecar_template( "每次唤醒先执行:\n" f"```sh\n{shlex.join(heartbeat_args)}\n```\n" "读取完整结果;仅 ok=true 时按本次 task_body 推进,不复用旧指令。" - "一次操作不代表结束;通知与执行分开,等待按当前调度契约(遇到 waiting_external / CI 等待时使用原生 schedule(DurationSeconds=..., TimerCondition=\"any\") 挂起,不跑 shell sleep 空查)。" + '一次操作不代表结束;通知与执行分开,等待按当前调度契约(遇到 waiting_external / CI 等待时使用原生 schedule(DurationSeconds=..., TimerCondition="any") 挂起,不跑 shell sleep 空查)。' "入口异常先做权限内恢复;契约仍不可用时不执行任务或记账,并报告阻塞。" ) + interval_minutes = max(1, int(interval_seconds) // 60) + cron_expr = cron_for_interval_minutes(interval_minutes) return { "schema_version": "loopx_antigravity_sidecar_template_v0", "config_path": ANTIGRAVITY_SIDECAR_CONFIG_LABEL, + "sidecar_config_path": ANTIGRAVITY_SIDECAR_CONFIG_LABEL, + "sidecar_json": { + "builtin": "schedule", + "args": [ + f"CRON_TZ=UTC {cron_expr}", + "agentapi", + "new-conversation", + "--model=pro", + "--", + bootstrap_prompt, + ], + "restart_policy": "always", + "display_name": "LoopX Antigravity Heartbeat", + "description": (f"LoopX periodic quota-gated heartbeat for goal {goal_id}"), + "agent_permissions": { + "access_grants": [ + f"command({cli_bin})", + ] + }, + }, "sidecar": { "loopx-heartbeat": { "description": ( f"LoopX periodic quota-gated heartbeat for goal {goal_id}" ), - "cron": "*/3 * * * *", + "cron": cron_expr, "interval_seconds": int(interval_seconds), "gate_command": shlex.join(quota_args), "wake_when_json_field_true": "should_run", @@ -194,6 +220,38 @@ def build_antigravity_sidecar_template( } +def _antigravity_provider_args( + operation: str, + *, + cli_bin: str, + goal_id: str | None, + agent_id: str | None, + conversation_id: str | None, + runtime_root: str | None, + registry: str | None = None, +) -> list[str]: + args = [ + sys.executable, + "-E", + "-m", + "loopx.agy_goal_mode.host_runtime", + operation, + "--cli-bin", + shutil.which(cli_bin) or cli_bin, + "--goal-id", + goal_id or "", + "--agent-id", + agent_id or "", + "--conversation-id", + conversation_id or "", + ] + if runtime_root: + args.extend(["--runtime-root", runtime_root]) + if registry: + args.extend(["--registry", registry]) + return args + + def cron_for_interval_minutes(interval_minutes: int) -> str: """Convert a LoopX ``recommended_interval_minutes`` into a 5-field cron expression.""" minutes = max(1, int(interval_minutes)) @@ -292,49 +350,26 @@ def build_antigravity_hooks_template( goal_id: str | None = None, agent_id: str | None = None, runtime_root: str | None = None, + conversation_id: str | None = None, + registry: str | None = None, ) -> dict[str, Any]: - """Build a public-safe ``hooks.json`` continuation gate entry for Antigravity 2.0. - - When bound to a concrete ``goal_id``, invokes ``quota should-run --goal-id ...``; - when installed globally without a goal binding, invokes ``quota plan --runtime-profile antigravity_app`` - so the command succeeds without ``--goal-id``. - """ - args: list[str] = ["--format", "json"] - if runtime_root: - args.extend(["--runtime-root", runtime_root]) - bound_goal = bool(goal_id and goal_id != "") - if bound_goal: - args.extend( - [ - "quota", - "should-run", - "--goal-id", - str(goal_id), - "--runtime-profile", - "antigravity_app", - ] - ) - if agent_id: - args.extend(["--agent-id", str(agent_id)]) - require_field = "should_run" - else: - args.extend( - [ - "quota", - "plan", - "--runtime-profile", - "antigravity_app", - ] - ) - require_field = "ok" + """Native Stop hook; installation alone never binds an arbitrary chat.""" + conversation_id = conversation_id or os.environ.get("ANTIGRAVITY_CONVERSATION_ID") + args = _antigravity_provider_args( + "stop-hook", + cli_bin=cli_bin, + goal_id=goal_id, + agent_id=agent_id, + conversation_id=conversation_id, + runtime_root=runtime_root, + registry=registry, + ) return { "loopx": { - "schema_version": "loopx_antigravity_hooks_gate_v0", - "event": "before_continuation", - "command": cli_bin, - "args": args, - "require_json_field_true": require_field, - "on_waiting_external": "suspend_via_schedule_timer", + "enabled": bool( + conversation_id and agent_id and goal_id and goal_id != "" + ), + "Stop": [{"type": "command", "command": shlex.join(args), "timeout": 20}], } } @@ -426,10 +461,7 @@ def antigravity_app_activation_extras( "extra_host_mutation": { "host_loop_primitive": "antigravity-schedule-tool-and-sidecar", "loop_driver": "antigravity_app_schedule_timer_and_sidecar_loop", - "quota_gate_enforcement": "hooks_or_advisory_quota_entry", - "native_goal_command": AGY_GOAL_COMMAND, - "goal_complete_token": AGY_GOAL_COMPLETE_TOKEN, - "goal_cancelled_token": AGY_GOAL_CANCELLED_TOKEN, + "quota_gate_enforcement": "advisory_agent_entry_with_bound_stop_recovery", "native_wake_tools": list(AGY_NATIVE_WAKE_TOOLS), "native_wake_facts": list(AGY_NATIVE_WAKE_FACTS), "skills_roots": [ @@ -443,8 +475,6 @@ def antigravity_app_activation_extras( "in_session_schedule_call": { "tool": "schedule", "arguments": { - "CronExpression": "*/3 * * * *", - "IsDaemon": True, "DurationSeconds": 180, "Prompt": wake_prompt, "TimerCondition": "any", @@ -467,7 +497,7 @@ def antigravity_app_activation_extras( "extra_activation_steps": [ f"Install or refresh the Antigravity skill and optional MCP/hooks surface (`{cli_bin} slash-commands --install --surface antigravity-app --with-agy-mcp --with-agy-hooks` and `{cli_bin} workflow-skills --install --host-surface antigravity-app`).", "When a turn enters `waiting_external` or waits on CI/review checks, suspend directly with the native `schedule(DurationSeconds=, Prompt=...)` tool and end the turn instead of polling in a shell loop.", - f"For background periodic heartbeats, apply the generated `sidecar_template` in `{ANTIGRAVITY_SIDECAR_CONFIG_LABEL}` so each tick gates on `quota should-run` before waking the agent.", + f"For background periodic heartbeats, write only `sidecar_template.sidecar_json` to `{ANTIGRAVITY_SIDECAR_CONFIG_LABEL}`. Preserve its existing id, model, permissions and host enable choice. Each fresh native heartbeat loads Core admission before work; do not persist an expanded execution body. Apply the bound `hooks_template` at the top level of hooks.json for bounded transport-error recovery; never wrap it in a `hooks` object.", ], "host_scheduler_note": ( "use the native `schedule(DurationSeconds=..., Prompt=...)` tool to suspend " diff --git a/loopx/agy_goal_mode/host_runtime.py b/loopx/agy_goal_mode/host_runtime.py new file mode 100644 index 0000000000..f1700f9105 --- /dev/null +++ b/loopx/agy_goal_mode/host_runtime.py @@ -0,0 +1,163 @@ +"""Antigravity IO for the existing LoopX heartbeat and native Stop hook. + +Quota and scheduler policy remain in Core. This provider translates native +events and reserves continuation decisions before replying to the host. +""" + +from __future__ import annotations + +import argparse +from contextlib import contextmanager +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import time +from typing import Any + +from ..file_lock import exclusive_file_lock + + +def retryable_native_error(error: Any) -> bool: + """Conservative mapping of the native provider's unstructured error field. + + Only known Google model transport errors qualify. User/model prose, quota, + auth, cancellation and permission errors do not establish retry authority. + """ + if isinstance(error, dict): + error = error.get("shortError") + if not isinstance(error, str): + return False + return error.startswith("agent executor error: generating and executing:") and ( + error.endswith("stream reading error: unexpected EOF") + or ( + 'request failed: Post "https://' in error + and ':streamGenerateContent?alt=sse": EOF' in error + ) + ) + + +def _core_args(args: argparse.Namespace, command: list[str]) -> list[str]: + result = [args.cli_bin, "--format", "json"] + for flag, value in ( + ("--registry", args.registry), + ("--runtime-root", args.runtime_root), + ): + if value: + result.extend([flag, value]) + return ( + result + + command + + [ + "--goal-id", + args.goal_id, + "--agent-id", + args.agent_id, + "--runtime-profile", + "antigravity_app", + ] + ) + + +def _admitted(args: argparse.Namespace) -> bool: + read = subprocess.run( + _core_args(args, ["quota", "should-run"]), + capture_output=True, + text=True, + timeout=15, + check=True, + env={key: value for key, value in os.environ.items() if key != "PYTHONPATH"}, + ) + return json.loads(read.stdout).get("should_run") is True + + +def bootstrap_prompt(args: argparse.Namespace) -> str: + import shlex + + return ( + "LoopX managed heartbeat bootstrap v2\n" + "Run this command and read its complete fresh JSON result:\n" + + shlex.join(_core_args(args, ["heartbeat-prompt", "--thin"])) + + "\nFollow task_body only when ok=true, including current scope, stop, " + "settlement and scheduler backoff. Do not replay the failed user request, " + "create another Goal/Agent, or reuse an old Turn receipt. Remain quiet " + "when the fresh user_channel requests no notification." + ) + + +@contextmanager +def _state(args: argparse.Namespace): + # Native conversation ids never become path components. + key = hashlib.sha256(args.conversation_id.encode()).hexdigest() + path = Path(args.state_dir).expanduser() / (key + ".json") + path.parent.mkdir(parents=True, exist_ok=True) + with exclusive_file_lock(path, timeout_seconds=2): + data = json.loads(path.read_text()) if path.exists() else {} + yield data + temporary = path.with_suffix(".tmp") + with temporary.open("w", encoding="utf-8") as stream: + temporary.chmod(0o600) + stream.write(json.dumps(data)) + stream.flush() + os.fsync(stream.fileno()) + temporary.replace(path) + + +def _reserve_retry(state: dict[str, Any], input_key: str) -> bool: + if state.get("input_key") != input_key: + state.update(input_key=input_key, retries=0) + if state.get("retries", 0) >= 2 or time.time() - state.get("retry_at", 0) < 60: + return False + state.update(retries=state.get("retries", 0) + 1, retry_at=time.time()) + return True + + +def stop_hook(args: argparse.Namespace, event: dict[str, Any]) -> dict[str, Any]: + if ( + event.get("conversationId") != args.conversation_id + or event.get("terminationReason") != "ERROR" + or event.get("fullyIdle") is not True + or not retryable_native_error(event.get("error")) + ): + return {} + text = event.get("lastUserInput") + if not isinstance(text, str) or not text or not _admitted(args): + return {} + with _state(args) as state: + if not _reserve_retry(state, hashlib.sha256(text.encode()).hexdigest()): + return {} + # Reservation is durable before returning the native continuation decision. + return {"decision": "continue", "reason": bootstrap_prompt(args)} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("operation", choices=("stop-hook",)) + parser.add_argument("--cli-bin", default="loopx") + parser.add_argument("--registry") + parser.add_argument("--runtime-root") + parser.add_argument("--goal-id", required=True) + parser.add_argument("--agent-id", required=True) + parser.add_argument("--conversation-id", required=True) + parser.add_argument( + "--state-dir", default=str(Path.home() / ".loopx/host-state/antigravity") + ) + args = parser.parse_args() + try: + result = stop_hook(args, json.load(sys.stdin)) + except Exception as exc: + # Hook stdout must remain valid native JSON. Provider detail may contain + # credentials or private context; expose only the failure class. + print( + f"LoopX Antigravity {args.operation} unavailable ({type(exc).__name__})", + file=sys.stderr, + ) + result = {} + print(json.dumps(result)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/loopx/capabilities/native_chat/antigravity_adapter.py b/loopx/capabilities/native_chat/antigravity_adapter.py index d637aa2f98..c5866c86f8 100644 --- a/loopx/capabilities/native_chat/antigravity_adapter.py +++ b/loopx/capabilities/native_chat/antigravity_adapter.py @@ -364,8 +364,11 @@ def read(method: str, **payload: Any) -> dict[str, Any]: execution = metadata.get("executionId") current = indexed[start:] if not isinstance(execution, str) or not execution or any( - step.get("status") != "CORTEX_STEP_STATUS_DONE" - or step.get("type") == "CORTEX_STEP_TYPE_ERROR_MESSAGE" + (step.get("status") != "CORTEX_STEP_STATUS_DONE" + and not (step.get("type") == "CORTEX_STEP_TYPE_ERROR_MESSAGE" + and step.get("status") == "CORTEX_STEP_STATUS_CLEARED")) + or (step.get("type") == "CORTEX_STEP_TYPE_ERROR_MESSAGE" + and (step.get("errorMessage") or {}).get("shouldShowUser") is True) or (step.get("metadata") or {}).get("executionId") != execution for _, step in current): return None @@ -675,13 +678,22 @@ def _run_turn( self._check_interrupted() self._interrupted.wait(0.25) self._check_interrupted() - traj = _rpc_post( - self.ls_url, - self.csrf_token, - "GetCascadeTrajectory", - {"cascadeId": self.cascade_id}, - timeout=2.0, - ) + try: + traj = _rpc_post( + self.ls_url, + self.csrf_token, + "GetCascadeTrajectory", + {"cascadeId": self.cascade_id}, + timeout=2.0, + ) + except CodexChatAgentError as exc: + # Losing an idempotent observation does not mean that the + # native invocation failed. Never resend its input, refresh its + # progress clock, or bypass the existing stop/deadline checks. + if exc.error_code != "antigravity_transport_error": + raise + self._interrupted.wait(1.0) + continue status = str(traj.get("status") or "") total_steps = int(traj.get("numTotalSteps") or 0) if status == "CASCADE_RUN_STATUS_RUNNING" or total_steps > start_step_index: @@ -691,17 +703,23 @@ def _run_turn( observed_total = total_steps if total_steps > start_step_index: self._check_interrupted() - steps_resp = _rpc_post( - self.ls_url, - self.csrf_token, - "GetCascadeTrajectorySteps", - { - "cascadeId": self.cascade_id, - "startIndex": start_step_index, - "endIndex": total_steps, - }, - timeout=2.0, - ) + try: + steps_resp = _rpc_post( + self.ls_url, + self.csrf_token, + "GetCascadeTrajectorySteps", + { + "cascadeId": self.cascade_id, + "startIndex": start_step_index, + "endIndex": total_steps, + }, + timeout=2.0, + ) + except CodexChatAgentError as exc: + if exc.error_code != "antigravity_transport_error": + raise + self._interrupted.wait(1.0) + continue steps = steps_resp.get("steps") try: if not isinstance(steps, list): @@ -777,17 +795,11 @@ def _run_turn( emitted_visible_delta = True event_sink("answer.delta", {"text": visible_delta}) elif step_type == "CORTEX_STEP_TYPE_ERROR_MESSAGE": - err_obj = step.get("errorMessage") or {} - err_msg = str( - (err_obj.get("error") if isinstance(err_obj, dict) else None) - or err_obj - or "Antigravity cascade step error" - ) - raise CodexChatAgentError( - err_msg, - gate=None, - error_code="antigravity_step_error", - ) + # The native trajectory also records recoverable + # API attempts and cleared errors. The native host + # owns retry: only its settled terminal error ends + # this turn. Do not cancel its in-flight backoff. + continue # A running status alone is not progress. Observe only this input's # steps, including streamed text and command output; old history or @@ -808,6 +820,14 @@ def _run_turn( gate=None, error_code="response_timeout") if saw_running_or_new_step and status == "CASCADE_RUN_STATUS_IDLE" and observed_total == total_steps: final = current_steps[-1][1] if current_steps else {} + if final.get("type") == "CORTEX_STEP_TYPE_ERROR_MESSAGE": + detail = (final.get("errorMessage") or {}).get("error") or {} + summary = (detail.get("userErrorMessage") or detail.get("shortError") + if isinstance(detail, dict) else str(detail)) + error = CodexChatAgentError(summary or "Antigravity cascade step error", + gate=None, error_code="antigravity_step_error") + error.native_error_info = detail + raise error planner = final.get("plannerResponse") or {} if self._read_only_review: inputs = [step for _, step in current_steps if step.get("type") == "CORTEX_STEP_TYPE_USER_INPUT"] diff --git a/loopx/doctor.py b/loopx/doctor.py index c446c11808..0fb990acce 100644 --- a/loopx/doctor.py +++ b/loopx/doctor.py @@ -1307,9 +1307,9 @@ def collect_doctor( "hooks_config_exists": ( agy_cfg_home / ANTIGRAVITY_HOOKS_CONFIG_FILENAME ).is_file(), - "sidecar_config_path": str(agy_cfg_home / ANTIGRAVITY_SIDECAR_FILENAME), + "sidecar_config_path": str(agy_cfg_home / "sidecars" / "loopx-antigravity-heartbeat" / ANTIGRAVITY_SIDECAR_FILENAME), "sidecar_config_exists": ( - agy_cfg_home / ANTIGRAVITY_SIDECAR_FILENAME + agy_cfg_home / "sidecars" / "loopx-antigravity-heartbeat" / ANTIGRAVITY_SIDECAR_FILENAME ).is_file(), "repair_commands": [ "loopx workflow-skills --install --host-surface antigravity-app", diff --git a/loopx/slash_command_install.py b/loopx/slash_command_install.py index feb04fce5f..72e024a624 100644 --- a/loopx/slash_command_install.py +++ b/loopx/slash_command_install.py @@ -918,7 +918,7 @@ def _merge_managed_hooks_json( uninstall: bool, execute: bool, ) -> str: - """Add or remove the ``loopx`` continuation gate in Antigravity ``hooks.json``.""" + """Add or remove the managed ``loopx`` Stop recovery hook in Antigravity ``hooks.json``.""" marker_path = config_path.parent / ANTIGRAVITY_HOOKS_MARKER_NAME try: text = config_path.read_text(encoding="utf-8") if config_path.exists() else "" @@ -927,13 +927,6 @@ def _merge_managed_hooks_json( return "blocked_invalid_antigravity_hooks_json" if not isinstance(raw, dict): return "blocked_invalid_antigravity_hooks_json" - hooks = raw.get("hooks") - if hooks is None: - hooks = {} - if not isinstance(hooks, dict): - return "blocked_invalid_antigravity_hooks_json" - - existing = hooks.get(MANAGED_MCP_KEY) try: marker_payload = json.loads(marker_path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): @@ -944,6 +937,16 @@ def _merge_managed_hooks_json( else {} ) recorded = recorded_hooks.get(MANAGED_MCP_KEY) + # Native named hooks live at the top level. Earlier proposals wrote an + # ignored `hooks.loopx` wrapper; migrate it only with exact ownership proof. + legacy = raw.get("hooks") + legacy_entry = legacy.get(MANAGED_MCP_KEY) if isinstance(legacy, dict) else None + existing = raw.get(MANAGED_MCP_KEY) + legacy_owned = existing is None and legacy_entry is not None and legacy_entry == recorded + if legacy_owned: + existing = legacy_entry + elif existing is None and legacy_entry is not None: + return "skipped_user_owned_hooks_entry" loopx_owned = existing is not None and existing == recorded if execute and recorded is not None and not loopx_owned: marker_path.unlink(missing_ok=True) @@ -955,8 +958,12 @@ def _merge_managed_hooks_json( return "skipped_user_owned_hooks_entry" if not execute: return "would_retire" - hooks.pop(MANAGED_MCP_KEY, None) - raw["hooks"] = hooks + if legacy_owned: + legacy.pop(MANAGED_MCP_KEY) + if not legacy: + raw.pop("hooks") + else: + raw.pop(MANAGED_MCP_KEY) _write_json_atomic(config_path, raw) marker_path.unlink(missing_ok=True) return "retired" @@ -965,11 +972,14 @@ def _merge_managed_hooks_json( return "skipped_user_owned_hooks_entry" entry = build_antigravity_hooks_template(cli_bin=cli_bin)[MANAGED_MCP_KEY] if not execute: - return "unchanged" if existing == entry else "would_write" - if existing == entry: + return "unchanged" if existing == entry and not legacy_owned else "would_write" + if existing == entry and not legacy_owned: return "unchanged" - hooks[MANAGED_MCP_KEY] = entry - raw["hooks"] = hooks + if legacy_owned: + legacy.pop(MANAGED_MCP_KEY) + if not legacy: + raw.pop("hooks") + raw[MANAGED_MCP_KEY] = entry _write_json_atomic(config_path, raw) _write_json_atomic( marker_path, @@ -1304,7 +1314,7 @@ def install_slash_commands( { "surface": "antigravity-app", "host_surfaces": ["antigravity-app", "agy"], - "mechanism": "antigravity_hooks_gate", + "mechanism": "antigravity_stop_recovery_hook", "command": "loopx", "path": str(agy_hooks_path), "status": hooks_status, diff --git a/tests/test_agy_host_surface.py b/tests/test_agy_host_surface.py index bd94178bb2..e6f4a58daf 100644 --- a/tests/test_agy_host_surface.py +++ b/tests/test_agy_host_surface.py @@ -300,10 +300,8 @@ def test_antigravity_app_first_class_host_surface_and_schedule_sidecar_contract( "antigravity_app" in mutation["sidecar_template"]["sidecar"]["loopx-heartbeat"]["gate_command"] ) - assert ( - mutation["hooks_template"]["loopx"]["schema_version"] - == "loopx_antigravity_hooks_gate_v0" - ) + assert mutation["hooks_template"]["loopx"]["Stop"][0]["type"] == "command" + assert mutation["sidecar_template"]["sidecar_json"]["args"][0] == "CRON_TZ=UTC */3 * * * *" gate_dir = tmp_path / "gate" gate_dir.mkdir() @@ -456,10 +454,9 @@ def test_antigravity_app_first_class_host_surface_and_schedule_sidecar_contract( # Verify hooks_template includes --goal-id and --agent-id when bound, and sidecar uses # scheduler_hint.app_automation.recommended_interval_minutes. - assert "--goal-id" in mutation["hooks_template"]["loopx"]["args"] - assert "surface-goal" in mutation["hooks_template"]["loopx"]["args"] - assert "--agent-id" in mutation["hooks_template"]["loopx"]["args"] - assert "probe-agent" in mutation["hooks_template"]["loopx"]["args"] + hook_command = mutation["hooks_template"]["loopx"]["Stop"][0]["command"] + assert "--goal-id surface-goal" in hook_command + assert "--agent-id probe-agent" in hook_command assert ( mutation["sidecar_template"]["sidecar"]["loopx-heartbeat"][ "suspend_on_waiting_external" diff --git a/tests/test_antigravity_host_runtime.py b/tests/test_antigravity_host_runtime.py new file mode 100644 index 0000000000..4989416db6 --- /dev/null +++ b/tests/test_antigravity_host_runtime.py @@ -0,0 +1,161 @@ +"""Native host schemas, bounded recovery and once-only wake delivery.""" + +from argparse import Namespace +import json +import shlex +import subprocess + +import pytest + +from loopx.agy_goal_mode import ( + build_antigravity_hooks_template, + build_antigravity_sidecar_template, +) +from loopx.agy_goal_mode import host_runtime as runtime +from loopx.slash_command_install import ( + ANTIGRAVITY_HOOKS_MARKER_NAME, + _merge_managed_hooks_json, +) + + +NETWORK_ERROR = "agent executor error: generating and executing: stream reading error: unexpected EOF" + + +def bound(tmp_path): + return Namespace( + cli_bin="loopx", + registry=None, + runtime_root=None, + goal_id="work", + agent_id="worker", + conversation_id="native-chat", + agentapi="agentapi", + state_dir=str(tmp_path / "recovery"), + ) + + +def event(**changes): + return { + "conversationId": "native-chat", + "terminationReason": "ERROR", + "fullyIdle": True, + "lastUserInput": "Continue the authorized work", + "error": NETWORK_ERROR, + **changes, + } + + +@pytest.mark.parametrize( + "change", + [ + {"conversationId": "another-chat"}, + {"terminationReason": "USER_CANCELED"}, + {"terminationReason": "NO_TOOL_CALL"}, + {"terminationReason": "MAX_TOKEN_BUDGET_EXCEEDED"}, + {"fullyIdle": False}, + {"lastUserInput": ""}, + {"error": "EOF"}, + {"error": "permission denied"}, + {"error": "quota exhausted"}, + ], +) +def test_unrelated_stop_and_user_stop_never_query_quota_or_continue( + tmp_path, monkeypatch, change +): + def forbidden(*_): + pytest.fail("unrelated native stop must have no Core side effects") + + monkeypatch.setattr(runtime, "_admitted", forbidden) + assert runtime.stop_hook(bound(tmp_path), event(**change)) == {} + assert not (tmp_path / "recovery").exists() + + +def test_native_retry_requires_fresh_admission_and_is_bounded_per_input( + tmp_path, monkeypatch +): + args = bound(tmp_path) + monkeypatch.setattr(runtime, "_admitted", lambda _: False) + assert runtime.stop_hook(args, event()) == {} + monkeypatch.setattr(runtime, "_admitted", lambda _: True) + clock = [1000] + monkeypatch.setattr(runtime.time, "time", lambda: clock[0]) + for attempt in range(4): + response = runtime.stop_hook(args, event()) + assert (response.get("decision") == "continue") == (attempt < 2) + if response: + assert "heartbeat-prompt --thin" in response["reason"] + assert "--agent-id worker" in response["reason"] + clock[0] += 61 + assert ( + runtime.stop_hook(args, event(lastUserInput="A new authorized request"))[ + "decision" + ] + == "continue" + ) + + +def test_generated_native_configs_execute_the_stdio_hook_from_another_directory( + tmp_path, monkeypatch +): + monkeypatch.delenv("ANTIGRAVITY_CONVERSATION_ID", raising=False) + foreign = tmp_path / "foreign" / "loopx" + foreign.mkdir(parents=True) + (foreign / "__init__.py").write_text( + 'raise RuntimeError("wrong installed release")' + ) + monkeypatch.setenv("PYTHONPATH", str(foreign.parent)) + assert build_antigravity_hooks_template()["loopx"]["enabled"] is False + assert "sidecar_json" in build_antigravity_sidecar_template() + kwargs = dict(goal_id="work", agent_id="worker", conversation_id="native-chat") + config = build_antigravity_sidecar_template(goal_id="work", agent_id="worker")[ + "sidecar_json" + ] + assert config["builtin"] == "schedule" + assert config["args"][0] == "CRON_TZ=UTC */3 * * * *" + assert config["args"][1:5] == ["agentapi", "new-conversation", "--model=pro", "--"] + hook = build_antigravity_hooks_template(**kwargs)["loopx"] + assert hook["enabled"] is True + # The installed native contract consumes one event on stdin and one native + # JSON result on stdout, not LoopX's arbitrary quota projection. + result = subprocess.run( + shlex.split(hook["Stop"][0]["command"]), + input=json.dumps(event(terminationReason="USER_CANCELED")), + cwd=tmp_path, + text=True, + capture_output=True, + timeout=10, + ) + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == {} + + +def test_hooks_migrate_only_an_unchanged_owned_legacy_entry(tmp_path): + path = tmp_path / "hooks.json" + legacy = {"event": "before_continuation", "command": "loopx"} + unrelated = {"Stop": [{"type": "command", "command": "echo '{}'"}]} + original = {"hooks": {"loopx": legacy}, "personal-hook": unrelated} + path.write_text(json.dumps(original)) + args = dict(config_path=path, cli_bin="loopx", uninstall=False) + assert ( + _merge_managed_hooks_json(**args, execute=True) + == "skipped_user_owned_hooks_entry" + ) + assert json.loads(path.read_text()) == original + marker = tmp_path / ANTIGRAVITY_HOOKS_MARKER_NAME + marker.write_text(json.dumps({"hooks": {"loopx": legacy}})) + assert _merge_managed_hooks_json(**args, execute=False) == "would_write" + assert json.loads(path.read_text()) == original + assert _merge_managed_hooks_json(**args, execute=True) == "written" + result = json.loads(path.read_text()) + assert result["personal-hook"] == unrelated + assert "hooks" not in result + assert result["loopx"]["enabled"] is False + assert "Stop" in result["loopx"] + assert _merge_managed_hooks_json(**args, execute=True) == "unchanged" + assert ( + _merge_managed_hooks_json( + config_path=path, cli_bin="loopx", uninstall=True, execute=True + ) + == "retired" + ) + assert json.loads(path.read_text()) == {"personal-hook": unrelated} diff --git a/tests/test_antigravity_steward_adapter.py b/tests/test_antigravity_steward_adapter.py index 28ba9dc8f2..3349462ed6 100644 --- a/tests/test_antigravity_steward_adapter.py +++ b/tests/test_antigravity_steward_adapter.py @@ -106,6 +106,13 @@ def do_POST(self): body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) method = self.path.rsplit("/", 1)[-1] state["calls"].append((method, body)) + failures = state.get("read_failures", {}) + if state["active"] and failures.get(method, 0): + failures[method] -= 1 + self.send_response(200) + self.end_headers() + self.wfile.write(b"incomplete observation") + return if state.get("redirect"): self.send_response(302) self.send_header("Location", state["redirect"]) @@ -231,7 +238,7 @@ def test_cancel_failure_cannot_replace_the_original_provider_error(tmp_path, lan assert any("cancellation remains unconfirmed" in note for note in error.value.__notes__) -@pytest.mark.parametrize("change", ["none", "stream-error", "pending-tool", "foreign-input", "wrong-source", +@pytest.mark.parametrize("change", ["none", "native-retry", "terminal-error", "stream-error", "pending-tool", "foreign-input", "wrong-source", "old-input", "execution-changed", "incomplete", "wrong-anchor", "running", "missing-index", "changed-during-read"]) def test_completed_result_readback_is_exact_and_never_executes(tmp_path, language_server, change): ref = "f" * 24 @@ -244,6 +251,10 @@ def test_completed_result_readback_is_exact_and_never_executes(tmp_path, languag {"type": "CORTEX_STEP_TYPE_PLANNER_RESPONSE", "status": "CORTEX_STEP_STATUS_DONE", "plannerResponse": {"response": "Original completed answer", "stopReason": "STOP_REASON_STOP_PATTERN"}}, ] + if change in {"native-retry", "terminal-error"}: + steps.insert(1, {"type": "CORTEX_STEP_TYPE_ERROR_MESSAGE", "status": "CORTEX_STEP_STATUS_CLEARED", + "errorMessage": {"error": {"shortError": "API attempt failed"}, + "shouldShowUser": change == "terminal-error"}}) for index, step in enumerate(steps): step["metadata"] = {"createdAt": "2026-01-01T00:00:05Z", "executionId": "original-execution", "sourceTrajectoryStepInfo": {"stepIndex": index}} @@ -273,7 +284,7 @@ def test_completed_result_readback_is_exact_and_never_executes(tmp_path, languag proof = AntigravityAdapter.completed_result_observation(cascade_id="synthetic-cascade", request_ref=ref, message="Original request", started_at="2026-01-01T00:00:00Z", completed_at="2026-01-01T00:00:10Z", work_dir=tmp_path, input_anchor={"input_sha256": "0" * 64} if change == "wrong-anchor" else None) - assert (proof is not None) == (change == "none") + assert (proof is not None) == (change in {"none", "native-retry"}) if proof: assert proof["response"]["message"] == "Original completed answer" assert not any(method in {"StartCascade", "SendUserCascadeMessage", "CancelCascadeInvocation"} @@ -559,3 +570,41 @@ def test_another_native_input_is_neither_adopted_nor_cancelled(tmp_path, languag assert error.value.error_code == "upstream_transport_failure" assert not language_server["cancelled"].is_set() assert len([1 for method, _ in language_server["calls"] if method == "SendUserCascadeMessage"]) == 1 + + +@pytest.mark.parametrize("method", ["GetCascadeTrajectory", "GetCascadeTrajectorySteps"]) +def test_lost_native_observation_keeps_the_same_invocation(tmp_path, language_server, method): + language_server["read_failures"] = {method: 1} + result = adapter_for(tmp_path).start_turn("task with effects") + assert result["message"] == "Argon answer 1" + assert not language_server["cancelled"].is_set() + assert len([1 for name, _ in language_server["calls"] if name == "SendUserCascadeMessage"]) == 1 + + +def test_native_retry_errors_do_not_cancel_native_backoff(tmp_path, language_server, monkeypatch): + frames = _native_frames() + error = {"type": "CORTEX_STEP_TYPE_ERROR_MESSAGE", "status": "CORTEX_STEP_STATUS_DONE", + "metadata": {"sourceTrajectoryStepInfo": {"stepIndex": 2}}, + "errorMessage": {"error": {"shortError": "API error (attempt 1): EOF"}}} + for frame in frames: + frame["steps"].insert(1, {**error, "metadata": {"sourceTrajectoryStepInfo": {"stepIndex": 1}}}) + frame["steps"][-1]["metadata"]["sourceTrajectoryStepInfo"]["stepIndex"] = 2 + _native_clock(monkeypatch, language_server, frames) + result = adapter_for(tmp_path).start_turn("original task") + assert result["message"] == "native final" + assert not language_server["cancelled"].is_set() + assert len([1 for name, _ in language_server["calls"] if name == "SendUserCascadeMessage"]) == 1 + + +def test_native_terminal_error_preserves_structured_cause(tmp_path, language_server, monkeypatch): + from loopx.chat_agent import CodexChatAgentError + frame = _native_frames()[-1] + detail = {"shortError": "stream reading error: unexpected EOF", "userErrorMessage": "Network interrupted", "errorCode": 2} + frame["steps"][-1] = {"type": "CORTEX_STEP_TYPE_ERROR_MESSAGE", "status": "CORTEX_STEP_STATUS_DONE", + "metadata": {"sourceTrajectoryStepInfo": {"stepIndex": 1}}, "errorMessage": {"error": detail}} + _native_clock(monkeypatch, language_server, [frame]) + with pytest.raises(CodexChatAgentError) as raised: + adapter_for(tmp_path).start_turn("original task") + assert raised.value.error_code == "antigravity_step_error" + assert str(raised.value) == "Network interrupted" + assert raised.value.native_error_info == detail From 5ac6686fb4e14d333f1b94cf9d2301983c28a610 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:14:23 +0530 Subject: [PATCH 12/34] docs(antigravity): qualify native heartbeat and recovery boundaries Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/manager-runtime-profile-v0.md | 5 ++ .../rfcs/manager-runtime-profile-v0.zh-CN.md | 5 ++ .../references/heartbeat-hosts.md | 46 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/docs/architecture/rfcs/manager-runtime-profile-v0.md b/docs/architecture/rfcs/manager-runtime-profile-v0.md index 0fc01bc157..955417258d 100644 --- a/docs/architecture/rfcs/manager-runtime-profile-v0.md +++ b/docs/architecture/rfcs/manager-runtime-profile-v0.md @@ -56,6 +56,11 @@ These defaults do not change ordinary Goal Chat models or grant other Agents too Argon tiers must be offered by the local account and match the requested reasoning effort. A missing original cascade fails resume without silently starting a replacement. Stop waits for the native cascade to become idle; timeout or partial output is not successful completion. + Read-only polling transport loss does not resend the input or reset its idle/hard deadline. + Native retry/error steps remain owned by the running cascade; only a settled terminal error + fails the adapter, with its structured native cause retained. Host heartbeat and bounded + Stop-hook recovery use the separate [Antigravity host procedure](../../../skills/loopx-project/references/heartbeat-hosts.md#antigravity-app). + These source boundaries do not establish upstream network repair or sustained long-run reliability. - `trusted_owner` applies to the local private owner-manager and a native private steward binding freshly verified by the existing Core conversation owner. That owner checks the App, owner principal, private source, current resource grant and frozen Session identity. diff --git a/docs/architecture/rfcs/manager-runtime-profile-v0.zh-CN.md b/docs/architecture/rfcs/manager-runtime-profile-v0.zh-CN.md index cacb419a3b..fc17d6ec82 100644 --- a/docs/architecture/rfcs/manager-runtime-profile-v0.zh-CN.md +++ b/docs/architecture/rfcs/manager-runtime-profile-v0.zh-CN.md @@ -46,6 +46,11 @@ LoopX 管家最初只有受限的规划会话:它可以读取 LoopX 提供的 这些契约。Argon 档位必须由本地账号提供,并与请求的思考深度一致。原始 cascade 丢失 时续接失败,不静默创建替代会话。停止等待原生 cascade 回到 idle;超时或部分输出 不代表成功完成。 + 只读轮询的传输中断不会重发输入,也不会重置 idle 或 hard deadline。运行中的原生 + cascade 继续拥有重试与错误步骤;接入层仅在原生终态错误后失败,并保留结构化原始 + 原因。宿主心跳与有界 Stop-hook 恢复遵循独立的 + [Antigravity 宿主流程](../../../skills/loopx-project/references/heartbeat-hosts.md#antigravity-app)。 + 这些源码边界不证明上游网络已修复,也不证明持续长程可靠性。 - `trusted_owner` 适用于本地私有 Owner 管家和由既有 Core conversation owner 重新核验的 原生本人管家私聊。Core 核验 App、Owner 身份、私聊来源、当前资源授权和冻结的 Session 身份;受众证明本身不授予工具,仍按实际机器策略执行,包括显式受限选择。其他外部 diff --git a/skills/loopx-project/references/heartbeat-hosts.md b/skills/loopx-project/references/heartbeat-hosts.md index 86e30ccca3..ba292a4e99 100644 --- a/skills/loopx-project/references/heartbeat-hosts.md +++ b/skills/loopx-project/references/heartbeat-hosts.md @@ -4,6 +4,52 @@ Read before creating, repairing or changing a recurring host loop. Save only the ## Set Up Recurring Heartbeats +### Antigravity App + +Select `antigravity-app` explicitly; `agy` remains the legacy CLI. Install its +skill facade with `slash-commands --install --surface antigravity-app` and +workflow skills with `workflow-skills --install --host-surface antigravity-app`. +Use the activation packet for the already registered Goal/Agent. Bind its native +conversation id from the current host's `ANTIGRAVITY_CONVERSATION_ID`, never +another chat's title or the only registered lane. + +Write only `sidecar_template.sidecar_json` to its `sidecar_config_path` under +`~/.gemini/config/sidecars//sidecar.json`. Native `builtin: schedule` +requires an `args` array beginning with the cron expression. `config.cron`, a +top-level `sidecar` wrapper and LoopX's template metadata are not native config. +Preserve an existing sidecar's id, enable choice and scoped permissions. New +sidecars retain the host's user enable step; configuration does not prove a live +loop. Inspect native startup/tick results after any change. + +The native schedule starts a fresh conversation with the stable bootstrap. +That conversation must load fresh Core admission before work, preserving its +registered Goal/Agent and current settlement identity. A fresh native context +is not a new Goal or authority to replay the failed task. A successful schedule +or conversation-start receipt does not establish completed work; inspect the +resulting admission and settlement. Native timer dispatch still starts a model +conversation when Core later selects a quiet no-op; no lower model cost is claimed. + +`hooks_template` is a native top-level named `loopx` group containing `Stop` +handlers, not `hooks.loopx` or a `before_continuation` event. Global +`--with-agy-hooks` installation is disabled until a concrete Goal, Agent and +native conversation are bound. Bound recovery handles only the native `ERROR` +event with a recognized Google model transport failure and fresh admission; +it reserves at most two additional continuations per original input, at least +60 seconds apart. Normal completion, user cancellation, permission/auth/quota +failures and unrelated conversations remain untouched. The hook uses its +corresponding Python installation and resolved LoopX executable, ignoring an +unrelated inherited `PYTHONPATH`. This provider recovery +is not a generic pre-invocation enforcement gate and does not repair an upstream +network connection. Uninstall removes only an unchanged, recorded managed entry. + +Follow fresh `scheduler_hint.app_automation` for cadence and terminal actions. +Update the existing native schedule or the same sidecar's `args[0]`, inspect host +readback, then execute the current `ack_hint.cli_args`; on failure execute the +current failure hint without ACK. A terminal stop requires no scheduler ACK. +Never copy a Codex automation's identity or claim cadence applied from the +template alone. In-session one-shot timers use `DurationSeconds`; recurring +timers use `CronExpression`, without combining both forms. + When the exact host is Codex App connected to a remote workspace over SSH and `automation_update` is unavailable because of that host boundary, use the visible Goal integration instead of reporting a missing-automation blocker: From b2211a45f1e8de5a81010da99f98721c12ac1ce0 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:19:31 +0530 Subject: [PATCH 13/34] feat(antigravity): add first-class antigravity-app host surface, config skills, schedule loop, and sidecar Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/agent_onboarding.py | 12 + loopx/agy_goal_mode/__init__.py | 355 +++++++++++++++--- loopx/bootstrap_command_pack.py | 2 + loopx/cli_commands/slash_commands.py | 21 ++ .../cli_commands/support_control_heartbeat.py | 1 + loopx/cli_commands/workflow_skills.py | 15 +- loopx/control_plane/goals/start_contract.py | 1 + loopx/control_plane/heartbeat/host.py | 1 + .../scheduler/execution_context.py | 44 ++- .../turn_driver/codex_context.py | 5 +- loopx/doctor.py | 81 ++++ loopx/host_loop_activation.py | 21 +- loopx/hosts/skill_facade.py | 29 +- loopx/slash_command_install.py | 186 ++++++++- loopx/slash_commands.py | 1 + loopx/workflow_skill_install.py | 19 +- tests/test_agy_host_surface.py | 167 ++++++++ 17 files changed, 883 insertions(+), 78 deletions(-) diff --git a/loopx/agent_onboarding.py b/loopx/agent_onboarding.py index 19eafcee5e..7bacb923ab 100644 --- a/loopx/agent_onboarding.py +++ b/loopx/agent_onboarding.py @@ -64,6 +64,8 @@ def _surface_install_command(agent_type: str, cli_bin: str, project: str) -> str return f"{host_command(cli_bin)} slash-commands --install --surface zcode" if agent_type == "agy": return f"{host_command(cli_bin)} slash-commands --install --surface agy" + if agent_type == "antigravity-app": + return f"{host_command(cli_bin)} slash-commands --install --surface antigravity-app" if agent_type == "kiro-cli": return f"{host_command(cli_bin)} slash-commands --install --surface kiro-cli" if agent_type == "pi": @@ -297,6 +299,7 @@ def _bootstrap_pack_command( "cursor-agent": "cursor-agent", "zcode": "zcode", "agy": "agy", + "antigravity-app": "antigravity-app", "kiro-cli": "kiro-cli", "deepseek-harness": "deepseek-harness", "deepseek-harness-native": "deepseek-harness-native", @@ -373,6 +376,15 @@ def _start_instruction(agent_type: str) -> str: "wake Prompt; recurring via MaxIterations) when quota allows more " "work." ) + if agent_type == "antigravity-app": + return ( + "Invoke the LoopX skill from `~/.gemini/config/skills` via `/loopx `; " + "after todo writeback, start every turn with `quota should-run`, suspend " + "directly via the native `schedule(DurationSeconds=..., Prompt=...)` tool " + "when `waiting_external` or CI checks are pending (instead of polling), " + "and optionally configure `~/.gemini/config/sidecar.json` for background " + "periodic heartbeats." + ) if agent_type == "kiro-cli": return ( f"Run `/loopx ` (the LoopX skill installed in " diff --git a/loopx/agy_goal_mode/__init__.py b/loopx/agy_goal_mode/__init__.py index 0d7301f3cb..e3037f27b2 100644 --- a/loopx/agy_goal_mode/__init__.py +++ b/loopx/agy_goal_mode/__init__.py @@ -1,26 +1,46 @@ +"""Antigravity CLI (``agy``) & Antigravity App (``antigravity-app``) host-surface constants and helpers. + +Supports both: +- Legacy Antigravity CLI (``agy`` 1.x) flat skills in ``~/.gemini/antigravity-cli/skills/.md`` with native ``/goal`` and ``schedule`` tools. +- Antigravity 2.0 / Antigravity App (``antigravity-app``) directory skills in ``~/.gemini/config/skills//SKILL.md``, in-session ``schedule(DurationSeconds=..., Prompt=...)`` closed-loop timer suspension (for ``waiting_external`` / CI waits), background ``sidecar.json`` periodic heartbeat template, and optional ``mcp_config.json`` + ``hooks.json`` continuation gate registration. +""" + from __future__ import annotations +import os +import shlex from pathlib import Path from typing import Any AGY_INSTALL_SURFACE = "agy" -DEFAULT_AGY_HOME = ".gemini/antigravity-cli" -SKILLS_SUBDIR = "skills" -SKILLS_ROOT_LABEL = "~/.gemini/antigravity-cli/skills" -AGY_ACCEPTED_INPUTS = ( +ANTIGRAVITY_APP_INSTALL_SURFACE = "antigravity-app" +ANTIGRAVITY_APP_AGENT_TYPE = "antigravity-app" + +# Every token normalize_agent_type and the slash-commands installer accept for +# the Antigravity CLI surface. +AGY_ACCEPTED_INPUTS: tuple[str, ...] = ( "agy", "antigravity", "antigravity-cli", "antigravity_cli", "antigravity cli", + "google-antigravity", "google antigravity", ) -# Native in-session automation primitives agy ships (verified against agy -# 1.1.18 live; no external driver involved). The `schedule` tool takes -# DurationSeconds + a wake Prompt, supports recurring wakes via MaxIterations -# and one-shot early-termination conditions. -AGY_NATIVE_WAKE_TOOLS = ( +ANTIGRAVITY_APP_ACCEPTED_INPUTS: tuple[str, ...] = ( + "antigravity-app", + "antigravity_app", + "antigravity app", + "agy-app", + "agy_app", + "agy app", + "antigravity-2", + "antigravity 2.0", +) + +# Native in-session automation primitives agy and Antigravity App ship. +AGY_NATIVE_WAKE_TOOLS: tuple[str, ...] = ( "schedule", "manage_task", "invoke_subagent", @@ -28,38 +48,147 @@ "manage_inbox", ) -AGY_NATIVE_WAKE_FACTS = ( - "native `schedule` tool: DurationSeconds + Prompt wake message, recurring " - "wakes via MaxIterations, one-shot early-termination conditions", - "background tasks (`manage_task`) and async subagents " - "(`invoke_subagent`/`send_message`) wake a live session without an " - "external driver", - "`hooks.json` (user or plugin) runs PostToolUse/Stop/PostInvocation " - "automation on tool events", +AGY_NATIVE_WAKE_FACTS: tuple[str, ...] = ( + "The built-in `schedule` tool arms a one-shot timer (`DurationSeconds` + " + "wake `Prompt`; optional `TimerCondition`: `never`, `any`, or a sender ID) " + "or a recurring cron (`CronExpression` + `Prompt`; optional `MaxIterations`, " + "`IsDaemon`, `ExactTiming`) that delivers its `Prompt` as a high-priority " + "wakeup notification when it fires; `manage_task` lists or kills active " + "schedules.", + "When a LoopX turn enters `waiting_external` (for example CI checks or PR " + "review waits), call `schedule(DurationSeconds=, " + "Prompt=...)` and end the turn immediately so the host wakes the session " + "reactively instead of running shell sleep polling loops.", + "Background `run_command` tasks and `invoke_subagent` runs also resume the " + "session reactively on completion or message arrival, so the agent does " + "not need a polling loop inside a turn.", + "All native wakes live inside the running Antigravity process; for " + "background periodic heartbeats in Antigravity 2.0 (`antigravity-app`), " + "configure `sidecar.json` and optional `hooks.json` continuation gates in " + "`~/.gemini/config`.", ) # Native goal primitive (built into the agy binary; live-verified on 1.1.18 in -# both the interactive TUI and headless `-p` print mode). The host injects a -# forced-continuation contract that audits work until the model emits the -# completion token; `GoalState` persists across the session. +# both the TUI and headless `agy -p`). AGY_GOAL_COMMAND = "/goal" -AGY_GOAL_COMMAND_DESCRIPTION = "Run until the specified goal is completely finished." +AGY_GOAL_COMMAND_DESCRIPTION = ( + "Pursue a goal relentlessly until it is completely finished" +) AGY_GOAL_COMPLETE_TOKEN = "" AGY_GOAL_CANCELLED_TOKEN = "" +DEFAULT_AGY_HOME = ".gemini/antigravity-cli" +DEFAULT_ANTIGRAVITY_CONFIG_HOME = ".gemini/config" +ANTIGRAVITY_SKILLS_ROOT_LABEL = "~/.gemini/config/skills" +ANTIGRAVITY_LEGACY_SKILLS_ROOT_LABEL = "~/.gemini/antigravity-cli/skills" +ANTIGRAVITY_MCP_CONFIG_FILENAME = "mcp_config.json" +ANTIGRAVITY_HOOKS_CONFIG_FILENAME = "hooks.json" +ANTIGRAVITY_SIDECAR_FILENAME = "sidecar.json" +ANTIGRAVITY_MCP_CONFIG_LABEL = "~/.gemini/config/mcp_config.json" +ANTIGRAVITY_HOOKS_CONFIG_LABEL = "~/.gemini/config/hooks.json" +ANTIGRAVITY_SIDECAR_CONFIG_LABEL = "~/.gemini/config/sidecar.json" + +ANTIGRAVITY_APP_AGENT_TYPE_CATALOG_ENTRY: dict[str, Any] = { + "display_name": "Antigravity App", + "host_loop": ( + "Antigravity App native schedule timer closed loop + optional background " + "sidecar.json heartbeat and hooks.json continuation gate" + ), + "entry": ( + f"the LoopX skill installed in {ANTIGRAVITY_SKILLS_ROOT_LABEL} " + f"(compat: {ANTIGRAVITY_LEGACY_SKILLS_ROOT_LABEL})" + ), + "accepted_inputs": list(ANTIGRAVITY_APP_ACCEPTED_INPUTS), +} + + +def build_antigravity_sidecar_template( + *, + goal_id: str = "", + agent_id: str | None = None, + cli_bin: str = "loopx", + interval_seconds: int = 300, +) -> dict[str, Any]: + """Build a public-safe Antigravity 2.0 ``sidecar.json`` periodic heartbeat template.""" + quota_args = [ + cli_bin, + "--format", + "json", + "quota", + "should-run", + "--goal-id", + goal_id, + "--runtime-profile", + "antigravity_app", + ] + if agent_id: + quota_args.extend(["--agent-id", agent_id]) + heartbeat_args = [ + cli_bin, + "--format", + "json", + "heartbeat-prompt", + "--goal-id", + goal_id, + "--thin", + "--runtime-profile", + "antigravity_app", + ] + if agent_id: + heartbeat_args.extend(["--agent-id", agent_id]) + return { + "schema_version": "loopx_antigravity_sidecar_template_v0", + "config_path": ANTIGRAVITY_SIDECAR_CONFIG_LABEL, + "sidecar": { + "loopx-heartbeat": { + "description": ( + f"LoopX periodic quota-gated heartbeat for goal {goal_id}" + ), + "interval_seconds": int(interval_seconds), + "gate_command": shlex.join(quota_args), + "wake_when_json_field_true": "should_run", + "prompt_command": shlex.join(heartbeat_args), + "prompt_json_field": "task_body", + "suspend_on_waiting_external": { + "tool": "schedule", + "duration_field": "scheduler_hint.poll_after_seconds", + "default_duration_seconds": int(interval_seconds), + }, + } + }, + } + + +def build_antigravity_hooks_template( + *, + cli_bin: str = "loopx", +) -> dict[str, Any]: + """Build a public-safe ``hooks.json`` continuation gate entry for Antigravity 2.0.""" + return { + "loopx": { + "schema_version": "loopx_antigravity_hooks_gate_v0", + "event": "before_continuation", + "command": cli_bin, + "args": [ + "--format", + "json", + "quota", + "should-run", + "--runtime-profile", + "antigravity_app", + ], + "require_json_field_true": "should_run", + "on_waiting_external": "suspend_via_schedule_timer", + } + } + def agy_activation_extras() -> dict[str, Any]: """Keyword overrides for ``agy_cli_activation``'s facade call. Keeps the agy host facts (goal command, tokens, wake tools, gate text, activation steps) in the agy package instead of growing - ``host_loop_activation.py`` past its module metric budget. agy ships BOTH - halves of a goal-mode host, verified live on 1.1.18: a native goal - primitive — ``/goal `` ("Run until the specified goal is completely - finished"), whose host-side forced continuation audits work until the - model emits ```` — and a native in-session - scheduler (``schedule`` tool plus background-task/subagent wakes). The - loop and wakes live and die with the session; no cross-session daemon. + ``host_loop_activation.py`` past its module metric budget. """ return { "activation_method": "bind_native_goal_with_advisory_quota_entry", @@ -71,48 +200,158 @@ def agy_activation_extras() -> dict[str, Any]: "goal_complete_token": AGY_GOAL_COMPLETE_TOKEN, "goal_cancelled_token": AGY_GOAL_CANCELLED_TOKEN, "native_wake_tools": list(AGY_NATIVE_WAKE_TOOLS), + "native_wake_facts": list(AGY_NATIVE_WAKE_FACTS), + "skills_roots": [ + ANTIGRAVITY_SKILLS_ROOT_LABEL, + ANTIGRAVITY_LEGACY_SKILLS_ROOT_LABEL, + ], "missing_host_tool_gate": ( "agy's /goal loop and native wakes fire only while the CLI " - "session is alive; there is no cross-session daemon. LoopX has " - "no host hook intercepting native continuations, so quota " - "pacing is advisory: the facade instructs, it cannot enforce. " - "If the session ends before the goal is done, show the exact " - "heartbeat-prompt command for the user to run and do not claim " - "unattended heartbeat support." + "session stays alive (there is no background daemon), and " + "agy exposes no hook to intercept `/goal`'s forced-" + "continuation audit, so `quota should-run` is advisory " + "guidance rather than a host-enforced gate. If the session " + "cannot keep entering through `quota should-run`, show the " + "exact heartbeat-prompt command for the user to run and do " + "not claim unattended heartbeat support." ), }, "extra_activation_steps": [ - "Bind the objective with the native goal command: " + "Bind the objective to agy's native goal loop by running " "`/goal ` — agy's forced continuation audits the work " - "until completion is emitted; `` ends the " - "goal loop and `` cancels it.", - "Start every turn, native wake, and audit-continuation with " - "`quota should-run` and honor a stop/throttle decision before any " - "delivery work — instructed pacing, not a host-enforced gate.", - "When a turn ends with work remaining and quota allows more, arm " - "the next bounded segment with the native `schedule` tool " - "(recurring wakes bounded by MaxIterations); the wake re-enters " - "through `quota should-run` on the same advisory basis.", + "at the end of each turn and keeps the agent going until every " + f"aspect is verified and `{AGY_GOAL_COMPLETE_TOKEN}` is emitted " + f"(or `{AGY_GOAL_CANCELLED_TOKEN}` to abort).", + "At the start of every turn, wake and `/goal` audit-continuation, " + "run `quota should-run` first; when it says stop, emit " + f"`{AGY_GOAL_CANCELLED_TOKEN}` (or `{AGY_GOAL_COMPLETE_TOKEN}` " + "when the LoopX goal is genuinely complete) so the native goal " + "loop terminates instead of free-running past the quota.", + "When `quota should-run` allows continuation and more work or a " + "deferred check remains (such as `waiting_external` or CI checks), " + "arm the next wakeup in-session with agy's built-in `schedule` tool " + "(`DurationSeconds` + wake `Prompt`, or `CronExpression` with " + "`MaxIterations` for bounded recurring checks) instead of blind " + "polling; cancel obsolete timers via `manage_task` before stopping.", + ], + "host_scheduler_note": ( + "use the built-in `schedule` tool (`DurationSeconds` or " + "`CronExpression` with `MaxIterations`) for in-session wakes, and " + f"emit `{AGY_GOAL_COMPLETE_TOKEN}` / `{AGY_GOAL_CANCELLED_TOKEN}` " + "to end the native `/goal` loop (neither survives after the agy " + "process exits)." + ), + } + + +def antigravity_app_activation_extras( + *, + goal_id: str = "", + agent_id: str | None = None, + cli_bin: str = "loopx", +) -> dict[str, Any]: + """Keyword overrides for ``antigravity_app_activation``'s facade call.""" + sidecar_template = build_antigravity_sidecar_template( + goal_id=goal_id, + agent_id=agent_id, + cli_bin=cli_bin, + ) + hooks_template = build_antigravity_hooks_template(cli_bin=cli_bin) + wake_prompt = ( + f"Run `{cli_bin} --format json quota should-run --goal-id {goal_id}" + + (f" --agent-id {agent_id}" if agent_id else "") + + " --runtime-profile antigravity_app` and continue the active LoopX Todo when allowed." + ) + return { + "activation_method": "bind_antigravity_schedule_loop_and_sidecar", + "extra_host_mutation": { + "host_loop_primitive": "antigravity-schedule-tool-and-sidecar", + "loop_driver": "antigravity_app_schedule_timer_and_sidecar_loop", + "quota_gate_enforcement": "hooks_or_advisory_quota_entry", + "native_goal_command": AGY_GOAL_COMMAND, + "goal_complete_token": AGY_GOAL_COMPLETE_TOKEN, + "goal_cancelled_token": AGY_GOAL_CANCELLED_TOKEN, + "native_wake_tools": list(AGY_NATIVE_WAKE_TOOLS), + "native_wake_facts": list(AGY_NATIVE_WAKE_FACTS), + "skills_roots": [ + ANTIGRAVITY_SKILLS_ROOT_LABEL, + ANTIGRAVITY_LEGACY_SKILLS_ROOT_LABEL, + ], + "host_mcp_server": "loopx", + "host_mcp_config": ANTIGRAVITY_MCP_CONFIG_LABEL, + "host_hooks_config": ANTIGRAVITY_HOOKS_CONFIG_LABEL, + "host_sidecar_config": ANTIGRAVITY_SIDECAR_CONFIG_LABEL, + "in_session_schedule_call": { + "tool": "schedule", + "arguments": { + "DurationSeconds": 180, + "Prompt": wake_prompt, + "TimerCondition": "any", + }, + "when_to_use": ( + "Call `schedule(DurationSeconds=..., Prompt=...)` and end the turn " + "whenever `waiting_external`, CI checks, or PR review gates block " + "immediate completion; never use shell sleep loops." + ), + }, + "sidecar_template": sidecar_template, + "hooks_template": hooks_template, + "missing_host_tool_gate": ( + "Antigravity App in-session `schedule(DurationSeconds=...)` timers fire " + "while the app session is active; for unattended background ticks, " + f"configure `sidecar.json` in `{ANTIGRAVITY_SIDECAR_CONFIG_LABEL}` " + f"and optional `hooks.json` in `{ANTIGRAVITY_HOOKS_CONFIG_LABEL}`." + ), + }, + "extra_activation_steps": [ + f"Install or refresh the Antigravity skill and optional MCP/hooks surface (`{cli_bin} slash-commands --install --surface antigravity-app --with-agy-mcp --with-agy-hooks` and `{cli_bin} workflow-skills --install --host-surface antigravity-app`).", + "When a turn enters `waiting_external` or waits on CI/review checks, suspend directly with the native `schedule(DurationSeconds=, Prompt=...)` tool and end the turn instead of polling in a shell loop.", + f"For background periodic heartbeats, apply the generated `sidecar_template` in `{ANTIGRAVITY_SIDECAR_CONFIG_LABEL}` so each tick gates on `quota should-run` before waking the agent.", ], "host_scheduler_note": ( - "the native `/goal` loop and `schedule` tool drive this session; " - "quota should-run entry is advisory guidance in the facade, not a " - "host-enforced gate." + "use the native `schedule(DurationSeconds=..., Prompt=...)` tool to suspend " + "and wake on `waiting_external` / CI waits, or configure `sidecar.json` for " + "background periodic heartbeats." ), } def agy_home(value: str | None = None) -> Path: - """The fixed Antigravity CLI home: ``~/.gemini/antigravity-cli``. - - agy discovers global skills from ``~/.gemini/antigravity-cli/skills`` - using the documented flat layout — one markdown file per skill - (``skills/.md`` with front matter). The official CLI docs do not - document any home override, so LoopX exposes none either: installs target - exactly this path (HOME-relative, which keeps tests hermetic), and the - root is shared with no other host (for example, Gemini CLI reads - ``~/.gemini/skills``). ``value`` is an internal injection point - for tests, not a public override. + """Resolve the legacy Antigravity CLI user home directory (``~/.gemini/antigravity-cli``). + + Honours an explicit ``--agy-home`` override or ``AGY_CLI_HOME`` environment + variable while defaulting to ``~/.gemini/antigravity-cli`` for flat + ``skills/.md`` compatibility. + """ + raw = value or os.environ.get("AGY_CLI_HOME") + if raw: + return Path(raw).expanduser() + return (Path.home() / DEFAULT_AGY_HOME).expanduser() + + +def antigravity_config_home(value: str | None = None) -> Path: + """Resolve the Antigravity 2.0 / Antigravity App config home (``~/.gemini/config``). + + Honours an explicit ``--agy-home`` override or ``AGY_HOME`` / ``ANTIGRAVITY_HOME`` + environment variables, defaulting to ``~/.gemini/config`` where + ``skills//SKILL.md``, ``mcp_config.json``, ``hooks.json``, and + ``sidecar.json`` live. """ - raw = value or str(Path.home() / DEFAULT_AGY_HOME) + raw = ( + value + or os.environ.get("AGY_HOME") + or os.environ.get("ANTIGRAVITY_HOME") + or str(Path.home() / DEFAULT_ANTIGRAVITY_CONFIG_HOME) + ) return Path(raw).expanduser() + + +def antigravity_skill_roots(value: str | None = None) -> tuple[Path, ...]: + """Return Antigravity skill roots in discovery order (2.0 config first, then legacy CLI).""" + if value: + base = Path(value).expanduser() + return (base / "skills",) + return ( + antigravity_config_home() / "skills", + agy_home() / "skills", + ) diff --git a/loopx/bootstrap_command_pack.py b/loopx/bootstrap_command_pack.py index 2b93c9a335..7aeb3742c7 100644 --- a/loopx/bootstrap_command_pack.py +++ b/loopx/bootstrap_command_pack.py @@ -84,6 +84,7 @@ "cursor-agent", "zcode", "agy", + "antigravity-app", "kiro-cli", "deepseek-harness", "deepseek-harness-native", @@ -373,6 +374,7 @@ def build_start_goal_host_surface_selection_packet( "cursor-agent": "cursor-agent driving its own loop through the LoopX skill facade and MCP server", "zcode": "ZCode loop via the LoopX skill facade", "agy": "agy session loop via the LoopX skill facade; native /goal + schedule wakes while the session lives", + "antigravity-app": "Antigravity App loop via ~/.gemini/config/skills; native schedule timer closed loop + sidecar.json heartbeat", "kiro-cli": "Kiro CLI loop via the LoopX skill facade; native /goal iteration budget", "deepseek-harness": "DeepSeek Harness automation loop through loopx.dsh_goal_mode (compat: scripts/dsh_turn_host_adapter.py)", "deepseek-harness-native": "DeepSeek Harness same-session LoopX skill and plugin driver", diff --git a/loopx/cli_commands/slash_commands.py b/loopx/cli_commands/slash_commands.py index 79a0f94b47..ccd6021d23 100644 --- a/loopx/cli_commands/slash_commands.py +++ b/loopx/cli_commands/slash_commands.py @@ -71,6 +71,7 @@ def register_slash_commands_command( "z-code", "agy", "antigravity", + "antigravity-app", "kiro", "kiro-cli", "pi", @@ -116,6 +117,23 @@ def register_slash_commands_command( dest="zcode_home", help=argparse.SUPPRESS, ) + parser.add_argument( + "--agy-home", + help=( + "Antigravity config home for skill, MCP, and hooks installation. " + "Defaults to AGY_HOME or ~/.gemini/config (and ~/.gemini/antigravity-cli for legacy CLI)." + ), + ) + parser.add_argument( + "--with-agy-mcp", + action="store_true", + help="Optionally register the LoopX MCP server in Antigravity mcp_config.json.", + ) + parser.add_argument( + "--with-agy-hooks", + action="store_true", + help="Optionally register the LoopX continuation gate in Antigravity hooks.json.", + ) parser.add_argument( "--opencode-home", help="OpenCode config directory. Defaults to OPENCODE_CONFIG_DIR or ~/.config/opencode.", @@ -159,6 +177,8 @@ def handle_slash_commands_command( execute=bool((args.install or args.uninstall) and not args.dry_run), uninstall=bool(args.uninstall), with_goal_bridge=bool(args.with_goal_bridge), + with_agy_mcp=bool(getattr(args, "with_agy_mcp", False)), + with_agy_hooks=bool(getattr(args, "with_agy_hooks", False)), surfaces=args.surface, cli_bin=args.cli_bin, include_legacy_aliases=not bool(args.no_legacy_aliases), @@ -168,6 +188,7 @@ def handle_slash_commands_command( gemini_home=args.gemini_home, cursor_home=args.cursor_home, zcode_home=getattr(args, "zcode_home", None), + agy_home=getattr(args, "agy_home", None), pi_project=args.pi_project, pi_scope=args.pi_scope, ) diff --git a/loopx/cli_commands/support_control_heartbeat.py b/loopx/cli_commands/support_control_heartbeat.py index ffb47f3065..d30e934d00 100644 --- a/loopx/cli_commands/support_control_heartbeat.py +++ b/loopx/cli_commands/support_control_heartbeat.py @@ -223,6 +223,7 @@ def handle_heartbeat_prompt_command( None, SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT.value, SchedulerRuntimeProfile.TRAE_APP.value, + SchedulerRuntimeProfile.ANTIGRAVITY_APP.value, SchedulerRuntimeProfile.GENERIC_CLI_AGENT_LOOP.value, # Kiro CLI moved off generic_cli onto its own profile; its facade # still reads the thin Agent input, so keep that output shape. diff --git a/loopx/cli_commands/workflow_skills.py b/loopx/cli_commands/workflow_skills.py index 08f317cfc0..0bc9c4cdaf 100644 --- a/loopx/cli_commands/workflow_skills.py +++ b/loopx/cli_commands/workflow_skills.py @@ -48,9 +48,21 @@ def register_workflow_skills_command( ) parser.add_argument( "--host-surface", - choices=["ark-managed-agent", "deepseek-harness-native"], + choices=[ + "ark-managed-agent", + "deepseek-harness-native", + "agy", + "antigravity-app", + ], help="Generate the managed $loopx entry for one exact host surface.", ) + parser.add_argument( + "--agy-home", + help=( + "Antigravity config home (defaults to AGY_HOME or ~/.gemini/config); " + "selects /skills when --skills-dir is omitted." + ), + ) parser.add_argument( "--dry-run", action="store_true", @@ -72,6 +84,7 @@ def handle_workflow_skills_command( uninstall=bool(args.uninstall), cli_bin=args.cli_bin, host_surface=args.host_surface, + agy_home=getattr(args, "agy_home", None), ) print_payload( payload, diff --git a/loopx/control_plane/goals/start_contract.py b/loopx/control_plane/goals/start_contract.py index 040fb3e1cb..0236c8a5af 100644 --- a/loopx/control_plane/goals/start_contract.py +++ b/loopx/control_plane/goals/start_contract.py @@ -120,6 +120,7 @@ def build_goal_start_contract( "cursor-agent": "agent-driven cursor-agent loop; every turn enters through quota should-run", "zcode": "agent-driven ZCode loop; turns enter through quota should-run", "agy": "agy native /goal loop with schedule wakes; quota entry advisory", + "antigravity-app": "Antigravity App native schedule timer closed loop + sidecar.json heartbeat", "kiro-cli": "Kiro CLI native /goal loop; quota entry advisory", "deepseek-harness-native": "DeepSeek Harness same-session plugin driver; every turn enters through quota should-run", "ark-managed-agent": "one-shot Goal", diff --git a/loopx/control_plane/heartbeat/host.py b/loopx/control_plane/heartbeat/host.py index 78796997bf..0900fe27e0 100644 --- a/loopx/control_plane/heartbeat/host.py +++ b/loopx/control_plane/heartbeat/host.py @@ -137,6 +137,7 @@ def resolve_exact_heartbeat_turn_identity( SchedulerRuntimeProfile.GENERIC_CLI_AGENT_LOOP, SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT, SchedulerRuntimeProfile.TRAE_APP, + SchedulerRuntimeProfile.ANTIGRAVITY_APP, }: raise ValueError( "--turn-instance-id requires runtime-profile generic_cli or " diff --git a/loopx/control_plane/scheduler/execution_context.py b/loopx/control_plane/scheduler/execution_context.py index 54b0b30420..0c9da7822f 100644 --- a/loopx/control_plane/scheduler/execution_context.py +++ b/loopx/control_plane/scheduler/execution_context.py @@ -17,6 +17,7 @@ class HostSurface(str, Enum): CODEX_CLI = "codex_cli" CODEX_APP_SERVER = "codex_app_server" TRAE_APP = "trae_app" + ANTIGRAVITY_APP = "antigravity_app" GENERIC_CLI = "generic_cli" CLAUDE_CODE = "claude_code" KUNLUNCODE = "kunluncode" @@ -44,6 +45,7 @@ class SchedulerRuntimeProfile(str, Enum): CODEX_APP_SSH_VISIBLE = "codex_app_ssh_goal" CODEX_CLI_VISIBLE = "codex_cli" TRAE_APP = "trae_app" + ANTIGRAVITY_APP = "antigravity_app" CLAUDE_CODE_VISIBLE = "claude_code" KUNLUNCODE_VISIBLE = "kunluncode" KIRO_CLI_VISIBLE = "kiro_cli" @@ -84,6 +86,7 @@ class SchedulerRuntimeProfile(str, Enum): { SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT, SchedulerRuntimeProfile.TRAE_APP, + SchedulerRuntimeProfile.ANTIGRAVITY_APP, SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE, } ) @@ -97,6 +100,7 @@ class SchedulerRuntimeProfile(str, Enum): { SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT, SchedulerRuntimeProfile.TRAE_APP, + SchedulerRuntimeProfile.ANTIGRAVITY_APP, } ) @@ -127,6 +131,11 @@ class SchedulerRuntimeProfile(str, Enum): SchedulerOwner.HOST_AUTOMATION, ExecutionMode.HOSTED_AUTOMATION, ), + SchedulerRuntimeProfile.ANTIGRAVITY_APP: ( + HostSurface.ANTIGRAVITY_APP, + SchedulerOwner.HOST_AUTOMATION, + ExecutionMode.HOSTED_AUTOMATION, + ), SchedulerRuntimeProfile.CLAUDE_CODE_VISIBLE: ( HostSurface.CLAUDE_CODE, SchedulerOwner.AGENT_CLI_LOOP, @@ -185,7 +194,12 @@ def codex_app_applicable(self) -> bool: @property def app_automation_applicable(self) -> bool: return ( - self.host_surface in {HostSurface.CODEX_APP, HostSurface.TRAE_APP} + self.host_surface + in { + HostSurface.CODEX_APP, + HostSurface.TRAE_APP, + HostSurface.ANTIGRAVITY_APP, + } and self.scheduler_owner is SchedulerOwner.HOST_AUTOMATION and self.execution_mode is ExecutionMode.HOSTED_AUTOMATION ) @@ -200,11 +214,13 @@ def projection(self) -> dict[str, Any]: "valid": True, } # Keep existing non-Trae projections byte-compatible. The neutral App - # field is needed to identify Trae's independent automation contract, - # but a second negative applicability flag adds no information for CLI - # and Goal runtimes. - if self.host_surface is HostSurface.TRAE_APP: - projection["app_automation_applicability"] = "applicable" + # field is needed to identify Trae/Antigravity's independent automation + # contract, but a second negative applicability flag adds no information + # for CLI and Goal runtimes. + if self.host_surface in {HostSurface.TRAE_APP, HostSurface.ANTIGRAVITY_APP}: + projection["app_automation_applicability"] = ( + "applicable" if self.app_automation_applicable else "not_applicable" + ) else: projection["codex_app_applicability"] = ( "applicable" if self.codex_app_applicable else "not_applicable" @@ -235,7 +251,10 @@ def projection(self) -> dict[str, Any]: "valid": False, "errors": list(self.errors), } - if supplied.get("host_surface") == HostSurface.TRAE_APP.value: + if supplied.get("host_surface") in { + HostSurface.TRAE_APP.value, + HostSurface.ANTIGRAVITY_APP.value, + }: projection["app_automation_applicability"] = "blocked_invalid_context" else: projection["codex_app_applicability"] = "blocked_invalid_context" @@ -257,8 +276,17 @@ def _validation_errors(context: SchedulerExecutionContext) -> list[str]: HostSurface.KUNLUNCODE, HostSurface.KIRO_CLI, } - if context.host_surface in {HostSurface.CODEX_APP, HostSurface.TRAE_APP}: + if context.host_surface in { + HostSurface.CODEX_APP, + HostSurface.TRAE_APP, + HostSurface.ANTIGRAVITY_APP, + }: host_name = context.host_surface.value + if context.host_surface is HostSurface.ANTIGRAVITY_APP and ( + context.scheduler_owner is SchedulerOwner.AGENT_CLI_LOOP + and context.execution_mode is ExecutionMode.INTERACTIVE + ): + return errors if context.scheduler_owner is not SchedulerOwner.HOST_AUTOMATION: errors.append(f"{host_name} requires scheduler_owner=host_automation") if context.execution_mode is not ExecutionMode.HOSTED_AUTOMATION: diff --git a/loopx/control_plane/turn_driver/codex_context.py b/loopx/control_plane/turn_driver/codex_context.py index a1bbbf292c..7da0b9791c 100644 --- a/loopx/control_plane/turn_driver/codex_context.py +++ b/loopx/control_plane/turn_driver/codex_context.py @@ -80,8 +80,9 @@ def context_delivery_activation(setting: str, *, runtime_profile: str | None = N # rejects them. Unknown/conflicting declarations never bypass this # adapter, and generic_cli still needs the benchmark declaration. if codex_machine_preference and context.host_surface in { - HostSurface.ARK_MANAGED_AGENT, HostSurface.TRAE_APP, HostSurface.CLAUDE_CODE, - HostSurface.KUNLUNCODE, HostSurface.KIRO_CLI, HostSurface.LOCAL_SCHEDULER, + HostSurface.ARK_MANAGED_AGENT, HostSurface.TRAE_APP, HostSurface.ANTIGRAVITY_APP, + HostSurface.CLAUDE_CODE, HostSurface.KUNLUNCODE, HostSurface.KIRO_CLI, + HostSurface.LOCAL_SCHEDULER, }: return context_delivery_activation("off") if (context.host_surface is HostSurface.CODEX_APP diff --git a/loopx/doctor.py b/loopx/doctor.py index fbbec1fd20..18f945932d 100644 --- a/loopx/doctor.py +++ b/loopx/doctor.py @@ -772,6 +772,23 @@ def collect_doctor( "loopx slash-commands --install --surface zcode " f"--zcode-home {home_arg}" ) + elif canonical_agent_type in {"agy", "antigravity-app"}: + from .agy_goal_mode import antigravity_config_home + from .slash_command_install import inspect_skill_facades + + agy_cfg_skills = antigravity_config_home() / "skills" + if (agy_cfg_skills / "loopx-project" / "SKILL.md").exists(): + skill_roots = (agy_cfg_skills,) + skills = installed_skill_summary(skill_roots) + project_skill = skills["loopx-project"] + elif (agy_cfg_skills / "loopx" / "SKILL.md").exists(): + skill_roots = (agy_cfg_skills,) + skills = inspect_skill_facades(agy_cfg_skills) + project_skill = skills["loopx"] + else: + skill_roots = (agy_cfg_skills, *codex_skill_roots()) + skills = installed_skill_summary(skill_roots) + project_skill = skills["loopx-project"] else: skill_roots = codex_skill_roots() skills = installed_skill_summary(skill_roots) @@ -1242,6 +1259,70 @@ def collect_doctor( payload["fix"] = skill_fix else: payload["fix"] += "\nAfter restoring the LoopX installation/runtime, " + skill_fix + if canonical_agent_type in {"agy", "antigravity-app"}: + from .agy_goal_mode import ( + ANTIGRAVITY_HOOKS_CONFIG_FILENAME, + ANTIGRAVITY_MCP_CONFIG_FILENAME, + ANTIGRAVITY_SIDECAR_FILENAME, + agy_home, + antigravity_config_home, + ) + from .slash_command_install import inspect_skill_facades + + agy_cfg_home = antigravity_config_home() + legacy_agy_home = agy_home() + agy_workflow_readback = inspect_skill_install_readback( + skills_dir=agy_cfg_home / "skills", + required_skill_ids=ARK_MANAGED_AGENT_REQUIRED_SKILL_IDS, + source_root=repo_root, + ) + agy_facades = inspect_skill_facades(agy_cfg_home / "skills") + agy_facades_ready = all( + item.get("required_phrases") for item in agy_facades.values() + ) + legacy_loopx_exists = (legacy_agy_home / "skills" / "loopx.md").is_file() + agy_ready = bool( + agy_workflow_readback.get("ready") + or agy_facades_ready + or legacy_loopx_exists + ) + payload["antigravity"] = { + "config_home": str(agy_cfg_home), + "legacy_cli_home": str(legacy_agy_home), + "skills_dir": str(agy_cfg_home / "skills"), + "legacy_skills_dir": str(legacy_agy_home / "skills"), + "workflow_skills_readback": agy_workflow_readback, + "command_facades": agy_facades, + "command_facades_ready": agy_facades_ready, + "legacy_cli_loopx_skill_exists": legacy_loopx_exists, + "mcp_config_path": str(agy_cfg_home / ANTIGRAVITY_MCP_CONFIG_FILENAME), + "mcp_config_exists": ( + agy_cfg_home / ANTIGRAVITY_MCP_CONFIG_FILENAME + ).is_file(), + "hooks_config_path": str(agy_cfg_home / ANTIGRAVITY_HOOKS_CONFIG_FILENAME), + "hooks_config_exists": ( + agy_cfg_home / ANTIGRAVITY_HOOKS_CONFIG_FILENAME + ).is_file(), + "sidecar_config_path": str(agy_cfg_home / ANTIGRAVITY_SIDECAR_FILENAME), + "sidecar_config_exists": ( + agy_cfg_home / ANTIGRAVITY_SIDECAR_FILENAME + ).is_file(), + "repair_commands": [ + "loopx workflow-skills --install --host-surface antigravity-app", + "loopx slash-commands --install --surface antigravity-app --with-agy-mcp --with-agy-hooks", + ], + } + checks.append( + { + "id": "antigravity_host_surface_ready", + "required": False, + "ok": agy_ready, + "applicable": True, + "detail": ( + f"{agy_cfg_home / 'skills'} (ready={agy_ready})" + ), + } + ) if deep_validation: payload["release_candidate"] = deep_validation return payload diff --git a/loopx/host_loop_activation.py b/loopx/host_loop_activation.py index 5e71612b99..3ecd9255f8 100644 --- a/loopx/host_loop_activation.py +++ b/loopx/host_loop_activation.py @@ -5,10 +5,14 @@ from .zcode_goal_mode import native_goal_activation from .agent_registry import normalize_registered_agents -from .agy_goal_mode import AGY_ACCEPTED_INPUTS +from .agy_goal_mode import ( + AGY_ACCEPTED_INPUTS, + ANTIGRAVITY_APP_AGENT_TYPE_CATALOG_ENTRY, +) from .control_plane.scheduler.execution_context import SchedulerRuntimeProfile from .hosts.skill_facade import ( agy_cli_activation, + antigravity_app_activation, cursor_agent_activation, gemini_cli_activation, kiro_cli_activation, @@ -61,6 +65,7 @@ def scheduler_command_binding_for_agent_type( "cursor-agent": SchedulerRuntimeProfile.GENERIC_CLI_AGENT_LOOP, "zcode": SchedulerRuntimeProfile.GENERIC_CLI_AGENT_LOOP, "agy": SchedulerRuntimeProfile.GENERIC_CLI_AGENT_LOOP, + "antigravity-app": SchedulerRuntimeProfile.ANTIGRAVITY_APP, "kiro-cli": SchedulerRuntimeProfile.KIRO_CLI_VISIBLE, "deepseek-harness": SchedulerRuntimeProfile.GENERIC_CLI_AGENT_LOOP, "deepseek-harness-native": SchedulerRuntimeProfile.GENERIC_CLI_AGENT_LOOP, @@ -91,6 +96,7 @@ def agent_type_uses_host_managed_skills(agent_type: str) -> bool: "cursor-agent", "zcode", "agy", + "antigravity-app", "kiro-cli", "deepseek-harness", "deepseek-harness-native", @@ -282,6 +288,7 @@ def agent_type_uses_host_managed_skills(agent_type: str) -> bool: "entry": "the LoopX skill installed in ~/.gemini/antigravity-cli/skills", "accepted_inputs": list(AGY_ACCEPTED_INPUTS), }, + "antigravity-app": ANTIGRAVITY_APP_AGENT_TYPE_CATALOG_ENTRY, "kiro-cli": KIRO_CLI_AGENT_TYPE_CATALOG_ENTRY, "deepseek-harness": { "display_name": "DeepSeek Harness", @@ -389,6 +396,10 @@ def to_payload(self) -> dict[str, Any]: "agy": "agy", "antigravity": "agy", "antigravity-cli": "agy", + "antigravity-app": "antigravity-app", + "antigravity_app": "antigravity-app", + "agy-app": "antigravity-app", + "agy_app": "antigravity-app", "kiro-cli": "kiro-cli", "kiro": "kiro-cli", "deepseek-harness": "deepseek-harness", @@ -532,6 +543,7 @@ def _heartbeat_commands( "cursor-agent": "Cursor Agent CLI loop gated by LoopX", "zcode": "ZCode agent loop gated by LoopX", "agy": "Antigravity CLI agent loop with advisory LoopX quota pacing", + "antigravity-app": "Antigravity App schedule timer & sidecar heartbeat loop", "kiro-cli": "Kiro CLI native /goal loop with advisory LoopX quota pacing", "deepseek-harness": "DeepSeek Harness automation loop gated by LoopX", "deepseek-harness-native": "DeepSeek Harness same-session plugin loop gated by LoopX", @@ -1306,6 +1318,13 @@ def build_host_loop_activation_packet( ) elif canonical == "agy": surface = agy_cli_activation(commands, cli_bin) + elif canonical == "antigravity-app": + surface = antigravity_app_activation( + commands, + cli_bin, + goal_id=goal_id, + agent_id=str(selected_agent_id) if selected_agent_id else None, + ) elif canonical == "kiro-cli": surface = kiro_cli_activation(commands, cli_bin) elif canonical == "deepseek-harness": diff --git a/loopx/hosts/skill_facade.py b/loopx/hosts/skill_facade.py index 7033ecd262..58172bb047 100644 --- a/loopx/hosts/skill_facade.py +++ b/loopx/hosts/skill_facade.py @@ -16,7 +16,12 @@ from typing import Any -from ..agy_goal_mode import agy_activation_extras +from ..agy_goal_mode import ( + ANTIGRAVITY_APP_INSTALL_SURFACE, + ANTIGRAVITY_SKILLS_ROOT_LABEL, + agy_activation_extras, + antigravity_app_activation_extras, +) from ..kiro_cli_goal_mode import ( KIRO_CLI_INSTALL_SURFACE, SKILLS_ROOT_LABEL as KIRO_CLI_SKILLS_ROOT_LABEL, @@ -159,6 +164,28 @@ def agy_cli_activation(commands: dict[str, str], cli_bin: str) -> dict[str, Any] ) +def antigravity_app_activation( + commands: dict[str, str], + cli_bin: str, + *, + goal_id: str = "", + agent_id: str | None = None, +) -> dict[str, Any]: + return skill_facade_cli_activation( + commands, + cli_bin, + host_label="Antigravity App", + host_surface="antigravity_app_loop", + install_surface=ANTIGRAVITY_APP_INSTALL_SURFACE, + skills_root=ANTIGRAVITY_SKILLS_ROOT_LABEL, + **antigravity_app_activation_extras( + goal_id=goal_id, + agent_id=agent_id, + cli_bin=cli_bin, + ), + ) + + def kiro_cli_activation(commands: dict[str, str], cli_bin: str) -> dict[str, Any]: return skill_facade_cli_activation( commands, diff --git a/loopx/slash_command_install.py b/loopx/slash_command_install.py index 5688093f64..9527ea42a8 100644 --- a/loopx/slash_command_install.py +++ b/loopx/slash_command_install.py @@ -11,7 +11,13 @@ from pathlib import Path from typing import Any -from .agy_goal_mode import agy_home as _agy_home +from .agy_goal_mode import ( + ANTIGRAVITY_HOOKS_CONFIG_FILENAME, + ANTIGRAVITY_MCP_CONFIG_FILENAME, + agy_home as _agy_home, + antigravity_config_home as _antigravity_config_home, + build_antigravity_hooks_template, +) from .kiro_cli_goal_mode import ( KIRO_CLI_MCP_CONFIG_SUBPATH as _KIRO_MCP_CONFIG_SUBPATH, SKILLS_ROOT_LABEL as _KIRO_SKILLS_ROOT_LABEL, @@ -422,6 +428,8 @@ def materialize_loopx_entry_skill( None, "ark-managed-agent", "deepseek-harness-native", + "agy", + "antigravity-app", }: raise ValueError(f"unsupported fixed LoopX entry host surface: {host_surface}") spec = next( @@ -701,6 +709,8 @@ def _normalize_surfaces(surfaces: list[str] | None) -> list[str]: candidates = ["zcode"] elif surface in {"agy", "antigravity", "antigravity-cli"}: candidates = ["agy"] + elif surface in {"antigravity-app", "antigravity_app", "agy-app", "agy_app"}: + candidates = ["antigravity-app"] elif surface in {"kiro", "kiro-cli", "kirocli"}: candidates = ["kiro-cli"] else: @@ -718,6 +728,9 @@ def _normalize_surfaces(surfaces: list[str] | None) -> list[str]: CURSOR_MCP_MARKER_NAME = ".loopx-managed-mcp.json" CURSOR_MCP_MARKER_SCHEMA_VERSION = "loopx_managed_cursor_mcp_v0" KIRO_CLI_MCP_MARKER_SCHEMA_VERSION = "loopx_managed_kiro_cli_mcp_v0" +ANTIGRAVITY_MCP_MARKER_SCHEMA_VERSION = "loopx_managed_antigravity_mcp_v0" +ANTIGRAVITY_HOOKS_MARKER_NAME = ".loopx-managed-hooks.json" +ANTIGRAVITY_HOOKS_MARKER_SCHEMA_VERSION = "loopx_managed_antigravity_hooks_v0" McpCommandFactory = Callable[[], tuple[str, str] | None] @@ -901,11 +914,86 @@ def _merge_managed_mcp_json( return "written" +def _merge_managed_hooks_json( + *, + config_path: Path, + cli_bin: str, + uninstall: bool, + execute: bool, +) -> str: + """Add or remove the ``loopx`` continuation gate in Antigravity ``hooks.json``.""" + marker_path = config_path.parent / ANTIGRAVITY_HOOKS_MARKER_NAME + try: + raw = ( + json.loads(config_path.read_text(encoding="utf-8")) + if config_path.exists() + else {} + ) + except (OSError, json.JSONDecodeError): + return "blocked_invalid_antigravity_hooks_json" + if not isinstance(raw, dict): + return "blocked_invalid_antigravity_hooks_json" + hooks = raw.get("hooks") + if hooks is None: + hooks = {} + if not isinstance(hooks, dict): + return "blocked_invalid_antigravity_hooks_json" + + existing = hooks.get(MANAGED_MCP_KEY) + try: + marker_payload = json.loads(marker_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + marker_payload = {} + recorded_hooks = ( + marker_payload.get("hooks") + if isinstance(marker_payload, dict) and isinstance(marker_payload.get("hooks"), dict) + else {} + ) + recorded = recorded_hooks.get(MANAGED_MCP_KEY) + loopx_owned = existing is not None and existing == recorded + if execute and recorded is not None and not loopx_owned: + marker_path.unlink(missing_ok=True) + + if uninstall: + if existing is None: + return "absent" + if not loopx_owned: + return "skipped_user_owned_hooks_entry" + if not execute: + return "would_retire" + hooks.pop(MANAGED_MCP_KEY, None) + raw["hooks"] = hooks + _write_json_atomic(config_path, raw) + marker_path.unlink(missing_ok=True) + return "retired" + + if existing is not None and not loopx_owned: + return "skipped_user_owned_hooks_entry" + entry = build_antigravity_hooks_template(cli_bin=cli_bin)[MANAGED_MCP_KEY] + if not execute: + return "unchanged" if existing == entry else "would_write" + if existing == entry: + return "unchanged" + hooks[MANAGED_MCP_KEY] = entry + raw["hooks"] = hooks + _write_json_atomic(config_path, raw) + _write_json_atomic( + marker_path, + { + "schema_version": ANTIGRAVITY_HOOKS_MARKER_SCHEMA_VERSION, + "hooks": {MANAGED_MCP_KEY: entry}, + }, + ) + return "written" + + def install_slash_commands( *, execute: bool, uninstall: bool = False, with_goal_bridge: bool = False, + with_agy_mcp: bool = False, + with_agy_hooks: bool = False, surfaces: list[str] | None = None, cli_bin: str = "loopx", include_legacy_aliases: bool = True, @@ -934,6 +1022,7 @@ def install_slash_commands( cursor_root = _cursor_home(cursor_home) zcode_root = _zcode_home(zcode_home or zcode_agents_home) agy_root = _agy_home(agy_home) + agy_config_root = _antigravity_config_home(agy_home) kiro_root = _kiro_home(kiro_home) if pi_scope not in {"project", "user"}: raise ValueError("pi_scope must be 'project' or 'user'") @@ -1146,10 +1235,10 @@ def install_slash_commands( if "agy" in effective_surfaces: # Antigravity CLI discovers global skills from the fixed # ~/.gemini/antigravity-cli/skills root using the documented flat - # layout (one .md per skill). The official docs describe no home - # override, so LoopX offers none: installs target exactly that path, - # and the root belongs to agy alone (Gemini CLI reads ~/.gemini/skills), - # so the managed skill surfaces never collide across different hosts. + # layout (one .md per skill). When --agy-home is not explicitly + # overriding the root, also populate Antigravity 2.0's directory-based + # ~/.gemini/config/skills//SKILL.md root so both Antigravity App + # and legacy CLI work out of the box. _install_skill_facade( specs=facade_specs, installed=installed, @@ -1161,6 +1250,73 @@ def install_slash_commands( uninstall=uninstall, flat=True, ) + if agy_home is None and "antigravity-app" not in effective_surfaces: + _install_skill_facade( + specs=facade_specs, + installed=installed, + skills_dir=agy_config_root / "skills", + surface="antigravity-app", + host_surfaces=["antigravity-app", "agy"], + mechanism="antigravity_app_skills", + execute=execute, + uninstall=uninstall, + invoke_prefix="/", + ) + + if "antigravity-app" in effective_surfaces: + _install_skill_facade( + specs=facade_specs, + installed=installed, + skills_dir=agy_config_root / "skills", + surface="antigravity-app", + host_surfaces=["antigravity-app", "agy"], + mechanism="antigravity_app_skills", + execute=execute, + uninstall=uninstall, + invoke_prefix="/", + ) + + if ("antigravity-app" in effective_surfaces or "agy" in effective_surfaces) and with_agy_mcp: + agy_mcp_path = agy_config_root / ANTIGRAVITY_MCP_CONFIG_FILENAME + mcp_status = _merge_managed_mcp_json( + config_path=agy_mcp_path, + marker_schema_version=ANTIGRAVITY_MCP_MARKER_SCHEMA_VERSION, + invalid_status="blocked_invalid_antigravity_mcp_json", + command_factory=lambda: _loopx_mcp_command(), + uninstall=uninstall, + execute=execute, + ) + installed.append( + { + "surface": "antigravity-app", + "host_surfaces": ["antigravity-app", "agy"], + "mechanism": "antigravity_mcp_server", + "command": "loopx", + "path": str(agy_mcp_path), + "status": mcp_status, + "invoke_as": [], + } + ) + + if ("antigravity-app" in effective_surfaces or "agy" in effective_surfaces) and with_agy_hooks: + agy_hooks_path = agy_config_root / ANTIGRAVITY_HOOKS_CONFIG_FILENAME + hooks_status = _merge_managed_hooks_json( + config_path=agy_hooks_path, + cli_bin=cli_bin, + uninstall=uninstall, + execute=execute, + ) + installed.append( + { + "surface": "antigravity-app", + "host_surfaces": ["antigravity-app", "agy"], + "mechanism": "antigravity_hooks_gate", + "command": "loopx", + "path": str(agy_hooks_path), + "status": hooks_status, + "invoke_as": [], + } + ) if "kiro-cli" in effective_surfaces: # Kiro CLI discovers global skills from /skills//SKILL.md @@ -1562,6 +1718,26 @@ def install_slash_commands( "cursor_mcp_path": str(cursor_root / "mcp.json") if "cursor" in effective_surfaces else None, "zcode_skill_dir": str(zcode_root / "skills") if "zcode" in effective_surfaces else None, "agy_skill_dir": str(agy_root / "skills") if "agy" in effective_surfaces else None, + "antigravity_app_skill_dir": ( + str(agy_config_root / "skills") + if ( + "antigravity-app" in effective_surfaces + or ("agy" in effective_surfaces and agy_home is None) + ) + else None + ), + "antigravity_mcp_path": ( + str(agy_config_root / ANTIGRAVITY_MCP_CONFIG_FILENAME) + if with_agy_mcp + and ("antigravity-app" in effective_surfaces or "agy" in effective_surfaces) + else None + ), + "antigravity_hooks_path": ( + str(agy_config_root / ANTIGRAVITY_HOOKS_CONFIG_FILENAME) + if with_agy_hooks + and ("antigravity-app" in effective_surfaces or "agy" in effective_surfaces) + else None + ), "kiro_cli_skill_dir": str(kiro_root / "skills") if "kiro-cli" in effective_surfaces else None, "kiro_cli_mcp_path": str(kiro_root / _KIRO_MCP_CONFIG_SUBPATH) if "kiro-cli" in effective_surfaces else None, "opencode_skill_dir": str(opencode_root / "skills") if "opencode" in effective_surfaces else None, diff --git a/loopx/slash_commands.py b/loopx/slash_commands.py index 023a08c079..13d23f2a01 100644 --- a/loopx/slash_commands.py +++ b/loopx/slash_commands.py @@ -77,6 +77,7 @@ def build_slash_command_catalog( "cursor-agent": "drive the loop from the agent's own turns; enter every turn through quota should-run", "zcode": "drive the loop from the agent's turns; enter through quota should-run", "agy": "native /goal + schedule wakes; quota entry advisory", + "antigravity-app": "native schedule(DurationSeconds=...) timer closed loop + sidecar.json heartbeat", "kiro-cli": "native /goal loop; quota entry advisory", "manual": "wire an external scheduler or run quota/status manually", "other-agent": "use the custom host loop driver declared by `loopx agent-onboard`", diff --git a/loopx/workflow_skill_install.py b/loopx/workflow_skill_install.py index c28940c889..11ca74240e 100644 --- a/loopx/workflow_skill_install.py +++ b/loopx/workflow_skill_install.py @@ -11,6 +11,7 @@ from typing import Any, Iterator, Mapping from . import __version__ +from .agy_goal_mode import antigravity_config_home from .file_lock import exclusive_file_lock from .python_install_owner import interpreter_invocation from .skill_install_readback import ( @@ -150,7 +151,14 @@ def resolve_workflow_skill_source() -> dict[str, Any]: } -def default_workflow_skills_dir(env: Mapping[str, str] | None = None) -> Path: +def default_workflow_skills_dir( + env: Mapping[str, str] | None = None, + *, + host_surface: str | None = None, + agy_home: str | None = None, +) -> Path: + if agy_home or host_surface in {"agy", "antigravity-app"}: + return antigravity_config_home(agy_home) / "skills" source_env = os.environ if env is None else env configured = str(source_env.get("CODEX_HOME") or "").strip() codex_root = Path(configured).expanduser() if configured else Path.home() / ".codex" @@ -280,8 +288,15 @@ def workflow_skill_install( uninstall: bool = False, cli_bin: str = "loopx", host_surface: str | None = None, + agy_home: str | None = None, ) -> dict[str, Any]: - target_root = (skills_dir or default_workflow_skills_dir()).expanduser().resolve() + target_root = ( + skills_dir + or default_workflow_skills_dir( + host_surface=host_surface, + agy_home=agy_home, + ) + ).expanduser().resolve() source = resolve_workflow_skill_source() source_root = Path(source["source_root"]) frozen_source_revision = ( diff --git a/tests/test_agy_host_surface.py b/tests/test_agy_host_surface.py index cf79d3a713..bc4d9d857d 100644 --- a/tests/test_agy_host_surface.py +++ b/tests/test_agy_host_surface.py @@ -28,6 +28,16 @@ scheduler_command_binding_for_agent_type, ) from loopx.slash_command_install import install_slash_commands +from loopx.workflow_skill_install import workflow_skill_install +from loopx.doctor import collect_doctor +from loopx.control_plane.scheduler.execution_context import ( + ExecutionMode, + HostSurface, + SchedulerOwner, + SchedulerRuntimeProfile, + resolve_scheduler_execution_context, + scheduler_execution_context_for_runtime_profile, +) from loopx.agy_goal_mode import ( AGY_GOAL_CANCELLED_TOKEN, AGY_GOAL_COMMAND, @@ -35,7 +45,11 @@ AGY_GOAL_COMPLETE_TOKEN, AGY_NATIVE_WAKE_FACTS, AGY_NATIVE_WAKE_TOOLS, + ANTIGRAVITY_APP_AGENT_TYPE, agy_home, + antigravity_config_home, + build_antigravity_hooks_template, + build_antigravity_sidecar_template, ) HOST_SURFACE = "agy" @@ -251,3 +265,156 @@ def test_native_goal_and_wake_facts_match_the_live_probes() -> None: "send_message", "manage_inbox", } + + +def test_antigravity_app_first_class_host_surface_and_schedule_sidecar_contract( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Antigravity 2.0 (antigravity-app) is a first-class host surface alongside agy.""" + start_dir = tmp_path / "start" + start_dir.mkdir() + payload = start_goal_accepts_surface(ANTIGRAVITY_APP_AGENT_TYPE, start_dir) + assert ( + payload["command_pack"]["host_loop_activation"]["host_surface"] + == "antigravity_app_loop" + ) + activation = build_host_loop_activation_packet( + agent_type=ANTIGRAVITY_APP_AGENT_TYPE, + goal_id="surface-goal", + agent_id="probe-agent", + registered_agents=["probe-agent"], + ) + assert activation["host_surface"] == "antigravity_app_loop" + assert ( + activation["activation_method"] + == "bind_antigravity_schedule_loop_and_sidecar" + ) + mutation = activation["host_mutation"] + assert mutation["host_loop_primitive"] == "antigravity-schedule-tool-and-sidecar" + assert mutation["in_session_schedule_call"]["tool"] == "schedule" + assert "DurationSeconds" in mutation["in_session_schedule_call"]["arguments"] + assert "waiting_external" in mutation["in_session_schedule_call"]["when_to_use"] + assert ( + mutation["sidecar_template"]["schema_version"] + == "loopx_antigravity_sidecar_template_v0" + ) + assert ( + "antigravity_app" + in mutation["sidecar_template"]["sidecar"]["loopx-heartbeat"]["gate_command"] + ) + assert ( + mutation["hooks_template"]["loopx"]["schema_version"] + == "loopx_antigravity_hooks_gate_v0" + ) + + gate_dir = tmp_path / "gate" + gate_dir.mkdir() + selection_gate_offers_surface(ANTIGRAVITY_APP_AGENT_TYPE, gate_dir) + + assert normalize_agent_type("antigravity-app") == ANTIGRAVITY_APP_AGENT_TYPE + assert normalize_agent_type("agy-app") == ANTIGRAVITY_APP_AGENT_TYPE + assert normalize_agent_type("antigravity 2.0") == ANTIGRAVITY_APP_AGENT_TYPE + assert scheduler_command_binding_for_agent_type(ANTIGRAVITY_APP_AGENT_TYPE) == { + "runtime_profile": "antigravity_app" + } + + # Verify both hosted automation (sidecar.json) and interactive (in-session schedule) execution contexts + hosted_ctx = scheduler_execution_context_for_runtime_profile( + SchedulerRuntimeProfile.ANTIGRAVITY_APP + ) + assert hosted_ctx.ok + assert hosted_ctx.context is not None + assert hosted_ctx.context.app_automation_applicable is True + assert ( + hosted_ctx.projection()["app_automation_applicability"] == "applicable" + ) + + interactive_ctx = resolve_scheduler_execution_context( + { + "host_surface": HostSurface.ANTIGRAVITY_APP.value, + "scheduler_owner": SchedulerOwner.AGENT_CLI_LOOP.value, + "execution_mode": ExecutionMode.INTERACTIVE.value, + } + ) + assert interactive_ctx.ok + + +def test_antigravity_dual_skills_mcp_hooks_workflow_skills_and_doctor( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Verify ~/.gemini/config/skills//SKILL.md dual compatibility, + --agy-home override, optional mcp_config.json + hooks.json registration, + workflow-skills --install, and doctor checks.""" + import loopx.slash_command_install as sci + + monkeypatch.setenv("HOME", str(tmp_path / "home")) + monkeypatch.delenv("AGY_HOME", raising=False) + monkeypatch.delenv("ANTIGRAVITY_HOME", raising=False) + monkeypatch.setattr( + sci, + "_loopx_mcp_command", + lambda: ("/usr/bin/python3", "/opt/loopx/mcp_server.py"), + ) + + # 1. Default `agy` surface installs BOTH legacy flat ~/.gemini/antigravity-cli/skills/.md + # AND Antigravity 2.0 ~/.gemini/config/skills//SKILL.md + dual_payload = install_slash_commands( + execute=True, + surfaces=["agy"], + ) + assert dual_payload["ok"] is True + assert ( + tmp_path / "home" / ".gemini" / "antigravity-cli" / "skills" / "loopx.md" + ).is_file() + assert ( + tmp_path / "home" / ".gemini" / "config" / "skills" / "loopx" / "SKILL.md" + ).is_file() + + # 2. Explicit --agy-home with --with-agy-mcp and --with-agy-hooks + custom_agy_home = tmp_path / "custom-agy-config" + app_payload = install_slash_commands( + execute=True, + surfaces=["antigravity-app"], + agy_home=str(custom_agy_home), + with_agy_mcp=True, + with_agy_hooks=True, + ) + assert app_payload["ok"] is True + assert (custom_agy_home / "skills" / "loopx" / "SKILL.md").is_file() + assert (custom_agy_home / "mcp_config.json").is_file() + assert (custom_agy_home / "hooks.json").is_file() + + # Uninstall retires managed MCP, hooks, and skill files cleanly + uninstall_payload = install_slash_commands( + execute=True, + uninstall=True, + surfaces=["antigravity-app"], + agy_home=str(custom_agy_home), + with_agy_mcp=True, + with_agy_hooks=True, + ) + assert uninstall_payload["ok"] is True + assert not (custom_agy_home / "skills" / "loopx" / "SKILL.md").exists() + + # 3. workflow-skills --install --host-surface antigravity-app --agy-home + wf_payload = workflow_skill_install( + execute=True, + host_surface="antigravity-app", + agy_home=str(custom_agy_home), + ) + assert wf_payload["ok"] is True + assert (custom_agy_home / "skills" / "loopx-project" / "SKILL.md").is_file() + assert (custom_agy_home / "skills" / "loopx" / "SKILL.md").is_file() + + # 4. doctor --agent-type antigravity-app checks the Antigravity config home + monkeypatch.setenv("AGY_HOME", str(custom_agy_home)) + doc = collect_doctor(agent_type="antigravity-app") + assert "antigravity" in doc + assert doc["antigravity"]["config_home"] == str(custom_agy_home) + assert doc["antigravity"]["workflow_skills_readback"]["ready"] is True + agy_check = next( + c for c in doc["checks"] if c["id"] == "antigravity_host_surface_ready" + ) + assert agy_check["ok"] is True From 6a875451660aa8671399b984e62e1a078f0c72f7 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:24:49 +0530 Subject: [PATCH 14/34] slash-commands: treat empty mcp_config.json and hooks.json as empty object Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/slash_command_install.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/loopx/slash_command_install.py b/loopx/slash_command_install.py index 9527ea42a8..feb04fce5f 100644 --- a/loopx/slash_command_install.py +++ b/loopx/slash_command_install.py @@ -843,11 +843,8 @@ def _merge_managed_mcp_json( """ marker_path = config_path.parent / CURSOR_MCP_MARKER_NAME try: - raw = ( - json.loads(config_path.read_text(encoding="utf-8")) - if config_path.exists() - else {} - ) + text = config_path.read_text(encoding="utf-8") if config_path.exists() else "" + raw = json.loads(text) if text.strip() else {} except (OSError, json.JSONDecodeError): return invalid_status if not isinstance(raw, dict): @@ -924,11 +921,8 @@ def _merge_managed_hooks_json( """Add or remove the ``loopx`` continuation gate in Antigravity ``hooks.json``.""" marker_path = config_path.parent / ANTIGRAVITY_HOOKS_MARKER_NAME try: - raw = ( - json.loads(config_path.read_text(encoding="utf-8")) - if config_path.exists() - else {} - ) + text = config_path.read_text(encoding="utf-8") if config_path.exists() else "" + raw = json.loads(text) if text.strip() else {} except (OSError, json.JSONDecodeError): return "blocked_invalid_antigravity_hooks_json" if not isinstance(raw, dict): From d06ca3257f35a52204eccd27b2d78b9f830db592 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:26:42 +0530 Subject: [PATCH 15/34] doctor: accept materialized workflow skills for antigravity facade readiness Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/doctor.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/loopx/doctor.py b/loopx/doctor.py index 18f945932d..c446c11808 100644 --- a/loopx/doctor.py +++ b/loopx/doctor.py @@ -1277,8 +1277,12 @@ def collect_doctor( source_root=repo_root, ) agy_facades = inspect_skill_facades(agy_cfg_home / "skills") + workflow_skill_ids = set( + agy_workflow_readback.get("materialized_skill_ids") or () + ) agy_facades_ready = all( - item.get("required_phrases") for item in agy_facades.values() + item.get("required_phrases") or name in workflow_skill_ids + for name, item in agy_facades.items() ) legacy_loopx_exists = (legacy_agy_home / "skills" / "loopx.md").is_file() agy_ready = bool( From 83ecd5b0503eef75612c4eafd900e8f3c1e636d5 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Sun, 1 Nov 2026 23:45:42 +0530 Subject: [PATCH 16/34] fix(agy): clean ruff unused imports and preserve read-only should-run context delivery Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/cli_commands/context_machine.py | 5 +++++ tests/test_agy_host_surface.py | 3 --- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/loopx/cli_commands/context_machine.py b/loopx/cli_commands/context_machine.py index 5a2854344a..df9d522f54 100644 --- a/loopx/cli_commands/context_machine.py +++ b/loopx/cli_commands/context_machine.py @@ -108,6 +108,11 @@ def apply_context_machine_options( value = machine_registry.resolve("context_delivery").public_descriptor()["configuration_template"] if value["mode"] == "off" or (using_default and os.name != "posix"): return + if command == "should-run" and not ( + getattr(args, "turn_instance_id", None) or bool(getattr(args, "begin_turn", False)) + ): + args.context_delivery = "off" + return # Recovery does not activate a run mode or create a missing session. if command != "context-read": args.context_delivery = "on" diff --git a/tests/test_agy_host_surface.py b/tests/test_agy_host_surface.py index bc4d9d857d..4fc43c2d6b 100644 --- a/tests/test_agy_host_surface.py +++ b/tests/test_agy_host_surface.py @@ -47,9 +47,6 @@ AGY_NATIVE_WAKE_TOOLS, ANTIGRAVITY_APP_AGENT_TYPE, agy_home, - antigravity_config_home, - build_antigravity_hooks_template, - build_antigravity_sidecar_template, ) HOST_SURFACE = "agy" From 3b648dbbb3d42a8bb7a4614b5542efb229412c50 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:00:47 +0530 Subject: [PATCH 17/34] fix(antigravity): bind antigravity_app to visible-goal settlement, fix hooks/sidecar args, and pass CLI budget differential Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/agy_goal_mode/__init__.py | 114 ++++++++++++------ loopx/bootstrap_command_pack.py | 4 +- loopx/cli_commands/quota_registration.py | 1 + .../scheduler/execution_context.py | 38 ++---- loopx/host_loop_activation.py | 1 + loopx/hosts/skill_facade.py | 2 + loopx/slash_commands.py | 2 +- tests/test_agy_host_surface.py | 30 ++++- 8 files changed, 121 insertions(+), 71 deletions(-) diff --git a/loopx/agy_goal_mode/__init__.py b/loopx/agy_goal_mode/__init__.py index e3037f27b2..4a179c4a42 100644 --- a/loopx/agy_goal_mode/__init__.py +++ b/loopx/agy_goal_mode/__init__.py @@ -106,34 +106,39 @@ def build_antigravity_sidecar_template( *, goal_id: str = "", agent_id: str | None = None, + runtime_root: str | None = None, cli_bin: str = "loopx", interval_seconds: int = 300, ) -> dict[str, Any]: """Build a public-safe Antigravity 2.0 ``sidecar.json`` periodic heartbeat template.""" - quota_args = [ - cli_bin, - "--format", - "json", - "quota", - "should-run", - "--goal-id", - goal_id, - "--runtime-profile", - "antigravity_app", - ] + quota_args = [cli_bin, "--format", "json"] + if runtime_root: + quota_args.extend(["--runtime-root", runtime_root]) + quota_args.extend( + [ + "quota", + "should-run", + "--goal-id", + goal_id, + "--runtime-profile", + "antigravity_app", + ] + ) if agent_id: quota_args.extend(["--agent-id", agent_id]) - heartbeat_args = [ - cli_bin, - "--format", - "json", - "heartbeat-prompt", - "--goal-id", - goal_id, - "--thin", - "--runtime-profile", - "antigravity_app", - ] + heartbeat_args = [cli_bin, "--format", "json"] + if runtime_root: + heartbeat_args.extend(["--runtime-root", runtime_root]) + heartbeat_args.extend( + [ + "heartbeat-prompt", + "--goal-id", + goal_id, + "--thin", + "--runtime-profile", + "antigravity_app", + ] + ) if agent_id: heartbeat_args.extend(["--agent-id", agent_id]) return { @@ -151,7 +156,9 @@ def build_antigravity_sidecar_template( "prompt_json_field": "task_body", "suspend_on_waiting_external": { "tool": "schedule", - "duration_field": "scheduler_hint.poll_after_seconds", + "interval_minutes_field": ( + "scheduler_hint.local_scheduler.recommended_interval_minutes" + ), "default_duration_seconds": int(interval_seconds), }, } @@ -162,22 +169,51 @@ def build_antigravity_sidecar_template( def build_antigravity_hooks_template( *, cli_bin: str = "loopx", + goal_id: str | None = None, + agent_id: str | None = None, + runtime_root: str | None = None, ) -> dict[str, Any]: - """Build a public-safe ``hooks.json`` continuation gate entry for Antigravity 2.0.""" + """Build a public-safe ``hooks.json`` continuation gate entry for Antigravity 2.0. + + When bound to a concrete ``goal_id``, invokes ``quota should-run --goal-id ...``; + when installed globally without a goal binding, invokes ``quota plan --runtime-profile antigravity_app`` + so the command succeeds without ``--goal-id``. + """ + args: list[str] = ["--format", "json"] + if runtime_root: + args.extend(["--runtime-root", runtime_root]) + bound_goal = bool(goal_id and goal_id != "") + if bound_goal: + args.extend( + [ + "quota", + "should-run", + "--goal-id", + str(goal_id), + "--runtime-profile", + "antigravity_app", + ] + ) + if agent_id: + args.extend(["--agent-id", str(agent_id)]) + require_field = "should_run" + else: + args.extend( + [ + "quota", + "plan", + "--runtime-profile", + "antigravity_app", + ] + ) + require_field = "ok" return { "loopx": { "schema_version": "loopx_antigravity_hooks_gate_v0", "event": "before_continuation", "command": cli_bin, - "args": [ - "--format", - "json", - "quota", - "should-run", - "--runtime-profile", - "antigravity_app", - ], - "require_json_field_true": "should_run", + "args": args, + "require_json_field_true": require_field, "on_waiting_external": "suspend_via_schedule_timer", } } @@ -248,17 +284,25 @@ def antigravity_app_activation_extras( *, goal_id: str = "", agent_id: str | None = None, + runtime_root: str | None = None, cli_bin: str = "loopx", ) -> dict[str, Any]: """Keyword overrides for ``antigravity_app_activation``'s facade call.""" sidecar_template = build_antigravity_sidecar_template( goal_id=goal_id, agent_id=agent_id, + runtime_root=runtime_root, + cli_bin=cli_bin, + ) + hooks_template = build_antigravity_hooks_template( cli_bin=cli_bin, + goal_id=goal_id, + agent_id=agent_id, + runtime_root=runtime_root, ) - hooks_template = build_antigravity_hooks_template(cli_bin=cli_bin) + runtime_arg = f" --runtime-root {shlex.quote(runtime_root)}" if runtime_root else "" wake_prompt = ( - f"Run `{cli_bin} --format json quota should-run --goal-id {goal_id}" + f"Run `{cli_bin} --format json{runtime_arg} quota should-run --goal-id {goal_id}" + (f" --agent-id {agent_id}" if agent_id else "") + " --runtime-profile antigravity_app` and continue the active LoopX Todo when allowed." ) diff --git a/loopx/bootstrap_command_pack.py b/loopx/bootstrap_command_pack.py index 7aeb3742c7..440711f17c 100644 --- a/loopx/bootstrap_command_pack.py +++ b/loopx/bootstrap_command_pack.py @@ -373,8 +373,8 @@ def build_start_goal_host_surface_selection_packet( "gemini-cli": "Gemini CLI driving its own loop through the LoopX skill facade", "cursor-agent": "cursor-agent driving its own loop through the LoopX skill facade and MCP server", "zcode": "ZCode loop via the LoopX skill facade", - "agy": "agy session loop via the LoopX skill facade; native /goal + schedule wakes while the session lives", - "antigravity-app": "Antigravity App loop via ~/.gemini/config/skills; native schedule timer closed loop + sidecar.json heartbeat", + "agy": "agy CLI loop (/goal + schedule wakes)", + "antigravity-app": "Antigravity App (schedule + sidecar)", "kiro-cli": "Kiro CLI loop via the LoopX skill facade; native /goal iteration budget", "deepseek-harness": "DeepSeek Harness automation loop through loopx.dsh_goal_mode (compat: scripts/dsh_turn_host_adapter.py)", "deepseek-harness-native": "DeepSeek Harness same-session LoopX skill and plugin driver", diff --git a/loopx/cli_commands/quota_registration.py b/loopx/cli_commands/quota_registration.py index d6ce128f8d..b5d09bbc41 100644 --- a/loopx/cli_commands/quota_registration.py +++ b/loopx/cli_commands/quota_registration.py @@ -154,6 +154,7 @@ def register_quota_command( "codex_app_ssh", "codex_cli", "trae_app", + "antigravity_app", "generic_cli", "claude_code", "kiro_cli", diff --git a/loopx/control_plane/scheduler/execution_context.py b/loopx/control_plane/scheduler/execution_context.py index 0c9da7822f..9da45866bf 100644 --- a/loopx/control_plane/scheduler/execution_context.py +++ b/loopx/control_plane/scheduler/execution_context.py @@ -79,6 +79,7 @@ class SchedulerRuntimeProfile(str, Enum): *NATIVE_GOAL_RUNTIME_PROFILES, SchedulerRuntimeProfile.CLAUDE_CODE_VISIBLE, SchedulerRuntimeProfile.KIRO_CLI_VISIBLE, + SchedulerRuntimeProfile.ANTIGRAVITY_APP, } ) @@ -100,7 +101,6 @@ class SchedulerRuntimeProfile(str, Enum): { SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT, SchedulerRuntimeProfile.TRAE_APP, - SchedulerRuntimeProfile.ANTIGRAVITY_APP, } ) @@ -133,8 +133,8 @@ class SchedulerRuntimeProfile(str, Enum): ), SchedulerRuntimeProfile.ANTIGRAVITY_APP: ( HostSurface.ANTIGRAVITY_APP, - SchedulerOwner.HOST_AUTOMATION, - ExecutionMode.HOSTED_AUTOMATION, + SchedulerOwner.AGENT_CLI_LOOP, + ExecutionMode.INTERACTIVE, ), SchedulerRuntimeProfile.CLAUDE_CODE_VISIBLE: ( HostSurface.CLAUDE_CODE, @@ -194,12 +194,7 @@ def codex_app_applicable(self) -> bool: @property def app_automation_applicable(self) -> bool: return ( - self.host_surface - in { - HostSurface.CODEX_APP, - HostSurface.TRAE_APP, - HostSurface.ANTIGRAVITY_APP, - } + self.host_surface in {HostSurface.CODEX_APP, HostSurface.TRAE_APP} and self.scheduler_owner is SchedulerOwner.HOST_AUTOMATION and self.execution_mode is ExecutionMode.HOSTED_AUTOMATION ) @@ -214,10 +209,10 @@ def projection(self) -> dict[str, Any]: "valid": True, } # Keep existing non-Trae projections byte-compatible. The neutral App - # field is needed to identify Trae/Antigravity's independent automation - # contract, but a second negative applicability flag adds no information - # for CLI and Goal runtimes. - if self.host_surface in {HostSurface.TRAE_APP, HostSurface.ANTIGRAVITY_APP}: + # field is needed to identify Trae's independent automation contract, + # but a second negative applicability flag adds no information for CLI + # and Goal runtimes. + if self.host_surface is HostSurface.TRAE_APP: projection["app_automation_applicability"] = ( "applicable" if self.app_automation_applicable else "not_applicable" ) @@ -251,10 +246,7 @@ def projection(self) -> dict[str, Any]: "valid": False, "errors": list(self.errors), } - if supplied.get("host_surface") in { - HostSurface.TRAE_APP.value, - HostSurface.ANTIGRAVITY_APP.value, - }: + if supplied.get("host_surface") == HostSurface.TRAE_APP.value: projection["app_automation_applicability"] = "blocked_invalid_context" else: projection["codex_app_applicability"] = "blocked_invalid_context" @@ -275,18 +267,10 @@ def _validation_errors(context: SchedulerExecutionContext) -> list[str]: HostSurface.CLAUDE_CODE, HostSurface.KUNLUNCODE, HostSurface.KIRO_CLI, - } - if context.host_surface in { - HostSurface.CODEX_APP, - HostSurface.TRAE_APP, HostSurface.ANTIGRAVITY_APP, - }: + } + if context.host_surface in {HostSurface.CODEX_APP, HostSurface.TRAE_APP}: host_name = context.host_surface.value - if context.host_surface is HostSurface.ANTIGRAVITY_APP and ( - context.scheduler_owner is SchedulerOwner.AGENT_CLI_LOOP - and context.execution_mode is ExecutionMode.INTERACTIVE - ): - return errors if context.scheduler_owner is not SchedulerOwner.HOST_AUTOMATION: errors.append(f"{host_name} requires scheduler_owner=host_automation") if context.execution_mode is not ExecutionMode.HOSTED_AUTOMATION: diff --git a/loopx/host_loop_activation.py b/loopx/host_loop_activation.py index 3ecd9255f8..903214ff0f 100644 --- a/loopx/host_loop_activation.py +++ b/loopx/host_loop_activation.py @@ -1324,6 +1324,7 @@ def build_host_loop_activation_packet( cli_bin, goal_id=goal_id, agent_id=str(selected_agent_id) if selected_agent_id else None, + runtime_root=runtime_root, ) elif canonical == "kiro-cli": surface = kiro_cli_activation(commands, cli_bin) diff --git a/loopx/hosts/skill_facade.py b/loopx/hosts/skill_facade.py index 58172bb047..ef32d65f9e 100644 --- a/loopx/hosts/skill_facade.py +++ b/loopx/hosts/skill_facade.py @@ -170,6 +170,7 @@ def antigravity_app_activation( *, goal_id: str = "", agent_id: str | None = None, + runtime_root: str | None = None, ) -> dict[str, Any]: return skill_facade_cli_activation( commands, @@ -181,6 +182,7 @@ def antigravity_app_activation( **antigravity_app_activation_extras( goal_id=goal_id, agent_id=agent_id, + runtime_root=runtime_root, cli_bin=cli_bin, ), ) diff --git a/loopx/slash_commands.py b/loopx/slash_commands.py index 13d23f2a01..3957044243 100644 --- a/loopx/slash_commands.py +++ b/loopx/slash_commands.py @@ -77,7 +77,7 @@ def build_slash_command_catalog( "cursor-agent": "drive the loop from the agent's own turns; enter every turn through quota should-run", "zcode": "drive the loop from the agent's turns; enter through quota should-run", "agy": "native /goal + schedule wakes; quota entry advisory", - "antigravity-app": "native schedule(DurationSeconds=...) timer closed loop + sidecar.json heartbeat", + "antigravity-app": "schedule timer + sidecar heartbeat", "kiro-cli": "native /goal loop; quota entry advisory", "manual": "wire an external scheduler or run quota/status manually", "other-agent": "use the custom host loop driver declared by `loopx agent-onboard`", diff --git a/tests/test_agy_host_surface.py b/tests/test_agy_host_surface.py index 4fc43c2d6b..133594086b 100644 --- a/tests/test_agy_host_surface.py +++ b/tests/test_agy_host_surface.py @@ -316,15 +316,20 @@ def test_antigravity_app_first_class_host_surface_and_schedule_sidecar_contract( "runtime_profile": "antigravity_app" } - # Verify both hosted automation (sidecar.json) and interactive (in-session schedule) execution contexts - hosted_ctx = scheduler_execution_context_for_runtime_profile( + # Verify antigravity_app runtime profile maps to interactive AGENT_CLI_LOOP + # (so todo complete / refresh-state use turn-scoped visible-goal settlement + # instead of emitting Codex App -A / --ack-applied-rrule commands). + runtime_ctx = scheduler_execution_context_for_runtime_profile( SchedulerRuntimeProfile.ANTIGRAVITY_APP ) - assert hosted_ctx.ok - assert hosted_ctx.context is not None - assert hosted_ctx.context.app_automation_applicable is True + assert runtime_ctx.ok + assert runtime_ctx.context is not None + assert runtime_ctx.context.host_surface is HostSurface.ANTIGRAVITY_APP + assert runtime_ctx.context.scheduler_owner is SchedulerOwner.AGENT_CLI_LOOP + assert runtime_ctx.context.execution_mode is ExecutionMode.INTERACTIVE + assert runtime_ctx.context.app_automation_applicable is False assert ( - hosted_ctx.projection()["app_automation_applicability"] == "applicable" + runtime_ctx.projection()["codex_app_applicability"] == "not_applicable" ) interactive_ctx = resolve_scheduler_execution_context( @@ -336,6 +341,19 @@ def test_antigravity_app_first_class_host_surface_and_schedule_sidecar_contract( ) assert interactive_ctx.ok + # Verify hooks_template includes --goal-id and --agent-id when bound, and sidecar uses + # scheduler_hint.local_scheduler.recommended_interval_minutes. + assert "--goal-id" in mutation["hooks_template"]["loopx"]["args"] + assert "surface-goal" in mutation["hooks_template"]["loopx"]["args"] + assert "--agent-id" in mutation["hooks_template"]["loopx"]["args"] + assert "probe-agent" in mutation["hooks_template"]["loopx"]["args"] + assert ( + mutation["sidecar_template"]["sidecar"]["loopx-heartbeat"][ + "suspend_on_waiting_external" + ]["interval_minutes_field"] + == "scheduler_hint.local_scheduler.recommended_interval_minutes" + ) + def test_antigravity_dual_skills_mcp_hooks_workflow_skills_and_doctor( tmp_path: Path, From 42fbaed1a425fa76051ede5b2987163a67e0232a Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:17:01 +0530 Subject: [PATCH 18/34] fix(antigravity): use 3-minute default cadence and canonical LoopX managed heartbeat bootstrap v2 Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/agy_goal_mode/__init__.py | 23 ++++++++++++------- .../heartbeat/bootstrap_prompt.py | 2 +- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/loopx/agy_goal_mode/__init__.py b/loopx/agy_goal_mode/__init__.py index 4a179c4a42..db0c5ba381 100644 --- a/loopx/agy_goal_mode/__init__.py +++ b/loopx/agy_goal_mode/__init__.py @@ -108,7 +108,7 @@ def build_antigravity_sidecar_template( agent_id: str | None = None, runtime_root: str | None = None, cli_bin: str = "loopx", - interval_seconds: int = 300, + interval_seconds: int = 180, ) -> dict[str, Any]: """Build a public-safe Antigravity 2.0 ``sidecar.json`` periodic heartbeat template.""" quota_args = [cli_bin, "--format", "json"] @@ -132,15 +132,23 @@ def build_antigravity_sidecar_template( heartbeat_args.extend( [ "heartbeat-prompt", + "--thin", "--goal-id", goal_id, - "--thin", "--runtime-profile", "antigravity_app", ] ) if agent_id: heartbeat_args.extend(["--agent-id", agent_id]) + bootstrap_prompt = ( + "LoopX managed heartbeat bootstrap v2\n" + "每次唤醒先执行:\n" + f"```sh\n{shlex.join(heartbeat_args)}\n```\n" + "读取完整结果;仅 ok=true 时按本次 task_body 推进,不复用旧指令。" + "一次操作不代表结束;通知与执行分开,等待按当前调度契约(遇到 waiting_external / CI 等待时使用原生 schedule(DurationSeconds=..., TimerCondition=\"any\") 挂起,不跑 shell sleep 空查)。" + "入口异常先做权限内恢复;契约仍不可用时不执行任务或记账,并报告阻塞。" + ) return { "schema_version": "loopx_antigravity_sidecar_template_v0", "config_path": ANTIGRAVITY_SIDECAR_CONFIG_LABEL, @@ -149,11 +157,13 @@ def build_antigravity_sidecar_template( "description": ( f"LoopX periodic quota-gated heartbeat for goal {goal_id}" ), + "cron": "*/3 * * * *", "interval_seconds": int(interval_seconds), "gate_command": shlex.join(quota_args), "wake_when_json_field_true": "should_run", "prompt_command": shlex.join(heartbeat_args), "prompt_json_field": "task_body", + "bootstrap_prompt": bootstrap_prompt, "suspend_on_waiting_external": { "tool": "schedule", "interval_minutes_field": ( @@ -300,12 +310,7 @@ def antigravity_app_activation_extras( agent_id=agent_id, runtime_root=runtime_root, ) - runtime_arg = f" --runtime-root {shlex.quote(runtime_root)}" if runtime_root else "" - wake_prompt = ( - f"Run `{cli_bin} --format json{runtime_arg} quota should-run --goal-id {goal_id}" - + (f" --agent-id {agent_id}" if agent_id else "") - + " --runtime-profile antigravity_app` and continue the active LoopX Todo when allowed." - ) + wake_prompt = sidecar_template["sidecar"]["loopx-heartbeat"]["bootstrap_prompt"] return { "activation_method": "bind_antigravity_schedule_loop_and_sidecar", "extra_host_mutation": { @@ -328,6 +333,8 @@ def antigravity_app_activation_extras( "in_session_schedule_call": { "tool": "schedule", "arguments": { + "CronExpression": "*/3 * * * *", + "IsDaemon": True, "DurationSeconds": 180, "Prompt": wake_prompt, "TimerCondition": "any", diff --git a/loopx/control_plane/heartbeat/bootstrap_prompt.py b/loopx/control_plane/heartbeat/bootstrap_prompt.py index 0f43fee53c..21bceb8c46 100644 --- a/loopx/control_plane/heartbeat/bootstrap_prompt.py +++ b/loopx/control_plane/heartbeat/bootstrap_prompt.py @@ -99,7 +99,7 @@ def goal_bootstrap(args: Any, *, registry: Path) -> str: args.codex_app or getattr(args, "trae_app", False) or getattr(args, "runtime_profile", None) - in {"codex_app_heartbeat", "trae_app"} + in {"codex_app_heartbeat", "trae_app", "antigravity_app"} ) command = _goal_command(args, registry=registry, legacy=not heartbeat) if heartbeat: From e66ef7d96870ca301f53abcbd010043eb724817c Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:49:02 +0530 Subject: [PATCH 19/34] feat(antigravity): wire antigravity_app into first-class app_automation stateful backoff Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/agy_goal_mode/__init__.py | 110 ++++++++++ loopx/cli_commands/quota_action_selection.py | 1 + loopx/cli_commands/quota_context.py | 30 ++- loopx/cli_commands/quota_registration.py | 5 + .../cli_commands/support_control_heartbeat.py | 10 +- .../support_control_heartbeat_registration.py | 6 + .../heartbeat/bootstrap_prompt.py | 8 +- loopx/control_plane/heartbeat/task_body.py | 8 +- loopx/control_plane/quota/scheduler_ack.py | 2 +- .../scheduler/execution_context.py | 34 ++- .../scheduler/heartbeat_followup_cli.ts | 10 + loopx/control_plane/scheduler/hint.ts | 2 +- .../control_plane/scheduler/hint_followup.ts | 3 +- .../turn_driver/codex_context.py | 3 +- loopx/entrypoint.py | 1 + .../test_scheduler_execution_context.py | 30 ++- tests/test_agy_host_surface.py | 203 +++++++++++++++++- 17 files changed, 421 insertions(+), 45 deletions(-) diff --git a/loopx/agy_goal_mode/__init__.py b/loopx/agy_goal_mode/__init__.py index db0c5ba381..083a2a669c 100644 --- a/loopx/agy_goal_mode/__init__.py +++ b/loopx/agy_goal_mode/__init__.py @@ -167,15 +167,125 @@ def build_antigravity_sidecar_template( "suspend_on_waiting_external": { "tool": "schedule", "interval_minutes_field": ( + "scheduler_hint.app_automation.recommended_interval_minutes" + ), + "fallback_interval_minutes_field": ( "scheduler_hint.local_scheduler.recommended_interval_minutes" ), "default_duration_seconds": int(interval_seconds), }, + "stateful_backoff": { + "state_key": "scheduler_hint.app_automation.stateful_backoff", + "recommended_rrule_field": ( + "scheduler_hint.app_automation.recommended_rrule" + ), + "recommended_interval_minutes_field": ( + "scheduler_hint.app_automation.recommended_interval_minutes" + ), + "ack_hint_field": ( + "scheduler_hint.app_automation.ack_hint.cli_args" + ), + "failure_hint_field": ( + "scheduler_hint.app_automation.failure_hint.cli_args" + ), + }, } }, } +def cron_for_interval_minutes(interval_minutes: int) -> str: + """Convert a LoopX ``recommended_interval_minutes`` into a 5-field cron expression.""" + minutes = max(1, int(interval_minutes)) + if minutes >= 60: + return "0 * * * *" + return f"*/{minutes} * * * *" + + +def build_antigravity_backoff_schedule_action( + scheduler_hint: dict[str, Any], + *, + current_task_id: str | None = None, + prompt: str = "", +) -> dict[str, Any]: + """Translate ``scheduler_hint.app_automation`` stateful backoff into Antigravity ``schedule`` / ``manage_task`` actions.""" + app = ( + scheduler_hint.get("app_automation") + if isinstance(scheduler_hint.get("app_automation"), dict) + else {} + ) + backoff = ( + app.get("stateful_backoff") + if isinstance(app.get("stateful_backoff"), dict) + else {} + ) + host_action = str(app.get("host_action") or "none") + if host_action == "pause_or_delete_current_heartbeat": + return { + "action": "pause_or_delete_current_heartbeat", + "apply_needed": bool(app.get("host_action_required", True)), + "ack_needed": False, + "manage_task_kill": ( + {"Action": "kill", "TaskId": current_task_id} + if current_task_id + else None + ), + "schedule_call": None, + "ack_cli_args": None, + "failure_cli_args": None, + } + apply_needed = bool(backoff.get("apply_needed")) + ack_needed = bool(backoff.get("ack_needed")) + interval = int( + app.get("recommended_interval_minutes") + or backoff.get("current_interval_minutes") + or 3 + ) + cron_expr = cron_for_interval_minutes(interval) + ack_hint = app.get("ack_hint") if isinstance(app.get("ack_hint"), dict) else {} + failure_hint = ( + app.get("failure_hint") if isinstance(app.get("failure_hint"), dict) else {} + ) + return { + "action": ( + "update_current_heartbeat_rrule" + if apply_needed + else "ack_current_heartbeat_rrule" + if ack_needed + else "noop" + ), + "apply_needed": apply_needed, + "ack_needed": ack_needed, + "recommended_interval_minutes": interval, + "recommended_rrule": app.get("recommended_rrule"), + "cron_expression": cron_expr, + "manage_task_kill": ( + {"Action": "kill", "TaskId": current_task_id} + if apply_needed and current_task_id + else None + ), + "schedule_call": ( + { + "CronExpression": cron_expr, + "IsDaemon": True, + "Prompt": prompt, + } + if apply_needed + else None + ), + "ack_cli_args": ( + list(ack_hint.get("cli_args", [])) + if isinstance(ack_hint.get("cli_args"), list) + else None + ), + "failure_cli_args": ( + list(failure_hint.get("cli_args", [])) + if isinstance(failure_hint.get("cli_args"), list) + else None + ), + } + + def build_antigravity_hooks_template( *, cli_bin: str = "loopx", diff --git a/loopx/cli_commands/quota_action_selection.py b/loopx/cli_commands/quota_action_selection.py index 735351d931..6120de9f00 100644 --- a/loopx/cli_commands/quota_action_selection.py +++ b/loopx/cli_commands/quota_action_selection.py @@ -73,6 +73,7 @@ def _requested_quota_action_todo_id( bool(getattr(args, "turn_instance_id", None)) or bool(args.codex_app) or bool(getattr(args, "trae_app", False)) + or bool(getattr(args, "antigravity_app", False)) or args.runtime_profile in {profile.value for profile in GUIDED_START_TURN_RUNTIME_PROFILES} ): diff --git a/loopx/cli_commands/quota_context.py b/loopx/cli_commands/quota_context.py index bb4213b48a..e7b84085f9 100644 --- a/loopx/cli_commands/quota_context.py +++ b/loopx/cli_commands/quota_context.py @@ -74,10 +74,11 @@ def _scheduler_execution_context_from_args( ) codex_app = bool(getattr(args, "codex_app", False)) trae_app = bool(getattr(args, "trae_app", False)) - app_alias_count = int(codex_app) + int(trae_app) + antigravity_app = bool(getattr(args, "antigravity_app", False)) + app_alias_count = int(codex_app) + int(trae_app) + int(antigravity_app) if app_alias_count > 1: raise QuotaCommandValidationError( - "--codex-app and --trae_app are mutually exclusive" + "--codex-app, --trae_app, and --antigravity_app are mutually exclusive" ) if app_alias_count and (args.runtime_profile or any(explicit_scheduler_fields)): raise QuotaCommandValidationError( @@ -94,6 +95,8 @@ def _scheduler_execution_context_from_args( if codex_app else SchedulerRuntimeProfile.TRAE_APP.value if trae_app + else SchedulerRuntimeProfile.ANTIGRAVITY_APP.value + if antigravity_app else args.runtime_profile ) if runtime_profile: @@ -200,10 +203,16 @@ def validate_quota_command_context_request( legacy_codex_rrule and resolved_scheduler_context.ok and resolved_scheduler_context.context is not None - and resolved_scheduler_context.context.host_surface is HostSurface.TRAE_APP + and resolved_scheduler_context.context.host_surface + in {HostSurface.TRAE_APP, HostSurface.ANTIGRAVITY_APP} ): + app_label = ( + "Trae App" + if resolved_scheduler_context.context.host_surface is HostSurface.TRAE_APP + else "Antigravity App" + ) raise QuotaCommandValidationError( - "Trae App uses --app-automation-current-rrule, not the Codex compatibility alias" + f"{app_label} uses --app-automation-current-rrule, not the Codex compatibility alias" ) args.app_automation_current_rrule = neutral_rrule or legacy_codex_rrule or None if command in QUOTA_SCHEDULER_FOLLOWUP_COMMANDS: @@ -225,16 +234,23 @@ def validate_quota_command_context_request( ).strip() default_state_key = ( APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY - if selected_surface == HostSurface.TRAE_APP.value + if selected_surface + in {HostSurface.TRAE_APP.value, HostSurface.ANTIGRAVITY_APP.value} else None ) if ( - selected_surface == HostSurface.TRAE_APP.value + selected_surface + in {HostSurface.TRAE_APP.value, HostSurface.ANTIGRAVITY_APP.value} and supplied_state_key and supplied_state_key != APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY ): + app_label = ( + "Trae App" + if selected_surface == HostSurface.TRAE_APP.value + else "Antigravity App" + ) raise QuotaCommandValidationError( - "Trae App scheduler follow-up requires the app_automation state key" + f"{app_label} scheduler follow-up requires the app_automation state key" ) args.state_key = supplied_state_key or default_state_key validate_quota_command_request(args) diff --git a/loopx/cli_commands/quota_registration.py b/loopx/cli_commands/quota_registration.py index b5d09bbc41..8119f6bea1 100644 --- a/loopx/cli_commands/quota_registration.py +++ b/loopx/cli_commands/quota_registration.py @@ -145,6 +145,11 @@ def register_quota_command( "runtime or execution context." ), ) + quota_parser.add_argument( + "--antigravity_app", + action="store_true", + help=argparse.SUPPRESS, + ) quota_parser.add_argument( "-H", "--host-surface", diff --git a/loopx/cli_commands/support_control_heartbeat.py b/loopx/cli_commands/support_control_heartbeat.py index d30e934d00..148b6ab141 100644 --- a/loopx/cli_commands/support_control_heartbeat.py +++ b/loopx/cli_commands/support_control_heartbeat.py @@ -46,6 +46,8 @@ def handle_heartbeat_prompt_command( if args.codex_app else SchedulerRuntimeProfile.TRAE_APP.value if getattr(args, "trae_app", False) + else SchedulerRuntimeProfile.ANTIGRAVITY_APP.value + if getattr(args, "antigravity_app", False) else args.runtime_profile ) try: @@ -101,12 +103,14 @@ def handle_heartbeat_prompt_command( args.scheduler_owner, args.execution_mode, ) - app_alias_count = int(bool(args.codex_app)) + int( - bool(getattr(args, "trae_app", False)) + app_alias_count = ( + int(bool(args.codex_app)) + + int(bool(getattr(args, "trae_app", False))) + + int(bool(getattr(args, "antigravity_app", False))) ) if app_alias_count > 1: raise ValueError( - "--codex-app and --trae_app are mutually exclusive" + "--codex-app, --trae_app, and --antigravity_app are mutually exclusive" ) if app_alias_count and ( args.runtime_profile or any(explicit_scheduler_fields) diff --git a/loopx/cli_commands/support_control_heartbeat_registration.py b/loopx/cli_commands/support_control_heartbeat_registration.py index 9b92ca2a5d..4ac448ef69 100644 --- a/loopx/cli_commands/support_control_heartbeat_registration.py +++ b/loopx/cli_commands/support_control_heartbeat_registration.py @@ -92,6 +92,11 @@ def register_heartbeat_control_commands( "trae_app in generated heartbeat commands." ), ) + heartbeat_prompt_parser.add_argument( + "--antigravity_app", + action="store_true", + help=argparse.SUPPRESS, + ) heartbeat_prompt_parser.add_argument( "--visible-goal-host", choices=["traex-cli"], @@ -109,6 +114,7 @@ def register_heartbeat_control_commands( "codex_app_ssh", "codex_cli", "trae_app", + "antigravity_app", "generic_cli", "claude_code", "kunluncode", diff --git a/loopx/control_plane/heartbeat/bootstrap_prompt.py b/loopx/control_plane/heartbeat/bootstrap_prompt.py index 21bceb8c46..183b5812cc 100644 --- a/loopx/control_plane/heartbeat/bootstrap_prompt.py +++ b/loopx/control_plane/heartbeat/bootstrap_prompt.py @@ -60,6 +60,8 @@ def _goal_command(args: Any, *, registry: Path, legacy: bool) -> list[str]: command.append("--codex-app") if getattr(args, "trae_app", False): command.append("--trae_app") + if getattr(args, "antigravity_app", False): + command.append("--antigravity_app") command += ["--goal-id", args.goal_id] for field, flag in ( ("agent_id", "--agent-id"), ("active_state", "--active-state"), @@ -89,6 +91,8 @@ def _goal_command(args: Any, *, registry: Path, legacy: bool) -> list[str]: command.append("--codex-app") if getattr(args, "trae_app", False): command.append("--trae_app") + if getattr(args, "antigravity_app", False): + command.append("--antigravity_app") command.append("--" + mode) return command @@ -98,6 +102,7 @@ def goal_bootstrap(args: Any, *, registry: Path) -> str: heartbeat = ( args.codex_app or getattr(args, "trae_app", False) + or getattr(args, "antigravity_app", False) or getattr(args, "runtime_profile", None) in {"codex_app_heartbeat", "trae_app", "antigravity_app"} ) @@ -117,7 +122,7 @@ def host_bootstrap_binding(prompt: str) -> dict[str, Any] | None: if command[1:3] != ["--format", "json"]: return None values: dict[str, Any] = dict(cli_bin=command[0], turn_instance_id=None, codex_app=False, - trae_app=False, + trae_app=False, antigravity_app=False, full=False, compact=False, brief=False, thin=False, turn_envelope=False, visible_goal_host=None, available_capabilities=[], agent_scopes=[], runtime_root=None) @@ -130,6 +135,7 @@ def host_bootstrap_binding(prompt: str) -> dict[str, Any] | None: booleans = { "--codex-app": "codex_app", "--trae_app": "trae_app", + "--antigravity_app": "antigravity_app", "--turn-envelope": "turn_envelope", **{"--" + name.replace("_", "-"): name for name in ("full", "compact", "brief", "thin")}, } diff --git a/loopx/control_plane/heartbeat/task_body.py b/loopx/control_plane/heartbeat/task_body.py index acf7f7d924..f50db9f9ea 100644 --- a/loopx/control_plane/heartbeat/task_body.py +++ b/loopx/control_plane/heartbeat/task_body.py @@ -842,7 +842,13 @@ def render_heartbeat_prompt_markdown(payload: dict[str, Any]) -> str: else: style = "" runtime_profile = str(payload.get("runtime_profile") or "") - host_label = "Trae App" if runtime_profile == "trae_app" else "Codex App" + host_label = ( + "Trae App" + if runtime_profile == "trae_app" + else "Antigravity App" + if runtime_profile == "antigravity_app" + else "Codex App" + ) return f"""# Heartbeat Automation Prompt Copy this {style}task body into a {host_label} heartbeat automation. diff --git a/loopx/control_plane/quota/scheduler_ack.py b/loopx/control_plane/quota/scheduler_ack.py index e4eb795b81..eaef19f94c 100644 --- a/loopx/control_plane/quota/scheduler_ack.py +++ b/loopx/control_plane/quota/scheduler_ack.py @@ -32,7 +32,7 @@ def _scheduler_packet( if isinstance(before.get("scheduler_hint"), dict) else {} ) - if surface == "trae_app": + if surface in {"trae_app", "antigravity_app"}: packet_key = "app_automation" elif ( surface == CODEX_APP_SURFACE diff --git a/loopx/control_plane/scheduler/execution_context.py b/loopx/control_plane/scheduler/execution_context.py index 9da45866bf..149b448d50 100644 --- a/loopx/control_plane/scheduler/execution_context.py +++ b/loopx/control_plane/scheduler/execution_context.py @@ -101,6 +101,7 @@ class SchedulerRuntimeProfile(str, Enum): { SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT, SchedulerRuntimeProfile.TRAE_APP, + SchedulerRuntimeProfile.ANTIGRAVITY_APP, } ) @@ -133,8 +134,8 @@ class SchedulerRuntimeProfile(str, Enum): ), SchedulerRuntimeProfile.ANTIGRAVITY_APP: ( HostSurface.ANTIGRAVITY_APP, - SchedulerOwner.AGENT_CLI_LOOP, - ExecutionMode.INTERACTIVE, + SchedulerOwner.HOST_AUTOMATION, + ExecutionMode.HOSTED_AUTOMATION, ), SchedulerRuntimeProfile.CLAUDE_CODE_VISIBLE: ( HostSurface.CLAUDE_CODE, @@ -194,7 +195,12 @@ def codex_app_applicable(self) -> bool: @property def app_automation_applicable(self) -> bool: return ( - self.host_surface in {HostSurface.CODEX_APP, HostSurface.TRAE_APP} + self.host_surface + in { + HostSurface.CODEX_APP, + HostSurface.TRAE_APP, + HostSurface.ANTIGRAVITY_APP, + } and self.scheduler_owner is SchedulerOwner.HOST_AUTOMATION and self.execution_mode is ExecutionMode.HOSTED_AUTOMATION ) @@ -209,10 +215,10 @@ def projection(self) -> dict[str, Any]: "valid": True, } # Keep existing non-Trae projections byte-compatible. The neutral App - # field is needed to identify Trae's independent automation contract, - # but a second negative applicability flag adds no information for CLI - # and Goal runtimes. - if self.host_surface is HostSurface.TRAE_APP: + # field is needed to identify Trae/Antigravity's independent automation + # contract, but a second negative applicability flag adds no information + # for CLI and Goal runtimes. + if self.host_surface in {HostSurface.TRAE_APP, HostSurface.ANTIGRAVITY_APP}: projection["app_automation_applicability"] = ( "applicable" if self.app_automation_applicable else "not_applicable" ) @@ -246,7 +252,10 @@ def projection(self) -> dict[str, Any]: "valid": False, "errors": list(self.errors), } - if supplied.get("host_surface") == HostSurface.TRAE_APP.value: + if supplied.get("host_surface") in { + HostSurface.TRAE_APP.value, + HostSurface.ANTIGRAVITY_APP.value, + }: projection["app_automation_applicability"] = "blocked_invalid_context" else: projection["codex_app_applicability"] = "blocked_invalid_context" @@ -267,9 +276,12 @@ def _validation_errors(context: SchedulerExecutionContext) -> list[str]: HostSurface.CLAUDE_CODE, HostSurface.KUNLUNCODE, HostSurface.KIRO_CLI, - HostSurface.ANTIGRAVITY_APP, } - if context.host_surface in {HostSurface.CODEX_APP, HostSurface.TRAE_APP}: + if context.host_surface in { + HostSurface.CODEX_APP, + HostSurface.TRAE_APP, + HostSurface.ANTIGRAVITY_APP, + }: host_name = context.host_surface.value if context.scheduler_owner is not SchedulerOwner.HOST_AUTOMATION: errors.append(f"{host_name} requires scheduler_owner=host_automation") @@ -437,6 +449,8 @@ def _render_scheduler_runtime_profile_args( return " --codex-app" if profile is SchedulerRuntimeProfile.TRAE_APP: return " --trae_app" + if profile is SchedulerRuntimeProfile.ANTIGRAVITY_APP: + return " --antigravity_app" return f" --runtime-profile {shlex.quote(profile.value)}" diff --git a/loopx/control_plane/scheduler/heartbeat_followup_cli.ts b/loopx/control_plane/scheduler/heartbeat_followup_cli.ts index d51ef570e3..683ba0dd08 100644 --- a/loopx/control_plane/scheduler/heartbeat_followup_cli.ts +++ b/loopx/control_plane/scheduler/heartbeat_followup_cli.ts @@ -80,6 +80,7 @@ function parseArgs(argv: string[]): ParsedArgs { "-A", "--codex-app", "--trae_app", + "--antigravity_app", "--execute", "--dry-run", "--host-match-observed", @@ -248,6 +249,15 @@ function requestFromArgs(argv: string[]): { request: Record; fo ); } } + if (parsed.booleans.has("--antigravity_app")) { + matchingText(facts, "surface", "antigravity_app", "--antigravity_app"); + if (legacyCodexObservedRrule) { + throw new EffectRuntimeRequestError( + "Antigravity App cannot use the Codex RRULE compatibility alias", + "scheduler_host_facts_identity_mismatch", + ); + } + } if (parsed.booleans.has("-A") || parsed.booleans.has("--codex-app")) { matchingText(facts, "surface", "codex_app", "--codex-app"); } diff --git a/loopx/control_plane/scheduler/hint.ts b/loopx/control_plane/scheduler/hint.ts index 14296ba829..31a7958620 100644 --- a/loopx/control_plane/scheduler/hint.ts +++ b/loopx/control_plane/scheduler/hint.ts @@ -408,7 +408,7 @@ function invalidContextHint(context: JsonObject): JsonObject { unchanged_poll: {local_scheduler: "stop_until_context_repaired", codex_cli_tui: "stop_until_context_repaired", [SSH]: "stop_until_context_repaired", claude_code_loop: "stop_until_context_repaired", final_quota_replan_check_enabled: false, spend_policy: spend}, consistency_error: {source: "scheduler_execution_context", errors: context.errors}, - [stripPythonWhitespace(String(context.host_surface ?? "")) === "trae_app" ? "app_automation" : "codex_app"]: { + [["trae_app", "antigravity_app"].includes(stripPythonWhitespace(String(context.host_surface ?? ""))) ? "app_automation" : "codex_app"]: { applicability: "blocked_invalid_context", apply: "none", host_action: "none", ack_required: false}}; } diff --git a/loopx/control_plane/scheduler/hint_followup.ts b/loopx/control_plane/scheduler/hint_followup.ts index 279dd3aae1..0edf044235 100644 --- a/loopx/control_plane/scheduler/hint_followup.ts +++ b/loopx/control_plane/scheduler/hint_followup.ts @@ -18,8 +18,9 @@ function capabilities(raw: unknown): string[] { function followupArgs(request: JsonObject, command: string): unknown[] { const surface = text(request.surface === undefined ? "codex_app" : request.surface); const stateKey = text(request.state_key === undefined ? APP_STATE : request.state_key); + const surfaceFlag = surface === "trae_app" ? "--trae_app" : surface === "antigravity_app" ? "--antigravity_app" : "-A"; const args: unknown[] = ["quota", command, "--goal-id", text(request.goal_id), - "--agent-id", text(request.agent_id), surface === "trae_app" ? "--trae_app" : "-A"]; + "--agent-id", text(request.agent_id), surfaceFlag]; for (const capability of capabilities(request.available_capabilities)) args.push("--available-capability", capability); if (jsonObject(request.scheduler_host_facts)) { // The Python adapter performs only the persisted zlib/base64 wire codec. diff --git a/loopx/control_plane/turn_driver/codex_context.py b/loopx/control_plane/turn_driver/codex_context.py index 7da0b9791c..2f4a6f36fb 100644 --- a/loopx/control_plane/turn_driver/codex_context.py +++ b/loopx/control_plane/turn_driver/codex_context.py @@ -40,7 +40,8 @@ def context_delivery_for_arguments(args: Any, *, codex_machine_preference: bool fields = {key: getattr(args, key, None) for key in ("host_surface", "scheduler_owner", "execution_mode")} profiles = [value for value in (getattr(args, "runtime_profile", None), "codex_app_heartbeat" if getattr(args, "codex_app", False) else None, - "trae_app" if getattr(args, "trae_app", False) else None) if value is not None] + "trae_app" if getattr(args, "trae_app", False) else None, + "antigravity_app" if getattr(args, "antigravity_app", False) else None) if value is not None] setting = getattr(args, "context_delivery", None) setting = "off" if setting is None else setting if len(profiles) > 1: diff --git a/loopx/entrypoint.py b/loopx/entrypoint.py index 4018032ec7..9017b22404 100644 --- a/loopx/entrypoint.py +++ b/loopx/entrypoint.py @@ -105,6 +105,7 @@ def _native_scheduler_followup_argv(raw_argv: list[str]) -> list[str] | None: if positionals[1] == "scheduler-ack-current" and ( "--host-match-observed" in raw_argv and "--trae_app" not in raw_argv + and "--antigravity_app" not in raw_argv ): # The bound hint predates the host update. Read the App again; only the # typed commit owner may decide whether that observation proves an ACK. diff --git a/tests/control_plane/test_scheduler_execution_context.py b/tests/control_plane/test_scheduler_execution_context.py index 21063780c0..fc41341d91 100644 --- a/tests/control_plane/test_scheduler_execution_context.py +++ b/tests/control_plane/test_scheduler_execution_context.py @@ -42,6 +42,7 @@ ("ark_managed_agent", "goal_runtime", "interactive"), ("codex_app", "host_automation", "hosted_automation"), ("trae_app", "host_automation", "hosted_automation"), + ("antigravity_app", "host_automation", "hosted_automation"), ("codex_app_ssh", "agent_cli_loop", "interactive"), ("local_scheduler", "host_automation", "hosted_automation"), *{ @@ -79,6 +80,11 @@ ("trae_app", "host_automation", "hosted_automation"), " --trae_app", ), + ( + SchedulerRuntimeProfile.ANTIGRAVITY_APP, + ("antigravity_app", "host_automation", "hosted_automation"), + " --antigravity_app", + ), ( SchedulerRuntimeProfile.CODEX_APP_SSH_VISIBLE, ("codex_app_ssh", "agent_cli_loop", "interactive"), @@ -214,7 +220,11 @@ def test_scheduler_execution_context_decision_table( assert resolution.ok is (values in VALID_COMBINATIONS) context_projection = resolution.projection() - if host_surface is HostSurface.TRAE_APP: + neutral_app_surface = host_surface in { + HostSurface.TRAE_APP, + HostSurface.ANTIGRAVITY_APP, + } + if neutral_app_surface: assert context_projection["app_automation_applicability"] == ( "applicable" if resolution.ok else "blocked_invalid_context" ) @@ -229,17 +239,16 @@ def test_scheduler_execution_context_decision_table( if values not in VALID_COMBINATIONS: assert hint["execution_phase"]["disposition"] == "contract_error" assert hint["execution_phase"]["completed"] is False - packet_key = ( - "app_automation" if host_surface is HostSurface.TRAE_APP else "codex_app" - ) + packet_key = "app_automation" if neutral_app_surface else "codex_app" assert hint[packet_key]["applicability"] == "blocked_invalid_context" - assert ("app_automation" in hint) is (host_surface is HostSurface.TRAE_APP) - assert ("codex_app" in hint) is (host_surface is not HostSurface.TRAE_APP) + assert ("app_automation" in hint) is neutral_app_surface + assert ("codex_app" in hint) is (not neutral_app_surface) return app_expected = values in { ("codex_app", "host_automation", "hosted_automation"), ("trae_app", "host_automation", "hosted_automation"), + ("antigravity_app", "host_automation", "hosted_automation"), } packet_key = "app_automation" if app_expected else "codex_app" assert hint[packet_key]["applicability"] == ( @@ -247,7 +256,7 @@ def test_scheduler_execution_context_decision_table( ) assert ("stateful_backoff" in hint[packet_key]) is app_expected assert ("app_automation" in hint) is app_expected - assert ("codex_app" in hint) is (host_surface is not HostSurface.TRAE_APP) + assert ("codex_app" in hint) is (not neutral_app_surface) if app_expected: assert "execution_context" not in hint assert "execution_phase" not in hint @@ -1594,6 +1603,7 @@ def test_stale_unbound_guard_converges_after_profile_regeneration( app_profile = profile in { SchedulerRuntimeProfile.CODEX_APP_HEARTBEAT, SchedulerRuntimeProfile.TRAE_APP, + SchedulerRuntimeProfile.ANTIGRAVITY_APP, } packet_key = "app_automation" if app_profile else "codex_app" assert regenerated_hint[packet_key]["applicability"] == ( @@ -1601,7 +1611,11 @@ def test_stale_unbound_guard_converges_after_profile_regeneration( ) assert ("app_automation" in regenerated_hint) is app_profile assert ("codex_app" in regenerated_hint) is ( - profile is not SchedulerRuntimeProfile.TRAE_APP + profile + not in { + SchedulerRuntimeProfile.TRAE_APP, + SchedulerRuntimeProfile.ANTIGRAVITY_APP, + } ) diff --git a/tests/test_agy_host_surface.py b/tests/test_agy_host_surface.py index 133594086b..3a477f9486 100644 --- a/tests/test_agy_host_surface.py +++ b/tests/test_agy_host_surface.py @@ -316,33 +316,133 @@ def test_antigravity_app_first_class_host_surface_and_schedule_sidecar_contract( "runtime_profile": "antigravity_app" } - # Verify antigravity_app runtime profile maps to interactive AGENT_CLI_LOOP - # (so todo complete / refresh-state use turn-scoped visible-goal settlement - # instead of emitting Codex App -A / --ack-applied-rrule commands). + from loopx.agy_goal_mode import build_antigravity_backoff_schedule_action + from loopx.cli import build_parser + from loopx.cli_commands.quota_context import validate_quota_command_context_request + from loopx.control_plane.scheduler.state import ( + APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, + ) + from tests.control_plane.scheduler_arbitration_fixture import ( + build_native_scheduler_hint, + ) + + # Verify antigravity_app runtime profile maps to HOST_AUTOMATION + HOSTED_AUTOMATION + # with first-class app_automation stateful backoff (matching Codex App & Trae App), + # while emitting --antigravity_app (never -A / --codex-app). runtime_ctx = scheduler_execution_context_for_runtime_profile( SchedulerRuntimeProfile.ANTIGRAVITY_APP ) assert runtime_ctx.ok assert runtime_ctx.context is not None assert runtime_ctx.context.host_surface is HostSurface.ANTIGRAVITY_APP - assert runtime_ctx.context.scheduler_owner is SchedulerOwner.AGENT_CLI_LOOP - assert runtime_ctx.context.execution_mode is ExecutionMode.INTERACTIVE - assert runtime_ctx.context.app_automation_applicable is False + assert runtime_ctx.context.scheduler_owner is SchedulerOwner.HOST_AUTOMATION + assert runtime_ctx.context.execution_mode is ExecutionMode.HOSTED_AUTOMATION + assert runtime_ctx.context.app_automation_applicable is True + assert "codex_app_applicability" not in runtime_ctx.projection() assert ( - runtime_ctx.projection()["codex_app_applicability"] == "not_applicable" + runtime_ctx.projection()["app_automation_applicability"] == "applicable" ) - interactive_ctx = resolve_scheduler_execution_context( + # Invalid owner/mode combinations on antigravity_app are rejected cleanly + invalid_ctx = resolve_scheduler_execution_context( { "host_surface": HostSurface.ANTIGRAVITY_APP.value, "scheduler_owner": SchedulerOwner.AGENT_CLI_LOOP.value, "execution_mode": ExecutionMode.INTERACTIVE.value, } ) - assert interactive_ctx.ok + assert not invalid_ctx.ok + assert ( + invalid_ctx.projection()["app_automation_applicability"] + == "blocked_invalid_context" + ) + + # Verify build_scheduler_hint projects scheduler_hint.app_automation with + # --antigravity_app and --surface antigravity_app (and no -A / codex_app) + hint = build_native_scheduler_hint( + { + "goal_id": "surface-goal", + "agent_identity": {"agent_id": "probe-agent"}, + "should_run": True, + "effective_action": "normal_run", + "recommended_action": "Advance the public fixture.", + "heartbeat_recommendation": { + "recommended_mode": "normal_run", + "spend_policy": "spend only after validated writeback", + }, + "execution_obligation": { + "must_attempt_work": True, + "spend_policy": "spend only after validated writeback", + }, + "automation_liveness": { + "automation_action": "execute_bounded_work", + "spend_policy": "spend only after validated writeback", + }, + "interaction_contract": { + "schema_version": "loopx_interaction_contract_v0", + "mode": "normal_run", + "user_channel": {"action_required": False, "notify": "DONT_NOTIFY"}, + "agent_channel": { + "must_attempt": True, + "delivery_allowed": True, + "quiet_noop_allowed": False, + }, + "cli_channel": {"next_cli_actions": [], "spend_allowed_now": False}, + }, + }, + include_detail=True, + scheduler_execution_context=runtime_ctx, + ) + assert "codex_app" not in hint + app_hint = hint["app_automation"] + assert app_hint["host_surface"] == "antigravity_app" + assert ( + app_hint["stateful_backoff"]["state_key"] + == APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY + ) + assert app_hint["recommended_interval_minutes"] == 3 + ack_cli_args = app_hint["ack_hint"]["cli_args"] + assert "--antigravity_app" in ack_cli_args + assert "-A" not in ack_cli_args + assert "--codex-app" not in ack_cli_args + assert "--surface" in ack_cli_args + assert "antigravity_app" in ack_cli_args + + schedule_action = build_antigravity_backoff_schedule_action( + hint, + current_task_id="task-1750", + prompt="LoopX managed heartbeat bootstrap v2", + ) + assert schedule_action["action"] == "update_current_heartbeat_rrule" + assert schedule_action["cron_expression"] == "*/3 * * * *" + assert schedule_action["manage_task_kill"] == { + "Action": "kill", + "TaskId": "task-1750", + } + assert schedule_action["schedule_call"]["CronExpression"] == "*/3 * * * *" + assert "--antigravity_app" in schedule_action["ack_cli_args"] + + # Verify quota scheduler follow-up parser defaults to antigravity_app + app_automation state key + followup_args = build_parser().parse_args( + [ + "quota", + "scheduler-fail-current", + "--goal-id", + "surface-goal", + "--agent-id", + "probe-agent", + "--antigravity_app", + "--failed-rrule", + "FREQ=MINUTELY;INTERVAL=3", + "--execute", + ] + ) + validate_quota_command_context_request(followup_args) + assert followup_args.surface == "antigravity_app" + assert followup_args.state_key == APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY # Verify hooks_template includes --goal-id and --agent-id when bound, and sidecar uses - # scheduler_hint.local_scheduler.recommended_interval_minutes. + # scheduler_hint.app_automation.recommended_interval_minutes. assert "--goal-id" in mutation["hooks_template"]["loopx"]["args"] assert "surface-goal" in mutation["hooks_template"]["loopx"]["args"] assert "--agent-id" in mutation["hooks_template"]["loopx"]["args"] @@ -351,7 +451,13 @@ def test_antigravity_app_first_class_host_surface_and_schedule_sidecar_contract( mutation["sidecar_template"]["sidecar"]["loopx-heartbeat"][ "suspend_on_waiting_external" ]["interval_minutes_field"] - == "scheduler_hint.local_scheduler.recommended_interval_minutes" + == "scheduler_hint.app_automation.recommended_interval_minutes" + ) + assert ( + mutation["sidecar_template"]["sidecar"]["loopx-heartbeat"][ + "stateful_backoff" + ]["state_key"] + == APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY ) @@ -433,3 +539,78 @@ def test_antigravity_dual_skills_mcp_hooks_workflow_skills_and_doctor( c for c in doc["checks"] if c["id"] == "antigravity_host_surface_ready" ) assert agy_check["ok"] is True + + +def test_antigravity_app_real_cli_ack_and_stateful_backoff_identity( + tmp_path: Path, +) -> None: + """Verify Antigravity App (`--antigravity_app`) runs real CLI `quota should-run`, + `quota scheduler-ack-current`, and `quota scheduler-fail-current` with independent + `antigravity_app` state persistence and zero `-A` / `--codex-app` leakage.""" + from examples.control_plane.quota_plan_fixtures import ( + SCOPED_AGENT_ID, + write_cli_fixture, + ) + from loopx.control_plane.scheduler.state import ( + APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, + load_scheduler_state, + ) + from loopx.control_plane.testing.canary_harness import ( + run_json_cli, + run_json_cli_result, + ) + + goal_id = "needs-operator" + registry_path, runtime_root, project = write_cli_fixture( + tmp_path / "fixture", + scoped_agents=True, + ) + _returncode, first = run_json_cli_result( + "quota", + "should-run", + "--goal-id", + goal_id, + "--agent-id", + SCOPED_AGENT_ID, + "--antigravity_app", + registry_path=registry_path, + runtime_root=runtime_root, + cwd=project, + ) + assert "codex_app" not in first["scheduler_hint"] + app = first["scheduler_hint"]["app_automation"] + assert app["host_surface"] == "antigravity_app" + assert ( + app["stateful_backoff"]["state_key"] + == APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY + ) + assert "--antigravity_app" in app["ack_hint"]["cli_args"] + assert "-A" not in app["ack_hint"]["cli_args"] + + ack = run_json_cli( + *app["ack_hint"]["cli_args"], + registry_path=registry_path, + runtime_root=runtime_root, + cwd=project, + ) + assert ack["scheduler_state_mutated"] is True + assert ack["surface"] == "antigravity_app" + assert ack["state_key"] == APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY + persisted = load_scheduler_state( + runtime_root, + goal_id=goal_id, + agent_id=SCOPED_AGENT_ID, + surface="antigravity_app", + state_key=APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, + ) + assert persisted is not None + assert ( + load_scheduler_state( + runtime_root, + goal_id=goal_id, + agent_id=SCOPED_AGENT_ID, + surface="codex_app", + state_key=APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY, + ) + is None + ) From 7ffb086f69b07ec4b83b14669a98601003c604c0 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 09:57:25 +0530 Subject: [PATCH 20/34] fix(antigravity): support receipt-bound native scheduler ACK without codex rrule readback Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/control_plane/scheduler/heartbeat_followup_cli.ts | 2 ++ tests/test_agy_host_surface.py | 2 ++ 2 files changed, 4 insertions(+) diff --git a/loopx/control_plane/scheduler/heartbeat_followup_cli.ts b/loopx/control_plane/scheduler/heartbeat_followup_cli.ts index 683ba0dd08..d7c448debf 100644 --- a/loopx/control_plane/scheduler/heartbeat_followup_cli.ts +++ b/loopx/control_plane/scheduler/heartbeat_followup_cli.ts @@ -302,6 +302,8 @@ function requestFromArgs(argv: string[]): { request: Record; fo Object.hasOwn(values, "--codex-app-current-rrule") ) ? { observed_host_rrule: neutralObservedRrule || legacyCodexObservedRrule } + : expectedOperation === "ack" && facts.surface !== "codex_app" + ? { observed_host_rrule: values["--applied-rrule"] ?? facts.applied_rrule } : {}), operation: expectedOperation, runtime_root: parsed.runtime_root, diff --git a/tests/test_agy_host_surface.py b/tests/test_agy_host_surface.py index 3a477f9486..cbcc364531 100644 --- a/tests/test_agy_host_surface.py +++ b/tests/test_agy_host_surface.py @@ -573,6 +573,8 @@ def test_antigravity_app_real_cli_ack_and_stateful_backoff_identity( "--agent-id", SCOPED_AGENT_ID, "--antigravity_app", + "--turn-instance-id", + "2026-11-02T04:00:00Z", registry_path=registry_path, runtime_root=runtime_root, cwd=project, From 4a56166f956bc32e0e4f4e345484683b0ea7108f Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:06:20 +0530 Subject: [PATCH 21/34] fix(antigravity): remove antigravity_app from visible_goal_settlement profiles to match heartbeat spend source Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/control_plane/scheduler/execution_context.py | 1 - tests/test_agy_host_surface.py | 13 +++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/loopx/control_plane/scheduler/execution_context.py b/loopx/control_plane/scheduler/execution_context.py index 149b448d50..c2773729da 100644 --- a/loopx/control_plane/scheduler/execution_context.py +++ b/loopx/control_plane/scheduler/execution_context.py @@ -79,7 +79,6 @@ class SchedulerRuntimeProfile(str, Enum): *NATIVE_GOAL_RUNTIME_PROFILES, SchedulerRuntimeProfile.CLAUDE_CODE_VISIBLE, SchedulerRuntimeProfile.KIRO_CLI_VISIBLE, - SchedulerRuntimeProfile.ANTIGRAVITY_APP, } ) diff --git a/tests/test_agy_host_surface.py b/tests/test_agy_host_surface.py index cbcc364531..bd94178bb2 100644 --- a/tests/test_agy_host_surface.py +++ b/tests/test_agy_host_surface.py @@ -329,6 +329,19 @@ def test_antigravity_app_first_class_host_surface_and_schedule_sidecar_contract( # Verify antigravity_app runtime profile maps to HOST_AUTOMATION + HOSTED_AUTOMATION # with first-class app_automation stateful backoff (matching Codex App & Trae App), # while emitting --antigravity_app (never -A / --codex-app). + from loopx.control_plane.scheduler.execution_context import ( + APP_HEARTBEAT_SETTLEMENT_RUNTIME_PROFILES, + VISIBLE_GOAL_SETTLEMENT_RUNTIME_PROFILES, + ) + + assert ( + SchedulerRuntimeProfile.ANTIGRAVITY_APP + in APP_HEARTBEAT_SETTLEMENT_RUNTIME_PROFILES + ) + assert ( + SchedulerRuntimeProfile.ANTIGRAVITY_APP + not in VISIBLE_GOAL_SETTLEMENT_RUNTIME_PROFILES + ) runtime_ctx = scheduler_execution_context_for_runtime_profile( SchedulerRuntimeProfile.ANTIGRAVITY_APP ) From 423df7f6f767bbee3678fd7c7da53a8b239a48b9 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 12:52:24 +0530 Subject: [PATCH 22/34] fix(antigravity): emit native builtin schedule sidecar.json args and restart_policy Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/agy_goal_mode/__init__.py | 26 +++++++++++++++++++++++++- tests/test_agy_host_surface.py | 13 +++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/loopx/agy_goal_mode/__init__.py b/loopx/agy_goal_mode/__init__.py index 083a2a669c..b223b2a80c 100644 --- a/loopx/agy_goal_mode/__init__.py +++ b/loopx/agy_goal_mode/__init__.py @@ -149,15 +149,39 @@ def build_antigravity_sidecar_template( "一次操作不代表结束;通知与执行分开,等待按当前调度契约(遇到 waiting_external / CI 等待时使用原生 schedule(DurationSeconds=..., TimerCondition=\"any\") 挂起,不跑 shell sleep 空查)。" "入口异常先做权限内恢复;契约仍不可用时不执行任务或记账,并报告阻塞。" ) + interval_minutes = max(1, int(interval_seconds) // 60) + cron_expr = cron_for_interval_minutes(interval_minutes) return { "schema_version": "loopx_antigravity_sidecar_template_v0", "config_path": ANTIGRAVITY_SIDECAR_CONFIG_LABEL, + "sidecar_config_path": "~/.gemini/config/sidecars/loopx-antigravity-heartbeat/sidecar.json", + "sidecar_json": { + "builtin": "schedule", + "args": [ + f"CRON_TZ=UTC {cron_expr}", + "agentapi", + "new-conversation", + "--model=pro", + "--", + bootstrap_prompt, + ], + "restart_policy": "always", + "display_name": "LoopX Antigravity Heartbeat", + "description": ( + f"LoopX periodic quota-gated heartbeat for goal {goal_id}" + ), + "agent_permissions": { + "access_grants": [ + f"command({cli_bin})", + ] + }, + }, "sidecar": { "loopx-heartbeat": { "description": ( f"LoopX periodic quota-gated heartbeat for goal {goal_id}" ), - "cron": "*/3 * * * *", + "cron": cron_expr, "interval_seconds": int(interval_seconds), "gate_command": shlex.join(quota_args), "wake_when_json_field_true": "should_run", diff --git a/tests/test_agy_host_surface.py b/tests/test_agy_host_surface.py index bd94178bb2..c6a06a5cc2 100644 --- a/tests/test_agy_host_surface.py +++ b/tests/test_agy_host_surface.py @@ -296,6 +296,19 @@ def test_antigravity_app_first_class_host_surface_and_schedule_sidecar_contract( mutation["sidecar_template"]["schema_version"] == "loopx_antigravity_sidecar_template_v0" ) + assert ( + mutation["sidecar_template"]["sidecar_config_path"] + == "~/.gemini/config/sidecars/loopx-antigravity-heartbeat/sidecar.json" + ) + sidecar_json = mutation["sidecar_template"]["sidecar_json"] + assert sidecar_json["builtin"] == "schedule" + assert sidecar_json["restart_policy"] == "always" + assert sidecar_json["args"][1:5] == [ + "agentapi", + "new-conversation", + "--model=pro", + "--", + ] assert ( "antigravity_app" in mutation["sidecar_template"]["sidecar"]["loopx-heartbeat"]["gate_command"] From 884599e2f8b5a2df5d71a749cec62d6ff1bcbf86 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:19:41 +0530 Subject: [PATCH 23/34] docs(cli): describe the opt-in Antigravity Stop recovery hook Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/cli_commands/slash_commands.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/loopx/cli_commands/slash_commands.py b/loopx/cli_commands/slash_commands.py index ccd6021d23..b0fcada137 100644 --- a/loopx/cli_commands/slash_commands.py +++ b/loopx/cli_commands/slash_commands.py @@ -132,7 +132,7 @@ def register_slash_commands_command( parser.add_argument( "--with-agy-hooks", action="store_true", - help="Optionally register the LoopX continuation gate in Antigravity hooks.json.", + help="Optionally install a disabled Antigravity Stop recovery hook; bind a Goal, Agent and native conversation before enabling it.", ) parser.add_argument( "--opencode-home", From a7fc59e422b5018c6f74e398caf0251331fdb633 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:22:15 +0530 Subject: [PATCH 24/34] fix(antigravity): bind hooks continuation gate to project goal and mark unbound advisory Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/agy_goal_mode/__init__.py | 45 +++++----- loopx/cli_commands/slash_commands.py | 3 + loopx/slash_command_install.py | 31 ++++++- tests/test_agy_host_surface.py | 122 +++++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 22 deletions(-) diff --git a/loopx/agy_goal_mode/__init__.py b/loopx/agy_goal_mode/__init__.py index b223b2a80c..3b6737bfab 100644 --- a/loopx/agy_goal_mode/__init__.py +++ b/loopx/agy_goal_mode/__init__.py @@ -319,15 +319,16 @@ def build_antigravity_hooks_template( ) -> dict[str, Any]: """Build a public-safe ``hooks.json`` continuation gate entry for Antigravity 2.0. - When bound to a concrete ``goal_id``, invokes ``quota should-run --goal-id ...``; - when installed globally without a goal binding, invokes ``quota plan --runtime-profile antigravity_app`` - so the command succeeds without ``--goal-id``. + When bound to a concrete ``goal_id``, invokes ``quota should-run --goal-id ...`` + and requires ``should_run=true``; + when installed without a goal binding, marks the gate as explicitly advisory + and unavailable rather than substituting an unbounded health check. """ - args: list[str] = ["--format", "json"] - if runtime_root: - args.extend(["--runtime-root", runtime_root]) bound_goal = bool(goal_id and goal_id != "") if bound_goal: + args: list[str] = ["--format", "json"] + if runtime_root: + args.extend(["--runtime-root", runtime_root]) args.extend( [ "quota", @@ -340,25 +341,27 @@ def build_antigravity_hooks_template( ) if agent_id: args.extend(["--agent-id", str(agent_id)]) - require_field = "should_run" - else: - args.extend( - [ - "quota", - "plan", - "--runtime-profile", - "antigravity_app", - ] - ) - require_field = "ok" + return { + "loopx": { + "schema_version": "loopx_antigravity_hooks_gate_v0", + "event": "before_continuation", + "status": "bound", + "goal_id": str(goal_id), + "agent_id": str(agent_id) if agent_id else None, + "command": cli_bin, + "args": args, + "require_json_field_true": "should_run", + "on_waiting_external": "suspend_via_schedule_timer", + } + } return { "loopx": { "schema_version": "loopx_antigravity_hooks_gate_v0", "event": "before_continuation", - "command": cli_bin, - "args": args, - "require_json_field_true": require_field, - "on_waiting_external": "suspend_via_schedule_timer", + "status": "unavailable", + "mode": "advisory", + "advisory": True, + "reason": "continuation gate unavailable without a bound goal_id", } } diff --git a/loopx/cli_commands/slash_commands.py b/loopx/cli_commands/slash_commands.py index ccd6021d23..f79b5daa54 100644 --- a/loopx/cli_commands/slash_commands.py +++ b/loopx/cli_commands/slash_commands.py @@ -191,6 +191,9 @@ def handle_slash_commands_command( agy_home=getattr(args, "agy_home", None), pi_project=args.pi_project, pi_scope=args.pi_scope, + goal_id=getattr(args, "goal_id", None), + agent_id=getattr(args, "agent_id", None), + runtime_root=getattr(args, "runtime_root", None), ) print_payload(payload, output_format(args), render_slash_command_install_markdown) return 0 if payload.get("ok") is True else 1 diff --git a/loopx/slash_command_install.py b/loopx/slash_command_install.py index feb04fce5f..4b12c746dd 100644 --- a/loopx/slash_command_install.py +++ b/loopx/slash_command_install.py @@ -917,6 +917,9 @@ def _merge_managed_hooks_json( cli_bin: str, uninstall: bool, execute: bool, + goal_id: str | None = None, + agent_id: str | None = None, + runtime_root: str | None = None, ) -> str: """Add or remove the ``loopx`` continuation gate in Antigravity ``hooks.json``.""" marker_path = config_path.parent / ANTIGRAVITY_HOOKS_MARKER_NAME @@ -963,7 +966,12 @@ def _merge_managed_hooks_json( if existing is not None and not loopx_owned: return "skipped_user_owned_hooks_entry" - entry = build_antigravity_hooks_template(cli_bin=cli_bin)[MANAGED_MCP_KEY] + entry = build_antigravity_hooks_template( + cli_bin=cli_bin, + goal_id=goal_id, + agent_id=agent_id, + runtime_root=runtime_root, + )[MANAGED_MCP_KEY] if not execute: return "unchanged" if existing == entry else "would_write" if existing == entry: @@ -1003,6 +1011,9 @@ def install_slash_commands( pi_project: str | None = None, pi_scope: str = "project", pi_user_home: str | None = None, + goal_id: str | None = None, + agent_id: str | None = None, + runtime_root: str | None = None, ) -> dict[str, Any]: facade_specs = _command_prompt_specs(cli_bin=cli_bin, include_legacy_aliases=True) canonical_specs = [spec for spec in facade_specs if "alias_for" not in spec] @@ -1293,12 +1304,27 @@ def install_slash_commands( ) if ("antigravity-app" in effective_surfaces or "agy" in effective_surfaces) and with_agy_hooks: + effective_goal_id = goal_id + effective_agent_id = agent_id + if not effective_goal_id: + try: + from .goal_mode_context import resolve_goal_context + + resolved_ctx = resolve_goal_context(pi_project_root) + if resolved_ctx and resolved_ctx.get("goal_id"): + effective_goal_id = resolved_ctx["goal_id"] + effective_agent_id = effective_agent_id or resolved_ctx.get("agent_id") + except Exception: + pass agy_hooks_path = agy_config_root / ANTIGRAVITY_HOOKS_CONFIG_FILENAME hooks_status = _merge_managed_hooks_json( config_path=agy_hooks_path, cli_bin=cli_bin, uninstall=uninstall, execute=execute, + goal_id=effective_goal_id, + agent_id=effective_agent_id, + runtime_root=runtime_root, ) installed.append( { @@ -1308,6 +1334,9 @@ def install_slash_commands( "command": "loopx", "path": str(agy_hooks_path), "status": hooks_status, + "goal_id": effective_goal_id, + "agent_id": effective_agent_id, + "gate_status": "bound" if effective_goal_id else "unavailable", "invoke_as": [], } ) diff --git a/tests/test_agy_host_surface.py b/tests/test_agy_host_surface.py index c6a06a5cc2..a83b1564d6 100644 --- a/tests/test_agy_host_surface.py +++ b/tests/test_agy_host_surface.py @@ -642,3 +642,125 @@ def test_antigravity_app_real_cli_ack_and_stateful_backoff_identity( ) is None ) + + +def test_antigravity_hooks_continuation_gate_ready_user_gate_quota0_and_unbound_advisory( + tmp_path: Path, +) -> None: + """Verify落盘 hooks.json continuation gate executes actual quota should-run argv + across ready, user_gate, and quota0 conditions, rejecting continuation when blocked, + and remaining explicitly unavailable/advisory when installed without a goal.""" + import json + from examples.control_plane.quota_plan_fixtures import ( + SCOPED_AGENT_ID, + write_cli_fixture, + ) + from loopx.control_plane.testing.canary_harness import run_json_cli + + # 1. Write an isolated CLI fixture with multiple canonical goals + registry_path, runtime_root, project = write_cli_fixture( + tmp_path / "fixture", + scoped_agents=True, + ) + + # 2. Modify throttled-half in the fixture to have allowed_slots=0 + reg_data = json.loads(registry_path.read_text(encoding="utf-8")) + for g in reg_data["goals"]: + if g["id"] == "throttled-half": + g["quota"]["allowed_slots"] = 0 + break + registry_path.write_text(json.dumps(reg_data, indent=2), encoding="utf-8") + + # 3. Test bound hooks installation for ready condition (full-speed goal) + custom_agy_home_ready = tmp_path / "agy-ready" + install_slash_commands( + execute=True, + surfaces=["antigravity-app"], + agy_home=str(custom_agy_home_ready), + with_agy_hooks=True, + goal_id="full-speed", + agent_id=SCOPED_AGENT_ID, + runtime_root=str(runtime_root), + ) + hooks_file = custom_agy_home_ready / "hooks.json" + assert hooks_file.is_file() + ready_entry = json.loads(hooks_file.read_text(encoding="utf-8"))["hooks"]["loopx"] + assert ready_entry["status"] == "bound" + assert ready_entry["require_json_field_true"] == "should_run" + assert "--goal-id" in ready_entry["args"] + assert "full-speed" in ready_entry["args"] + + # Execute the落盘 argv directly: runnable goal must evaluate should_run=True (continuation permitted) + argv = list(ready_entry["args"]) + ["--turn-instance-id", "2026-11-02T05:00:00Z"] + res_ready = run_json_cli( + *argv, + registry_path=registry_path, + runtime_root=runtime_root, + cwd=project, + ) + assert res_ready.get("should_run") is True + + # 4. Test bound hooks installation for user_gate condition (needs-operator goal) + custom_agy_home_gate = tmp_path / "agy-gate" + install_slash_commands( + execute=True, + surfaces=["antigravity-app"], + agy_home=str(custom_agy_home_gate), + with_agy_hooks=True, + goal_id="needs-operator", + agent_id=SCOPED_AGENT_ID, + runtime_root=str(runtime_root), + ) + gate_entry = json.loads((custom_agy_home_gate / "hooks.json").read_text(encoding="utf-8"))["hooks"]["loopx"] + argv_gate = list(gate_entry["args"]) + ["--turn-instance-id", "2026-11-02T05:00:01Z"] + res_gate = run_json_cli( + *argv_gate, + registry_path=registry_path, + runtime_root=runtime_root, + cwd=project, + ) + # User gate must refuse continuation (should_run=False) + assert res_gate.get("should_run") is False + assert res_gate.get("decision") in {"operator_gate", "skip", "unsettled_host_turn_recovery"} + + # 5. Test bound hooks installation for quota0 condition (allowed_slots=0) + custom_agy_home_q0 = tmp_path / "agy-q0" + install_slash_commands( + execute=True, + surfaces=["antigravity-app"], + agy_home=str(custom_agy_home_q0), + with_agy_hooks=True, + goal_id="throttled-half", + agent_id=SCOPED_AGENT_ID, + runtime_root=str(runtime_root), + ) + q0_entry = json.loads((custom_agy_home_q0 / "hooks.json").read_text(encoding="utf-8"))["hooks"]["loopx"] + argv_q0 = list(q0_entry["args"]) + ["--turn-instance-id", "2026-11-02T05:00:02Z"] + res_q0 = run_json_cli( + *argv_q0, + registry_path=registry_path, + runtime_root=runtime_root, + cwd=project, + ) + # Quota 0 must refuse continuation (should_run=False) + assert res_q0.get("should_run") is False + assert res_q0.get("decision") in {"throttled_skip", "skip"} + + # 6. Test unbound hooks installation without project/goal context + custom_agy_home_unbound = tmp_path / "agy-unbound" + empty_dir = tmp_path / "empty-dir" + empty_dir.mkdir(parents=True, exist_ok=True) + install_slash_commands( + execute=True, + surfaces=["antigravity-app"], + agy_home=str(custom_agy_home_unbound), + with_agy_hooks=True, + pi_project=str(empty_dir), + ) + unbound_file = custom_agy_home_unbound / "hooks.json" + assert unbound_file.is_file() + unbound_entry = json.loads(unbound_file.read_text(encoding="utf-8"))["hooks"]["loopx"] + assert unbound_entry["status"] == "unavailable" + assert unbound_entry["mode"] == "advisory" + assert unbound_entry["advisory"] is True + assert unbound_entry.get("require_json_field_true") is None From 95c186339c3a64cc90867663af42558e57f92755 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:31:43 +0530 Subject: [PATCH 25/34] fix(antigravity): fence Stop recovery to the original native input Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/agy_goal_mode/__init__.py | 2 +- loopx/agy_goal_mode/host_runtime.py | 73 ++++++++++++++++++- .../references/heartbeat-hosts.md | 5 +- tests/test_antigravity_host_runtime.py | 64 ++++++++++++++++ 4 files changed, 139 insertions(+), 5 deletions(-) diff --git a/loopx/agy_goal_mode/__init__.py b/loopx/agy_goal_mode/__init__.py index 466b2ab82d..a791ac9153 100644 --- a/loopx/agy_goal_mode/__init__.py +++ b/loopx/agy_goal_mode/__init__.py @@ -369,7 +369,7 @@ def build_antigravity_hooks_template( "enabled": bool( conversation_id and agent_id and goal_id and goal_id != "" ), - "Stop": [{"type": "command", "command": shlex.join(args), "timeout": 20}], + "Stop": [{"type": "command", "command": shlex.join(args), "timeout": 30}], } } diff --git a/loopx/agy_goal_mode/host_runtime.py b/loopx/agy_goal_mode/host_runtime.py index f1700f9105..1e34082547 100644 --- a/loopx/agy_goal_mode/host_runtime.py +++ b/loopx/agy_goal_mode/host_runtime.py @@ -66,7 +66,7 @@ def _admitted(args: argparse.Namespace) -> bool: _core_args(args, ["quota", "should-run"]), capture_output=True, text=True, - timeout=15, + timeout=10, check=True, env={key: value for key, value in os.environ.items() if key != "PYTHONPATH"}, ) @@ -114,6 +114,70 @@ def _reserve_retry(state: dict[str, Any], input_key: str) -> bool: return True +def _native_input(args: argparse.Namespace, text: str) -> tuple[str, str, int] | None: + """Freeze the native input identity, including repeated identical messages.""" + from ..capabilities.native_chat.antigravity_adapter import ( + _local_url, + _observed_turn_steps, + _rpc_post, + discover_antigravity_language_server, + ) + + address = os.environ.get("ANTIGRAVITY_LS_ADDRESS") + token = os.environ.get("ANTIGRAVITY_CSRF_TOKEN") + if not address and not token: + endpoint = discover_antigravity_language_server() + if endpoint is None: + return None + address, token = endpoint["url"], endpoint["csrf_token"] + elif not address or not token: + return None + url = _local_url(address if "://" in address else "http://" + address) + payload = {"cascadeId": args.conversation_id} + before = _rpc_post(url, token, "GetCascadeTrajectory", payload, timeout=2) + total = before.get("numTotalSteps") + status = before.get("status") + if ( + type(total) is not int + or total < 1 + or status not in {"CASCADE_RUN_STATUS_IDLE", "CASCADE_RUN_STATUS_RUNNING"} + or (before.get("trajectory") or {}).get("cascadeId") != args.conversation_id + ): + return None + raw = _rpc_post( + url, + token, + "GetCascadeTrajectorySteps", + {**payload, "startIndex": 0, "endIndex": total}, + timeout=2, + ).get("steps") + indexed, covered = _observed_turn_steps(raw, 0, total) + inputs = [ + (index, step) + for index, step in indexed + if step.get("type") == "CORTEX_STEP_TYPE_USER_INPUT" + ] + after = _rpc_post(url, token, "GetCascadeTrajectory", payload, timeout=2) + if ( + not inputs + or covered != total + or after.get("status") != status + or after.get("numTotalSteps") != total + or (after.get("trajectory") or {}).get("cascadeId") != args.conversation_id + ): + return None + index, user = inputs[-1] + created = (user.get("metadata") or {}).get("createdAt") + if ( + not isinstance(created, str) + or not created + or (user.get("userInput") or {}).get("userResponse") != text + ): + return None + anchor = json.dumps([args.conversation_id, index, created, text]) + return hashlib.sha256(anchor.encode()).hexdigest(), status, total + + def stop_hook(args: argparse.Namespace, event: dict[str, Any]) -> dict[str, Any]: if ( event.get("conversationId") != args.conversation_id @@ -123,10 +187,13 @@ def stop_hook(args: argparse.Namespace, event: dict[str, Any]) -> dict[str, Any] ): return {} text = event.get("lastUserInput") - if not isinstance(text, str) or not text or not _admitted(args): + if not isinstance(text, str) or not text: + return {} + native = _native_input(args, text) + if native is None or not _admitted(args) or _native_input(args, text) != native: return {} with _state(args) as state: - if not _reserve_retry(state, hashlib.sha256(text.encode()).hexdigest()): + if not _reserve_retry(state, native[0]): return {} # Reservation is durable before returning the native continuation decision. return {"decision": "continue", "reason": bootstrap_prompt(args)} diff --git a/skills/loopx-project/references/heartbeat-hosts.md b/skills/loopx-project/references/heartbeat-hosts.md index ba292a4e99..ab678a601b 100644 --- a/skills/loopx-project/references/heartbeat-hosts.md +++ b/skills/loopx-project/references/heartbeat-hosts.md @@ -33,7 +33,10 @@ conversation when Core later selects a quiet no-op; no lower model cost is claim handlers, not `hooks.loopx` or a `before_continuation` event. Global `--with-agy-hooks` installation is disabled until a concrete Goal, Agent and native conversation are bound. Bound recovery handles only the native `ERROR` -event with a recognized Google model transport failure and fresh admission; +event with a recognized Google model transport failure and fresh admission. +The latest native input must match before and after admission; its index and +creation time distinguish repeated identical messages. Missing native readback +does not admit a continuation; it reserves at most two additional continuations per original input, at least 60 seconds apart. Normal completion, user cancellation, permission/auth/quota failures and unrelated conversations remain untouched. The hook uses its diff --git a/tests/test_antigravity_host_runtime.py b/tests/test_antigravity_host_runtime.py index 4989416db6..57db614356 100644 --- a/tests/test_antigravity_host_runtime.py +++ b/tests/test_antigravity_host_runtime.py @@ -74,6 +74,7 @@ def test_native_retry_requires_fresh_admission_and_is_bounded_per_input( tmp_path, monkeypatch ): args = bound(tmp_path) + monkeypatch.setattr(runtime, "_native_input", lambda _, text: (text, "RUNNING", 5)) monkeypatch.setattr(runtime, "_admitted", lambda _: False) assert runtime.stop_hook(args, event()) == {} monkeypatch.setattr(runtime, "_admitted", lambda _: True) @@ -94,6 +95,69 @@ def test_native_retry_requires_fresh_admission_and_is_bounded_per_input( ) +def test_repeated_identical_native_inputs_get_distinct_retry_budgets( + tmp_path, monkeypatch +): + monkeypatch.setattr(runtime, "_admitted", lambda _: True) + native = ["first-input", "RUNNING", 5] + monkeypatch.setattr(runtime, "_native_input", lambda *_: tuple(native)) + clock = [1000] + monkeypatch.setattr(runtime.time, "time", lambda: clock[0]) + for _ in range(2): + assert runtime.stop_hook(bound(tmp_path), event())["decision"] == "continue" + clock[0] += 61 + assert runtime.stop_hook(bound(tmp_path), event()) == {} + native[:] = ["second-identical-input", "RUNNING", 8] + assert runtime.stop_hook(bound(tmp_path), event())["decision"] == "continue" + + +def test_native_input_change_or_missing_evidence_cannot_continue(tmp_path, monkeypatch): + monkeypatch.setattr(runtime, "_admitted", lambda _: True) + monkeypatch.setattr(runtime, "_native_input", lambda *_: None) + assert runtime.stop_hook(bound(tmp_path), event()) == {} + snapshots = iter([("input", "RUNNING", 5), ("input", "IDLE", 5)]) + monkeypatch.setattr(runtime, "_native_input", lambda *_: next(snapshots)) + assert runtime.stop_hook(bound(tmp_path), event()) == {} + + +def test_native_input_anchor_survives_retry_steps_and_distinguishes_identical_inputs( + tmp_path, monkeypatch +): + from loopx.capabilities.native_chat import antigravity_adapter as provider + + monkeypatch.setenv("ANTIGRAVITY_LS_ADDRESS", "127.0.0.1:1234") + monkeypatch.setenv("ANTIGRAVITY_CSRF_TOKEN", "synthetic-token") + text = "Continue" + user = { + "type": "CORTEX_STEP_TYPE_USER_INPUT", + "status": "CORTEX_STEP_STATUS_DONE", + "metadata": {"createdAt": "2026-01-01T00:00:00Z"}, + "userInput": {"userResponse": text}, + } + steps = [user] + + def read(url, token, method, payload, **_): + assert payload["cascadeId"] == "native-chat" + if method == "GetCascadeTrajectorySteps": + return {"steps": steps} + return { + "trajectory": {"cascadeId": "native-chat"}, + "status": "CASCADE_RUN_STATUS_RUNNING", + "numTotalSteps": len(steps), + } + + monkeypatch.setattr(provider, "_rpc_post", read) + first = runtime._native_input(bound(tmp_path), text) + assert first is not None + steps.append( + {"type": "CORTEX_STEP_TYPE_ERROR_MESSAGE", "status": "CORTEX_STEP_STATUS_DONE"} + ) + assert runtime._native_input(bound(tmp_path), text)[0] == first[0] + steps.append({**user, "metadata": {"createdAt": "2026-01-01T00:00:05Z"}}) + assert runtime._native_input(bound(tmp_path), text)[0] != first[0] + assert runtime._native_input(bound(tmp_path), "A changed message") is None + + def test_generated_native_configs_execute_the_stdio_hook_from_another_directory( tmp_path, monkeypatch ): From 09156b52c9a1898a7e2dcd3c91b2425732ecab5d Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 13:47:47 +0530 Subject: [PATCH 26/34] fix(antigravity): isolate hook imports and qualify real Core admission Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/agy_goal_mode/__init__.py | 1 + tests/test_antigravity_host_runtime.py | 37 +++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/loopx/agy_goal_mode/__init__.py b/loopx/agy_goal_mode/__init__.py index a791ac9153..9b5a41a1f6 100644 --- a/loopx/agy_goal_mode/__init__.py +++ b/loopx/agy_goal_mode/__init__.py @@ -233,6 +233,7 @@ def _antigravity_provider_args( args = [ sys.executable, "-E", + "-P", "-m", "loopx.agy_goal_mode.host_runtime", operation, diff --git a/tests/test_antigravity_host_runtime.py b/tests/test_antigravity_host_runtime.py index 57db614356..fe79173ac0 100644 --- a/tests/test_antigravity_host_runtime.py +++ b/tests/test_antigravity_host_runtime.py @@ -1,9 +1,11 @@ -"""Native host schemas, bounded recovery and once-only wake delivery.""" +"""Native host schemas and bounded recovery of the original input.""" from argparse import Namespace import json +from pathlib import Path import shlex import subprocess +import sys import pytest @@ -168,6 +170,11 @@ def test_generated_native_configs_execute_the_stdio_hook_from_another_directory( 'raise RuntimeError("wrong installed release")' ) monkeypatch.setenv("PYTHONPATH", str(foreign.parent)) + local_package = tmp_path / "loopx" + local_package.mkdir() + (local_package / "__init__.py").write_text( + 'raise RuntimeError("wrong working directory")' + ) assert build_antigravity_hooks_template()["loopx"]["enabled"] is False assert "sidecar_json" in build_antigravity_sidecar_template() kwargs = dict(goal_id="work", agent_id="worker", conversation_id="native-chat") @@ -193,6 +200,34 @@ def test_generated_native_configs_execute_the_stdio_hook_from_another_directory( assert json.loads(result.stdout) == {} +def test_native_hook_admission_uses_real_core_ready_user_gate_and_zero_quota(tmp_path): + from examples.control_plane.quota_plan_fixtures import ( + SCOPED_AGENT_ID, + write_cli_fixture, + ) + + registry, runtime_root, _ = write_cli_fixture(tmp_path / "core", scoped_agents=True) + data = json.loads(registry.read_text()) + for goal in data["goals"]: + if goal["id"] == "throttled-half": + goal["quota"]["allowed_slots"] = 0 + registry.write_text(json.dumps(data)) + args = bound(tmp_path) + args.cli_bin = str(Path(sys.executable).with_name("loopx")) + args.registry, args.runtime_root, args.agent_id = ( + str(registry), + str(runtime_root), + SCOPED_AGENT_ID, + ) + for goal, expected in ( + ("full-speed", True), + ("needs-operator", False), + ("throttled-half", False), + ): + args.goal_id = goal + assert runtime._admitted(args) is expected + + def test_hooks_migrate_only_an_unchanged_owned_legacy_entry(tmp_path): path = tmp_path / "hooks.json" legacy = {"event": "before_continuation", "command": "loopx"} From 18dd988a4d45e17939b4cb8bcdbb9cd591593ef3 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:08:06 +0530 Subject: [PATCH 27/34] fix(antigravity): keep public activation hooks_template path-safe and remove unused backoff translator Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/agy_goal_mode/__init__.py | 96 ++++----------------------------- tests/test_agy_host_surface.py | 17 ++---- 2 files changed, 14 insertions(+), 99 deletions(-) diff --git a/loopx/agy_goal_mode/__init__.py b/loopx/agy_goal_mode/__init__.py index 9b5a41a1f6..bb2c73536d 100644 --- a/loopx/agy_goal_mode/__init__.py +++ b/loopx/agy_goal_mode/__init__.py @@ -229,16 +229,21 @@ def _antigravity_provider_args( conversation_id: str | None, runtime_root: str | None, registry: str | None = None, + resolve_local_executable: bool = True, ) -> list[str]: + python_bin = sys.executable if resolve_local_executable else "python3" + resolved_cli_bin = ( + (shutil.which(cli_bin) or cli_bin) if resolve_local_executable else cli_bin + ) args = [ - sys.executable, + python_bin, "-E", "-P", "-m", "loopx.agy_goal_mode.host_runtime", operation, "--cli-bin", - shutil.which(cli_bin) or cli_bin, + resolved_cli_bin, "--goal-id", goal_id or "", "--agent-id", @@ -261,90 +266,6 @@ def cron_for_interval_minutes(interval_minutes: int) -> str: return f"*/{minutes} * * * *" -def build_antigravity_backoff_schedule_action( - scheduler_hint: dict[str, Any], - *, - current_task_id: str | None = None, - prompt: str = "", -) -> dict[str, Any]: - """Translate ``scheduler_hint.app_automation`` stateful backoff into Antigravity ``schedule`` / ``manage_task`` actions.""" - app = ( - scheduler_hint.get("app_automation") - if isinstance(scheduler_hint.get("app_automation"), dict) - else {} - ) - backoff = ( - app.get("stateful_backoff") - if isinstance(app.get("stateful_backoff"), dict) - else {} - ) - host_action = str(app.get("host_action") or "none") - if host_action == "pause_or_delete_current_heartbeat": - return { - "action": "pause_or_delete_current_heartbeat", - "apply_needed": bool(app.get("host_action_required", True)), - "ack_needed": False, - "manage_task_kill": ( - {"Action": "kill", "TaskId": current_task_id} - if current_task_id - else None - ), - "schedule_call": None, - "ack_cli_args": None, - "failure_cli_args": None, - } - apply_needed = bool(backoff.get("apply_needed")) - ack_needed = bool(backoff.get("ack_needed")) - interval = int( - app.get("recommended_interval_minutes") - or backoff.get("current_interval_minutes") - or 3 - ) - cron_expr = cron_for_interval_minutes(interval) - ack_hint = app.get("ack_hint") if isinstance(app.get("ack_hint"), dict) else {} - failure_hint = ( - app.get("failure_hint") if isinstance(app.get("failure_hint"), dict) else {} - ) - return { - "action": ( - "update_current_heartbeat_rrule" - if apply_needed - else "ack_current_heartbeat_rrule" - if ack_needed - else "noop" - ), - "apply_needed": apply_needed, - "ack_needed": ack_needed, - "recommended_interval_minutes": interval, - "recommended_rrule": app.get("recommended_rrule"), - "cron_expression": cron_expr, - "manage_task_kill": ( - {"Action": "kill", "TaskId": current_task_id} - if apply_needed and current_task_id - else None - ), - "schedule_call": ( - { - "CronExpression": cron_expr, - "IsDaemon": True, - "Prompt": prompt, - } - if apply_needed - else None - ), - "ack_cli_args": ( - list(ack_hint.get("cli_args", [])) - if isinstance(ack_hint.get("cli_args"), list) - else None - ), - "failure_cli_args": ( - list(failure_hint.get("cli_args", [])) - if isinstance(failure_hint.get("cli_args"), list) - else None - ), - } - - def build_antigravity_hooks_template( *, cli_bin: str = "loopx", @@ -353,6 +274,7 @@ def build_antigravity_hooks_template( runtime_root: str | None = None, conversation_id: str | None = None, registry: str | None = None, + resolve_local_executable: bool = True, ) -> dict[str, Any]: """Native Stop hook; installation alone never binds an arbitrary chat.""" conversation_id = conversation_id or os.environ.get("ANTIGRAVITY_CONVERSATION_ID") @@ -364,6 +286,7 @@ def build_antigravity_hooks_template( conversation_id=conversation_id, runtime_root=runtime_root, registry=registry, + resolve_local_executable=resolve_local_executable, ) return { "loopx": { @@ -455,6 +378,7 @@ def antigravity_app_activation_extras( goal_id=goal_id, agent_id=agent_id, runtime_root=runtime_root, + resolve_local_executable=False, ) wake_prompt = sidecar_template["sidecar"]["loopx-heartbeat"]["bootstrap_prompt"] return { diff --git a/tests/test_agy_host_surface.py b/tests/test_agy_host_surface.py index 09505b167e..54599097c7 100644 --- a/tests/test_agy_host_surface.py +++ b/tests/test_agy_host_surface.py @@ -327,7 +327,7 @@ def test_antigravity_app_first_class_host_surface_and_schedule_sidecar_contract( "runtime_profile": "antigravity_app" } - from loopx.agy_goal_mode import build_antigravity_backoff_schedule_action + from loopx.agy_goal_mode import cron_for_interval_minutes from loopx.cli import build_parser from loopx.cli_commands.quota_context import validate_quota_command_context_request from loopx.control_plane.scheduler.state import ( @@ -432,19 +432,10 @@ def test_antigravity_app_first_class_host_surface_and_schedule_sidecar_contract( assert "--surface" in ack_cli_args assert "antigravity_app" in ack_cli_args - schedule_action = build_antigravity_backoff_schedule_action( - hint, - current_task_id="task-1750", - prompt="LoopX managed heartbeat bootstrap v2", + assert ( + cron_for_interval_minutes(app_hint["recommended_interval_minutes"]) + == "*/3 * * * *" ) - assert schedule_action["action"] == "update_current_heartbeat_rrule" - assert schedule_action["cron_expression"] == "*/3 * * * *" - assert schedule_action["manage_task_kill"] == { - "Action": "kill", - "TaskId": "task-1750", - } - assert schedule_action["schedule_call"]["CronExpression"] == "*/3 * * * *" - assert "--antigravity_app" in schedule_action["ack_cli_args"] # Verify quota scheduler follow-up parser defaults to antigravity_app + app_automation state key followup_args = build_parser().parse_args( From d195c6bf65c5a82a355f026213c9e9252c1c2ba7 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:23:31 +0530 Subject: [PATCH 28/34] fix(antigravity): integrate session-liveness sidecar backoff and generic session binding Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/agy_goal_mode/__init__.py | 136 +++- loopx/agy_goal_mode/host_runtime.py | 716 +++++++++++++++++- .../native_chat/antigravity_adapter.py | 8 +- loopx/cli_commands/_host_thread.py | 28 +- loopx/cli_commands/registry_admin.py | 40 + loopx/host_loop_activation.py | 5 + loopx/hosts/skill_facade.py | 2 + loopx/slash_command_install.py | 16 +- tests/test_agy_host_surface.py | 17 +- tests/test_antigravity_host_runtime.py | 270 ++++++- 10 files changed, 1211 insertions(+), 27 deletions(-) diff --git a/loopx/agy_goal_mode/__init__.py b/loopx/agy_goal_mode/__init__.py index bb2c73536d..d3f7ea6748 100644 --- a/loopx/agy_goal_mode/__init__.py +++ b/loopx/agy_goal_mode/__init__.py @@ -110,11 +110,15 @@ def build_antigravity_sidecar_template( *, goal_id: str = "", agent_id: str | None = None, + conversation_id: str | None = None, runtime_root: str | None = None, + registry: str | None = None, cli_bin: str = "loopx", interval_seconds: int = 180, + resolve_local_executable: bool = False, ) -> dict[str, Any]: """Build a public-safe Antigravity 2.0 ``sidecar.json`` periodic heartbeat template.""" + conversation_id = conversation_id or os.environ.get("ANTIGRAVITY_CONVERSATION_ID") quota_args = [cli_bin, "--format", "json"] if runtime_root: quota_args.extend(["--runtime-root", runtime_root]) @@ -155,6 +159,16 @@ def build_antigravity_sidecar_template( ) interval_minutes = max(1, int(interval_seconds) // 60) cron_expr = cron_for_interval_minutes(interval_minutes) + provider_args = _antigravity_provider_args( + "sidecar-tick", + cli_bin=cli_bin, + goal_id=goal_id, + agent_id=agent_id, + conversation_id=conversation_id, + runtime_root=runtime_root, + registry=registry, + resolve_local_executable=resolve_local_executable, + ) return { "schema_version": "loopx_antigravity_sidecar_template_v0", "config_path": ANTIGRAVITY_SIDECAR_CONFIG_LABEL, @@ -163,11 +177,7 @@ def build_antigravity_sidecar_template( "builtin": "schedule", "args": [ f"CRON_TZ=UTC {cron_expr}", - "agentapi", - "new-conversation", - "--model=pro", - "--", - bootstrap_prompt, + *provider_args, ], "restart_policy": "always", "display_name": "LoopX Antigravity Heartbeat", @@ -266,6 +276,101 @@ def cron_for_interval_minutes(interval_minutes: int) -> str: return f"*/{minutes} * * * *" +def build_antigravity_backoff_schedule_action( + scheduler_hint: dict[str, Any], + *, + current_task_id: str | None = None, + prompt: str = "", +) -> dict[str, Any]: + """Translate ``scheduler_hint.app_automation`` stateful backoff into Antigravity ``schedule`` / ``sidecar.json`` actions.""" + hint_root = ( + scheduler_hint.get("scheduler_hint") + if isinstance(scheduler_hint.get("scheduler_hint"), dict) + else scheduler_hint + ) + app = ( + hint_root.get("app_automation") + if isinstance(hint_root.get("app_automation"), dict) + else {} + ) + backoff = ( + app.get("stateful_backoff") + if isinstance(app.get("stateful_backoff"), dict) + else {} + ) + host_action = str(app.get("host_action") or app.get("action") or "none") + if host_action == "pause_or_delete_current_heartbeat": + return { + "action": "pause_or_delete_current_heartbeat", + "apply_needed": bool(app.get("host_action_required", True)), + "ack_needed": False, + "manage_task_kill": ( + {"Action": "kill", "TaskId": current_task_id} + if current_task_id + else None + ), + "schedule_call": None, + "ack_cli_args": None, + "failure_cli_args": None, + } + apply_needed = bool( + app.get("apply_needed") + if "apply_needed" in app + else backoff.get("apply_needed") + ) + ack_needed = bool( + app.get("ack_needed") if "ack_needed" in app else backoff.get("ack_needed") + ) + interval = int( + app.get("recommended_interval_minutes") + or backoff.get("current_interval_minutes") + or 3 + ) + cron_expr = cron_for_interval_minutes(interval) + ack_hint = app.get("ack_hint") if isinstance(app.get("ack_hint"), dict) else {} + failure_hint = ( + app.get("failure_hint") if isinstance(app.get("failure_hint"), dict) else {} + ) + return { + "action": ( + "update_current_heartbeat_rrule" + if apply_needed + else "ack_current_heartbeat_rrule" + if ack_needed + else "noop" + ), + "apply_needed": apply_needed, + "ack_needed": ack_needed, + "recommended_interval_minutes": interval, + "recommended_rrule": app.get("recommended_rrule"), + "cron_expression": cron_expr, + "manage_task_kill": ( + {"Action": "kill", "TaskId": current_task_id} + if apply_needed and current_task_id + else None + ), + "schedule_call": ( + { + "CronExpression": cron_expr, + "IsDaemon": True, + "Prompt": prompt, + } + if apply_needed + else None + ), + "ack_cli_args": ( + list(ack_hint.get("cli_args", [])) + if isinstance(ack_hint.get("cli_args"), list) + else None + ), + "failure_cli_args": ( + list(failure_hint.get("cli_args", [])) + if isinstance(failure_hint.get("cli_args"), list) + else None + ), + } + + def build_antigravity_hooks_template( *, cli_bin: str = "loopx", @@ -291,7 +396,11 @@ def build_antigravity_hooks_template( return { "loopx": { "enabled": bool( - conversation_id and agent_id and goal_id and goal_id != "" + conversation_id + and conversation_id != "" + and agent_id + and goal_id + and goal_id != "" ), "Stop": [{"type": "command", "command": shlex.join(args), "timeout": 30}], } @@ -363,6 +472,7 @@ def antigravity_app_activation_extras( *, goal_id: str = "", agent_id: str | None = None, + conversation_id: str | None = None, runtime_root: str | None = None, cli_bin: str = "loopx", ) -> dict[str, Any]: @@ -370,13 +480,25 @@ def antigravity_app_activation_extras( sidecar_template = build_antigravity_sidecar_template( goal_id=goal_id, agent_id=agent_id, + conversation_id=conversation_id, runtime_root=runtime_root, cli_bin=cli_bin, + resolve_local_executable=False, ) hooks_template = build_antigravity_hooks_template( cli_bin=cli_bin, goal_id=goal_id, agent_id=agent_id, + conversation_id=conversation_id, + runtime_root=runtime_root, + resolve_local_executable=False, + ) + bind_args = _antigravity_provider_args( + "bind-session", + cli_bin=cli_bin, + goal_id=goal_id, + agent_id=agent_id, + conversation_id=conversation_id or "auto", runtime_root=runtime_root, resolve_local_executable=False, ) @@ -397,6 +519,7 @@ def antigravity_app_activation_extras( "host_mcp_config": ANTIGRAVITY_MCP_CONFIG_LABEL, "host_hooks_config": ANTIGRAVITY_HOOKS_CONFIG_LABEL, "host_sidecar_config": ANTIGRAVITY_SIDECAR_CONFIG_LABEL, + "bind_session_command": shlex.join(bind_args), "in_session_schedule_call": { "tool": "schedule", "arguments": { @@ -421,6 +544,7 @@ def antigravity_app_activation_extras( }, "extra_activation_steps": [ f"Install or refresh the Antigravity skill and optional MCP/hooks surface (`{cli_bin} slash-commands --install --surface antigravity-app --with-agy-mcp --with-agy-hooks` and `{cli_bin} workflow-skills --install --host-surface antigravity-app`).", + f"Bind the current Antigravity conversation to `hooks.json` and `sidecar.json` via `{shlex.join(bind_args)}` (or automatic `bind-agent-thread --host-surface antigravity-app --execute` sync).", "When a turn enters `waiting_external` or waits on CI/review checks, suspend directly with the native `schedule(DurationSeconds=, Prompt=...)` tool and end the turn instead of polling in a shell loop.", f"For background periodic heartbeats, write only `sidecar_template.sidecar_json` to `{ANTIGRAVITY_SIDECAR_CONFIG_LABEL}`. Preserve its existing id, model, permissions and host enable choice. Each fresh native heartbeat loads Core admission before work; do not persist an expanded execution body. Apply the bound `hooks_template` at the top level of hooks.json for bounded transport-error recovery; never wrap it in a `hooks` object.", ], diff --git a/loopx/agy_goal_mode/host_runtime.py b/loopx/agy_goal_mode/host_runtime.py index 1e34082547..969a2d6f31 100644 --- a/loopx/agy_goal_mode/host_runtime.py +++ b/loopx/agy_goal_mode/host_runtime.py @@ -12,10 +12,12 @@ import json import os from pathlib import Path +import shutil import subprocess import sys import time from typing import Any +from urllib.parse import unquote, urlparse from ..file_lock import exclusive_file_lock @@ -199,21 +201,725 @@ def stop_hook(args: argparse.Namespace, event: dict[str, Any]) -> dict[str, Any] return {"decision": "continue", "reason": bootstrap_prompt(args)} +def _ls_endpoint() -> tuple[str, str] | None: + from ..capabilities.native_chat.antigravity_adapter import ( + _local_url, + discover_antigravity_language_server, + ) + + address = os.environ.get("ANTIGRAVITY_LS_ADDRESS") or os.environ.get( + "ANTIGRAVITY_LS_URL" + ) + token = os.environ.get("ANTIGRAVITY_CSRF_TOKEN") + if not address and not token: + endpoint = discover_antigravity_language_server() + if endpoint is None: + return None + address, token = endpoint["url"], endpoint["csrf_token"] + elif not address or not token: + return None + url = _local_url(address if "://" in address else "http://" + address) + return url, token + + +def _uri_to_resolved_path(uri: Any) -> Path | None: + if not isinstance(uri, str) or not uri: + return None + parsed = urlparse(uri) + if parsed.scheme == "file": + raw_path = unquote(parsed.path) + elif not parsed.scheme: + raw_path = uri + else: + return None + try: + return Path(raw_path).expanduser().resolve() + except OSError: + return None + + +def _git_common_dir(path: Path) -> Path | None: + git_bin = shutil.which("git") or "/usr/bin/git" + try: + proc = subprocess.run( + [git_bin, "-C", str(path), "rev-parse", "--git-common-dir"], + capture_output=True, + text=True, + timeout=2, + check=False, + ) + except Exception: + return None + if proc.returncode != 0: + return None + raw = proc.stdout.strip() + if not raw: + return None + candidate = Path(raw) + if not candidate.is_absolute(): + candidate = path / candidate + try: + return candidate.resolve() + except OSError: + return None + + +def _workspace_matches(summary: dict[str, Any], target_workspace: Path) -> bool: + workspaces = summary.get("workspaces") + if not isinstance(workspaces, list) or not workspaces: + return False + target_common_git = _git_common_dir(target_workspace) + for ws in workspaces: + if not isinstance(ws, dict): + continue + for key in ("workspaceFolderAbsoluteUri", "gitRootAbsoluteUri"): + ws_path = _uri_to_resolved_path(ws.get(key)) + if ws_path is None: + continue + if ( + ws_path == target_workspace + or target_workspace.is_relative_to(ws_path) + or ws_path.is_relative_to(target_workspace) + ): + return True + if target_common_git is not None: + ws_common_git = _git_common_dir(ws_path) + if ws_common_git is not None and ws_common_git == target_common_git: + return True + return False + + +def discover_active_conversation_id( + workspace: str | Path | None = None, +) -> str | None: + """Discover the currently active Antigravity conversation ID from Language Server.""" + env_cid = (os.environ.get("ANTIGRAVITY_CONVERSATION_ID") or "").strip() + if env_cid: + return env_cid + endpoint = _ls_endpoint() + if endpoint is None: + return None + from ..capabilities.native_chat.antigravity_adapter import _rpc_post + + url, token = endpoint + try: + summaries_resp = _rpc_post( + url, token, "GetAllCascadeTrajectories", {}, timeout=2 + ) + except Exception: + return None + summaries = (summaries_resp or {}).get("trajectorySummaries") + if not isinstance(summaries, dict) or not summaries: + return None + + target_workspace: Path | None = None + if workspace is not None: + try: + target_workspace = Path(workspace).expanduser().resolve() + except OSError: + target_workspace = None + if target_workspace is None: + try: + target_workspace = Path.cwd().resolve() + except OSError: + target_workspace = None + + running_workspace_matches: list[tuple[str, int, str]] = [] + running_root_candidates: list[tuple[str, int, str]] = [] + idle_workspace_matches: list[tuple[str, int, str]] = [] + + for cid, summary in summaries.items(): + if not isinstance(cid, str) or not cid or not isinstance(summary, dict): + continue + meta = summary.get("trajectoryMetadata") + if isinstance(meta, dict) and meta.get("parentConversationId"): + # Skip subagent trajectories when binding root session + continue + status = str(summary.get("status") or "") + modified = str(summary.get("lastModifiedTime") or summary.get("createdTime") or "") + steps = int(summary.get("stepCount") or 0) + ws_match = ( + _workspace_matches(summary, target_workspace) + if target_workspace is not None + else False + ) + if status == "CASCADE_RUN_STATUS_RUNNING": + if ws_match: + running_workspace_matches.append((modified, steps, cid)) + running_root_candidates.append((modified, steps, cid)) + elif status == "CASCADE_RUN_STATUS_IDLE" and ws_match: + if ( + summary.get("latestExecutorTerminationReason") + != "EXECUTOR_TERMINATION_REASON_USER_CANCELED" + ): + idle_workspace_matches.append((modified, steps, cid)) + + for bucket in ( + running_workspace_matches, + running_root_candidates if len(running_root_candidates) == 1 else [], + idle_workspace_matches if len(idle_workspace_matches) == 1 else [], + ): + if bucket: + bucket.sort(reverse=True) + return bucket[0][2] + return None + + +def _resolve_bound_session( + args: argparse.Namespace, +) -> tuple[str | None, str | None, str | None]: + goal_id = (getattr(args, "goal_id", None) or "").strip() or None + agent_id = (getattr(args, "agent_id", None) or "").strip() or None + conversation_id = (getattr(args, "conversation_id", None) or "").strip() or None + if goal_id and agent_id and conversation_id: + return goal_id, agent_id, conversation_id + + try: + from ..cli_commands.support_control_registry import explicit_global_registry + from ..history import load_registry + from ..registry import registry_goals + from ..thread_agent_binding import normalize_thread_agent_bindings + + registry_path = ( + Path(args.registry).expanduser() + if getattr(args, "registry", None) + else explicit_global_registry(getattr(args, "runtime_root", None)) + ) + if not registry_path.exists(): + return goal_id, agent_id, conversation_id + payload = load_registry(registry_path) + candidates: list[tuple[str, str, str, str]] = [] + for goal in registry_goals(payload): + gid = str(goal.get("id") or "").strip() + if not gid or (goal_id and gid != goal_id): + continue + if str(goal.get("status") or "active") in { + "completed", + "archived", + "cancelled", + }: + continue + coordination = ( + goal.get("coordination") + if isinstance(goal.get("coordination"), dict) + else {} + ) + bindings = normalize_thread_agent_bindings( + coordination.get("thread_agent_bindings") + ) + for item in bindings: + if item.get("host_surface") not in { + "antigravity-app", + "antigravity_app", + }: + continue + aid = str(item.get("agent_id") or "").strip() + tid = str(item.get("thread_id") or "").strip() + if not aid or not tid: + continue + if agent_id and aid != agent_id: + continue + if conversation_id and tid != conversation_id: + continue + bound_at = str(item.get("bound_at") or "") + candidates.append((bound_at, gid, aid, tid)) + if candidates: + candidates.sort(reverse=True) + _, resolved_gid, resolved_aid, resolved_tid = candidates[0] + return ( + goal_id or resolved_gid, + agent_id or resolved_aid, + conversation_id or resolved_tid, + ) + except Exception: + pass + return goal_id, agent_id, conversation_id + + +def _inspect_conversation_liveness(conversation_id: str) -> dict[str, Any]: + """Query Antigravity Language Server to determine whether the bound session is alive, running, or idle.""" + endpoint = _ls_endpoint() + if endpoint is None: + return { + "alive": False, + "running": False, + "idle": False, + "user_canceled": False, + "reason": "language_server_not_running", + } + from ..capabilities.native_chat.antigravity_adapter import _rpc_post + + url, token = endpoint + try: + traj = _rpc_post( + url, + token, + "GetCascadeTrajectory", + {"cascadeId": conversation_id}, + timeout=2, + ) + except Exception: + return { + "alive": False, + "running": False, + "idle": False, + "user_canceled": False, + "reason": "trajectory_rpc_error", + } + if ( + not isinstance(traj, dict) + or (traj.get("trajectory") or {}).get("cascadeId") != conversation_id + ): + return { + "alive": False, + "running": False, + "idle": False, + "user_canceled": False, + "reason": "session_not_found", + } + status = str(traj.get("status") or "") + termination_reason = "" + try: + all_resp = _rpc_post( + url, token, "GetAllCascadeTrajectories", {}, timeout=2 + ) + summary = ((all_resp or {}).get("trajectorySummaries") or {}).get( + conversation_id + ) + if isinstance(summary, dict): + termination_reason = str( + summary.get("latestExecutorTerminationReason") or "" + ) + if not status: + status = str(summary.get("status") or "") + except Exception: + pass + + user_canceled = ( + termination_reason == "EXECUTOR_TERMINATION_REASON_USER_CANCELED" + ) + running = status == "CASCADE_RUN_STATUS_RUNNING" + idle = status == "CASCADE_RUN_STATUS_IDLE" + return { + "alive": running or idle, + "running": running, + "idle": idle, + "user_canceled": user_canceled, + "status": status, + "termination_reason": termination_reason, + "num_total_steps": traj.get("numTotalSteps"), + "reason": ( + "session_running" + if running + else ( + "session_user_canceled" + if user_canceled + else ("session_idle" if idle else "session_inactive") + ) + ), + } + + +def _sync_sidecar_cron(sidecar_path: Path, cron_expression: str) -> bool: + if not sidecar_path.exists(): + return False + try: + data = json.loads(sidecar_path.read_text(encoding="utf-8")) + except Exception: + return False + if not isinstance(data, dict): + return False + args_list = data.get("args") + if not isinstance(args_list, list) or not args_list: + return False + desired = f"CRON_TZ=UTC {cron_expression}" + if args_list[0] == desired: + return False + args_list[0] = desired + tmp = sidecar_path.with_suffix(".tmp") + tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") + tmp.replace(sidecar_path) + return True + + +def _run_core_json( + args: argparse.Namespace, command: list[str] +) -> dict[str, Any] | None: + env = {key: value for key, value in os.environ.items() if key != "PYTHONPATH"} + proc = subprocess.run( + _core_args(args, command), + capture_output=True, + text=True, + timeout=15, + check=False, + env=env, + ) + if proc.returncode != 0 and not proc.stdout.strip(): + return None + try: + parsed = json.loads(proc.stdout) + return parsed if isinstance(parsed, dict) else None + except Exception: + return None + + +def _run_cli_raw(args: argparse.Namespace, cli_args: list[str]) -> bool: + env = {key: value for key, value in os.environ.items() if key != "PYTHONPATH"} + cmd = [args.cli_bin, "--format", "json"] + for flag, value in ( + ("--registry", args.registry), + ("--runtime-root", args.runtime_root), + ): + if value: + cmd.extend([flag, value]) + cmd.extend(cli_args) + proc = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=15, + check=False, + env=env, + ) + return proc.returncode == 0 + + +def sidecar_tick(args: argparse.Namespace) -> dict[str, Any]: + """Execute one stateful-backoff-aware Antigravity sidecar tick. + + 1. Resolves the bound (goal_id, agent_id, conversation_id). + 2. Verifies the bound Antigravity session exists, is not user-canceled, and is IDLE + (if the session is already RUNNING or not alive, skips automation). + 3. Queries `loopx quota should-run --runtime-profile antigravity_app`, syncs the + sidecar cron expression with LoopX `stateful_backoff`, and acknowledges the + scheduler state (`quota scheduler-ack-current --antigravity_app`). + 4. Wakes the existing bound conversation in-place via `agentapi send-message` + (never spawning orphan conversations via `agentapi new-conversation`). + """ + from . import ( + antigravity_config_home, + build_antigravity_backoff_schedule_action, + ) + + goal_id, agent_id, conversation_id = _resolve_bound_session(args) + if not goal_id or not agent_id or not conversation_id: + return { + "ok": True, + "decision": "noop", + "reason": "unbound_session", + "goal_id": goal_id, + "agent_id": agent_id, + "conversation_id": conversation_id, + } + + bound_args = argparse.Namespace(**vars(args)) + bound_args.goal_id = goal_id + bound_args.agent_id = agent_id + bound_args.conversation_id = conversation_id + + liveness = _inspect_conversation_liveness(conversation_id) + if not liveness.get("alive"): + return { + "ok": True, + "decision": "noop", + "reason": liveness.get("reason", "session_not_alive"), + "goal_id": goal_id, + "agent_id": agent_id, + "conversation_id": conversation_id, + "liveness": liveness, + } + if liveness.get("running"): + return { + "ok": True, + "decision": "noop", + "reason": "session_already_running", + "goal_id": goal_id, + "agent_id": agent_id, + "conversation_id": conversation_id, + "liveness": liveness, + } + if liveness.get("user_canceled"): + return { + "ok": True, + "decision": "noop", + "reason": "session_user_canceled", + "goal_id": goal_id, + "agent_id": agent_id, + "conversation_id": conversation_id, + "liveness": liveness, + } + if not liveness.get("idle"): + return { + "ok": True, + "decision": "noop", + "reason": "session_not_idle", + "goal_id": goal_id, + "agent_id": agent_id, + "conversation_id": conversation_id, + "liveness": liveness, + } + + quota_payload = _run_core_json(bound_args, ["quota", "should-run"]) + if quota_payload is None: + return { + "ok": False, + "decision": "noop", + "reason": "quota_check_failed", + "goal_id": goal_id, + "agent_id": agent_id, + "conversation_id": conversation_id, + } + + backoff_action = build_antigravity_backoff_schedule_action(quota_payload) + sidecar_path = ( + Path(args.sidecar_config).expanduser() + if getattr(args, "sidecar_config", None) + else ( + antigravity_config_home(getattr(args, "agy_home", None)) + / "sidecars" + / "loopx-antigravity-heartbeat" + / "sidecar.json" + ) + ) + target_cron = str(backoff_action.get("cron_expression") or "*/3 * * * *") + cron_updated = False + if backoff_action.get("action") != "pause_or_delete_current_heartbeat": + cron_updated = _sync_sidecar_cron(sidecar_path, target_cron) + + ack_cli_args = backoff_action.get("ack_cli_args") + ack_executed = False + if ( + isinstance(ack_cli_args, list) + and ack_cli_args + and (backoff_action.get("apply_needed") or cron_updated) + ): + ack_executed = _run_cli_raw( + bound_args, [str(item) for item in ack_cli_args] + ) + + should_run = quota_payload.get("should_run") is True + if ( + not should_run + or backoff_action.get("action") == "pause_or_delete_current_heartbeat" + ): + return { + "ok": True, + "decision": "noop", + "reason": "quota_should_not_run", + "goal_id": goal_id, + "agent_id": agent_id, + "conversation_id": conversation_id, + "effective_action": quota_payload.get("effective_action"), + "recommended_interval_minutes": backoff_action.get( + "recommended_interval_minutes" + ), + "cron_expression": target_cron, + "cron_updated": cron_updated, + "ack_executed": ack_executed, + } + + # Re-verify session is still idle right before sending the wake message. + recheck = _inspect_conversation_liveness(conversation_id) + if not recheck.get("idle") or recheck.get("running") or recheck.get("user_canceled"): + return { + "ok": True, + "decision": "noop", + "reason": "session_state_changed_before_wake", + "goal_id": goal_id, + "agent_id": agent_id, + "conversation_id": conversation_id, + "liveness": recheck, + "cron_updated": cron_updated, + "ack_executed": ack_executed, + } + + agentapi_bin = ( + getattr(args, "agentapi", None) + or shutil.which("agentapi") + or str(Path.home() / ".antigravity" / "antigravity" / "bin" / "agentapi") + ) + prompt = bootstrap_prompt(bound_args) + wake = subprocess.run( + [ + agentapi_bin, + "send-message", + "--title=LoopX Heartbeat", + conversation_id, + prompt, + ], + capture_output=True, + text=True, + timeout=15, + check=False, + ) + if wake.returncode != 0: + return { + "ok": False, + "decision": "wake_failed", + "reason": "agentapi_send_message_failed", + "goal_id": goal_id, + "agent_id": agent_id, + "conversation_id": conversation_id, + "returncode": wake.returncode, + "cron_updated": cron_updated, + "ack_executed": ack_executed, + } + return { + "ok": True, + "decision": "woke_bound_session", + "goal_id": goal_id, + "agent_id": agent_id, + "conversation_id": conversation_id, + "recommended_interval_minutes": backoff_action.get( + "recommended_interval_minutes" + ), + "cron_expression": target_cron, + "cron_updated": cron_updated, + "ack_executed": ack_executed, + } + + +def bind_antigravity_session_configs( + *, + goal_id: str, + agent_id: str, + conversation_id: str, + cli_bin: str = "loopx", + registry: str | None = None, + runtime_root: str | None = None, + agy_home: str | None = None, + interval_minutes: int = 3, + write_sidecar: bool = True, + write_hooks: bool = True, +) -> dict[str, Any]: + """Atomically bind local Antigravity `hooks.json` and `sidecar.json` to `(goal_id, agent_id, conversation_id)`.""" + from . import antigravity_config_home, build_antigravity_sidecar_template + from ..slash_command_install import _merge_managed_hooks_json + + config_root = antigravity_config_home(agy_home) + hooks_path = config_root / "hooks.json" + sidecar_path = ( + config_root + / "sidecars" + / "loopx-antigravity-heartbeat" + / "sidecar.json" + ) + hooks_status = "skipped" + if write_hooks: + hooks_status = _merge_managed_hooks_json( + config_path=hooks_path, + cli_bin=cli_bin, + uninstall=False, + execute=True, + goal_id=goal_id, + agent_id=agent_id, + conversation_id=conversation_id, + registry=registry, + runtime_root=runtime_root, + ) + sidecar_status = "skipped" + if write_sidecar: + existing_cron: str | None = None + if sidecar_path.exists(): + try: + existing_data = json.loads(sidecar_path.read_text(encoding="utf-8")) + existing_args = existing_data.get("args") if isinstance(existing_data, dict) else None + if isinstance(existing_args, list) and existing_args and isinstance(existing_args[0], str): + existing_cron = existing_args[0] + except Exception: + existing_cron = None + sidecar_template = build_antigravity_sidecar_template( + goal_id=goal_id, + agent_id=agent_id, + conversation_id=conversation_id, + cli_bin=cli_bin, + interval_seconds=max(60, int(interval_minutes) * 60), + registry=registry, + runtime_root=runtime_root, + resolve_local_executable=True, + ) + sidecar_json = dict(sidecar_template["sidecar_json"]) + if existing_cron and existing_cron.startswith("CRON_TZ=UTC ") and interval_minutes == 3: + sidecar_args = list(sidecar_json.get("args") or []) + if sidecar_args: + sidecar_args[0] = existing_cron + sidecar_json["args"] = sidecar_args + sidecar_path.parent.mkdir(parents=True, exist_ok=True) + tmp = sidecar_path.with_suffix(".tmp") + tmp.write_text(json.dumps(sidecar_json, indent=2) + "\n", encoding="utf-8") + tmp.replace(sidecar_path) + sidecar_status = "written" + return { + "ok": True, + "goal_id": goal_id, + "agent_id": agent_id, + "conversation_id": conversation_id, + "hooks_path": str(hooks_path), + "hooks_status": hooks_status, + "sidecar_path": str(sidecar_path), + "sidecar_status": sidecar_status, + } + + +def bind_session(args: argparse.Namespace) -> dict[str, Any]: + conversation_id = (getattr(args, "conversation_id", None) or "").strip() or None + if not conversation_id: + conversation_id = discover_active_conversation_id( + workspace=getattr(args, "workspace", None) + ) + if not conversation_id: + return { + "ok": False, + "error_kind": "conversation_id_unresolved", + "error": "Could not auto-discover active Antigravity conversation ID; pass --conversation-id explicitly.", + } + if not getattr(args, "goal_id", None) or not getattr(args, "agent_id", None): + return { + "ok": False, + "error_kind": "missing_goal_or_agent_id", + "error": "--goal-id and --agent-id are required for bind-session.", + } + return bind_antigravity_session_configs( + goal_id=args.goal_id, + agent_id=args.agent_id, + conversation_id=conversation_id, + cli_bin=args.cli_bin, + registry=args.registry, + runtime_root=args.runtime_root, + agy_home=getattr(args, "agy_home", None), + ) + + def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("operation", choices=("stop-hook",)) + parser.add_argument( + "operation", + choices=("stop-hook", "sidecar-tick", "bind-session"), + ) parser.add_argument("--cli-bin", default="loopx") parser.add_argument("--registry") parser.add_argument("--runtime-root") - parser.add_argument("--goal-id", required=True) - parser.add_argument("--agent-id", required=True) - parser.add_argument("--conversation-id", required=True) + parser.add_argument("--goal-id") + parser.add_argument("--agent-id") + parser.add_argument("--conversation-id") + parser.add_argument("--workspace") + parser.add_argument("--agy-home") + parser.add_argument("--sidecar-config") + parser.add_argument("--agentapi") parser.add_argument( "--state-dir", default=str(Path.home() / ".loopx/host-state/antigravity") ) args = parser.parse_args() try: - result = stop_hook(args, json.load(sys.stdin)) + if args.operation == "stop-hook": + if not args.goal_id or not args.agent_id or not args.conversation_id: + result = {} + else: + result = stop_hook(args, json.load(sys.stdin)) + elif args.operation == "sidecar-tick": + result = sidecar_tick(args) + else: + result = bind_session(args) except Exception as exc: # Hook stdout must remain valid native JSON. Provider detail may contain # credentials or private context; expose only the failure class. diff --git a/loopx/capabilities/native_chat/antigravity_adapter.py b/loopx/capabilities/native_chat/antigravity_adapter.py index c5866c86f8..5fff6e2fbd 100644 --- a/loopx/capabilities/native_chat/antigravity_adapter.py +++ b/loopx/capabilities/native_chat/antigravity_adapter.py @@ -174,9 +174,10 @@ def discover_antigravity_language_server( except (CodexChatAgentError, ValueError): return None + ps_bin = shutil.which("ps") or ("/bin/ps" if Path("/bin/ps").exists() else "ps") try: ps_out = subprocess.check_output( - ["ps", "-u", str(os.getuid()), "-o", "pid=,command="], + [ps_bin, "-u", str(os.getuid()), "-o", "pid=,command="], text=True, stderr=subprocess.DEVNULL, timeout=3.0, @@ -198,13 +199,16 @@ def discover_antigravity_language_server( if token_match: candidates.append((pid, token_match.group(1))) + lsof_bin = shutil.which("lsof") or ( + "/usr/sbin/lsof" if Path("/usr/sbin/lsof").exists() else "lsof" + ) deadline = time.monotonic() + 4.0 for pid, token in candidates: if time.monotonic() >= deadline: break try: lsof_out = subprocess.check_output( - ["lsof", "-nP", "-a", "-p", str(pid), "-iTCP", "-sTCP:LISTEN"], + [lsof_bin, "-nP", "-a", "-p", str(pid), "-iTCP", "-sTCP:LISTEN"], text=True, stderr=subprocess.DEVNULL, timeout=max(0.1, deadline - time.monotonic()), diff --git a/loopx/cli_commands/_host_thread.py b/loopx/cli_commands/_host_thread.py index dde98cef63..70e0758605 100644 --- a/loopx/cli_commands/_host_thread.py +++ b/loopx/cli_commands/_host_thread.py @@ -3,6 +3,7 @@ import argparse import os +from ..agy_goal_mode import ANTIGRAVITY_APP_AGENT_TYPE from ..kiro_cli_goal_mode import KIRO_CLI_AGENT_TYPE, KIRO_CLI_SESSION_ID_ENV # Host surface -> the environment variable that host exports for its own @@ -16,22 +17,43 @@ "codex-ide-plugin": "CODEX_THREAD_ID", "codex-cli-tui": "CODEX_THREAD_ID", "trae_app": "TRAECLI_THREAD_ID", + ANTIGRAVITY_APP_AGENT_TYPE: "ANTIGRAVITY_CONVERSATION_ID", + "antigravity_app": "ANTIGRAVITY_CONVERSATION_ID", KIRO_CLI_AGENT_TYPE: KIRO_CLI_SESSION_ID_ENV, } -def ambient_host_thread_id(host_surface: str) -> str | None: +def ambient_host_thread_id( + host_surface: str, + *, + workspace: str | None = None, +) -> str | None: """Read the host-provided local context, never a caller's route override. This is a trusted-local-runtime fence, not authentication against another process that can change this OS user's environment or canonical files. """ variable = HOST_THREAD_ID_ENV.get(host_surface) - return (os.environ.get(variable) or None) if variable else None + env_value = (os.environ.get(variable) or None) if variable else None + if env_value: + return env_value + if host_surface in {ANTIGRAVITY_APP_AGENT_TYPE, "antigravity_app"}: + if os.environ.get("PYTEST_CURRENT_TEST") and not ( + os.environ.get("ANTIGRAVITY_LS_ADDRESS") + or os.environ.get("ANTIGRAVITY_LS_URL") + ): + return None + from ..agy_goal_mode.host_runtime import discover_active_conversation_id + + return discover_active_conversation_id(workspace=workspace) + return None def current_host_thread_id(args: argparse.Namespace) -> str | None: explicit = getattr(args, "thread_id", None) if explicit: return str(explicit) - return ambient_host_thread_id(str(getattr(args, "host_surface", None) or "")) + return ambient_host_thread_id( + str(getattr(args, "host_surface", None) or ""), + workspace=getattr(args, "project", None), + ) diff --git a/loopx/cli_commands/registry_admin.py b/loopx/cli_commands/registry_admin.py index f5f85031c1..cd33446d04 100644 --- a/loopx/cli_commands/registry_admin.py +++ b/loopx/cli_commands/registry_admin.py @@ -720,6 +720,46 @@ def handle_registry_admin_command( payload["ok"] = bool(payload["global_sync"].get("ok")) and readback_verified if not payload["ok"]: payload["error_kind"] = "thread_agent_binding_readback_failed" + elif ( + args.command == "bind-agent-thread" + and args.host_surface in {"antigravity-app", "antigravity_app"} + ): + try: + from ..agy_goal_mode import antigravity_config_home + from ..agy_goal_mode.host_runtime import ( + bind_antigravity_session_configs, + ) + from ..slash_command_install import ( + ANTIGRAVITY_HOOKS_MARKER_NAME, + ) + + cfg_root = antigravity_config_home() + has_managed_hooks = ( + cfg_root / ANTIGRAVITY_HOOKS_MARKER_NAME + ).exists() + has_managed_sidecar = ( + cfg_root + / "sidecars" + / "loopx-antigravity-heartbeat" + / "sidecar.json" + ).exists() + if has_managed_hooks or has_managed_sidecar: + payload["antigravity_host_config_sync"] = ( + bind_antigravity_session_configs( + goal_id=args.goal_id, + agent_id=args.agent_id, + conversation_id=args.thread_id, + registry=str(source_path), + runtime_root=str(global_path.parent), + write_hooks=has_managed_hooks, + write_sidecar=has_managed_sidecar, + ) + ) + except Exception as sync_exc: + payload["antigravity_host_config_sync"] = { + "ok": False, + "error": str(sync_exc), + } else: payload["global_sync"] = {"enabled": bool(args.execute), "wrote": False} except Exception as exc: diff --git a/loopx/host_loop_activation.py b/loopx/host_loop_activation.py index 903214ff0f..9143a57d69 100644 --- a/loopx/host_loop_activation.py +++ b/loopx/host_loop_activation.py @@ -1324,6 +1324,11 @@ def build_host_loop_activation_packet( cli_bin, goal_id=goal_id, agent_id=str(selected_agent_id) if selected_agent_id else None, + conversation_id=( + str(thread_binding.get("thread_id")) + if isinstance(thread_binding, dict) and thread_binding.get("thread_id") + else None + ), runtime_root=runtime_root, ) elif canonical == "kiro-cli": diff --git a/loopx/hosts/skill_facade.py b/loopx/hosts/skill_facade.py index ef32d65f9e..61ffb58211 100644 --- a/loopx/hosts/skill_facade.py +++ b/loopx/hosts/skill_facade.py @@ -170,6 +170,7 @@ def antigravity_app_activation( *, goal_id: str = "", agent_id: str | None = None, + conversation_id: str | None = None, runtime_root: str | None = None, ) -> dict[str, Any]: return skill_facade_cli_activation( @@ -182,6 +183,7 @@ def antigravity_app_activation( **antigravity_app_activation_extras( goal_id=goal_id, agent_id=agent_id, + conversation_id=conversation_id, runtime_root=runtime_root, cli_bin=cli_bin, ), diff --git a/loopx/slash_command_install.py b/loopx/slash_command_install.py index 72e024a624..0ada7146e9 100644 --- a/loopx/slash_command_install.py +++ b/loopx/slash_command_install.py @@ -917,6 +917,12 @@ def _merge_managed_hooks_json( cli_bin: str, uninstall: bool, execute: bool, + goal_id: str | None = None, + agent_id: str | None = None, + conversation_id: str | None = None, + registry: str | None = None, + runtime_root: str | None = None, + resolve_local_executable: bool = True, ) -> str: """Add or remove the managed ``loopx`` Stop recovery hook in Antigravity ``hooks.json``.""" marker_path = config_path.parent / ANTIGRAVITY_HOOKS_MARKER_NAME @@ -970,7 +976,15 @@ def _merge_managed_hooks_json( if existing is not None and not loopx_owned: return "skipped_user_owned_hooks_entry" - entry = build_antigravity_hooks_template(cli_bin=cli_bin)[MANAGED_MCP_KEY] + entry = build_antigravity_hooks_template( + cli_bin=cli_bin, + goal_id=goal_id, + agent_id=agent_id, + conversation_id=conversation_id, + registry=registry, + runtime_root=runtime_root, + resolve_local_executable=resolve_local_executable, + )[MANAGED_MCP_KEY] if not execute: return "unchanged" if existing == entry and not legacy_owned else "would_write" if existing == entry and not legacy_owned: diff --git a/tests/test_agy_host_surface.py b/tests/test_agy_host_surface.py index 54599097c7..0b130601d5 100644 --- a/tests/test_agy_host_surface.py +++ b/tests/test_agy_host_surface.py @@ -303,12 +303,19 @@ def test_antigravity_app_first_class_host_surface_and_schedule_sidecar_contract( sidecar_json = mutation["sidecar_template"]["sidecar_json"] assert sidecar_json["builtin"] == "schedule" assert sidecar_json["restart_policy"] == "always" - assert sidecar_json["args"][1:5] == [ - "agentapi", - "new-conversation", - "--model=pro", - "--", + assert sidecar_json["args"][1:8] == [ + "python3", + "-E", + "-P", + "-m", + "loopx.agy_goal_mode.host_runtime", + "sidecar-tick", + "--cli-bin", ] + assert "new-conversation" not in sidecar_json["args"] + assert ( + "bind-session" in mutation["bind_session_command"] + ) assert ( "antigravity_app" in mutation["sidecar_template"]["sidecar"]["loopx-heartbeat"]["gate_command"] diff --git a/tests/test_antigravity_host_runtime.py b/tests/test_antigravity_host_runtime.py index fe79173ac0..36cc46a195 100644 --- a/tests/test_antigravity_host_runtime.py +++ b/tests/test_antigravity_host_runtime.py @@ -177,13 +177,20 @@ def test_generated_native_configs_execute_the_stdio_hook_from_another_directory( ) assert build_antigravity_hooks_template()["loopx"]["enabled"] is False assert "sidecar_json" in build_antigravity_sidecar_template() - kwargs = dict(goal_id="work", agent_id="worker", conversation_id="native-chat") - config = build_antigravity_sidecar_template(goal_id="work", agent_id="worker")[ - "sidecar_json" - ] + kwargs = dict( + goal_id="work", + agent_id="worker", + conversation_id="native-chat", + resolve_local_executable=True, + ) + config = build_antigravity_sidecar_template(**kwargs)["sidecar_json"] assert config["builtin"] == "schedule" assert config["args"][0] == "CRON_TZ=UTC */3 * * * *" - assert config["args"][1:5] == ["agentapi", "new-conversation", "--model=pro", "--"] + assert "loopx.agy_goal_mode.host_runtime" in config["args"] + assert "sidecar-tick" in config["args"] + assert "--conversation-id" in config["args"] + assert "native-chat" in config["args"] + assert "new-conversation" not in config["args"] hook = build_antigravity_hooks_template(**kwargs)["loopx"] assert hook["enabled"] is True # The installed native contract consumes one event on stdin and one native @@ -200,6 +207,259 @@ def test_generated_native_configs_execute_the_stdio_hook_from_another_directory( assert json.loads(result.stdout) == {} +def test_sidecar_tick_checks_session_liveness_syncs_stateful_backoff_and_wakes_in_place( + tmp_path, monkeypatch +): + sidecar_path = tmp_path / "sidecar.json" + sidecar_template = build_antigravity_sidecar_template( + goal_id="work", + agent_id="worker", + conversation_id="native-chat", + ) + sidecar_path.write_text(json.dumps(sidecar_template["sidecar_json"], indent=2)) + + args = bound(tmp_path) + args.sidecar_config = str(sidecar_path) + args.agy_home = str(tmp_path) + + # 1. Language server not running -> noop, never calls quota or agentapi + monkeypatch.setattr( + runtime, + "_inspect_conversation_liveness", + lambda _cid: { + "alive": False, + "running": False, + "idle": False, + "user_canceled": False, + "reason": "language_server_not_running", + }, + ) + res = runtime.sidecar_tick(args) + assert res["decision"] == "noop" + assert res["reason"] == "language_server_not_running" + + # 2. Session is actively RUNNING -> noop, do not disturb or double-trigger + monkeypatch.setattr( + runtime, + "_inspect_conversation_liveness", + lambda _cid: { + "alive": True, + "running": True, + "idle": False, + "user_canceled": False, + "reason": "session_running", + }, + ) + res = runtime.sidecar_tick(args) + assert res["decision"] == "noop" + assert res["reason"] == "session_already_running" + + # 3. Session was stopped by user (USER_CANCELED) -> noop, respect user cancellation + monkeypatch.setattr( + runtime, + "_inspect_conversation_liveness", + lambda _cid: { + "alive": True, + "running": False, + "idle": True, + "user_canceled": True, + "reason": "session_user_canceled", + }, + ) + res = runtime.sidecar_tick(args) + assert res["decision"] == "noop" + assert res["reason"] == "session_user_canceled" + + # 4. Session is IDLE, quota should-run=False with stateful backoff interval=12m -> + # updates sidecar.json cron to */12 * * * *, executes ack_cli_args, and does not wake + monkeypatch.setattr( + runtime, + "_inspect_conversation_liveness", + lambda _cid: { + "alive": True, + "running": False, + "idle": True, + "user_canceled": False, + "reason": "session_idle", + }, + ) + ack_calls: list[list[str]] = [] + monkeypatch.setattr( + runtime, + "_run_cli_raw", + lambda _a, cli_args: (ack_calls.append(cli_args) or True), + ) + monkeypatch.setattr( + runtime, + "_run_core_json", + lambda _a, _cmd: { + "should_run": False, + "effective_action": "waiting_external", + "scheduler_hint": { + "app_automation": { + "action": "keep_or_update_current_heartbeat", + "apply_needed": True, + "recommended_interval_minutes": 12, + "recommended_rrule": "FREQ=MINUTELY;INTERVAL=12", + "stateful_backoff": {"consecutive_noops": 2}, + "ack_hint": { + "cli_args": [ + "quota", + "scheduler-ack-current", + "--goal-id", + "work", + "--agent-id", + "worker", + "--antigravity_app", + "--applied-rrule", + "FREQ=MINUTELY;INTERVAL=12", + "--execute", + ] + }, + } + }, + }, + ) + res = runtime.sidecar_tick(args) + assert res["decision"] == "noop" + assert res["reason"] == "quota_should_not_run" + assert res["cron_updated"] is True + assert res["ack_executed"] is True + assert len(ack_calls) == 1 + assert "--antigravity_app" in ack_calls[0] + updated_sidecar = json.loads(sidecar_path.read_text()) + assert updated_sidecar["args"][0] == "CRON_TZ=UTC */12 * * * *" + + # 5. Session is IDLE and quota should-run=True -> resets backoff to */3 * * * * and wakes bound session in-place via agentapi send-message + wake_cmds: list[list[str]] = [] + + def fake_run(cmd, **_kwargs): + wake_cmds.append(list(cmd)) + return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") + + monkeypatch.setattr(runtime.subprocess, "run", fake_run) + monkeypatch.setattr( + runtime, + "_run_core_json", + lambda _a, _cmd: { + "should_run": True, + "effective_action": "normal_run", + "scheduler_hint": { + "app_automation": { + "action": "keep_or_update_current_heartbeat", + "apply_needed": True, + "recommended_interval_minutes": 3, + "recommended_rrule": "FREQ=MINUTELY;INTERVAL=3", + "stateful_backoff": {"consecutive_noops": 0}, + "ack_hint": { + "cli_args": [ + "quota", + "scheduler-ack-current", + "--goal-id", + "work", + "--agent-id", + "worker", + "--antigravity_app", + "--applied-rrule", + "FREQ=MINUTELY;INTERVAL=3", + "--execute", + ] + }, + } + }, + }, + ) + res = runtime.sidecar_tick(args) + assert res["ok"] is True + assert res["decision"] == "woke_bound_session" + assert res["conversation_id"] == "native-chat" + assert res["cron_updated"] is True + assert json.loads(sidecar_path.read_text())["args"][0] == "CRON_TZ=UTC */3 * * * *" + assert len(wake_cmds) == 1 + assert wake_cmds[0][:4] == [ + "agentapi", + "send-message", + "--title=LoopX Heartbeat", + "native-chat", + ] + assert "new-conversation" not in wake_cmds[0] + + +def test_discover_active_conversation_id_and_bind_session_for_new_session_startup( + tmp_path, monkeypatch +): + from loopx.capabilities.native_chat import antigravity_adapter as provider + + monkeypatch.delenv("ANTIGRAVITY_CONVERSATION_ID", raising=False) + monkeypatch.setenv("ANTIGRAVITY_LS_ADDRESS", "127.0.0.1:4321") + monkeypatch.setenv("ANTIGRAVITY_CSRF_TOKEN", "token-xyz") + + workspace = tmp_path / "repo" + workspace.mkdir() + ws_uri = workspace.resolve().as_uri() + + def fake_rpc(_url, _token, method, _payload, **_): + assert method == "GetAllCascadeTrajectories" + return { + "trajectorySummaries": { + "subagent-cid": { + "status": "CASCADE_RUN_STATUS_RUNNING", + "lastModifiedTime": "2026-11-02T10:05:00Z", + "stepCount": 10, + "trajectoryMetadata": {"parentConversationId": "active-root-cid"}, + "workspaces": [{"workspaceFolderAbsoluteUri": ws_uri}], + }, + "active-root-cid": { + "status": "CASCADE_RUN_STATUS_RUNNING", + "lastModifiedTime": "2026-11-02T10:04:00Z", + "stepCount": 25, + "trajectoryMetadata": {}, + "workspaces": [{"workspaceFolderAbsoluteUri": ws_uri}], + }, + "other-idle-cid": { + "status": "CASCADE_RUN_STATUS_IDLE", + "lastModifiedTime": "2026-11-02T10:06:00Z", + "stepCount": 99, + "trajectoryMetadata": {}, + "workspaces": [{"workspaceFolderAbsoluteUri": ws_uri}], + }, + } + } + + monkeypatch.setattr(provider, "_rpc_post", fake_rpc) + discovered = runtime.discover_active_conversation_id(workspace=workspace) + assert discovered == "active-root-cid" + + agy_cfg = tmp_path / "agy-config" + bind_res = runtime.bind_session( + Namespace( + cli_bin="loopx", + registry="/tmp/reg.json", + runtime_root="/tmp/rt", + goal_id="loopx-goal", + agent_id="antigravity-app", + conversation_id=None, + workspace=str(workspace), + agy_home=str(agy_cfg), + ) + ) + assert bind_res["ok"] is True + assert bind_res["conversation_id"] == "active-root-cid" + hooks_data = json.loads((agy_cfg / "hooks.json").read_text()) + assert hooks_data["loopx"]["enabled"] is True + assert "--conversation-id active-root-cid" in hooks_data["loopx"]["Stop"][0]["command"] + sidecar_data = json.loads( + ( + agy_cfg + / "sidecars" + / "loopx-antigravity-heartbeat" + / "sidecar.json" + ).read_text() + ) + assert "sidecar-tick" in sidecar_data["args"] + assert "active-root-cid" in sidecar_data["args"] + + def test_native_hook_admission_uses_real_core_ready_user_gate_and_zero_quota(tmp_path): from examples.control_plane.quota_plan_fixtures import ( SCOPED_AGENT_ID, From db17e46e9813cc33282f7f29fa924d30c417cbb2 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:42:50 +0530 Subject: [PATCH 29/34] fix(antigravity): support agy and antigravity-app thread bindings in session resolution Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/agy_goal_mode/host_runtime.py | 1 + loopx/cli_commands/_host_thread.py | 3 ++- loopx/cli_commands/registry_admin.py | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/loopx/agy_goal_mode/host_runtime.py b/loopx/agy_goal_mode/host_runtime.py index 969a2d6f31..9abd42fce6 100644 --- a/loopx/agy_goal_mode/host_runtime.py +++ b/loopx/agy_goal_mode/host_runtime.py @@ -409,6 +409,7 @@ def _resolve_bound_session( ) for item in bindings: if item.get("host_surface") not in { + "agy", "antigravity-app", "antigravity_app", }: diff --git a/loopx/cli_commands/_host_thread.py b/loopx/cli_commands/_host_thread.py index 70e0758605..7d0c51cc4c 100644 --- a/loopx/cli_commands/_host_thread.py +++ b/loopx/cli_commands/_host_thread.py @@ -17,6 +17,7 @@ "codex-ide-plugin": "CODEX_THREAD_ID", "codex-cli-tui": "CODEX_THREAD_ID", "trae_app": "TRAECLI_THREAD_ID", + "agy": "ANTIGRAVITY_CONVERSATION_ID", ANTIGRAVITY_APP_AGENT_TYPE: "ANTIGRAVITY_CONVERSATION_ID", "antigravity_app": "ANTIGRAVITY_CONVERSATION_ID", KIRO_CLI_AGENT_TYPE: KIRO_CLI_SESSION_ID_ENV, @@ -37,7 +38,7 @@ def ambient_host_thread_id( env_value = (os.environ.get(variable) or None) if variable else None if env_value: return env_value - if host_surface in {ANTIGRAVITY_APP_AGENT_TYPE, "antigravity_app"}: + if host_surface in {"agy", ANTIGRAVITY_APP_AGENT_TYPE, "antigravity_app"}: if os.environ.get("PYTEST_CURRENT_TEST") and not ( os.environ.get("ANTIGRAVITY_LS_ADDRESS") or os.environ.get("ANTIGRAVITY_LS_URL") diff --git a/loopx/cli_commands/registry_admin.py b/loopx/cli_commands/registry_admin.py index cd33446d04..cec4227da0 100644 --- a/loopx/cli_commands/registry_admin.py +++ b/loopx/cli_commands/registry_admin.py @@ -722,7 +722,8 @@ def handle_registry_admin_command( payload["error_kind"] = "thread_agent_binding_readback_failed" elif ( args.command == "bind-agent-thread" - and args.host_surface in {"antigravity-app", "antigravity_app"} + and args.host_surface + in {"agy", "antigravity-app", "antigravity_app"} ): try: from ..agy_goal_mode import antigravity_config_home From a8f55d7245507630a3dfcf58d981068cbe5de3f4 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:39:20 +0530 Subject: [PATCH 30/34] fix(antigravity): emit CodexActivitySteps and flush per-step visible stream Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../native_chat/antigravity_adapter.py | 423 ++++++++++++++++-- 1 file changed, 397 insertions(+), 26 deletions(-) diff --git a/loopx/capabilities/native_chat/antigravity_adapter.py b/loopx/capabilities/native_chat/antigravity_adapter.py index 5fff6e2fbd..54825e2b76 100644 --- a/loopx/capabilities/native_chat/antigravity_adapter.py +++ b/loopx/capabilities/native_chat/antigravity_adapter.py @@ -26,6 +26,7 @@ parse_agent_response, ) from ...chat_agent import CodexChatAgentError, _turn_prompt +from ...presentation.codex_activity import CodexActivitySteps ANTIGRAVITY_DEFAULT_MODEL = "gemini-4-argon-high" @@ -82,6 +83,155 @@ def _check_hard_timeout(value: float | None) -> None: raise ValueError("Antigravity hard timeout must be finite and positive") +_ITEM_STARTED_LABELS: dict[str, str] = { + "reasoning": "Agent 正在思考", + "contextCompaction": "Agent 正在压缩会话上下文", + "commandExecution": "Agent 正在执行命令", + "mcpToolCall": "Agent 正在调用工具", + "dynamicToolCall": "Agent 正在调用工具", + "webSearch": "Agent 正在检索", + "fileChange": "Agent 正在修改文件", +} + +_NATIVE_TOOL_STEP_TYPES = frozenset({ + "CORTEX_STEP_TYPE_RUN_COMMAND", + "CORTEX_STEP_TYPE_VIEW_FILE", + "CORTEX_STEP_TYPE_CODE_ACTION", + "CORTEX_STEP_TYPE_GENERIC", +}) + + +def _parse_tool_args(raw: Any) -> dict[str, Any]: + if isinstance(raw, Mapping): + return dict(raw) + if isinstance(raw, str) and raw.strip(): + try: + parsed = json.loads(raw) + if isinstance(parsed, dict): + return parsed + except (TypeError, ValueError): + pass + return {} + + +def _antigravity_tool_item( + item_id: str, + tool_name: str, + args: Mapping[str, Any], + *, + step: Mapping[str, Any] | None = None, + status: str = "inProgress", +) -> dict[str, Any]: + step_type = str((step or {}).get("type") or "") + name = tool_name.strip() + if name == "run_command" or step_type == "CORTEX_STEP_TYPE_RUN_COMMAND": + run_cmd = (step or {}).get("runCommand") + run_cmd = run_cmd if isinstance(run_cmd, Mapping) else {} + command = str( + run_cmd.get("commandLine") + or args.get("CommandLine") + or args.get("command") + or args.get("toolSummary") + or "run_command" + ).strip() + combined = run_cmd.get("combinedOutput") + output = ( + str(combined.get("full") or "") + if isinstance(combined, Mapping) + else (str(combined) if isinstance(combined, str) else "") + ) + exit_code = ( + run_cmd.get("exitCode") + if type(run_cmd.get("exitCode")) is int + else (0 if status == "completed" else None) + ) + return { + "id": item_id, + "type": "commandExecution", + "command": command, + "commandActions": [{"type": "run", "command": command}], + "status": status, + **({"exitCode": exit_code} if exit_code is not None else {}), + **({"aggregatedOutput": output} if output else {}), + } + if name == "read_file" or step_type == "CORTEX_STEP_TYPE_VIEW_FILE": + view_file = (step or {}).get("viewFile") + view_file = view_file if isinstance(view_file, Mapping) else {} + uri = str(view_file.get("absolutePathUri") or "") + path = str( + args.get("Path") + or (uri[7:] if uri.startswith("file://") else uri) + or "" + ).strip() + filename = Path(path).name if path else "file" + command = path or filename + return { + "id": item_id, + "type": "commandExecution", + "command": command, + "commandActions": [ + {"type": "read", "path": path or filename, "name": filename, "command": command} + ], + "status": status, + **({"exitCode": 0} if status == "completed" else {}), + } + if name in {"edit_file", "write_file"} or step_type == "CORTEX_STEP_TYPE_CODE_ACTION": + code_action = (step or {}).get("codeAction") + code_action = code_action if isinstance(code_action, Mapping) else {} + action_spec = code_action.get("actionSpec") + spec_uri = "" + if isinstance(action_spec, Mapping): + for key in ("createFile", "editFile", "deleteFile"): + sub = action_spec.get(key) + if isinstance(sub, Mapping): + spec_uri = str(sub.get("absoluteUri") or sub.get("path") or "") + if spec_uri: + break + path = str( + args.get("Path") + or (spec_uri[7:] if spec_uri.startswith("file://") else spec_uri) + or name + or "file" + ).strip() + return { + "id": item_id, + "type": "fileChange", + "status": status, + "changes": [{"path": path}], + } + if name == "search_web": + query = str(args.get("query") or args.get("domain") or "search_web").strip() + return { + "id": item_id, + "type": "webSearch", + "query": query, + "action": {"type": "search", "query": query}, + "status": status, + } + if name == "call_mcp_tool": + server = str(args.get("ServerName") or "").strip() + mcp_tool = str(args.get("ToolName") or "mcp").strip() + return { + "id": item_id, + "type": "mcpToolCall", + "server": server, + "tool": mcp_tool, + "arguments": ( + dict(args["Arguments"]) + if isinstance(args.get("Arguments"), Mapping) + else dict(args) + ), + "status": status, + } + return { + "id": item_id, + "type": "dynamicToolCall", + "tool": name or step_type.removeprefix("CORTEX_STEP_TYPE_").lower() or "tool", + "arguments": dict(args), + "status": status, + } + + class _LocalRedirectHandler(urllib.request.HTTPRedirectHandler): def redirect_request(self, req: Any, fp: Any, code: int, msg: str, headers: Any, newurl: str) -> None: @@ -133,10 +283,16 @@ def _rpc_post( data = json.loads(raw) if raw.strip() else {} return data if isinstance(data, dict) else {} except urllib.error.HTTPError as exc: + err_code = ( + "antigravity_transport_error" + if exc.code in {499, 502, 503, 504} + and method in {"GetCascadeTrajectory", "GetCascadeTrajectorySteps"} + else "antigravity_rpc_error" + ) raise CodexChatAgentError( f"Antigravity LanguageServerService/{method} failed with HTTP {exc.code}", gate=None, - error_code="antigravity_rpc_error", + error_code=err_code, ) from exc except Exception as exc: raise CodexChatAgentError( @@ -567,6 +723,13 @@ def _build_turn_prompt(self, message: str) -> str: history_lines.append(f"{role}: {content}") if len(history_lines) > 1: parts.append("\n".join(history_lines)) + if self.runtime_profile == "trusted_owner" and not self._read_only_review: + parts.append( + "When calling tools across multiple steps, include a brief one-sentence Chinese progress note " + "in your visible response alongside each tool-call batch (without ) so the " + "user sees live progress; then write the complete self-contained final answer and single " + " envelope in your final tool-free response." + ) parts.append( _turn_prompt( message, @@ -664,10 +827,16 @@ def _run_turn( self._system_primed = True self._pending_history.clear() - stream_filter = VisibleResponseStreamFilter() + stream_filters_by_step: dict[int, VisibleResponseStreamFilter] = {} + flushed_planner_steps: set[int] = set() emitted_visible_delta = False emitted_text_by_step: dict[int, str] = {} + emitted_thinking_by_step: dict[int, str] = {} + emitted_cmd_output_by_step: dict[int, str] = {} announced_phases: set[tuple[int, str]] = set() + completed_step_ids: set[str] = set() + active_tool_items: list[dict[str, Any]] = [] + activity_steps = CodexActivitySteps(protected_paths=[self.work_dir]) latest_response_text = "" last_progress_at = time.monotonic() deadline = last_progress_at + self.hard_timeout_sec if self.hard_timeout_sec is not None else None @@ -757,35 +926,142 @@ def _run_turn( pr = step.get("plannerResponse") or {} if not isinstance(pr, dict): continue + if event_sink is not None and active_tool_items: + for prev_item in list(active_tool_items): + prev_id = str(prev_item.get("id") or "") + if prev_id and prev_id not in completed_step_ids: + completed_step_ids.add(prev_id) + done_item = dict(prev_item, status="completed") + done_step = activity_steps.completed(done_item) + if done_step: + event_sink( + "agent.phase", + { + "phase": "tool_call", + "label": "Agent 返回了处理状态", + "method": "item/completed", + "step": done_step, + }, + ) + active_tool_items.clear() thinking = str(pr.get("thinking") or "").strip() - if thinking and (step_idx, "thinking") not in announced_phases: - announced_phases.add((step_idx, "thinking")) - if event_sink is not None: - event_sink( - "agent.phase", - { - "phase": "thinking", - "label": f"Gemini 4 Argon 思考中 ({self.model_id})", - }, + think_item_id = f"agy_think_{step_idx}" + if thinking: + if (step_idx, "thinking") not in announced_phases: + announced_phases.add((step_idx, "thinking")) + started_step = activity_steps.started( + {"id": think_item_id, "type": "reasoning", "summary": []} + ) + if event_sink is not None: + event_sink( + "agent.phase", + { + "phase": "thinking", + "label": _ITEM_STARTED_LABELS["reasoning"], + "method": "item/started", + **({"step": started_step} if started_step else {}), + }, + ) + prev_think = emitted_thinking_by_step.get(step_idx, "") + think_delta = ( + thinking[len(prev_think) :] + if thinking.startswith(prev_think) + else thinking + ) + emitted_thinking_by_step[step_idx] = thinking + if think_delta and event_sink is not None: + delta_step = activity_steps.reasoning_delta( + think_item_id, think_delta, summary=True + ) + if delta_step: + event_sink( + "agent.phase", + { + "phase": "thinking", + "label": "Agent 正在思考", + "method": "item/reasoning/summaryTextDelta", + "step": delta_step, + }, + ) + if ( + step_status == "CORTEX_STEP_STATUS_DONE" + and think_item_id not in completed_step_ids + ): + completed_step_ids.add(think_item_id) + done_step = activity_steps.completed( + {"id": think_item_id, "type": "reasoning", "summary": [thinking]} ) + if done_step and event_sink is not None: + event_sink( + "agent.phase", + { + "phase": "thinking", + "label": "Agent 返回了处理状态", + "method": "item/completed", + "step": done_step, + }, + ) tool_calls = pr.get("toolCalls") or [] if tool_calls and (self.runtime_profile == "restricted" or self._read_only_review): raise CodexChatAgentError("Antigravity did not honor text-only Chat.", gate=None, error_code="unsupported_configuration") if isinstance(tool_calls, list) and tool_calls and (step_idx, "tool") not in announced_phases: announced_phases.add((step_idx, "tool")) - first_tool = tool_calls[0] if isinstance(tool_calls[0], dict) else {} - tool_name = str(first_tool.get("name") or "tool") - if event_sink is not None: - event_sink( - "agent.phase", - { - "phase": "tool_call", - "label": f"正在调用 {tool_name}", - }, - ) + for tc_idx, tc in enumerate(tool_calls): + if not isinstance(tc, dict): + continue + tool_name = str(tc.get("name") or "tool") + tool_id = str(tc.get("id") or f"agy_tool_{step_idx}_{tc_idx}") + raw_args = tc.get("argumentsJson") + parsed_args: dict[str, Any] = {} + if isinstance(raw_args, str) and raw_args.strip(): + try: + loaded = json.loads(raw_args) + if isinstance(loaded, dict): + parsed_args = loaded + except ValueError: + pass + if tool_name == "run_command": + cmd_str = str( + parsed_args.get("CommandLine") + or parsed_args.get("toolSummary") + or "run_command" + ) + item_obj: dict[str, Any] = { + "id": tool_id, + "type": "commandExecution", + "command": cmd_str, + "status": "inProgress", + } + phase_label = _ITEM_STARTED_LABELS["commandExecution"] + else: + server_name = str(parsed_args.get("ServerName") or "antigravity") + inner_tool = str(parsed_args.get("ToolName") or tool_name) + item_obj = { + "id": tool_id, + "type": "mcpToolCall", + "server": server_name, + "tool": inner_tool, + "status": "inProgress", + } + phase_label = f"正在调用 {inner_tool}" + active_tool_items.append(item_obj) + started_step = activity_steps.started(item_obj) + if event_sink is not None: + event_sink( + "agent.phase", + { + "phase": "tool_call", + "label": phase_label, + "method": "item/started", + **({"step": started_step} if started_step else {}), + }, + ) text = str(pr.get("modifiedResponse") or pr.get("response") or "") if text: + step_filter = stream_filters_by_step.setdefault( + step_idx, VisibleResponseStreamFilter() + ) prev_text = emitted_text_by_step.get(step_idx, "") if text.startswith(prev_text): delta_chunk = text[len(prev_text) :] @@ -794,10 +1070,102 @@ def _run_turn( emitted_text_by_step[step_idx] = text latest_response_text = text if delta_chunk and event_sink is not None: - visible_delta = stream_filter.feed(delta_chunk) + visible_delta = step_filter.feed(delta_chunk) if visible_delta: emitted_visible_delta = True event_sink("answer.delta", {"text": visible_delta}) + if ( + step_status == "CORTEX_STEP_STATUS_DONE" + and tool_calls + and step_idx not in flushed_planner_steps + ): + flushed_planner_steps.add(step_idx) + if event_sink is not None: + step_tail = step_filter.finish() + if step_tail: + emitted_visible_delta = True + event_sink("answer.delta", {"text": step_tail + "\n\n"}) + elif step_type == "CORTEX_STEP_TYPE_RUN_COMMAND": + rc = step.get("runCommand") or {} + if not isinstance(rc, dict): + continue + cmd_item_id = f"agy_cmd_{step_idx}" + cmd_str = str(rc.get("commandLine") or rc.get("command") or "command") + if (step_idx, "run_command") not in announced_phases: + announced_phases.add((step_idx, "run_command")) + if not active_tool_items: + started_step = activity_steps.started( + { + "id": cmd_item_id, + "type": "commandExecution", + "command": cmd_str, + "status": "inProgress", + } + ) + if event_sink is not None: + event_sink( + "agent.phase", + { + "phase": "tool_call", + "label": _ITEM_STARTED_LABELS["commandExecution"], + "method": "item/started", + **({"step": started_step} if started_step else {}), + }, + ) + target_id = ( + str(active_tool_items[0].get("id")) + if active_tool_items + else cmd_item_id + ) + combined_out = str(rc.get("combinedOutput") or "") + if combined_out: + prev_out = emitted_cmd_output_by_step.get(step_idx, "") + out_delta = ( + combined_out[len(prev_out) :] + if combined_out.startswith(prev_out) + else combined_out + ) + emitted_cmd_output_by_step[step_idx] = combined_out + if out_delta and event_sink is not None: + delta_step = activity_steps.activity_delta( + target_id, out_delta, kind="command" + ) + if delta_step: + event_sink( + "agent.phase", + { + "phase": "tool_call", + "label": "Agent 收到命令新输出", + "method": "item/commandExecution/outputDelta", + "step": delta_step, + }, + ) + if ( + step_status == "CORTEX_STEP_STATUS_DONE" + and target_id not in completed_step_ids + ): + completed_step_ids.add(target_id) + exit_code = rc.get("exitCode") + done_step = activity_steps.completed( + { + "id": target_id, + "type": "commandExecution", + "command": cmd_str, + "status": "completed", + "exitCode": exit_code if isinstance(exit_code, int) else 0, + "aggregatedOutput": combined_out, + } + ) + if done_step and event_sink is not None: + event_sink( + "agent.phase", + { + "phase": "tool_call", + "label": "Agent 返回了处理状态", + "method": "item/completed", + "step": done_step, + }, + ) elif step_type == "CORTEX_STEP_TYPE_ERROR_MESSAGE": # The native trajectory also records recoverable # API attempts and cleared errors. The native host @@ -851,10 +1219,13 @@ def _run_turn( break if event_sink is not None: - tail = stream_filter.finish() - if tail: - emitted_visible_delta = True - event_sink("answer.delta", {"text": tail}) + for s_idx, sf in sorted(stream_filters_by_step.items()): + if s_idx in flushed_planner_steps: + continue + tail = sf.finish() + if tail: + emitted_visible_delta = True + event_sink("answer.delta", {"text": tail}) if not latest_response_text.strip(): raise CodexChatAgentError( From 44d6d3fa418345dbf8af1026d0b7f389629aef6d Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:44:40 +0530 Subject: [PATCH 31/34] fix(agy): resolve auto sentinel, scope discovery to workspace, and preserve sidecar policy Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/agy_goal_mode/__init__.py | 38 ++- loopx/agy_goal_mode/host_runtime.py | 327 ++++++++++++++++++++---- loopx/claude_goal_mode/mcp/loopx_mcp.py | 17 +- tests/test_agy_host_surface.py | 8 +- tests/test_antigravity_host_runtime.py | 318 +++++++++++++++++++++++ 5 files changed, 645 insertions(+), 63 deletions(-) diff --git a/loopx/agy_goal_mode/__init__.py b/loopx/agy_goal_mode/__init__.py index d3f7ea6748..a801321bfb 100644 --- a/loopx/agy_goal_mode/__init__.py +++ b/loopx/agy_goal_mode/__init__.py @@ -230,6 +230,24 @@ def build_antigravity_sidecar_template( } +CONVERSATION_DISCOVERY_SENTINELS: frozenset[str] = frozenset( + { + "", + "auto", + "", + "", + } +) + + +def normalize_bound_conversation_id(value: str | None) -> str | None: + """Normalize an explicit conversation ID, returning None for discovery sentinels.""" + raw = str(value or "").strip() + if not raw or raw.lower() in CONVERSATION_DISCOVERY_SENTINELS: + return None + return raw + + def _antigravity_provider_args( operation: str, *, @@ -245,6 +263,7 @@ def _antigravity_provider_args( resolved_cli_bin = ( (shutil.which(cli_bin) or cli_bin) if resolve_local_executable else cli_bin ) + bound_cid = normalize_bound_conversation_id(conversation_id) args = [ python_bin, "-E", @@ -258,9 +277,12 @@ def _antigravity_provider_args( goal_id or "", "--agent-id", agent_id or "", - "--conversation-id", - conversation_id or "", ] + if operation == "bind-session": + if bound_cid is not None: + args.extend(["--conversation-id", bound_cid]) + else: + args.extend(["--conversation-id", bound_cid or ""]) if runtime_root: args.extend(["--runtime-root", runtime_root]) if registry: @@ -382,13 +404,15 @@ def build_antigravity_hooks_template( resolve_local_executable: bool = True, ) -> dict[str, Any]: """Native Stop hook; installation alone never binds an arbitrary chat.""" - conversation_id = conversation_id or os.environ.get("ANTIGRAVITY_CONVERSATION_ID") + bound_cid = normalize_bound_conversation_id( + conversation_id or os.environ.get("ANTIGRAVITY_CONVERSATION_ID") + ) args = _antigravity_provider_args( "stop-hook", cli_bin=cli_bin, goal_id=goal_id, agent_id=agent_id, - conversation_id=conversation_id, + conversation_id=bound_cid, runtime_root=runtime_root, registry=registry, resolve_local_executable=resolve_local_executable, @@ -396,9 +420,9 @@ def build_antigravity_hooks_template( return { "loopx": { "enabled": bool( - conversation_id - and conversation_id != "" + bound_cid and agent_id + and agent_id != "" and goal_id and goal_id != "" ), @@ -498,7 +522,7 @@ def antigravity_app_activation_extras( cli_bin=cli_bin, goal_id=goal_id, agent_id=agent_id, - conversation_id=conversation_id or "auto", + conversation_id=normalize_bound_conversation_id(conversation_id), runtime_root=runtime_root, resolve_local_executable=False, ) diff --git a/loopx/agy_goal_mode/host_runtime.py b/loopx/agy_goal_mode/host_runtime.py index 9abd42fce6..df32711017 100644 --- a/loopx/agy_goal_mode/host_runtime.py +++ b/loopx/agy_goal_mode/host_runtime.py @@ -292,8 +292,17 @@ def _workspace_matches(summary: dict[str, Any], target_workspace: Path) -> bool: def discover_active_conversation_id( workspace: str | Path | None = None, ) -> str | None: - """Discover the currently active Antigravity conversation ID from Language Server.""" - env_cid = (os.environ.get("ANTIGRAVITY_CONVERSATION_ID") or "").strip() + """Discover the currently active Antigravity conversation ID from Language Server. + + Only binds a conversation that unambiguously matches the target workspace + (or shares its underlying git repository/worktree). Foreign workspaces and + ambiguous multi-session workspaces return None so the caller fails closed. + """ + from . import normalize_bound_conversation_id + + env_cid = normalize_bound_conversation_id( + os.environ.get("ANTIGRAVITY_CONVERSATION_ID") + ) if env_cid: return env_cid endpoint = _ls_endpoint() @@ -317,15 +326,14 @@ def discover_active_conversation_id( try: target_workspace = Path(workspace).expanduser().resolve() except OSError: - target_workspace = None - if target_workspace is None: + return None + else: try: target_workspace = Path.cwd().resolve() except OSError: - target_workspace = None + return None running_workspace_matches: list[tuple[str, int, str]] = [] - running_root_candidates: list[tuple[str, int, str]] = [] idle_workspace_matches: list[tuple[str, int, str]] = [] for cid, summary in summaries.items(): @@ -335,42 +343,43 @@ def discover_active_conversation_id( if isinstance(meta, dict) and meta.get("parentConversationId"): # Skip subagent trajectories when binding root session continue + if not _workspace_matches(summary, target_workspace): + continue status = str(summary.get("status") or "") modified = str(summary.get("lastModifiedTime") or summary.get("createdTime") or "") steps = int(summary.get("stepCount") or 0) - ws_match = ( - _workspace_matches(summary, target_workspace) - if target_workspace is not None - else False - ) if status == "CASCADE_RUN_STATUS_RUNNING": - if ws_match: - running_workspace_matches.append((modified, steps, cid)) - running_root_candidates.append((modified, steps, cid)) - elif status == "CASCADE_RUN_STATUS_IDLE" and ws_match: + running_workspace_matches.append((modified, steps, cid)) + elif status == "CASCADE_RUN_STATUS_IDLE": if ( summary.get("latestExecutorTerminationReason") != "EXECUTOR_TERMINATION_REASON_USER_CANCELED" ): idle_workspace_matches.append((modified, steps, cid)) - for bucket in ( - running_workspace_matches, - running_root_candidates if len(running_root_candidates) == 1 else [], - idle_workspace_matches if len(idle_workspace_matches) == 1 else [], - ): - if bucket: - bucket.sort(reverse=True) - return bucket[0][2] + if len(running_workspace_matches) == 1: + return running_workspace_matches[0][2] + if len(running_workspace_matches) > 1: + return None + if len(idle_workspace_matches) == 1: + return idle_workspace_matches[0][2] return None def _resolve_bound_session( args: argparse.Namespace, ) -> tuple[str | None, str | None, str | None]: + from . import normalize_bound_conversation_id + goal_id = (getattr(args, "goal_id", None) or "").strip() or None + if goal_id == "": + goal_id = None agent_id = (getattr(args, "agent_id", None) or "").strip() or None - conversation_id = (getattr(args, "conversation_id", None) or "").strip() or None + if agent_id == "": + agent_id = None + conversation_id = normalize_bound_conversation_id( + getattr(args, "conversation_id", None) + ) if goal_id and agent_id and conversation_id: return goal_id, agent_id, conversation_id @@ -692,7 +701,11 @@ def sidecar_tick(args: argparse.Namespace) -> dict[str, Any]: if ( isinstance(ack_cli_args, list) and ack_cli_args - and (backoff_action.get("apply_needed") or cron_updated) + and ( + backoff_action.get("apply_needed") + or backoff_action.get("ack_needed") + or cron_updated + ) ): ack_executed = _run_cli_raw( bound_args, [str(item) for item in ack_cli_args] @@ -780,6 +793,37 @@ def sidecar_tick(args: argparse.Namespace) -> dict[str, Any]: } +ANTIGRAVITY_SIDECAR_MARKER_NAME = ".loopx-managed-sidecar.json" +ANTIGRAVITY_SIDECAR_MARKER_SCHEMA_VERSION = "loopx_managed_antigravity_sidecar_v0" + + +def _is_loopx_compatible_sidecar( + existing_data: dict[str, Any], + marker_payload: dict[str, Any] | None = None, +) -> bool: + """Return True when an existing sidecar.json belongs to LoopX or is a user policy overlay on LoopX's heartbeat sidecar.""" + if isinstance(marker_payload, dict) and existing_data == marker_payload.get("sidecar"): + return True + if any(key in existing_data for key in ("command", "script", "entrypoint")): + return False + builtin = existing_data.get("builtin") + if builtin is not None and builtin != "schedule": + return False + existing_args = existing_data.get("args") + if existing_args is not None: + if not isinstance(existing_args, list): + return False + if len(existing_args) > 1: + joined = " ".join(str(item) for item in existing_args) + if "loopx" not in joined and "host_runtime" not in joined: + return False + elif len(existing_args) == 1: + first = str(existing_args[0]) + if not first.startswith("CRON_TZ=") and "loopx" not in first: + return False + return True + + def bind_antigravity_session_configs( *, goal_id: str, @@ -794,9 +838,23 @@ def bind_antigravity_session_configs( write_hooks: bool = True, ) -> dict[str, Any]: """Atomically bind local Antigravity `hooks.json` and `sidecar.json` to `(goal_id, agent_id, conversation_id)`.""" - from . import antigravity_config_home, build_antigravity_sidecar_template + from . import ( + antigravity_config_home, + build_antigravity_sidecar_template, + normalize_bound_conversation_id, + ) from ..slash_command_install import _merge_managed_hooks_json + normalized_cid = normalize_bound_conversation_id(conversation_id) + normalized_gid = (goal_id or "").strip() + normalized_aid = (agent_id or "").strip() + if not normalized_cid or not normalized_gid or normalized_gid == "" or not normalized_aid or normalized_aid == "": + return { + "ok": False, + "error_kind": "conversation_id_unresolved" if not normalized_cid else "missing_goal_or_agent_id", + "error": "Concrete goal_id, agent_id, and conversation_id are required before writing Antigravity configs.", + } + config_root = antigravity_config_home(agy_home) hooks_path = config_root / "hooks.json" sidecar_path = ( @@ -805,6 +863,49 @@ def bind_antigravity_session_configs( / "loopx-antigravity-heartbeat" / "sidecar.json" ) + sidecar_marker_path = sidecar_path.parent / ANTIGRAVITY_SIDECAR_MARKER_NAME + + existing_sidecar_data: dict[str, Any] | None = None + if write_sidecar and sidecar_path.exists(): + try: + raw_text = sidecar_path.read_text(encoding="utf-8") + parsed_sidecar = json.loads(raw_text) if raw_text.strip() else {} + except (OSError, json.JSONDecodeError): + return { + "ok": False, + "error_kind": "invalid_sidecar_config", + "error": f"Existing {sidecar_path} is not valid JSON.", + "hooks_path": str(hooks_path), + "hooks_status": "skipped", + "sidecar_path": str(sidecar_path), + "sidecar_status": "blocked_invalid_antigravity_sidecar_json", + } + if not isinstance(parsed_sidecar, dict): + return { + "ok": False, + "error_kind": "invalid_sidecar_config", + "error": f"Existing {sidecar_path} root must be a JSON object.", + "hooks_path": str(hooks_path), + "hooks_status": "skipped", + "sidecar_path": str(sidecar_path), + "sidecar_status": "blocked_invalid_antigravity_sidecar_json", + } + try: + marker_payload = json.loads(sidecar_marker_path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + marker_payload = None + if not _is_loopx_compatible_sidecar(parsed_sidecar, marker_payload): + return { + "ok": False, + "error_kind": "sidecar_ownership_conflict", + "error": f"Existing {sidecar_path} belongs to a non-LoopX command and will not be overwritten.", + "hooks_path": str(hooks_path), + "hooks_status": "skipped", + "sidecar_path": str(sidecar_path), + "sidecar_status": "skipped_user_owned_sidecar_entry", + } + existing_sidecar_data = parsed_sidecar + hooks_status = "skipped" if write_hooks: hooks_status = _merge_managed_hooks_json( @@ -812,49 +913,81 @@ def bind_antigravity_session_configs( cli_bin=cli_bin, uninstall=False, execute=True, - goal_id=goal_id, - agent_id=agent_id, - conversation_id=conversation_id, + goal_id=normalized_gid, + agent_id=normalized_aid, + conversation_id=normalized_cid, registry=registry, runtime_root=runtime_root, ) + if hooks_status.startswith("blocked_") or hooks_status.startswith("skipped_user_owned_"): + return { + "ok": False, + "error_kind": "hooks_ownership_conflict", + "error": f"Could not update {hooks_path}: {hooks_status}", + "hooks_path": str(hooks_path), + "hooks_status": hooks_status, + "sidecar_path": str(sidecar_path), + "sidecar_status": "skipped", + } + sidecar_status = "skipped" if write_sidecar: existing_cron: str | None = None - if sidecar_path.exists(): - try: - existing_data = json.loads(sidecar_path.read_text(encoding="utf-8")) - existing_args = existing_data.get("args") if isinstance(existing_data, dict) else None - if isinstance(existing_args, list) and existing_args and isinstance(existing_args[0], str): + if existing_sidecar_data is not None: + existing_args = existing_sidecar_data.get("args") + if isinstance(existing_args, list) and existing_args and isinstance(existing_args[0], str): + if existing_args[0].startswith("CRON_TZ="): existing_cron = existing_args[0] - except Exception: - existing_cron = None sidecar_template = build_antigravity_sidecar_template( - goal_id=goal_id, - agent_id=agent_id, - conversation_id=conversation_id, + goal_id=normalized_gid, + agent_id=normalized_aid, + conversation_id=normalized_cid, cli_bin=cli_bin, interval_seconds=max(60, int(interval_minutes) * 60), registry=registry, runtime_root=runtime_root, resolve_local_executable=True, ) - sidecar_json = dict(sidecar_template["sidecar_json"]) - if existing_cron and existing_cron.startswith("CRON_TZ=UTC ") and interval_minutes == 3: - sidecar_args = list(sidecar_json.get("args") or []) - if sidecar_args: - sidecar_args[0] = existing_cron - sidecar_json["args"] = sidecar_args + template_json = dict(sidecar_template["sidecar_json"]) + template_args = list(template_json.get("args") or []) + if existing_cron and interval_minutes == 3 and template_args: + template_args[0] = existing_cron + + if existing_sidecar_data is not None: + sidecar_json = dict(existing_sidecar_data) + sidecar_json["builtin"] = template_json["builtin"] + sidecar_json["args"] = template_args + sidecar_json["description"] = template_json["description"] + sidecar_json.setdefault("restart_policy", template_json["restart_policy"]) + sidecar_json.setdefault("display_name", template_json["display_name"]) + sidecar_json.setdefault("agent_permissions", template_json["agent_permissions"]) + else: + sidecar_json = template_json + sidecar_json["args"] = template_args + sidecar_path.parent.mkdir(parents=True, exist_ok=True) tmp = sidecar_path.with_suffix(".tmp") tmp.write_text(json.dumps(sidecar_json, indent=2) + "\n", encoding="utf-8") tmp.replace(sidecar_path) + marker_tmp = sidecar_marker_path.with_suffix(".tmp") + marker_tmp.write_text( + json.dumps( + { + "schema_version": ANTIGRAVITY_SIDECAR_MARKER_SCHEMA_VERSION, + "sidecar": sidecar_json, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + marker_tmp.replace(sidecar_marker_path) sidecar_status = "written" return { "ok": True, - "goal_id": goal_id, - "agent_id": agent_id, - "conversation_id": conversation_id, + "goal_id": normalized_gid, + "agent_id": normalized_aid, + "conversation_id": normalized_cid, "hooks_path": str(hooks_path), "hooks_status": hooks_status, "sidecar_path": str(sidecar_path), @@ -862,8 +995,94 @@ def bind_antigravity_session_configs( } +def resolve_bound_mcp_goal_context( + agy_home: str | Path | None = None, +) -> dict[str, Any] | None: + """Resolve Goal/agent context from the bound Antigravity sidecar when MCP runs with cwd=/.""" + from . import antigravity_config_home, normalize_bound_conversation_id + from loopx.goal_mode_context import find_registry, resolve_goal_context + from loopx.history import load_registry + from loopx.paths import global_registry_path, select_default_runtime_root + + cfg_root = antigravity_config_home(agy_home) + sidecar_path = cfg_root / "sidecars" / "loopx-antigravity-heartbeat" / "sidecar.json" + if not sidecar_path.is_file(): + return None + try: + sidecar = json.loads(sidecar_path.read_text(encoding="utf-8")) + except Exception: + return None + if not _is_loopx_compatible_sidecar(sidecar): + return None + args = [str(item) for item in (sidecar.get("args") or [])] + + def _flag_value(flag: str) -> str | None: + if flag in args: + idx = args.index(flag) + if idx + 1 < len(args): + return args[idx + 1].strip() or None + return None + + goal_id = _flag_value("--goal-id") + agent_id = _flag_value("--agent-id") + conversation_id = normalize_bound_conversation_id(_flag_value("--conversation-id")) + if ( + not goal_id + or goal_id == "" + or not agent_id + or agent_id == "" + or not conversation_id + ): + return None + + explicit_registry = _flag_value("--registry") + explicit_runtime_root = _flag_value("--runtime-root") + registry_path: Path | None = None + if explicit_registry: + candidate = Path(explicit_registry).expanduser() + if candidate.is_file(): + registry_path = candidate + + if registry_path is None: + try: + rt_root = ( + Path(explicit_runtime_root).expanduser() + if explicit_runtime_root + else select_default_runtime_root() + ) + global_reg = global_registry_path(rt_root) + if global_reg.is_file(): + global_data = load_registry(global_reg) + for g in global_data.get("goals") or []: + if isinstance(g, dict) and str(g.get("id") or "") == goal_id: + src_reg = g.get("source_registry") + if src_reg and Path(str(src_reg)).expanduser().is_file(): + registry_path = Path(str(src_reg)).expanduser() + break + repo = g.get("repo") + if repo: + registry_path = find_registry(Path(str(repo)).expanduser()) + if registry_path is not None: + break + except Exception: + registry_path = None + + if registry_path is None: + return None + return resolve_goal_context( + registry_path.parent.parent, + preferred_goal_id=goal_id, + preferred_agent_id=agent_id, + require_preferred_binding=True, + ) + + def bind_session(args: argparse.Namespace) -> dict[str, Any]: - conversation_id = (getattr(args, "conversation_id", None) or "").strip() or None + from . import normalize_bound_conversation_id + + conversation_id = normalize_bound_conversation_id( + getattr(args, "conversation_id", None) + ) if not conversation_id: conversation_id = discover_active_conversation_id( workspace=getattr(args, "workspace", None) @@ -874,15 +1093,17 @@ def bind_session(args: argparse.Namespace) -> dict[str, Any]: "error_kind": "conversation_id_unresolved", "error": "Could not auto-discover active Antigravity conversation ID; pass --conversation-id explicitly.", } - if not getattr(args, "goal_id", None) or not getattr(args, "agent_id", None): + goal_id = (getattr(args, "goal_id", None) or "").strip() + agent_id = (getattr(args, "agent_id", None) or "").strip() + if not goal_id or goal_id == "" or not agent_id or agent_id == "": return { "ok": False, "error_kind": "missing_goal_or_agent_id", "error": "--goal-id and --agent-id are required for bind-session.", } return bind_antigravity_session_configs( - goal_id=args.goal_id, - agent_id=args.agent_id, + goal_id=goal_id, + agent_id=agent_id, conversation_id=conversation_id, cli_bin=args.cli_bin, registry=args.registry, @@ -930,6 +1151,8 @@ def main() -> int: ) result = {} print(json.dumps(result)) + if args.operation == "bind-session" and not result.get("ok"): + return 1 return 0 diff --git a/loopx/claude_goal_mode/mcp/loopx_mcp.py b/loopx/claude_goal_mode/mcp/loopx_mcp.py index 57627e2199..6bb1fb9493 100644 --- a/loopx/claude_goal_mode/mcp/loopx_mcp.py +++ b/loopx/claude_goal_mode/mcp/loopx_mcp.py @@ -24,7 +24,22 @@ legacy_host_surface="claude_code", setup_hint="run /loopx first", ) -mcp, control = create_fastmcp_server(CONFIG, lambda: goal_context(Path.cwd())) +def _resolve_mcp_context() -> dict | None: + cwd = Path.cwd() + ctx = goal_context(cwd) + if ctx is not None: + return ctx + try: + if cwd.resolve() == Path("/"): + from loopx.agy_goal_mode.host_runtime import resolve_bound_mcp_goal_context + + return resolve_bound_mcp_goal_context() + except Exception: + return None + return None + + +mcp, control = create_fastmcp_server(CONFIG, _resolve_mcp_context) # Compatibility names used by existing smoke tests and local integrations. should_run = control.should_run diff --git a/tests/test_agy_host_surface.py b/tests/test_agy_host_surface.py index 0b130601d5..7ee982fb95 100644 --- a/tests/test_agy_host_surface.py +++ b/tests/test_agy_host_surface.py @@ -313,9 +313,8 @@ def test_antigravity_app_first_class_host_surface_and_schedule_sidecar_contract( "--cli-bin", ] assert "new-conversation" not in sidecar_json["args"] - assert ( - "bind-session" in mutation["bind_session_command"] - ) + assert "bind-session" in mutation["bind_session_command"] + assert "--conversation-id auto" not in mutation["bind_session_command"] assert ( "antigravity_app" in mutation["sidecar_template"]["sidecar"]["loopx-heartbeat"]["gate_command"] @@ -564,6 +563,7 @@ def test_antigravity_dual_skills_mcp_hooks_workflow_skills_and_doctor( def test_antigravity_app_real_cli_ack_and_stateful_backoff_identity( tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify Antigravity App (`--antigravity_app`) runs real CLI `quota should-run`, `quota scheduler-ack-current`, and `quota scheduler-fail-current` with independent @@ -577,10 +577,12 @@ def test_antigravity_app_real_cli_ack_and_stateful_backoff_identity( load_scheduler_state, ) from loopx.control_plane.testing.canary_harness import ( + REPO_ROOT, run_json_cli, run_json_cli_result, ) + monkeypatch.setenv("PYTHONPATH", str(REPO_ROOT)) goal_id = "needs-operator" registry_path, runtime_root, project = write_cli_fixture( tmp_path / "fixture", diff --git a/tests/test_antigravity_host_runtime.py b/tests/test_antigravity_host_runtime.py index 36cc46a195..5d59d4a2fc 100644 --- a/tests/test_antigravity_host_runtime.py +++ b/tests/test_antigravity_host_runtime.py @@ -518,3 +518,321 @@ def test_hooks_migrate_only_an_unchanged_owned_legacy_entry(tmp_path): == "retired" ) assert json.loads(path.read_text()) == {"personal-hook": unrelated} + + +def test_generated_bind_session_command_and_auto_sentinel_resolve_via_loopback_http( + tmp_path, monkeypatch +): + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + import threading + from loopx.control_plane.testing.canary_harness import REPO_ROOT + from loopx.host_loop_activation import build_host_loop_activation_packet + + monkeypatch.delenv("ANTIGRAVITY_CONVERSATION_ID", raising=False) + workspace = tmp_path / "target-repo" + workspace.mkdir() + ws_uri = workspace.resolve().as_uri() + + rpc_calls: list[str] = [] + summaries_state: dict[str, dict] = {} + + class Handler(BaseHTTPRequestHandler): + def do_POST(self): + rpc_calls.append(self.path) + body = json.dumps({"trajectorySummaries": summaries_state}).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): + return + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + addr = f"127.0.0.1:{server.server_port}" + env = { + **{k: v for k, v in runtime.os.environ.items() if k != "ANTIGRAVITY_CONVERSATION_ID"}, + "ANTIGRAVITY_LS_ADDRESS": addr, + "ANTIGRAVITY_CSRF_TOKEN": "loopback-token", + "PYTHONPATH": str(REPO_ROOT), + } + activation = build_host_loop_activation_packet( + agent_type="antigravity-app", + goal_id="surface-goal", + agent_id="probe-agent", + registered_agents=["probe-agent"], + ) + bind_cmd = shlex.split(activation["host_mutation"]["bind_session_command"]) + assert bind_cmd[:6] == [ + "python3", + "-E", + "-P", + "-m", + "loopx.agy_goal_mode.host_runtime", + "bind-session", + ] + assert "auto" not in bind_cmd + exec_bind_cmd = [ + sys.executable, + "-m", + "loopx.agy_goal_mode.host_runtime", + *bind_cmd[5:], + ] + + # 1. Foreign-only running root -> refused, no config files written + foreign_ws = (tmp_path / "foreign-repo").resolve().as_uri() + summaries_state.clear() + summaries_state["foreign-root-cid"] = { + "status": "CASCADE_RUN_STATUS_RUNNING", + "lastModifiedTime": "2026-11-02T11:00:00Z", + "stepCount": 12, + "trajectoryMetadata": {}, + "workspaces": [{"workspaceFolderAbsoluteUri": foreign_ws}], + } + agy_foreign = tmp_path / "agy-foreign" + res_foreign = subprocess.run( + [*exec_bind_cmd, "--workspace", str(workspace), "--agy-home", str(agy_foreign)], + capture_output=True, + text=True, + env=env, + timeout=10, + ) + assert res_foreign.returncode == 1 + payload_foreign = json.loads(res_foreign.stdout) + assert payload_foreign["ok"] is False + assert payload_foreign["error_kind"] == "conversation_id_unresolved" + assert len(rpc_calls) >= 1 + assert not (agy_foreign / "hooks.json").exists() + assert not ( + agy_foreign / "sidecars" / "loopx-antigravity-heartbeat" / "sidecar.json" + ).exists() + + # 2. Ambiguous multiple running roots in the same workspace -> refused + summaries_state.clear() + summaries_state["same-ws-1"] = { + "status": "CASCADE_RUN_STATUS_RUNNING", + "lastModifiedTime": "2026-11-02T11:01:00Z", + "stepCount": 5, + "trajectoryMetadata": {}, + "workspaces": [{"workspaceFolderAbsoluteUri": ws_uri}], + } + summaries_state["same-ws-2"] = { + "status": "CASCADE_RUN_STATUS_RUNNING", + "lastModifiedTime": "2026-11-02T11:02:00Z", + "stepCount": 8, + "trajectoryMetadata": {}, + "workspaces": [{"workspaceFolderAbsoluteUri": ws_uri}], + } + res_ambig = subprocess.run( + [*exec_bind_cmd, "--workspace", str(workspace), "--agy-home", str(agy_foreign)], + capture_output=True, + text=True, + env=env, + timeout=10, + ) + assert res_ambig.returncode == 1 + assert json.loads(res_ambig.stdout)["error_kind"] == "conversation_id_unresolved" + assert not (agy_foreign / "hooks.json").exists() + + # 3. Single matching running root in target workspace (plus subagent & foreign root) -> + # both the generated command AND explicit `--conversation-id auto` resolve real ID + summaries_state.clear() + summaries_state["foreign-root-cid"] = { + "status": "CASCADE_RUN_STATUS_RUNNING", + "lastModifiedTime": "2026-11-02T11:05:00Z", + "stepCount": 99, + "trajectoryMetadata": {}, + "workspaces": [{"workspaceFolderAbsoluteUri": foreign_ws}], + } + summaries_state["subagent-cid"] = { + "status": "CASCADE_RUN_STATUS_RUNNING", + "lastModifiedTime": "2026-11-02T11:06:00Z", + "stepCount": 50, + "trajectoryMetadata": {"parentConversationId": "scoped-root-cid"}, + "workspaces": [{"workspaceFolderAbsoluteUri": ws_uri}], + } + summaries_state["scoped-root-cid"] = { + "status": "CASCADE_RUN_STATUS_RUNNING", + "lastModifiedTime": "2026-11-02T11:03:00Z", + "stepCount": 20, + "trajectoryMetadata": {}, + "workspaces": [{"workspaceFolderAbsoluteUri": ws_uri}], + } + for idx, extra_args in enumerate(([], ["--conversation-id", "auto"])): + agy_ok = tmp_path / f"agy-ok-{idx}" + before_rpcs = len(rpc_calls) + res_ok = subprocess.run( + [ + *exec_bind_cmd, + *extra_args, + "--workspace", + str(workspace), + "--agy-home", + str(agy_ok), + ], + capture_output=True, + text=True, + env=env, + timeout=10, + ) + assert res_ok.returncode == 0, res_ok.stderr + assert len(rpc_calls) > before_rpcs + payload_ok = json.loads(res_ok.stdout) + assert payload_ok["ok"] is True + assert payload_ok["conversation_id"] == "scoped-root-cid" + hooks_written = json.loads((agy_ok / "hooks.json").read_text()) + assert hooks_written["loopx"]["enabled"] is True + assert "--conversation-id scoped-root-cid" in hooks_written["loopx"]["Stop"][0]["command"] + assert "--conversation-id auto" not in hooks_written["loopx"]["Stop"][0]["command"] + sidecar_written = json.loads( + ( + agy_ok + / "sidecars" + / "loopx-antigravity-heartbeat" + / "sidecar.json" + ).read_text() + ) + assert "scoped-root-cid" in sidecar_written["args"] + assert "auto" not in sidecar_written["args"] + finally: + server.shutdown() + server.server_close() + + +def test_bind_session_and_bind_agent_thread_preserve_sidecar_policy_and_reject_foreign_owner( + tmp_path, monkeypatch +): + from examples.control_plane.quota_plan_fixtures import ( + SCOPED_AGENT_ID, + write_cli_fixture, + ) + from loopx.control_plane.testing.canary_harness import REPO_ROOT, run_json_cli + + agy_cfg = tmp_path / "agy-policy" + sidecar_path = ( + agy_cfg / "sidecars" / "loopx-antigravity-heartbeat" / "sidecar.json" + ) + sidecar_path.parent.mkdir(parents=True, exist_ok=True) + + # 1. Existing user-owned non-LoopX sidecar is rejected without modification + foreign_sidecar = { + "builtin": "schedule", + "args": ["CRON_TZ=UTC */5 * * * *", "/usr/local/bin/custom-user-job"], + "enabled": True, + } + sidecar_path.write_text(json.dumps(foreign_sidecar, indent=2)) + conflict_res = runtime.bind_antigravity_session_configs( + goal_id="work", + agent_id="worker", + conversation_id="cid-1", + agy_home=str(agy_cfg), + ) + assert conflict_res["ok"] is False + assert conflict_res["error_kind"] == "sidecar_ownership_conflict" + assert conflict_res["sidecar_status"] == "skipped_user_owned_sidecar_entry" + assert json.loads(sidecar_path.read_text()) == foreign_sidecar + + # 2. Existing LoopX heartbeat sidecar with user policy overrides (enabled=False, + # custom id, custom model, empty access_grants, custom cron) preserves all + # policy fields across both `bind-session` and `bind-agent-thread --execute` + policy_sidecar = { + "id": "user-heartbeat-id", + "enabled": False, + "model": "gemini-2.5-pro-custom", + "builtin": "schedule", + "args": [ + "CRON_TZ=UTC */7 * * * *", + "python3", + "-E", + "-P", + "-m", + "loopx.agy_goal_mode.host_runtime", + "sidecar-tick", + "--goal-id", + "old-goal", + "--agent-id", + "old-agent", + "--conversation-id", + "old-cid", + ], + "agent_permissions": {"access_grants": []}, + "custom_user_note": "preserve-me", + } + sidecar_path.write_text(json.dumps(policy_sidecar, indent=2)) + + res_direct = runtime.bind_session( + Namespace( + cli_bin="loopx", + registry="/tmp/reg.json", + runtime_root="/tmp/rt", + goal_id="bound-goal-1", + agent_id="bound-agent-1", + conversation_id="bound-cid-1", + workspace=None, + agy_home=str(agy_cfg), + ) + ) + assert res_direct["ok"] is True + after_direct = json.loads(sidecar_path.read_text()) + assert after_direct["id"] == "user-heartbeat-id" + assert after_direct["enabled"] is False + assert after_direct["model"] == "gemini-2.5-pro-custom" + assert after_direct["agent_permissions"] == {"access_grants": []} + assert after_direct["custom_user_note"] == "preserve-me" + assert after_direct["args"][0] == "CRON_TZ=UTC */7 * * * *" + assert "bound-goal-1" in after_direct["args"] + assert "bound-agent-1" in after_direct["args"] + assert "bound-cid-1" in after_direct["args"] + + # Also test automatic sync through real `bind-agent-thread --host-surface antigravity-app --execute` + from loopx.global_registry import sync_project_registry_to_global + + registry_path, runtime_root, project = write_cli_fixture( + tmp_path / "cli-bind", + scoped_agents=True, + ) + sync_project_registry_to_global( + registry_path=registry_path, + runtime_root_override=str(runtime_root), + goal_id="full-speed", + dry_run=False, + ) + monkeypatch.setenv("PYTHONPATH", str(REPO_ROOT)) + monkeypatch.setenv("AGY_HOME", str(agy_cfg)) + cli_payload = run_json_cli( + "bind-agent-thread", + "--goal-id", + "full-speed", + "--agent-id", + SCOPED_AGENT_ID, + "--host-surface", + "antigravity-app", + "--thread-id", + "bound-cid-via-registry-admin", + "--execute", + registry_path=registry_path, + runtime_root=runtime_root, + cwd=project, + ) + assert cli_payload["ok"] is True + assert cli_payload["antigravity_host_config_sync"]["ok"] is True + after_cli = json.loads(sidecar_path.read_text()) + assert after_cli["id"] == "user-heartbeat-id" + assert after_cli["enabled"] is False + assert after_cli["model"] == "gemini-2.5-pro-custom" + assert after_cli["agent_permissions"] == {"access_grants": []} + assert after_cli["custom_user_note"] == "preserve-me" + assert after_cli["args"][0] == "CRON_TZ=UTC */7 * * * *" + assert "full-speed" in after_cli["args"] + assert SCOPED_AGENT_ID in after_cli["args"] + assert "bound-cid-via-registry-admin" in after_cli["args"] + mcp_ctx = runtime.resolve_bound_mcp_goal_context(agy_home=str(agy_cfg)) + assert mcp_ctx is not None + assert mcp_ctx["goal_id"] == "full-speed" + assert mcp_ctx["agent_id"] == SCOPED_AGENT_ID + assert Path(mcp_ctx["registry"]).resolve() == registry_path.resolve() + From 93941ee481687e665dc51367a99db5674f4854ad Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Mon, 2 Nov 2026 21:47:11 +0530 Subject: [PATCH 32/34] fix(agy): resolve workspace registry in bind-session and dual-runtime MCP fallback Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/agy_goal_mode/host_runtime.py | 43 +++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 11 deletions(-) diff --git a/loopx/agy_goal_mode/host_runtime.py b/loopx/agy_goal_mode/host_runtime.py index df32711017..47b56d22ac 100644 --- a/loopx/agy_goal_mode/host_runtime.py +++ b/loopx/agy_goal_mode/host_runtime.py @@ -1044,14 +1044,22 @@ def _flag_value(flag: str) -> str | None: registry_path = candidate if registry_path is None: - try: - rt_root = ( - Path(explicit_runtime_root).expanduser() - if explicit_runtime_root - else select_default_runtime_root() - ) - global_reg = global_registry_path(rt_root) - if global_reg.is_file(): + candidate_rt_roots: list[Path] = [] + if explicit_runtime_root: + candidate_rt_roots.append(Path(explicit_runtime_root).expanduser()) + else: + try: + candidate_rt_roots.append(select_default_runtime_root()) + except Exception: + pass + for fallback_rt in (Path.home() / ".loopx", Path.home() / ".goal-harness"): + if fallback_rt not in candidate_rt_roots: + candidate_rt_roots.append(fallback_rt) + for rt_root in candidate_rt_roots: + try: + global_reg = global_registry_path(rt_root) + if not global_reg.is_file(): + continue global_data = load_registry(global_reg) for g in global_data.get("goals") or []: if isinstance(g, dict) and str(g.get("id") or "") == goal_id: @@ -1064,8 +1072,10 @@ def _flag_value(flag: str) -> str | None: registry_path = find_registry(Path(str(repo)).expanduser()) if registry_path is not None: break - except Exception: - registry_path = None + if registry_path is not None: + break + except Exception: + continue if registry_path is None: return None @@ -1101,12 +1111,23 @@ def bind_session(args: argparse.Namespace) -> dict[str, Any]: "error_kind": "missing_goal_or_agent_id", "error": "--goal-id and --agent-id are required for bind-session.", } + registry = getattr(args, "registry", None) + if not registry: + try: + from loopx.goal_mode_context import find_registry + + ws = getattr(args, "workspace", None) + found_reg = find_registry(ws if ws else Path.cwd()) + if found_reg is not None: + registry = str(found_reg) + except Exception: + registry = None return bind_antigravity_session_configs( goal_id=goal_id, agent_id=agent_id, conversation_id=conversation_id, cli_bin=args.cli_bin, - registry=args.registry, + registry=registry, runtime_root=args.runtime_root, agy_home=getattr(args, "agy_home", None), ) From 5965ed0bc3354c0641f1dad91f7f7efc90f078fb Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:04:28 +0800 Subject: [PATCH 33/34] fix(antigravity): enforce structured sidecar ownership check and remove unused tool mapper Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/agy_goal_mode/host_runtime.py | 46 ++++-- .../native_chat/antigravity_adapter.py | 138 ------------------ tests/test_antigravity_host_runtime.py | 96 ++++++++++-- 3 files changed, 118 insertions(+), 162 deletions(-) diff --git a/loopx/agy_goal_mode/host_runtime.py b/loopx/agy_goal_mode/host_runtime.py index 47b56d22ac..7590731e5e 100644 --- a/loopx/agy_goal_mode/host_runtime.py +++ b/loopx/agy_goal_mode/host_runtime.py @@ -797,12 +797,46 @@ def sidecar_tick(args: argparse.Namespace) -> dict[str, Any]: ANTIGRAVITY_SIDECAR_MARKER_SCHEMA_VERSION = "loopx_managed_antigravity_sidecar_v0" +def _is_loopx_structured_heartbeat_args(existing_args: list[Any]) -> bool: + """Return True only when `args` is a policy-only cron entry or a canonical LoopX heartbeat argv.""" + if not existing_args: + return True + tokens = [str(item) for item in existing_args] + if not tokens[0].startswith("CRON_TZ="): + return False + if len(tokens) == 1: + return True + for idx in range(1, len(tokens) - 2): + if tokens[idx : idx + 3] == [ + "-m", + "loopx.agy_goal_mode.host_runtime", + "sidecar-tick", + ]: + return True + if ( + len(tokens) >= 4 + and tokens[1] == "agentapi" + and tokens[2] == "new-conversation" + and any( + token.startswith("LoopX periodic quota-gated heartbeat for goal ") + for token in tokens[3:] + ) + ): + return True + return False + + def _is_loopx_compatible_sidecar( existing_data: dict[str, Any], marker_payload: dict[str, Any] | None = None, ) -> bool: """Return True when an existing sidecar.json belongs to LoopX or is a user policy overlay on LoopX's heartbeat sidecar.""" - if isinstance(marker_payload, dict) and existing_data == marker_payload.get("sidecar"): + if ( + isinstance(marker_payload, dict) + and marker_payload.get("schema_version") + == ANTIGRAVITY_SIDECAR_MARKER_SCHEMA_VERSION + and existing_data == marker_payload.get("sidecar") + ): return True if any(key in existing_data for key in ("command", "script", "entrypoint")): return False @@ -813,14 +847,8 @@ def _is_loopx_compatible_sidecar( if existing_args is not None: if not isinstance(existing_args, list): return False - if len(existing_args) > 1: - joined = " ".join(str(item) for item in existing_args) - if "loopx" not in joined and "host_runtime" not in joined: - return False - elif len(existing_args) == 1: - first = str(existing_args[0]) - if not first.startswith("CRON_TZ=") and "loopx" not in first: - return False + if not _is_loopx_structured_heartbeat_args(existing_args): + return False return True diff --git a/loopx/capabilities/native_chat/antigravity_adapter.py b/loopx/capabilities/native_chat/antigravity_adapter.py index 54825e2b76..12b0b84b9e 100644 --- a/loopx/capabilities/native_chat/antigravity_adapter.py +++ b/loopx/capabilities/native_chat/antigravity_adapter.py @@ -93,144 +93,6 @@ def _check_hard_timeout(value: float | None) -> None: "fileChange": "Agent 正在修改文件", } -_NATIVE_TOOL_STEP_TYPES = frozenset({ - "CORTEX_STEP_TYPE_RUN_COMMAND", - "CORTEX_STEP_TYPE_VIEW_FILE", - "CORTEX_STEP_TYPE_CODE_ACTION", - "CORTEX_STEP_TYPE_GENERIC", -}) - - -def _parse_tool_args(raw: Any) -> dict[str, Any]: - if isinstance(raw, Mapping): - return dict(raw) - if isinstance(raw, str) and raw.strip(): - try: - parsed = json.loads(raw) - if isinstance(parsed, dict): - return parsed - except (TypeError, ValueError): - pass - return {} - - -def _antigravity_tool_item( - item_id: str, - tool_name: str, - args: Mapping[str, Any], - *, - step: Mapping[str, Any] | None = None, - status: str = "inProgress", -) -> dict[str, Any]: - step_type = str((step or {}).get("type") or "") - name = tool_name.strip() - if name == "run_command" or step_type == "CORTEX_STEP_TYPE_RUN_COMMAND": - run_cmd = (step or {}).get("runCommand") - run_cmd = run_cmd if isinstance(run_cmd, Mapping) else {} - command = str( - run_cmd.get("commandLine") - or args.get("CommandLine") - or args.get("command") - or args.get("toolSummary") - or "run_command" - ).strip() - combined = run_cmd.get("combinedOutput") - output = ( - str(combined.get("full") or "") - if isinstance(combined, Mapping) - else (str(combined) if isinstance(combined, str) else "") - ) - exit_code = ( - run_cmd.get("exitCode") - if type(run_cmd.get("exitCode")) is int - else (0 if status == "completed" else None) - ) - return { - "id": item_id, - "type": "commandExecution", - "command": command, - "commandActions": [{"type": "run", "command": command}], - "status": status, - **({"exitCode": exit_code} if exit_code is not None else {}), - **({"aggregatedOutput": output} if output else {}), - } - if name == "read_file" or step_type == "CORTEX_STEP_TYPE_VIEW_FILE": - view_file = (step or {}).get("viewFile") - view_file = view_file if isinstance(view_file, Mapping) else {} - uri = str(view_file.get("absolutePathUri") or "") - path = str( - args.get("Path") - or (uri[7:] if uri.startswith("file://") else uri) - or "" - ).strip() - filename = Path(path).name if path else "file" - command = path or filename - return { - "id": item_id, - "type": "commandExecution", - "command": command, - "commandActions": [ - {"type": "read", "path": path or filename, "name": filename, "command": command} - ], - "status": status, - **({"exitCode": 0} if status == "completed" else {}), - } - if name in {"edit_file", "write_file"} or step_type == "CORTEX_STEP_TYPE_CODE_ACTION": - code_action = (step or {}).get("codeAction") - code_action = code_action if isinstance(code_action, Mapping) else {} - action_spec = code_action.get("actionSpec") - spec_uri = "" - if isinstance(action_spec, Mapping): - for key in ("createFile", "editFile", "deleteFile"): - sub = action_spec.get(key) - if isinstance(sub, Mapping): - spec_uri = str(sub.get("absoluteUri") or sub.get("path") or "") - if spec_uri: - break - path = str( - args.get("Path") - or (spec_uri[7:] if spec_uri.startswith("file://") else spec_uri) - or name - or "file" - ).strip() - return { - "id": item_id, - "type": "fileChange", - "status": status, - "changes": [{"path": path}], - } - if name == "search_web": - query = str(args.get("query") or args.get("domain") or "search_web").strip() - return { - "id": item_id, - "type": "webSearch", - "query": query, - "action": {"type": "search", "query": query}, - "status": status, - } - if name == "call_mcp_tool": - server = str(args.get("ServerName") or "").strip() - mcp_tool = str(args.get("ToolName") or "mcp").strip() - return { - "id": item_id, - "type": "mcpToolCall", - "server": server, - "tool": mcp_tool, - "arguments": ( - dict(args["Arguments"]) - if isinstance(args.get("Arguments"), Mapping) - else dict(args) - ), - "status": status, - } - return { - "id": item_id, - "type": "dynamicToolCall", - "tool": name or step_type.removeprefix("CORTEX_STEP_TYPE_").lower() or "tool", - "arguments": dict(args), - "status": status, - } - class _LocalRedirectHandler(urllib.request.HTTPRedirectHandler): def redirect_request(self, req: Any, fp: Any, code: int, msg: str, diff --git a/tests/test_antigravity_host_runtime.py b/tests/test_antigravity_host_runtime.py index 5d59d4a2fc..5e2a0c73d9 100644 --- a/tests/test_antigravity_host_runtime.py +++ b/tests/test_antigravity_host_runtime.py @@ -718,23 +718,89 @@ def test_bind_session_and_bind_agent_thread_preserve_sidecar_policy_and_reject_f ) sidecar_path.parent.mkdir(parents=True, exist_ok=True) - # 1. Existing user-owned non-LoopX sidecar is rejected without modification - foreign_sidecar = { - "builtin": "schedule", - "args": ["CRON_TZ=UTC */5 * * * *", "/usr/local/bin/custom-user-job"], - "enabled": True, + monkeypatch.setenv("PYTHONPATH", str(REPO_ROOT)) + # 1. Existing user-owned non-LoopX sidecars (including script names/paths mentioning + # `loopx` or `host_runtime` without a managed marker) are rejected via CLI with exit 1 + for foreign_args in ( + ["CRON_TZ=UTC */5 * * * *", "/usr/local/bin/custom-user-job"], + ["CRON_TZ=UTC */5 * * * *", "python export_notes.py"], + ["CRON_TZ=UTC */5 * * * *", "python export_loopx_notes.py"], + ["CRON_TZ=UTC */5 * * * *", "python /opt/host_runtime/export_notes.py"], + ): + foreign_sidecar = { + "builtin": "schedule", + "args": list(foreign_args), + "enabled": True, + } + raw_before = json.dumps(foreign_sidecar, indent=2) + sidecar_path.write_text(raw_before) + marker_path = sidecar_path.parent / runtime.ANTIGRAVITY_SIDECAR_MARKER_NAME + if marker_path.exists(): + marker_path.unlink() + proc = subprocess.run( + [ + sys.executable, + "-m", + "loopx.agy_goal_mode.host_runtime", + "bind-session", + "--goal-id", + "work", + "--agent-id", + "worker", + "--conversation-id", + "cid-1", + "--agy-home", + str(agy_cfg), + ], + capture_output=True, + text=True, + check=False, + ) + assert proc.returncode == 1 + conflict_res = json.loads(proc.stdout) + assert conflict_res["ok"] is False + assert conflict_res["error_kind"] == "sidecar_ownership_conflict" + assert conflict_res["sidecar_status"] == "skipped_user_owned_sidecar_entry" + assert sidecar_path.read_text() == raw_before + assert not marker_path.exists() + + # 1b. Policy-only overlay (no `args` field, user-chosen id/model, disabled, empty grants) + # is accepted by CLI `bind-session` and preserves all user policy fields + policy_only_overlay = { + "id": "policy-only-id", + "enabled": False, + "model": "gemini-2.5-pro-policy", + "agent_permissions": {"access_grants": []}, } - sidecar_path.write_text(json.dumps(foreign_sidecar, indent=2)) - conflict_res = runtime.bind_antigravity_session_configs( - goal_id="work", - agent_id="worker", - conversation_id="cid-1", - agy_home=str(agy_cfg), + sidecar_path.write_text(json.dumps(policy_only_overlay, indent=2)) + proc_overlay = subprocess.run( + [ + sys.executable, + "-m", + "loopx.agy_goal_mode.host_runtime", + "bind-session", + "--goal-id", + "work", + "--agent-id", + "worker", + "--conversation-id", + "cid-overlay", + "--agy-home", + str(agy_cfg), + ], + capture_output=True, + text=True, + check=False, ) - assert conflict_res["ok"] is False - assert conflict_res["error_kind"] == "sidecar_ownership_conflict" - assert conflict_res["sidecar_status"] == "skipped_user_owned_sidecar_entry" - assert json.loads(sidecar_path.read_text()) == foreign_sidecar + assert proc_overlay.returncode == 0 + overlay_res = json.loads(proc_overlay.stdout) + assert overlay_res["ok"] is True + overlay_written = json.loads(sidecar_path.read_text()) + assert overlay_written["id"] == "policy-only-id" + assert overlay_written["enabled"] is False + assert overlay_written["model"] == "gemini-2.5-pro-policy" + assert overlay_written["agent_permissions"] == {"access_grants": []} + assert "cid-overlay" in overlay_written["args"] # 2. Existing LoopX heartbeat sidecar with user policy overrides (enabled=False, # custom id, custom model, empty access_grants, custom cron) preserves all From 05f97e9f6b979fe5a0349a2d02e90b48c0325ea2 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Tue, 3 Nov 2026 12:04:28 +0800 Subject: [PATCH 34/34] refactor(antigravity): converge to native schedule sidecar and minimal stop-hook contract Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/agy_goal_mode/__init__.py | 168 +--- loopx/agy_goal_mode/host_runtime.py | 1071 +---------------------- loopx/claude_goal_mode/mcp/loopx_mcp.py | 17 +- loopx/cli_commands/_host_thread.py | 29 +- loopx/cli_commands/registry_admin.py | 41 - loopx/host_loop_activation.py | 5 - loopx/hosts/skill_facade.py | 2 - loopx/slash_command_install.py | 16 +- tests/test_agy_host_surface.py | 19 +- tests/test_antigravity_host_runtime.py | 778 +--------------- 10 files changed, 31 insertions(+), 2115 deletions(-) diff --git a/loopx/agy_goal_mode/__init__.py b/loopx/agy_goal_mode/__init__.py index a801321bfb..bb2c73536d 100644 --- a/loopx/agy_goal_mode/__init__.py +++ b/loopx/agy_goal_mode/__init__.py @@ -110,15 +110,11 @@ def build_antigravity_sidecar_template( *, goal_id: str = "", agent_id: str | None = None, - conversation_id: str | None = None, runtime_root: str | None = None, - registry: str | None = None, cli_bin: str = "loopx", interval_seconds: int = 180, - resolve_local_executable: bool = False, ) -> dict[str, Any]: """Build a public-safe Antigravity 2.0 ``sidecar.json`` periodic heartbeat template.""" - conversation_id = conversation_id or os.environ.get("ANTIGRAVITY_CONVERSATION_ID") quota_args = [cli_bin, "--format", "json"] if runtime_root: quota_args.extend(["--runtime-root", runtime_root]) @@ -159,16 +155,6 @@ def build_antigravity_sidecar_template( ) interval_minutes = max(1, int(interval_seconds) // 60) cron_expr = cron_for_interval_minutes(interval_minutes) - provider_args = _antigravity_provider_args( - "sidecar-tick", - cli_bin=cli_bin, - goal_id=goal_id, - agent_id=agent_id, - conversation_id=conversation_id, - runtime_root=runtime_root, - registry=registry, - resolve_local_executable=resolve_local_executable, - ) return { "schema_version": "loopx_antigravity_sidecar_template_v0", "config_path": ANTIGRAVITY_SIDECAR_CONFIG_LABEL, @@ -177,7 +163,11 @@ def build_antigravity_sidecar_template( "builtin": "schedule", "args": [ f"CRON_TZ=UTC {cron_expr}", - *provider_args, + "agentapi", + "new-conversation", + "--model=pro", + "--", + bootstrap_prompt, ], "restart_policy": "always", "display_name": "LoopX Antigravity Heartbeat", @@ -230,24 +220,6 @@ def build_antigravity_sidecar_template( } -CONVERSATION_DISCOVERY_SENTINELS: frozenset[str] = frozenset( - { - "", - "auto", - "", - "", - } -) - - -def normalize_bound_conversation_id(value: str | None) -> str | None: - """Normalize an explicit conversation ID, returning None for discovery sentinels.""" - raw = str(value or "").strip() - if not raw or raw.lower() in CONVERSATION_DISCOVERY_SENTINELS: - return None - return raw - - def _antigravity_provider_args( operation: str, *, @@ -263,7 +235,6 @@ def _antigravity_provider_args( resolved_cli_bin = ( (shutil.which(cli_bin) or cli_bin) if resolve_local_executable else cli_bin ) - bound_cid = normalize_bound_conversation_id(conversation_id) args = [ python_bin, "-E", @@ -277,12 +248,9 @@ def _antigravity_provider_args( goal_id or "", "--agent-id", agent_id or "", + "--conversation-id", + conversation_id or "", ] - if operation == "bind-session": - if bound_cid is not None: - args.extend(["--conversation-id", bound_cid]) - else: - args.extend(["--conversation-id", bound_cid or ""]) if runtime_root: args.extend(["--runtime-root", runtime_root]) if registry: @@ -298,101 +266,6 @@ def cron_for_interval_minutes(interval_minutes: int) -> str: return f"*/{minutes} * * * *" -def build_antigravity_backoff_schedule_action( - scheduler_hint: dict[str, Any], - *, - current_task_id: str | None = None, - prompt: str = "", -) -> dict[str, Any]: - """Translate ``scheduler_hint.app_automation`` stateful backoff into Antigravity ``schedule`` / ``sidecar.json`` actions.""" - hint_root = ( - scheduler_hint.get("scheduler_hint") - if isinstance(scheduler_hint.get("scheduler_hint"), dict) - else scheduler_hint - ) - app = ( - hint_root.get("app_automation") - if isinstance(hint_root.get("app_automation"), dict) - else {} - ) - backoff = ( - app.get("stateful_backoff") - if isinstance(app.get("stateful_backoff"), dict) - else {} - ) - host_action = str(app.get("host_action") or app.get("action") or "none") - if host_action == "pause_or_delete_current_heartbeat": - return { - "action": "pause_or_delete_current_heartbeat", - "apply_needed": bool(app.get("host_action_required", True)), - "ack_needed": False, - "manage_task_kill": ( - {"Action": "kill", "TaskId": current_task_id} - if current_task_id - else None - ), - "schedule_call": None, - "ack_cli_args": None, - "failure_cli_args": None, - } - apply_needed = bool( - app.get("apply_needed") - if "apply_needed" in app - else backoff.get("apply_needed") - ) - ack_needed = bool( - app.get("ack_needed") if "ack_needed" in app else backoff.get("ack_needed") - ) - interval = int( - app.get("recommended_interval_minutes") - or backoff.get("current_interval_minutes") - or 3 - ) - cron_expr = cron_for_interval_minutes(interval) - ack_hint = app.get("ack_hint") if isinstance(app.get("ack_hint"), dict) else {} - failure_hint = ( - app.get("failure_hint") if isinstance(app.get("failure_hint"), dict) else {} - ) - return { - "action": ( - "update_current_heartbeat_rrule" - if apply_needed - else "ack_current_heartbeat_rrule" - if ack_needed - else "noop" - ), - "apply_needed": apply_needed, - "ack_needed": ack_needed, - "recommended_interval_minutes": interval, - "recommended_rrule": app.get("recommended_rrule"), - "cron_expression": cron_expr, - "manage_task_kill": ( - {"Action": "kill", "TaskId": current_task_id} - if apply_needed and current_task_id - else None - ), - "schedule_call": ( - { - "CronExpression": cron_expr, - "IsDaemon": True, - "Prompt": prompt, - } - if apply_needed - else None - ), - "ack_cli_args": ( - list(ack_hint.get("cli_args", [])) - if isinstance(ack_hint.get("cli_args"), list) - else None - ), - "failure_cli_args": ( - list(failure_hint.get("cli_args", [])) - if isinstance(failure_hint.get("cli_args"), list) - else None - ), - } - - def build_antigravity_hooks_template( *, cli_bin: str = "loopx", @@ -404,15 +277,13 @@ def build_antigravity_hooks_template( resolve_local_executable: bool = True, ) -> dict[str, Any]: """Native Stop hook; installation alone never binds an arbitrary chat.""" - bound_cid = normalize_bound_conversation_id( - conversation_id or os.environ.get("ANTIGRAVITY_CONVERSATION_ID") - ) + conversation_id = conversation_id or os.environ.get("ANTIGRAVITY_CONVERSATION_ID") args = _antigravity_provider_args( "stop-hook", cli_bin=cli_bin, goal_id=goal_id, agent_id=agent_id, - conversation_id=bound_cid, + conversation_id=conversation_id, runtime_root=runtime_root, registry=registry, resolve_local_executable=resolve_local_executable, @@ -420,11 +291,7 @@ def build_antigravity_hooks_template( return { "loopx": { "enabled": bool( - bound_cid - and agent_id - and agent_id != "" - and goal_id - and goal_id != "" + conversation_id and agent_id and goal_id and goal_id != "" ), "Stop": [{"type": "command", "command": shlex.join(args), "timeout": 30}], } @@ -496,7 +363,6 @@ def antigravity_app_activation_extras( *, goal_id: str = "", agent_id: str | None = None, - conversation_id: str | None = None, runtime_root: str | None = None, cli_bin: str = "loopx", ) -> dict[str, Any]: @@ -504,25 +370,13 @@ def antigravity_app_activation_extras( sidecar_template = build_antigravity_sidecar_template( goal_id=goal_id, agent_id=agent_id, - conversation_id=conversation_id, runtime_root=runtime_root, cli_bin=cli_bin, - resolve_local_executable=False, ) hooks_template = build_antigravity_hooks_template( cli_bin=cli_bin, goal_id=goal_id, agent_id=agent_id, - conversation_id=conversation_id, - runtime_root=runtime_root, - resolve_local_executable=False, - ) - bind_args = _antigravity_provider_args( - "bind-session", - cli_bin=cli_bin, - goal_id=goal_id, - agent_id=agent_id, - conversation_id=normalize_bound_conversation_id(conversation_id), runtime_root=runtime_root, resolve_local_executable=False, ) @@ -543,7 +397,6 @@ def antigravity_app_activation_extras( "host_mcp_config": ANTIGRAVITY_MCP_CONFIG_LABEL, "host_hooks_config": ANTIGRAVITY_HOOKS_CONFIG_LABEL, "host_sidecar_config": ANTIGRAVITY_SIDECAR_CONFIG_LABEL, - "bind_session_command": shlex.join(bind_args), "in_session_schedule_call": { "tool": "schedule", "arguments": { @@ -568,7 +421,6 @@ def antigravity_app_activation_extras( }, "extra_activation_steps": [ f"Install or refresh the Antigravity skill and optional MCP/hooks surface (`{cli_bin} slash-commands --install --surface antigravity-app --with-agy-mcp --with-agy-hooks` and `{cli_bin} workflow-skills --install --host-surface antigravity-app`).", - f"Bind the current Antigravity conversation to `hooks.json` and `sidecar.json` via `{shlex.join(bind_args)}` (or automatic `bind-agent-thread --host-surface antigravity-app --execute` sync).", "When a turn enters `waiting_external` or waits on CI/review checks, suspend directly with the native `schedule(DurationSeconds=, Prompt=...)` tool and end the turn instead of polling in a shell loop.", f"For background periodic heartbeats, write only `sidecar_template.sidecar_json` to `{ANTIGRAVITY_SIDECAR_CONFIG_LABEL}`. Preserve its existing id, model, permissions and host enable choice. Each fresh native heartbeat loads Core admission before work; do not persist an expanded execution body. Apply the bound `hooks_template` at the top level of hooks.json for bounded transport-error recovery; never wrap it in a `hooks` object.", ], diff --git a/loopx/agy_goal_mode/host_runtime.py b/loopx/agy_goal_mode/host_runtime.py index 733d18e37e..1e34082547 100644 --- a/loopx/agy_goal_mode/host_runtime.py +++ b/loopx/agy_goal_mode/host_runtime.py @@ -12,12 +12,10 @@ import json import os from pathlib import Path -import shutil import subprocess import sys import time from typing import Any -from urllib.parse import unquote, urlparse from ..file_lock import exclusive_file_lock @@ -180,9 +178,6 @@ def _native_input(args: argparse.Namespace, text: str) -> tuple[str, str, int] | return hashlib.sha256(anchor.encode()).hexdigest(), status, total -MAX_STOP_HOOK_RECOVERY_STEPS = 400 - - def stop_hook(args: argparse.Namespace, event: dict[str, Any]) -> dict[str, Any]: if ( event.get("conversationId") != args.conversation_id @@ -195,15 +190,7 @@ def stop_hook(args: argparse.Namespace, event: dict[str, Any]) -> dict[str, Any] if not isinstance(text, str) or not text: return {} native = _native_input(args, text) - max_steps = int( - getattr(args, "max_recovery_steps", None) or MAX_STOP_HOOK_RECOVERY_STEPS - ) - if ( - native is None - or native[2] > max_steps - or not _admitted(args) - or _native_input(args, text) != native - ): + if native is None or not _admitted(args) or _native_input(args, text) != native: return {} with _state(args) as state: if not _reserve_retry(state, native[0]): @@ -212,1065 +199,21 @@ def stop_hook(args: argparse.Namespace, event: dict[str, Any]) -> dict[str, Any] return {"decision": "continue", "reason": bootstrap_prompt(args)} -def _ls_endpoint() -> tuple[str, str] | None: - from ..capabilities.native_chat.antigravity_adapter import ( - _local_url, - discover_antigravity_language_server, - ) - - address = os.environ.get("ANTIGRAVITY_LS_ADDRESS") or os.environ.get( - "ANTIGRAVITY_LS_URL" - ) - token = os.environ.get("ANTIGRAVITY_CSRF_TOKEN") - if not address and not token: - endpoint = discover_antigravity_language_server() - if endpoint is None: - return None - address, token = endpoint["url"], endpoint["csrf_token"] - elif not address or not token: - return None - url = _local_url(address if "://" in address else "http://" + address) - return url, token - - -def _uri_to_resolved_path(uri: Any) -> Path | None: - if not isinstance(uri, str) or not uri: - return None - parsed = urlparse(uri) - if parsed.scheme == "file": - raw_path = unquote(parsed.path) - elif not parsed.scheme: - raw_path = uri - else: - return None - try: - return Path(raw_path).expanduser().resolve() - except OSError: - return None - - -def _git_common_dir(path: Path) -> Path | None: - git_bin = shutil.which("git") or "/usr/bin/git" - try: - proc = subprocess.run( - [git_bin, "-C", str(path), "rev-parse", "--git-common-dir"], - capture_output=True, - text=True, - timeout=2, - check=False, - ) - except Exception: - return None - if proc.returncode != 0: - return None - raw = proc.stdout.strip() - if not raw: - return None - candidate = Path(raw) - if not candidate.is_absolute(): - candidate = path / candidate - try: - return candidate.resolve() - except OSError: - return None - - -def _workspace_matches(summary: dict[str, Any], target_workspace: Path) -> bool: - workspaces = summary.get("workspaces") - if not isinstance(workspaces, list) or not workspaces: - return False - target_common_git = _git_common_dir(target_workspace) - for ws in workspaces: - if not isinstance(ws, dict): - continue - for key in ("workspaceFolderAbsoluteUri", "gitRootAbsoluteUri"): - ws_path = _uri_to_resolved_path(ws.get(key)) - if ws_path is None: - continue - if ( - ws_path == target_workspace - or target_workspace.is_relative_to(ws_path) - or ws_path.is_relative_to(target_workspace) - ): - return True - if target_common_git is not None: - ws_common_git = _git_common_dir(ws_path) - if ws_common_git is not None and ws_common_git == target_common_git: - return True - return False - - -def discover_active_conversation_id( - workspace: str | Path | None = None, -) -> str | None: - """Discover the currently active Antigravity conversation ID from Language Server. - - Only binds a conversation that unambiguously matches the target workspace - (or shares its underlying git repository/worktree). Foreign workspaces and - ambiguous multi-session workspaces return None so the caller fails closed. - """ - from . import normalize_bound_conversation_id - - env_cid = normalize_bound_conversation_id( - os.environ.get("ANTIGRAVITY_CONVERSATION_ID") - ) - if env_cid: - return env_cid - endpoint = _ls_endpoint() - if endpoint is None: - return None - from ..capabilities.native_chat.antigravity_adapter import _rpc_post - - url, token = endpoint - try: - summaries_resp = _rpc_post( - url, token, "GetAllCascadeTrajectories", {}, timeout=2 - ) - except Exception: - return None - summaries = (summaries_resp or {}).get("trajectorySummaries") - if not isinstance(summaries, dict) or not summaries: - return None - - target_workspace: Path | None = None - if workspace is not None: - try: - target_workspace = Path(workspace).expanduser().resolve() - except OSError: - return None - else: - try: - target_workspace = Path.cwd().resolve() - except OSError: - return None - - running_workspace_matches: list[tuple[str, int, str]] = [] - idle_workspace_matches: list[tuple[str, int, str]] = [] - - for cid, summary in summaries.items(): - if not isinstance(cid, str) or not cid or not isinstance(summary, dict): - continue - meta = summary.get("trajectoryMetadata") - if isinstance(meta, dict) and meta.get("parentConversationId"): - # Skip subagent trajectories when binding root session - continue - if not _workspace_matches(summary, target_workspace): - continue - status = str(summary.get("status") or "") - modified = str(summary.get("lastModifiedTime") or summary.get("createdTime") or "") - steps = int(summary.get("stepCount") or 0) - if status == "CASCADE_RUN_STATUS_RUNNING": - running_workspace_matches.append((modified, steps, cid)) - elif status == "CASCADE_RUN_STATUS_IDLE": - if ( - summary.get("latestExecutorTerminationReason") - != "EXECUTOR_TERMINATION_REASON_USER_CANCELED" - ): - idle_workspace_matches.append((modified, steps, cid)) - - if len(running_workspace_matches) == 1: - return running_workspace_matches[0][2] - if len(running_workspace_matches) > 1: - return None - if len(idle_workspace_matches) == 1: - return idle_workspace_matches[0][2] - return None - - -def _resolve_bound_session( - args: argparse.Namespace, -) -> tuple[str | None, str | None, str | None]: - from . import normalize_bound_conversation_id - - goal_id = (getattr(args, "goal_id", None) or "").strip() or None - if goal_id == "": - goal_id = None - agent_id = (getattr(args, "agent_id", None) or "").strip() or None - if agent_id == "": - agent_id = None - conversation_id = normalize_bound_conversation_id( - getattr(args, "conversation_id", None) - ) - if goal_id and agent_id and conversation_id: - return goal_id, agent_id, conversation_id - - try: - from ..cli_commands.support_control_registry import explicit_global_registry - from ..history import load_registry - from ..registry import registry_goals - from ..thread_agent_binding import normalize_thread_agent_bindings - - registry_path = ( - Path(args.registry).expanduser() - if getattr(args, "registry", None) - else explicit_global_registry(getattr(args, "runtime_root", None)) - ) - if not registry_path.exists(): - return goal_id, agent_id, conversation_id - payload = load_registry(registry_path) - candidates: list[tuple[str, str, str, str]] = [] - for goal in registry_goals(payload): - gid = str(goal.get("id") or "").strip() - if not gid or (goal_id and gid != goal_id): - continue - if str(goal.get("status") or "active") in { - "completed", - "archived", - "cancelled", - }: - continue - coordination = ( - goal.get("coordination") - if isinstance(goal.get("coordination"), dict) - else {} - ) - bindings = normalize_thread_agent_bindings( - coordination.get("thread_agent_bindings") - ) - for item in bindings: - if item.get("host_surface") not in { - "agy", - "antigravity-app", - "antigravity_app", - }: - continue - aid = str(item.get("agent_id") or "").strip() - tid = str(item.get("thread_id") or "").strip() - if not aid or not tid: - continue - if agent_id and aid != agent_id: - continue - if conversation_id and tid != conversation_id: - continue - bound_at = str(item.get("bound_at") or "") - candidates.append((bound_at, gid, aid, tid)) - if candidates: - candidates.sort(reverse=True) - _, resolved_gid, resolved_aid, resolved_tid = candidates[0] - return ( - goal_id or resolved_gid, - agent_id or resolved_aid, - conversation_id or resolved_tid, - ) - except Exception: - pass - return goal_id, agent_id, conversation_id - - -def _inspect_conversation_liveness(conversation_id: str) -> dict[str, Any]: - """Query Antigravity Language Server to determine whether the bound session is alive, running, or idle.""" - endpoint = _ls_endpoint() - if endpoint is None: - return { - "alive": False, - "running": False, - "idle": False, - "user_canceled": False, - "reason": "language_server_not_running", - } - from ..capabilities.native_chat.antigravity_adapter import _rpc_post - - url, token = endpoint - try: - traj = _rpc_post( - url, - token, - "GetCascadeTrajectory", - {"cascadeId": conversation_id}, - timeout=2, - ) - except Exception: - return { - "alive": False, - "running": False, - "idle": False, - "user_canceled": False, - "reason": "trajectory_rpc_error", - } - if ( - not isinstance(traj, dict) - or (traj.get("trajectory") or {}).get("cascadeId") != conversation_id - ): - return { - "alive": False, - "running": False, - "idle": False, - "user_canceled": False, - "reason": "session_not_found", - } - status = str(traj.get("status") or "") - termination_reason = "" - summary_available = False - try: - all_resp = _rpc_post( - url, token, "GetAllCascadeTrajectories", {}, timeout=2 - ) - summaries = (all_resp or {}).get("trajectorySummaries") - summary = ( - summaries.get(conversation_id) - if isinstance(summaries, dict) - else None - ) - if isinstance(summary, dict): - summary_available = True - termination_reason = str( - summary.get("latestExecutorTerminationReason") or "" - ) - if not status: - status = str(summary.get("status") or "") - except Exception: - summary_available = False - - user_canceled = ( - termination_reason == "EXECUTOR_TERMINATION_REASON_USER_CANCELED" - ) - running = status == "CASCADE_RUN_STATUS_RUNNING" - raw_idle = status == "CASCADE_RUN_STATUS_IDLE" - if raw_idle and not summary_available: - return { - "alive": True, - "running": False, - "idle": False, - "user_canceled": False, - "summary_available": False, - "status": status, - "termination_reason": "", - "num_total_steps": traj.get("numTotalSteps"), - "reason": "trajectory_summary_unavailable", - } - idle = raw_idle and summary_available - return { - "alive": running or raw_idle, - "running": running, - "idle": idle, - "user_canceled": user_canceled, - "summary_available": summary_available, - "status": status, - "termination_reason": termination_reason, - "num_total_steps": traj.get("numTotalSteps"), - "reason": ( - "session_running" - if running - else ( - "session_user_canceled" - if user_canceled - else ("session_idle" if idle else "session_inactive") - ) - ), - } - - -def _sync_sidecar_cron(sidecar_path: Path, cron_expression: str) -> bool: - if not sidecar_path.exists(): - return False - try: - data = json.loads(sidecar_path.read_text(encoding="utf-8")) - except Exception: - return False - if not isinstance(data, dict): - return False - args_list = data.get("args") - if not isinstance(args_list, list) or not args_list: - return False - desired = f"CRON_TZ=UTC {cron_expression}" - if args_list[0] == desired: - return False - args_list[0] = desired - tmp = sidecar_path.with_suffix(".tmp") - tmp.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") - tmp.replace(sidecar_path) - return True - - -def _run_core_json( - args: argparse.Namespace, command: list[str] -) -> dict[str, Any] | None: - env = {key: value for key, value in os.environ.items() if key != "PYTHONPATH"} - proc = subprocess.run( - _core_args(args, command), - capture_output=True, - text=True, - timeout=15, - check=False, - env=env, - ) - if proc.returncode != 0 and not proc.stdout.strip(): - return None - try: - parsed = json.loads(proc.stdout) - return parsed if isinstance(parsed, dict) else None - except Exception: - return None - - -def _run_cli_raw(args: argparse.Namespace, cli_args: list[str]) -> bool: - env = {key: value for key, value in os.environ.items() if key != "PYTHONPATH"} - cmd = [args.cli_bin, "--format", "json"] - for flag, value in ( - ("--registry", args.registry), - ("--runtime-root", args.runtime_root), - ): - if value: - cmd.extend([flag, value]) - cmd.extend(cli_args) - proc = subprocess.run( - cmd, - capture_output=True, - text=True, - timeout=15, - check=False, - env=env, - ) - return proc.returncode == 0 - - -def sidecar_tick(args: argparse.Namespace) -> dict[str, Any]: - """Execute one stateful-backoff-aware Antigravity sidecar tick. - - 1. Resolves the bound (goal_id, agent_id, conversation_id). - 2. Verifies the bound Antigravity session exists, is not user-canceled, and is IDLE - (if the session is already RUNNING or not alive, skips automation). - 3. Queries `loopx quota should-run --runtime-profile antigravity_app`, syncs the - sidecar cron expression with LoopX `stateful_backoff`, and acknowledges the - scheduler state (`quota scheduler-ack-current --antigravity_app`). - 4. Wakes the existing bound conversation in-place via `agentapi send-message` - (never spawning orphan conversations via `agentapi new-conversation`). - """ - from . import ( - antigravity_config_home, - build_antigravity_backoff_schedule_action, - ) - - goal_id, agent_id, conversation_id = _resolve_bound_session(args) - if not goal_id or not agent_id or not conversation_id: - return { - "ok": True, - "decision": "noop", - "reason": "unbound_session", - "goal_id": goal_id, - "agent_id": agent_id, - "conversation_id": conversation_id, - } - - bound_args = argparse.Namespace(**vars(args)) - bound_args.goal_id = goal_id - bound_args.agent_id = agent_id - bound_args.conversation_id = conversation_id - - liveness = _inspect_conversation_liveness(conversation_id) - if not liveness.get("alive"): - return { - "ok": True, - "decision": "noop", - "reason": liveness.get("reason", "session_not_alive"), - "goal_id": goal_id, - "agent_id": agent_id, - "conversation_id": conversation_id, - "liveness": liveness, - } - if liveness.get("running"): - return { - "ok": True, - "decision": "noop", - "reason": "session_already_running", - "goal_id": goal_id, - "agent_id": agent_id, - "conversation_id": conversation_id, - "liveness": liveness, - } - if liveness.get("user_canceled"): - return { - "ok": True, - "decision": "noop", - "reason": "session_user_canceled", - "goal_id": goal_id, - "agent_id": agent_id, - "conversation_id": conversation_id, - "liveness": liveness, - } - if not liveness.get("idle"): - return { - "ok": True, - "decision": "noop", - "reason": ( - "trajectory_summary_unavailable" - if liveness.get("reason") == "trajectory_summary_unavailable" - else "session_not_idle" - ), - "goal_id": goal_id, - "agent_id": agent_id, - "conversation_id": conversation_id, - "liveness": liveness, - } - - quota_payload = _run_core_json(bound_args, ["quota", "should-run"]) - if quota_payload is None: - return { - "ok": False, - "decision": "noop", - "reason": "quota_check_failed", - "goal_id": goal_id, - "agent_id": agent_id, - "conversation_id": conversation_id, - } - - backoff_action = build_antigravity_backoff_schedule_action(quota_payload) - sidecar_path = ( - Path(args.sidecar_config).expanduser() - if getattr(args, "sidecar_config", None) - else ( - antigravity_config_home(getattr(args, "agy_home", None)) - / "sidecars" - / "loopx-antigravity-heartbeat" - / "sidecar.json" - ) - ) - target_cron = str(backoff_action.get("cron_expression") or "*/3 * * * *") - cron_updated = False - if backoff_action.get("action") != "pause_or_delete_current_heartbeat": - cron_updated = _sync_sidecar_cron(sidecar_path, target_cron) - - ack_cli_args = backoff_action.get("ack_cli_args") - ack_executed = False - if ( - isinstance(ack_cli_args, list) - and ack_cli_args - and ( - backoff_action.get("apply_needed") - or backoff_action.get("ack_needed") - or cron_updated - ) - ): - ack_executed = _run_cli_raw( - bound_args, [str(item) for item in ack_cli_args] - ) - - should_run = quota_payload.get("should_run") is True - if ( - not should_run - or backoff_action.get("action") == "pause_or_delete_current_heartbeat" - ): - return { - "ok": True, - "decision": "noop", - "reason": "quota_should_not_run", - "goal_id": goal_id, - "agent_id": agent_id, - "conversation_id": conversation_id, - "effective_action": quota_payload.get("effective_action"), - "recommended_interval_minutes": backoff_action.get( - "recommended_interval_minutes" - ), - "cron_expression": target_cron, - "cron_updated": cron_updated, - "ack_executed": ack_executed, - } - - # Re-verify session is still idle right before sending the wake message. - recheck = _inspect_conversation_liveness(conversation_id) - if not recheck.get("idle") or recheck.get("running") or recheck.get("user_canceled"): - return { - "ok": True, - "decision": "noop", - "reason": "session_state_changed_before_wake", - "goal_id": goal_id, - "agent_id": agent_id, - "conversation_id": conversation_id, - "liveness": recheck, - "cron_updated": cron_updated, - "ack_executed": ack_executed, - } - - agentapi_bin = ( - getattr(args, "agentapi", None) - or shutil.which("agentapi") - or str(Path.home() / ".antigravity" / "antigravity" / "bin" / "agentapi") - ) - prompt = bootstrap_prompt(bound_args) - wake = subprocess.run( - [ - agentapi_bin, - "send-message", - "--title=LoopX Heartbeat", - conversation_id, - prompt, - ], - capture_output=True, - text=True, - timeout=15, - check=False, - ) - if wake.returncode != 0: - return { - "ok": False, - "decision": "wake_failed", - "reason": "agentapi_send_message_failed", - "goal_id": goal_id, - "agent_id": agent_id, - "conversation_id": conversation_id, - "returncode": wake.returncode, - "cron_updated": cron_updated, - "ack_executed": ack_executed, - } - return { - "ok": True, - "decision": "woke_bound_session", - "goal_id": goal_id, - "agent_id": agent_id, - "conversation_id": conversation_id, - "recommended_interval_minutes": backoff_action.get( - "recommended_interval_minutes" - ), - "cron_expression": target_cron, - "cron_updated": cron_updated, - "ack_executed": ack_executed, - } - - -ANTIGRAVITY_SIDECAR_MARKER_NAME = ".loopx-managed-sidecar.json" -ANTIGRAVITY_SIDECAR_MARKER_SCHEMA_VERSION = "loopx_managed_antigravity_sidecar_v0" - - -_PYTHON_SIMPLE_FLAGS = frozenset( - { - "-b", - "-bb", - "-B", - "-d", - "-E", - "-i", - "-I", - "-O", - "-OO", - "-P", - "-q", - "-s", - "-S", - "-u", - "-v", - "-x", - } -) -_PYTHON_ARG_FLAGS = frozenset({"-W", "-X", "--check-hash-based-pycs"}) - - -def _is_python_executable_token(token: str) -> bool: - name = Path(token).name.lower() - if name.endswith(".exe"): - name = name[:-4] - if name in {"python", "python3", "pypy", "pypy3"}: - return True - if name.startswith("python3.") or name.startswith("python2."): - suffix = name.split(".", 1)[1] - return bool(suffix) and all(ch.isdigit() or ch == "t" for ch in suffix) - return False - - -def _is_loopx_structured_heartbeat_args(existing_args: list[Any]) -> bool: - """Return True only when `args` is a policy-only cron entry or a canonical LoopX heartbeat argv.""" - if not existing_args: - return True - tokens = [str(item) for item in existing_args] - if not tokens[0].startswith("CRON_TZ="): - return False - if len(tokens) == 1: - return True - if _is_python_executable_token(tokens[1]): - idx = 2 - while idx < len(tokens): - tok = tokens[idx] - if tok in _PYTHON_SIMPLE_FLAGS: - idx += 1 - elif tok in _PYTHON_ARG_FLAGS: - idx += 2 - elif (tok.startswith("-W") or tok.startswith("-X")) and len(tok) > 2: - idx += 1 - else: - break - if tokens[idx : idx + 3] == [ - "-m", - "loopx.agy_goal_mode.host_runtime", - "sidecar-tick", - ]: - return True - if ( - len(tokens) >= 4 - and tokens[1] == "agentapi" - and tokens[2] == "new-conversation" - and any( - token.startswith("LoopX periodic quota-gated heartbeat for goal ") - for token in tokens[3:] - ) - ): - return True - return False - - -def _is_loopx_compatible_sidecar( - existing_data: dict[str, Any], - marker_payload: dict[str, Any] | None = None, -) -> bool: - """Return True when an existing sidecar.json belongs to LoopX or is a user policy overlay on LoopX's heartbeat sidecar.""" - if ( - isinstance(marker_payload, dict) - and marker_payload.get("schema_version") - == ANTIGRAVITY_SIDECAR_MARKER_SCHEMA_VERSION - and existing_data == marker_payload.get("sidecar") - ): - return True - if any(key in existing_data for key in ("command", "script", "entrypoint")): - return False - builtin = existing_data.get("builtin") - if builtin is not None and builtin != "schedule": - return False - existing_args = existing_data.get("args") - if existing_args is not None: - if not isinstance(existing_args, list): - return False - if not _is_loopx_structured_heartbeat_args(existing_args): - return False - return True - - -def bind_antigravity_session_configs( - *, - goal_id: str, - agent_id: str, - conversation_id: str, - cli_bin: str = "loopx", - registry: str | None = None, - runtime_root: str | None = None, - agy_home: str | None = None, - interval_minutes: int = 3, - write_sidecar: bool = True, - write_hooks: bool = True, -) -> dict[str, Any]: - """Atomically bind local Antigravity `hooks.json` and `sidecar.json` to `(goal_id, agent_id, conversation_id)`.""" - from . import ( - antigravity_config_home, - build_antigravity_sidecar_template, - normalize_bound_conversation_id, - ) - from ..slash_command_install import _merge_managed_hooks_json - - normalized_cid = normalize_bound_conversation_id(conversation_id) - normalized_gid = (goal_id or "").strip() - normalized_aid = (agent_id or "").strip() - if not normalized_cid or not normalized_gid or normalized_gid == "" or not normalized_aid or normalized_aid == "": - return { - "ok": False, - "error_kind": "conversation_id_unresolved" if not normalized_cid else "missing_goal_or_agent_id", - "error": "Concrete goal_id, agent_id, and conversation_id are required before writing Antigravity configs.", - } - - config_root = antigravity_config_home(agy_home) - hooks_path = config_root / "hooks.json" - sidecar_path = ( - config_root - / "sidecars" - / "loopx-antigravity-heartbeat" - / "sidecar.json" - ) - sidecar_marker_path = sidecar_path.parent / ANTIGRAVITY_SIDECAR_MARKER_NAME - - existing_sidecar_data: dict[str, Any] | None = None - if write_sidecar and sidecar_path.exists(): - try: - raw_text = sidecar_path.read_text(encoding="utf-8") - parsed_sidecar = json.loads(raw_text) if raw_text.strip() else {} - except (OSError, json.JSONDecodeError): - return { - "ok": False, - "error_kind": "invalid_sidecar_config", - "error": f"Existing {sidecar_path} is not valid JSON.", - "hooks_path": str(hooks_path), - "hooks_status": "skipped", - "sidecar_path": str(sidecar_path), - "sidecar_status": "blocked_invalid_antigravity_sidecar_json", - } - if not isinstance(parsed_sidecar, dict): - return { - "ok": False, - "error_kind": "invalid_sidecar_config", - "error": f"Existing {sidecar_path} root must be a JSON object.", - "hooks_path": str(hooks_path), - "hooks_status": "skipped", - "sidecar_path": str(sidecar_path), - "sidecar_status": "blocked_invalid_antigravity_sidecar_json", - } - try: - marker_payload = json.loads(sidecar_marker_path.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - marker_payload = None - if not _is_loopx_compatible_sidecar(parsed_sidecar, marker_payload): - return { - "ok": False, - "error_kind": "sidecar_ownership_conflict", - "error": f"Existing {sidecar_path} belongs to a non-LoopX command and will not be overwritten.", - "hooks_path": str(hooks_path), - "hooks_status": "skipped", - "sidecar_path": str(sidecar_path), - "sidecar_status": "skipped_user_owned_sidecar_entry", - } - existing_sidecar_data = parsed_sidecar - - hooks_status = "skipped" - if write_hooks: - hooks_status = _merge_managed_hooks_json( - config_path=hooks_path, - cli_bin=cli_bin, - uninstall=False, - execute=True, - goal_id=normalized_gid, - agent_id=normalized_aid, - conversation_id=normalized_cid, - registry=registry, - runtime_root=runtime_root, - ) - if hooks_status.startswith("blocked_") or hooks_status.startswith("skipped_user_owned_"): - return { - "ok": False, - "error_kind": "hooks_ownership_conflict", - "error": f"Could not update {hooks_path}: {hooks_status}", - "hooks_path": str(hooks_path), - "hooks_status": hooks_status, - "sidecar_path": str(sidecar_path), - "sidecar_status": "skipped", - } - - sidecar_status = "skipped" - if write_sidecar: - existing_cron: str | None = None - if existing_sidecar_data is not None: - existing_args = existing_sidecar_data.get("args") - if isinstance(existing_args, list) and existing_args and isinstance(existing_args[0], str): - if existing_args[0].startswith("CRON_TZ="): - existing_cron = existing_args[0] - sidecar_template = build_antigravity_sidecar_template( - goal_id=normalized_gid, - agent_id=normalized_aid, - conversation_id=normalized_cid, - cli_bin=cli_bin, - interval_seconds=max(60, int(interval_minutes) * 60), - registry=registry, - runtime_root=runtime_root, - resolve_local_executable=True, - ) - template_json = dict(sidecar_template["sidecar_json"]) - template_args = list(template_json.get("args") or []) - if existing_cron and interval_minutes == 3 and template_args: - template_args[0] = existing_cron - - if existing_sidecar_data is not None: - sidecar_json = dict(existing_sidecar_data) - sidecar_json["builtin"] = template_json["builtin"] - sidecar_json["args"] = template_args - sidecar_json["description"] = template_json["description"] - sidecar_json.setdefault("restart_policy", template_json["restart_policy"]) - sidecar_json.setdefault("display_name", template_json["display_name"]) - sidecar_json.setdefault("agent_permissions", template_json["agent_permissions"]) - else: - sidecar_json = template_json - sidecar_json["args"] = template_args - - sidecar_path.parent.mkdir(parents=True, exist_ok=True) - tmp = sidecar_path.with_suffix(".tmp") - tmp.write_text(json.dumps(sidecar_json, indent=2) + "\n", encoding="utf-8") - tmp.replace(sidecar_path) - marker_tmp = sidecar_marker_path.with_suffix(".tmp") - marker_tmp.write_text( - json.dumps( - { - "schema_version": ANTIGRAVITY_SIDECAR_MARKER_SCHEMA_VERSION, - "sidecar": sidecar_json, - }, - indent=2, - ) - + "\n", - encoding="utf-8", - ) - marker_tmp.replace(sidecar_marker_path) - sidecar_status = "written" - return { - "ok": True, - "goal_id": normalized_gid, - "agent_id": normalized_aid, - "conversation_id": normalized_cid, - "hooks_path": str(hooks_path), - "hooks_status": hooks_status, - "sidecar_path": str(sidecar_path), - "sidecar_status": sidecar_status, - } - - -def resolve_bound_mcp_goal_context( - agy_home: str | Path | None = None, -) -> dict[str, Any] | None: - """Resolve Goal/agent context from the bound Antigravity sidecar when MCP runs with cwd=/.""" - from . import antigravity_config_home, normalize_bound_conversation_id - from loopx.goal_mode_context import find_registry, resolve_goal_context - from loopx.history import load_registry - from loopx.paths import global_registry_path, select_default_runtime_root - - cfg_root = antigravity_config_home(agy_home) - sidecar_path = cfg_root / "sidecars" / "loopx-antigravity-heartbeat" / "sidecar.json" - if not sidecar_path.is_file(): - return None - try: - sidecar = json.loads(sidecar_path.read_text(encoding="utf-8")) - except Exception: - return None - if not _is_loopx_compatible_sidecar(sidecar): - return None - args = [str(item) for item in (sidecar.get("args") or [])] - - def _flag_value(flag: str) -> str | None: - if flag in args: - idx = args.index(flag) - if idx + 1 < len(args): - return args[idx + 1].strip() or None - return None - - goal_id = _flag_value("--goal-id") - agent_id = _flag_value("--agent-id") - conversation_id = normalize_bound_conversation_id(_flag_value("--conversation-id")) - if ( - not goal_id - or goal_id == "" - or not agent_id - or agent_id == "" - or not conversation_id - ): - return None - - explicit_registry = _flag_value("--registry") - explicit_runtime_root = _flag_value("--runtime-root") - registry_path: Path | None = None - if explicit_registry: - candidate = Path(explicit_registry).expanduser() - if candidate.is_file(): - registry_path = candidate - - if registry_path is None: - candidate_rt_roots: list[Path] = [] - if explicit_runtime_root: - candidate_rt_roots.append(Path(explicit_runtime_root).expanduser()) - else: - try: - candidate_rt_roots.append(select_default_runtime_root()) - except Exception: - pass - for fallback_rt in (Path.home() / ".loopx", Path.home() / ".goal-harness"): - if fallback_rt not in candidate_rt_roots: - candidate_rt_roots.append(fallback_rt) - for rt_root in candidate_rt_roots: - try: - global_reg = global_registry_path(rt_root) - if not global_reg.is_file(): - continue - global_data = load_registry(global_reg) - for g in global_data.get("goals") or []: - if isinstance(g, dict) and str(g.get("id") or "") == goal_id: - src_reg = g.get("source_registry") - if src_reg and Path(str(src_reg)).expanduser().is_file(): - registry_path = Path(str(src_reg)).expanduser() - break - repo = g.get("repo") - if repo: - registry_path = find_registry(Path(str(repo)).expanduser()) - if registry_path is not None: - break - if registry_path is not None: - break - except Exception: - continue - - if registry_path is None: - return None - return resolve_goal_context( - registry_path.parent.parent, - preferred_goal_id=goal_id, - preferred_agent_id=agent_id, - require_preferred_binding=True, - ) - - -def bind_session(args: argparse.Namespace) -> dict[str, Any]: - from . import normalize_bound_conversation_id - - conversation_id = normalize_bound_conversation_id( - getattr(args, "conversation_id", None) - ) - if not conversation_id: - conversation_id = discover_active_conversation_id( - workspace=getattr(args, "workspace", None) - ) - if not conversation_id: - return { - "ok": False, - "error_kind": "conversation_id_unresolved", - "error": "Could not auto-discover active Antigravity conversation ID; pass --conversation-id explicitly.", - } - goal_id = (getattr(args, "goal_id", None) or "").strip() - agent_id = (getattr(args, "agent_id", None) or "").strip() - if not goal_id or goal_id == "" or not agent_id or agent_id == "": - return { - "ok": False, - "error_kind": "missing_goal_or_agent_id", - "error": "--goal-id and --agent-id are required for bind-session.", - } - registry = getattr(args, "registry", None) - if not registry: - try: - from loopx.goal_mode_context import find_registry - - ws = getattr(args, "workspace", None) - found_reg = find_registry(ws if ws else Path.cwd()) - if found_reg is not None: - registry = str(found_reg) - except Exception: - registry = None - return bind_antigravity_session_configs( - goal_id=goal_id, - agent_id=agent_id, - conversation_id=conversation_id, - cli_bin=args.cli_bin, - registry=registry, - runtime_root=args.runtime_root, - agy_home=getattr(args, "agy_home", None), - ) - - def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument( - "operation", - choices=("stop-hook", "sidecar-tick", "bind-session"), - ) + parser.add_argument("operation", choices=("stop-hook",)) parser.add_argument("--cli-bin", default="loopx") parser.add_argument("--registry") parser.add_argument("--runtime-root") - parser.add_argument("--goal-id") - parser.add_argument("--agent-id") - parser.add_argument("--conversation-id") - parser.add_argument("--workspace") - parser.add_argument("--agy-home") - parser.add_argument("--sidecar-config") - parser.add_argument("--agentapi") + parser.add_argument("--goal-id", required=True) + parser.add_argument("--agent-id", required=True) + parser.add_argument("--conversation-id", required=True) parser.add_argument( "--state-dir", default=str(Path.home() / ".loopx/host-state/antigravity") ) args = parser.parse_args() try: - if args.operation == "stop-hook": - if not args.goal_id or not args.agent_id or not args.conversation_id: - result = {} - else: - result = stop_hook(args, json.load(sys.stdin)) - elif args.operation == "sidecar-tick": - result = sidecar_tick(args) - else: - result = bind_session(args) + result = stop_hook(args, json.load(sys.stdin)) except Exception as exc: # Hook stdout must remain valid native JSON. Provider detail may contain # credentials or private context; expose only the failure class. @@ -1280,8 +223,6 @@ def main() -> int: ) result = {} print(json.dumps(result)) - if args.operation == "bind-session" and not result.get("ok"): - return 1 return 0 diff --git a/loopx/claude_goal_mode/mcp/loopx_mcp.py b/loopx/claude_goal_mode/mcp/loopx_mcp.py index 6bb1fb9493..57627e2199 100644 --- a/loopx/claude_goal_mode/mcp/loopx_mcp.py +++ b/loopx/claude_goal_mode/mcp/loopx_mcp.py @@ -24,22 +24,7 @@ legacy_host_surface="claude_code", setup_hint="run /loopx first", ) -def _resolve_mcp_context() -> dict | None: - cwd = Path.cwd() - ctx = goal_context(cwd) - if ctx is not None: - return ctx - try: - if cwd.resolve() == Path("/"): - from loopx.agy_goal_mode.host_runtime import resolve_bound_mcp_goal_context - - return resolve_bound_mcp_goal_context() - except Exception: - return None - return None - - -mcp, control = create_fastmcp_server(CONFIG, _resolve_mcp_context) +mcp, control = create_fastmcp_server(CONFIG, lambda: goal_context(Path.cwd())) # Compatibility names used by existing smoke tests and local integrations. should_run = control.should_run diff --git a/loopx/cli_commands/_host_thread.py b/loopx/cli_commands/_host_thread.py index 7d0c51cc4c..dde98cef63 100644 --- a/loopx/cli_commands/_host_thread.py +++ b/loopx/cli_commands/_host_thread.py @@ -3,7 +3,6 @@ import argparse import os -from ..agy_goal_mode import ANTIGRAVITY_APP_AGENT_TYPE from ..kiro_cli_goal_mode import KIRO_CLI_AGENT_TYPE, KIRO_CLI_SESSION_ID_ENV # Host surface -> the environment variable that host exports for its own @@ -17,44 +16,22 @@ "codex-ide-plugin": "CODEX_THREAD_ID", "codex-cli-tui": "CODEX_THREAD_ID", "trae_app": "TRAECLI_THREAD_ID", - "agy": "ANTIGRAVITY_CONVERSATION_ID", - ANTIGRAVITY_APP_AGENT_TYPE: "ANTIGRAVITY_CONVERSATION_ID", - "antigravity_app": "ANTIGRAVITY_CONVERSATION_ID", KIRO_CLI_AGENT_TYPE: KIRO_CLI_SESSION_ID_ENV, } -def ambient_host_thread_id( - host_surface: str, - *, - workspace: str | None = None, -) -> str | None: +def ambient_host_thread_id(host_surface: str) -> str | None: """Read the host-provided local context, never a caller's route override. This is a trusted-local-runtime fence, not authentication against another process that can change this OS user's environment or canonical files. """ variable = HOST_THREAD_ID_ENV.get(host_surface) - env_value = (os.environ.get(variable) or None) if variable else None - if env_value: - return env_value - if host_surface in {"agy", ANTIGRAVITY_APP_AGENT_TYPE, "antigravity_app"}: - if os.environ.get("PYTEST_CURRENT_TEST") and not ( - os.environ.get("ANTIGRAVITY_LS_ADDRESS") - or os.environ.get("ANTIGRAVITY_LS_URL") - ): - return None - from ..agy_goal_mode.host_runtime import discover_active_conversation_id - - return discover_active_conversation_id(workspace=workspace) - return None + return (os.environ.get(variable) or None) if variable else None def current_host_thread_id(args: argparse.Namespace) -> str | None: explicit = getattr(args, "thread_id", None) if explicit: return str(explicit) - return ambient_host_thread_id( - str(getattr(args, "host_surface", None) or ""), - workspace=getattr(args, "project", None), - ) + return ambient_host_thread_id(str(getattr(args, "host_surface", None) or "")) diff --git a/loopx/cli_commands/registry_admin.py b/loopx/cli_commands/registry_admin.py index cec4227da0..f5f85031c1 100644 --- a/loopx/cli_commands/registry_admin.py +++ b/loopx/cli_commands/registry_admin.py @@ -720,47 +720,6 @@ def handle_registry_admin_command( payload["ok"] = bool(payload["global_sync"].get("ok")) and readback_verified if not payload["ok"]: payload["error_kind"] = "thread_agent_binding_readback_failed" - elif ( - args.command == "bind-agent-thread" - and args.host_surface - in {"agy", "antigravity-app", "antigravity_app"} - ): - try: - from ..agy_goal_mode import antigravity_config_home - from ..agy_goal_mode.host_runtime import ( - bind_antigravity_session_configs, - ) - from ..slash_command_install import ( - ANTIGRAVITY_HOOKS_MARKER_NAME, - ) - - cfg_root = antigravity_config_home() - has_managed_hooks = ( - cfg_root / ANTIGRAVITY_HOOKS_MARKER_NAME - ).exists() - has_managed_sidecar = ( - cfg_root - / "sidecars" - / "loopx-antigravity-heartbeat" - / "sidecar.json" - ).exists() - if has_managed_hooks or has_managed_sidecar: - payload["antigravity_host_config_sync"] = ( - bind_antigravity_session_configs( - goal_id=args.goal_id, - agent_id=args.agent_id, - conversation_id=args.thread_id, - registry=str(source_path), - runtime_root=str(global_path.parent), - write_hooks=has_managed_hooks, - write_sidecar=has_managed_sidecar, - ) - ) - except Exception as sync_exc: - payload["antigravity_host_config_sync"] = { - "ok": False, - "error": str(sync_exc), - } else: payload["global_sync"] = {"enabled": bool(args.execute), "wrote": False} except Exception as exc: diff --git a/loopx/host_loop_activation.py b/loopx/host_loop_activation.py index 9143a57d69..903214ff0f 100644 --- a/loopx/host_loop_activation.py +++ b/loopx/host_loop_activation.py @@ -1324,11 +1324,6 @@ def build_host_loop_activation_packet( cli_bin, goal_id=goal_id, agent_id=str(selected_agent_id) if selected_agent_id else None, - conversation_id=( - str(thread_binding.get("thread_id")) - if isinstance(thread_binding, dict) and thread_binding.get("thread_id") - else None - ), runtime_root=runtime_root, ) elif canonical == "kiro-cli": diff --git a/loopx/hosts/skill_facade.py b/loopx/hosts/skill_facade.py index 61ffb58211..ef32d65f9e 100644 --- a/loopx/hosts/skill_facade.py +++ b/loopx/hosts/skill_facade.py @@ -170,7 +170,6 @@ def antigravity_app_activation( *, goal_id: str = "", agent_id: str | None = None, - conversation_id: str | None = None, runtime_root: str | None = None, ) -> dict[str, Any]: return skill_facade_cli_activation( @@ -183,7 +182,6 @@ def antigravity_app_activation( **antigravity_app_activation_extras( goal_id=goal_id, agent_id=agent_id, - conversation_id=conversation_id, runtime_root=runtime_root, cli_bin=cli_bin, ), diff --git a/loopx/slash_command_install.py b/loopx/slash_command_install.py index 0ada7146e9..72e024a624 100644 --- a/loopx/slash_command_install.py +++ b/loopx/slash_command_install.py @@ -917,12 +917,6 @@ def _merge_managed_hooks_json( cli_bin: str, uninstall: bool, execute: bool, - goal_id: str | None = None, - agent_id: str | None = None, - conversation_id: str | None = None, - registry: str | None = None, - runtime_root: str | None = None, - resolve_local_executable: bool = True, ) -> str: """Add or remove the managed ``loopx`` Stop recovery hook in Antigravity ``hooks.json``.""" marker_path = config_path.parent / ANTIGRAVITY_HOOKS_MARKER_NAME @@ -976,15 +970,7 @@ def _merge_managed_hooks_json( if existing is not None and not loopx_owned: return "skipped_user_owned_hooks_entry" - entry = build_antigravity_hooks_template( - cli_bin=cli_bin, - goal_id=goal_id, - agent_id=agent_id, - conversation_id=conversation_id, - registry=registry, - runtime_root=runtime_root, - resolve_local_executable=resolve_local_executable, - )[MANAGED_MCP_KEY] + entry = build_antigravity_hooks_template(cli_bin=cli_bin)[MANAGED_MCP_KEY] if not execute: return "unchanged" if existing == entry and not legacy_owned else "would_write" if existing == entry and not legacy_owned: diff --git a/tests/test_agy_host_surface.py b/tests/test_agy_host_surface.py index 7ee982fb95..54599097c7 100644 --- a/tests/test_agy_host_surface.py +++ b/tests/test_agy_host_surface.py @@ -303,18 +303,12 @@ def test_antigravity_app_first_class_host_surface_and_schedule_sidecar_contract( sidecar_json = mutation["sidecar_template"]["sidecar_json"] assert sidecar_json["builtin"] == "schedule" assert sidecar_json["restart_policy"] == "always" - assert sidecar_json["args"][1:8] == [ - "python3", - "-E", - "-P", - "-m", - "loopx.agy_goal_mode.host_runtime", - "sidecar-tick", - "--cli-bin", + assert sidecar_json["args"][1:5] == [ + "agentapi", + "new-conversation", + "--model=pro", + "--", ] - assert "new-conversation" not in sidecar_json["args"] - assert "bind-session" in mutation["bind_session_command"] - assert "--conversation-id auto" not in mutation["bind_session_command"] assert ( "antigravity_app" in mutation["sidecar_template"]["sidecar"]["loopx-heartbeat"]["gate_command"] @@ -563,7 +557,6 @@ def test_antigravity_dual_skills_mcp_hooks_workflow_skills_and_doctor( def test_antigravity_app_real_cli_ack_and_stateful_backoff_identity( tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, ) -> None: """Verify Antigravity App (`--antigravity_app`) runs real CLI `quota should-run`, `quota scheduler-ack-current`, and `quota scheduler-fail-current` with independent @@ -577,12 +570,10 @@ def test_antigravity_app_real_cli_ack_and_stateful_backoff_identity( load_scheduler_state, ) from loopx.control_plane.testing.canary_harness import ( - REPO_ROOT, run_json_cli, run_json_cli_result, ) - monkeypatch.setenv("PYTHONPATH", str(REPO_ROOT)) goal_id = "needs-operator" registry_path, runtime_root, project = write_cli_fixture( tmp_path / "fixture", diff --git a/tests/test_antigravity_host_runtime.py b/tests/test_antigravity_host_runtime.py index bcb7021d20..fe79173ac0 100644 --- a/tests/test_antigravity_host_runtime.py +++ b/tests/test_antigravity_host_runtime.py @@ -177,20 +177,13 @@ def test_generated_native_configs_execute_the_stdio_hook_from_another_directory( ) assert build_antigravity_hooks_template()["loopx"]["enabled"] is False assert "sidecar_json" in build_antigravity_sidecar_template() - kwargs = dict( - goal_id="work", - agent_id="worker", - conversation_id="native-chat", - resolve_local_executable=True, - ) - config = build_antigravity_sidecar_template(**kwargs)["sidecar_json"] + kwargs = dict(goal_id="work", agent_id="worker", conversation_id="native-chat") + config = build_antigravity_sidecar_template(goal_id="work", agent_id="worker")[ + "sidecar_json" + ] assert config["builtin"] == "schedule" assert config["args"][0] == "CRON_TZ=UTC */3 * * * *" - assert "loopx.agy_goal_mode.host_runtime" in config["args"] - assert "sidecar-tick" in config["args"] - assert "--conversation-id" in config["args"] - assert "native-chat" in config["args"] - assert "new-conversation" not in config["args"] + assert config["args"][1:5] == ["agentapi", "new-conversation", "--model=pro", "--"] hook = build_antigravity_hooks_template(**kwargs)["loopx"] assert hook["enabled"] is True # The installed native contract consumes one event on stdin and one native @@ -207,259 +200,6 @@ def test_generated_native_configs_execute_the_stdio_hook_from_another_directory( assert json.loads(result.stdout) == {} -def test_sidecar_tick_checks_session_liveness_syncs_stateful_backoff_and_wakes_in_place( - tmp_path, monkeypatch -): - sidecar_path = tmp_path / "sidecar.json" - sidecar_template = build_antigravity_sidecar_template( - goal_id="work", - agent_id="worker", - conversation_id="native-chat", - ) - sidecar_path.write_text(json.dumps(sidecar_template["sidecar_json"], indent=2)) - - args = bound(tmp_path) - args.sidecar_config = str(sidecar_path) - args.agy_home = str(tmp_path) - - # 1. Language server not running -> noop, never calls quota or agentapi - monkeypatch.setattr( - runtime, - "_inspect_conversation_liveness", - lambda _cid: { - "alive": False, - "running": False, - "idle": False, - "user_canceled": False, - "reason": "language_server_not_running", - }, - ) - res = runtime.sidecar_tick(args) - assert res["decision"] == "noop" - assert res["reason"] == "language_server_not_running" - - # 2. Session is actively RUNNING -> noop, do not disturb or double-trigger - monkeypatch.setattr( - runtime, - "_inspect_conversation_liveness", - lambda _cid: { - "alive": True, - "running": True, - "idle": False, - "user_canceled": False, - "reason": "session_running", - }, - ) - res = runtime.sidecar_tick(args) - assert res["decision"] == "noop" - assert res["reason"] == "session_already_running" - - # 3. Session was stopped by user (USER_CANCELED) -> noop, respect user cancellation - monkeypatch.setattr( - runtime, - "_inspect_conversation_liveness", - lambda _cid: { - "alive": True, - "running": False, - "idle": True, - "user_canceled": True, - "reason": "session_user_canceled", - }, - ) - res = runtime.sidecar_tick(args) - assert res["decision"] == "noop" - assert res["reason"] == "session_user_canceled" - - # 4. Session is IDLE, quota should-run=False with stateful backoff interval=12m -> - # updates sidecar.json cron to */12 * * * *, executes ack_cli_args, and does not wake - monkeypatch.setattr( - runtime, - "_inspect_conversation_liveness", - lambda _cid: { - "alive": True, - "running": False, - "idle": True, - "user_canceled": False, - "reason": "session_idle", - }, - ) - ack_calls: list[list[str]] = [] - monkeypatch.setattr( - runtime, - "_run_cli_raw", - lambda _a, cli_args: (ack_calls.append(cli_args) or True), - ) - monkeypatch.setattr( - runtime, - "_run_core_json", - lambda _a, _cmd: { - "should_run": False, - "effective_action": "waiting_external", - "scheduler_hint": { - "app_automation": { - "action": "keep_or_update_current_heartbeat", - "apply_needed": True, - "recommended_interval_minutes": 12, - "recommended_rrule": "FREQ=MINUTELY;INTERVAL=12", - "stateful_backoff": {"consecutive_noops": 2}, - "ack_hint": { - "cli_args": [ - "quota", - "scheduler-ack-current", - "--goal-id", - "work", - "--agent-id", - "worker", - "--antigravity_app", - "--applied-rrule", - "FREQ=MINUTELY;INTERVAL=12", - "--execute", - ] - }, - } - }, - }, - ) - res = runtime.sidecar_tick(args) - assert res["decision"] == "noop" - assert res["reason"] == "quota_should_not_run" - assert res["cron_updated"] is True - assert res["ack_executed"] is True - assert len(ack_calls) == 1 - assert "--antigravity_app" in ack_calls[0] - updated_sidecar = json.loads(sidecar_path.read_text()) - assert updated_sidecar["args"][0] == "CRON_TZ=UTC */12 * * * *" - - # 5. Session is IDLE and quota should-run=True -> resets backoff to */3 * * * * and wakes bound session in-place via agentapi send-message - wake_cmds: list[list[str]] = [] - - def fake_run(cmd, **_kwargs): - wake_cmds.append(list(cmd)) - return subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") - - monkeypatch.setattr(runtime.subprocess, "run", fake_run) - monkeypatch.setattr( - runtime, - "_run_core_json", - lambda _a, _cmd: { - "should_run": True, - "effective_action": "normal_run", - "scheduler_hint": { - "app_automation": { - "action": "keep_or_update_current_heartbeat", - "apply_needed": True, - "recommended_interval_minutes": 3, - "recommended_rrule": "FREQ=MINUTELY;INTERVAL=3", - "stateful_backoff": {"consecutive_noops": 0}, - "ack_hint": { - "cli_args": [ - "quota", - "scheduler-ack-current", - "--goal-id", - "work", - "--agent-id", - "worker", - "--antigravity_app", - "--applied-rrule", - "FREQ=MINUTELY;INTERVAL=3", - "--execute", - ] - }, - } - }, - }, - ) - res = runtime.sidecar_tick(args) - assert res["ok"] is True - assert res["decision"] == "woke_bound_session" - assert res["conversation_id"] == "native-chat" - assert res["cron_updated"] is True - assert json.loads(sidecar_path.read_text())["args"][0] == "CRON_TZ=UTC */3 * * * *" - assert len(wake_cmds) == 1 - assert wake_cmds[0][:4] == [ - "agentapi", - "send-message", - "--title=LoopX Heartbeat", - "native-chat", - ] - assert "new-conversation" not in wake_cmds[0] - - -def test_discover_active_conversation_id_and_bind_session_for_new_session_startup( - tmp_path, monkeypatch -): - from loopx.capabilities.native_chat import antigravity_adapter as provider - - monkeypatch.delenv("ANTIGRAVITY_CONVERSATION_ID", raising=False) - monkeypatch.setenv("ANTIGRAVITY_LS_ADDRESS", "127.0.0.1:4321") - monkeypatch.setenv("ANTIGRAVITY_CSRF_TOKEN", "token-xyz") - - workspace = tmp_path / "repo" - workspace.mkdir() - ws_uri = workspace.resolve().as_uri() - - def fake_rpc(_url, _token, method, _payload, **_): - assert method == "GetAllCascadeTrajectories" - return { - "trajectorySummaries": { - "subagent-cid": { - "status": "CASCADE_RUN_STATUS_RUNNING", - "lastModifiedTime": "2026-11-02T10:05:00Z", - "stepCount": 10, - "trajectoryMetadata": {"parentConversationId": "active-root-cid"}, - "workspaces": [{"workspaceFolderAbsoluteUri": ws_uri}], - }, - "active-root-cid": { - "status": "CASCADE_RUN_STATUS_RUNNING", - "lastModifiedTime": "2026-11-02T10:04:00Z", - "stepCount": 25, - "trajectoryMetadata": {}, - "workspaces": [{"workspaceFolderAbsoluteUri": ws_uri}], - }, - "other-idle-cid": { - "status": "CASCADE_RUN_STATUS_IDLE", - "lastModifiedTime": "2026-11-02T10:06:00Z", - "stepCount": 99, - "trajectoryMetadata": {}, - "workspaces": [{"workspaceFolderAbsoluteUri": ws_uri}], - }, - } - } - - monkeypatch.setattr(provider, "_rpc_post", fake_rpc) - discovered = runtime.discover_active_conversation_id(workspace=workspace) - assert discovered == "active-root-cid" - - agy_cfg = tmp_path / "agy-config" - bind_res = runtime.bind_session( - Namespace( - cli_bin="loopx", - registry="/tmp/reg.json", - runtime_root="/tmp/rt", - goal_id="loopx-goal", - agent_id="antigravity-app", - conversation_id=None, - workspace=str(workspace), - agy_home=str(agy_cfg), - ) - ) - assert bind_res["ok"] is True - assert bind_res["conversation_id"] == "active-root-cid" - hooks_data = json.loads((agy_cfg / "hooks.json").read_text()) - assert hooks_data["loopx"]["enabled"] is True - assert "--conversation-id active-root-cid" in hooks_data["loopx"]["Stop"][0]["command"] - sidecar_data = json.loads( - ( - agy_cfg - / "sidecars" - / "loopx-antigravity-heartbeat" - / "sidecar.json" - ).read_text() - ) - assert "sidecar-tick" in sidecar_data["args"] - assert "active-root-cid" in sidecar_data["args"] - - def test_native_hook_admission_uses_real_core_ready_user_gate_and_zero_quota(tmp_path): from examples.control_plane.quota_plan_fixtures import ( SCOPED_AGENT_ID, @@ -518,511 +258,3 @@ def test_hooks_migrate_only_an_unchanged_owned_legacy_entry(tmp_path): == "retired" ) assert json.loads(path.read_text()) == {"personal-hook": unrelated} - - -def test_generated_bind_session_command_and_auto_sentinel_resolve_via_loopback_http( - tmp_path, monkeypatch -): - from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - import threading - from loopx.control_plane.testing.canary_harness import REPO_ROOT - from loopx.host_loop_activation import build_host_loop_activation_packet - - monkeypatch.delenv("ANTIGRAVITY_CONVERSATION_ID", raising=False) - workspace = tmp_path / "target-repo" - workspace.mkdir() - ws_uri = workspace.resolve().as_uri() - - rpc_calls: list[str] = [] - summaries_state: dict[str, dict] = {} - - class Handler(BaseHTTPRequestHandler): - def do_POST(self): - rpc_calls.append(self.path) - body = json.dumps({"trajectorySummaries": summaries_state}).encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "application/json") - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def log_message(self, *_args): - return - - server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - try: - addr = f"127.0.0.1:{server.server_port}" - env = { - **{k: v for k, v in runtime.os.environ.items() if k != "ANTIGRAVITY_CONVERSATION_ID"}, - "ANTIGRAVITY_LS_ADDRESS": addr, - "ANTIGRAVITY_CSRF_TOKEN": "loopback-token", - "PYTHONPATH": str(REPO_ROOT), - } - activation = build_host_loop_activation_packet( - agent_type="antigravity-app", - goal_id="surface-goal", - agent_id="probe-agent", - registered_agents=["probe-agent"], - ) - bind_cmd = shlex.split(activation["host_mutation"]["bind_session_command"]) - assert bind_cmd[:6] == [ - "python3", - "-E", - "-P", - "-m", - "loopx.agy_goal_mode.host_runtime", - "bind-session", - ] - assert "auto" not in bind_cmd - exec_bind_cmd = [ - sys.executable, - "-m", - "loopx.agy_goal_mode.host_runtime", - *bind_cmd[5:], - ] - - # 1. Foreign-only running root -> refused, no config files written - foreign_ws = (tmp_path / "foreign-repo").resolve().as_uri() - summaries_state.clear() - summaries_state["foreign-root-cid"] = { - "status": "CASCADE_RUN_STATUS_RUNNING", - "lastModifiedTime": "2026-11-02T11:00:00Z", - "stepCount": 12, - "trajectoryMetadata": {}, - "workspaces": [{"workspaceFolderAbsoluteUri": foreign_ws}], - } - agy_foreign = tmp_path / "agy-foreign" - res_foreign = subprocess.run( - [*exec_bind_cmd, "--workspace", str(workspace), "--agy-home", str(agy_foreign)], - capture_output=True, - text=True, - env=env, - timeout=10, - ) - assert res_foreign.returncode == 1 - payload_foreign = json.loads(res_foreign.stdout) - assert payload_foreign["ok"] is False - assert payload_foreign["error_kind"] == "conversation_id_unresolved" - assert len(rpc_calls) >= 1 - assert not (agy_foreign / "hooks.json").exists() - assert not ( - agy_foreign / "sidecars" / "loopx-antigravity-heartbeat" / "sidecar.json" - ).exists() - - # 2. Ambiguous multiple running roots in the same workspace -> refused - summaries_state.clear() - summaries_state["same-ws-1"] = { - "status": "CASCADE_RUN_STATUS_RUNNING", - "lastModifiedTime": "2026-11-02T11:01:00Z", - "stepCount": 5, - "trajectoryMetadata": {}, - "workspaces": [{"workspaceFolderAbsoluteUri": ws_uri}], - } - summaries_state["same-ws-2"] = { - "status": "CASCADE_RUN_STATUS_RUNNING", - "lastModifiedTime": "2026-11-02T11:02:00Z", - "stepCount": 8, - "trajectoryMetadata": {}, - "workspaces": [{"workspaceFolderAbsoluteUri": ws_uri}], - } - res_ambig = subprocess.run( - [*exec_bind_cmd, "--workspace", str(workspace), "--agy-home", str(agy_foreign)], - capture_output=True, - text=True, - env=env, - timeout=10, - ) - assert res_ambig.returncode == 1 - assert json.loads(res_ambig.stdout)["error_kind"] == "conversation_id_unresolved" - assert not (agy_foreign / "hooks.json").exists() - - # 3. Single matching running root in target workspace (plus subagent & foreign root) -> - # both the generated command AND explicit `--conversation-id auto` resolve real ID - summaries_state.clear() - summaries_state["foreign-root-cid"] = { - "status": "CASCADE_RUN_STATUS_RUNNING", - "lastModifiedTime": "2026-11-02T11:05:00Z", - "stepCount": 99, - "trajectoryMetadata": {}, - "workspaces": [{"workspaceFolderAbsoluteUri": foreign_ws}], - } - summaries_state["subagent-cid"] = { - "status": "CASCADE_RUN_STATUS_RUNNING", - "lastModifiedTime": "2026-11-02T11:06:00Z", - "stepCount": 50, - "trajectoryMetadata": {"parentConversationId": "scoped-root-cid"}, - "workspaces": [{"workspaceFolderAbsoluteUri": ws_uri}], - } - summaries_state["scoped-root-cid"] = { - "status": "CASCADE_RUN_STATUS_RUNNING", - "lastModifiedTime": "2026-11-02T11:03:00Z", - "stepCount": 20, - "trajectoryMetadata": {}, - "workspaces": [{"workspaceFolderAbsoluteUri": ws_uri}], - } - for idx, extra_args in enumerate(([], ["--conversation-id", "auto"])): - agy_ok = tmp_path / f"agy-ok-{idx}" - before_rpcs = len(rpc_calls) - res_ok = subprocess.run( - [ - *exec_bind_cmd, - *extra_args, - "--workspace", - str(workspace), - "--agy-home", - str(agy_ok), - ], - capture_output=True, - text=True, - env=env, - timeout=10, - ) - assert res_ok.returncode == 0, res_ok.stderr - assert len(rpc_calls) > before_rpcs - payload_ok = json.loads(res_ok.stdout) - assert payload_ok["ok"] is True - assert payload_ok["conversation_id"] == "scoped-root-cid" - hooks_written = json.loads((agy_ok / "hooks.json").read_text()) - assert hooks_written["loopx"]["enabled"] is True - assert "--conversation-id scoped-root-cid" in hooks_written["loopx"]["Stop"][0]["command"] - assert "--conversation-id auto" not in hooks_written["loopx"]["Stop"][0]["command"] - sidecar_written = json.loads( - ( - agy_ok - / "sidecars" - / "loopx-antigravity-heartbeat" - / "sidecar.json" - ).read_text() - ) - assert "scoped-root-cid" in sidecar_written["args"] - assert "auto" not in sidecar_written["args"] - finally: - server.shutdown() - server.server_close() - - -def test_bind_session_and_bind_agent_thread_preserve_sidecar_policy_and_reject_foreign_owner( - tmp_path, monkeypatch -): - from examples.control_plane.quota_plan_fixtures import ( - SCOPED_AGENT_ID, - write_cli_fixture, - ) - from loopx.control_plane.testing.canary_harness import REPO_ROOT, run_json_cli - - agy_cfg = tmp_path / "agy-policy" - sidecar_path = ( - agy_cfg / "sidecars" / "loopx-antigravity-heartbeat" / "sidecar.json" - ) - sidecar_path.parent.mkdir(parents=True, exist_ok=True) - - monkeypatch.setenv("PYTHONPATH", str(REPO_ROOT)) - # 1. Existing user-owned non-LoopX sidecars (including script names/paths mentioning - # `loopx` or `host_runtime` without a managed marker) are rejected via CLI with exit 1 - for foreign_args in ( - ["CRON_TZ=UTC */5 * * * *", "/usr/local/bin/custom-user-job"], - ["CRON_TZ=UTC */5 * * * *", "python export_notes.py"], - ["CRON_TZ=UTC */5 * * * *", "python export_loopx_notes.py"], - ["CRON_TZ=UTC */5 * * * *", "python /opt/host_runtime/export_notes.py"], - [ - "CRON_TZ=UTC */5 * * * *", - "python", - "export_notes.py", - "-m", - "loopx.agy_goal_mode.host_runtime", - "sidecar-tick", - ], - [ - "CRON_TZ=UTC */5 * * * *", - "/usr/local/bin/custom-runner", - "-m", - "loopx.agy_goal_mode.host_runtime", - "sidecar-tick", - ], - ): - foreign_sidecar = { - "builtin": "schedule", - "args": list(foreign_args), - "enabled": True, - } - raw_before = json.dumps(foreign_sidecar, indent=2) - sidecar_path.write_text(raw_before) - marker_path = sidecar_path.parent / runtime.ANTIGRAVITY_SIDECAR_MARKER_NAME - if marker_path.exists(): - marker_path.unlink() - proc = subprocess.run( - [ - sys.executable, - "-m", - "loopx.agy_goal_mode.host_runtime", - "bind-session", - "--goal-id", - "work", - "--agent-id", - "worker", - "--conversation-id", - "cid-1", - "--agy-home", - str(agy_cfg), - ], - capture_output=True, - text=True, - check=False, - ) - assert proc.returncode == 1 - conflict_res = json.loads(proc.stdout) - assert conflict_res["ok"] is False - assert conflict_res["error_kind"] == "sidecar_ownership_conflict" - assert conflict_res["sidecar_status"] == "skipped_user_owned_sidecar_entry" - assert sidecar_path.read_text() == raw_before - assert not marker_path.exists() - - # 1b. Policy-only overlay (no `args` field, user-chosen id/model, disabled, empty grants) - # is accepted by CLI `bind-session` and preserves all user policy fields - policy_only_overlay = { - "id": "policy-only-id", - "enabled": False, - "model": "gemini-2.5-pro-policy", - "agent_permissions": {"access_grants": []}, - } - sidecar_path.write_text(json.dumps(policy_only_overlay, indent=2)) - proc_overlay = subprocess.run( - [ - sys.executable, - "-m", - "loopx.agy_goal_mode.host_runtime", - "bind-session", - "--goal-id", - "work", - "--agent-id", - "worker", - "--conversation-id", - "cid-overlay", - "--agy-home", - str(agy_cfg), - ], - capture_output=True, - text=True, - check=False, - ) - assert proc_overlay.returncode == 0 - overlay_res = json.loads(proc_overlay.stdout) - assert overlay_res["ok"] is True - overlay_written = json.loads(sidecar_path.read_text()) - assert overlay_written["id"] == "policy-only-id" - assert overlay_written["enabled"] is False - assert overlay_written["model"] == "gemini-2.5-pro-policy" - assert overlay_written["agent_permissions"] == {"access_grants": []} - assert "cid-overlay" in overlay_written["args"] - - # 2. Existing LoopX heartbeat sidecar with user policy overrides (enabled=False, - # custom id, custom model, empty access_grants, custom cron) preserves all - # policy fields across both `bind-session` and `bind-agent-thread --execute` - policy_sidecar = { - "id": "user-heartbeat-id", - "enabled": False, - "model": "gemini-2.5-pro-custom", - "builtin": "schedule", - "args": [ - "CRON_TZ=UTC */7 * * * *", - "python3", - "-E", - "-P", - "-m", - "loopx.agy_goal_mode.host_runtime", - "sidecar-tick", - "--goal-id", - "old-goal", - "--agent-id", - "old-agent", - "--conversation-id", - "old-cid", - ], - "agent_permissions": {"access_grants": []}, - "custom_user_note": "preserve-me", - } - sidecar_path.write_text(json.dumps(policy_sidecar, indent=2)) - - res_direct = runtime.bind_session( - Namespace( - cli_bin="loopx", - registry="/tmp/reg.json", - runtime_root="/tmp/rt", - goal_id="bound-goal-1", - agent_id="bound-agent-1", - conversation_id="bound-cid-1", - workspace=None, - agy_home=str(agy_cfg), - ) - ) - assert res_direct["ok"] is True - after_direct = json.loads(sidecar_path.read_text()) - assert after_direct["id"] == "user-heartbeat-id" - assert after_direct["enabled"] is False - assert after_direct["model"] == "gemini-2.5-pro-custom" - assert after_direct["agent_permissions"] == {"access_grants": []} - assert after_direct["custom_user_note"] == "preserve-me" - assert after_direct["args"][0] == "CRON_TZ=UTC */7 * * * *" - assert "bound-goal-1" in after_direct["args"] - assert "bound-agent-1" in after_direct["args"] - assert "bound-cid-1" in after_direct["args"] - - # Also test automatic sync through real `bind-agent-thread --host-surface antigravity-app --execute` - from loopx.global_registry import sync_project_registry_to_global - - registry_path, runtime_root, project = write_cli_fixture( - tmp_path / "cli-bind", - scoped_agents=True, - ) - sync_project_registry_to_global( - registry_path=registry_path, - runtime_root_override=str(runtime_root), - goal_id="full-speed", - dry_run=False, - ) - monkeypatch.setenv("PYTHONPATH", str(REPO_ROOT)) - monkeypatch.setenv("AGY_HOME", str(agy_cfg)) - cli_payload = run_json_cli( - "bind-agent-thread", - "--goal-id", - "full-speed", - "--agent-id", - SCOPED_AGENT_ID, - "--host-surface", - "antigravity-app", - "--thread-id", - "bound-cid-via-registry-admin", - "--execute", - registry_path=registry_path, - runtime_root=runtime_root, - cwd=project, - ) - assert cli_payload["ok"] is True - assert cli_payload["antigravity_host_config_sync"]["ok"] is True - after_cli = json.loads(sidecar_path.read_text()) - assert after_cli["id"] == "user-heartbeat-id" - assert after_cli["enabled"] is False - assert after_cli["model"] == "gemini-2.5-pro-custom" - assert after_cli["agent_permissions"] == {"access_grants": []} - assert after_cli["custom_user_note"] == "preserve-me" - assert after_cli["args"][0] == "CRON_TZ=UTC */7 * * * *" - assert "full-speed" in after_cli["args"] - assert SCOPED_AGENT_ID in after_cli["args"] - assert "bound-cid-via-registry-admin" in after_cli["args"] - mcp_ctx = runtime.resolve_bound_mcp_goal_context(agy_home=str(agy_cfg)) - assert mcp_ctx is not None - assert mcp_ctx["goal_id"] == "full-speed" - assert mcp_ctx["agent_id"] == SCOPED_AGENT_ID - assert Path(mcp_ctx["registry"]).resolve() == registry_path.resolve() - - -def test_liveness_and_sidecar_tick_fail_closed_when_trajectory_summary_unavailable( - tmp_path, monkeypatch -): - from loopx.capabilities.native_chat import antigravity_adapter as provider - - monkeypatch.setenv("ANTIGRAVITY_LS_ADDRESS", "127.0.0.1:4321") - monkeypatch.setenv("ANTIGRAVITY_CSRF_TOKEN", "token-xyz") - args = bound(tmp_path) - args.agy_home = str(tmp_path / "agy-home") - args.sidecar_config = str(tmp_path / "sidecar.json") - args.agentapi = "agentapi" - - wake_cmds: list[list[str]] = [] - monkeypatch.setattr( - runtime.subprocess, - "run", - lambda cmd, **_kw: ( - wake_cmds.append(list(cmd)) - or subprocess.CompletedProcess(cmd, 0, stdout="ok", stderr="") - ), - ) - monkeypatch.setattr( - runtime, - "_run_core_json", - lambda _a, _cmd: {"should_run": True, "effective_action": "normal_run"}, - ) - - # 1. Initial check: GetAllCascadeTrajectories raises (e.g. HTTP 503) -> fail closed, no wake - def rpc_summary_error(_url, _token, method, _payload, **_): - if method == "GetCascadeTrajectory": - return { - "trajectory": {"cascadeId": "native-chat"}, - "status": "CASCADE_RUN_STATUS_IDLE", - "numTotalSteps": 4, - } - raise RuntimeError("HTTP 503 Service Unavailable") - - monkeypatch.setattr(provider, "_rpc_post", rpc_summary_error) - liveness_err = runtime._inspect_conversation_liveness("native-chat") - assert liveness_err["alive"] is True - assert liveness_err["idle"] is False - assert liveness_err["summary_available"] is False - assert liveness_err["reason"] == "trajectory_summary_unavailable" - res_err = runtime.sidecar_tick(args) - assert res_err["decision"] == "noop" - assert res_err["reason"] == "trajectory_summary_unavailable" - assert wake_cmds == [] - - # 2. Initial check: GetAllCascadeTrajectories omits conversation_id -> fail closed, no wake - monkeypatch.setattr( - provider, - "_rpc_post", - lambda _url, _token, method, _payload, **_: ( - { - "trajectory": {"cascadeId": "native-chat"}, - "status": "CASCADE_RUN_STATUS_IDLE", - "numTotalSteps": 4, - } - if method == "GetCascadeTrajectory" - else {"trajectorySummaries": {"other-session": {"status": "CASCADE_RUN_STATUS_IDLE"}}} - ), - ) - res_missing = runtime.sidecar_tick(args) - assert res_missing["decision"] == "noop" - assert res_missing["reason"] == "trajectory_summary_unavailable" - assert wake_cmds == [] - - # 3. Pre-wake recheck: initial summary succeeds (idle, not canceled), but recheck summary fails -> no wake - summary_calls = 0 - - def rpc_recheck_summary_fails(_url, _token, method, _payload, **_): - nonlocal summary_calls - if method == "GetCascadeTrajectory": - return { - "trajectory": {"cascadeId": "native-chat"}, - "status": "CASCADE_RUN_STATUS_IDLE", - "numTotalSteps": 4, - } - summary_calls += 1 - if summary_calls == 1: - return { - "trajectorySummaries": { - "native-chat": { - "status": "CASCADE_RUN_STATUS_IDLE", - "latestExecutorTerminationReason": "", - } - } - } - raise RuntimeError("HTTP 503 on pre-wake recheck") - - monkeypatch.setattr(provider, "_rpc_post", rpc_recheck_summary_fails) - res_recheck = runtime.sidecar_tick(args) - assert res_recheck["decision"] == "noop" - assert res_recheck["reason"] == "session_state_changed_before_wake" - assert wake_cmds == [] - - -def test_stop_hook_skips_in_place_recovery_when_session_steps_exceed_ceiling( - tmp_path, monkeypatch -): - args = bound(tmp_path) - monkeypatch.setattr(runtime, "_admitted", lambda _a: True) - monkeypatch.setattr( - runtime, - "_native_input", - lambda _a, _t: ("anchor-bloated", "CASCADE_RUN_STATUS_IDLE", 401), - ) - assert runtime.stop_hook(args, event()) == {}