diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 40de3fab..1004a784 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -117,6 +117,11 @@ jobs: [roboco-agent-grok-prompter]=docker/agent-grok-prompter.Dockerfile [roboco-agent-grok-secretary]=docker/agent-grok-secretary.Dockerfile [roboco-agent-codex]=docker/agent-codex.Dockerfile + # Gemini (Google, official CLI) — one-shot delivery roles only (V1), + # no interactive prompter/secretary variant depends FROM it, so + # (unlike roboco-agent-grok above) it needs no special build-order + # carve-out and is just another entry here. + [roboco-agent-gemini]=docker/agent-gemini.Dockerfile ) for name in "${!IMAGES[@]}"; do echo "::group::build ${name}" diff --git a/CLAUDE.md b/CLAUDE.md index 2cde99de..be4f1a83 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -380,12 +380,14 @@ The `next` field tells the agent what to call next; the `remediate` field on err ## Agent Providers -Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider` lifecycle ABC (`base.py`) and a `ProviderRegistry` keyed by `ModelProvider` (`registry.py`), with `ClaudeCodeProvider` (default) and `GrokCliProvider`. The orchestrator resolves a provider at spawn from the agent's `ModelProvider`; when no dedicated provider is registered it falls back to the built-in Claude Code spawn. `ModelProvider` (`roboco/models/base.py`) is `ANTHROPIC` (default), `GROK`, `LOCAL`, `OLLAMA_CLOUD`, `OPENAI` (reserved). The seam is additive: only `GROK` routes through `GrokCliProvider`; Anthropic / Ollama Cloud / self-hosted spawns are unchanged, and every provider gets the same MCP gateway + tool-manifest wiring by construction. +Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider` lifecycle ABC (`base.py`) and a `ProviderRegistry` keyed by `ModelProvider` (`registry.py`), with `ClaudeCodeProvider` (default), `GrokCliProvider`, and `GeminiCliProvider`. The orchestrator resolves a provider at spawn from the agent's `ModelProvider`; when no dedicated provider is registered it falls back to the built-in Claude Code spawn. `ModelProvider` (`roboco/models/base.py`) is `ANTHROPIC` (default), `GROK`, `GEMINI`, `LOCAL`, `OLLAMA_CLOUD`, `OPENAI` (reserved). The seam is additive: only `GROK`/`GEMINI` route through their dedicated providers; Anthropic / Ollama Cloud / self-hosted spawns are unchanged, and every provider gets the same MCP gateway + tool-manifest wiring by construction. **Grok runtime.** `GROK` agents run xAI's official `grok` CLI (model `grok-build`) authenticated by a **SuperGrok subscription**, not a metered API key — so a Grok workforce can't stall mid-task on out-of-credits. The host `~/.grok/auth.json` is mounted **read-only** into each agent (`GrokCliProvider._append_grok_auth_mount`; `ROBOCO_HOST_GROK_DIR` is the host mount source, set up once with `grok login`). It reaches parity with the Claude path by construction: same MCP gateway + manifest, per-role tool-removal and git-operation deny rules, a prompt-injection guard on the task prompt, headless tool auto-approval, and per-agent token/cost capture from the grok session store. It covers both one-shot delivery roles and the interactive Intake (Prompter) and Secretary chats (per-turn `grok -p` with session resume). **Token auto-refresh.** The grok access token has a fixed ~6h server-set TTL and the CLI cannot refresh it headlessly — on an expired token it hangs forever at an interactive login prompt. The orchestrator mints a fresh token from the offline-access refresh token (xAI's OIDC `refresh_token` grant) before expiry and rewrites the shared `auth.json` in place (`roboco/llm/providers/grok_auth.py` `refresh_if_stale`, run once per dispatch tick; the orchestrator's `~/.grok` mount is read-write so it can rewrite it). As a backstop the agent entrypoint runs `python -m roboco.llm.providers.grok_auth --check` and refuses to start (exit 78) on a missing/expired token instead of hanging. +**Gemini runtime (V1: one-shot delivery roles only, no interactive Intake/Secretary).** `GEMINI` agents run Google's official `gemini` CLI (GA ids `gemini-2.5-pro`/`-flash`/`-flash-lite`, pinned via `ROBOCO_GEMINI_CLI_MODEL`) authenticated by an **OAuth login**, not a metered key. The host `~/.gemini` (from a one-time interactive `gemini` login, `ROBOCO_HOST_GEMINI_DIR`) is mounted **read-only** at a staging path; the entrypoint COPIES it into a container-local, writable `~/.gemini` so the CLI's own in-process token refresh (google-auth-library) can write back locally without ever touching the host copy. Unlike grok's single-use refresh token (which needs one orchestrator-side writer serializing every refresh, `grok_auth.py`), Google's refresh token is reusable, so each container refreshing its own copy independently is safe with **no orchestrator refresh daemon** — `roboco/llm/providers/gemini.py`'s module docstring spells out the contrast. Tool scoping has no CLI-flag equivalent to grok's `--disallowed-tools`/`--deny`: it's expressed entirely through a rendered TOML Policy Engine (`~/.gemini/policies/roboco.toml`, deny-only rules keyed by `toolName`/`commandPrefix`) plus `settings.json` (`security.auth.selectedType` for headless OAuth, `experimental.enableAgents=false` for the fleet-wide subagent ban, `advanced.autoConfigureMemory=false`), all rendered by `roboco/llm/providers/gemini_cli_config.py`; `--approval-mode yolo` is universal (headless auto-approval). Usage/cost capture (`gemini_cli_usage.py`) reads the run's own `--output-format stream-json` terminal `result` event for per-model token stats — no session-file scraping — and prices each of the three GA models at its own rate before flattening to the grok-shaped `usage.json`; the same module also remaps a quota/rate-limit error (no dedicated CLI exit code — parsed from the run's JSON `error.type`) to exit 75, while exit 41 (the CLI's own auth-failure code) passes straight through, so the orchestrator parks the `GEMINI` provider on either exactly like it does for grok's exit-75/78. + ## Self-Healing & Feature Flags **Self-healing CI loop (default-off).** RoboCo can watch its own repository's CI (a single named workflow) and, on a detected regression, open a fix task that is held out of dispatch until the CEO approves it (it terminates at `awaiting_ceo_approval`), then dispatch it through the normal delivery flow. It is dormant by default and armed by `ROBOCO_SELF_HEAL_ENABLED` plus a second opt-in `ROBOCO_SELF_HEAL_ORIGINATE_ENABLED`; origination is bounded by `ROBOCO_SELF_HEAL_MAX_OPEN_TASKS` / `_MAX_PER_CYCLE` so it can't flood the backlog. It never auto-merges or self-deploys (`roboco/services/self_heal_engine.py`). diff --git a/alembic/versions/084_modelprovider_gemini.py b/alembic/versions/084_modelprovider_gemini.py new file mode 100644 index 00000000..133a9ffb --- /dev/null +++ b/alembic/versions/084_modelprovider_gemini.py @@ -0,0 +1,49 @@ +"""Add 'gemini' to the postgres modelprovider enum. + +Gemini (``ModelProvider.GEMINI`` — Google's OAuth-authenticated ``gemini`` CLI) +is a new agent backend. Seeding its provider row (migration 085) and routing +agents to it requires the postgres ``modelprovider`` enum to carry the value. +Mirrors the enum-add pattern of migration 038 (grok); the row seed is split +into 085 because a newly added enum value cannot be used in the same +transaction that adds it. + +RE-CHAIN CAVEAT: this task built against a checkout where 081 was head, so it +originally numbered these 082/083. Two sibling worktrees landed 082 (routing) +and 083 (codex `seed_openai_provider`) first — this pair was renumbered +084/085 on top of them post-hoc, in this worktree only, to keep a single +linear head: routing(082) -> codex(083) -> gemini(084/085). Neither 082 nor +083 exists in THIS checkout, so this worktree's own migration-graph-integrity +and enum-migration-parity tests fail on the missing siblings until the real +merge lands all three branches together. + +Revision ID: 084_modelprovider_gemini +Revises: 083_seed_openai_provider +Create Date: 2026-07-23 +""" + +from __future__ import annotations + +from alembic import op + +revision = "084_modelprovider_gemini" +down_revision = "083_seed_openai_provider" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # The new value must be COMMITTED before migration 085 inserts a row using + # it: alembic runs the whole upgrade in a single transaction, and Postgres + # forbids using a freshly added enum value in the same transaction that + # added it (UnsafeNewEnumValueUsageError). autocommit_block commits the + # ALTER on its own so 'gemini' is usable downstream. Still renders the + # ALTER TYPE in offline --sql, so the enum-migration-parity test sees it. + # Idempotent via IF NOT EXISTS. + with op.get_context().autocommit_block(): + op.execute("ALTER TYPE modelprovider ADD VALUE IF NOT EXISTS 'gemini'") + + +def downgrade() -> None: + # Postgres does not support removing enum values without a destructive + # type recreation. Forward-only by design (see migration 037). + pass diff --git a/alembic/versions/085_seed_gemini_provider.py b/alembic/versions/085_seed_gemini_provider.py new file mode 100644 index 00000000..426ac4e3 --- /dev/null +++ b/alembic/versions/085_seed_gemini_provider.py @@ -0,0 +1,70 @@ +"""Idempotently seed the Gemini (Google) provider row. + +The ``modelprovider`` enum carries ``'gemini'`` as of migration 084. This +migration seeds the corresponding ``provider_configs`` row so the Settings UI +can list it for role/agent model assignment. + +Unlike Grok's row (migration 039), Gemini has no API-key mode: the CLI +authenticates from a mounted OAuth credential (``~/.gemini/oauth_creds.json``), +never a base URL / bearer token, so both columns stay NULL permanently. The +row starts disabled; an operator enables it once the host OAuth credential is +in place (``ROBOCO_HOST_GEMINI_DIR``). ON CONFLICT (name) DO NOTHING keeps +this safe to re-run. + +RE-CHAIN CAVEAT: renumbered 083->085 (was 083 in this task's original +checkout at head 081) to merge after two sibling worktrees' 082 (routing) / +083 (codex `seed_openai_provider`) — see 084_modelprovider_gemini.py's +docstring for the full note. Neither sibling exists in this checkout, so this +worktree's own migration-graph-integrity / enum-parity tests fail on the +missing revisions until the real three-way merge lands. + +Revision ID: 085_seed_gemini_provider +Revises: 084_modelprovider_gemini +Create Date: 2026-07-23 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "085_seed_gemini_provider" +down_revision = "084_modelprovider_gemini" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.execute( + sa.text( + """ + INSERT INTO provider_configs + (id, name, type, base_url, auth_token_encrypted, enabled, created_at) + VALUES + ( + gen_random_uuid(), + 'Gemini (Google)', + 'gemini', + NULL, + NULL, + false, + now() + ) + ON CONFLICT (name) DO NOTHING + """ + ) + ) + + +def downgrade() -> None: + # Drop model_assignments pointing at the Gemini row first to avoid a FK + # RESTRICT violation on provider_configs.id. + op.execute( + sa.text( + "DELETE FROM model_assignments " + "WHERE provider_config_id IN (" + " SELECT id FROM provider_configs WHERE name = 'Gemini (Google)'" + ")" + ) + ) + op.execute(sa.text("DELETE FROM provider_configs WHERE name = 'Gemini (Google)'")) diff --git a/docker-compose.registry.yml b/docker-compose.registry.yml index bd8f55e4..8979b1a3 100644 --- a/docker-compose.registry.yml +++ b/docker-compose.registry.yml @@ -285,6 +285,13 @@ services: entrypoint: ["/bin/sh", "-c", "echo 'agent-codex image present'"] restart: "no" + # Gemini (Google, via the official gemini CLI). One-shot delivery roles + # only (V1) — no interactive prompter/secretary variant, contrast Grok above. + agent-gemini-image: + image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-agent-gemini:${ROBOCO_VERSION:-latest} + entrypoint: ["/bin/sh", "-c", "echo 'agent-gemini image present'"] + restart: "no" + # Sandbox PG (kitchen-sink) — pulled by the provisioner when a venture opts # into pg extensions. Bare sandboxes use the upstream postgres image, so this # is only needed by extension-using projects. @@ -359,6 +366,10 @@ services: # ChatGPT-subscription auth (host ~/.codex) for Codex-CLI agents — same # shape as ROBOCO_HOST_GROK_DIR. Run `codex login` on the host. ROBOCO_HOST_CODEX_DIR: ${ROBOCO_HOST_CODEX_DIR:-${HOME}/.codex} + # OAuth login (host ~/.gemini) for Gemini-CLI agents — the orchestrator + # mounts /oauth_creds.json (read-only) into each Gemini agent. Run + # `gemini` interactively once on the host to produce it. + ROBOCO_HOST_GEMINI_DIR: ${ROBOCO_HOST_GEMINI_DIR:-${HOME}/.gemini} ROBOCO_HOST_DATA_DIR: ${ROBOCO_HOST_DATA_DIR:-/opt/roboco/data} # Reachable base URL for commit-trailer links — set to your host's LAN # address or domain so the links in commit bodies resolve. @@ -462,6 +473,13 @@ services: - ${ROBOCO_HOST_GROK_DIR:-${HOME}/.grok}:${ROBOCO_HOST_GROK_DIR:-${HOME}/.grok} # Codex CLI auth — same shape as the SuperGrok mount above. - ${ROBOCO_HOST_CODEX_DIR:-${HOME}/.codex}:${ROBOCO_HOST_CODEX_DIR:-${HOME}/.codex} + # OAuth login for Gemini-CLI agents — mount host ~/.gemini at the SAME + # host path the orchestrator hands each Gemini agent's `-v`, so its + # oauth_creds.json exists() check passes here AND the agent bind + # resolves on the host. Read-ONLY: Google's OAuth refresh token is + # reusable and refreshed IN-PROCESS by each agent container's own CLI — + # never by the orchestrator — unlike grok's read-write mount above. + - ${ROBOCO_HOST_GEMINI_DIR:-${HOME}/.gemini}:${ROBOCO_HOST_GEMINI_DIR:-${HOME}/.gemini}:ro - ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs - ${ROBOCO_DATA_DIR:-./data}/vault:/app/vault - ${ROBOCO_DATA_DIR:-./data}/prompts-generated:/app/prompts-generated @@ -471,6 +489,8 @@ services: - ${ROBOCO_DATA_DIR:-./data}/grok-usage:/data/grok-usage # Per-agent CODEX usage capture — same shape as grok-usage above. - ${ROBOCO_DATA_DIR:-./data}/codex-usage:/data/codex-usage + # Per-agent GEMINI usage capture (usage.json -> finalizer). + - ${ROBOCO_DATA_DIR:-./data}/gemini-usage:/data/gemini-usage - ${ROBOCO_DATA_DIR:-./data}/logs:/data/logs # video engine: NAS-only bind mount, off by default in public registry. # Uncomment + set ROBOCO_VIDEO_ENGINE_ENABLED=true in .env to arm. diff --git a/docker-compose.yaml b/docker-compose.yaml index 3436b6dc..8df9289a 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -405,6 +405,21 @@ services: depends_on: - agent-base-image + # ========================================================================== + # Agent Gemini Image Builder (Google Gemini via the official gemini CLI). + # One-shot delivery roles only (V1) — no interactive prompter/secretary + # variant exists for Gemini yet, contrast the Grok images above. + # ========================================================================== + agent-gemini-image: + build: + context: . + dockerfile: docker/agent-gemini.Dockerfile + image: roboco-agent-gemini + entrypoint: ["/bin/sh", "-c", 'echo "Agent Gemini image built"'] + restart: "no" + depends_on: + - agent-base-image + # ========================================================================== # Sandbox PG Image Builder (kitchen-sink postgres for parameterized dev DBs) # Only pulled by the provisioner when a venture requests pg extensions; bare @@ -491,6 +506,10 @@ services: # ChatGPT-subscription auth (host ~/.codex) for Codex-CLI agents — same # shape as ROBOCO_HOST_GROK_DIR. Run `codex login` on the host. ROBOCO_HOST_CODEX_DIR: ${ROBOCO_HOST_CODEX_DIR:-/home/renzof/.codex} + # OAuth login (host ~/.gemini) for Gemini-CLI agents — the orchestrator + # mounts /oauth_creds.json (read-only) into each Gemini agent. Run + # `gemini` interactively once on the host to produce it. + ROBOCO_HOST_GEMINI_DIR: ${ROBOCO_HOST_GEMINI_DIR:-/home/renzof/.gemini} ROBOCO_HOST_DATA_DIR: ${ROBOCO_HOST_DATA_DIR:-/volume1/roboco/data} # Public base URL for commit-trailer links. Default 127.0.0.1 produces # unusable links in commit message bodies; set to NAS LAN IP so @@ -690,6 +709,14 @@ services: # the orchestrator auto-refreshes the access token in place # (codex_auth.refresh_if_stale); each agent's own mount stays read-only. - ${ROBOCO_HOST_CODEX_DIR:-/home/renzof/.codex}:${ROBOCO_HOST_CODEX_DIR:-/home/renzof/.codex} + # OAuth login for Gemini-CLI agents — mount the host ~/.gemini at the + # SAME host path the orchestrator passes to each Gemini agent's `-v`, so + # its oauth_creds.json exists() check passes here AND the agent bind + # resolves on the host. Read-ONLY: unlike grok's single-use SuperGrok + # token, Google's OAuth refresh token is reusable and refreshed + # IN-PROCESS by each agent container's own CLI — never by the + # orchestrator — so no read-write access is needed here. + - ${ROBOCO_HOST_GEMINI_DIR:-/home/renzof/.gemini}:${ROBOCO_HOST_GEMINI_DIR:-/home/renzof/.gemini}:ro # Shared config directory for MCP configs (writable) - ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs - ${ROBOCO_DATA_DIR:-./data}/vault:/app/vault @@ -704,6 +731,9 @@ services: - ${ROBOCO_DATA_DIR:-./data}/grok-usage:/data/grok-usage # Per-agent CODEX usage capture — same shape as grok-usage above. - ${ROBOCO_DATA_DIR:-./data}/codex-usage:/data/codex-usage + # Per-agent GEMINI usage capture: each Gemini agent writes usage.json + # under /; the finalizer reads the captured tokens/cost back here. + - ${ROBOCO_DATA_DIR:-./data}/gemini-usage:/data/gemini-usage # Persistent logs — survive `docker compose down/up`. Orchestrator and # each spawned agent write structured logs here so we can audit past # runs instead of relying on ephemeral `docker logs`. diff --git a/docker-compose.yml b/docker-compose.yml index 3436b6dc..8df9289a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -405,6 +405,21 @@ services: depends_on: - agent-base-image + # ========================================================================== + # Agent Gemini Image Builder (Google Gemini via the official gemini CLI). + # One-shot delivery roles only (V1) — no interactive prompter/secretary + # variant exists for Gemini yet, contrast the Grok images above. + # ========================================================================== + agent-gemini-image: + build: + context: . + dockerfile: docker/agent-gemini.Dockerfile + image: roboco-agent-gemini + entrypoint: ["/bin/sh", "-c", 'echo "Agent Gemini image built"'] + restart: "no" + depends_on: + - agent-base-image + # ========================================================================== # Sandbox PG Image Builder (kitchen-sink postgres for parameterized dev DBs) # Only pulled by the provisioner when a venture requests pg extensions; bare @@ -491,6 +506,10 @@ services: # ChatGPT-subscription auth (host ~/.codex) for Codex-CLI agents — same # shape as ROBOCO_HOST_GROK_DIR. Run `codex login` on the host. ROBOCO_HOST_CODEX_DIR: ${ROBOCO_HOST_CODEX_DIR:-/home/renzof/.codex} + # OAuth login (host ~/.gemini) for Gemini-CLI agents — the orchestrator + # mounts /oauth_creds.json (read-only) into each Gemini agent. Run + # `gemini` interactively once on the host to produce it. + ROBOCO_HOST_GEMINI_DIR: ${ROBOCO_HOST_GEMINI_DIR:-/home/renzof/.gemini} ROBOCO_HOST_DATA_DIR: ${ROBOCO_HOST_DATA_DIR:-/volume1/roboco/data} # Public base URL for commit-trailer links. Default 127.0.0.1 produces # unusable links in commit message bodies; set to NAS LAN IP so @@ -690,6 +709,14 @@ services: # the orchestrator auto-refreshes the access token in place # (codex_auth.refresh_if_stale); each agent's own mount stays read-only. - ${ROBOCO_HOST_CODEX_DIR:-/home/renzof/.codex}:${ROBOCO_HOST_CODEX_DIR:-/home/renzof/.codex} + # OAuth login for Gemini-CLI agents — mount the host ~/.gemini at the + # SAME host path the orchestrator passes to each Gemini agent's `-v`, so + # its oauth_creds.json exists() check passes here AND the agent bind + # resolves on the host. Read-ONLY: unlike grok's single-use SuperGrok + # token, Google's OAuth refresh token is reusable and refreshed + # IN-PROCESS by each agent container's own CLI — never by the + # orchestrator — so no read-write access is needed here. + - ${ROBOCO_HOST_GEMINI_DIR:-/home/renzof/.gemini}:${ROBOCO_HOST_GEMINI_DIR:-/home/renzof/.gemini}:ro # Shared config directory for MCP configs (writable) - ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs - ${ROBOCO_DATA_DIR:-./data}/vault:/app/vault @@ -704,6 +731,9 @@ services: - ${ROBOCO_DATA_DIR:-./data}/grok-usage:/data/grok-usage # Per-agent CODEX usage capture — same shape as grok-usage above. - ${ROBOCO_DATA_DIR:-./data}/codex-usage:/data/codex-usage + # Per-agent GEMINI usage capture: each Gemini agent writes usage.json + # under /; the finalizer reads the captured tokens/cost back here. + - ${ROBOCO_DATA_DIR:-./data}/gemini-usage:/data/gemini-usage # Persistent logs — survive `docker compose down/up`. Orchestrator and # each spawned agent write structured logs here so we can audit past # runs instead of relying on ephemeral `docker logs`. diff --git a/docker/agent-gemini.Dockerfile b/docker/agent-gemini.Dockerfile new file mode 100644 index 00000000..cbe8dd7f --- /dev/null +++ b/docker/agent-gemini.Dockerfile @@ -0,0 +1,48 @@ +# Gemini (Google) Agent Image +# ============================================================================= +# Runs Gemini through Google's official `gemini` CLI, authenticated by an OAuth +# login via a mounted ~/.gemini/oauth_creds.json — the parity analogue of the +# Claude Code path's mounted ~/.claude and the grok path's mounted ~/.grok (no +# metered API key). Reuses the base image's roboco venv + uv + the RoboCo MCP +# gateway servers, and the base image's Node.js 22 (the CLI needs node >= 20). +# The entrypoint copies the staged read-only OAuth credential into a writable +# ~/.gemini, renders ~/.gemini/settings.json + a Policy Engine TOML from the +# mounted mcp-config.json (see roboco.llm.providers.gemini_cli_config), and +# runs the CLI headless. One runtime image serves every role — role behaviour +# comes from the mounted system prompt / manifest / mcp-config, exactly as on +# the Claude/grok paths. +# ============================================================================= + +FROM roboco-agent-base + +USER root + +# Install the official Gemini CLI. Pinned — untrusted model output runs under +# it, so bump the version deliberately, never float (spike verified 0.52.0 at +# github.com/google-gemini/gemini-cli @ 9681621c). npm installs to the global +# node_modules the base image's Node 22 already resolves onto PATH. +ARG GEMINI_CLI_VERSION=0.52.0 +RUN npm install -g "@google/gemini-cli@${GEMINI_CLI_VERSION}" \ + && npm cache clean --force \ + && rm -rf /root/.npm /tmp/* \ + && gemini --version + +# Entrypoint: copy the staged OAuth credential into a writable ~/.gemini, +# render settings.json + policy TOML, then run gemini headless (overrides the +# base image's `claude` entrypoint). Owned by agent (mirrors the grok image). +COPY docker/scripts/gemini-cli-agent-entrypoint.sh /app/scripts/gemini-cli-agent-entrypoint.sh +RUN chmod 0755 /app/scripts/gemini-cli-agent-entrypoint.sh \ + && mkdir -p /home/agent/.gemini \ + && chown -R agent:agent /home/agent/.gemini + +USER agent + +LABEL role="gemini-cli-runtime" +LABEL description="Gemini (Google) agent runtime — Gemini Build via the official gemini CLI" + +# advanced.autoConfigureMemory=false (rendered into settings.json) pins Node's +# heap sizing away from auto-detection against a shared host; this bounds it +# explicitly instead. Tunable per-deploy without a rebuild. +ENV NODE_OPTIONS="--max-old-space-size=2048" + +ENTRYPOINT ["/app/scripts/gemini-cli-agent-entrypoint.sh"] diff --git a/docker/scripts/gemini-cli-agent-entrypoint.sh b/docker/scripts/gemini-cli-agent-entrypoint.sh new file mode 100644 index 00000000..7a4b9629 --- /dev/null +++ b/docker/scripts/gemini-cli-agent-entrypoint.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +# Entrypoint for the roboco-agent-gemini image (one-shot delivery roles). +# +# Runs an agent on Google's official `gemini` CLI, authenticated by an OAuth +# login via a mounted ~/.gemini/oauth_creds.json — the parity analogue of the +# Claude Code path's mounted ~/.claude and the grok path's mounted ~/.grok. The +# gateway, identity, and workspace are mounted by the orchestrator's shared +# container assembly (the same that wires Claude/grok); this entrypoint copies +# the staged OAuth credential into a writable ~/.gemini, renders the gemini +# runtime config from that mount, and runs the CLI headless. +set -euo pipefail + +# The orchestrator mounts the host ~/.gemini DIRECTORY read-only at this +# staging path (roboco.llm.providers.gemini._append_gemini_auth_mount). Copy it +# into the image's own ~/.gemini (agent-owned, writable — see the Dockerfile) +# so the CLI's in-process OAuth refresh (google-auth-library) can write the +# refreshed token back locally: Google's refresh token is REUSABLE, so each +# container refreshing its OWN copy independently is safe (contrast grok's +# live-symlinked RO mount, which needs single-writer orchestrator-side +# serialization because xAI's refresh token is single-use — see +# roboco.llm.providers.gemini's module docstring). The host's copy is never +# touched. +AUTH_STAGING_DIR="/home/agent/.gemini-auth-ro" +if [ -d "$AUTH_STAGING_DIR" ]; then + cp -a "$AUTH_STAGING_DIR"/. /home/agent/.gemini/ 2>/dev/null || true +fi + +# Auth preflight. Without a real OAuth credential the CLI would hang at an +# interactive consent prompt in a headless container (or refuse outright, +# depending on the auth-check path) — refuse fast instead: exit 41 (the CLI's +# own dedicated auth-failure code, so the orchestrator's exit classifier +# treats a missing credential identically to a real CLI auth rejection). +if [ ! -s /home/agent/.gemini/oauth_creds.json ]; then + echo "[gemini] OAuth credential missing at ~/.gemini/oauth_creds.json — refusing" \ + "to run. Run 'gemini' interactively once on the host (or set" \ + "ROBOCO_HOST_GEMINI_DIR at the directory holding oauth_creds.json) before" \ + "spawning Gemini agents." >&2 + exit 41 +fi + +# Render ~/.gemini/settings.json (mcpServers + selectedType/enableAgents/ +# autoConfigureMemory) + GEMINI.md + the Policy Engine TOML. Run from /app so +# `python -m` resolves the INSTALLED roboco package: dev/doc/qa agents run at +# their workspace-clone cwd, whose own roboco/ dir would shadow it on the +# sys.path front (the ModuleNotFound lesson). The render reads +# ROBOCO_MCP_CONFIG + ROBOCO_AGENT_ID and writes the config files. +( cd /app && python -m roboco.llm.providers.gemini_cli_config ) + +# Prompt-injection guard (parity with the Claude UserPromptSubmit hook / the +# grok path): the task prompt is DATA, not instructions — refuse a poisoned +# one before the model sees it. Same patterns as +# docker/scripts/user-prompt-hook.sh; run from /app too. +if ! ( cd /app && python -m roboco.agent_sdk.prompt_guard "${ROBOCO_INITIAL_PROMPT:-}" ); then + echo "Refusing to run: task prompt matched a prompt-injection pattern." >&2 + exit 1 +fi + +# MCP-dead-server mitigation. The Gemini CLI has no native fail-fast for a +# dead MCP server (a disconnected server just logs DISCONNECTED and the run +# continues, tool-less) — so this cheap out-of-band check (the same +# gateway-venv import probe the orchestrator's reaper uses, +# `_probe_gateway_health`) catches a corrupted /app/.venv BEFORE the run +# starts, exiting 52 (EX_CONFIG-style: config/environment broken, not a task +# failure) instead of burning a whole run against a tool-less gateway. +if ! /app/.venv/bin/python -c "import httpx, mcp" 2>/dev/null; then + echo "[gemini] MCP gateway venv is broken (httpx/mcp import failed) — refusing" \ + "to run against a dead gateway." >&2 + exit 52 +fi + +# Run the agent. The prompt comes from an env var (never an untrusted argv +# positional). `< /dev/null` keeps the headless run from blocking on stdin. We +# do NOT `exec`: the script regains control to classify the exit code + +# capture usage. `--output-format stream-json` + `tee` streams the run to the +# container's stdout LIVE (so `docker logs` shows the agent reasoning / +# answering in real time, parity with the Claude/grok paths' stream-json) +# while ALSO capturing it to RUN_LOG for the usage-stats / exit-classification +# reads below. stderr goes to ERR_LOG and is surfaced after the run. +RUN_LOG="/tmp/gemini-run.json" +ERR_LOG="/tmp/gemini-run.err" +# gemini_cli_config (rendered above) already wrote the per-role CLI flag +# tokens (today: just --approval-mode yolo — tool scoping lives in +# settings.json/policy TOML, not CLI flags) one per line to this file. +GEMINI_ARGS_FILE="${ROBOCO_GEMINI_ARGS_FILE:-/tmp/roboco-gemini-args}" +mapfile -t GEMINI_ARGS < "$GEMINI_ARGS_FILE" + +set +e +gemini -p "${ROBOCO_INITIAL_PROMPT:-}" \ + -m "${ROBOCO_AGENT_MODEL:-gemini-2.5-pro}" \ + --output-format stream-json \ + "${GEMINI_ARGS[@]}" \ + < /dev/null 2> "$ERR_LOG" | tee "$RUN_LOG" +run_rc=${PIPESTATUS[0]} +set -e +# stdout already streamed live via tee; surface stderr (tool calls / errors) too. +[ -s "$ERR_LOG" ] && cat "$ERR_LOG" >&2 + +# Capture token usage from the run's own captured stdout (no session-file +# scraping — Gemini reports per-model stats directly, unlike grok). Writes a +# usage.json the orchestrator reads back at finalize. Best-effort; never fails +# the run. Run from /app for the same module-resolution reason as the render +# above. +( cd /app && ROBOCO_GEMINI_RUN_LOG="$RUN_LOG" \ + python -m roboco.llm.providers.gemini_cli_usage ) || true + +# Exit-code classification. 41 (auth) is the CLI's own dedicated exit code and +# passes through unchanged. A quota/rate-limit error has NO dedicated CLI exit +# code — it falls to the CLI's generic 1 — so this remaps it to 75 (EX_TEMPFAIL) +# by parsing the run's captured JSON for a quota-error `error.type` +# (TerminalQuotaError / RetryableQuotaError), the parity analogue of grok's +# text-grep exit-75 detector. The orchestrator parks the GEMINI provider on 75 +# instead of the dispatcher respawning the same task every tick. +classified_rc=$(cd /app && ROBOCO_GEMINI_RUN_LOG="$RUN_LOG" \ + ROBOCO_GEMINI_CLI_EXIT_CODE="$run_rc" \ + python -m roboco.llm.providers.gemini_cli_usage --classify-exit) +if [ "$classified_rc" != "$run_rc" ]; then + echo "[gemini] exit $run_rc reclassified to $classified_rc (quota/rate-limit" \ + "detected in the run output) — the orchestrator parks the provider; the" \ + "task is retried when the limit lifts." >&2 +fi + +# A graceful exit without a terminal verb is handled server-side by the +# orchestrator (_handle_stopped_container substitutes the still-owned task) — +# the gemini-cli runtime needs no in-container SDK server for that. +exit "$classified_rc" diff --git a/roboco/billing/pricing.py b/roboco/billing/pricing.py index 8ffe981e..88d885e1 100644 --- a/roboco/billing/pricing.py +++ b/roboco/billing/pricing.py @@ -8,9 +8,10 @@ Pricing is provider-aware. A model name resolves to one of four cases: * **Anthropic** — priced from the table below by substring match. * **Priced non-Anthropic** — xAI Grok (``grok-build-*``, billed per token via - the xAI API) and OpenAI Codex (``gpt-5.3-codex``, a ChatGPT-subscription CLI + the xAI API), OpenAI Codex (``gpt-5.3-codex``, a ChatGPT-subscription CLI priced here for cost attribution, not because the subscription itself is - metered) are priced from the table too. Match by substring like the rest. + metered), and Google Gemini (``gemini-2.5-*``, billed per token via the + Gemini API) are priced from the table too. Match by substring like the rest. * **Free non-Anthropic** — local self-hosted Ollama models (``ollama/`` prefix or bare model tags) and Ollama Cloud models (``:cloud`` tag). These have **no per-token cost**: local inference runs on owned hardware, and Ollama Cloud @@ -71,6 +72,16 @@ _PRICING: list[tuple[str, float, float, float, float]] = [ # $0.175/1M; OpenAI publishes no cache-write premium, so cache_write is # the normal input rate (same convention as grok-build above). ("gpt-5.3-codex", 1.75, 14.00, 0.175, 1.75), + # Google Gemini — priced non-Anthropic (per-token via the Gemini API), all + # three GA models (≤200k context tier for Pro). No cache-rate premium/ + # discount is published in the spike that sourced these, so cache_read / + # cache_write both fall back to the normal input rate (conservative — not + # free — rather than an invented discount). "gemini-2.5-flash" is a prefix + # of "gemini-2.5-flash-lite"; longest-fragment-wins in _lookup_prices + # disambiguates them correctly. + ("gemini-2.5-pro", 1.25, 10.00, 1.25, 1.25), + ("gemini-2.5-flash-lite", 0.10, 0.40, 0.10, 0.10), + ("gemini-2.5-flash", 0.30, 2.50, 0.30, 0.30), # Short aliases used in ROLE_MODEL_MAP / MODEL_MAP ("opus", 5.00, 25.00, 0.50, 6.25), ("sonnet", 3.00, 15.00, 0.30, 0.75), diff --git a/roboco/config.py b/roboco/config.py index d36593bc..3f2ef646 100644 --- a/roboco/config.py +++ b/roboco/config.py @@ -1861,6 +1861,30 @@ class Settings(BaseSettings): "ROBOCO_CODEX_CLI_MODEL" ), ) + # Base retry_after when parking the GEMINI provider on a quota/rate-limit + # exit (see roboco.runtime.orchestrator._park_gemini_rate_limited, which + # backs this off exponentially on repeated re-parks within one episode — + # same shape as grok's park, but grok hardcodes its base as a module + # constant; Gemini's is a tunable Setting since an operator may want a + # different cadence for Google's own OAuth-quota reset window). + gemini_rate_limit_retry_after_seconds: float = Field( + default=60.0, + ge=1.0, + description=( + "Base retry_after (seconds) when parking the GEMINI provider on a " + "quota/rate-limit exit; override via " + "ROBOCO_GEMINI_RATE_LIMIT_RETRY_AFTER_SECONDS" + ), + ) + gemini_auth_retry_after_seconds: float = Field( + default=60.0, + ge=1.0, + description=( + "retry_after (seconds) when parking the GEMINI provider on a " + "missing/invalid OAuth credential (entrypoint preflight exit 41); " + "override via ROBOCO_GEMINI_AUTH_RETRY_AFTER_SECONDS" + ), + ) # An interactive intake/secretary chat the human abandoned (closed the tab # without confirming/stopping) otherwise leaks its container until the # orchestrator restarts. The sweeper reaps a live session whose diff --git a/roboco/llm/providers/__init__.py b/roboco/llm/providers/__init__.py index a58a2234..c5599f8b 100644 --- a/roboco/llm/providers/__init__.py +++ b/roboco/llm/providers/__init__.py @@ -13,11 +13,15 @@ Backends: - :class:`CodexCliProvider` — OpenAI via the official ``codex`` CLI on a ChatGPT subscription (mounted ``~/.codex`` auth, same shape). One-shot delivery roles only in V1 — no interactive intake/secretary support. +- :class:`GeminiCliProvider` — Google Gemini via the official ``gemini`` CLI on + an OAuth login (mounted ``~/.gemini`` auth, one-shot delivery roles only — + see :mod:`roboco.llm.providers.gemini` for the V1 scope). """ from roboco.llm.providers.base import AgentProvider, ProviderError, SpawnResult from roboco.llm.providers.claude_code import ClaudeCodeProvider from roboco.llm.providers.codex import CodexCliProvider +from roboco.llm.providers.gemini import GeminiCliProvider from roboco.llm.providers.grok import GrokCliProvider from roboco.llm.providers.registry import ProviderNotRegisteredError, ProviderRegistry @@ -25,6 +29,7 @@ __all__ = [ "AgentProvider", "ClaudeCodeProvider", "CodexCliProvider", + "GeminiCliProvider", "GrokCliProvider", "ProviderError", "ProviderNotRegisteredError", diff --git a/roboco/llm/providers/gemini.py b/roboco/llm/providers/gemini.py new file mode 100644 index 00000000..744c68a2 --- /dev/null +++ b/roboco/llm/providers/gemini.py @@ -0,0 +1,264 @@ +"""Gemini CLI provider — Google Gemini via the official ``gemini`` CLI. + +Google ships an official terminal coding agent (the ``gemini`` CLI) +authenticated by an OAuth login (``ROBOCO_HOST_GEMINI_DIR``, subscription-style +daily quota caps — the OAuth-login analogue of grok's SuperGrok subscription). +RoboCo runs Gemini agents on it the same way it runs grok agents: the +orchestrator's shared container assembly mounts the RoboCo MCP gateway +(``mcp-config.json``), the agent HMAC identity, and the git context; this +provider adds the OAuth credential mount and the runtime env the gemini-cli +entrypoint reads, then launches the ``roboco-agent-gemini`` image — whose +entrypoint renders ``~/.gemini/settings.json`` + a Policy Engine TOML from the +mounted mcp-config.json (see :mod:`roboco.llm.providers.gemini_cli_config`) and +runs ``gemini -p`` headless. + +Two things differ from the Claude Code spawn (mirrors ``GrokCliProvider``): + 1. **Auth** — the host's ``~/.gemini`` (OAuth credential from a one-time + interactive ``gemini`` login) is mounted READ-ONLY at a staging path; the + entrypoint COPIES it into a container-local, WRITABLE ``~/.gemini`` before + running the CLI. This is where Gemini genuinely diverges from grok's + symlink-to-a-live-RO-mount design (see :mod:`roboco.llm.providers.grok_auth` + for the contrast): + + xAI's grok refresh token is SINGLE-USE — a rotated refresh token + invalidates the prior one instantly, so a shared, live, host-writable + credential needs one orchestrator-side writer serializing every refresh + (that whole module exists to make that safe). Google's OAuth refresh + token is REUSABLE — minting a new access token does not invalidate it — + so there is no shared-writer race to serialize in the first place. Each + container refreshing its OWN local copy in-process (the ``gemini`` CLI's + bundled google-auth-library does this automatically) is therefore safe + with NO orchestrator daemon: no rotation to lose, no concurrent-refresh + race, and the host's read-only copy is never mutated (so it can never be + corrupted by a container's write-back, and a per-container copy means one + agent's refreshed token never propagates to (or conflicts with) a + sibling's). This is why ``gemini_auth.py`` — the grok module this docstring + contrasts against — has no counterpart here. + 2. **Runtime** — the ``roboco-agent-gemini`` image (Gemini CLI) instead of + ``claude``. + +The initial prompt is passed via an **env var, not a positional CLI arg**, +which structurally avoids a flag-injection vector (parity with grok). + +V1 scope: one-shot delivery roles ONLY — no interactive Intake/Secretary driver +(contrast ``GrokCliProvider``, which also serves those via a resumable grok +session). A Gemini agent runs a single ``gemini -p`` invocation per task, the +same shape as a one-shot grok agent. +""" + +from __future__ import annotations + +import asyncio +import dataclasses +import logging +import os +from pathlib import Path +from typing import TYPE_CHECKING, Protocol + +from roboco.llm.providers._docker import container_running, stop_container +from roboco.llm.providers.base import AgentProvider, ProviderError, SpawnResult + +if TYPE_CHECKING: + from roboco.models.runtime import OrchestratorAgentConfig as AgentConfig + +_log = logging.getLogger(__name__) + +# The Gemini agent image (own image, like every other agent role). Overridable +# for tests / staged rollout. +_DEFAULT_GEMINI_IMAGE = os.environ.get( + "ROBOCO_GEMINI_AGENT_IMAGE", "roboco-agent-gemini:latest" +) + +# The gemini CLI model id. GA ids: gemini-2.5-pro / gemini-2.5-flash / +# gemini-2.5-flash-lite (spike-verified). +_GEMINI_CLI_MODEL = os.environ.get("ROBOCO_GEMINI_CLI_MODEL", "gemini-2.5-pro") + +# Host directory holding the OAuth credential (``oauth_creds.json``, from a +# one-time interactive ``gemini`` login). Mounted into the agent's staging path +# like the grok path mounts ``~/.grok``. Override for docker-in-docker / NAS +# deploys (the orchestrator's home is not the host's). +GEMINI_AUTH_HOST_PATH = os.environ.get( + "ROBOCO_HOST_GEMINI_DIR", str(Path.home() / ".gemini") +) + +# In-container paths. +_MCP_CONFIG_IN_CONTAINER = "/app/mcp-config.json" +# The host ~/.gemini DIRECTORY (not the single oauth_creds.json file) is +# mounted RO here — a directory mount (not a single-file bind) so the +# entrypoint's copy step sees a consistent tree even mid-host-write; the +# entrypoint COPIES this into a container-local, writable ~/.gemini (see the +# module docstring for why a copy, not grok's live-symlink, is the right and +# SAFE choice for Gemini's reusable refresh token). +_GEMINI_AUTH_STAGING_DIR_IN_CONTAINER = "/home/agent/.gemini-auth-ro" +# Per-agent data dir (the host side is reused from the shared assembly): the +# entrypoint writes the captured token usage here so the orchestrator reads it +# back at finalize, the gemini analogue of the mounted Claude transcript. +_GEMINI_USAGE_DIR_IN_CONTAINER = "/home/agent/.gemini-usage" +_GEMINI_USAGE_FILE_IN_CONTAINER = f"{_GEMINI_USAGE_DIR_IN_CONTAINER}/usage.json" + + +def _container_name(agent_id: str) -> str: + return f"roboco-agent-{agent_id}" + + +class _GeminiHost(Protocol): + """The orchestrator surface GeminiCliProvider reuses for container assembly. + + Typed as a Protocol so this module never imports ``AgentOrchestrator`` (no + import cycle) and is trivially mockable in tests. + """ + + async def _remove_container( + self, container_name: str, *, stop_reason: str | None = None + ) -> None: ... + + def _ensure_gemini_usage_dir(self, agent_id: str) -> None: ... + + def _resolve_host_paths( + self, config: AgentConfig, agent_settings_path: Path | None + ) -> dict[str, str | None]: ... + + def _build_mount_args( + self, + container_name: str, + config: AgentConfig, + hosts: dict[str, str | None], + ) -> list[str]: ... + + def _append_agent_auth_env(self, cmd: list[str], config: AgentConfig) -> None: ... + + def _append_git_context_env(self, cmd: list[str], config: AgentConfig) -> None: ... + + +class GeminiCliProvider(AgentProvider): + """Spawn a Gemini (Google, official CLI) agent as a gateway-wired container.""" + + def __init__(self, host: _GeminiHost, image: str | None = None) -> None: + self._host = host + self._image = image or _DEFAULT_GEMINI_IMAGE + + async def spawn( + self, + config: AgentConfig, + initial_prompt: str | None = None, + agent_settings_path: Path | None = None, + ) -> SpawnResult: + if not config.mcp_config_path: + raise ProviderError( + "GEMINI spawn requires an MCP config (gateway access).", + agent_id=config.agent_id, + ) + + container_name = _container_name(config.agent_id) + await self._host._remove_container( + container_name, stop_reason="pre_spawn_stale_clear" + ) + # Pre-create the per-agent data dir (world-writable) before the bind + # mount so the non-root agent can write the usage file (else EACCES). + self._host._ensure_gemini_usage_dir(config.agent_id) + + # Reuse the orchestrator's mount/auth/git assembly so the agent gets the + # full MCP gateway + identity wiring. Blank the provider routing fields + # first: otherwise the shared builder would inject the provider endpoint + # as ANTHROPIC_BASE_URL/AUTH_TOKEN — gemini authenticates from the + # mounted ~/.gemini OAuth credential, not a provider key. + mount_config = dataclasses.replace( + config, provider_base_url=None, provider_auth_token=None + ) + hosts = self._host._resolve_host_paths(config, agent_settings_path) + cmd = self._host._build_mount_args(container_name, mount_config, hosts) + self._host._append_agent_auth_env(cmd, config) + self._host._append_git_context_env(cmd, config) + self._append_gemini_auth_mount(cmd) + self._append_usage_mount(cmd, hosts) + self._append_gemini_env(cmd, config, initial_prompt) + cmd.append(self._image) + + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + stdout, stderr = await proc.communicate() + if proc.returncode != 0: + raise ProviderError( + f"Failed to start Gemini container: {stderr.decode().strip()}", + agent_id=config.agent_id, + ) + return SpawnResult( + instance_id=container_name, + extra={"container_id": stdout.decode().strip(), "model": _GEMINI_CLI_MODEL}, + ) + + @staticmethod + def _append_gemini_auth_mount(cmd: list[str]) -> None: + """Mount the host's OAuth credential directory (read-only). + + See the module docstring: unlike grok's live-symlinked RO mount, the + entrypoint COPIES this staged mount into a container-local, writable + ``~/.gemini`` — safe here because Google's refresh token is reusable + (no single-use rotation to lose, no shared-writer race to serialize). + """ + auth_dir = Path(GEMINI_AUTH_HOST_PATH) + if (auth_dir / "oauth_creds.json").exists(): + cmd.extend(["-v", f"{auth_dir}:{_GEMINI_AUTH_STAGING_DIR_IN_CONTAINER}:ro"]) + else: + # The mount is the Gemini OAuth credential — without it the + # container starts but the entrypoint's preflight refuses to run + # (exit 41, no credential). Fail loud at spawn time so the operator + # sees the missing credential immediately instead of diagnosing a + # later exit-41 from the container log markers. + _log.warning( + "gemini host oauth_creds.json not found at %s — spawn will start " + "the container but it is doomed to exit 41 (no OAuth credential). " + "Run `gemini` interactively once on the host (or set " + "ROBOCO_HOST_GEMINI_DIR to the directory holding oauth_creds.json) " + "before spawning Gemini agents.", + auth_dir / "oauth_creds.json", + ) + + @staticmethod + def _append_usage_mount(cmd: list[str], hosts: dict[str, str | None]) -> None: + """Mount the per-agent data dir so the orchestrator reads usage back. + + Reuses the shared per-agent host dir (``hosts["gemini_usage"]``); the + entrypoint writes ``usage.json`` here after the run and the + orchestrator reads it back at finalize. Without it a Gemini agent + finalizes at 0 tokens / $0. + """ + data_host = hosts.get("gemini_usage") + if data_host: + cmd.extend(["-v", f"{data_host}:{_GEMINI_USAGE_DIR_IN_CONTAINER}"]) + + def _append_gemini_env( + self, cmd: list[str], config: AgentConfig, initial_prompt: str | None + ) -> None: + """Append the runtime env the gemini-cli entrypoint + renderer read. + + ``ROBOCO_AGENT_ID`` lets the renderer compute the per-role + settings/policy; ``ROBOCO_MCP_CONFIG`` points it at the mounted gateway + config; the prompt travels as an env var (never an argv positional). + """ + cmd.extend( + [ + "-e", + f"ROBOCO_AGENT_ID={config.agent_id}", + "-e", + f"ROBOCO_AGENT_MODEL={_GEMINI_CLI_MODEL}", + "-e", + f"ROBOCO_MCP_CONFIG={_MCP_CONFIG_IN_CONTAINER}", + "-e", + f"ROBOCO_INITIAL_PROMPT={initial_prompt or ''}", + "-e", + f"ROBOCO_GEMINI_USAGE_FILE={_GEMINI_USAGE_FILE_IN_CONTAINER}", + ] + ) + + async def stop(self, instance_id: str, graceful: bool = True) -> None: + await stop_container(instance_id, graceful) + + async def health_check(self, instance_id: str) -> bool: + return await container_running(instance_id) + + async def remove(self, instance_id: str) -> None: + await self._host._remove_container(instance_id) diff --git a/roboco/llm/providers/gemini_cli_config.py b/roboco/llm/providers/gemini_cli_config.py new file mode 100644 index 00000000..5c56d636 --- /dev/null +++ b/roboco/llm/providers/gemini_cli_config.py @@ -0,0 +1,293 @@ +"""Render a Gemini CLI agent's runtime config + per-role policy at container start. + +The ``roboco-agent-gemini`` image's entrypoint runs ``python -m +roboco.llm.providers.gemini_cli_config`` to turn the mounted Claude Code +``mcp-config.json`` into ``~/.gemini/settings.json`` (``mcpServers`` + +auth/experimental/advanced flags) and to render a per-role TOML Policy Engine +file at ``~/.gemini/policies/roboco.toml``. Keeping the translation in +importable Python (not a shell heredoc) makes it unit-testable. + +Unlike the grok CLI (native ``--disallowed-tools`` / ``--deny`` flags), the +Gemini CLI has no per-invocation tool-removal flag: tool scoping is expressed +entirely through the TOML Policy Engine (``decision = "deny"`` rules matched +by ``toolName`` / ``commandPrefix``) plus two ``settings.json`` switches. This +module's rules are the parity analogue of ``grok_cli_config``'s +``_disallowed_tools`` / ``_deny_rules``: + + * **subagents** — fleet-wide ban via ``settings.json``'s + ``experimental.enableAgents = false`` (no per-role policy rule needed; this + is a single global switch, unlike grok's per-role ``--disallowed-tools Agent``). + * **editing** — a role that doesn't write code (``role_config.allows_write`` + is False) gets both edit tools (``write_file``, ``replace``) denied. + * **shell** — a role that never runs a shell (review / board) gets + ``run_shell_command`` denied outright — nothing left to gate underneath it. + * **git mutation** — a bash-capable role keeps the shell tool, but a + ``commandPrefix`` deny rule per git-mutating verb blocks raw git network / + branch / history ops (agents commit / push through the gateway verbs). + * **destructive / raw package manager** — ``rm -rf`` and the raw + uv/pip/conda/poetry invocations are denied the same way (CEO direction: + use ``make`` instead). + +``--approval-mode yolo`` (headless full auto-approval — the CLI's own +``ask_user`` policy auto-denies in a headless run, so an unapproved run would +never progress) is universal across roles, unlike grok's per-role reasoning +``--effort``: the spike found no verified reasoning-effort knob for the Gemini +CLI, so none is rendered here. + +No hooks are installed for Gemini (contrast ``grok_cli_config``'s +``write_grok_hooks`` / ``write_grok_fable_hooks``): the spike found no verified +hook mechanism on the Gemini CLI, so the exfil-pattern bash-guard and +Fable-mode honesty-nudge are NOT ported in V1 — the Policy Engine deny rules +above are the only enforcement layer. The role blueprint reaches the model via +``~/.gemini/GEMINI.md``, the CLI's hierarchical user-memory file (the +parity analogue of grok's global ``AGENTS.md``). +""" + +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path +from typing import Any + +import tomli_w + +from roboco.agents_config import get_agent_role +from roboco.services.gateway.role_config import get_role_config + +# gemini reads its global settings from ``$HOME/.gemini/settings.json`` (the +# agent's HOME is ``/home/agent``; the host OAuth credential is staged +# alongside it — see roboco.llm.providers.gemini for the copy-not-symlink +# rationale). +GEMINI_SETTINGS_PATH = Path.home() / ".gemini" / "settings.json" +# gemini loads ``$HOME/.gemini/GEMINI.md`` as a hierarchical user-memory file +# (on top of any project-level GEMINI.md under cwd) regardless of --cwd — the +# parity analogue of the grok path's global AGENTS.md. This is how the RoboCo +# role blueprint becomes gemini's system prompt without writing into (and +# polluting) the agent's git workspace. +GEMINI_MEMORY_PATH = Path.home() / ".gemini" / "GEMINI.md" +# The composed role blueprint the orchestrator mounts into every agent container. +SYSTEM_PROMPT_PATH = Path( + os.environ.get("ROBOCO_SYSTEM_PROMPT", "/app/system-prompt.md") +) +# The TOML Policy Engine reads every ``*.toml`` file under this directory. +GEMINI_POLICIES_DIR = Path.home() / ".gemini" / "policies" +_POLICY_FILE_NAME = "roboco.toml" + +# The auth mode a headless run must declare in settings.json, else the CLI +# refuses with exit 41 instead of silently using the mounted OAuth credential +# (verified fact). ``oauth-personal`` is the CLI's "Login with Google" +# personal-OAuth AuthType — the free-tier / subscription-style mode this +# provider mounts a credential for (contrast ``gemini-api-key`` / ``vertex-ai``, +# neither of which apply here). +_AUTH_SELECTED_TYPE = "oauth-personal" + +# The entrypoint reads the computed per-role CLI flags (one token per line) +# from this file. Defaults under the system temp dir (not a hardcoded /tmp +# literal) — mirrors grok_cli_config.GROK_ARGS_PATH. +GEMINI_ARGS_PATH = Path( + os.environ.get("ROBOCO_GEMINI_ARGS_FILE") + or Path(tempfile.gettempdir()) / "roboco-gemini-args" +) + +# Roles that legitimately run a shell. Review / board roles never do — mirrors +# grok_cli_config._BASH_ROLES exactly. +_BASH_ROLES = frozenset({"developer", "documenter", "cell_pm", "main_pm"}) + +# Gemini CLI built-in tool ids gated by the Policy Engine. +_TOOL_SHELL = "run_shell_command" +_EDIT_TOOLS = ("write_file", "replace") + +# Raw git network / branch / history mutation is gateway-mediated (the commit / +# open_pr verbs); agents never run these via raw shell. Denied for every +# bash-capable role — the same set the grok path's ``_GIT_MUTATE_DENY`` blocks. +_GIT_MUTATE_PREFIXES: tuple[str, ...] = ( + "git push", + "git fetch", + "git pull", + "git clone", + "git commit", + "git remote", + "git reset", + "git ls-remote", + "git checkout", + "git merge", + "git rebase", + "git cherry-pick", + "git revert", + "git update-ref", + "git tag -d", + "git reflog delete", +) +_DESTRUCTIVE_PREFIXES: tuple[str, ...] = ("rm -rf",) +# Raw package-manager / test-runner commands — use the Makefile (CEO direction), +# mirroring grok_cli_config._RAW_PM_DENY. +_RAW_PM_PREFIXES: tuple[str, ...] = ( + "uv run", + "uv sync", + "uv pip install", + "uv pip uninstall", + "uv lock", + "uv add", + "uv remove", + "pip install", + "pip3 install", + "pip uninstall", + "conda install", + "conda create", + "conda run", + "poetry run", + "poetry install", + "poetry add", +) + + +def _allows_write(role: str) -> bool: + """True if the role writes code (``role_config.allows_write``).""" + try: + return bool(get_role_config(role).allows_write) + except KeyError: + return False + + +def policy_rules_for_role(role: str) -> list[dict[str, Any]]: + """The Policy Engine ``[[rule]]`` entries (as dicts) gating one role. + + Deny-only (mirrors grok's native ``--deny``): nothing here grants + permission — ``--approval-mode yolo`` auto-approves everything the Policy + Engine doesn't explicitly deny. Priority is uniform (evaluation order + doesn't matter between denies); a shell-less role gets one blanket + ``run_shell_command`` deny and nothing else, since there is no command + left to gate underneath it. + """ + rules: list[dict[str, Any]] = [] + if not _allows_write(role): + rules.extend( + {"toolName": tool, "decision": "deny", "priority": 10} + for tool in _EDIT_TOOLS + ) + if role not in _BASH_ROLES: + rules.append({"toolName": _TOOL_SHELL, "decision": "deny", "priority": 10}) + return rules + for prefix in (*_DESTRUCTIVE_PREFIXES, *_GIT_MUTATE_PREFIXES, *_RAW_PM_PREFIXES): + rules.append( + { + "toolName": _TOOL_SHELL, + "commandPrefix": prefix, + "decision": "deny", + "priority": 20, + } + ) + return rules + + +def render_policy_toml(role: str) -> str: + """Render the Policy Engine TOML for a role; ``""`` when it has no rules.""" + rules = policy_rules_for_role(role) + return tomli_w.dumps({"rule": rules}) if rules else "" + + +def render_settings_json(mcp_config: dict[str, Any]) -> dict[str, Any]: + """Translate Claude Code ``mcpServers`` + fixed flags into gemini's settings.json. + + ``{"command": "uv", "args": [...], "env": {...}}`` becomes the identically + shaped ``mcpServers.`` entry gemini's own schema uses. The three + fixed flags are DESIGN DECISIONS, not per-role: ``selectedType`` makes + headless auth resolve against the mounted OAuth credential instead of + exiting 41; ``enableAgents=false`` is the fleet-wide subagent ban (CEO, + 2026-07-09); ``autoConfigureMemory=false`` pins Node's heap sizing so the + CLI doesn't try to auto-size against a shared host (the Dockerfile sets an + explicit ``--max-old-space-size`` instead). + """ + servers: dict[str, dict[str, Any]] = {} + for name, spec in (mcp_config.get("mcpServers") or {}).items(): + block: dict[str, Any] = { + "command": str(spec.get("command", "")), + "args": [str(a) for a in (spec.get("args") or [])], + } + env = spec.get("env") or {} + if env: + block["env"] = {str(k): str(v) for k, v in env.items()} + servers[str(name)] = block + return { + "mcpServers": servers, + "security": {"auth": {"selectedType": _AUTH_SELECTED_TYPE}}, + "experimental": {"enableAgents": False}, + "advanced": {"autoConfigureMemory": False}, + } + + +def gemini_cli_args() -> list[str]: + """The ``gemini -p`` flag tokens (excludes ``-p``/``-m``/``--cwd``). + + Universal across every role — ``--approval-mode yolo`` (headless + auto-approval); tool scoping lives entirely in the rendered Policy Engine + / settings.json (see :func:`policy_rules_for_role`), not in a CLI flag, + unlike grok's per-role ``grok_cli_args_for_role``. + """ + return ["--approval-mode", "yolo"] + + +def _load_mcp_config(path: str) -> dict[str, Any]: + """Load the mounted mcp-config.json, tolerating a missing / invalid file.""" + try: + with Path(path).open(encoding="utf-8") as fh: + loaded = json.load(fh) + return loaded if isinstance(loaded, dict) else {} + except (OSError, json.JSONDecodeError): + return {} + + +def write_gemini_memory( + *, source: Path = SYSTEM_PROMPT_PATH, dest: Path = GEMINI_MEMORY_PATH +) -> bool: + """Install the mounted role blueprint as gemini's global memory file. + + Copies the composed prompt to ``~/.gemini/GEMINI.md``. Best-effort: returns + False and writes nothing if the source is absent / unreadable, so a missing + prompt never fails the render. + """ + try: + blueprint = source.read_text(encoding="utf-8") + except OSError: + return False + dest.parent.mkdir(parents=True, exist_ok=True) + dest.write_text(blueprint, encoding="utf-8") + return True + + +def write_policy_toml(role: str, *, policies_dir: Path = GEMINI_POLICIES_DIR) -> bool: + """Write the role's Policy Engine TOML; returns False (no-op) if it has no rules.""" + rendered = render_policy_toml(role) + if not rendered: + return False + policies_dir.mkdir(parents=True, exist_ok=True) + (policies_dir / _POLICY_FILE_NAME).write_text(rendered, encoding="utf-8") + return True + + +def main() -> int: + """Entrypoint: write ``~/.gemini/settings.json`` + GEMINI.md + policy TOML.""" + agent_id = os.environ.get("ROBOCO_AGENT_ID", "") + mcp_path = os.environ.get("ROBOCO_MCP_CONFIG", "/app/mcp-config.json") + role = get_agent_role(agent_id) or "" + + GEMINI_SETTINGS_PATH.parent.mkdir(parents=True, exist_ok=True) + GEMINI_SETTINGS_PATH.write_text( + json.dumps(render_settings_json(_load_mcp_config(mcp_path)), indent=2), + encoding="utf-8", + ) + # Pass the module globals explicitly (not relying on write_gemini_memory / + # write_policy_toml's own defaults, which bind at function-definition time + # and would go stale if a caller reassigns the globals after import — a + # real gap for e.g. a test module monkeypatching them post-import). + write_gemini_memory(source=SYSTEM_PROMPT_PATH, dest=GEMINI_MEMORY_PATH) + write_policy_toml(role, policies_dir=GEMINI_POLICIES_DIR) + GEMINI_ARGS_PATH.parent.mkdir(parents=True, exist_ok=True) + GEMINI_ARGS_PATH.write_text("\n".join(gemini_cli_args()) + "\n", encoding="utf-8") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/roboco/llm/providers/gemini_cli_usage.py b/roboco/llm/providers/gemini_cli_usage.py new file mode 100644 index 00000000..a81ac25e --- /dev/null +++ b/roboco/llm/providers/gemini_cli_usage.py @@ -0,0 +1,290 @@ +"""Capture token usage from a Gemini CLI run for the usage / cost dashboard. + +Gemini reports its own per-model token stats directly in the run's stdout — no +session-file scraping needed (contrast ``grok_cli_usage``, which has to locate +and parse ``~/.grok/sessions/.../updates.jsonl`` because ``grok -p`` prints no +summary of its own). The Gemini CLI's ``--output-format json`` terminates with +a single ``{response, stats, error?}`` object; ``--output-format stream-json`` +(NDJSON: init|message|tool_use|tool_result|error|result) carries the SAME +``stats`` block on its terminal ``result`` event. The entrypoint runs +stream-json (for the live ``docker logs`` view, parity with the Claude/grok +paths) and tees it to a run log this module scans for that ``result`` event. + +``stats.models`` is keyed by model name (normally one — the pinned +``ROBOCO_GEMINI_CLI_MODEL`` — but summed generically in case the CLI ever +reports more than one). Its per-model entry shape depends on which +``--output-format`` produced it — our entrypoint always uses stream-json, so +that is the PRIMARY shape parsed; the ``json``-mode shape is a tolerated +fallback (grok-style dual-shape tolerance), never actually hit by our own +entrypoint today: + +* **stream-json (primary — what the entrypoint actually emits).** The + terminal ``result`` event's ``stats`` is ``StreamStats`` + (``packages/core/src/output/types.ts``), built by + ``stream-json-formatter.ts``'s ``convertToStreamStats``. Each + ``stats.models.`` entry is FLAT — no nested ``tokens`` key — verbatim: + ``{total_tokens, input_tokens, output_tokens, cached, input}``, where + ``input_tokens`` is already the model's full billable prompt-token count + (``modelMetrics.tokens.prompt``) and ``cached``/``input`` are its own + breakdown components, not additive on top of it. There is no separate + "thoughts"/"tool" field in this flat shape — reasoning tokens are simply not + broken out here, so nothing needs folding in. +* **json mode (fallback — not emitted by our entrypoint).** ``--output-format + json``'s single-object ``stats`` is the raw ``SessionMetrics`` + (``packages/core/src/telemetry/uiTelemetry.ts``); each + ``stats.models.`` nests a ``tokens`` sub-object: + ``{input, prompt, candidates, total, cached, thoughts, tool}``. "Thoughts" + (reasoning) and tool-use tokens fold into the output bucket here — Gemini + bills reasoning tokens at the output rate, mirroring how ``grok_cli_usage`` + folds reasoning into output; a cached-content token count folds into input + (no cached-rate discount is published for Gemini in + ``roboco.billing.pricing``, so it prices at the full input rate rather than + an unverified free ride). + +Unlike grok (one model, so a blanket total was safe to price at a single +output rate), Gemini's three GA models are priced 4-12x apart, so each +model's tokens are priced with its OWN rate via +``roboco.billing.pricing.calculate_cost`` and the per-model costs are summed +before folding down to the single ``{model, total_tokens, cost_usd}`` shape +the orchestrator reads back (the grok usage.json shape). + +This module also classifies the CLI's raw exit code for the entrypoint +(:func:`classify_exit_code`): the Gemini CLI has no dedicated exit code for a +quota/rate-limit error (it falls to the generic 1), so the wrapper remaps it +to 75 by parsing the run's captured JSON for a quota-error ``error.type`` +(``TerminalQuotaError`` / ``RetryableQuotaError``) — the one case grok +resolves with a plain text ``grep`` instead, since grok's exit-75 detector has +no verified-error-shape equivalent to key off. Exit 41 (auth) is the CLI's own +dedicated code and passes through unchanged. +""" + +from __future__ import annotations + +import contextlib +import json +import logging +import os +import sys +import tempfile +from pathlib import Path +from typing import Any + +from roboco.billing.pricing import calculate_cost + +logger = logging.getLogger(__name__) + +# Where the entrypoint writes the captured usage for the orchestrator to read. +# Defaults under the system temp dir (not a hardcoded /tmp literal). +USAGE_OUT_PATH = Path( + os.environ.get("ROBOCO_GEMINI_USAGE_FILE") + or Path(tempfile.gettempdir()) / "roboco-gemini-usage.json" +) + +_DEFAULT_MODEL = "gemini-2.5-pro" + +# The Gemini CLI's own quota/rate-limit error.type values (spike-verified). +# Neither maps to a dedicated CLI exit code — both fall to the generic 1 — so +# the entrypoint remaps via classify_exit_code() instead of a text grep. +_QUOTA_ERROR_TYPES = ("TerminalQuotaError", "RetryableQuotaError") +# The CLI's own dedicated auth-failure exit code (source-verified) — passed +# through unchanged by classify_exit_code(). +_AUTH_EXIT_CODE = 41 +# Remapped target for a quota/rate-limit error (parity with grok's exit-75 +# rate-limit detector; see roboco.runtime.orchestrator._GEMINI_RATE_LIMIT_EXIT_CODE). +_RATE_LIMIT_EXIT_CODE = 75 + + +def _coerce_int(value: object) -> int: + return int(value) if isinstance(value, (int, float)) else 0 + + +def _model_tokens(entry: dict[str, Any]) -> tuple[int, int]: + """Return ``(input, output)`` tokens for one ``stats.models.`` entry. + + Tries the flat stream-json ``ModelStreamStats`` shape first (our + entrypoint's real wire format: ``input_tokens``/``output_tokens`` sit + directly on the entry, no nested key) — a nested ``tokens`` sub-object + only ever appears on the ``json``-mode ``SessionMetrics`` fallback shape, + so its presence is the discriminator between the two. See the module + docstring for the exact field names + source citations for both shapes. + """ + tokens = entry.get("tokens") + if isinstance(tokens, dict): + # Fallback: --output-format json's raw SessionMetrics.ModelMetrics. + prompt = _coerce_int(tokens.get("prompt", 0)) + cached = _coerce_int(tokens.get("cached", 0)) + candidates = _coerce_int(tokens.get("candidates", 0)) + thoughts = _coerce_int(tokens.get("thoughts", 0)) + tool = _coerce_int(tokens.get("tool", 0)) + return (prompt + cached, candidates + thoughts + tool) + # Primary: --output-format stream-json's flat ModelStreamStats — the + # shape our entrypoint actually parses. input_tokens already IS the full + # billable prompt count (cached is a breakdown component of it, not + # additive on top). + return ( + _coerce_int(entry.get("input_tokens", 0)), + _coerce_int(entry.get("output_tokens", 0)), + ) + + +def extract_model_stats(stats: dict[str, Any]) -> dict[str, tuple[int, int]]: + """Return ``{model_name: (input, output)}`` from a run's ``stats`` block. + + ``{}`` for a missing/malformed ``models`` sub-object (no stats parsed at + all, e.g. a crash before the terminal ``result`` event ever printed). + """ + models = stats.get("models") if isinstance(stats, dict) else None + if not isinstance(models, dict): + return {} + return { + str(name): _model_tokens(entry) + for name, entry in models.items() + if isinstance(entry, dict) + } + + +def usage_and_cost(stats: dict[str, Any]) -> tuple[int, float]: + """Return ``(total_tokens, total_cost_usd)`` for a run's ``stats`` block. + + Each model's tokens are priced at ITS OWN rate (unlike grok's single-model + blanket total) and the per-model costs summed. + """ + total_tokens = 0 + total_cost = 0.0 + for model, (input_tokens, output_tokens) in extract_model_stats(stats).items(): + total_tokens += input_tokens + output_tokens + total_cost += calculate_cost( + model, tokens_input=input_tokens, tokens_output=output_tokens + ) + return total_tokens, round(total_cost, 8) + + +def _error_type(payload: dict[str, Any]) -> str | None: + error = payload.get("error") + if isinstance(error, dict): + error_type = error.get("type") + return error_type if isinstance(error_type, str) else None + return None + + +def _iter_json_events(text: str) -> list[dict[str, Any]]: + """Parse *text* as either a single JSON object or NDJSON lines. + + Tolerant of a partially-written / truncated log: unparseable lines are + skipped rather than aborting the whole scan. + """ + with contextlib.suppress(json.JSONDecodeError): + obj = json.loads(text) + if isinstance(obj, dict): + return [obj] + events: list[dict[str, Any]] = [] + for raw in text.splitlines(): + line = raw.strip() + if not line: + continue + with contextlib.suppress(json.JSONDecodeError): + event = json.loads(line) + if isinstance(event, dict): + events.append(event) + return events + + +def stats_from_run_log(run_log: Path) -> dict[str, Any]: + """Extract the ``stats`` object from a Gemini run's captured stdout. + + Handles both ``--output-format json`` (single object) and + ``--output-format stream-json`` (NDJSON; the terminal ``result`` event + carries ``stats``, the last one wins). Returns ``{}`` on a missing / + unparseable / stats-less log. + """ + try: + text = run_log.read_text(encoding="utf-8") + except OSError: + return {} + stats: dict[str, Any] = {} + for event in _iter_json_events(text): + candidate = event.get("stats") + if isinstance(candidate, dict) and ( + "type" not in event or event.get("type") == "result" + ): + stats = candidate + return stats + + +def is_quota_error(run_log: Path) -> bool: + """True if the run's captured stdout carries a quota-exceeded error. + + Scans for an ``error``-typed NDJSON event (or the single-shot ``error`` + field) whose ``error.type`` is one of :data:`_QUOTA_ERROR_TYPES` — the + Gemini CLI's own rate-limit/quota-exhaustion signal. The CLI itself exits + with the generic code 1 for this case, so :func:`classify_exit_code` remaps + it to 75 instead of falling through to a blind crash-retry. + """ + try: + text = run_log.read_text(encoding="utf-8") + except OSError: + return False + return any( + _error_type(event) in _QUOTA_ERROR_TYPES for event in _iter_json_events(text) + ) + + +def classify_exit_code(cli_exit_code: int, run_log: Path) -> int: + """Remap the CLI's raw exit code into RoboCo's provider-park vocabulary. + + 41 (auth) is the CLI's own dedicated exit code, passed through unchanged — + ``roboco.runtime.orchestrator._is_gemini_auth_exit`` checks it directly. A + quota/rate-limit error has NO dedicated CLI exit code (it falls to the + generic 1), so this is the one case the wrapper remaps: to 75, parity with + grok's exit-75 rate-limit detector. Every other exit code (0, 42, 52, 53, + 54, 130, ...) passes through unchanged. + """ + if cli_exit_code == _AUTH_EXIT_CODE: + return _AUTH_EXIT_CODE + if is_quota_error(run_log): + return _RATE_LIMIT_EXIT_CODE + return cli_exit_code + + +def capture_run_usage(*, run_log: Path, fallback_model: str, out_path: Path) -> int: + """Write ``usage.json`` (``{model, total_tokens, cost_usd}``) for one run. + + Best-effort: a missing/unreadable log or a log with no parsed ``stats`` + still writes a zero-usage file (never raises) — a genuinely absent + ``usage.json`` at finalize is then unambiguously a mount/path failure, not + a quiet zero-cost run indistinguishable from "no output happened". + Returns the total token count written. + """ + stats = stats_from_run_log(run_log) + tokens, cost = usage_and_cost(stats) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text( + json.dumps({"model": fallback_model, "total_tokens": tokens, "cost_usd": cost}), + encoding="utf-8", + ) + return tokens + + +def main(argv: list[str] | None = None) -> int: + """CLI: default writes ``usage.json``; ``--classify-exit`` prints the remapped code. + + The bash entrypoint calls this twice: once (default) after the run to + capture usage, and once with ``--classify-exit`` to decide what exit code + to actually return (see :func:`classify_exit_code`). Both read the SAME + ``ROBOCO_GEMINI_RUN_LOG``. + """ + args = argv if argv is not None else sys.argv[1:] + run_log = Path(os.environ.get("ROBOCO_GEMINI_RUN_LOG", "")) + + if "--classify-exit" in args: + cli_exit_code = int(os.environ.get("ROBOCO_GEMINI_CLI_EXIT_CODE", "1")) + print(classify_exit_code(cli_exit_code, run_log)) + return 0 + + model = os.environ.get("ROBOCO_AGENT_MODEL", _DEFAULT_MODEL) + capture_run_usage(run_log=run_log, fallback_model=model, out_path=USAGE_OUT_PATH) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/roboco/models/base.py b/roboco/models/base.py index af656d95..a740eca6 100644 --- a/roboco/models/base.py +++ b/roboco/models/base.py @@ -195,6 +195,11 @@ class ModelProvider(StrEnum): (roboco.llm.providers.codex.CodexCliProvider), mirroring GROK's shape: a mounted subscription credential (`~/.codex`), not a metered API key. One-shot delivery roles only — no interactive intake/secretary support. + `GEMINI` is Google's Gemini via the official `gemini` CLI, authenticated by + an OAuth login (mounted `~/.gemini/oauth_creds.json`) rather than a metered + API key — the same subscription-style auth shape as GROK. Routes through a + dedicated provider (roboco.llm.providers.gemini), never + ANTHROPIC_BASE_URL injection. """ ANTHROPIC = "anthropic" @@ -202,6 +207,7 @@ class ModelProvider(StrEnum): OPENAI = "openai" LOCAL = "local" GROK = "grok" + GEMINI = "gemini" class AssignmentScope(StrEnum): diff --git a/roboco/models/llm_catalog.py b/roboco/models/llm_catalog.py index 36239103..9b9d0858 100644 --- a/roboco/models/llm_catalog.py +++ b/roboco/models/llm_catalog.py @@ -84,6 +84,16 @@ MODEL_CATALOG: tuple[CatalogEntry, ...] = ( # Routes to the OPENAI provider → CodexCliProvider spawn. Subscription auth # (~/.codex, from `codex login`), no metered API key — parity with Grok. CatalogEntry("gpt-5.3-codex", ModelProvider.OPENAI, "GPT-5.3 Codex"), + # --- Gemini (Google, official gemini CLI) --- + # Routes to the GEMINI provider → GeminiCliProvider spawn (OAuth login, not + # a metered key — see roboco.llm.providers.gemini). One-shot delivery roles + # only (V1). All three GA ids, most-capable (and default, ROBOCO_GEMINI_CLI_MODEL) + # first, down to the cheapest. + CatalogEntry("gemini-2.5-pro", ModelProvider.GEMINI, "Gemini 2.5 Pro"), + CatalogEntry("gemini-2.5-flash", ModelProvider.GEMINI, "Gemini 2.5 Flash"), + CatalogEntry( + "gemini-2.5-flash-lite", ModelProvider.GEMINI, "Gemini 2.5 Flash Lite" + ), ) diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index d928f292..5aa54e7d 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -258,7 +258,8 @@ _ANTHROPIC_RATE_LIMIT_MARKERS: tuple[str, ...] = ( _OLLAMA_RATE_LIMIT_MARKERS: tuple[str, ...] = ("rate limit exceeded",) # ponytail: marker map drives the detector — adding a provider later is a -# table row, not a new branch. Grok is deliberately absent (exit-75 detector). +# table row, not a new branch. Grok and Gemini are deliberately absent (both +# use their own exit-75 detectors instead of a text-marker scan). _RATE_LIMIT_MARKERS_BY_PROVIDER: dict[str, tuple[str, ...]] = { ModelProvider.ANTHROPIC.value: _ANTHROPIC_RATE_LIMIT_MARKERS, ModelProvider.OLLAMA_CLOUD.value: _OLLAMA_RATE_LIMIT_MARKERS, @@ -420,6 +421,36 @@ _CODEX_RATE_LIMIT_RETRY_AFTER_S = 60.0 _CODEX_AUTH_EXIT_CODE = 78 _CODEX_AUTH_RETRY_AFTER_S = 60.0 +# In-orchestrator path where each GEMINI agent's usage capture is visible — +# the gemini analogue of GROK_USAGE_DATA_DIR (see there for the mount shape). +GEMINI_USAGE_DATA_DIR = os.environ.get("ROBOCO_GEMINI_USAGE_DIR", "/data/gemini-usage") + +# A one-shot Gemini container exits with this code (EX_TEMPFAIL) when the run's +# captured stdout carried a quota/rate-limit error (gemini-cli-agent- +# entrypoint.sh remaps the CLI's generic exit 1 to this via +# roboco.llm.providers.gemini_cli_usage.classify_exit_code — the CLI itself has +# no dedicated exit code for this case, unlike grok's own text-grep detector). +# Same numeric value as grok's exit-75 detector (both are "try again later"), +# but checked by a SEPARATE provider-scoped predicate (_is_gemini_rate_limit_exit) +# so the two providers park independently. +_GEMINI_RATE_LIMIT_EXIT_CODE = 75 +# Gemini, like grok, has no real recovery probe (an OAuth-login daily quota cap +# has no cheap API to poll remaining balance) — the probe loop clears a park on +# a timer, and a still-active quota re-parks. Back the re-park retry_after off +# exponentially within one episode so the churn dampens, mirroring +# _GROK_REPARK_BACKOFF_CAP / _GROK_REPARK_EPISODE_GAP_S exactly. +_GEMINI_REPARK_BACKOFF_CAP = 4 +_GEMINI_REPARK_EPISODE_GAP_S = 1500.0 +# A one-shot Gemini container exits with this code (the CLI's own dedicated +# auth-failure code, source-verified) when the entrypoint's OAuth-credential +# preflight found the mounted credential missing/empty. Unlike grok's exit 78, +# no orchestrator-side refresher daemon proactively mints a new token here — +# Google's refresh token is reusable and refreshed IN-PROCESS by the CLI itself +# (see roboco.llm.providers.gemini) — so a genuinely bad/missing credential +# re-parks flat (no exponential backoff) until an operator fixes it on the +# host, exactly like grok's own auth-exit park. +_GEMINI_AUTH_EXIT_CODE = 41 + # ============================================================================= # ORCHESTRATOR @@ -1125,6 +1156,19 @@ class AgentOrchestrator: # actually lifted) resets the count for the next episode. self._grok_last_park_at: datetime | None = None self._grok_repark_count: int = 0 + # Gemini re-park backoff state — same shape as grok's above, tracked + # separately so the two providers' rate-limit episodes never interfere. + self._gemini_last_park_at: datetime | None = None + self._gemini_repark_count: int = 0 + # Configurable retry_after base for GEMINI parks (operators may want to + # tune these for Google's own OAuth-quota reset cadence, unlike grok's + # hardcoded equivalents — see settings.gemini_rate_limit_retry_after_seconds). + self._gemini_rate_limit_retry_after_s: float = ( + settings.gemini_rate_limit_retry_after_seconds + ) + self._gemini_auth_retry_after_s: float = ( + settings.gemini_auth_retry_after_seconds + ) def _init_engine_loop_task_slots(self) -> None: """Task handles for the default-off engine loops. Split out of @@ -1584,6 +1628,49 @@ class AgentOrchestrator: error=str(exc), ) + @staticmethod + def _gemini_usage_root() -> Path: + """The base dir all per-agent gemini usage dirs live under (no agent id). + + Branched compose-vs-local exactly like :meth:`_grok_usage_root`. + """ + if PROJECT_HOST_PATH: + return Path(GEMINI_USAGE_DATA_DIR) + return Path(tempfile.gettempdir()) / "roboco-gemini-usage" + + @staticmethod + def _gemini_usage_dir(agent_id: str) -> Path: + """Per-agent gemini usage dir under :meth:`_gemini_usage_root`. + + Single source of truth for BOTH the pre-create/mount side + (``_ensure_gemini_usage_dir``) and the finalize read side + (``_gemini_usage_json``) — see :meth:`_grok_usage_dir`'s docstring for + the ``_safe_agent_path_segment`` traversal-rejection rationale, shared + verbatim here. + """ + return AgentOrchestrator._gemini_usage_root() / ( + AgentOrchestrator._safe_agent_path_segment(agent_id) + ) + + def _ensure_gemini_usage_dir(self, agent_id: str) -> None: + """Pre-create the agent's gemini usage dir (world-writable) before the mount. + + Same EACCES rationale as :meth:`_ensure_grok_usage_dir`: a missing + Linux bind-mount source is auto-created ``root:root``, so the non-root + ``agent`` user would fail to write ``usage.json`` there without this. + """ + target = self._gemini_usage_dir(agent_id) + try: + target.mkdir(parents=True, exist_ok=True) + target.chmod(0o777) + except OSError as exc: + logger.warning( + "could not pre-create gemini usage dir; gemini agent may EACCES", + agent_id=agent_id, + path=str(target), + error=str(exc), + ) + async def _ensure_image_present( self, bare_image: str, dockerfile_path: str, build_context: str ) -> None: @@ -2896,6 +2983,9 @@ class AgentOrchestrator: "grok_usage": f"{DATA_HOST_PATH}/grok-usage/{config.agent_id}", # Per-agent codex usage dir (OPENAI only); same shape. "codex_usage": f"{DATA_HOST_PATH}/codex-usage/{config.agent_id}", + # Per-agent gemini usage dir (GEMINI only); same shape as + # grok_usage above (see GEMINI_USAGE_DATA_DIR). + "gemini_usage": f"{DATA_HOST_PATH}/gemini-usage/{config.agent_id}", "prompt": ( f"{DATA_HOST_PATH}/prompts-generated/{config.agent_id}-prompt.md" ), @@ -2921,6 +3011,9 @@ class AgentOrchestrator: "codex_usage": str( Path(tempfile.gettempdir()) / "roboco-codex-usage" / config.agent_id ), + "gemini_usage": str( + Path(tempfile.gettempdir()) / "roboco-gemini-usage" / config.agent_id + ), "prompt": str( Path(tempfile.gettempdir()) / "roboco-prompts" @@ -3290,12 +3383,15 @@ class AgentOrchestrator: """Build (once) the registry of dedicated provider backends. Only providers that need a runtime other than the built-in Claude Code - container are registered. Today that is GROK (xAI) and OPENAI (Codex - CLI) — both OpenAI-protocol-shaped subscription CLIs. + container are registered. Today that is GROK (xAI, OpenAI protocol), + OPENAI (Codex CLI, subscription-shaped like GROK), and GEMINI (Google, + official CLI, one-shot delivery roles only — see + roboco.llm.providers.gemini for the V1 scope). """ if self._provider_registry is None: from roboco.llm.providers import ( CodexCliProvider, + GeminiCliProvider, GrokCliProvider, ProviderRegistry, ) @@ -3315,6 +3411,12 @@ class AgentOrchestrator: self, image=_qualify_agent_image("roboco-agent-codex") ), ) + registry.register( + ModelProvider.GEMINI, + GeminiCliProvider( + self, image=_qualify_agent_image("roboco-agent-gemini") + ), + ) self._provider_registry = registry return self._provider_registry @@ -6267,6 +6369,17 @@ class AgentOrchestrator: """ return self._read_usage_json_contained(self._codex_usage_root(), agent_id) + def _gemini_usage_json(self, agent_id: str) -> dict[str, Any] | None: + """Read a GEMINI agent's ``usage.json`` (``{model, total_tokens, cost_usd}``). + + Written to the per-agent data dir by the gemini-cli entrypoint + (one-shot, post-run); read back from the same branched dir the writer + mounts (``_gemini_usage_dir``). Returns ``None`` when absent/unreadable + — shares ``_read_usage_json_contained``'s resolve-and-contain barrier + with the grok/codex reads. + """ + return self._read_usage_json_contained(self._gemini_usage_root(), agent_id) + def _codex_usage_tokens(self, agent_id: str) -> tuple[int, int, int, int]: """An OPENAI agent's token usage from its ``usage.json``. @@ -6310,6 +6423,41 @@ class AgentOrchestrator: except (TypeError, ValueError): return 0 + def _gemini_usage_tokens(self, agent_id: str) -> tuple[int, int, int, int]: + """A GEMINI agent's token usage from its ``usage.json``. + + The entrypoint's usage capture already priced each model's real + input/output split at its OWN rate (unlike grok's single-model + blanket), but flattens to one ``total_tokens`` for this shape — so, + like grok, the whole total folds into output here. A WARNING is + logged on a missing/zero read (a silent mount/uid failure is otherwise + indistinguishable from a genuine zero-cost run). + """ + data = self._gemini_usage_json(agent_id) + total = 0 + if data: + try: + total = int(data.get("total_tokens", 0)) + except (TypeError, ValueError): + total = 0 + if not total: + logger.warning( + "GEMINI agent finalized with no readable usage " + "(0 tokens / $0) — check the data dir mount", + agent_id=agent_id, + ) + return (0, total, 0, 0) + + def _gemini_cost_usd(self, agent_id: str) -> float: + """A GEMINI agent's captured cost from its ``usage.json`` (0 if none).""" + data = self._gemini_usage_json(agent_id) + if not data: + return 0.0 + try: + return float(data.get("cost_usd", 0.0)) + except (TypeError, ValueError): + return 0.0 + async def _enforce_grok_cost_budget(self) -> None: """Kill a live GROK container whose captured cost exceeds the cap. @@ -6377,12 +6525,12 @@ class AgentOrchestrator: ) -> tuple[int, int, int, int]: """Resolve final token counts for a stopping agent. - For a GROK or OPENAI (codex) agent, reads the captured ``usage.json`` - (no SDK server / Claude transcript exists for either). Otherwise tries - the live SDK ``/usage/status`` first; if that misses — the SDK's - in-memory counts race container teardown for short-lived agents — it - falls back to the agent's Claude Code transcript, which is durable and - mounted into this container. Returns + For a GROK, OPENAI (codex), or GEMINI agent, reads the captured + ``usage.json`` (no SDK server / Claude transcript exists for any of + them). Otherwise tries the live SDK ``/usage/status`` first; if that + misses — the SDK's in-memory counts race container teardown for + short-lived agents — it falls back to the agent's Claude Code + transcript, which is durable and mounted into this container. Returns ``(input, output, cache_read, cache_write)``. """ from roboco.models.base import ModelProvider @@ -6392,6 +6540,8 @@ class AgentOrchestrator: return self._grok_usage_tokens(agent_id) if provider == ModelProvider.OPENAI.value: return self._codex_usage_tokens(agent_id) + if provider == ModelProvider.GEMINI.value: + return self._gemini_usage_tokens(agent_id) tokens = (0, 0, 0, 0) sdk_url = f"http://roboco-agent-{agent_id}:{SDK_PORT}/usage/status" @@ -6430,15 +6580,15 @@ class AgentOrchestrator: For ``turns`` only there is a durable Claude-transcript fallback (unique assistant-message count) for short-lived agents whose SDK counts race teardown; ``tool_calls`` has no transcript equivalent and stays 0 ("n/a") - when the SDK misses. Grok agents have neither — returns ``(0, 0)``. - Codex agents have a real ``turn.completed`` count (from its usage.json) - but no tool-call signal — returns ``(turns, 0)``. Best-effort: any - failure degrades to zeros, never blocks finalize. + when the SDK misses. Grok and Gemini agents have neither — returns + ``(0, 0)``. Codex agents have a real ``turn.completed`` count (from + its usage.json) but no tool-call signal — returns ``(turns, 0)``. + Best-effort: any failure degrades to zeros, never blocks finalize. """ from roboco.models.base import ModelProvider provider = self.get_provider_for_agent(agent_id) - if provider == ModelProvider.GROK.value: + if provider in (ModelProvider.GROK.value, ModelProvider.GEMINI.value): return (0, 0) if provider == ModelProvider.OPENAI.value: return (self._codex_usage_turns(agent_id), 0) @@ -6630,10 +6780,12 @@ class AgentOrchestrator: Tries the agent SDK's ``/usage/status`` first; on a zero/miss falls back to the durable transcript (the SDK can report zero mid-run, the same race the finalize path handles). Returns ``None`` when neither - source has any usage yet. GROK / OPENAI (codex) have no SDK server or - Claude transcript, so each routes to its own ``usage.json`` — the same - early return the finalize path uses, so live USAGE_SNAPSHOT reflects - grok/codex agents mid-run too. + source has any usage yet. GROK / OPENAI (codex) / GEMINI have no SDK + server or Claude transcript, so each routes to its own ``usage.json`` + — the same early return the finalize path uses, so live + USAGE_SNAPSHOT reflects grok/codex/gemini agents mid-run too (in + practice a one-shot run's usage.json is written only post-run, so + this is a no-op ``None`` until the run ends). """ instance = self._instances.get(agent_id) provider = ( @@ -6641,12 +6793,18 @@ class AgentOrchestrator: if instance is not None and instance.config is not None else None ) - if provider == ModelProvider.GROK.value: - grok_tokens = self._grok_usage_tokens(agent_id) - return grok_tokens if any(grok_tokens) else None - if provider == ModelProvider.OPENAI.value: - codex_tokens = self._codex_usage_tokens(agent_id) - return codex_tokens if any(codex_tokens) else None + # One-shot CLIs (no SDK server / Claude transcript) each read their own + # captured usage.json — collapsed into a lookup so a third such + # provider is one dict entry, not another branch. + usage_json_readers = { + ModelProvider.GROK.value: self._grok_usage_tokens, + ModelProvider.OPENAI.value: self._codex_usage_tokens, + ModelProvider.GEMINI.value: self._gemini_usage_tokens, + } + read_usage_json = usage_json_readers.get(provider) if provider else None + if read_usage_json is not None: + cli_tokens = read_usage_json(agent_id) + return cli_tokens if any(cli_tokens) else None tokens = await self._fetch_agent_tokens(client, agent_id) if tokens is not None: return tokens @@ -8227,6 +8385,31 @@ Start by: return True return False + async def _maybe_park_for_known_exit( + self, agent_id: str, instance: Any, exit_code: int | None + ) -> bool: + """Park the agent's provider on a recognized rate-limit/auth exit code. + + Grok, Codex, and Gemini each run a one-shot CLI with no live SDK/usage + signal, so a 429-equivalent or missing-credential exit is detected + purely from the exit code (see the individual ``_is_*_exit`` checks). + Tries each provider's pair in turn; returns True on the first match + (having already awaited its ``_park_*`` call), False when none match. + """ + checks = ( + (self._is_grok_rate_limit_exit, self._park_grok_rate_limited), + (self._is_grok_auth_exit, self._park_grok_auth_unavailable), + (self._is_codex_rate_limit_exit, self._park_codex_rate_limited), + (self._is_codex_auth_exit, self._park_codex_auth_unavailable), + (self._is_gemini_rate_limit_exit, self._park_gemini_rate_limited), + (self._is_gemini_auth_exit, self._park_gemini_auth_unavailable), + ) + for is_exit, park in checks: + if is_exit(instance, exit_code): + await park(agent_id, instance) + return True + return False + async def _handle_stopped_container( self, agent_id: str, instance: Any, exit_code: int | None ) -> None: @@ -8240,30 +8423,12 @@ Start by: do nothing; non-zero exits keep the existing crash-retry behaviour. """ cid = instance.container_id[:12] if instance.container_id else None - # Grok 429 parking (B4): a one-shot grok run that hit an xAI 429 exits - # 75 (set by grok-cli-agent-entrypoint.sh). Park the provider instead of - # crash-retrying so the spawn guard suppresses the respawn loop; the - # probe-resume loop revives the task when the limit lifts. - if self._is_grok_rate_limit_exit(instance, exit_code): - await self._park_grok_rate_limited(agent_id, instance) - return - # Grok auth-missing parking (F041): a one-shot grok run whose entrypoint - # found the token missing/expired exits 78 (EX_CONFIG). Park the provider - # instead of crash-retrying — the agent can't start without a valid token, - # so respawning burns tokens for zero progress. The probe-resume loop - # revives the task once grok_auth.refresh_if_stale mints a fresh token. - if self._is_grok_auth_exit(instance, exit_code): - await self._park_grok_auth_unavailable(agent_id, instance) - return - # Codex 429/auth parking: same exit-code convention as grok (see - # _CODEX_RATE_LIMIT_EXIT_CODE / _CODEX_AUTH_EXIT_CODE), scoped to - # ModelProvider.OPENAI so a numeric-code collision with another - # provider's crash can never mis-park. - if self._is_codex_rate_limit_exit(instance, exit_code): - await self._park_codex_rate_limited(agent_id, instance) - return - if self._is_codex_auth_exit(instance, exit_code): - await self._park_codex_auth_unavailable(agent_id, instance) + # Grok/Codex/Gemini rate-limit + auth-missing parking: each one-shot + # CLI mirrors the same "recognized exit code -> park the provider" + # shape (see the individual _is_*_exit / _park_* pairs), so a single + # dispatch loop replaces what would otherwise be 6 near-identical + # early returns (keeps this function's branching flat for PLR0911). + if await self._maybe_park_for_known_exit(agent_id, instance, exit_code): return graceful = exit_code == 0 # Park the provider on a session/usage limit or a server overload detected @@ -9690,6 +9855,33 @@ Start by: and instance.config.provider_type == ModelProvider.OPENAI.value ) + @staticmethod + def _is_gemini_rate_limit_exit(instance: Any, exit_code: int | None) -> bool: + """True for a one-shot gemini container that exited 75 (quota/rate-limit).""" + from roboco.models.base import ModelProvider + + return ( + exit_code == _GEMINI_RATE_LIMIT_EXIT_CODE + and instance.config is not None + and instance.config.provider_type == ModelProvider.GEMINI.value + ) + + @staticmethod + def _is_gemini_auth_exit(instance: Any, exit_code: int | None) -> bool: + """True for a one-shot gemini container that exited 41 (OAuth credential gone). + + The entrypoint's preflight refuses to run (exit 41 — the CLI's own + dedicated auth-failure code) when the mounted ``~/.gemini/oauth_creds.json`` + is missing/empty. See ``_GEMINI_AUTH_EXIT_CODE`` for the full rationale. + """ + from roboco.models.base import ModelProvider + + return ( + exit_code == _GEMINI_AUTH_EXIT_CODE + and instance.config is not None + and instance.config.provider_type == ModelProvider.GEMINI.value + ) + @staticmethod async def _tail_container_logs(container_name: str, lines: int = 80) -> str: """Return the last ``lines`` of a container's combined output, '' on error. @@ -9954,6 +10146,59 @@ Start by: kind="auth_missing", ) + async def _park_gemini_rate_limited(self, agent_id: str, instance: Any) -> None: + """Park a gemini agent whose run hit a quota/rate-limit (entrypoint exit 75). + + Same repark-backoff shape as ``_park_grok_rate_limited`` (F097): Gemini + has no real recovery probe either (an OAuth-login daily quota cap has + no cheap balance-check API), so the probe loop clears the park + optimistically on a timer and a still-active quota re-parks. Back the + re-park ``retry_after`` off exponentially within one episode so the + churn dampens; a gap past ``_GEMINI_REPARK_EPISODE_GAP_S`` starts a + fresh episode at the base retry_after. + """ + from roboco.models.base import ModelProvider + + now = datetime.now(UTC) + last = self._gemini_last_park_at + if ( + last is not None + and (now - last).total_seconds() < _GEMINI_REPARK_EPISODE_GAP_S + ): + self._gemini_repark_count += 1 + else: + self._gemini_repark_count = 0 + self._gemini_last_park_at = now + backoff = 2 ** min(self._gemini_repark_count, _GEMINI_REPARK_BACKOFF_CAP) + base = getattr(self, "_gemini_rate_limit_retry_after_s", 60.0) + await self._park_provider_unavailable( + agent_id, + instance, + provider=ModelProvider.GEMINI.value, + retry_after=base * backoff, + kind="rate_limited", + ) + + async def _park_gemini_auth_unavailable(self, agent_id: str, instance: Any) -> None: + """Park a gemini agent whose OAuth credential was missing (entrypoint exit 41). + + Unlike grok's exit-78 auth park, no orchestrator-side refresher daemon + proactively mints a new token here (see ``_GEMINI_AUTH_EXIT_CODE`` and + ``roboco.llm.providers.gemini``'s module docstring for why none is + needed for a genuinely PRESENT-but-stale credential); a genuinely + missing/invalid one re-parks flat until an operator fixes it on the + host — same flat (no-backoff) shape as grok's own auth park. + """ + from roboco.models.base import ModelProvider + + await self._park_provider_unavailable( + agent_id, + instance, + provider=ModelProvider.GEMINI.value, + retry_after=getattr(self, "_gemini_auth_retry_after_s", 60.0), + kind="auth_missing", + ) + @staticmethod def _too_early_to_probe(state: dict[str, Any]) -> bool: """True while the estimated lift time (activated_at + retry_after) is future. @@ -11707,7 +11952,11 @@ Start now: evidence(task_id="{task_id}") forever (#73). Returns the slug only for a non-GROK ACTIVE instance whose heartbeat has been stale longer than ``claude_stuck_kill_seconds`` — a recent heartbeat, no owner, a GROK provider (handled by the wedged-grok - path), or a non-ACTIVE instance all yield ``None``. + path), or a non-ACTIVE instance all yield ``None``. The bucket is + provider-agnostic by construction (it excludes GROK specifically, not + an allowlist of what it includes) — GEMINI (a one-shot CLI runtime with + no SDK server either) falls into this same generic "non-GROK" bucket + for free, with no dedicated wedge-kill path of its own. """ from roboco.models.base import ModelProvider diff --git a/tests/unit/llm/providers/test_gemini_cli_config.py b/tests/unit/llm/providers/test_gemini_cli_config.py new file mode 100644 index 00000000..2d448070 --- /dev/null +++ b/tests/unit/llm/providers/test_gemini_cli_config.py @@ -0,0 +1,164 @@ +"""gemini_cli_config — mcp-config -> settings.json + per-role Policy Engine TOML.""" + +from __future__ import annotations + +import json +import tomllib +from typing import TYPE_CHECKING + +from roboco.llm.providers import gemini_cli_config as gc + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + +_SAMPLE_MCP = { + "mcpServers": { + "roboco-flow": { + "command": "uv", + "args": ["run", "--no-sync", "python", "-m", "roboco.mcp.flow_server"], + "env": {"ROBOCO_AGENT_ID": "be-dev-1", "ROBOCO_AGENT_TOKEN": "tok-123"}, + }, + "roboco-do": {"command": "uv", "args": ["run", "x"]}, + } +} + + +def _rules_by_tool(rules: list[dict], tool: str) -> list[dict]: + return [r for r in rules if r.get("toolName") == tool] + + +def test_render_settings_json_injects_mcp_servers_and_env() -> None: + rendered = gc.render_settings_json(_SAMPLE_MCP) + flow = rendered["mcpServers"]["roboco-flow"] + assert flow["command"] == "uv" + assert flow["args"][:2] == ["run", "--no-sync"] + assert flow["env"]["ROBOCO_AGENT_TOKEN"] == "tok-123" + assert "env" not in rendered["mcpServers"]["roboco-do"] + + +def test_render_settings_json_fixed_flags() -> None: + rendered = gc.render_settings_json({}) + assert rendered["security"]["auth"]["selectedType"] == "oauth-personal" + assert rendered["experimental"]["enableAgents"] is False + assert rendered["advanced"]["autoConfigureMemory"] is False + assert rendered["mcpServers"] == {} + + +def test_write_gemini_memory_installs_the_blueprint(tmp_path: Path) -> None: + src = tmp_path / "system-prompt.md" + src.write_text("You are a RoboCo backend developer.", encoding="utf-8") + dest = tmp_path / ".gemini" / "GEMINI.md" + assert gc.write_gemini_memory(source=src, dest=dest) is True + assert dest.read_text(encoding="utf-8") == "You are a RoboCo backend developer." + + +def test_write_gemini_memory_noops_when_source_absent(tmp_path: Path) -> None: + dest = tmp_path / ".gemini" / "GEMINI.md" + assert gc.write_gemini_memory(source=tmp_path / "absent.md", dest=dest) is False + assert not dest.exists() + + +def test_developer_policy_only_denies_bash_capable_hazards() -> None: + rules = gc.policy_rules_for_role("developer") + # Developer writes code + runs a shell -> no edit-tool / shell-blanket deny. + assert _rules_by_tool(rules, "write_file") == [] + assert _rules_by_tool(rules, "replace") == [] + shell_rules = _rules_by_tool(rules, "run_shell_command") + assert shell_rules # bash-capable: command-scoped denies exist + assert all("commandPrefix" in r for r in shell_rules) + prefixes = {r["commandPrefix"] for r in shell_rules} + assert "git push" in prefixes + assert "rm -rf" in prefixes + + +def test_pr_reviewer_policy_blanket_denies_shell_and_edit() -> None: + rules = gc.policy_rules_for_role("pr_reviewer") + assert _rules_by_tool(rules, "write_file") + assert _rules_by_tool(rules, "replace") + shell_rules = _rules_by_tool(rules, "run_shell_command") + # A read-only reviewer gets ONE blanket shell deny, no command scoping. + assert len(shell_rules) == 1 + assert "commandPrefix" not in shell_rules[0] + + +def test_main_pm_keeps_shell_but_denies_git_and_edit() -> None: + rules = gc.policy_rules_for_role("main_pm") + assert _rules_by_tool(rules, "write_file") # PM doesn't write code + shell_rules = _rules_by_tool(rules, "run_shell_command") + prefixes = {r.get("commandPrefix") for r in shell_rules} + assert "git push" in prefixes + assert None not in prefixes # no blanket deny — PM keeps its shell + + +def test_render_policy_toml_is_valid_toml() -> None: + parsed = tomllib.loads(gc.render_policy_toml("developer")) + assert isinstance(parsed["rule"], list) + assert all(r["decision"] == "deny" for r in parsed["rule"]) + + +def test_render_policy_toml_empty_when_no_rules( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Every REAL role currently produces at least one rule (write or shell + # denies), but render_policy_toml must still degrade to "" rather than + # emit an empty [[rule]] table for the hypothetical case it doesn't. + monkeypatch.setattr(gc, "policy_rules_for_role", lambda _role: []) + assert gc.render_policy_toml("anything") == "" + + +def test_unknown_role_gets_every_deny_category() -> None: + # An unrecognised role name fails _allows_write's role_config lookup (-> + # False) and isn't in _BASH_ROLES, so it gets edit denies PLUS the blanket + # shell deny — the most restrictive combination. + rules = gc.policy_rules_for_role("unknown-role-xyz") + assert _rules_by_tool(rules, "write_file") + assert _rules_by_tool(rules, "replace") + assert len(_rules_by_tool(rules, "run_shell_command")) == 1 + + +def test_write_policy_toml_writes_file(tmp_path: Path) -> None: + policies_dir = tmp_path / "policies" + assert gc.write_policy_toml("developer", policies_dir=policies_dir) is True + written = (policies_dir / "roboco.toml").read_text(encoding="utf-8") + assert "run_shell_command" in written + + +def test_gemini_cli_args_is_yolo_only() -> None: + assert gc.gemini_cli_args() == ["--approval-mode", "yolo"] + + +def test_main_writes_settings_and_args( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + mcp_path = tmp_path / "mcp-config.json" + mcp_path.write_text(json.dumps(_SAMPLE_MCP), encoding="utf-8") + settings_path = tmp_path / ".gemini" / "settings.json" + memory_path = tmp_path / ".gemini" / "GEMINI.md" + policies_dir = tmp_path / ".gemini" / "policies" + args_path = tmp_path / "gemini-args" + system_prompt = tmp_path / "system-prompt.md" + system_prompt.write_text("blueprint", encoding="utf-8") + + monkeypatch.setattr(gc, "GEMINI_SETTINGS_PATH", settings_path) + monkeypatch.setattr(gc, "GEMINI_MEMORY_PATH", memory_path) + monkeypatch.setattr(gc, "GEMINI_POLICIES_DIR", policies_dir) + monkeypatch.setattr(gc, "GEMINI_ARGS_PATH", args_path) + monkeypatch.setattr(gc, "SYSTEM_PROMPT_PATH", system_prompt) + monkeypatch.setenv("ROBOCO_AGENT_ID", "be-dev-1") + monkeypatch.setenv("ROBOCO_MCP_CONFIG", str(mcp_path)) + + assert gc.main() == 0 + + rendered = json.loads(settings_path.read_text(encoding="utf-8")) + assert rendered["mcpServers"]["roboco-flow"]["env"]["ROBOCO_AGENT_TOKEN"] == ( + "tok-123" + ) + assert memory_path.read_text(encoding="utf-8") == "blueprint" + assert (policies_dir / "roboco.toml").exists() + # One flag token per line — the entrypoint reads it via bash `mapfile -t`. + assert args_path.read_text(encoding="utf-8").splitlines() == [ + "--approval-mode", + "yolo", + ] diff --git a/tests/unit/llm/providers/test_gemini_cli_usage.py b/tests/unit/llm/providers/test_gemini_cli_usage.py new file mode 100644 index 00000000..66f13c51 --- /dev/null +++ b/tests/unit/llm/providers/test_gemini_cli_usage.py @@ -0,0 +1,239 @@ +"""gemini_cli_usage — stats-from-stdout usage capture + exit classification.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +import pytest +from roboco.llm.providers import gemini_cli_usage as gu + +if TYPE_CHECKING: + from pathlib import Path + + +def _single_json(stats: dict) -> str: + return json.dumps({"response": "ok", "stats": stats, "error": None}) + + +def _stream_json(events: list[dict]) -> str: + return "\n".join(json.dumps(e) for e in events) + + +# Flat ModelStreamStats — the REAL shape our entrypoint actually parses, +# transcribed verbatim from the terminal `result` event's `stats.models.` +# entry (--output-format stream-json), per +# packages/core/src/output/types.ts's ModelStreamStats interface: +# {total_tokens, input_tokens, output_tokens, cached, input} — NO nested +# "tokens" key. `input_tokens` is already the full billable prompt count. +_FLAT_MODEL_STATS = { + "models": { + "gemini-2.5-pro": { + "total_tokens": 1500, + "input_tokens": 1000, + "output_tokens": 500, + "cached": 0, + "input": 1000, + } + } +} + +# Nested SessionMetrics.ModelMetrics — the --output-format json FALLBACK +# shape (never actually emitted by our stream-json entrypoint, but tolerated +# defensively), transcribed verbatim from +# packages/core/src/telemetry/uiTelemetry.ts's ModelMetrics interface: +# tokens: {input, prompt, candidates, total, cached, thoughts, tool}. +_NESTED_MODEL_STATS = { + "models": { + "gemini-2.5-pro": { + "tokens": { + "input": 1000, + "prompt": 1000, + "candidates": 500, + "total": 1700, + "cached": 0, + "thoughts": 200, + "tool": 0, + } + } + } +} + + +def test_extract_model_stats_reads_flat_stream_json_shape() -> None: + # The PRIMARY path: this is the real shape produced by our entrypoint's + # --output-format stream-json — no "tokens" nesting, no thoughts/tool + # fields to fold (they aren't broken out in this flat shape at all). + result = gu.extract_model_stats(_FLAT_MODEL_STATS) + assert result == {"gemini-2.5-pro": (1000, 500)} + + +def test_extract_model_stats_empty_for_missing_models() -> None: + assert gu.extract_model_stats({}) == {} + assert gu.extract_model_stats({"models": "not-a-dict"}) == {} + + +def test_extract_model_stats_reads_nested_json_mode_fallback() -> None: + # The regression test for the shape bug: a fixture in the OTHER mode's + # (--output-format json) shape must still produce sane non-zero usage via + # the nested-"tokens" fallback branch, even though our entrypoint never + # actually emits this shape. thoughts folds into output: 500 + 200 = 700. + result = gu.extract_model_stats(_NESTED_MODEL_STATS) + assert result == {"gemini-2.5-pro": (1000, 700)} + + +def test_usage_and_cost_prices_each_model_at_its_own_rate() -> None: + stats = { + "models": { + # pro: $1.25/$10.00 per 1M + "gemini-2.5-pro": {"input_tokens": 1_000_000, "output_tokens": 0}, + # flash-lite: $0.10/$0.40 per 1M + "gemini-2.5-flash-lite": {"input_tokens": 0, "output_tokens": 1_000_000}, + } + } + tokens, cost = gu.usage_and_cost(stats) + assert tokens == 2_000_000 # noqa: PLR2004 + assert cost == pytest.approx(1.25 + 0.40) + + +def test_usage_and_cost_zero_for_empty_stats() -> None: + assert gu.usage_and_cost({}) == (0, 0.0) + + +def test_stats_from_run_log_single_json(tmp_path: Path) -> None: + log = tmp_path / "run.json" + log.write_text(_single_json(_FLAT_MODEL_STATS), encoding="utf-8") + assert gu.stats_from_run_log(log) == _FLAT_MODEL_STATS + + +def test_stats_from_run_log_stream_json_terminal_result_wins(tmp_path: Path) -> None: + log = tmp_path / "run.ndjson" + log.write_text( + _stream_json( + [ + {"type": "init"}, + {"type": "message", "data": "hi"}, + {"type": "result", "stats": _FLAT_MODEL_STATS}, + ] + ), + encoding="utf-8", + ) + assert gu.stats_from_run_log(log) == _FLAT_MODEL_STATS + + +def test_stats_from_run_log_missing_or_empty(tmp_path: Path) -> None: + assert gu.stats_from_run_log(tmp_path / "absent.json") == {} + empty = tmp_path / "empty.json" + empty.write_text("", encoding="utf-8") + assert gu.stats_from_run_log(empty) == {} + + +def test_is_quota_error_detects_terminal_and_retryable(tmp_path: Path) -> None: + terminal = tmp_path / "terminal.json" + terminal.write_text( + _single_json({}).replace( + '"error": null', '"error": {"type": "TerminalQuotaError"}' + ), + encoding="utf-8", + ) + assert gu.is_quota_error(terminal) is True + + retryable = tmp_path / "retryable.ndjson" + retryable.write_text( + _stream_json([{"type": "error", "error": {"type": "RetryableQuotaError"}}]), + encoding="utf-8", + ) + assert gu.is_quota_error(retryable) is True + + +def test_is_quota_error_false_for_unrelated_error(tmp_path: Path) -> None: + log = tmp_path / "run.ndjson" + log.write_text( + _stream_json([{"type": "error", "error": {"type": "SomeOtherError"}}]), + encoding="utf-8", + ) + assert gu.is_quota_error(log) is False + assert gu.is_quota_error(tmp_path / "absent.ndjson") is False + + +def test_classify_exit_code_auth_passes_through(tmp_path: Path) -> None: + # 41 is returned unchanged regardless of what the log carries. + log = tmp_path / "run.json" + log.write_text(_single_json({}), encoding="utf-8") + assert gu.classify_exit_code(41, log) == 41 # noqa: PLR2004 + + +def test_classify_exit_code_remaps_quota_to_75(tmp_path: Path) -> None: + log = tmp_path / "run.ndjson" + log.write_text( + _stream_json([{"type": "error", "error": {"type": "TerminalQuotaError"}}]), + encoding="utf-8", + ) + assert gu.classify_exit_code(1, log) == 75 # noqa: PLR2004 + + +def test_classify_exit_code_passes_through_other_codes(tmp_path: Path) -> None: + log = tmp_path / "run.json" + log.write_text(_single_json({}), encoding="utf-8") + for code in (0, 42, 52, 53, 54, 130): + assert gu.classify_exit_code(code, log) == code + + +def test_capture_run_usage_writes_usage_json(tmp_path: Path) -> None: + log = tmp_path / "run.ndjson" + log.write_text( + _stream_json([{"type": "result", "stats": _FLAT_MODEL_STATS}]), + encoding="utf-8", + ) + out = tmp_path / "usage.json" + tokens = gu.capture_run_usage( + run_log=log, fallback_model="gemini-2.5-pro", out_path=out + ) + assert tokens == 1500 # noqa: PLR2004 — 1000 input + 500 output + data = json.loads(out.read_text()) + assert data["model"] == "gemini-2.5-pro" + assert data["total_tokens"] == 1500 # noqa: PLR2004 + assert data["cost_usd"] > 0.0 + + +def test_capture_run_usage_zero_when_log_absent(tmp_path: Path) -> None: + out = tmp_path / "usage.json" + tokens = gu.capture_run_usage( + run_log=tmp_path / "absent.ndjson", + fallback_model="gemini-2.5-pro", + out_path=out, + ) + assert tokens == 0 + assert json.loads(out.read_text())["total_tokens"] == 0 + + +def test_main_writes_usage_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + log = tmp_path / "run.ndjson" + log.write_text( + _stream_json([{"type": "result", "stats": _FLAT_MODEL_STATS}]), + encoding="utf-8", + ) + out = tmp_path / "usage.json" + monkeypatch.setattr(gu, "USAGE_OUT_PATH", out) + monkeypatch.setenv("ROBOCO_GEMINI_RUN_LOG", str(log)) + monkeypatch.setenv("ROBOCO_AGENT_MODEL", "gemini-2.5-pro") + assert gu.main([]) == 0 + assert json.loads(out.read_text())["total_tokens"] == 1500 # noqa: PLR2004 + + +def test_main_classify_exit_prints_remapped_code( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + log = tmp_path / "run.ndjson" + log.write_text( + _stream_json([{"type": "error", "error": {"type": "RetryableQuotaError"}}]), + encoding="utf-8", + ) + monkeypatch.setenv("ROBOCO_GEMINI_RUN_LOG", str(log)) + monkeypatch.setenv("ROBOCO_GEMINI_CLI_EXIT_CODE", "1") + assert gu.main(["--classify-exit"]) == 0 + assert capsys.readouterr().out.strip() == "75" diff --git a/tests/unit/llm/providers/test_gemini_provider.py b/tests/unit/llm/providers/test_gemini_provider.py new file mode 100644 index 00000000..e199ef73 --- /dev/null +++ b/tests/unit/llm/providers/test_gemini_provider.py @@ -0,0 +1,258 @@ +"""Tests for GeminiCliProvider (Google Gemini via the official ``gemini`` CLI). + +Mirrors ``tests/unit/llm/test_providers.py``'s Grok coverage — the same safety +properties matter here: + + * the agent gets the MCP gateway wiring (reuses the orchestrator mount path); + * the OAuth credential (~/.gemini) is mounted, and the provider routing + fields are blanked so the gemini endpoint is never mislabelled ANTHROPIC_*; + * the prompt travels via env, so a leading ``--`` cannot become a CLI flag. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from roboco.llm.providers import GeminiCliProvider, ProviderError, SpawnResult +from roboco.models.runtime import OrchestratorAgentConfig + + +@pytest.fixture(autouse=True) +def _isolate_gemini_auth(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Point GEMINI_AUTH_HOST_PATH at a fresh tmp dir so tests never mount the + real ~/.gemini. Tests that exercise the auth mount create oauth_creds.json + themselves.""" + monkeypatch.setattr( + "roboco.llm.providers.gemini.GEMINI_AUTH_HOST_PATH", str(tmp_path) + ) + return tmp_path + + +def _config( + *, + agent_id: str = "be-dev-1", + provider_type: str = "gemini", + provider_base_url: str | None = None, + provider_auth_token: str | None = None, + mcp_config_path: Path | None = Path("/host/mcp-configs/be-dev-1.json"), +) -> OrchestratorAgentConfig: + return OrchestratorAgentConfig( + agent_id=agent_id, + blueprint_path=Path("/app/system-prompt.md"), + model="gemini-2.5-pro", + mcp_config_path=mcp_config_path, + claude_session_id="sess-1", + provider_type=provider_type, + provider_base_url=provider_base_url, + provider_auth_token=provider_auth_token, + ) + + +class _FakeHost: + """Implements the orchestrator surface the provider delegates to.""" + + def __init__(self) -> None: + self.removed: list[str] = [] + self.remove_stop_reasons: list[str | None] = [] + self.mount_config: OrchestratorAgentConfig | None = None + self.data_dirs_ensured: list[str] = [] + + async def _remove_container( + self, container_name: str, *, stop_reason: str | None = None + ) -> None: + self.removed.append(container_name) + self.remove_stop_reasons.append(stop_reason) + + def _ensure_gemini_usage_dir(self, agent_id: str) -> None: + self.data_dirs_ensured.append(agent_id) + + def _resolve_host_paths( + self, config: OrchestratorAgentConfig, agent_settings_path: Path | None + ) -> dict[str, str | None]: + return { + "mcp_config": str(config.mcp_config_path) + if config.mcp_config_path + else None, + "settings": str(agent_settings_path) if agent_settings_path else None, + "gemini_usage": f"/host/data/gemini-usage/{config.agent_id}", + } + + def _build_mount_args( + self, + container_name: str, + config: OrchestratorAgentConfig, + hosts: dict[str, str | None], + ) -> list[str]: + # Record the config the mount step saw, and MIMIC the real + # _append_provider_env so a missed blanking would leak ANTHROPIC_*. + self.mount_config = config + cmd = ["docker", "run", "-d", "--name", container_name] + mcp = hosts.get("mcp_config") + if mcp: + cmd += ["-v", f"{mcp}:/app/mcp-config.json:ro"] + if config.provider_base_url: + cmd += ["-e", f"ANTHROPIC_BASE_URL={config.provider_base_url}"] + if config.provider_auth_token: + cmd += ["-e", f"ANTHROPIC_AUTH_TOKEN={config.provider_auth_token}"] + return cmd + + def _append_agent_auth_env( + self, cmd: list[str], config: OrchestratorAgentConfig + ) -> None: + cmd += ["-e", f"ROBOCO_AGENT_TOKEN=hmac-{config.agent_id}"] + + def _append_git_context_env( + self, cmd: list[str], config: OrchestratorAgentConfig + ) -> None: + cmd += ["-e", f"ROBOCO_GIT_AGENT={config.agent_id}"] + + +def _proc( + returncode: int = 0, stdout: bytes = b"cid\n", stderr: bytes = b"" +) -> MagicMock: + proc = MagicMock() + proc.returncode = returncode + proc.communicate = AsyncMock(return_value=(stdout, stderr)) + return proc + + +async def test_gemini_spawn_requires_mcp_config() -> None: + provider = GeminiCliProvider(_FakeHost()) + with pytest.raises(ProviderError, match="MCP config"): + await provider.spawn(_config(mcp_config_path=None)) + + +async def test_gemini_spawn_does_not_require_api_key() -> None: + # OAuth login (mounted ~/.gemini) — a missing provider key/url is fine. + host = _FakeHost() + provider = GeminiCliProvider(host) + with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())): + result = await provider.spawn(_config(provider_auth_token=None)) + assert result.instance_id == "roboco-agent-be-dev-1" + + +async def test_gemini_spawn_no_anthropic_leak() -> None: + host = _FakeHost() + provider = GeminiCliProvider(host, image="roboco-agent-gemini:test") + with patch( + "asyncio.create_subprocess_exec", AsyncMock(return_value=_proc()) + ) as exec_mock: + await provider.spawn( + _config(provider_base_url="https://ignored", provider_auth_token="ignored"), + initial_prompt="do the work", + ) + cmd = list(exec_mock.call_args.args) + # The provider endpoint must NOT be injected as an Anthropic var. + assert not any(c.startswith("ANTHROPIC_BASE_URL=") for c in cmd) + assert not any(c.startswith("ANTHROPIC_AUTH_TOKEN=") for c in cmd) + # Provider fields were blanked before the shared mount step. + assert host.mount_config is not None + assert host.mount_config.provider_base_url is None + assert host.mount_config.provider_auth_token is None + + +async def test_gemini_spawn_wires_gateway_env_and_image_last() -> None: + host = _FakeHost() + provider = GeminiCliProvider(host, image="roboco-agent-gemini:test") + with patch( + "asyncio.create_subprocess_exec", AsyncMock(return_value=_proc()) + ) as exec_mock: + result = await provider.spawn(_config()) + cmd = list(exec_mock.call_args.args) + assert "ROBOCO_MCP_CONFIG=/app/mcp-config.json" in cmd + assert "ROBOCO_AGENT_ID=be-dev-1" in cmd + assert "ROBOCO_AGENT_MODEL=gemini-2.5-pro" in cmd + # Usage capture: per-agent data dir mounted + the entrypoint's usage file. + assert host.data_dirs_ensured == ["be-dev-1"] + assert "/host/data/gemini-usage/be-dev-1:/home/agent/.gemini-usage" in cmd + assert "ROBOCO_GEMINI_USAGE_FILE=/home/agent/.gemini-usage/usage.json" in cmd + # Identity wiring from the shared host helpers is present. + assert "ROBOCO_AGENT_TOKEN=hmac-be-dev-1" in cmd + # The image is the final docker-run argument. + assert cmd[-1] == "roboco-agent-gemini:test" + assert host.removed == ["roboco-agent-be-dev-1"] + assert host.remove_stop_reasons == ["pre_spawn_stale_clear"] + assert result == SpawnResult( + instance_id="roboco-agent-be-dev-1", + extra={"container_id": "cid", "model": "gemini-2.5-pro"}, + ) + + +async def test_gemini_spawn_mounts_auth_when_present( + _isolate_gemini_auth: Path, +) -> None: + (_isolate_gemini_auth / "oauth_creds.json").write_text("{}", encoding="utf-8") + host = _FakeHost() + provider = GeminiCliProvider(host) + with patch( + "asyncio.create_subprocess_exec", AsyncMock(return_value=_proc()) + ) as exec_mock: + await provider.spawn(_config()) + cmd = list(exec_mock.call_args.args) + expected = f"{_isolate_gemini_auth}:/home/agent/.gemini-auth-ro:ro" + assert expected in cmd + + +async def test_gemini_spawn_omits_auth_mount_when_absent() -> None: + # No oauth_creds.json in the (tmp) GEMINI_AUTH_HOST_PATH -> no mount, no crash. + host = _FakeHost() + provider = GeminiCliProvider(host) + with patch( + "asyncio.create_subprocess_exec", AsyncMock(return_value=_proc()) + ) as exec_mock: + await provider.spawn(_config()) + cmd = list(exec_mock.call_args.args) + assert not any("/home/agent/.gemini-auth-ro" in c for c in cmd) + + +async def test_gemini_spawn_warns_when_auth_absent( + caplog: pytest.LogCaptureFixture, +) -> None: + """A missing host oauth_creds.json must not be silent — the spawn is doomed + to exit 41, so the operator gets a spawn-time WARNING naming the missing + file and the remediation.""" + caplog.set_level("WARNING", logger="roboco.llm.providers.gemini") + host = _FakeHost() + provider = GeminiCliProvider(host) + with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())): + await provider.spawn(_config()) + warnings = [r for r in caplog.records if r.levelname == "WARNING"] + assert warnings, "expected a spawn-time WARNING for the missing oauth_creds.json" + msg = warnings[0].getMessage() + assert "oauth_creds.json" in msg + assert "gemini" in msg # names the remediation + + +async def test_gemini_spawn_prompt_is_injection_safe() -> None: + host = _FakeHost() + provider = GeminiCliProvider(host) + nasty = "--model evil --approval-mode yolo-override" + with patch( + "asyncio.create_subprocess_exec", AsyncMock(return_value=_proc()) + ) as exec_mock: + await provider.spawn(_config(), initial_prompt=nasty) + cmd = list(exec_mock.call_args.args) + # Passed only as an env value, never as a bare argv token. + assert f"ROBOCO_INITIAL_PROMPT={nasty}" in cmd + assert nasty not in cmd + + +async def test_gemini_spawn_raises_on_docker_failure() -> None: + provider = GeminiCliProvider(_FakeHost()) + with ( + patch( + "asyncio.create_subprocess_exec", + AsyncMock(return_value=_proc(returncode=1, stderr=b"boom")), + ), + pytest.raises(ProviderError, match="boom"), + ): + await provider.spawn(_config()) + + +async def test_gemini_remove_delegates_to_host() -> None: + host = _FakeHost() + provider = GeminiCliProvider(host) + await provider.remove("roboco-agent-be-dev-1") + assert host.removed == ["roboco-agent-be-dev-1"] diff --git a/tests/unit/runtime/test_gemini_rate_limit.py b/tests/unit/runtime/test_gemini_rate_limit.py new file mode 100644 index 00000000..89f16f10 --- /dev/null +++ b/tests/unit/runtime/test_gemini_rate_limit.py @@ -0,0 +1,253 @@ +"""GEMINI quota/auth parking: break the exit -> respawn cost loop. + +A one-shot gemini run that hits a quota error is remapped to exit 75 by the +entrypoint wrapper (see gemini_cli_usage.classify_exit_code); a missing/empty +OAuth credential exits 41 (the CLI's own dedicated auth-failure code). Both +park the GEMINI provider instead of crash-retrying, mirroring grok's exit-75 / +exit-78 parks (see test_grok_rate_limit.py) but tracked independently. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from unittest.mock import AsyncMock + +import pytest +from roboco.models.runtime import AgentInstance +from roboco.runtime.orchestrator import ( + _GEMINI_AUTH_EXIT_CODE, + _GEMINI_RATE_LIMIT_EXIT_CODE, + _GEMINI_REPARK_BACKOFF_CAP, + AgentOrchestrator, + AgentState, +) + + +def _gemini_instance(provider_type: str = "gemini") -> AgentInstance: + cfg = type("C", (), {"provider_type": provider_type, "model": "gemini-2.5-pro"})() + inst = AgentInstance(agent_id="be-dev-1", state=AgentState.ACTIVE, config=cfg) + inst.current_task_id = "task-1" + inst.container_id = "cid" + return inst + + +class _FakeTracker: + def __init__(self) -> None: + self.activated_with: dict[str, object] | None = None + + async def activate( + self, + *, + retry_after: float, + affected_agents: list[str], + kind: str = "rate_limited", + ) -> None: + self.activated_with = { + "retry_after": retry_after, + "affected_agents": affected_agents, + "kind": kind, + } + + +class _RecordingTracker: + """Records every activate() retry_after across multiple re-parks.""" + + def __init__(self) -> None: + self.retry_afters: list[float] = [] + self.kinds: list[str] = [] + + async def activate( + self, *, retry_after: float, affected_agents: list[str], kind: str + ) -> None: + del affected_agents + self.retry_afters.append(retry_after) + self.kinds.append(kind) + + +def _orch() -> AgentOrchestrator: + orch = AgentOrchestrator.__new__(AgentOrchestrator) + orch._waiting_records = {} + orch._rate_limit_ceo_notified = set() + orch._gemini_last_park_at = None + orch._gemini_repark_count = 0 + orch._gemini_rate_limit_retry_after_s = 60.0 + orch._gemini_auth_retry_after_s = 60.0 + return orch + + +def test_is_gemini_rate_limit_exit() -> None: + inst = _gemini_instance() + assert AgentOrchestrator._is_gemini_rate_limit_exit( + inst, _GEMINI_RATE_LIMIT_EXIT_CODE + ) + assert not AgentOrchestrator._is_gemini_rate_limit_exit(inst, 0) + assert not AgentOrchestrator._is_gemini_rate_limit_exit(inst, 1) + assert not AgentOrchestrator._is_gemini_rate_limit_exit( + _gemini_instance(provider_type="anthropic"), _GEMINI_RATE_LIMIT_EXIT_CODE + ) + # Same numeric exit code as grok's own detector, but provider-scoped: a + # grok instance exiting 75 is NOT a gemini rate-limit exit. + assert not AgentOrchestrator._is_gemini_rate_limit_exit( + _gemini_instance(provider_type="grok"), _GEMINI_RATE_LIMIT_EXIT_CODE + ) + + +def test_is_gemini_auth_exit() -> None: + inst = _gemini_instance() + assert AgentOrchestrator._is_gemini_auth_exit(inst, _GEMINI_AUTH_EXIT_CODE) + assert not AgentOrchestrator._is_gemini_auth_exit(inst, 0) + assert not AgentOrchestrator._is_gemini_auth_exit(inst, 1) + assert not AgentOrchestrator._is_gemini_auth_exit( + _gemini_instance(provider_type="anthropic"), _GEMINI_AUTH_EXIT_CODE + ) + + +@pytest.mark.asyncio +async def test_park_gemini_rate_limited_activates_and_offlines( + monkeypatch: pytest.MonkeyPatch, +) -> None: + orch = _orch() + inst = _gemini_instance() + inst.error_count = 2 # pretend prior crashes — parking must NOT count one + tracker = _FakeTracker() + monkeypatch.setattr(orch, "_make_tracker", lambda _p: tracker) + finalize = AsyncMock() + monkeypatch.setattr(orch, "_finalize_spawn_session", finalize) + monkeypatch.setattr(orch, "_persist_waiting_record", AsyncMock()) + + await orch._park_gemini_rate_limited("be-dev-1", inst) + + finalize.assert_awaited_once() + assert inst.state == AgentState.OFFLINE + assert inst.container_id is None + assert inst.error_count == 0 # a quota park is not a crash + assert tracker.activated_with == { + "retry_after": pytest.approx(60.0), + "affected_agents": ["be-dev-1"], + "kind": "rate_limited", + } + + +@pytest.mark.asyncio +async def test_park_gemini_auth_unavailable_activates_with_auth_missing_kind( + monkeypatch: pytest.MonkeyPatch, +) -> None: + orch = _orch() + inst = _gemini_instance() + inst.error_count = 2 + tracker = _FakeTracker() + monkeypatch.setattr(orch, "_make_tracker", lambda _p: tracker) + monkeypatch.setattr(orch, "_finalize_spawn_session", AsyncMock()) + monkeypatch.setattr(orch, "_persist_waiting_record", AsyncMock()) + + await orch._park_gemini_auth_unavailable("be-dev-1", inst) + + assert inst.state == AgentState.OFFLINE + assert inst.container_id is None + assert inst.error_count == 0 + assert tracker.activated_with == { + "retry_after": pytest.approx(60.0), + "affected_agents": ["be-dev-1"], + "kind": "auth_missing", + } + + +@pytest.mark.asyncio +async def test_handle_stopped_container_parks_on_gemini_quota_exit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + orch = AgentOrchestrator.__new__(AgentOrchestrator) + inst = _gemini_instance() + park = AsyncMock() + finalize = AsyncMock() + monkeypatch.setattr(orch, "_park_gemini_rate_limited", park) + monkeypatch.setattr(orch, "_finalize_spawn_session", finalize) + + await orch._handle_stopped_container("be-dev-1", inst, _GEMINI_RATE_LIMIT_EXIT_CODE) + + park.assert_awaited_once_with("be-dev-1", inst) + finalize.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_handle_stopped_container_parks_on_gemini_auth_exit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + orch = AgentOrchestrator.__new__(AgentOrchestrator) + inst = _gemini_instance() + park = AsyncMock() + finalize = AsyncMock() + monkeypatch.setattr(orch, "_park_gemini_auth_unavailable", park) + monkeypatch.setattr(orch, "_finalize_spawn_session", finalize) + + await orch._handle_stopped_container("be-dev-1", inst, _GEMINI_AUTH_EXIT_CODE) + + park.assert_awaited_once_with("be-dev-1", inst) + finalize.assert_not_awaited() + + +# --------------------------------------------------------------------------- # +# Gemini has no real recovery probe either (an OAuth-login daily quota cap has +# no cheap balance-check API) — mirrors grok's repark-backoff tests exactly. +# --------------------------------------------------------------------------- # + + +def _backoff_orchestrator() -> AgentOrchestrator: + return _orch() + + +@pytest.mark.asyncio +async def test_gemini_repark_backs_off_within_episode( + monkeypatch: pytest.MonkeyPatch, +) -> None: + orch = _backoff_orchestrator() + tracker = _RecordingTracker() + monkeypatch.setattr(orch, "_make_tracker", lambda _p: tracker) + monkeypatch.setattr(orch, "_finalize_spawn_session", AsyncMock()) + monkeypatch.setattr(orch, "_persist_waiting_record", AsyncMock()) + inst = _gemini_instance() + + await orch._park_gemini_rate_limited("be-dev-1", inst) + await orch._park_gemini_rate_limited("be-dev-1", inst) + await orch._park_gemini_rate_limited("be-dev-1", inst) + + assert tracker.retry_afters == [60.0, 120.0, 240.0] + assert tracker.kinds == ["rate_limited", "rate_limited", "rate_limited"] + + +@pytest.mark.asyncio +async def test_gemini_repark_resets_after_episode_gap( + monkeypatch: pytest.MonkeyPatch, +) -> None: + orch = _backoff_orchestrator() + orch._gemini_repark_count = 3 + orch._gemini_last_park_at = datetime.now(UTC) - timedelta(hours=2) + tracker = _RecordingTracker() + monkeypatch.setattr(orch, "_make_tracker", lambda _p: tracker) + monkeypatch.setattr(orch, "_finalize_spawn_session", AsyncMock()) + monkeypatch.setattr(orch, "_persist_waiting_record", AsyncMock()) + inst = _gemini_instance() + + await orch._park_gemini_rate_limited("be-dev-1", inst) + + assert tracker.retry_afters == [60.0] + assert orch._gemini_repark_count == 0 + + +@pytest.mark.asyncio +async def test_gemini_repark_backoff_caps(monkeypatch: pytest.MonkeyPatch) -> None: + orch = _backoff_orchestrator() + tracker = _RecordingTracker() + monkeypatch.setattr(orch, "_make_tracker", lambda _p: tracker) + monkeypatch.setattr(orch, "_finalize_spawn_session", AsyncMock()) + monkeypatch.setattr(orch, "_persist_waiting_record", AsyncMock()) + inst = _gemini_instance() + + for _ in range(_GEMINI_REPARK_BACKOFF_CAP + 3): + await orch._park_gemini_rate_limited("be-dev-1", inst) + + max_expected = 60.0 * (2**_GEMINI_REPARK_BACKOFF_CAP) + assert all( + r == max_expected for r in tracker.retry_afters[_GEMINI_REPARK_BACKOFF_CAP:] + ) + assert max(tracker.retry_afters) == max_expected diff --git a/tests/unit/runtime/test_gemini_usage_finalize.py b/tests/unit/runtime/test_gemini_usage_finalize.py new file mode 100644 index 00000000..0ffa5bbb --- /dev/null +++ b/tests/unit/runtime/test_gemini_usage_finalize.py @@ -0,0 +1,158 @@ +"""GEMINI agents capture token usage/cost from their captured ``usage.json``. + +A Gemini agent runs the gemini CLI — no SDK /usage/status server and no +Claude transcript — so finalize reads the ``usage.json`` the entrypoint wrote +to the per-agent data dir (mounted into the orchestrator). Mirrors +test_grok_usage_finalize.py; gemini's usage.json is priced per-model +server-side (gemini_cli_usage.usage_and_cost) but flattens to the SAME +``{model, total_tokens, cost_usd}`` shape, so the read side is identical to +grok's: the whole total folds into output. +""" + +from __future__ import annotations + +import json +import tempfile +from typing import TYPE_CHECKING + +import httpx +import pytest +from roboco.models.runtime import AgentInstance +from roboco.runtime import orchestrator as orch_mod +from roboco.runtime.orchestrator import AgentOrchestrator + +if TYPE_CHECKING: + from pathlib import Path + + +def _write_usage(path: Path, total_tokens: int, cost_usd: float) -> None: + path.write_text( + json.dumps( + { + "model": "gemini-2.5-pro", + "total_tokens": total_tokens, + "cost_usd": cost_usd, + } + ), + encoding="utf-8", + ) + + +def test_gemini_usage_folds_total_into_output( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + usage = tmp_path / "usage.json" + _write_usage(usage, total_tokens=180, cost_usd=0.02) + orch = AgentOrchestrator.__new__(AgentOrchestrator) + monkeypatch.setattr( + orch, "_gemini_usage_json", lambda _aid: json.loads(usage.read_text()) + ) + + assert orch._gemini_usage_tokens("be-dev-1") == (0, 180, 0, 0) + + +def test_gemini_usage_zero_when_store_missing(monkeypatch: pytest.MonkeyPatch) -> None: + orch = AgentOrchestrator.__new__(AgentOrchestrator) + monkeypatch.setattr(orch, "_gemini_usage_json", lambda _aid: None) + assert orch._gemini_usage_tokens("be-dev-1") == (0, 0, 0, 0) + + +def test_gemini_cost_read_from_usage_json(monkeypatch: pytest.MonkeyPatch) -> None: + captured_cost = 3.25 + orch = AgentOrchestrator.__new__(AgentOrchestrator) + monkeypatch.setattr( + orch, + "_gemini_usage_json", + lambda _aid: {"cost_usd": captured_cost, "total_tokens": 9}, + ) + assert orch._gemini_cost_usd("be-dev-1") == captured_cost + monkeypatch.setattr(orch, "_gemini_usage_json", lambda _aid: None) + assert orch._gemini_cost_usd("be-dev-1") == 0.0 + + +@pytest.mark.asyncio +async def test_resolve_final_usage_routes_gemini_to_usage_json( + monkeypatch: pytest.MonkeyPatch, +) -> None: + orch = AgentOrchestrator.__new__(AgentOrchestrator) + monkeypatch.setattr( + orch, "_gemini_usage_json", lambda _aid: {"total_tokens": 12, "cost_usd": 0.01} + ) + cfg = type("C", (), {"provider_type": "gemini"})() + orch._instances = {"be-dev-1": AgentInstance(agent_id="be-dev-1", config=cfg)} + + assert await orch._resolve_final_token_usage("be-dev-1") == (0, 12, 0, 0) + + +@pytest.mark.asyncio +async def test_resolve_final_turns_tools_gemini_has_neither() -> None: + orch = AgentOrchestrator.__new__(AgentOrchestrator) + cfg = type("C", (), {"provider_type": "gemini"})() + orch._instances = {"be-dev-1": AgentInstance(agent_id="be-dev-1", config=cfg)} + assert await orch._resolve_final_turns_tools("be-dev-1") == (0, 0) + + +@pytest.mark.asyncio +async def test_resolve_active_tokens_routes_gemini_to_usage_json( + monkeypatch: pytest.MonkeyPatch, +) -> None: + orch = AgentOrchestrator.__new__(AgentOrchestrator) + monkeypatch.setattr( + orch, "_gemini_usage_json", lambda _aid: {"total_tokens": 12, "cost_usd": 0.01} + ) + cfg = type("C", (), {"provider_type": "gemini"})() + orch._instances = {"be-dev-1": AgentInstance(agent_id="be-dev-1", config=cfg)} + async with httpx.AsyncClient() as client: + assert await orch._resolve_active_tokens(client, "be-dev-1") == (0, 12, 0, 0) + + +def test_gemini_usage_dir_branches_compose_vs_local( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(orch_mod, "PROJECT_HOST_PATH", "") + local = AgentOrchestrator._gemini_usage_dir("be-dev-1") + assert "roboco-gemini-usage" in str(local) + assert local.name == "be-dev-1" + + monkeypatch.setattr(orch_mod, "PROJECT_HOST_PATH", "/volume1/roboco") + monkeypatch.setattr(orch_mod, "GEMINI_USAGE_DATA_DIR", "/data/gemini-usage") + assert str(AgentOrchestrator._gemini_usage_dir("be-dev-1")) == ( + "/data/gemini-usage/be-dev-1" + ) + + +@pytest.mark.parametrize( + "bad", + ["..", ".", "../etc", "a/b", "a\\b", "", "be-dev-1/../x", "x\x00y"], +) +def test_gemini_usage_dir_rejects_path_traversal(bad: str) -> None: + with pytest.raises(ValueError, match="unsafe agent id"): + AgentOrchestrator._gemini_usage_dir(bad) + + +def test_gemini_usage_json_reads_the_real_local_dir( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # The un-mocked read path must find usage.json in the SAME branched dir the + # writer mounts (mirrors _ensure_gemini_usage_dir's create path). + monkeypatch.setattr(orch_mod, "PROJECT_HOST_PATH", "") + monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path)) + udir = tmp_path / "roboco-gemini-usage" / "be-dev-1" + udir.mkdir(parents=True) + (udir / "usage.json").write_text( + json.dumps({"total_tokens": 55, "cost_usd": 0.1}), encoding="utf-8" + ) + orch = AgentOrchestrator.__new__(AgentOrchestrator) + assert orch._gemini_usage_tokens("be-dev-1") == (0, 55, 0, 0) + assert orch._gemini_cost_usd("be-dev-1") == 0.1 # noqa: PLR2004 + + +def test_ensure_gemini_usage_dir_creates_world_writable( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(orch_mod, "PROJECT_HOST_PATH", "") + monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path)) + orch = AgentOrchestrator.__new__(AgentOrchestrator) + orch._ensure_gemini_usage_dir("be-dev-1") + target = tmp_path / "roboco-gemini-usage" / "be-dev-1" + assert target.is_dir() diff --git a/tests/unit/runtime/test_provider_routing.py b/tests/unit/runtime/test_provider_routing.py index 4a9f2ac9..3f1de335 100644 --- a/tests/unit/runtime/test_provider_routing.py +++ b/tests/unit/runtime/test_provider_routing.py @@ -1,15 +1,16 @@ """The orchestrator routes only dedicated-backend providers through the registry. -GROK gets the GrokCliProvider; Anthropic / Ollama Cloud / self-hosted (and any -unknown value) return None so ``_spawn_container`` runs its built-in Claude Code -path unchanged. This keeps the GROK addition purely additive. +GROK gets the GrokCliProvider, GEMINI gets the GeminiCliProvider; Anthropic / +Ollama Cloud / self-hosted (and any unknown value) return None so +``_spawn_container`` runs its built-in Claude Code path unchanged. This keeps +the GROK / GEMINI additions purely additive. """ from __future__ import annotations from unittest.mock import patch -from roboco.llm.providers import GrokCliProvider +from roboco.llm.providers import GeminiCliProvider, GrokCliProvider from roboco.runtime.orchestrator import AgentOrchestrator @@ -24,6 +25,10 @@ def test_provider_for_grok_returns_grok_provider() -> None: assert isinstance(_make_orch()._provider_for("grok"), GrokCliProvider) +def test_provider_for_gemini_returns_gemini_provider() -> None: + assert isinstance(_make_orch()._provider_for("gemini"), GeminiCliProvider) + + def test_provider_for_anthropic_returns_none() -> None: assert _make_orch()._provider_for("anthropic") is None