diff --git a/docs/architecture/rfcs/manager-runtime-profile-v0.md b/docs/architecture/rfcs/manager-runtime-profile-v0.md index 19e12fe7fe..d3bee0c390 100644 --- a/docs/architecture/rfcs/manager-runtime-profile-v0.md +++ b/docs/architecture/rfcs/manager-runtime-profile-v0.md @@ -62,6 +62,11 @@ These defaults do not change ordinary Goal Chat models or grant other Agents too steps to be done, matching native result recovery's completion requirement. Transient idle does not extend the existing no-progress or hard deadline. Permission gates, native errors, unverified stop reasons and another input retain their existing refusal paths. + 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. - Antigravity manager inputs place the complete original user message before the frozen Core evidence, encoding that message as a JSON string so whitespace and quoted transport markers remain exact. Original-result recovery recognizes both this framing and historical 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 2507783505..ea690de24e 100644 --- a/docs/architecture/rfcs/manager-runtime-profile-v0.zh-CN.md +++ b/docs/architecture/rfcs/manager-runtime-profile-v0.zh-CN.md @@ -50,6 +50,11 @@ LoopX 管家最初只有受限的规划会话:它可以读取 LoopX 提供的 LoopX 继续观察原调用,不重发输入;最终答案须带正常停止标记,且本次覆盖的全部步骤均 已完成才能收尾,与原生结果恢复的完成要求一致。短暂 idle 不延长既有无进展或硬截止 时间;权限等待、原生错误、未核验的停止原因及另一个输入仍沿既有拒绝路径处理。 + 只读轮询的传输中断不会重发输入,也不会重置 idle 或 hard deadline。运行中的原生 + cascade 继续拥有重试与错误步骤;接入层仅在原生终态错误后失败,并保留结构化原始 + 原因。宿主心跳与有界 Stop-hook 恢复遵循独立的 + [Antigravity 宿主流程](../../../skills/loopx-project/references/heartbeat-hosts.md#antigravity-app)。 + 这些源码边界不证明上游网络已修复,也不证明持续长程可靠性。 - Antigravity 管家输入先呈现完整用户原话,再附上冻结的 Core 证据;用户原话按 JSON 字符串编码,保留空白和被引用的传输标记。原始结果恢复同时识别新格式与历史上的 证据在前格式,沿用同一来源、输入时间、prompt hash 和原生终止检查。 diff --git a/examples/personal-workspace-browser/replan-cadence.mjs b/examples/personal-workspace-browser/replan-cadence.mjs index 6fc1f43519..ee1b471553 100644 --- a/examples/personal-workspace-browser/replan-cadence.mjs +++ b/examples/personal-workspace-browser/replan-cadence.mjs @@ -83,7 +83,7 @@ export const replanCadenceScenario = { const count = page.getByLabel("两次复核间的数量", { exact: true }); const strategy = page.getByLabel(/^步幅策略/); await waitForInputValue(count, "6"); - if (await strategy.inputValue() !== "off") throw new Error("Stride must default off"); + if (await strategy.inputValue() !== "evidence_conditioned_v1") throw new Error("Stride must default to evidence_conditioned_v1"); if (await unit.inputValue() !== "effective_turns") throw new Error("Product default is not settled work Turns"); const horizonGoal = page.getByLabel(/^在规划视野中显示 Goal/); const advanced = page.getByText("高级设置", {exact: true}); @@ -302,7 +302,7 @@ export const replanCadenceScenario = { if (inherited.source !== "capability_default" || inherited.configuration.count_unit !== "effective_turns" || inherited.configuration.count !== 6) throw new Error("Removing device policy did not restore product default"); await reopenGoalCadence(); await waitForInputValue(englishCount, "6"); - if (await englishUnit.inputValue() !== "effective_turns" || await englishStrategy.inputValue() !== "off") throw new Error("Goal editor did not restore product defaults"); + if (await englishUnit.inputValue() !== "effective_turns" || await englishStrategy.inputValue() !== "evidence_conditioned_v1") throw new Error("Goal editor did not restore product defaults"); await englishCount.fill("5"); await page.getByRole("button", { name: "Preview changes", exact: true }).click(); const failedReadback = holdGoalReadback(); 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..bb2c73536d 100644 --- a/loopx/agy_goal_mode/__init__.py +++ b/loopx/agy_goal_mode/__init__.py @@ -1,26 +1,48 @@ +"""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`` bounded Stop recovery registration. +""" + from __future__ import annotations +import os +import shlex +import shutil +import sys 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 +50,260 @@ "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` bound Stop recovery 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/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 bound Stop recovery" + ), + "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, + runtime_root: str | None = None, + cli_bin: str = "loopx", + 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"] + 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"] + if runtime_root: + heartbeat_args.extend(["--runtime-root", runtime_root]) + heartbeat_args.extend( + [ + "heartbeat-prompt", + "--thin", + "--goal-id", + goal_id, + "--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 空查)。' + "入口异常先做权限内恢复;契约仍不可用时不执行任务或记账,并报告阻塞。" + ) + 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": cron_expr, + "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": ( + "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 _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, + 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 = [ + python_bin, + "-E", + "-P", + "-m", + "loopx.agy_goal_mode.host_runtime", + operation, + "--cli-bin", + resolved_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)) + if minutes >= 60: + return "0 * * * *" + return f"*/{minutes} * * * *" + + +def build_antigravity_hooks_template( + *, + cli_bin: str = "loopx", + goal_id: str | None = None, + agent_id: str | None = None, + 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") + 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, + resolve_local_executable=resolve_local_executable, + ) + return { + "loopx": { + "enabled": bool( + conversation_id and agent_id and goal_id and goal_id != "" + ), + "Stop": [{"type": "command", "command": shlex.join(args), "timeout": 30}], + } + } + 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 +315,159 @@ 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": ( - "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 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, + 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, + resolve_local_executable=False, + ) + wake_prompt = sidecar_template["sidecar"]["loopx-heartbeat"]["bootstrap_prompt"] + 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": "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": [ + 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, 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 " + "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 str(Path.home() / DEFAULT_AGY_HOME) + 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 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/agy_goal_mode/host_runtime.py b/loopx/agy_goal_mode/host_runtime.py new file mode 100644 index 0000000000..1e34082547 --- /dev/null +++ b/loopx/agy_goal_mode/host_runtime.py @@ -0,0 +1,230 @@ +"""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=10, + 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 _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 + 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: + 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, native[0]): + 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/bootstrap_command_pack.py b/loopx/bootstrap_command_pack.py index 2b93c9a335..440711f17c 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", @@ -372,7 +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", + "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/capabilities/native_chat/antigravity_adapter.py b/loopx/capabilities/native_chat/antigravity_adapter.py index f33743bd71..6cd16d030e 100644 --- a/loopx/capabilities/native_chat/antigravity_adapter.py +++ b/loopx/capabilities/native_chat/antigravity_adapter.py @@ -378,8 +378,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 @@ -690,13 +693,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: @@ -706,17 +718,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): @@ -804,17 +822,11 @@ def _run_turn( event_sink("answer.delta", {"text": visible_delta}) emitted_text_by_step[step_idx] = text 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 @@ -835,6 +847,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 (final.get("status") == "CORTEX_STEP_STATUS_CANCELED" and all(step.get("status") in {"CORTEX_STEP_STATUS_DONE", "CORTEX_STEP_STATUS_CANCELED"} 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/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 bed2a3411c..56762e4b68 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", @@ -154,6 +159,7 @@ def register_quota_command( "codex_app_ssh", "codex_cli", "trae_app", + "antigravity_app", "generic_cli", "claude_code", "kiro_cli", diff --git a/loopx/cli_commands/slash_commands.py b/loopx/cli_commands/slash_commands.py index 79a0f94b47..b0fcada137 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 install a disabled Antigravity Stop recovery hook; bind a Goal, Agent and native conversation before enabling it.", + ) 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..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) @@ -223,6 +227,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/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/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/bootstrap_prompt.py b/loopx/control_plane/heartbeat/bootstrap_prompt.py index 0f43fee53c..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,8 +102,9 @@ 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"} + in {"codex_app_heartbeat", "trae_app", "antigravity_app"} ) command = _goal_command(args, registry=registry, legacy=not heartbeat) if heartbeat: @@ -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/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/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 54b0b30420..c2773729da 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,7 +276,11 @@ 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.scheduler_owner is not SchedulerOwner.HOST_AUTOMATION: errors.append(f"{host_name} requires scheduler_owner=host_automation") @@ -425,6 +448,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..d7c448debf 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"); } @@ -292,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/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 bf40e30e9a..c6a0f848e2 100644 --- a/loopx/control_plane/turn_driver/codex_context.py +++ b/loopx/control_plane/turn_driver/codex_context.py @@ -43,7 +43,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: @@ -83,8 +84,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..0fb990acce 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,74 @@ 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") + workflow_skill_ids = set( + agy_workflow_readback.get("materialized_skill_ids") or () + ) + agy_facades_ready = all( + 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( + 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 / "sidecars" / "loopx-antigravity-heartbeat" / ANTIGRAVITY_SIDECAR_FILENAME), + "sidecar_config_exists": ( + agy_cfg_home / "sidecars" / "loopx-antigravity-heartbeat" / 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/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/loopx/host_loop_activation.py b/loopx/host_loop_activation.py index 5e71612b99..903214ff0f 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,14 @@ 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, + runtime_root=runtime_root, + ) 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..ef32d65f9e 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,30 @@ 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, + runtime_root: 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, + runtime_root=runtime_root, + 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..72e024a624 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] @@ -830,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): @@ -901,11 +911,93 @@ 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 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 "" + raw = json.loads(text) if text.strip() else {} + except (OSError, json.JSONDecodeError): + return "blocked_invalid_antigravity_hooks_json" + if not isinstance(raw, dict): + return "blocked_invalid_antigravity_hooks_json" + 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) + # 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) + + if uninstall: + if existing is None: + return "absent" + if not loopx_owned: + return "skipped_user_owned_hooks_entry" + if not execute: + return "would_retire" + 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" + + 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 and not legacy_owned else "would_write" + if existing == entry and not legacy_owned: + return "unchanged" + 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, + { + "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 +1026,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 +1239,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 +1254,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_stop_recovery_hook", + "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 +1722,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..3957044243 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": "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/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/skills/loopx-project/references/heartbeat-hosts.md b/skills/loopx-project/references/heartbeat-hosts.md index 86e30ccca3..ab678a601b 100644 --- a/skills/loopx-project/references/heartbeat-hosts.md +++ b/skills/loopx-project/references/heartbeat-hosts.md @@ -4,6 +4,55 @@ 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. +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 +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: diff --git a/tests/control_plane/test_scheduler_execution_context.py b/tests/control_plane/test_scheduler_execution_context.py index 27e06124a9..8ddba43856 100644 --- a/tests/control_plane/test_scheduler_execution_context.py +++ b/tests/control_plane/test_scheduler_execution_context.py @@ -43,6 +43,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"), *{ @@ -80,6 +81,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"), @@ -215,7 +221,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" ) @@ -230,17 +240,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"] == ( @@ -248,7 +257,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 @@ -1595,6 +1604,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"] == ( @@ -1602,7 +1612,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 cf79d3a713..54599097c7 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,6 +45,7 @@ AGY_GOAL_COMPLETE_TOKEN, AGY_NATIVE_WAKE_FACTS, AGY_NATIVE_WAKE_TOOLS, + ANTIGRAVITY_APP_AGENT_TYPE, agy_home, ) @@ -251,3 +262,371 @@ 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 ( + 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"] + ) + 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() + 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" + } + + 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 ( + 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). + 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 + ) + 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.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()["app_automation_applicability"] == "applicable" + ) + + # 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 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 + + assert ( + cron_for_interval_minutes(app_hint["recommended_interval_minutes"]) + == "*/3 * * * *" + ) + + # 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.app_automation.recommended_interval_minutes. + 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" + ]["interval_minutes_field"] + == "scheduler_hint.app_automation.recommended_interval_minutes" + ) + assert ( + mutation["sidecar_template"]["sidecar"]["loopx-heartbeat"][ + "stateful_backoff" + ]["state_key"] + == APP_AUTOMATION_STATEFUL_BACKOFF_STATE_KEY + ) + + +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 + + +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", + "--turn-instance-id", + "2026-11-02T04:00:00Z", + 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 + ) diff --git a/tests/test_antigravity_host_runtime.py b/tests/test_antigravity_host_runtime.py new file mode 100644 index 0000000000..fe79173ac0 --- /dev/null +++ b/tests/test_antigravity_host_runtime.py @@ -0,0 +1,260 @@ +"""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 + +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, "_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) + 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_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 +): + 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)) + 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") + 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_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"} + 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 7926228f72..28464ee707 100644 --- a/tests/test_antigravity_steward_adapter.py +++ b/tests/test_antigravity_steward_adapter.py @@ -108,6 +108,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"]) @@ -411,7 +418,7 @@ def test_cancel_failure_cannot_replace_the_original_provider_error(tmp_path, lan @pytest.mark.parametrize("layout", ["legacy", "user-first"]) -@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, layout): ref = "f" * 24 @@ -426,6 +433,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}} @@ -455,7 +466,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"} @@ -1225,3 +1236,41 @@ def test_multistep_planner_responses_preserve_split_control_marker_across_steps( assert "PROTOCOL_ONLY_SENTINEL" not in str(progress_state.get("answer") or "") assert result["message"] == "首段安全可见输出。" 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