"""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 secrets import shlex 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. Стандартные места установки. # # Раньше проверялась только раскладка Windows (%LOCALAPPDATA%/agy/bin), а в # Linux agy ставится в ~/.local/bin. Хаб запускается с урезанным окружением, # где этого каталога в PATH нет, и вход в Antigravity падал с «agy executable # not found» при установленной и работающей утилите. from antigravity_provider.paths import get_hermes_home exe_name = "agy.exe" if os.name == "nt" else "agy" candidates = [ get_hermes_home().parent / "agy" / "bin" / exe_name, Path.home() / ".local" / "bin" / exe_name, Path("/usr/local/bin") / exe_name, Path("/usr/bin") / exe_name, Path("/snap/bin") / exe_name, ] checked = [] for candidate in candidates: checked.append(str(candidate)) try: if candidate.is_file(): return str(candidate) except OSError as exc: # Каталог может быть закрыт правами: это «не смогли проверить», # а не «файла нет». checked[-1] = f"{candidate} (нет доступа: {exc.strerror or exc})" # 3. PATH found = shutil.which("agy") or shutil.which("agy.exe") if found: return found raise FileNotFoundError( "Утилита agy не найдена. Проверено: " + "; ".join(checked) + "; и PATH процесса. Задайте путь переменной AGY_EXE_PATH либо установите agy." ) 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 profile_id is None and _AGY_MODEL_CACHE: 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 target_profile_id: try: from antigravity_provider.router.profile_manager import ProfileAuthManager st = ProfileAuthManager.get_profile_status("antigravity", target_profile_id) if not st.get("authenticated") and not ProfileAuthManager.load_profile_auth("antigravity", target_profile_id): target_profile_id = None except Exception: pass if not target_profile_id: try: from antigravity_provider.paths import get_hermes_home from antigravity_provider.router.profile_manager import ProfileAuthManager from antigravity_provider.router.router_config import load_router_config candidate_pids: list[str] = [] try: cfg = load_router_config() for pid, pcfg in cfg.profiles.items(): if pcfg.provider.lower() in ("antigravity", "google-antigravity") and pid not in candidate_pids: candidate_pids.append(pid) except Exception: pass main_p = ProfileAuthManager.get_main_profile("antigravity") if main_p and main_p not in candidate_pids: candidate_pids.append(main_p) standard_slots = ( ["ag-orch-primary", "ag-orch-fallback"] + [f"ag-{i}" for i in range(1, 21)] + [f"ag-w{i}" for i in range(1, 11)] ) for s in standard_slots: if s not in candidate_pids: candidate_pids.append(s) try: agy_dir = get_hermes_home() / "agy_profiles" if agy_dir.is_dir(): for sub in sorted(agy_dir.iterdir()): if sub.is_dir() and sub.name not in candidate_pids: candidate_pids.append(sub.name) except Exception: pass for candidate in candidate_pids: st = ProfileAuthManager.get_profile_status("antigravity", candidate) if st.get("authenticated") or 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) overrides = { "USERPROFILE": str(profile_dir), "HOME": str(profile_dir), "HOMEPATH": str(profile_dir), } # Проверка доступности у Google смотрит на адрес: без выхода через # разрешённую страну она отвечает «not currently available in your # location», и каталог моделей получить нельзя. overrides.update(proxy_env_overrides(resolve_provider_proxy(target_profile_id))) env = build_safe_subprocess_env(overrides=overrides) else: env = build_safe_subprocess_env( overrides=proxy_env_overrides(resolve_provider_proxy()) ) try: result = subprocess.run( [exe, "models"], capture_output=True, text=True, timeout=60, encoding="utf-8", errors="replace", env=env, stdin=subprocess.DEVNULL, **hidden_process_kwargs(), ) raw = result.stdout.strip() if not raw or result.returncode != 0: if profile_id: # Прежнее сообщение «код 1; каталог не получен» скрывало причину. # agy пишет её в stderr, и без неё непонятно главное: он # запускается с HOME, подменённым на каталог профиля (ради # изоляции учётных данных между аккаунтами). Если вход # выполнялся обычным agy в оболочке, ключи легли в настоящий # домашний каталог, и профиль пуст — отсюда отказ. detail = (result.stderr or "").strip() or (result.stdout or "").strip() detail = detail.splitlines()[-1][:300] if detail else "вывод пуст" raise RuntimeError( f"agy models: код {result.returncode}. Ответ agy: {detail}. " f"Запуск с HOME={env.get('HOME') or env.get('USERPROFILE') or 'не задан'}" ) return dict(_AGY_MODEL_CACHE or {}) except Exception: if profile_id: raise return dict(_AGY_MODEL_CACHE or {}) 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 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/A57 Requirement: Detection without credential logging or stream interception. Checks .gemini/antigravity-cli/antigravity-oauth-token first, then .gemini/oauth_creds.json. """ 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" cli_dir = gemini_dir / "antigravity-cli" cli_token_file = cli_dir / "antigravity-oauth-token" creds_file = gemini_dir / "oauth_creds.json" accounts_file = gemini_dir / "google_accounts.json" auth_file = profile_dir / "auth.json" token_data: dict[str, Any] | None = None auth_method: str = "consumer" # 1. Primary source: agy 2.0 native token file (.gemini/antigravity-cli/antigravity-oauth-token) if cli_token_file.is_file() and cli_token_file.stat().st_size > 0: try: cli_raw = json.loads(cli_token_file.read_text(encoding="utf-8")) if isinstance(cli_raw, dict): auth_method = cli_raw.get("auth_method", "consumer") inner = cli_raw.get("token") if isinstance(inner, dict): token_data = inner elif "access_token" in cli_raw or "refresh_token" in cli_raw: token_data = cli_raw except Exception as exc: logger.debug("Error reading cli_token_file for %s: %s", profile_id, exc) # 2. Secondary source: Gemini CLI legacy credentials (.gemini/oauth_creds.json) if not token_data and creds_file.is_file() and creds_file.stat().st_size > 0: try: creds_raw = json.loads(creds_file.read_text(encoding="utf-8")) if isinstance(creds_raw, dict): token_data = creds_raw auth_method = "oauth" except Exception as exc: logger.debug("Error reading creds_file for %s: %s", profile_id, exc) # 3. Third source: existing auth.json if not token_data and auth_file.is_file() and auth_file.stat().st_size > 0: try: a_raw = json.loads(auth_file.read_text(encoding="utf-8")) if isinstance(a_raw, dict): inner = a_raw.get("token") or a_raw.get("tokens") if isinstance(inner, dict): token_data = inner auth_method = a_raw.get("auth_method", "oauth") except Exception as exc: logger.debug("Error reading auth_file for %s: %s", profile_id, exc) if not token_data or not isinstance(token_data, dict): return False, None, None access_token = token_data.get("access_token") refresh_token = token_data.get("refresh_token") if not access_token and not refresh_token: return False, None, None # Extract email identity truthfully (P0-3: do not invent) email: str | None = 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) and acc_data.get("active"): em = str(acc_data["active"]).strip() if "@" in em: email = em except Exception: email = None if not email and auth_file.is_file(): try: a_data = json.loads(auth_file.read_text(encoding="utf-8")) if isinstance(a_data, dict): em = a_data.get("email") or a_data.get("user_email") if em and "@" in str(em): email = str(em).strip() except Exception: pass if not email: from antigravity_provider.router.profile_manager import ProfileAuthManager id_token = token_data.get("id_token") if id_token: jwt_email, _ = ProfileAuthManager.extract_jwt_identity(str(id_token)) if jwt_email and "@" in jwt_email: email = jwt_email # Токен доступа Google (ya29....) — не JWT, разбирать его как JWT # бессмысленно. Почту по нему отдаёт UserInfo, и этим же путём её узнаёт # браузерный вход. Без почты не срабатывает проверка двойников, и один # аккаунт снова расползётся по слотам — ровно та беда, которую чинили. # # Спрашиваем провайдера, только если в самом профиле почты не нашлось: # у свежего слота нет ни google_accounts.json, ни auth.json, а файл # antigravity-oauth-token (505 байт у владельца) id_token не содержит. if not email and access_token: try: from antigravity_provider.oauth import fetch_user_email remote_email = fetch_user_email(str(access_token)) if remote_email and "@" in remote_email: email = remote_email.strip() except Exception as exc: # Отказ сети не должен ронять вход: аккаунт подключён, почта — Н/Д. logger.info("Почту через UserInfo установить не удалось: %s", exc) auth_data = { "auth_method": auth_method, "email": email or "", "token": token_data, "updated_at": time.time(), } # Keep profile's auth.json in sync auth_file = profile_dir / "auth.json" if not auth_file.is_file(): try: auth_file.write_text(json.dumps(auth_data, indent=2, ensure_ascii=False), encoding="utf-8") os.chmod(auth_file, 0o600) except Exception: pass # Ensure profile permissions (0700 on dirs, 0600 on files) try: os.chmod(profile_dir, 0o700) if gemini_dir.is_dir(): os.chmod(gemini_dir, 0o700) if cli_dir.is_dir(): os.chmod(cli_dir, 0o700) if cli_token_file.is_file(): os.chmod(cli_token_file, 0o600) if creds_file.is_file(): os.chmod(creds_file, 0o600) if auth_file.is_file(): os.chmod(auth_file, 0o600) except OSError: pass return True, email, auth_data 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": "", "arguments": {}}\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", "COLORTERM", # GUI Display and session environment (for terminal emulators) "DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR", "XDG_SESSION_TYPE", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", # 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 resolve_provider_proxy(profile_id: str | None = None) -> str: """Адрес прокси для обращений провайдера: сначала профиль, затем общий. Google отказывает по местоположению: «not currently available in your location». Проверка смотрит на адрес, поэтому вопрос решается выходом через нужную страну, а не правкой чужого бинарника. Настройка на профиль важна не для красоты: у владельца несколько выходных узлов в разных странах, и разным аккаунтам может требоваться разный. """ if profile_id: try: from antigravity_provider.router.profile_manager import ProfileAuthManager auth = ProfileAuthManager.load_profile_auth("antigravity", profile_id) or {} own = str(auth.get("proxy_url") or "").strip() if own: return own except Exception as exc: logger.debug("Прокси профиля %s не прочитан: %s", profile_id, exc) try: from antigravity_provider.router.settings_service import get_hub_settings return str(get_hub_settings().get("provider_proxy_url") or "").strip() except Exception as exc: logger.debug("Общая настройка прокси не прочитана: %s", exc) return "" def proxy_env_overrides(proxy_url: str) -> dict[str, str]: """Переменные окружения для прокси. Пустой адрес — пустой набор. Пишем и заглавные, и строчные имена: Go читает HTTPS_PROXY, curl и большинство библиотек — https_proxy. ALL_PROXY нужен для socks5. """ url = (proxy_url or "").strip() if not url: return {} names = ("HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY") out: dict[str, str] = {} for name in names: out[name] = url out[name.lower()] = url return out def detect_graphical_session() -> tuple[dict[str, str], list[str]]: """Найти графический сеанс владельца. Вернуть (переменные, что проверено). Хаб запускается через nohup и наследует окружение той оболочки, из которой его запустили. Запуск по SSH или из службы оставляет процесс без DISPLAY — и хаб отказывался открыть терминал, стоя при этом на рабочем столе. Наследование не единственный источник. systemd знает про сеанс: на сервере владельца `loginctl show-user -p Display` даёт c1, а `loginctl show-session c1` — Type=x11, Display=:10, Active=yes. Спросить у системы честнее, чем сдаться. XAUTHORITY не выставляем: клиенты X11 по умолчанию берут ~/.Xauthority того же пользователя, а хаб работает под ним же. """ checked: list[str] = [] found: dict[str, str] = {} for var in ("DISPLAY", "WAYLAND_DISPLAY", "MIR_SOCKET"): value = os.environ.get(var, "").strip() checked.append(f"{var} ({'задан: ' + value if value else 'не задан'})") if value: found[var] = value if found: return found, checked loginctl = shutil.which("loginctl") if not loginctl: checked.append("loginctl (не найден)") return {}, checked def _ask(args: list[str]) -> str: try: res = subprocess.run( [loginctl, *args], capture_output=True, text=True, timeout=5, encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL, ) return res.stdout.strip() if res.returncode == 0 else "" except Exception: return "" user = os.environ.get("USER") or os.environ.get("LOGNAME") or "" session = _ask(["show-user", user, "--value", "-p", "Display"]) if user else "" if not session: checked.append(f"loginctl show-user {user or '<пользователь неизвестен>'} (сеанс не назван)") return {}, checked stype = _ask(["show-session", session, "--value", "-p", "Type"]) display = _ask(["show-session", session, "--value", "-p", "Display"]) checked.append(f"loginctl сеанс {session} (тип: {stype or 'Н/Д'}, дисплей: {display or 'Н/Д'})") if stype == "wayland" and display: found["WAYLAND_DISPLAY"] = display elif display: found["DISPLAY"] = display return found, checked def _is_windows() -> bool: """Отдельная проверка системы, чтобы тестам не подменять os.name. Подмена глобального os.name задевает pathlib: он выбирает по нему класс пути, и на Windows создание PosixPath падает — ломая не только проверяемый код, но и сам pytest. """ return os.name == "nt" def write_login_helper(profile_dir: Path, agy_exe: str, profile_id: str) -> Path: """Создать сценарий, который терминал запустит вместо самой agy. Три причины, все проверены на сервере владельца. Окружение задаётся внутри сценария, а не наследуется. Многие эмуляторы — xfce4-terminal, gnome-terminal — держат один процесс на сеанс: новый вызов передаёт задание уже работающему экземпляру и немедленно умирает (в ps остаётся [xfce4-terminal] ). Команда при этом выполняется в окружении СТАРОГО экземпляра, и подменённый HOME не применяется — вход ушёл бы в настоящий домашний каталог владельца мимо изоляции слотов. Окно не закрывается по завершении agy: с ключом -e терминал исчезает вместе с командой, и владелец не успевает прочитать причину отказа. Видно, куда идёт вход: путь к каталогу профиля печатается до запуска. """ helper_path = profile_dir / ".hermes-agy-login.sh" lines = [ "#!/bin/sh", "# Создан Hermes Hub для входа в слот " + profile_id + ".", "# Секретов не содержит: только пути.", "HOME=" + shlex.quote(str(profile_dir)), "USERPROFILE=" + shlex.quote(str(profile_dir)), "HOMEPATH=" + shlex.quote(str(profile_dir)), "export HOME USERPROFILE HOMEPATH", ] # Проверка доступности у Google смотрит на адрес выхода. Без прокси вход # завершается «Eligibility check failed: not currently available in your # location» — аккаунт при этом опознан верно, дело только в стране. proxy = resolve_provider_proxy(profile_id) if proxy: for name, value in proxy_env_overrides(proxy).items(): lines.append(name + "=" + shlex.quote(value)) lines.append("export " + " ".join(sorted(proxy_env_overrides(proxy)))) lines.append('echo "Выход через прокси: ' + proxy.replace('"', "") + '"') lines += [ "cd " + shlex.quote(str(profile_dir)) + " || exit 1", 'echo "Вход Antigravity в слот ' + profile_id + '"', 'echo "Каталог профиля: $HOME"', 'echo', shlex.quote(agy_exe), "status=$?", 'echo', 'echo "agy завершился с кодом $status. Окно можно закрыть."', 'printf "Нажмите Enter... "', "read _ignored", ] helper_path.write_text(chr(10).join(lines) + chr(10), encoding="utf-8") try: os.chmod(helper_path, 0o700) except OSError: pass return helper_path def find_terminal_emulator( profile_id: str, agy_exe: str, profile_dir: Path, title: str | None = None, ) -> tuple[list[str] | None, str | None, list[str]]: """Locate an available GUI terminal emulator on the host system to run native agy CLI login. Returns: (command_args, error_message, checked_candidates) """ term_title = title or f"Antigravity Login ({profile_id})" if _is_windows(): checked = ["Windows Terminal (wt.exe)", "cmd.exe", "powershell.exe"] wt_path = shutil.which("wt.exe") or shutil.which("wt") if wt_path: cmd = [wt_path, "-w", "0", "nt", "-d", str(profile_dir), "--title", term_title, agy_exe] return cmd, None, checked cmd_path = shutil.which("cmd.exe") or shutil.which("cmd") or "cmd.exe" # /k вместо /c: иначе окно исчезает вместе с agy и причина отказа # остаётся непрочитанной. cmd = [cmd_path, "/c", "start", term_title, "cmd", "/k", agy_exe] return cmd, None, checked # Linux / Unix / macOS session_env, checked = detect_graphical_session() if not session_env: err_msg = ( "Графический сеанс не обнаружен: ни в окружении хаба, ни у systemd. " "Для входа на машине без графического интерфейса используйте вход по ссылке " "через браузер." ) return None, err_msg, checked # Что запускаем, решает вызывающий: поиск терминала не должен ничего # создавать. Нативный вход передаёт сюда путь сценария входа, а не саму # agy — см. write_login_helper. launch = agy_exe # Конкретные эмуляторы идут раньше x-terminal-emulator: это обёртка над # альтернативами Debian, лишний слой между нами и настоящей программой. # У xfce4-terminal обязателен --disable-server, иначе вызов передаётся # уже работающему экземпляру и наш процесс умирает, не открыв окна, — # именно это владелец и увидел. candidates: list[tuple[str, Any]] = [ ("xfce4-terminal", lambda p: [p, "--disable-server", "--title", term_title, "-e", launch]), ("konsole", lambda p: [p, "-p", f"tabtitle={term_title}", "-e", launch]), ("tilix", lambda p: [p, "-t", term_title, "-e", launch]), ("alacritty", lambda p: [p, "-t", term_title, "-e", launch]), ("kitty", lambda p: [p, "--title", term_title, launch]), ("terminator", lambda p: [p, "-T", term_title, "-e", launch]), ("urxvt", lambda p: [p, "-title", term_title, "-e", launch]), ("foot", lambda p: [p, "--title", term_title, launch]), ("xterm", lambda p: [p, "-title", term_title, "-e", launch]), ("gnome-terminal", lambda p: [p, "--title", term_title, "--", launch]), ("x-terminal-emulator", lambda p: [p, "-e", launch]), ] checked = [] for name, cmd_builder in candidates: found_path = shutil.which(name) if found_path: checked.append(f"{name} (найден: {found_path})") return cmd_builder(found_path), None, checked else: checked.append(f"{name} (не найден)") err_msg = ( "Терминал не найден на сервере. Проверено: " + "; ".join(checked) + "; и PATH процесса. Запустите вход через браузер либо установите терминал " "(например, gnome-terminal, xfce4-terminal или xterm)." ) return None, err_msg, checked def write_terminal_script_helper( work_dir: Path, command_args: list[str], title: str, env_vars: dict[str, str] | None = None, ) -> Path: """Создать исполняемый сценарий запуска в терминале с удержанием окна.""" work_dir.mkdir(parents=True, exist_ok=True) helper_path = work_dir / f".hermes-task-{secrets.token_hex(4)}.sh" cmd_str = " ".join(shlex.quote(a) for a in command_args) lines = [ "#!/bin/sh", f"# Hermes Hub terminal helper: {title}", ] if env_vars: for k, v in sorted(env_vars.items()): lines.append(f"{k}={shlex.quote(v)}") lines.append("export " + " ".join(sorted(env_vars.keys()))) lines += [ f"cd {shlex.quote(str(work_dir))} || exit 1", f'echo "=== {title} ==="', 'echo', cmd_str, "status=$?", 'echo', 'echo "Команда завершилась с кодом $status. Окно можно закрыть."', 'printf "Нажмите Enter... "', "read _ignored", ] helper_path.write_text("\n".join(lines) + "\n", encoding="utf-8") try: os.chmod(helper_path, 0o700) except OSError: pass return helper_path def launch_terminal_task( title: str, command_args: list[str], work_dir: Path | None = None, env_overrides: dict[str, str] | None = None, ) -> tuple[bool, str, dict[str, Any]]: """Запустить команду в терминале через find_terminal_emulator.""" from antigravity_provider.paths import get_hermes_home wdir = work_dir or get_hermes_home() wdir.mkdir(parents=True, exist_ok=True) script_path = str(write_terminal_script_helper(wdir, command_args, title, env_vars=env_overrides)) term_cmd, err_msg, checked = find_terminal_emulator("task", script_path, wdir, title=title) if err_msg or not term_cmd: return False, err_msg or "Терминал не найден", {"checked_terminals": checked} session_env, _checked = detect_graphical_session() env = build_safe_subprocess_env(overrides=dict(session_env)) if env_overrides: env.update(env_overrides) try: proc = subprocess.Popen( term_cmd, env=env, cwd=str(wdir), stdin=None, stdout=None, stderr=None, ) time.sleep(1.0) exit_code = proc.poll() if isinstance(exit_code, int) and exit_code != 0: return False, f"Терминал {term_cmd[0]} завершился сразу с кодом {exit_code}", { "checked_terminals": checked, "terminal_cmd": term_cmd[0], "exit_code": exit_code, } except Exception as exc: return False, f"Не удалось запустить терминал ({term_cmd[0]}): {exc}", { "checked_terminals": checked, } return True, f"Команда «{title}» успешно запущена в терминале", { "terminal_cmd": term_cmd[0], "work_dir": str(wdir), } class NativeAgySession: """Tracks a native agy CLI terminal login session.""" def __init__(self, profile_id: str, profile_dir: Path, timeout_sec: int = 600): self.session_id = secrets.token_urlsafe(16) self.profile_id = profile_id self.profile_dir = profile_dir self.timeout_sec = timeout_sec self.created_at = time.time() self.status = "pending" # pending, completed, timeout, failed, cancelled self.error_msg: str | None = None self.terminal_cmd: list[str] | None = None self.token_path = profile_dir / ".gemini" / "antigravity-cli" / "antigravity-oauth-token" self.creds_path = profile_dir / ".gemini" / "oauth_creds.json" self.initial_token_mtime = self._get_token_mtime() def _get_token_mtime(self) -> float: mtime = 0.0 if self.token_path.is_file(): try: mtime = max(mtime, self.token_path.stat().st_mtime) except OSError: pass if self.creds_path.is_file(): try: mtime = max(mtime, self.creds_path.stat().st_mtime) except OSError: pass return mtime def check_status(self) -> tuple[bool, str, dict[str, Any]]: """Poll the filesystem to verify if agy created the authentication credentials.""" if self.status == "completed": return True, "Авторизация успешно завершена", { "status": "completed", "profile_id": self.profile_id, } if self.status in ("failed", "cancelled"): return False, self.error_msg or "Авторизация отменена", { "status": self.status, "profile_id": self.profile_id, } now = time.time() if now - self.created_at > self.timeout_sec: self.status = "timeout" self.error_msg = ( f"Время ожидания авторизации в терминале истекло ({int(self.timeout_sec // 60)} минут). " f"Файл учётных данных не появился в {self.profile_dir}." ) return False, self.error_msg, { "status": "timeout", "profile_id": self.profile_id, "home": str(self.profile_dir), } is_authenticated, email, auth_data = check_profile_native_auth_status(self.profile_id) current_mtime = self._get_token_mtime() if is_authenticated and (current_mtime >= (self.created_at - 2.0) or self.initial_token_mtime == 0.0): # Check duplicate identity if email: try: from antigravity_provider.router.auto_assigner import AutoAssigner existing = AutoAssigner.check_duplicate_identity( "antigravity", email, exclude_profile_id=self.profile_id ) if existing and existing != self.profile_id: logger.info( "Native agy login: identity %s already exists in %s, syncing from %s", email, existing, self.profile_id, ) from antigravity_provider.router.adapters.antigravity_adapter import get_profile_env_dir target_dir = get_profile_env_dir(existing) target_gemini = target_dir / ".gemini" target_gemini.mkdir(parents=True, exist_ok=True) src_gemini = self.profile_dir / ".gemini" if src_gemini.is_dir(): shutil.copytree(src_gemini, target_gemini, dirs_exist_ok=True) self.profile_id = existing except Exception as exc: logger.warning("Error checking duplicate identity: %s", exc) # Ensure profile definition and role assignment from antigravity_provider.router.auto_assigner import AutoAssigner AutoAssigner.ensure_profile_definition("antigravity", self.profile_id) self.status = "completed" # Trigger background probe without blocking try: from antigravity_provider.router.account_probe_service import AccountProbeService AccountProbeService.get().schedule("antigravity", self.profile_id, force=True) except Exception: pass return True, "Авторизация успешно завершена через agy CLI", { "status": "completed", "profile_id": self.profile_id, "email": email or "Н/Д (почта не передана провайдером)", } elapsed = int(now - self.created_at) return True, "Ожидание завершения авторизации в терминале...", { "status": "pending", "profile_id": self.profile_id, "elapsed_sec": elapsed, "timeout_sec": self.timeout_sec, } _ACTIVE_NATIVE_SESSIONS: dict[str, NativeAgySession] = {} def get_native_agy_session(session_id: str) -> NativeAgySession | None: return _ACTIVE_NATIVE_SESSIONS.get(session_id) def start_native_agy_login( profile_id: str | None = None, force: bool = False, ) -> tuple[bool, str, dict[str, Any]]: """Launch agy CLI in a new host terminal window with isolated HOME pointing to profile directory. Returns: (ok, message, data) """ from antigravity_provider.router.adapters.antigravity_adapter import get_profile_env_dir from antigravity_provider.router.auto_assigner import AutoAssigner from antigravity_provider.router.profile_manager import ProfileAuthManager slot = profile_id or AutoAssigner.find_free_slot("antigravity") or "ag-1" valid, reason = AutoAssigner.validate_slot("antigravity", slot) if not valid: return False, reason, {"profile_id": slot} # P0-2.2: Do not overwrite occupied slot without explicit confirmation if not force: status = ProfileAuthManager.get_profile_status("antigravity", slot) if status.get("authenticated"): email_info = status.get("email_masked") or "Google Account" return False, f"Слот {slot} уже занят аккаунтом ({email_info}). Подтвердите перезапись учётных данных.", { "confirmation_required": True, "profile_id": slot, "email": email_info, } profile_dir = get_profile_env_dir(slot) try: profile_dir.mkdir(parents=True, exist_ok=True) os.chmod(profile_dir, 0o700) except OSError as exc: return False, f"Не удалось создать каталог профиля {profile_dir}: {exc}", {"profile_id": slot} try: agy_exe = get_agy_exe() except Exception as exc: return False, str(exc), {"profile_id": slot, "home": str(profile_dir)} # Терминал запускает сценарий, а не agy напрямую: сценарий сам задаёт HOME # и не даёт окну закрыться вместе с командой. launch_script = str(write_login_helper(profile_dir, agy_exe, slot)) term_cmd, err_msg, checked = find_terminal_emulator(slot, launch_script, profile_dir) if err_msg or not term_cmd: return False, err_msg or "Терминал не найден", { "profile_id": slot, "home": str(profile_dir), "checked_terminals": checked, } # HOME терминалу НЕ подменяем. Клиенты X11 берут ключ авторизации из # ~/.Xauthority, и с подменённым HOME его там нет: xfce4-terminal не мог # подключиться к дисплею и выходил с кодом 1, не открыв окна. Изоляцию # обеспечивает сценарий входа — он задаёт HOME сам, уже внутри терминала, # непосредственно перед запуском agy. # # Дисплей, найденный у systemd, наоборот, обязан попасть в окружение: в # окружении самого хаба его может не быть, если хаб запущен по SSH. session_env, _checked = detect_graphical_session() env = build_safe_subprocess_env(overrides=dict(session_env)) try: proc = subprocess.Popen( term_cmd, env=env, cwd=str(profile_dir), stdin=None, stdout=None, stderr=None, ) # Popen возвращается сразу и об открытии окна не говорит ничего. # Владелец видел «Терминал запущен», а окна не было: процесс умирал # мгновенно, оставляя зомби. Даём ему секунду и смотрим, жив ли он. time.sleep(1.0) exit_code = proc.poll() # Только настоящий ненулевой код считаем отказом: в проверках # Popen подменяется заглушкой, и её poll() возвращает объект. if isinstance(exit_code, int) and exit_code != 0: return False, ( f"Терминал {term_cmd[0]} завершился сразу с кодом {exit_code}, " f"окно не открылось. Запуск с HOME={profile_dir}" ), { "profile_id": slot, "home": str(profile_dir), "checked_terminals": checked, "terminal_cmd": term_cmd[0], "exit_code": exit_code, } except Exception as launch_exc: return False, f"Не удалось запустить терминал ({term_cmd[0]}): {launch_exc}. Запуск с HOME={profile_dir}", { "profile_id": slot, "home": str(profile_dir), "checked_terminals": checked, } session = NativeAgySession(profile_id=slot, profile_dir=profile_dir) session.terminal_cmd = term_cmd _ACTIVE_NATIVE_SESSIONS[session.session_id] = session logger.info("Started native agy login session=%s profile=%s in terminal=%s", session.session_id, slot, term_cmd[0]) return True, "Терминал успешно запущен. Пройдите авторизацию в открывшемся окне.", { "session_id": session.session_id, "profile_id": slot, "profile_dir": str(profile_dir), "terminal_cmd": term_cmd[0], "timeout_sec": session.timeout_sec, } def poll_native_agy_login(session_id: str) -> tuple[bool, str, dict[str, Any]]: """Check the status of an ongoing native agy login session.""" session = get_native_agy_session(session_id) if not session: return False, "Сессия авторизации не найдена или уже завершена", {"status": "not_found"} return session.check_status() def cancel_native_agy_login(session_id: str) -> tuple[bool, str]: """Cancel an ongoing native agy login session.""" session = get_native_agy_session(session_id) if session: session.status = "cancelled" session.error_msg = "Авторизация отменена пользователем" return True, "Авторизация отменена" return False, "Сессия не найдена" def hidden_process_kwargs() -> dict: """Флаги запуска подпроцесса без видимого окна консоли (только Windows). Хаб — оконное приложение без консоли, поэтому каждый запуск консольного exe (agy.exe и прочие) открывал отдельное чёрное окно. Пока проверка шла по нажатию, это было незаметно. После A50 проверка аккаунтов запускается сама раз в минуту, и окна стали появляться постоянно, мешая работе. Применять ко всем ФОНОВЫМ вызовам. Для входа по OAuth окно нужно видимым — там сознательно используется CREATE_NEW_CONSOLE. """ if os.name != "nt": return {} flags = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW startupinfo.wShowWindow = subprocess.SW_HIDE return {"creationflags": flags, "startupinfo": startupinfo} 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", ] # Каталог agy отдаёт идентификаторы с уровнем усилия: gemini-3.7-flash-high, # -medium, -low. В профиле же хранится голое имя, и вызов уходил с пустым # --effort: agy отвечал «gemini-3.7-flash requires --effort (available: low, # medium, high)» и работа не начиналась. if agy_model and not agy_effort: base, _, tail = str(agy_model).rpartition("-") if base and tail in ("low", "medium", "high"): # Уровень зашит в самом имени — отделяем его. agy_model, agy_effort = base, tail else: known = _AGY_EFFORT_MAP.get(agy_model) or set() if known: # Берём средний уровень, если он есть: он и по названию средний, # и по расходу квоты. Иначе — любой доступный, по порядку. for candidate in ("medium", "high", "low"): if candidate in known: agy_effort = candidate break if not agy_effort: agy_effort = sorted(known)[0] logger.info( "agy_generate: у модели %s не задан уровень усилия, выбран %s из %s", agy_model, agy_effort, sorted(known), ) 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(), **hidden_process_kwargs(), ) 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", } }