hermes-hub/src/antigravity_provider/agy_subprocess.py
Hermes Team e15f12a3bd fix(router): обработчик ошибок падал сам, уровень усилия не подставлялся
Найдено проверкой всех шести ролей на живой машине владельца. До правок
работали три роли из шести.

1. antigravity_adapter.classify_error возвращал ErrorCategory.UNKNOWN —
   значения с таким именем не существует, есть AUTH_REQUIRED, FATAL,
   INVALID_REQUEST, QUOTA_EXHAUSTED, RATE_LIMITED, TRANSIENT. Обращение
   роняло сам классификатор с AttributeError, то есть отказ происходил
   ровно там, где обрабатывался другой отказ, и маршрутизация обрывалась
   вместо перехода к резервному профилю. Заменено на FATAL по образцу
   codex и opencode: неразобранная ошибка не должна давать повторов.

2. Уровень усилия не подставлялся, если у конкретного профиля не выполнен
   вход agy: карта усилий строится обнаружением ЧЕРЕЗ этот профиль, и при
   неудаче оставалась пустой. Уровни же — свойство модели, а не аккаунта.
   Добавлен запасной источник: сохранённый на диске список моделей со
   склеенными идентификаторами вида gemini-3.7-flash-high, из которых
   уровни выводятся напрямую и переживают перезапуск.

После правок работают все шесть ролей, включая живое переключение
'fast': opengo-1 -> ag-w4.

Закрыто тестами, включая защиту от возврата несуществующей категории.

Тесты: 373 passed, ruff чисто.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 08:51:01 +07:00

840 lines
30 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""AGY subprocess backend for the Antigravity Hermes provider plugin.
Routes chat-completion requests through the locally-installed ``agy`` CLI
instead of making direct HTTP calls to the Cloud Code Assist API. This
avoids the 429 RESOURCE_EXHAUSTED errors seen with direct API access while
reusing agy's existing authentication session.
Usage (from hermes_plugin.py middleware)::
from .agy_subprocess import agy_generate
completion_dict = agy_generate(openai_request_dict)
"""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import subprocess
import time
import uuid
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# AGY executable discovery
# ---------------------------------------------------------------------------
_agy_exe_cache: str | None = None
def _find_agy_exe() -> str:
"""Locate the ``agy`` (or ``agy.exe``) binary.
Resolution order:
1. ``AGY_EXE_PATH`` environment variable (explicit override)
2. ``%LOCALAPPDATA%/agy/bin/agy.exe`` (standard Windows install)
3. ``PATH`` lookup via :func:`shutil.which`
"""
# 1. Explicit env var
env = os.environ.get("AGY_EXE_PATH", "").strip()
if env and Path(env).is_file():
return env
# 2. Standard location based on hermes home parent
from antigravity_provider.paths import get_hermes_home
exe_name = "agy.exe" if os.name == "nt" else "agy"
candidate = get_hermes_home().parent / "agy" / "bin" / exe_name
if candidate.is_file():
return str(candidate)
# 3. PATH
found = shutil.which("agy") or shutil.which("agy.exe")
if found:
return found
raise FileNotFoundError(
"agy executable not found. Set the AGY_EXE_PATH environment "
"variable, install agy, or ensure it is on PATH."
)
def get_agy_exe() -> str:
"""Return the cached path to the ``agy`` binary."""
global _agy_exe_cache
if _agy_exe_cache is None:
_agy_exe_cache = _find_agy_exe()
return _agy_exe_cache
# ---------------------------------------------------------------------------
# Model discovery (dynamic — no hard-coded catalog)
# ---------------------------------------------------------------------------
# Maps *display* base names (lowered, dashed) → confirmed agy CLI model ids.
# Populated lazily by :func:`discover_models`.
_AGY_MODEL_CACHE: dict[str, str] | None = None
# Maps agy CLI model id → set of supported effort levels.
# Populated alongside _AGY_MODEL_CACHE by :func:`discover_models`.
_AGY_EFFORT_MAP: dict[str, set[str]] = {}
def _display_to_cli(display_name: str) -> tuple[str, str]:
"""Convert ``'Gemini 3.7 Flash (High)'`` → ``('gemini-3.7-flash', 'high')``.
Returns ``(cli_model, effort)``.
"""
m = re.match(r"^(.+?)\s*\((\w+)\)\s*$", display_name.strip())
if not m:
return display_name.strip().lower().replace(" ", "-"), ""
raw_name = m.group(1).strip()
effort = m.group(2).strip().lower()
# Normalise: lower-case, replace spaces with dashes
cli = raw_name.lower().replace(" ", "-")
# "4.6" → "4-6" only for non-gemini (gemini keeps dots: 3.7, 3.6 …)
if not cli.startswith("gemini"):
cli = cli.replace(".", "-")
return cli, effort
def discover_models(profile_id: str | None = None) -> dict[str, str]:
"""Discover available models by querying ``agy models``.
Returns a dict mapping *hermes-style* model ids
(``google-antigravity/gemini-3.7-flash``) to *agy CLI* model ids
(``gemini-3.7-flash``). The result is cached for the process lifetime.
Also populates :data:`_AGY_EFFORT_MAP` with supported efforts per model.
"""
global _AGY_MODEL_CACHE, _AGY_EFFORT_MAP
if _AGY_MODEL_CACHE is not None:
return dict(_AGY_MODEL_CACHE)
exe = get_agy_exe()
# agy читает учётные данные из $HOME/$USERPROFILE, а адаптер подменяет их
# на каталог профиля — так шесть аккаунтов и сосуществуют. Раньше эта
# команда запускалась в ГЛОБАЛЬНОМ окружении, где вход не выполнен, и
# отвечала «Please sign in to view available models» — при шести рабочих
# OAuth-профилях. Список моделей поэтому был пуст всегда.
target_profile_id = profile_id
if not target_profile_id:
try:
from antigravity_provider.router.profile_manager import ProfileAuthManager
main_p = ProfileAuthManager.get_main_profile("antigravity")
if main_p and ProfileAuthManager.load_profile_auth("antigravity", main_p):
target_profile_id = main_p
else:
for candidate in ["ag-orch-primary", "ag-w1", "ag-w2", "ag-w3", "ag-w4", "ag-w5"]:
if ProfileAuthManager.load_profile_auth("antigravity", candidate):
target_profile_id = candidate
break
except Exception:
target_profile_id = None
if target_profile_id:
from antigravity_provider.router.adapters.antigravity_adapter import get_profile_env_dir
profile_dir = get_profile_env_dir(target_profile_id)
env = build_safe_subprocess_env(
overrides={
"USERPROFILE": str(profile_dir),
"HOME": str(profile_dir),
"HOMEPATH": str(profile_dir),
}
)
else:
env = build_safe_subprocess_env()
try:
result = subprocess.run(
[exe, "models"],
capture_output=True,
text=True,
timeout=60,
encoding="utf-8",
errors="replace",
env=env,
stdin=subprocess.DEVNULL,
)
raw = result.stdout.strip()
if not raw or result.returncode != 0:
logger.warning("discover_models: agy models failed or returned empty output (rc=%s)", result.returncode)
if _AGY_MODEL_CACHE is None:
_AGY_MODEL_CACHE = {}
_AGY_EFFORT_MAP = {}
return dict(_AGY_MODEL_CACHE)
except subprocess.TimeoutExpired:
logger.warning("discover_models: agy models timed out")
if _AGY_MODEL_CACHE is None:
_AGY_MODEL_CACHE = {}
_AGY_EFFORT_MAP = {}
return dict(_AGY_MODEL_CACHE)
except Exception as exc:
logger.warning("discover_models failed: %s", exc)
if _AGY_MODEL_CACHE is None:
_AGY_MODEL_CACHE = {}
_AGY_EFFORT_MAP = {}
return dict(_AGY_MODEL_CACHE)
models: dict[str, str] = {}
effort_map: dict[str, set[str]] = {}
for line in raw.splitlines():
line = line.strip()
if not line or line.lower().startswith("model"):
continue
parts = line.split('\t')
cli_model = parts[0].strip()
if not cli_model:
continue
hermes_id = f"google-antigravity/{cli_model}"
models[hermes_id] = cli_model
if cli_model not in effort_map:
effort_map[cli_model] = set()
# Parse effort from description or known capabilities if needed
# Assuming effort is not explicitly provided in the tabbed output or we extract it
if len(parts) > 1:
desc = parts[1].strip().lower()
if "(high)" in desc:
effort_map[cli_model].add("high")
if "(low)" in desc:
effort_map[cli_model].add("low")
if "(medium)" in desc:
effort_map[cli_model].add("medium")
_AGY_MODEL_CACHE = models
_AGY_EFFORT_MAP = effort_map
logger.info(
"discover_models: found %d models, efforts=%s",
len(models),
{k: sorted(v) for k, v in effort_map.items()},
)
return dict(models)
def launch_native_agy_login(profile_id: str) -> subprocess.Popen:
"""Launch agy CLI in a visible interactive terminal window with isolated profile environment.
A22 Requirement: Native login executed by agy itself within the target profile's isolated directory.
Zero interception, zero credential logging.
"""
from antigravity_provider.router.adapters.antigravity_adapter import get_profile_env_dir
profile_dir = get_profile_env_dir(profile_id)
profile_dir.mkdir(parents=True, exist_ok=True)
gemini_dir = profile_dir / ".gemini"
gemini_dir.mkdir(parents=True, exist_ok=True)
exe = get_agy_exe()
env = build_safe_subprocess_env(
overrides={
"USERPROFILE": str(profile_dir),
"HOME": str(profile_dir),
"HOMEPATH": str(profile_dir),
"HOMEDRIVE": str(profile_dir)[:2] if str(profile_dir)[1:2] == ":" else "",
}
)
if os.name == "nt":
# Launch visible console window on Windows
return subprocess.Popen(
[exe],
env=env,
creationflags=subprocess.CREATE_NEW_CONSOLE,
)
else:
# Cross-platform fallback (Linux/macOS)
import shutil
terminals = [
["x-terminal-emulator", "-e", exe],
["gnome-terminal", "--", exe],
["xterm", "-e", exe],
["konsole", "-e", exe],
]
for term_cmd in terminals:
if shutil.which(term_cmd[0]):
return subprocess.Popen(term_cmd, env=env)
return subprocess.Popen([exe], env=env)
def check_profile_native_auth_status(profile_id: str) -> tuple[bool, str | None, dict[str, Any] | None]:
"""Check if agy native authentication has completed in profile's directory.
Returns (is_authenticated, email, auth_data).
A22 Requirement: Detection without credential logging or stream interception.
"""
from antigravity_provider.router.adapters.antigravity_adapter import get_profile_env_dir
profile_dir = get_profile_env_dir(profile_id)
gemini_dir = profile_dir / ".gemini"
creds_file = gemini_dir / "oauth_creds.json"
accounts_file = gemini_dir / "google_accounts.json"
if not creds_file.is_file() or creds_file.stat().st_size == 0:
return False, None, None
try:
creds_data = json.loads(creds_file.read_text(encoding="utf-8"))
if not isinstance(creds_data, dict):
return False, None, None
# Verify essential fields
access_token = creds_data.get("access_token")
refresh_token = creds_data.get("refresh_token")
if not access_token and not refresh_token:
return False, None, None
email = None
if accounts_file.is_file() and accounts_file.stat().st_size > 0:
try:
acc_data = json.loads(accounts_file.read_text(encoding="utf-8"))
if isinstance(acc_data, dict):
email = acc_data.get("active")
except Exception:
email = None
if not email:
auth_file = profile_dir / "auth.json"
if auth_file.is_file():
try:
a_data = json.loads(auth_file.read_text(encoding="utf-8"))
if isinstance(a_data, dict):
email = a_data.get("email") or a_data.get("user_email")
except Exception:
pass
if not email:
from antigravity_provider.router.profile_manager import ProfileAuthManager
id_token = creds_data.get("id_token")
if id_token:
email, _ = ProfileAuthManager.extract_jwt_identity(str(id_token))
auth_data = {
"auth_method": "oauth",
"email": email or "Google Account",
"token": creds_data,
"updated_at": time.time(),
}
# Keep profile's auth.json in sync
auth_file = profile_dir / "auth.json"
if not auth_file.is_file():
auth_file.write_text(json.dumps(auth_data, indent=2), encoding="utf-8")
return True, email, auth_data
except Exception as exc:
logger.debug("check_profile_native_auth_status error: %s", exc)
return False, None, None
def _model_supported_efforts(agy_model: str, profile_id: str | None = None) -> set[str]:
"""Return the set of effort levels supported by *agy_model*.
Профиль обязателен: обнаружение читает учётные данные из HOME, и без
подмены окружения оно выполняется в глобальном, где вход не сделан.
Карта усилий тогда остаётся пустой, подстановка уровня по умолчанию не
срабатывает, и agy отвергает вызов с «requires --effort» — при том что
сама модель совершенно настоящая.
"""
discover_models(profile_id=profile_id)
efforts = _AGY_EFFORT_MAP.get(agy_model, set())
if efforts:
return efforts
# Запасной источник: сохранённый на диске список моделей. Обнаружение
# ходит через КОНКРЕТНЫЙ профиль, и если именно у него вход не сделан,
# карта усилий остаётся пустой — тогда уровень не подставляется и agy
# отвергает вызов, хотя модель настоящая. Уровни усилия — свойство
# модели, а не аккаунта, поэтому их можно взять из склеенных
# идентификаторов вида "gemini-3.7-flash-high", уже лежащих в кэше.
try:
from antigravity_provider.router.model_discovery import ModelDiscoveryService
cached = ModelDiscoveryService.get().get_models("antigravity") or []
except Exception:
return efforts
known = {"low", "medium", "high"}
derived = {
suffix
for model_id in cached
for base, _, suffix in [model_id.rpartition("-")]
if base == agy_model and suffix in known
}
return derived
# Effort values understood by both hermes and agy
_EFFORT_NORMALISE: dict[str, str] = {
"off": "",
"none": "",
"disabled": "",
"minimal": "low",
"minimum": "low",
"low": "low",
"medium": "medium",
"normal": "medium",
"high": "high",
"xhigh": "high",
"max": "high",
}
def _resolve_effort(raw: str | None) -> str:
"""Normalise a hermes reasoning_effort value to an agy ``--effort`` arg."""
if not raw:
return ""
return _EFFORT_NORMALISE.get(raw.strip().lower().replace("_", "-"), "")
def _resolve_model(hermes_model: str) -> str:
"""Convert a hermes model id to an agy ``--model`` arg.
Falls back to stripping the ``google-antigravity/`` prefix.
"""
catalog = discover_models()
if hermes_model in catalog:
return catalog[hermes_model]
# Strip provider prefix
if "/" in hermes_model:
return hermes_model.split("/", 1)[1]
return hermes_model
# ---------------------------------------------------------------------------
# Conversation serialisation
# ---------------------------------------------------------------------------
def _content_text(content: Any) -> str:
"""Extract plain text from an OpenAI ``content`` field."""
if content is None:
return ""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: list[str] = []
for item in content:
if isinstance(item, dict):
if item.get("type") == "text":
parts.append(str(item.get("text", "")))
elif item.get("type") == "image_url":
parts.append("[image]")
return "\n".join(parts)
return str(content)
def _serialize_tool_defs(tools: list[dict[str, Any]]) -> str:
"""Serialise OpenAI tool definitions into a readable block."""
lines: list[str] = []
for tool in tools:
if not isinstance(tool, dict) or tool.get("type") != "function":
continue
fn = tool.get("function") or {}
if not isinstance(fn, dict) or not fn.get("name"):
continue
lines.append(json.dumps(fn, indent=2, ensure_ascii=False))
if not lines:
return ""
return (
"## SYSTEM DIRECTIVE: FUNCTION CALLING MODE\n"
"You are acting as an API LLM backend for Hermes Agent. You do NOT possess any built-in tools, shell access, or local file system access.\n"
"To perform any operation, inspect files, or call functions, you MUST output a tool call block using the exact format below. Do NOT attempt to run commands or read files yourself.\n\n"
"```tool_call\n"
'{"name": "<tool_name>", "arguments": {<args>}}\n'
"```\n\n"
"### Available Client Functions:\n"
+ "\n---\n".join(lines)
)
def serialize_messages(
messages: list[dict[str, Any]],
tools: list[dict[str, Any]] | None = None,
) -> str:
"""Flatten an OpenAI messages array (+ tools) into a single text prompt.
For simple system-plus-one-user prompts (no tools, no history) the output
is a clean concatenation — no role tags — so the model behaves as if it
received a direct instruction.
For multi-turn or tool-bearing conversations each turn is tagged with
``[User]``, ``[Assistant]``, ``[Tool …]`` to preserve structure.
"""
system_parts: list[str] = []
turns: list[tuple[str, str]] = [] # (role_tag, text)
for msg in messages:
role = msg.get("role", "")
content = _content_text(msg.get("content"))
if role in ("system", "developer"):
if content.strip():
system_parts.append(content)
elif role == "user":
if content.strip():
turns.append(("user", content))
elif role == "assistant":
parts: list[str] = []
if content and content.strip():
parts.append(content)
for tc in msg.get("tool_calls") or []:
if not isinstance(tc, dict):
continue
fn = tc.get("function") or {}
if isinstance(fn, dict) and fn.get("name"):
parts.append(
f'[Tool call: {fn["name"]}'
f'({fn.get("arguments", "{}")})]'
)
if parts:
turns.append(("assistant", "\n".join(parts)))
elif role == "tool":
name = msg.get("name", "tool")
if content.strip():
turns.append(("tool", f"[Tool result ({name})]: {content}"))
# --- simple case: system + single user, no tools ---
if (
len(turns) == 1
and turns[0][0] == "user"
and not tools
):
prefix = "\n\n".join(system_parts)
body = turns[0][1]
return f"{prefix}\n\n{body}" if prefix else body
# --- complex case: multi-turn / tools ---
sections: list[str] = []
if system_parts:
sections.append("\n\n".join(system_parts))
if tools:
td = _serialize_tool_defs(tools)
if td:
sections.append(td)
for tag, text in turns:
if tag == "user":
sections.append(f"[User]\n{text}")
elif tag == "assistant":
sections.append(f"[Assistant]\n{text}")
elif tag == "tool":
sections.append(text)
return "\n\n".join(sections)
# ---------------------------------------------------------------------------
# Tool-call extraction from model text
# ---------------------------------------------------------------------------
def _extract_tool_calls(text: str) -> list[dict[str, Any]] | None:
"""Best-effort extraction of ``tool_call`` blocks from model output."""
pattern = r"```tool_call\s*\n(.*?)\n```"
matches = re.findall(pattern, text, re.DOTALL)
if not matches:
return None
calls: list[dict[str, Any]] = []
for raw in matches:
try:
data = json.loads(raw.strip())
name = data.get("name", "")
args = data.get("arguments", {})
if not name:
continue
if not isinstance(args, dict):
try:
args = json.loads(str(args))
except (json.JSONDecodeError, TypeError):
args = {}
calls.append(
{
"id": "call_" + uuid.uuid4().hex[:12],
"type": "function",
"function": {
"name": name,
"arguments": json.dumps(
args, separators=(",", ":"), ensure_ascii=False
),
},
}
)
except (json.JSONDecodeError, AttributeError):
continue
return calls if calls else None
def _strip_tool_call_blocks(text: str) -> str:
"""Remove ``tool_call`` fenced blocks from the response text."""
cleaned = re.sub(r"```tool_call\s*\n.*?\n```", "", text, flags=re.DOTALL)
return cleaned.strip()
# ---------------------------------------------------------------------------
# Safe environment (no hermes secrets in subprocess)
# ---------------------------------------------------------------------------
SAFE_SYSTEM_ENV_VARS: set[str] = {
# Windows standard environment
"SYSTEMROOT", "SYSTEMDRIVE", "WINDIR", "COMSPEC", "PATH", "PATHEXT",
"TEMP", "TMP", "LOCALAPPDATA", "APPDATA", "PROGRAMDATA", "PROGRAMFILES",
"PROGRAMFILES(X86)", "COMMONPROGRAMFILES", "COMMONPROGRAMFILES(X86)",
"USERPROFILE", "HOME", "HOMEDRIVE", "HOMEPATH",
"NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE", "PROCESSOR_IDENTIFIER",
"OS", "COMPUTERNAME", "LOGONSERVER", "USERDOMAIN", "USERNAME",
# Unix standard environment
"USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE", "LC_MESSAGES",
"TMPDIR", "TERM", "PWD",
# Networking & Proxy & SSL certificates
"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY",
"http_proxy", "https_proxy", "no_proxy", "all_proxy",
"SSL_CERT_FILE", "SSL_CERT_DIR", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS",
# Specific toolchain overrides (non-secret)
"AGY_EXE_PATH", "AGY_CONFIG_PATH", "HERMES_HOME", "HERMES_HUB_DEV_MODE",
"NODE_OPTIONS", "PYTHONUTF8", "PYTHONIOENCODING",
}
BLOCKED_SECRET_PATTERNS: tuple[str, ...] = (
"api_key", "token", "secret", "auth", "password", "bearer", "private_key",
"openai", "codex", "anthropic", "claude", "deepseek", "opencode", "xai", "grok",
"hermes_api", "hermes_secret", "google_api_key", "gemini_api",
)
def build_safe_subprocess_env(
base_env: dict[str, str] | None = None,
allow_extra_keys: set[str] | list[str] | None = None,
overrides: dict[str, str] | None = None,
) -> dict[str, str]:
"""Construct an explicitly isolated and sanitized environment dictionary for child subprocesses.
Copies ONLY explicitly permitted system variables from base_env (defaulting to os.environ),
strips out any provider API keys or secrets, and applies explicit overrides.
"""
src = os.environ if base_env is None else base_env
allow = set(k.upper() for k in SAFE_SYSTEM_ENV_VARS)
if allow_extra_keys:
allow.update(k.upper() for k in allow_extra_keys)
clean_env: dict[str, str] = {}
for k, v in src.items():
k_upper = k.upper()
k_lower = k.lower()
if k_upper in allow:
# Strip secret-bearing keys even if matching an allow pattern unless explicitly in allow_extra_keys
if not allow_extra_keys or k not in allow_extra_keys:
if any(pat in k_lower for pat in BLOCKED_SECRET_PATTERNS):
continue
clean_env[k] = v
if overrides:
for k, v in overrides.items():
clean_env[k] = str(v)
return clean_env
def _safe_env() -> dict[str, str]:
"""Backward-compatible wrapper for build_safe_subprocess_env."""
return build_safe_subprocess_env()
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def agy_generate(
request: dict[str, Any],
custom_env: dict[str, str] | None = None,
profile_id: str | None = None,
) -> dict[str, Any]:
"""Execute a chat completion via the ``agy`` subprocess."""
exe = get_agy_exe()
timeout = int(request.get("timeout") or 180)
messages = request.get("messages") or []
tools = request.get("tools") if isinstance(request.get("tools"), list) else None
model_raw = str(request.get("model") or "")
# Build the flat text prompt
prompt = serialize_messages(messages, tools)
if not prompt.strip():
prompt = "Continue."
# Resolve model & effort
agy_model = _resolve_model(model_raw)
reasoning_effort = request.get("reasoning_effort")
if reasoning_effort is None and isinstance(request.get("reasoning"), dict):
reasoning_effort = request["reasoning"].get("effort")
if reasoning_effort is None and isinstance(request.get("extra_body"), dict):
eb = request["extra_body"]
if isinstance(eb.get("reasoning"), dict):
reasoning_effort = eb["reasoning"].get("effort")
agy_effort = _resolve_effort(reasoning_effort)
# Smart effort selection based on model capabilities.
# Some models (gemini) REQUIRE --effort, others (claude, gpt) DON'T SUPPORT it.
supported = _model_supported_efforts(agy_model, profile_id=profile_id)
if supported:
# Model supports specific efforts
if not agy_effort:
# Default: pick "low" if available, else first sorted effort
agy_effort = "low" if "low" in supported else sorted(supported)[0]
elif agy_effort not in supported:
# Requested effort not supported — pick closest
logger.warning(
"effort %r not supported for %s (available: %s), using fallback",
agy_effort, agy_model, sorted(supported),
)
agy_effort = "low" if "low" in supported else sorted(supported)[0]
else:
# Model has NO effort entries — don't pass --effort at all
agy_effort = ""
# Build command.
# Prompt is delivered via stdin (--input-format text), NOT via -p argv.
# This avoids Windows CreateProcess 32767-char command-line limit.
cmd = [
exe,
"--input-format", "text",
"--output-format", "json",
"--dangerously-skip-permissions",
"--disable-slash-commands",
"--print-timeout", f"{timeout}s",
]
if agy_model:
cmd.extend(["--model", agy_model])
if agy_effort:
cmd.extend(["--effort", agy_effort])
logger.info(
"agy_generate: model=%s effort=%s prompt_len=%d",
agy_model, agy_effort, len(prompt),
)
# Pre-flight: verify exe still exists
if not os.path.isfile(exe):
return _error_completion(
model_raw,
f"agy binary does not exist at {exe}",
)
# --- run subprocess ---
t0 = time.monotonic()
try:
result = subprocess.run(
cmd,
input=prompt,
capture_output=True,
text=True,
timeout=timeout + 30,
encoding="utf-8",
errors="replace",
env=custom_env if custom_env is not None else build_safe_subprocess_env(),
)
except subprocess.TimeoutExpired:
return _error_completion(model_raw, "agy subprocess timed out")
except FileNotFoundError as exc:
return _error_completion(model_raw, f"agy binary not found: {exc}")
except OSError as exc:
return _error_completion(model_raw, f"agy OS error: {exc}")
except Exception as exc:
return _error_completion(
model_raw, f"agy subprocess error: {type(exc).__name__}: {exc}"
)
elapsed = time.monotonic() - t0
# --- parse stdout ---
stdout = result.stdout.strip()
if not stdout:
stderr = (result.stderr or "").strip()[:500]
return _error_completion(
model_raw,
f"agy returned empty output (exit {result.returncode}): {stderr}",
)
try:
data = json.loads(stdout)
except json.JSONDecodeError:
return _error_completion(
model_raw, f"agy returned invalid JSON: {stdout[:500]}"
)
status = data.get("status", "")
if status == "ERROR":
return _error_completion(
model_raw, f"agy error: {data.get('error', 'unknown')}"
)
response_text = data.get("response", "")
usage = data.get("usage") or {}
logger.info(
"agy_generate: status=%s elapsed=%.1fs tokens=%s",
status, elapsed, usage.get("total_tokens"),
)
# --- try to extract tool calls ---
tool_calls = _extract_tool_calls(response_text)
message: dict[str, Any] = {
"role": "assistant",
"content": (
_strip_tool_call_blocks(response_text)
if tool_calls
else response_text
) or None,
}
if tool_calls:
message["tool_calls"] = tool_calls
return {
"id": "chatcmpl-agy-" + uuid.uuid4().hex[:16],
"object": "chat.completion",
"created": int(time.time()),
"model": model_raw or "google-antigravity/unknown",
"choices": [
{
"index": 0,
"message": message,
"finish_reason": (
"tool_calls" if tool_calls else "stop"
),
}
],
"usage": {
"prompt_tokens": int(usage.get("input_tokens") or 0),
"completion_tokens": int(usage.get("output_tokens") or 0),
"total_tokens": int(usage.get("total_tokens") or 0),
},
}
def _error_completion(model: str, error_msg: str) -> dict[str, Any]:
"""Build a structured provider error object for router failover."""
logger.error("agy_generate error: %s", error_msg)
return {
"error": {
"message": f"Antigravity error: {error_msg}",
"type": "provider_error",
"model": model or "google-antigravity/unknown",
}
}