mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
feat(providers): Gemini CLI provider — ModelProvider.GEMINI (#660)
* feat(providers): Gemini CLI provider — ModelProvider.GEMINI Mirrors the grok blueprint with source-verified divergences (all facts pinned against google-gemini/gemini-cli @ 9681621c): no refresher daemon — Google's refresh tokens are reusable, so the RO host mount is COPIED to a writable container-local ~/.gemini and each container refreshes in-process independently (the write-back crash risk on RO never triggers); settings.json renders security.auth.selectedType 'oauth-personal', experimental.enableAgents=false (subagent ban), autoConfigureMemory=false with a bounded heap; tool scoping rides the tiered TOML Policy Engine (deny-only rules that yolo mode structurally cannot beat); gemini -p with --output-format stream-json; usage parsed from the run's own stdout stats — the adversarial pass caught the parser reading the json-mode nested shape while the entrypoint runs stream-json's FLAT shape (every real run would have priced $0 forever, hidden by fixtures sharing the assumption) — now flat-primary with the nested shape as cited fallback; rate-limit classified from structured error.type only (model-echo immune), native exit 41 auth passthrough; per-model pricing for the three GA models; migrations 084 (enum) + 085 (seed) complete the 082-085 finale chain. V1 excludes interactive intake/secretary. Stack-merge required two behavior-preserving complexity refactors in the shared park/usage plumbing (a park-pair loop; a usage-reader dispatch dict). * fix(providers): route gemini usage read through the containment barrier Mirrors the codex/grok fix — _gemini_usage_json now delegates to _read_usage_json_contained, so CodeQL's path-injection alert on the gemini read is resolved by the same resolve-and-contain guard. --------- Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -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}"
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
|
||||
@@ -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)'"))
|
||||
@@ -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 <dir>/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.
|
||||
|
||||
@@ -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 <dir>/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 <agent_id>/; 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`.
|
||||
|
||||
@@ -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 <dir>/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 <agent_id>/; 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`.
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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.<name>`` 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())
|
||||
@@ -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.<name>`` 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.<name>`` 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.<name>`` 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())
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
+298
-49
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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.<name>`
|
||||
# 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"
|
||||
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user