Spaces:
Runtime error
Runtime error
| """Bridge to the Antigravity CLI (`agy`) WITHOUT any direct API calls. | |
| `agy -p` emits nothing to stdout; it persists the answer under | |
| <home>/.gemini/antigravity-cli/brain/<conv-id>/.system_generated/logs/transcript.jsonl | |
| and maps <working-dir> -> <conv-id> in <home>/.gemini/antigravity-cli/cache/last_conversations.json. | |
| ISOLATION: every call takes a `home` directory. We point the agy subprocess's | |
| HOME/USERPROFILE at it, so each API key's conversations, scratch, and config live | |
| in their OWN folder. Combined with allowNonWorkspaceAccess=false + sandbox, the | |
| agent cannot read or write outside that home (no cross-tenant access). | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import re | |
| import shutil | |
| import subprocess | |
| import tempfile | |
| import time | |
| from pathlib import Path | |
| AGY_BIN = os.environ.get( | |
| "AGY_BIN", str(Path.home() / "AppData" / "Local" / "agy" / "bin" / "agy.exe") | |
| ) | |
| DEFAULT_TIMEOUT = float(os.environ.get("AGY_TIMEOUT", "180")) | |
| # Fallback home when no per-key home is supplied (local dev / no auth). | |
| _SERVER_HOME = os.environ.get("AGY_SERVER_HOME", "").strip() | |
| DEFAULT_HOME = Path(_SERVER_HOME) if _SERVER_HOME else Path.home() | |
| # Hardened, isolated config written into EVERY home: web search allowed; commands, | |
| # files, image-gen, subagents denied; no access outside the workspace. | |
| _HARDENED_SETTINGS = { | |
| "toolPermission": "strict", | |
| "allowNonWorkspaceAccess": False, | |
| "enableTerminalSandbox": True, | |
| "runningLightSpeed": "off", | |
| "verbosity": "low", | |
| "altScreenMode": "never", | |
| "showTips": False, | |
| "notifications": False, | |
| "showFeedbackSurvey": False, | |
| "model": os.environ.get("AGY_MODEL", "gemini-3.5-flash"), | |
| "permissions": { | |
| "allow": ["search_web(*)", "read_url_content(*)", "ask_question(*)", "finish(*)"], | |
| "deny": [ | |
| "run_command(*)", "edit_file(*)", "create_file(*)", "view_file(*)", | |
| "find_file(*)", "list_directory(*)", "search_directory(*)", | |
| "start_subagent(*)", "generate_image(*)", | |
| ], | |
| "ask": [], | |
| }, | |
| } | |
| _ready_homes: set[str] = set() | |
| # On Linux/HF, agy reads creds from a file relative to HOME (no keyring). Since we | |
| # redirect HOME per key, each key's home needs its own copy of the credential. | |
| # No-op on Windows (AGY_CREDS_JSON unset -> uses the shared OS keyring). | |
| _CREDS_JSON = os.environ.get("AGY_CREDS_JSON", "").strip() | |
| # agy reads its OAuth token from this file (relative to HOME), confirmed on Linux. | |
| _CREDS_FILENAME = os.environ.get("AGY_CREDS_FILENAME", "antigravity-oauth-token") | |
| # Run agy with terminal restrictions so the agent can't run commands that read our | |
| # home internals (OAuth token, trajectory DB, other sessions). Web search still works. | |
| _SANDBOX = os.environ.get("AGY_SANDBOX", "true").strip().lower() == "true" | |
| # Steps that are actually tool output / internal dumps, not a chat answer. We never | |
| # return these as the assistant's reply. | |
| _INTERNAL_STEP = re.compile( | |
| r"(tables in sqlite|trajectory_meta|executor_metadata|gen_metadata|parent_references|" | |
| r"trajectory_metadata_blob|battle_mode_infos|the command completed successfully|" | |
| r"antigravity-cli|\.system_generated|antigravity-oauth|last_conversations|" | |
| r"created at:\s*\d{4}-\d\d-\d\d|completed at:\s*\d{4}-\d\d-\d\d)", | |
| re.I | re.S, | |
| ) | |
| _FALLBACK_ANSWER = "Sorry, I couldn't produce a proper response to that. Could you rephrase?" | |
| def _pick_answer(steps: list[str]) -> str | None: | |
| """Return the most recent step that is a real chat reply (not a tool/internal dump).""" | |
| for s in reversed(steps): | |
| if s and s.strip() and not _INTERNAL_STEP.search(s): | |
| return s | |
| return None | |
| class AgyError(RuntimeError): | |
| pass | |
| def agy_dir(home: Path) -> Path: | |
| return home / ".gemini" / "antigravity-cli" | |
| def ensure_home(home: Path) -> None: | |
| """Write the hardened config (+ creds on Linux/HF) into this home, once per home.""" | |
| key = os.path.normcase(str(home)) | |
| if key in _ready_homes: | |
| return | |
| cfg_dir = agy_dir(home) | |
| cfg_dir.mkdir(parents=True, exist_ok=True) | |
| (cfg_dir / "settings.json").write_text(json.dumps(_HARDENED_SETTINGS, indent=2), encoding="utf-8") | |
| if _CREDS_JSON: # Linux/HF: each isolated home gets the credential file | |
| cf = cfg_dir / _CREDS_FILENAME | |
| cf.write_text(_CREDS_JSON, encoding="utf-8") | |
| try: | |
| os.chmod(cf, 0o600) | |
| except OSError: | |
| pass | |
| _ready_homes.add(key) | |
| def _write_agents_md(workdir: Path, agents_md: str | None) -> None: | |
| """Drop (or clear) a per-user AGENTS.md in the workspace so agy loads it natively. | |
| Called per run so an admin's later edit takes effect on the next turn.""" | |
| target = Path(workdir) / "AGENTS.md" | |
| try: | |
| if agents_md and agents_md.strip(): | |
| target.write_text(agents_md.strip() + "\n", encoding="utf-8") | |
| elif target.exists(): | |
| target.unlink() | |
| except OSError: | |
| pass | |
| def _child_env(home: Path) -> dict: | |
| """Env for the agy subprocess; HOME/USERPROFILE -> this key's isolated home.""" | |
| env = os.environ.copy() | |
| env["USERPROFILE"] = str(home) | |
| env["HOME"] = str(home) | |
| return env | |
| def _transcript_path(conv_id: str, home: Path) -> Path: | |
| return agy_dir(home) / "brain" / conv_id / ".system_generated" / "logs" / "transcript.jsonl" | |
| def _conv_id_for_dir(workdir: Path, home: Path) -> str | None: | |
| lc = agy_dir(home) / "cache" / "last_conversations.json" | |
| try: | |
| mapping = json.loads(lc.read_text(encoding="utf-8")) | |
| except (FileNotFoundError, json.JSONDecodeError): | |
| return None | |
| target = os.path.normcase(os.path.abspath(str(workdir))) | |
| for k, conv_id in mapping.items(): | |
| if os.path.normcase(os.path.abspath(k)) == target: | |
| return conv_id | |
| return None | |
| def _iter_model(conv_id: str, home: Path): | |
| path = _transcript_path(conv_id, home) | |
| if not path.is_file(): | |
| return | |
| for line in path.read_text(encoding="utf-8").splitlines(): | |
| line = line.strip() | |
| if not line: | |
| continue | |
| try: | |
| obj = json.loads(line) | |
| except json.JSONDecodeError: | |
| continue | |
| yield obj | |
| def _model_steps(conv_id: str, home: Path) -> list[str]: | |
| return [ | |
| o["content"] for o in _iter_model(conv_id, home) | |
| if o.get("source") == "MODEL" and isinstance(o.get("content"), str) and o["content"].strip() | |
| ] | |
| def _extract_answer(conv_id: str, home: Path) -> str: | |
| steps = _model_steps(conv_id, home) | |
| if not steps: | |
| raise AgyError(f"no MODEL text response in transcript for {conv_id}") | |
| return _pick_answer(steps) or _FALLBACK_ANSWER | |
| def session_stats(conv_id: str, home: Path) -> dict: | |
| turns = chars = 0 | |
| for o in _iter_model(conv_id, home): | |
| c = o.get("content") | |
| if isinstance(c, str): | |
| chars += len(c) | |
| if o.get("source") in ("USER_EXPLICIT", "MODEL"): | |
| turns += 1 | |
| return {"turns": turns, "context_chars": chars} | |
| def _cmd(base: list[str], model: str | None, image_dir: str | None) -> list[str]: | |
| cmd = list(base) | |
| if _SANDBOX: | |
| cmd += ["--sandbox"] # terminal restrictions: no reading our home internals | |
| if model: | |
| cmd += ["--model", model] | |
| if image_dir: | |
| cmd += ["--add-dir", image_dir] # grant read access to ONLY the attached image | |
| return cmd | |
| def _run(cmd: list[str], cwd: str, home: Path, timeout: float) -> subprocess.CompletedProcess: | |
| if not Path(AGY_BIN).is_file(): | |
| raise AgyError(f"agy binary not found at {AGY_BIN}") | |
| ensure_home(home) | |
| try: | |
| return subprocess.run( | |
| cmd, cwd=cwd, timeout=timeout, capture_output=True, text=True, env=_child_env(home) | |
| ) | |
| except subprocess.TimeoutExpired as e: | |
| raise AgyError(f"agy timed out after {timeout}s") from e | |
| def run_agy(prompt: str, model: str | None = None, image_dir: str | None = None, | |
| home: Path | None = None, timeout: float = DEFAULT_TIMEOUT, | |
| agents_md: str | None = None) -> dict: | |
| """Stateless one-shot in the key's isolated home.""" | |
| home = home or DEFAULT_HOME | |
| workdir = Path(tempfile.mkdtemp(prefix="agy_bridge_")) | |
| started = time.time() | |
| try: | |
| _write_agents_md(workdir, agents_md) | |
| cmd = _cmd([AGY_BIN, "-p", prompt], model, image_dir) | |
| proc = _run(cmd, str(workdir), home, timeout) | |
| conv_id = _conv_id_for_dir(workdir, home) | |
| if not conv_id: | |
| raise AgyError(f"could not resolve conversation id (exit={proc.returncode}, stderr={proc.stderr[:200]!r})") | |
| return {"answer": _extract_answer(conv_id, home), "conversation_id": conv_id, | |
| "elapsed": round(time.time() - started, 2), "exit_code": proc.returncode} | |
| finally: | |
| shutil.rmtree(workdir, ignore_errors=True) | |
| def start_session(prompt: str, session_dir: Path, model: str | None = None, | |
| image_dir: str | None = None, home: Path | None = None, | |
| timeout: float = DEFAULT_TIMEOUT, agents_md: str | None = None) -> dict: | |
| """First turn of a session in the key's isolated home.""" | |
| home = home or DEFAULT_HOME | |
| session_dir.mkdir(parents=True, exist_ok=True) | |
| _write_agents_md(session_dir, agents_md) | |
| started = time.time() | |
| proc = _run(_cmd([AGY_BIN, "-p", prompt], model, image_dir), str(session_dir), home, timeout) | |
| conv_id = _conv_id_for_dir(session_dir, home) | |
| if not conv_id: | |
| raise AgyError(f"could not resolve conversation id (exit={proc.returncode}, stderr={proc.stderr[:200]!r})") | |
| steps = _model_steps(conv_id, home) | |
| if not steps: | |
| raise AgyError(f"no MODEL response for new session {conv_id}") | |
| answer = _pick_answer(steps) or _FALLBACK_ANSWER | |
| return {"answer": answer, "conversation_id": conv_id, "elapsed": round(time.time() - started, 2)} | |
| def continue_session(prompt: str, conv_id: str, session_dir: Path, model: str | None = None, | |
| image_dir: str | None = None, home: Path | None = None, | |
| timeout: float = DEFAULT_TIMEOUT, agents_md: str | None = None) -> dict: | |
| """Resume a conversation in the key's isolated home; agy retains context.""" | |
| home = home or DEFAULT_HOME | |
| _write_agents_md(session_dir, agents_md) | |
| before = len(_model_steps(conv_id, home)) | |
| started = time.time() | |
| _run(_cmd([AGY_BIN, "--conversation", conv_id, "-p", prompt], model, image_dir), | |
| str(session_dir), home, timeout) | |
| steps = _model_steps(conv_id, home) | |
| # Only consider steps produced by THIS turn — never fall back to a prior turn's | |
| # step (that caused a stale tool-dump to repeat across unrelated questions). | |
| answer = _pick_answer(steps[before:]) or _FALLBACK_ANSWER | |
| return {"answer": answer, "conversation_id": conv_id, "elapsed": round(time.time() - started, 2)} | |