From 6374bbbed0a0eefdfc3f8c3b71cd8c573ceba1b9 Mon Sep 17 00:00:00 2001 From: Renzo F <45401804+rennf93@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:48:55 +0200 Subject: [PATCH] feat(kimi): Kimi K3 provider on the official kimi-code CLI (#713) * feat(kimi): Kimi K3 provider on the official kimi-code CLI (Wave 1) ModelProvider.KIMI routes through KimiCliProvider driving Moonshot's kimi CLI on a Kimi subscription (OAuth device-code, no metered key). One-shot delivery roles only (V1), interactive ban wired in both guard lists. Auth: one shared RW auth mount; containers symlink credentials/ and oauth/ (the CLI's cross-process refresh-lock dir) into a container-local KIMI_CODE_HOME so every container and the host redeem the SAME rotating refresh chain - live-verified that per-copy chains cross-invalidate after the reuse-grace window. No orchestrator refresh daemon; an expires_at preflight exits 78. Config renderer mirrors the login-managed provider/model blocks field-for-field (live-captured; the model value is the CLI-side name, never the raw API id), plus per-role deny rules and the bash-guard as a PreToolUse hook via a wrapper script (an env key on a hooks entry makes the CLI silently drop ALL hooks - live-verified). Usage capture sums wire.jsonl usage.record 4-bucket events; sniff classifies rate-limit/auth from structured error text only, mapped to the shared 75/78 park contract. Image installs the CLI latest-at-build (no version pin, by policy) with the resolved version stamped as provenance, binary split to /usr/local away from mutable state. Migrations 090 (enum) + 091 (provider seed); catalog, pricing, routing mode, and orchestrator park/usage wiring mirror the codex integration. * feat(kimi): surface sweep + fleet-wide pin drop (Wave 2) Compose x3 gain the agent-kimi-image service and the orchestrator's read-write ~/.kimi-code mount + kimi-usage dir; .env.example documents the Kimi block. Panel mirrors ModelProvider.KIMI and adds the kimi routing mode (catalog filter, mode button, mix-picker group, badge) with tests; provider routes gain the kimi remediation entry. CLAUDE.md and docs/map document the runtime. Per the no-pins policy, agent-grok/ gemini/codex Dockerfiles drop their version pins for latest-at-build with resolved-version provenance stamps (grok resolves 0.2.112 vs the old 0.2.56 pin - verified by real builds of all four images). --------- Co-authored-by: Renn F --- .env.example | 19 + CLAUDE.md | 6 +- alembic/versions/090_modelprovider_kimi.py | 40 ++ alembic/versions/091_seed_kimi_provider.py | 66 +++ docker-compose.registry.yml | 28 ++ docker-compose.yaml | 36 ++ docker-compose.yml | 36 ++ docker/agent-codex.Dockerfile | 21 +- docker/agent-gemini.Dockerfile | 16 +- docker/agent-grok.Dockerfile | 23 +- docker/agent-kimi.Dockerfile | 71 +++ docker/scripts/kimi-bash-guard-wrapper.sh | 9 + docker/scripts/kimi-cli-agent-entrypoint.sh | 144 ++++++ docs/map/_complete_map.md | 42 +- docs/map/deployment-tooling.md | 9 +- docs/map/runtime-providers.md | 33 +- .../__tests__/ai-routing-card.test.tsx | 65 ++- .../components/settings/ai-routing-card.tsx | 86 +++- panel/src/lib/api/providers.ts | 1 + panel/src/types/index.ts | 1 + roboco/api/routes/provider.py | 6 + roboco/api/schemas/provider.py | 7 + roboco/billing/pricing.py | 15 + roboco/config.py | 46 ++ roboco/llm/providers/__init__.py | 6 + roboco/llm/providers/kimi.py | 266 +++++++++++ roboco/llm/providers/kimi_cli_config.py | 441 ++++++++++++++++++ roboco/llm/providers/kimi_cli_sniff.py | 150 ++++++ roboco/llm/providers/kimi_cli_usage.py | 280 +++++++++++ roboco/models/base.py | 8 + roboco/models/llm_catalog.py | 16 + roboco/runtime/orchestrator.py | 295 ++++++++++-- roboco/services/llm.py | 70 ++- tests/integration/test_llm_routing.py | 132 +++++- .../test_migration_091_seed_kimi_provider.py | 162 +++++++ tests/unit/billing/test_pricing.py | 95 ++++ .../llm/providers/test_kimi_cli_config.py | 333 +++++++++++++ .../unit/llm/providers/test_kimi_cli_sniff.py | 186 ++++++++ .../unit/llm/providers/test_kimi_cli_usage.py | 264 +++++++++++ tests/unit/llm/test_providers.py | 164 +++++++ .../test_interactive_provider_guard.py | 23 +- tests/unit/runtime/test_kimi_rate_limit.py | 156 +++++++ .../unit/runtime/test_kimi_usage_finalize.py | 144 ++++++ 43 files changed, 3907 insertions(+), 110 deletions(-) create mode 100644 alembic/versions/090_modelprovider_kimi.py create mode 100644 alembic/versions/091_seed_kimi_provider.py create mode 100644 docker/agent-kimi.Dockerfile create mode 100755 docker/scripts/kimi-bash-guard-wrapper.sh create mode 100755 docker/scripts/kimi-cli-agent-entrypoint.sh create mode 100644 roboco/llm/providers/kimi.py create mode 100644 roboco/llm/providers/kimi_cli_config.py create mode 100644 roboco/llm/providers/kimi_cli_sniff.py create mode 100644 roboco/llm/providers/kimi_cli_usage.py create mode 100644 tests/integration/test_migration_091_seed_kimi_provider.py create mode 100644 tests/unit/llm/providers/test_kimi_cli_config.py create mode 100644 tests/unit/llm/providers/test_kimi_cli_sniff.py create mode 100644 tests/unit/llm/providers/test_kimi_cli_usage.py create mode 100644 tests/unit/runtime/test_kimi_rate_limit.py create mode 100644 tests/unit/runtime/test_kimi_usage_finalize.py diff --git a/.env.example b/.env.example index c5645911..487eff56 100644 --- a/.env.example +++ b/.env.example @@ -181,6 +181,25 @@ ROBOCO_DEFAULT_EMBEDDING_MODEL=qwen3-embedding:0.6b # ROBOCO_GEMINI_RATE_LIMIT_RETRY_AFTER_SECONDS=300 # ROBOCO_GEMINI_AUTH_RETRY_AFTER_SECONDS=300 +# ============================================================================= +# Kimi (Moonshot AI) Provider — optional +# ============================================================================= +# RoboCo can run agents on Moonshot's Kimi K3 via the official kimi (kimi-code) +# CLI on a Kimi subscription (OAuth device-code login), not a metered key — run +# `kimi login` once on the host. Enable via the "Kimi" routing mode or a +# kimi-code/* model per agent. Every container shares ONE rotating credential +# chain with the host (RW mount, symlinked-in credentials/+oauth/) since +# Moonshot's refresh token is rotation-with-short-reuse-grace, not truly +# reusable — no orchestrator refresh daemon, the CLI's own cross-process lock +# serializes redemptions. All vars optional. +# ROBOCO_HOST_KIMI_DIR=/home/youruser/.kimi-code +# Login-managed alias (namespaced under the "kimi-code" provider); the cost +# lever is kimi-code/kimi-for-coding ($4/M out vs k3's $15/M out). +# ROBOCO_KIMI_CLI_MODEL=kimi-code/k3 +# Park-and-retry delays after a rate-limit / auth failure (seconds): +# ROBOCO_KIMI_RATE_LIMIT_RETRY_AFTER_SECONDS=60 +# ROBOCO_KIMI_AUTH_RETRY_AFTER_SECONDS=60 + # ============================================================================= # Cost budgets — optional # ============================================================================= diff --git a/CLAUDE.md b/CLAUDE.md index 7f1c56fd..18433788 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -79,7 +79,7 @@ pnpm test | RAG Engine | in-house (asyncpg + pgvector, hybrid retrieval) | | Cache/Queue | Redis | | Container Runtime | Docker + Docker Compose | -| Cloud LLM | Claude API (claude-opus-5) + xAI Grok (official `grok` CLI, SuperGrok subscription) + OpenAI (official `codex` CLI, ChatGPT subscription) + Google Gemini (official `gemini` CLI, OAuth login) | +| Cloud LLM | Claude API (claude-opus-5) + xAI Grok (official `grok` CLI, SuperGrok subscription) + OpenAI (official `codex` CLI, ChatGPT subscription) + Google Gemini (official `gemini` CLI, OAuth login) + Moonshot Kimi K3 (official `kimi` CLI, Kimi subscription) | | Local LLM | Ollama (glm-5.2:cloud for RAG/hybrid retrieval) | | Embeddings | qwen3-embedding:0.6b (1024 dim) | | Frontend | Next.js 16 + TypeScript + Tailwind + Radix UI (in `panel/`) | @@ -390,7 +390,7 @@ 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), `GrokCliProvider`, `GeminiCliProvider`, and `CodexCliProvider`. 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`, `OPENAI`, `LOCAL`, `OLLAMA_CLOUD` — `OPENAI` routes through the official Codex CLI on a ChatGPT subscription (`CodexCliProvider`), not a reserved/unimplemented value. The seam is additive: only `GROK`/`GEMINI`/`OPENAI` 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. +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`, `GeminiCliProvider`, `CodexCliProvider`, and `KimiCliProvider`. 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`, `OPENAI`, `KIMI`, `LOCAL`, `OLLAMA_CLOUD` — `OPENAI` routes through the official Codex CLI on a ChatGPT subscription (`CodexCliProvider`), not a reserved/unimplemented value. The seam is additive: only `GROK`/`GEMINI`/`OPENAI`/`KIMI` 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. **Cost-tiered routing + routing presets.** `ModelRoutingService` (`roboco/services/llm.py`) resolves `(provider, model)` per agent at spawn from `model_assignments` with precedence `AGENT_SLUG > ROLE(:complexity) > ROLE > GLOBAL`; the compound `ROLE:complexity` rung (e.g. `developer:low`) reuses the existing ROLE scope + `scope_value` column — no schema change — to pin a role to a cheaper model at a given task's `estimated_complexity` without touching the plain ROLE row everything else still uses. `apply_mode('cost_tiered')` additively seeds `_COST_TIERED_SEED`, unlike every other mode's wipe-then-seed; the seed is now an empty tuple (see "haiku retired from delivery-lifecycle roles" below) — the mode stays wired for a future above-floor re-seed, it just ships inert. On top of modes, **routing presets** (`RoutingPresetTable`) let an operator name-and-snapshot the FULL current routing state (mode + every assignment row, AGENT_SLUG pins included) via `save_routing_preset`, then restore it wholesale later with `apply_routing_preset` — a full swap, validate-every-entry-first so an invalid payload never triggers the wipe, unlike a mode's pin-preserving behavior. Panel: the AI routing settings card (`ai-routing-card.tsx`) exposes preset save/apply/delete alongside the existing mode buttons and the complexity-override editor. @@ -404,6 +404,8 @@ Agent backends are pluggable. `roboco/llm/providers/` defines an `AgentProvider` **Codex runtime (V1: one-shot delivery roles only, no interactive Intake/Secretary).** `OPENAI` agents run OpenAI's official `codex` CLI (model pinned via `ROBOCO_CODEX_CLI_MODEL`, default `gpt-5.3-codex` — codex has no reliable default) authenticated by a **ChatGPT subscription**, not a metered API key. The host `~/.codex` (from a one-time `codex login`, `ROBOCO_HOST_CODEX_DIR`) is mounted **read-only** as a DIRECTORY, not a single `auth.json` file — a single-file bind mount pins the inode, so the orchestrator's atomic tmp+rename refresh would never reach a running container, the same concern grok's mount documents; the entrypoint symlinks `~/.codex/auth.json` to the RO mount while codex's own writable state (`config.toml`, `rules/`, `sessions/`) lives in the image's own `~/.codex`. `roboco/llm/providers/codex_auth.py` runs the orchestrator-side refresh loop (`refresh_if_stale`, mirroring `grok_auth.py`): the access token is a JWT whose `exp` claim is the only expiry signal (unlike grok's bundle there's no sibling `expires_at` field), and the refresh-token grant against `auth.openai.com/oauth/token` is single-use, guarded by the same process-wide lock + re-check-inside-the-lock pattern that protects grok's rotation from a concurrent double-burn. Tool scoping has no CLI-flag equivalent to grok's `--disallowed-tools`: a per-role `--sandbox` level (`workspace-write` for `developer`, `read-only` for every other role) plus one shared `~/.codex/rules/default.rules` execpolicy file (Starlark `prefix_rule`s denying git-mutation/destructive/raw-package-manager commands, rendered by `roboco/llm/providers/codex_cli_config.py`) do the job instead; the CLI has no verified system-prompt-file mechanism, so the composed role blueprint is prepended to the task prompt itself rather than mounted separately. Codex has no exit-code taxonomy (every failure exits 1), so `codex_cli_sniff.py` classifies a run's terminal state (`rate_limit`/`auth`/none) from ONLY the structured `error.message` field of its JSONL events plus stderr — never the model's own transcript, which could false-positive on ordinary on-topic prose (this repo's own prompts use the phrase "quota-limited"). Usage capture (`codex_cli_usage.py`) sums `turn.completed` events' real input/output/cache-read/cache-write split — a genuine four-bucket split, unlike grok's output-only fallback — into the grok-shaped `usage.json`. +**Kimi runtime (V1: one-shot delivery roles only, no interactive Intake/Secretary).** `KIMI` agents run Moonshot AI's official `kimi` (kimi-code) CLI (model alias pinned via `ROBOCO_KIMI_CLI_MODEL`, default `kimi-code/k3` — aliases are namespaced under the login-managed `kimi-code` provider, `kimi-code/kimi-for-coding` is the cheaper cost lever) authenticated by a **Kimi subscription** (OAuth device-code login via `kimi login`), not a metered key. Auth is the one structural departure from codex/gemini: Moonshot's refresh token is rotation-with-short-reuse-grace, not truly reusable — two independent per-container copies of one credential snapshot eventually cross-invalidate each other's tokens (live-verified: a real login died and needed a fresh device-code approval). So the host `~/.kimi-code` (`ROBOCO_HOST_KIMI_DIR`) is mounted **read-write** and SHARED — every container plus the orchestrator redeem the SAME rotating chain — while the entrypoint keeps a container-local writable `~/.kimi-code` for config.toml/mcp.json/AGENTS.md (rendered fresh) and only symlinks `credentials/` and `oauth/` (the lock dir) in from the shared mount; the CLI's own cross-process lock (`oauth/kimi-code.lock`) serializes redemptions, so there is still **no orchestrator refresh daemon** — the CLI refreshes itself. `kimi login`'s managed `[providers."managed:kimi-code"]`/`[models."kimi-code/"]`/`[services.moonshot_*]` config.toml blocks are account-fixed, not ours to discover per-container (the symlink step deliberately does NOT carry the host's own config.toml forward), so `kimi_cli_config.py` renders them as constants with the CLI's own model names rather than reading them off any mount. Tool scoping is the rendered `[[permission.rules]]` deny-first array (`-p` has no CLI-flag tool-removal equivalent); the same `bash-guard-hook.sh` the Claude/grok paths install is wired as a `[[hooks]]` entry, but a kimi hooks entry only tolerates `event`/`matcher`/`command`/`timeout` fields (an `env` key silently drops the WHOLE hooks section), so `ROBOCO_GUARD_SKIP_GIT=1` rides a wrapper script's own `export` instead of a hook `env` block. Kimi has no exit-code taxonomy for `-p` either (a claimed 75/1 split is unverified noise), so `kimi_cli_sniff.py` classifies a run's terminal state from ONLY a structured `error` field off any JSONL event plus stderr — the model's own echoed assistant/tool content can never reach the classifier. Usage capture (`kimi_cli_usage.py`) sums `wire.jsonl`'s real `inputOther`/`output`/`inputCacheRead`/`inputCacheCreation` 4-bucket split (session id read from the run's own terminal stdout event, falling back to the newest on-disk session dir) into the grok-shaped `usage.json`. The entrypoint maps a rate-limit/quota sniff to exit 75 and a missing/expired credential to exit 78, so the orchestrator parks the `KIMI` provider on either exactly like codex/gemini. Like the rest of the fleet's CLI runtimes (2026-07-28 policy), the `roboco-agent-kimi` image installs the CLI with **no version pin** — latest at build, always adapt — stamping the resolved version to `/etc/kimi-cli-version` for provenance. + ## Self-Healing & Feature Flags **Self-healing CI loop (default-off).** RoboCo can watch its own repository's CI (a single named workflow) and, on a detected regression, open a fix task that is held out of dispatch until the CEO approves it (it terminates at `awaiting_ceo_approval`), then dispatch it through the normal delivery flow. It is dormant by default and armed by `ROBOCO_SELF_HEAL_ENABLED` plus a second opt-in `ROBOCO_SELF_HEAL_ORIGINATE_ENABLED`; origination is bounded by `ROBOCO_SELF_HEAL_MAX_OPEN_TASKS` / `_MAX_PER_CYCLE` so it can't flood the backlog. It never auto-merges or self-deploys (`roboco/services/self_heal_engine.py`). diff --git a/alembic/versions/090_modelprovider_kimi.py b/alembic/versions/090_modelprovider_kimi.py new file mode 100644 index 00000000..1d913c1f --- /dev/null +++ b/alembic/versions/090_modelprovider_kimi.py @@ -0,0 +1,40 @@ +"""Add 'kimi' to the postgres modelprovider enum. + +Kimi (``ModelProvider.KIMI`` — Moonshot's subscription-authenticated +``kimi``/kimi-code CLI) is a new agent backend. Seeding its provider row +(migration 091) and routing agents to it requires the postgres +``modelprovider`` enum to carry the value. Mirrors the enum-add pattern of +migration 084 (gemini); the row seed is split into 091 because a newly added +enum value cannot be used in the same transaction that adds it. + +Revision ID: 090_modelprovider_kimi +Revises: 089_board_cycle_ntp_reason +Create Date: 2026-07-28 +""" + +from __future__ import annotations + +from alembic import op + +revision = "090_modelprovider_kimi" +down_revision = "089_board_cycle_ntp_reason" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # The new value must be COMMITTED before migration 091 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 'kimi' 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 'kimi'") + + +def downgrade() -> None: + # Postgres does not support removing enum values without a destructive + # type recreation. Forward-only by design (see migration 037). + pass diff --git a/alembic/versions/091_seed_kimi_provider.py b/alembic/versions/091_seed_kimi_provider.py new file mode 100644 index 00000000..656dc6cb --- /dev/null +++ b/alembic/versions/091_seed_kimi_provider.py @@ -0,0 +1,66 @@ +"""Idempotently seed the Kimi (Moonshot) provider row. + +The ``modelprovider`` enum carries ``'kimi'`` as of migration 090. This +migration seeds the corresponding ``provider_configs`` row so the Settings UI +can list it for role/agent model assignment. + +Like Codex (migration 083), seeded ``enabled=true`` directly: the +KimiCliProvider authenticates from a mounted subscription credential +(``~/.kimi-code/credentials/kimi-code.json``, from a `kimi login` device-code +flow), never a stored API key, so there is no secret to withhold behind a +disabled row — ``base_url``/``auth_token_encrypted`` stay NULL permanently +(mirroring Gemini's row). Unlike Gemini's row (seeded disabled, flipped by a +follow-up migration), there is no reason to gate this one behind a second +migration since there's no key-collection step it needs to wait on. +ON CONFLICT (name) DO NOTHING keeps this safe to re-run. + +Revision ID: 091_seed_kimi_provider +Revises: 090_modelprovider_kimi +Create Date: 2026-07-28 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "091_seed_kimi_provider" +down_revision = "090_modelprovider_kimi" +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(), + 'Kimi (Moonshot)', + 'kimi', + NULL, + NULL, + true, + now() + ) + ON CONFLICT (name) DO NOTHING + """ + ) + ) + + +def downgrade() -> None: + # Drop model_assignments pointing at the Kimi 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 = 'Kimi (Moonshot)'" + ")" + ) + ) + op.execute(sa.text("DELETE FROM provider_configs WHERE name = 'Kimi (Moonshot)'")) diff --git a/docker-compose.registry.yml b/docker-compose.registry.yml index 8e7b3169..83943b80 100644 --- a/docker-compose.registry.yml +++ b/docker-compose.registry.yml @@ -292,6 +292,13 @@ services: entrypoint: ["/bin/sh", "-c", "echo 'agent-gemini image present'"] restart: "no" + # Kimi (Moonshot AI, via the official kimi CLI). One-shot delivery roles + # only (V1) — no interactive prompter/secretary variant, contrast Grok above. + agent-kimi-image: + image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-agent-kimi:${ROBOCO_VERSION:-latest} + entrypoint: ["/bin/sh", "-c", "echo 'agent-kimi 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. @@ -370,6 +377,14 @@ services: # mounts /oauth_creds.json (read-only) into each Gemini agent. Run # `gemini` interactively once on the host to produce it. ROBOCO_HOST_GEMINI_DIR: ${ROBOCO_HOST_GEMINI_DIR:-${HOME}/.gemini} + # Kimi subscription auth (host ~/.kimi-code) for Kimi-CLI agents — the + # orchestrator mounts /credentials/kimi-code.json into each Kimi + # agent. Run `kimi login` on the host. Read-WRITE (see the volumes + # mount below): unlike gemini's reusable refresh token, Kimi's refresh + # is rotation-with-short-reuse-grace, so every container shares this + # ONE host chain via a symlinked-in credentials/+oauth/ mount rather + # than a per-container copy (kimi follows codex's RW mount mode here). + ROBOCO_HOST_KIMI_DIR: ${ROBOCO_HOST_KIMI_DIR:-${HOME}/.kimi-code} 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. @@ -498,6 +513,17 @@ services: # 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 + # Kimi subscription auth — mount the host ~/.kimi-code at the SAME host + # path the orchestrator passes to each Kimi agent's `-v`, so the + # credentials/kimi-code.json exists() check passes here AND the agent + # bind resolves on the host. Read-WRITE (unlike gemini's RO mount + # above): Moonshot's refresh token is rotation-with-short-reuse-grace, + # not truly reusable, so every container symlinks credentials/+oauth/ + # from this ONE shared chain instead of refreshing a private copy — + # the CLI's own cross-process lock (oauth/kimi-code.lock) serializes + # redemptions. No orchestrator refresh daemon (the CLI refreshes + # itself); this mount just needs to be writable so the CLI can. + - ${ROBOCO_HOST_KIMI_DIR:-${HOME}/.kimi-code}:${ROBOCO_HOST_KIMI_DIR:-${HOME}/.kimi-code} - ${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 @@ -509,6 +535,8 @@ services: - ${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 + # Per-agent KIMI usage capture — same shape as grok/codex/gemini-usage. + - ${ROBOCO_DATA_DIR:-./data}/kimi-usage:/data/kimi-usage - ${ROBOCO_DATA_DIR:-./data}/logs:/data/logs # video engine: NAS-only bind mount, off by default in public registry. # Uncomment + set ROBOCO_VIDEO_ENGINE_ENABLED=true in .env to arm. diff --git a/docker-compose.yaml b/docker-compose.yaml index 08aa33e0..5ff01e6c 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -420,6 +420,21 @@ services: depends_on: - agent-base-image + # ========================================================================== + # Agent Kimi Image Builder (Moonshot AI Kimi K3 via the official kimi CLI). + # One-shot delivery roles only (V1) — no interactive prompter/secretary + # variant exists for Kimi yet, contrast the Grok images above. + # ========================================================================== + agent-kimi-image: + build: + context: . + dockerfile: docker/agent-kimi.Dockerfile + image: roboco-agent-kimi + entrypoint: ["/bin/sh", "-c", 'echo "Agent Kimi 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 @@ -510,6 +525,14 @@ services: # mounts /oauth_creds.json (read-only) into each Gemini agent. Run # `gemini` interactively once on the host to produce it. ROBOCO_HOST_GEMINI_DIR: ${ROBOCO_HOST_GEMINI_DIR:-/home/renzof/.gemini} + # Kimi subscription auth (host ~/.kimi-code) for Kimi-CLI agents — the + # orchestrator mounts /credentials/kimi-code.json into each Kimi + # agent. Run `kimi login` on the host. Read-WRITE (see the volumes + # mount below): unlike gemini's reusable refresh token, Kimi's refresh + # is rotation-with-short-reuse-grace, so every container shares this + # ONE host chain via a symlinked-in credentials/+oauth/ mount rather + # than a per-container copy (kimi follows codex's RW mount mode here). + ROBOCO_HOST_KIMI_DIR: ${ROBOCO_HOST_KIMI_DIR:-/home/renzof/.kimi-code} 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 @@ -735,6 +758,17 @@ services: # 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 + # Kimi subscription auth — mount the host ~/.kimi-code at the SAME host + # path the orchestrator passes to each Kimi agent's `-v`, so the + # credentials/kimi-code.json exists() check passes here AND the agent + # bind resolves on the host. Read-WRITE (unlike gemini's RO mount + # above): Moonshot's refresh token is rotation-with-short-reuse-grace, + # not truly reusable, so every container symlinks credentials/+oauth/ + # from this ONE shared chain instead of refreshing a private copy — + # the CLI's own cross-process lock (oauth/kimi-code.lock) serializes + # redemptions. No orchestrator refresh daemon (the CLI refreshes + # itself); this mount just needs to be writable so the CLI can. + - ${ROBOCO_HOST_KIMI_DIR:-/home/renzof/.kimi-code}:${ROBOCO_HOST_KIMI_DIR:-/home/renzof/.kimi-code} # Shared config directory for MCP configs (writable) - ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs - ${ROBOCO_DATA_DIR:-./data}/vault:/app/vault @@ -752,6 +786,8 @@ services: # Per-agent GEMINI usage capture: each Gemini agent writes usage.json # under /; the finalizer reads the captured tokens/cost back here. - ${ROBOCO_DATA_DIR:-./data}/gemini-usage:/data/gemini-usage + # Per-agent KIMI usage capture — same shape as grok/codex/gemini-usage. + - ${ROBOCO_DATA_DIR:-./data}/kimi-usage:/data/kimi-usage # Persistent logs — survive `docker compose down/up`. Orchestrator and # each spawned agent write structured logs here so we can audit past # runs instead of relying on ephemeral `docker logs`. diff --git a/docker-compose.yml b/docker-compose.yml index 08aa33e0..5ff01e6c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -420,6 +420,21 @@ services: depends_on: - agent-base-image + # ========================================================================== + # Agent Kimi Image Builder (Moonshot AI Kimi K3 via the official kimi CLI). + # One-shot delivery roles only (V1) — no interactive prompter/secretary + # variant exists for Kimi yet, contrast the Grok images above. + # ========================================================================== + agent-kimi-image: + build: + context: . + dockerfile: docker/agent-kimi.Dockerfile + image: roboco-agent-kimi + entrypoint: ["/bin/sh", "-c", 'echo "Agent Kimi 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 @@ -510,6 +525,14 @@ services: # mounts /oauth_creds.json (read-only) into each Gemini agent. Run # `gemini` interactively once on the host to produce it. ROBOCO_HOST_GEMINI_DIR: ${ROBOCO_HOST_GEMINI_DIR:-/home/renzof/.gemini} + # Kimi subscription auth (host ~/.kimi-code) for Kimi-CLI agents — the + # orchestrator mounts /credentials/kimi-code.json into each Kimi + # agent. Run `kimi login` on the host. Read-WRITE (see the volumes + # mount below): unlike gemini's reusable refresh token, Kimi's refresh + # is rotation-with-short-reuse-grace, so every container shares this + # ONE host chain via a symlinked-in credentials/+oauth/ mount rather + # than a per-container copy (kimi follows codex's RW mount mode here). + ROBOCO_HOST_KIMI_DIR: ${ROBOCO_HOST_KIMI_DIR:-/home/renzof/.kimi-code} 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 @@ -735,6 +758,17 @@ services: # 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 + # Kimi subscription auth — mount the host ~/.kimi-code at the SAME host + # path the orchestrator passes to each Kimi agent's `-v`, so the + # credentials/kimi-code.json exists() check passes here AND the agent + # bind resolves on the host. Read-WRITE (unlike gemini's RO mount + # above): Moonshot's refresh token is rotation-with-short-reuse-grace, + # not truly reusable, so every container symlinks credentials/+oauth/ + # from this ONE shared chain instead of refreshing a private copy — + # the CLI's own cross-process lock (oauth/kimi-code.lock) serializes + # redemptions. No orchestrator refresh daemon (the CLI refreshes + # itself); this mount just needs to be writable so the CLI can. + - ${ROBOCO_HOST_KIMI_DIR:-/home/renzof/.kimi-code}:${ROBOCO_HOST_KIMI_DIR:-/home/renzof/.kimi-code} # Shared config directory for MCP configs (writable) - ${ROBOCO_DATA_DIR:-./data}/mcp-configs:/app/mcp-configs - ${ROBOCO_DATA_DIR:-./data}/vault:/app/vault @@ -752,6 +786,8 @@ services: # Per-agent GEMINI usage capture: each Gemini agent writes usage.json # under /; the finalizer reads the captured tokens/cost back here. - ${ROBOCO_DATA_DIR:-./data}/gemini-usage:/data/gemini-usage + # Per-agent KIMI usage capture — same shape as grok/codex/gemini-usage. + - ${ROBOCO_DATA_DIR:-./data}/kimi-usage:/data/kimi-usage # Persistent logs — survive `docker compose down/up`. Orchestrator and # each spawned agent write structured logs here so we can audit past # runs instead of relying on ephemeral `docker logs`. diff --git a/docker/agent-codex.Dockerfile b/docker/agent-codex.Dockerfile index 60fba207..1a6549e8 100644 --- a/docker/agent-codex.Dockerfile +++ b/docker/agent-codex.Dockerfile @@ -19,17 +19,17 @@ FROM roboco-agent-base USER root -# Install the official codex CLI globally via npm. Pinned — untrusted model -# output runs under it, so bump the version deliberately, never float. The -# npm route (not chatgpt.com/codex/install.sh, which the CDN denies to -# non-browser clients) has no postinstall network fetch: the native binary -# rides an optionalDependency (@openai/codex-linux-x64) served from the -# public npm registry. Global install symlinks `codex` onto PATH for the -# agent user; verify it runs so a broken install fails the build, not spawn. -ARG CODEX_CLI_VERSION=0.145.0 -RUN npm install -g @openai/codex@${CODEX_CLI_VERSION} \ +# Install the official codex CLI globally via npm. NO version pin (2026-07-28 +# policy: latest-at-build, always adapt — fleet-wide across grok/gemini/codex/ +# kimi). The npm route (not chatgpt.com/codex/install.sh, which the CDN denies +# to non-browser clients) has no postinstall network fetch: the native binary +# rides an optionalDependency (@openai/codex-linux-x64) served from the public +# npm registry. Global install symlinks `codex` onto PATH for the agent user; +# verify it runs so a broken install fails the build, not spawn; the resolved +# version is stamped to /etc/codex-cli-version for per-image provenance. +RUN npm install -g @openai/codex \ && command -v codex \ - && codex --version + && codex --version | tee /etc/codex-cli-version # Entrypoint: render ~/.codex/config.toml + execpolicy rules + the per-role # sandbox flag, then run codex headless (overrides the base image's `claude` @@ -47,5 +47,6 @@ ENV PATH="/home/agent/.codex/bin:/home/agent/.local/bin:/app/.venv/bin:$PATH" LABEL role="codex-cli-runtime" LABEL description="Codex (OpenAI) agent runtime — Codex Build via the official codex CLI" +LABEL codex.cli.pinned="false" ENTRYPOINT ["/app/scripts/codex-cli-agent-entrypoint.sh"] diff --git a/docker/agent-gemini.Dockerfile b/docker/agent-gemini.Dockerfile index cbe8dd7f..6fd48d21 100644 --- a/docker/agent-gemini.Dockerfile +++ b/docker/agent-gemini.Dockerfile @@ -17,15 +17,16 @@ 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}" \ +# Install the official Gemini CLI. NO version pin (2026-07-28 policy: +# latest-at-build, always adapt — fleet-wide across grok/gemini/codex/kimi). +# npm installs to the global node_modules the base image's Node 22 already +# resolves onto PATH; the resolved version is stamped to /etc/gemini-cli-version +# for per-image provenance (a record, not a pin). +RUN npm install -g @google/gemini-cli \ && npm cache clean --force \ && rm -rf /root/.npm /tmp/* \ - && gemini --version + && command -v gemini \ + && gemini --version | tee /etc/gemini-cli-version # Entrypoint: copy the staged OAuth credential into a writable ~/.gemini, # render settings.json + policy TOML, then run gemini headless (overrides the @@ -39,6 +40,7 @@ USER agent LABEL role="gemini-cli-runtime" LABEL description="Gemini (Google) agent runtime — Gemini Build via the official gemini CLI" +LABEL gemini.cli.pinned="false" # advanced.autoConfigureMemory=false (rendered into settings.json) pins Node's # heap sizing away from auto-detection against a shared host; this bounds it diff --git a/docker/agent-grok.Dockerfile b/docker/agent-grok.Dockerfile index b54c8e70..e3b73209 100644 --- a/docker/agent-grok.Dockerfile +++ b/docker/agent-grok.Dockerfile @@ -17,17 +17,21 @@ USER root # Install the official grok CLI (Grok Build) for the agent user. The installer's # default is $HOME/.grok/bin, so the binary lands at ~/.grok/bin/grok alongside # its runtime (downloads / bundled / skills) under ~/.grok, all agent-owned. -# Pinned — untrusted model output runs under it, so bump the version deliberately, -# never float. Download the installer to a file first (a `curl | bash` pipe hides -# a curl failure as a silent no-op) and verify the binary installed AND runs, so -# a broken install fails the build here, not at spawn. (curl/bash from the base.) -ARG GROK_CLI_VERSION=0.2.56 +# NO version pin (2026-07-28 policy: latest-at-build, always adapt — fleet-wide +# across grok/gemini/codex/kimi). Download the installer to a file first (a +# `curl | bash` pipe hides a curl failure as a silent no-op) and verify the +# binary installed AND runs, so a broken install fails the build here, not at +# spawn; the resolved version is stamped to /etc/grok-cli-version (build-log + +# on-disk provenance, not a pin — the next build reinstalls whatever's latest). RUN su agent -s /bin/bash -c "set -euo pipefail; export HOME=/home/agent; \ curl -fsSL https://x.ai/cli/install.sh -o /tmp/grok-install.sh; \ - bash /tmp/grok-install.sh ${GROK_CLI_VERSION}; \ - test -x /home/agent/.grok/bin/grok; \ - /home/agent/.grok/bin/grok --version" \ - && rm -rf /tmp/* + bash /tmp/grok-install.sh; \ + test -x /home/agent/.grok/bin/grok" \ + && rm -rf /tmp/* \ + # Provenance stamp runs as root (outside the su subshell — /etc is + # root-writable only) with root's HOME; fine while `grok --version` + # touches no $HOME-relative state. + && /home/agent/.grok/bin/grok --version | tee /etc/grok-cli-version # Entrypoint: render ~/.grok/config.toml + the per-role flags, then run grok # headless (overrides the base image's `claude` entrypoint). ~/.grok is already @@ -43,5 +47,6 @@ ENV PATH="/home/agent/.grok/bin:/app/.venv/bin:$PATH" LABEL role="grok-cli-runtime" LABEL description="Grok (xAI) agent runtime — Grok Build via the official grok CLI" +LABEL grok.cli.pinned="false" ENTRYPOINT ["/app/scripts/grok-cli-agent-entrypoint.sh"] diff --git a/docker/agent-kimi.Dockerfile b/docker/agent-kimi.Dockerfile new file mode 100644 index 00000000..37d1f64d --- /dev/null +++ b/docker/agent-kimi.Dockerfile @@ -0,0 +1,71 @@ +# Kimi (Moonshot) Agent Image +# ============================================================================= +# Runs Kimi K3 through Moonshot's official `kimi` (kimi-code) CLI, +# authenticated by a Kimi subscription via a symlinked-in +# ~/.kimi-code/credentials/kimi-code.json + oauth/ (the shared RW auth mount +# — see roboco.llm.providers.kimi's module docstring) — the parity analogue +# of the Claude Code path's mounted ~/.claude and the codex/gemini paths' +# subscription mounts (no metered API key). Reuses the base image's roboco +# venv + uv + the RoboCo MCP gateway servers. The entrypoint symlinks the +# mounted credential in, renders ~/.kimi-code/config.toml + mcp.json + +# AGENTS.md from the mounted mcp-config.json (see +# roboco.llm.providers.kimi_cli_config), and runs the CLI headless. One +# runtime image serves every one-shot delivery role — role behaviour comes +# from the mounted system prompt / manifest / mcp-config, exactly as on the +# Claude/grok/codex/gemini paths. +# +# V1 scope: no interactive intake/secretary variant of this image exists +# (unlike grok's agent-grok-prompter / agent-grok-secretary) — Kimi is +# one-shot delivery roles only for now. +# ============================================================================= + +FROM roboco-agent-base + +USER root + +# Install the official kimi-code CLI. NO version pin (CEO decision, +# 2026-07-28 — latest at build, always adapt: Kimi's CLI is very young and +# has already shipped a breaking rename within a week of release). The +# install script itself SHA-256-verifies the binary it fetches. +# KIMI_INSTALL_DIR splits the binary (/usr/local/bin/kimi — needs root to +# write) from KIMI_CODE_HOME's mutable per-agent state (~/.kimi-code, +# rendered fresh at container start, never baked into the image — see +# roboco.llm.providers.kimi_cli_config): the installer's own default +# co-locates both under ~/.kimi-code, which would mix a writable binary path +# into the exact tree the entrypoint later writes credentials/config into. +# Build-fails-loud verification (a broken install fails the build here, not +# at spawn); the resolved version is captured to both the build log (RUN +# output) and a baked-in file for runtime attribution — Docker has no native +# mechanism to compute a LABEL value from a RUN command's own output, so the +# file is the durable per-image provenance record (a record, not a pin: the +# next build always reinstalls whatever is latest that day). +ENV KIMI_INSTALL_DIR=/usr/local +RUN curl -fsSL https://code.kimi.com/kimi-code/install.sh -o /tmp/kimi-install.sh \ + && bash /tmp/kimi-install.sh \ + && command -v kimi \ + && kimi --version | tee /etc/kimi-cli-version \ + && rm -rf /tmp/* + +# Entrypoint: symlink the mounted credential in, render config.toml/mcp.json/ +# AGENTS.md, then run kimi headless (overrides the base image's `claude` +# entrypoint). Pre-create + chown ~/.kimi-code (mirrors the gemini image — +# the entrypoint's own symlink/render steps then just write into it). +COPY docker/scripts/kimi-cli-agent-entrypoint.sh /app/scripts/kimi-cli-agent-entrypoint.sh +COPY docker/scripts/kimi-bash-guard-wrapper.sh /app/scripts/kimi-bash-guard-wrapper.sh +RUN chmod 0755 /app/scripts/kimi-cli-agent-entrypoint.sh /app/scripts/kimi-bash-guard-wrapper.sh \ + && mkdir -p /home/agent/.kimi-code \ + && chown -R agent:agent /home/agent/.kimi-code + +USER agent + +LABEL role="kimi-cli-runtime" +LABEL description="Kimi (Moonshot) agent runtime — Kimi K3 via the official kimi CLI" +LABEL kimi.cli.pinned="false" + +# Runtime self-update is pure spawn latency + an unreviewed binary fetch in +# an ephemeral container (an update can't persist anyway) — suppressed at +# the env level; `[upgrade] auto_install=false` in the rendered config.toml +# is the belt-and-suspenders config-level twin (roboco.llm.providers.kimi_cli_config). +ENV KIMI_CODE_NO_AUTO_UPDATE=1 + +ENTRYPOINT ["/app/scripts/kimi-cli-agent-entrypoint.sh"] diff --git a/docker/scripts/kimi-bash-guard-wrapper.sh b/docker/scripts/kimi-bash-guard-wrapper.sh new file mode 100755 index 00000000..cc3086c0 --- /dev/null +++ b/docker/scripts/kimi-bash-guard-wrapper.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +# Kimi's [[hooks]] TOML entry has no `env` field — an entry carrying one is +# silently dropped whole (live-verified: "Ignored invalid config ... hooks", +# run continues with NO hooks installed at all). ROBOCO_GUARD_SKIP_GIT=1 must +# therefore ride a wrapper's own export, not the hook config, since kimi's +# `command` is a plain path (unverified whether it shell-interprets the +# string, so `env VAR=1 cmd` is not assumed safe). +export ROBOCO_GUARD_SKIP_GIT=1 +exec /app/scripts/bash-guard-hook.sh "$@" diff --git a/docker/scripts/kimi-cli-agent-entrypoint.sh b/docker/scripts/kimi-cli-agent-entrypoint.sh new file mode 100755 index 00000000..f598c47d --- /dev/null +++ b/docker/scripts/kimi-cli-agent-entrypoint.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash +# Entrypoint for the roboco-agent-kimi image (one-shot delivery roles only — +# see docker/agent-kimi.Dockerfile for the V1 scope note). +# +# Runs an agent on Moonshot's official `kimi` (kimi-code) CLI, authenticated +# by a Kimi subscription via a symlinked-in +# ~/.kimi-code/credentials/kimi-code.json + oauth/ (the shared RW auth mount) +# — the parity analogue of the codex-cli entrypoint's mounted ~/.codex. The +# gateway, identity, and workspace are mounted by the orchestrator's shared +# container assembly (the same that wires Claude/grok/codex); this entrypoint +# symlinks the host credential in, renders the kimi runtime config from that +# mount, and runs the CLI headless. +set -euo pipefail + +# Split-install sanity: docker/agent-kimi.Dockerfile installs the binary to +# KIMI_INSTALL_DIR=/usr/local (split from KIMI_CODE_HOME's mutable state) — +# verify it actually resolved on PATH before doing any other work. +command -v kimi >/dev/null || { + echo "[kimi] kimi CLI not found on PATH — image build is broken." >&2 + exit 1 +} + +# Symlink phase. The orchestrator mounts the host ~/.kimi-code DIRECTORY +# READ-WRITE at this path (roboco.llm.providers.kimi._append_kimi_auth_mount). +# Moonshot's refresh token is rotation-with-short-reuse-grace, not truly +# reusable (live-verified: a per-container COPY cross-invalidates the shared +# chain once the grace window passes — see roboco.llm.providers.kimi's +# module docstring), so every container must redeem the SAME chain the host +# uses: symlink credentials/ AND oauth/ (the cross-process refresh lock +# directory — entirely missing from the old copy-in, and load-bearing: it's +# what serializes concurrent redemptions across containers + the host) +# straight into the image's own, writable ~/.kimi-code. config.toml/mcp.json/ +# AGENTS.md are still rendered fresh below, never symlinked. +AUTH_DIR="/home/agent/.kimi-code-auth" +mkdir -p /home/agent/.kimi-code +if [ -d "$AUTH_DIR/credentials" ]; then + mkdir -p "$AUTH_DIR/oauth" + ln -sfn "$AUTH_DIR/credentials" /home/agent/.kimi-code/credentials + ln -sfn "$AUTH_DIR/oauth" /home/agent/.kimi-code/oauth +fi + +# Render ~/.kimi-code/config.toml (managed provider/model blocks + telemetry/ +# upgrade knobs + per-role [[permission.rules]] + the bash-guard [[hooks]]) +# + mcp.json + AGENTS.md. 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 same +# ModuleNotFound lesson the codex/grok entrypoints document). +( cd /app && python -m roboco.llm.providers.kimi_cli_config ) + +# Prompt-injection guard (parity with the Claude/grok/codex path): the task +# prompt is DATA, not instructions — refuse a poisoned one before the model +# ever sees it. The composed role blueprint travels separately via the +# additive AGENTS.md (rendered above), so only the raw task prompt is +# screened here. 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 + +# Auth fail-fast guard. D2 resolved Kimi's refresh token as +# rotation-with-short-reuse-grace over ONE shared chain (symlinked, not +# copied — see above) — each container self-refreshes through the CLI's own +# cross-process lock, so there is no orchestrator-side refresh daemon to +# backstop, only this preflight: read the symlinked +# credentials/kimi-code.json's expires_at (a plain JSON field, no JWT +# decode) and refuse fast (exit 78 / EX_CONFIG) on missing/expired, instead +# of the CLI hanging or failing deep into the run. +if ! ( cd /app && python -m roboco.llm.providers.kimi_cli_config --check ); then + echo "[kimi] auth credential missing or expired — refusing to run. Run" \ + "\`kimi login\` on the host (or set ROBOCO_HOST_KIMI_DIR to the" \ + "directory holding credentials/kimi-code.json) before spawning Kimi" \ + "agents." >&2 + exit 78 +fi + +# Run the agent. `< /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. The container's cwd is already the agent's +# workspace (the orchestrator sets it via docker run -w, mirroring the +# Claude/grok/codex path) — captured here BEFORE the usage-capture step's +# `cd /app` needs it to locate the right sessions/wd__*/ dir. +# The prompt travels only as an env-var expansion into a single quoted argv +# token (never re-parsed by the shell) — the same injection-safety property +# as the grok/gemini path's env-var prompt passing; the composed role +# blueprint is NOT folded in here (unlike codex) since it already reached +# the model via the additive AGENTS.md rendered above. +WORKDIR="$PWD" +RUN_LOG="/tmp/kimi-run.jsonl" +ERR_LOG="/tmp/kimi-run.err" + +# `--output-format stream-json` streams JSONL to stdout; `tee` shows it live +# via `docker logs` (parity with the Claude/grok/codex path) while ALSO +# capturing it to RUN_LOG for the usage-capture + sniff reads below. Never +# pipe this through `head` (a known EPIPE hazard on an early-closing reader — +# `tee` alone is safe, it always drains stdin to completion). stderr goes to +# ERR_LOG and is surfaced after the run. +set +e +kimi -p "${ROBOCO_INITIAL_PROMPT:-}" \ + --output-format stream-json \ + -m "${ROBOCO_AGENT_MODEL:-kimi-code/k3}" \ + < /dev/null 2> "$ERR_LOG" | tee "$RUN_LOG" +run_rc=${PIPESTATUS[0]} +set -e +[ -s "$ERR_LOG" ] && cat "$ERR_LOG" >&2 + +# Capture token usage from the session's wire.jsonl (kimi's stdout carries no +# usage summary of its own, unlike codex/gemini — see kimi_cli_usage for the +# session-dir resolution). Best-effort; never fails the run. Run from /app +# for the same module-resolution reason as the render above; ROBOCO_KIMI_WORKDIR +# carries the captured workspace cwd so the usage reader can find the right +# sessions/wd__*/ directory after this subshell's own `cd /app`. +( cd /app && ROBOCO_KIMI_RUN_LOG="$RUN_LOG" ROBOCO_KIMI_WORKDIR="$WORKDIR" \ + python -m roboco.llm.providers.kimi_cli_usage ) || true + +# Kimi has NO documented exit-code taxonomy for `-p` (a claimed 75/1 split is +# unverified noise) — every failure looks the same at the process level. +# Classify the run WITHOUT scanning the full transcript: the model's own +# on-topic prose can false-positive a raw grep by construction — kimi_cli_sniff +# extracts ONLY structured error fields off error-bearing JSONL events plus +# stderr and classifies THAT, never stdout's echoed assistant/tool content. +# Mirrors the codex/grok/gemini entrypoints' exit-75/78 convention so the +# orchestrator's existing park-and-probe logic, scoped by provider_type, +# handles all four providers identically: +# - rate-limit/quota -> exit 75 (EX_TEMPFAIL): the orchestrator PARKS the +# provider instead of the dispatcher respawning the same task every tick. +# - auth/membership failure (a lapsed subscription or an expired credential +# discovered mid-run, past the --check backstop above) -> exit 78 +# (EX_CONFIG): parked the same way as a pre-run auth miss. +SNIFF="$( (cd /app && python -m roboco.llm.providers.kimi_cli_sniff "$RUN_LOG" "$ERR_LOG") 2>/dev/null || true)" +if [ "$SNIFF" = "rate_limit" ]; then + echo "[kimi] rate-limited — exiting 75 so the orchestrator parks the" \ + "provider; the task is retried when the limit lifts." >&2 + exit 75 +fi +if [ "$SNIFF" = "auth" ]; then + echo "[kimi] auth/membership failure detected mid-run — exiting 78 so the" \ + "orchestrator parks the provider until the credential is refreshed." >&2 + exit 78 +fi + +# A graceful exit without a terminal verb is handled server-side by the +# orchestrator (_handle_stopped_container substitutes the still-owned task) — +# the kimi-cli runtime needs no in-container SDK server for that. +exit "$run_rc" diff --git a/docs/map/_complete_map.md b/docs/map/_complete_map.md index b13aefe8..6bbc6b90 100644 --- a/docs/map/_complete_map.md +++ b/docs/map/_complete_map.md @@ -959,9 +959,10 @@ This slice is the packaging, build, and runtime-tooling layer of RoboCo: the Doc | docker/agent-prompter.Dockerfile | Intake (Prompter) — persistent Claude Agent SDK session, ENTRYPOINT python -m roboco.agent_sdk.intake_main | 17 | | docker/agent-secretary.Dockerfile | Secretary — persistent Claude Agent SDK session with gated CEO-authority tools, ENTRYPOINT python -m roboco.agent_sdk.secretary_main | 19 | | docker/agent-pr-reviewer.Dockerfile | PR Reviewer — FROM base, keeps claude entrypoint; read-only reviewer dispatched per review task | 16 | -| docker/agent-grok.Dockerfile | Grok runtime — base + official grok CLI 0.2.56 install, ENTRYPOINT grok-cli-agent-entrypoint.sh | 48 | +| docker/agent-grok.Dockerfile | Grok runtime — base + official grok CLI install (NO version pin since 2026-07-28, latest-at-build), ENTRYPOINT grok-cli-agent-entrypoint.sh | 48 | | docker/agent-grok-prompter.Dockerfile | Grok intake — FROM grok, ENTRYPOINT python -m roboco.agent_sdk.grok_intake_main, EXPOSE 9000 | 23 | | docker/agent-grok-secretary.Dockerfile | Grok secretary — FROM grok, ENTRYPOINT python -m roboco.agent_sdk.grok_secretary_main, EXPOSE 9000 | 23 | +| docker/agent-kimi.Dockerfile | Kimi (Moonshot) runtime — base + official kimi-code CLI install (NO version pin, latest-at-build, resolved version stamped to /etc/kimi-cli-version), ENTRYPOINT kimi-cli-agent-entrypoint.sh | 71 | | docker/panel.Dockerfile | Multi-stage Next.js build (node:22-alpine, pnpm, shamefully-hoist), non-root nextjs runtime serving server.js on :3000 | 76 | | docker/postgres-pgvector.Dockerfile | Example custom pgvector build (pg17) — currently unused; compose uses pgvector/pgvector:pg16 image directly | 15 | | docker/nginx.conf | nginx default.conf template: /health /ready /api/ /ws/ -> orchestrator (with X-Agent-Token header), everything else -> panel | 67 | @@ -979,6 +980,8 @@ This slice is the packaging, build, and runtime-tooling layer of RoboCo: the Doc | docker/scripts/session-end-hook.sh | SessionEnd: post a reflective journal post-mortem (tool count, halt/loop, last terminal tool) to the SDK | 49 | | docker/scripts/fable-{stop-gate,bash-discipline,honesty-nudge,prompt-nudge,precompact}-hook.sh | 5 vendored fable-mode hook scripts (from `opus-fable-playbook` v0.1.3), installed only when `fable_mode_enabled`: Stop/SubagentStop turn-discipline gate, PreToolUse[Bash] read-tool discipline, PostToolUse[Bash] honesty nudge (the one also ported to grok), UserPromptSubmit shape-matched reminder, PreCompact survival-list injection; all fail-open | ~200 | | docker/scripts/grok-cli-agent-entrypoint.sh | Grok runtime entrypoint: render ~/.grok/config.toml, prompt-guard, symlink auth.json from RO mount, grok_auth --check (exit 78 on stale), run grok -p streaming-json, capture usage, exit 75 on 429/quota | 112 | +| docker/scripts/kimi-cli-agent-entrypoint.sh | Kimi runtime entrypoint: symlink credentials/+oauth/ from the shared RW mount, render config.toml/mcp.json/AGENTS.md, prompt-guard, kimi_cli_config --check (exit 78 on missing/expired credential), run kimi -p stream-json, capture wire.jsonl usage, exit 75 on rate-limit/quota sniff | 144 | +| docker/scripts/kimi-bash-guard-wrapper.sh | Wrapper `command` for kimi's [[hooks]] TOML entry: exports ROBOCO_GUARD_SKIP_GIT=1 then execs bash-guard-hook.sh — a kimi hooks entry has no `env` field (it silently drops the WHOLE hooks section on one), so the wrapper carries what a hook `env` block would elsewhere | 9 | | docker/scripts/tests/bash-guard-tests.sh | bash-guard-hook test harness: run_case allow/deny table incl. the new /app venv-protection cases | 7451 | | scripts/build_lifecycle_artifacts.py | Deterministic regeneration of lifecycle artifacts (intent-verbs.md, status-transitions.md, panel/lib/lifecycle.json, per-role prompt fragments) from foundation.policy.lifecycle | 57 | | scripts/regenerate_verb_tables.py | Regenerate agents/prompts/_generated/verbs.md + per-role verb tables from role_config ROLE_CONFIGS + Pydantic flow/do schemas (skips driver-based prompter/secretary) | 233 | @@ -1109,7 +1112,7 @@ deployment-tooling │ ├─ agent-base.Dockerfile (venv + Node22 + claude-code + hooks, USER agent) │ │ └─ docker/scripts/*.sh (sdk-startup, a2a-check, bash-guard, post-tool-budget, usage-report, stop, user-prompt, pre-compact, session-end, + 5 default-off fable-*.sh gated by fable_mode_enabled) │ ├─ role images FROM agent-base: pm, dev-be, dev-fe, qa-be, qa-fe, ux, doc, prompter, secretary, pr-reviewer -│ ├─ grok family: agent-grok.Dockerfile (+ grok CLI 0.2.56, grok-cli-agent-entrypoint.sh) +│ ├─ grok family: agent-grok.Dockerfile (+ grok CLI latest-at-build, grok-cli-agent-entrypoint.sh) │ │ └─ agent-grok-prompter / agent-grok-secretary (FROM grok, agent_sdk drivers) │ ├─ panel.Dockerfile (Next.js standalone, non-root nextjs) │ ├─ postgres-pgvector.Dockerfile (example, unused) @@ -1136,7 +1139,7 @@ deployment-tooling ## Dependencies - Internal: roboco.api.app, roboco.api.deps, roboco.api.websocket, roboco.api.websocket_bridge, roboco.db (bootstrap_database), roboco.events (init_event_bus, register_default_handlers, set_event_context), roboco.runtime (AgentOrchestrator, set_reasoning_stream_callback), roboco.services.notification.NotificationService, roboco.foundation._generators, roboco.foundation.policy.lifecycle.Role, roboco.foundation.identity (Role, Team), roboco.foundation._validate, roboco.foundation.policy.lifecycle, roboco.api.schemas.v1.flow / .do, roboco.services.gateway.role_config.ROLE_CONFIGS, roboco.agent_sdk (intake_main/secretary_main/grok_*_main referenced by Dockerfiles), roboco.llm.providers.grok_cli_config / grok_auth / grok_cli_usage (referenced by grok entrypoint), roboco.agent_sdk.prompt_guard, roboco.agents_config.issue_panel_token (Makefile panel-token) -- External: python>=3.13,<3.15, pydantic / pydantic-settings, fastapi / uvicorn[standard] / websockets / sse-starlette, sqlalchemy[asyncio] / asyncpg / alembic, redis / hiredis, anthropic / openai / tiktoken / claude-agent-sdk, mcp / tomli-w, httpx / python-multipart / python-jose[cryptography] / passlib[bcrypt] / tenacity / structlog, cryptography / packaging / pyyaml / tree-sitter(-python/-typescript), docker (compose, cli, daemon socket mount), nginx:alpine, pgvector/pgvector:pg16, ollama/ollama:latest, curlimages/curl:latest, redis:8-alpine, node:22-alpine (panel), python:3.13-slim-bookworm (orchestrator + agent-base), @anthropic-ai/claude-code, pnpm, Playwright, chromium, xAI grok CLI 0.2.56, uv (astral), ruff, mypy, pytest(-asyncio/-cov/-xdist), vulture, bandit, pip-audit, radon, xenon, deptry, import-linter, mkdocs-material, pymarkdownlnt, make, git, jq +- External: python>=3.13,<3.15, pydantic / pydantic-settings, fastapi / uvicorn[standard] / websockets / sse-starlette, sqlalchemy[asyncio] / asyncpg / alembic, redis / hiredis, anthropic / openai / tiktoken / claude-agent-sdk, mcp / tomli-w, httpx / python-multipart / python-jose[cryptography] / passlib[bcrypt] / tenacity / structlog, cryptography / packaging / pyyaml / tree-sitter(-python/-typescript), docker (compose, cli, daemon socket mount), nginx:alpine, pgvector/pgvector:pg16, ollama/ollama:latest, curlimages/curl:latest, redis:8-alpine, node:22-alpine (panel), python:3.13-slim-bookworm (orchestrator + agent-base), @anthropic-ai/claude-code, pnpm, Playwright, chromium, xAI grok CLI (latest-at-build), uv (astral), ruff, mypy, pytest(-asyncio/-cov/-xdist), vulture, bandit, pip-audit, radon, xenon, deptry, import-linter, mkdocs-material, pymarkdownlnt, make, git, jq ## Entry Points @@ -4519,7 +4522,7 @@ release-manager slice The slice is well-structured: deterministic correctness lives in pure primitives (release_readiness) with a Protocol seam (ReleaseOps) making the fail-closed ordering unit-testable, and the detect→originate→hold→CEO-approve→publish separation is clean and matches CLAUDE.md. Post-snapshot hardening rounds (2759edf7, 05616607, 0bf6c848) resolved all four previously-flagged regression risks: (1) the Redis mutex TTL race is closed by a heartbeat loop that keeps the TTL refreshed and aborts execute fail-closed on lock-loss; (2) commit_and_push/publish_release RuntimeErrors are now caught by execute and returned as structured commit_failed/publish_failed results (no 500); (3) Redis outage now returns redis_unavailable (not already_in_progress) so the CEO knows to fix Redis; (4) the zombie-on-timeout is fixed by awaiting proc.wait(). The approve route is now async-202 with a background dispatcher (dispatch_approve). The half-landed (publish_failed) retry path (release_commit_sha) closes the prior gap where a second CEO approve re-inserted the changelog entry and created a duplicate release commit. The release CI gate is decoupled from self_heal_ci_workflow via a dedicated settings.release_ci_workflow. One low-severity known-by-design item remains: first-release version_ref gap suppression (intentional). release_manager_engine.py and release_readiness.py are unchanged since 15effce0. ## Purpose -This slice is the agent-runtime + LLM-provider seam plus the in-container agent SDK. The provider layer (roboco/llm/providers/) abstracts how agents are spawned/stopped/health-checked/removed across LLM backends (Claude Code default, Grok CLI, Codex CLI, Gemini CLI) behind an AgentProvider ABC + ProviderRegistry, with a Grok auth-token refresh loop keeping the SuperGrok credential live and an orchestrator-side Codex refresh loop keeping the ChatGPT-subscription credential live (Gemini needs neither — its OAuth refresh token is reusable, so each container refreshes its own local copy in-process). The agent SDK (roboco/agent_sdk/) is the FastAPI sidecar running inside every agent container handling A2A messaging, tool-budget/loop/verb-circuit breakers, token-usage capture, and the interactive intake/secretary chat drivers (Claude SDK + Grok CLI — Codex and Gemini are one-shot delivery roles only, no interactive intake/secretary support). The runtime helpers (spawn_manifest, streaming, transcript_retention) build the per-role tool manifest, wire reasoning-stream callbacks, and select old agent transcripts to prune. +This slice is the agent-runtime + LLM-provider seam plus the in-container agent SDK. The provider layer (roboco/llm/providers/) abstracts how agents are spawned/stopped/health-checked/removed across LLM backends (Claude Code default, Grok CLI, Codex CLI, Gemini CLI, Kimi CLI) behind an AgentProvider ABC + ProviderRegistry, with a Grok auth-token refresh loop keeping the SuperGrok credential live and an orchestrator-side Codex refresh loop keeping the ChatGPT-subscription credential live (Gemini needs neither — its OAuth refresh token is reusable, so each container refreshes its own local copy in-process; Kimi also needs no orchestrator daemon, but for the opposite reason — its refresh is rotation-with-short-reuse-grace, so every container shares ONE host-mounted rotating chain instead of refreshing a private copy). The agent SDK (roboco/agent_sdk/) is the FastAPI sidecar running inside every agent container handling A2A messaging, tool-budget/loop/verb-circuit breakers, token-usage capture, and the interactive intake/secretary chat drivers (Claude SDK + Grok CLI — Codex, Gemini, and Kimi are one-shot delivery roles only, no interactive intake/secretary support). The runtime helpers (spawn_manifest, streaming, transcript_retention) build the per-role tool manifest, wire reasoning-stream callbacks, and select old agent transcripts to prune. ## Files @@ -4551,6 +4554,10 @@ This slice is the agent-runtime + LLM-provider seam plus the in-container agent | roboco/llm/providers/gemini.py | GeminiCliProvider: spawns roboco-agent-gemini container, copies host ~/.gemini OAuth creds into a container-local writable copy + usage dir + gemini env | 264 | | roboco/llm/providers/gemini_cli_config.py | Entrypoint renderer: mcp-config -> ~/.gemini/settings.json + per-role TOML Policy Engine deny rules (no native tool-removal flag), GEMINI.md blueprint | 293 | | roboco/llm/providers/gemini_cli_usage.py | Capture token usage from gemini --output-format stream-json terminal result event -> usage.json (per-GA-model pricing); remaps quota/rate-limit errors to exit 75 | 290 | +| roboco/llm/providers/kimi.py | KimiCliProvider: spawns roboco-agent-kimi container, mounts host ~/.kimi-code dir (RW, shared credential chain) at a fixed staging path + usage dir + kimi env | 266 | +| roboco/llm/providers/kimi_cli_config.py | Entrypoint renderer: renders login-managed config.toml provider/model/service blocks as constants + per-role [[permission.rules]] deny set + bash-guard [[hooks]] wiring, mcp.json (near-passthrough of Claude's schema), AGENTS.md; also the `--check` auth preflight CLI (no separate kimi_auth.py — no orchestrator refresh daemon exists) | 441 | +| roboco/llm/providers/kimi_cli_usage.py | Capture token usage from wire.jsonl usage.record events -> usage.json (real inputOther/output/inputCacheRead/inputCacheCreation 4-bucket split, priced per-bucket); session id from stdout resume_hint, falling back to newest on-disk session dir | 280 | +| roboco/llm/providers/kimi_cli_sniff.py | Classify a kimi run's terminal state (rate_limit/auth/none) from ONLY a structured error field off any JSONL event + stderr, never the model's own echoed assistant/tool content | 150 | | roboco/agent_sdk/__init__.py | Package docstring only | 10 | | roboco/agent_sdk/models.py | Pydantic models: A2A messages, budget/terminal/verb-circuit/token-usage request+status | 258 | | roboco/agent_sdk/prompt_guard.py | Prompt-injection detector (5 patterns) + CLI for grok entrypoint turn scan | 93 | @@ -4615,6 +4622,21 @@ This slice is the agent-runtime + LLM-provider seam plus the in-container agent | extract_model_stats / usage_and_cost (gemini) | functions | roboco/llm/providers/gemini_cli_usage.py:130 / 146 | Reads the run's own `--output-format stream-json` terminal `result` event for per-model FLAT token stats — no session-file scraping — and prices each of the three GA models (`gemini-2.5-pro`/`-flash`/`-flash-lite`) at its own rate | | classify_exit_code (gemini) | function | roboco/llm/providers/gemini_cli_usage.py:232 | Remaps a quota/rate-limit error (no dedicated CLI exit code — parsed from the run's JSON `error.type`) to exit 75, mirroring codex/grok's park signal; exit 41 (the CLI's own auth-failure code) passes straight through | | gemini_cli_usage.main | function | roboco/llm/providers/gemini_cli_usage.py:268 | Entrypoint: write usage.json for the run | +| KimiCliProvider | class | roboco/llm/providers/kimi.py:135 | Spawns roboco-agent-kimi container: reuse shared mount/auth/git assembly, blank provider routing fields, add the kimi RW auth mount + usage mount + env | +| KimiCliProvider._append_kimi_auth_mount | staticmethod | roboco/llm/providers/kimi.py:197 | Mount host ~/.kimi-code DIRECTORY RW to a fixed staging path (/home/agent/.kimi-code-auth) — RW because Moonshot's refresh token is rotation-with-short-reuse-grace, not truly reusable like gemini's; warn (never fail) if credentials/kimi-code.json is missing | +| KimiCliProvider._append_usage_mount | staticmethod | roboco/llm/providers/kimi.py:224 | Mount per-agent data dir so the entrypoint writes usage.json the orchestrator reads at finalize | +| permission_rules_for_role | function | roboco/llm/providers/kimi_cli_config.py:226 | Per-role [[permission.rules]] deny set (fleet-wide subagent/web/cron/skill deny + Bash for non-bash roles + Write/Edit for non-author roles + the git-mutation/destructive/raw-package-manager prefix set for bash roles) — kimi has no CLI-flag tool-removal equivalent to grok's --disallowed-tools | +| kimi_hooks_config | function | roboco/llm/providers/kimi_cli_config.py:251 | Build the [[hooks]] TOML entry pointing at kimi-bash-guard-wrapper.sh (not the hook script directly) — a kimi hooks entry silently drops the WHOLE section on an extra field like env, so ROBOCO_GUARD_SKIP_GIT=1 rides the wrapper's own export instead | +| render_config_toml (kimi) | function | roboco/llm/providers/kimi_cli_config.py:278 | Renders the login-managed [providers."managed:kimi-code"]/[models."kimi-code/"]/[services.moonshot_*] blocks as constants (not read off any mount) + telemetry/upgrade knobs + permission rules + hooks | +| render_mcp_json | function | roboco/llm/providers/kimi_cli_config.py:322 | Near-passthrough translation of the mounted Claude Code mcp-config.json into kimi's mcp.json — Claude-identical mcpServers schema, unlike grok's TOML or codex's config.toml translation | +| write_agents_md (kimi) | function | roboco/llm/providers/kimi_cli_config.py:344 | Install the composed role blueprint as ~/.kimi-code/AGENTS.md (grok's proven additive-instruction-file mechanism; SYSTEM.md would fully replace kimi's own built-in prompt and is deliberately not used) | +| is_valid / seconds_until_expiry (kimi) | functions | roboco/llm/providers/kimi_cli_config.py:399 / 383 | The `--check` auth preflight backstop (entrypoint refuses to start on a missing/expired credentials/kimi-code.json) — folded into this renderer rather than a separate kimi_auth.py, since no orchestrator-side refresh loop exists for Kimi | +| kimi_cli_config.main | function | roboco/llm/providers/kimi_cli_config.py:416 | Entrypoint renderer: config.toml + mcp.json + AGENTS.md, or `--check` auth preflight | +| extract_error_text / is_rate_limited / is_auth_failure / classify (kimi) | functions | roboco/llm/providers/kimi_cli_sniff.py:59-120 | Classifies a kimi run's terminal state (rate_limit/auth/none) from ONLY a structured error field off any JSONL event + stderr — the model's own echoed assistant/tool content can never reach the classifier, mirroring codex_cli_sniff's false-positive-safe design | +| session_id_from_run_log / resolve_session_dir (kimi) | functions | roboco/llm/providers/kimi_cli_usage.py:76 / 128 | Session id from the run's own terminal stdout resume_hint event (no file scraping for the id, unlike grok); falls back to the newest session dir under the workdir-keyed sessions/wd__*/ glob | +| aggregate_usage_from_wire | function | roboco/llm/providers/kimi_cli_usage.py:164 | Sums wire.jsonl usage.record events' real inputOther/output/inputCacheRead/inputCacheCreation 4-bucket split — already-disjoint buckets, unlike codex's cached_input_tokens subset | +| capture_run_usage (kimi) | function | roboco/llm/providers/kimi_cli_usage.py:195 | Entrypoint: write the grok-shaped usage.json for the run | +| kimi_cli_usage.main | function | roboco/llm/providers/kimi_cli_usage.py:249 | Entrypoint: write usage.json for the run | | refresh_if_stale | function | roboco/llm/providers/grok_auth.py:307 | Mint fresh access token from refresh_token grant if expiry within skew; acquires _refresh_lock then delegates to _recheck_or_refresh to prevent concurrent double-rotation; returns fresh/refreshed/missing/no_refresh_token/failed (best-effort, never raises) | | _recheck_or_refresh | function | roboco/llm/providers/grok_auth.py:283 | Locked body of refresh_if_stale: re-load bundle + re-check staleness inside _refresh_lock, then call _do_refresh if still stale (prevents concurrent double-rotation of the single-use refresh grant, #94) | | _atomic_write | function | roboco/llm/providers/grok_auth.py:138 | Rewrite auth.json atomically (tmp+replace) with direct-write fallback so a rotated single-use refresh_token is never lost (F006) | @@ -4693,7 +4715,9 @@ The streaming.py callback is set once at bootstrap (websocket_bridge) and invoke **Gemini spawn/refresh.** `ProviderRegistry` also registers `ModelProvider.GEMINI` → `GeminiCliProvider`. Spawn stages the host `~/.gemini` (from a one-time interactive `gemini` login, `ROBOCO_HOST_GEMINI_DIR`) read-only, then 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 — Google's refresh token is REUSABLE (unlike grok's single-use one), so each container refreshing its own copy independently is safe with NO orchestrator refresh daemon for Gemini at all (a deliberate contrast the module docstring spells out against grok/codex). Tool scoping is expressed entirely through a rendered TOML Policy Engine (`~/.gemini/policies/roboco.toml`, deny-only rules keyed by `toolName`/`commandPrefix`) plus `settings.json` (`experimental.enableAgents=false` fleet-wide subagent ban); `--approval-mode yolo` is universal headless auto-approval. `gemini_cli_usage.py` reads the run's own `--output-format stream-json` terminal `result` event for per-model FLAT token stats (no session-file scraping), prices each of the three GA models at its own rate, and remaps a quota/rate-limit error (parsed from the run's JSON `error.type`, no dedicated CLI exit code) to exit 75 — exit 41 (the CLI's own auth-failure code) passes straight through. Both Codex and Gemini are one-shot delivery-role runtimes only in this release — no interactive Intake/Secretary support (only Claude and Grok drive those chats). -**Interactive-role exemption (post-finale sweep, #661).** GLOBAL/ROLE routing rows on `OPENAI`/`GEMINI` are not-applicable to `intake-1`/`secretary-1` — those two agents stay on Anthropic regardless of a fleet-wide mode switch to Codex or Gemini, since neither provider supports the interactive chat driver; an explicit `AGENT_SLUG` pin attempting to route either of them onto `OPENAI`/`GEMINI` is refused loudly by the spawn guard rather than silently spawning a broken interactive session. +**Kimi spawn/refresh.** `ProviderRegistry` also registers `ModelProvider.KIMI` → `KimiCliProvider`. Unlike codex (RO directory) and gemini (RO staged + local copy), the host `~/.kimi-code` (`ROBOCO_HOST_KIMI_DIR`, from a one-time `kimi login`) is mounted **read-write** and SHARED across every container plus the orchestrator — Moonshot's refresh token is rotation-with-short-reuse-grace, not truly reusable (live-verified: two isolated per-container copies of one credential snapshot eventually cross-invalidated each other and the CLI wiped the stored tokens outright). The entrypoint keeps a container-local writable `~/.kimi-code` for config.toml/mcp.json/AGENTS.md (rendered fresh each spawn) but symlinks only `credentials/` and `oauth/` (the lock dir) in from the shared RW mount, so every container redeems the SAME chain and the CLI's own cross-process lock (`oauth/kimi-code.lock`) serializes refreshes — still **no orchestrator refresh daemon** (the CLI refreshes itself, same "no daemon" outcome as gemini but for the opposite structural reason). `kimi login`'s managed config.toml blocks (`[providers."managed:kimi-code"]`/`[models."kimi-code/"]`/`[services.moonshot_*]`) are account-fixed and rendered as constants by `kimi_cli_config.py` rather than read off any mount (the symlink step deliberately does not carry the host's own config.toml forward). Tool scoping is the rendered `[[permission.rules]]` deny-first array (no CLI-flag tool-removal equivalent, like gemini's TOML policy engine); the same `bash-guard-hook.sh` is wired as a `[[hooks]]` entry via a wrapper script, since a kimi hooks entry silently drops the whole section on an extra field like `env`. Kimi has no exit-code taxonomy for `-p` either (a claimed 75/1 split is unverified noise) — `kimi_cli_sniff.py` classifies from ONLY a structured `error` field off any JSONL event + stderr, the same false-positive-safe design as `codex_cli_sniff.py`. `kimi_cli_usage.py` sums `wire.jsonl`'s real 4-bucket usage split (a genuine disjoint split like codex's, unlike grok's output-only fallback). + +**Interactive-role exemption (post-finale sweep, #661; extended to Kimi).** GLOBAL/ROLE routing rows on `OPENAI`/`GEMINI`/`KIMI` are not-applicable to `intake-1`/`secretary-1` — those two agents stay on Anthropic regardless of a fleet-wide mode switch to Codex, Gemini, or Kimi, since none of the three providers supports the interactive chat driver; an explicit `AGENT_SLUG` pin attempting to route either of them onto `OPENAI`/`GEMINI`/`KIMI` is refused loudly by the spawn guard rather than silently spawning a broken interactive session. ## Mermaid ```mermaid @@ -4850,6 +4874,9 @@ runtime-providers - CODEX_HOME (codex_auth.default_auth_path) - ROBOCO_HOST_GEMINI_DIR (gemini.py host `~/.gemini` staging mount source, from a one-time interactive `gemini` login) - ROBOCO_GEMINI_CLI_MODEL (pins one of the three GA ids: `gemini-2.5-pro`/`-flash`/`-flash-lite`) +- ROBOCO_HOST_KIMI_DIR (kimi.py host `~/.kimi-code` RW shared-mount source, from a one-time `kimi login`) +- ROBOCO_KIMI_CLI_MODEL (login-managed alias, default `kimi-code/k3`; `kimi-code/kimi-for-coding` is the cost lever) +- ROBOCO_KIMI_RATE_LIMIT_RETRY_AFTER_SECONDS / ROBOCO_KIMI_AUTH_RETRY_AFTER_SECONDS (park-and-retry delays, gemini's tunable-Settings-field pattern rather than codex's hardcoded constants) ## Gotchas @@ -4874,6 +4901,8 @@ runtime-providers - gemini's refresh token is REUSABLE (unlike grok/codex) — this is the one structural asymmetry in the whole provider family: no orchestrator-side refresh daemon exists for Gemini at all, by design, because each container can safely refresh its own local copy independently without a single writer serializing rotation. - codex has NO exit-code taxonomy — every failure exits 1, so `codex_cli_sniff.py` must classify from structured JSONL `error.message` fields only; it deliberately never inspects the model's own transcript text, since this repo's own prompts legitimately use phrases like "quota-limited" that would false-positive a transcript-text classifier. - gemini's quota/rate-limit signal has no dedicated CLI exit code either — `gemini_cli_usage.classify_exit_code` parses the run's own JSON `error.type` and remaps to exit 75 (the same park signal grok's 429 and codex's rate-limit sniff produce), so the orchestrator's park-and-probe loop treats all three providers identically at that seam despite three different underlying signals. +- kimi's refresh token is rotation-with-short-reuse-grace, NOT truly reusable like gemini's — a first probe (two isolated copies redeeming the same token ~90s apart) looked reusable (a grace window), but the ORIGINAL credential home redeeming that same token ~40min later was refused as reuse-after-grace and the CLI wiped the stored credentials outright (empty-string tokens). The corrected design (a shared RW mount + symlinked-in `credentials/`+`oauth/`, one chain, the CLI's own cross-process lock serializing redemptions) avoids the cross-invalidation the naive per-container-copy design (gemini's shape) would have hit in production. +- kimi's `[[hooks]]` TOML entry silently drops the WHOLE hooks section on an unrecognized field (an `env` key produced a bare "Ignored invalid config" warning, run continues with NO hooks installed at all) — a live-verified, undocumented CLI behavior; `kimi_cli_config.py` routes anything a hook needs (`ROBOCO_GUARD_SKIP_GIT=1`) through a wrapper script's own `export` instead of a hooks `env` block to avoid tripping this. ## Drift from CLAUDE.md @@ -4897,6 +4926,8 @@ runtime-providers > > **Post-finale completeness sweep (#661, `d4b7e1e7`).** Closes gaps found after the Codex/Gemini rollout: one-click `apply_mode` entries + panel mode cards for both new providers (`docs/map/support-services.md`); the interactive-role exemption (GLOBAL/ROLE rows on OPENAI/GEMINI are not-applicable to `intake-1`/`secretary-1`, an explicit AGENT_SLUG pin onto either is refused loudly by the spawn guard rather than silently breaking the interactive chat driver); compose env passthrough for `ROBOCO_GUARD_TRUSTED_CHAIN_PEERS` and `ROBOCO_TASK_BUDGETS_ENABLED` (the provider host-mount dirs were already wired by the provider PRs themselves); and a `gt=0` validation tightening on the task/project budget fields (`docs/map/gateway-support.md`, `docs/map/orchestrator.md`) so a `0` budget — which would silently block everything — is rejected outright. > +> **Kimi CLI provider.** New `kimi.py`/`kimi_cli_config.py`/`kimi_cli_usage.py`/`kimi_cli_sniff.py` add `ModelProvider.KIMI` running Moonshot AI's official `kimi` (kimi-code) CLI on a Kimi subscription — `ProviderRegistry` now registers KIMI alongside GROK/OPENAI/GEMINI. No `kimi_auth.py`: the D2 spike found Moonshot's refresh token is rotation-with-short-reuse-grace rather than truly reusable, so the design landed on ONE shared RW host mount + symlinked-in `credentials/`+`oauth/` (the CLI's own cross-process lock serializes redemptions) instead of either grok/codex's orchestrator-refreshed single-use pattern or gemini's per-container-reusable-copy pattern — no orchestrator daemon either way, but for a different structural reason than gemini's. Fleet-wide alongside this: the existing grok/gemini/codex Dockerfiles drop their version pins (latest-at-build, always adapt — a CEO decision record) and gain the same resolved-version provenance stamp (`/etc/-version` + a `.cli.pinned="false"` label) kimi's own Dockerfile establishes. +> > **v0.18.0** (2026-07-04): Fable mode's grok side — `fable_honesty_nudge_hook_config`/`write_grok_fable_hooks` (grok_cli_config.py:308-345), gated by `fable_mode_enabled` (default off). Deliberately narrower than the Claude path's 5 hooks: only the never-denying PostToolUse honesty-nudge is ported, because a grok `PreToolUse`/`Stop` hook deny cancels the entire run (verified live) — the same asymmetry this file's Gotchas section already documents for the bash-guard's git-deny-vs-exfil-cancel split. ## Regression Risks @@ -4911,6 +4942,7 @@ runtime-providers | Missing auth.json now logs warning but still spawns doomed container | roboco/llm/providers/grok.py:185 | When ~/.grok/auth.json is absent, _append_grok_auth_mount now only logs a warning (was: silently skipped the mount). The spawn still proceeds and the container exits 78 at the entrypoint --check. Behavior of the spawn path is unchanged, but an operator who does not tail logs will still diagnose a later exit-78; the warning is only useful if someone reads it. | low | | codex_cli_sniff classifies from error.message text only | roboco/llm/providers/codex_cli_sniff.py:94 | Codex has no exit-code taxonomy, so every quota/rate-limit/auth distinction rests entirely on the shape of a structured JSONL `error.message` field. If a future codex CLI version changes that field's wording or moves the signal elsewhere, the sniff silently falls through to "none" and the orchestrator crash-retries straight back into the same rate limit instead of parking the provider — the same failure mode the overload-break feature exists to prevent for the other providers. | medium | | Gemini has no orchestrator-side refresh daemon at all | roboco/llm/providers/gemini.py:135 | The reusable-refresh-token design is safe under the CURRENT assumption (each container's local copy refreshes independently, no shared writer to serialize). If Google ever changes the refresh grant to single-use (matching grok/codex), every container refreshing concurrently would race to burn the same grant with no lock protecting it, unlike grok_auth/codex_auth's process-wide lock — this provider has no equivalent safety net because none was ever needed under the current contract. | low | +| Kimi's shared RW auth mount widens exposure vs a per-agent copy | roboco/llm/providers/kimi.py:207 | Every Kimi agent container mounts the SAME host `~/.kimi-code` directory read-write (not a per-agent copy or an RO staging mount like codex/gemini) so every container can redeem the one rotating refresh chain. A container that could escape its own sandbox could read or corrupt the shared credential state for every OTHER Kimi agent and the host — the tradeoff the D2 spike accepted after the naive per-container-copy design was found to cross-invalidate tokens in production. Mitigated only by container sandboxing, not by the mount itself. | medium | ## Health This slice is coherent and well-factored: the provider ABC + registry cleanly isolates the Grok backend while the Anthropic/Ollama/LOCAL paths stay on the built-in spawn (additive seam, no destabilization), and the agent_sdk sidecar centralizes budget/loop/verb-circuit/token state that hooks share. The single baseline-to-HEAD commit (15effce0) landed three genuine hardening fixes — the F006 refresh_token-loss guard with direct-write fallback, the JWT-exp decode so a refreshed token isn't forever rejected, and the /usage/sync path-traversal guard — plus the grok directory-mount fix that resolves the inode-pinning hang. The main integrity concerns are operational rather than structural: the grok directory mount widens RO exposure to host grok state, the 6h expires_at default can burn the single-use refresh_token on the rare double-miss, the in-process SDK state is lost on every container restart (by design, but means verb-circuit/budget counters reset), and ClaudeCodeProvider is dead reference code whose 'default' label in CLAUDE.md is misleading. Interactive intake/secretary parity between Claude and Grok is real (shared IntakeDriver, only the SessionFactory differs). No obviously broken logic was introduced; the regression risks are edge-case behavior shifts, not holes. Recommend re-running the grok auth refresh test against a token that omits expires_in to confirm the JWT-exp path, and a /usage/sync test with a symlinked transcript to confirm the new guard fails loud where appropriate. diff --git a/docs/map/deployment-tooling.md b/docs/map/deployment-tooling.md index 7c891d82..9a768e43 100644 --- a/docs/map/deployment-tooling.md +++ b/docs/map/deployment-tooling.md @@ -28,9 +28,10 @@ This slice is the packaging, build, and runtime-tooling layer of RoboCo: the Doc | docker/agent-prompter.Dockerfile | Intake (Prompter) — persistent Claude Agent SDK session, ENTRYPOINT python -m roboco.agent_sdk.intake_main | 17 | | docker/agent-secretary.Dockerfile | Secretary — persistent Claude Agent SDK session with gated CEO-authority tools, ENTRYPOINT python -m roboco.agent_sdk.secretary_main | 19 | | docker/agent-pr-reviewer.Dockerfile | PR Reviewer — FROM base, keeps claude entrypoint; read-only reviewer dispatched per review task | 16 | -| docker/agent-grok.Dockerfile | Grok runtime — base + official grok CLI 0.2.56 install, ENTRYPOINT grok-cli-agent-entrypoint.sh | 48 | +| docker/agent-grok.Dockerfile | Grok runtime — base + official grok CLI install (NO version pin since 2026-07-28, latest-at-build), ENTRYPOINT grok-cli-agent-entrypoint.sh | 48 | | docker/agent-grok-prompter.Dockerfile | Grok intake — FROM grok, ENTRYPOINT python -m roboco.agent_sdk.grok_intake_main, EXPOSE 9000 | 23 | | docker/agent-grok-secretary.Dockerfile | Grok secretary — FROM grok, ENTRYPOINT python -m roboco.agent_sdk.grok_secretary_main, EXPOSE 9000 | 23 | +| docker/agent-kimi.Dockerfile | Kimi (Moonshot) runtime — base + official kimi-code CLI install (NO version pin, latest-at-build, resolved version stamped to /etc/kimi-cli-version), ENTRYPOINT kimi-cli-agent-entrypoint.sh | 71 | | docker/panel.Dockerfile | Multi-stage Next.js build (node:22-alpine, pnpm, shamefully-hoist), non-root nextjs runtime serving server.js on :3000 | 76 | | docker/postgres-pgvector.Dockerfile | Example custom pgvector build (pg17) — currently unused; compose uses pgvector/pgvector:pg16 image directly | 15 | | docker/nginx.conf | nginx default.conf template: /health /ready /api/ /ws/ -> orchestrator (with X-Agent-Token header), everything else -> panel | 67 | @@ -48,6 +49,8 @@ This slice is the packaging, build, and runtime-tooling layer of RoboCo: the Doc | docker/scripts/session-end-hook.sh | SessionEnd: post a reflective journal post-mortem (tool count, halt/loop, last terminal tool) to the SDK | 49 | | docker/scripts/fable-{stop-gate,bash-discipline,honesty-nudge,prompt-nudge,precompact}-hook.sh | 5 vendored fable-mode hook scripts (from `opus-fable-playbook` v0.1.3), installed only when `fable_mode_enabled`: Stop/SubagentStop turn-discipline gate, PreToolUse[Bash] read-tool discipline, PostToolUse[Bash] honesty nudge (the one also ported to grok), UserPromptSubmit shape-matched reminder, PreCompact survival-list injection; all fail-open | ~200 | | docker/scripts/grok-cli-agent-entrypoint.sh | Grok runtime entrypoint: render ~/.grok/config.toml, prompt-guard, symlink auth.json from RO mount, grok_auth --check (exit 78 on stale), run grok -p streaming-json, capture usage, exit 75 on 429/quota | 112 | +| docker/scripts/kimi-cli-agent-entrypoint.sh | Kimi runtime entrypoint: symlink credentials/+oauth/ from the shared RW mount, render config.toml/mcp.json/AGENTS.md, prompt-guard, kimi_cli_config --check (exit 78 on missing/expired credential), run kimi -p stream-json, capture wire.jsonl usage, exit 75 on rate-limit/quota sniff | 144 | +| docker/scripts/kimi-bash-guard-wrapper.sh | Wrapper `command` for kimi's [[hooks]] TOML entry: exports ROBOCO_GUARD_SKIP_GIT=1 then execs bash-guard-hook.sh — a kimi hooks entry has no `env` field (it silently drops the WHOLE hooks section on one), so the wrapper carries what a hook `env` block would elsewhere | 9 | | docker/scripts/tests/bash-guard-tests.sh | bash-guard-hook test harness: run_case allow/deny table incl. the new /app venv-protection cases | 7451 | | scripts/build_lifecycle_artifacts.py | Deterministic regeneration of lifecycle artifacts (intent-verbs.md, status-transitions.md, panel/lib/lifecycle.json, per-role prompt fragments) from foundation.policy.lifecycle | 57 | | scripts/regenerate_verb_tables.py | Regenerate agents/prompts/_generated/verbs.md + per-role verb tables from role_config ROLE_CONFIGS + Pydantic flow/do schemas (skips driver-based prompter/secretary) | 233 | @@ -178,7 +181,7 @@ deployment-tooling │ ├─ agent-base.Dockerfile (venv + Node22 + claude-code + hooks, USER agent) │ │ └─ docker/scripts/*.sh (sdk-startup, a2a-check, bash-guard, post-tool-budget, usage-report, stop, user-prompt, pre-compact, session-end, + 5 default-off fable-*.sh gated by fable_mode_enabled) │ ├─ role images FROM agent-base: pm, dev-be, dev-fe, qa-be, qa-fe, ux, doc, prompter, secretary, pr-reviewer -│ ├─ grok family: agent-grok.Dockerfile (+ grok CLI 0.2.56, grok-cli-agent-entrypoint.sh) +│ ├─ grok family: agent-grok.Dockerfile (+ grok CLI latest-at-build, grok-cli-agent-entrypoint.sh) │ │ └─ agent-grok-prompter / agent-grok-secretary (FROM grok, agent_sdk drivers) │ ├─ panel.Dockerfile (Next.js standalone, non-root nextjs) │ ├─ postgres-pgvector.Dockerfile (example, unused) @@ -205,7 +208,7 @@ deployment-tooling ## Dependencies - Internal: roboco.api.app, roboco.api.deps, roboco.api.websocket, roboco.api.websocket_bridge, roboco.db (bootstrap_database), roboco.events (init_event_bus, register_default_handlers, set_event_context), roboco.runtime (AgentOrchestrator, set_reasoning_stream_callback), roboco.services.notification.NotificationService, roboco.foundation._generators, roboco.foundation.policy.lifecycle.Role, roboco.foundation.identity (Role, Team), roboco.foundation._validate, roboco.foundation.policy.lifecycle, roboco.api.schemas.v1.flow / .do, roboco.services.gateway.role_config.ROLE_CONFIGS, roboco.agent_sdk (intake_main/secretary_main/grok_*_main referenced by Dockerfiles), roboco.llm.providers.grok_cli_config / grok_auth / grok_cli_usage (referenced by grok entrypoint), roboco.agent_sdk.prompt_guard, roboco.agents_config.issue_panel_token (Makefile panel-token) -- External: python>=3.13,<3.15, pydantic / pydantic-settings, fastapi / uvicorn[standard] / websockets / sse-starlette, sqlalchemy[asyncio] / asyncpg / alembic, redis / hiredis, anthropic / openai / tiktoken / claude-agent-sdk, mcp / tomli-w, httpx / python-multipart / python-jose[cryptography] / passlib[bcrypt] / tenacity / structlog, cryptography / packaging / pyyaml / tree-sitter(-python/-typescript), docker (compose, cli, daemon socket mount), nginx:alpine, pgvector/pgvector:pg16, ollama/ollama:latest, curlimages/curl:latest, redis:8-alpine, node:22-alpine (panel), python:3.13-slim-bookworm (orchestrator + agent-base), @anthropic-ai/claude-code, pnpm, Playwright, chromium, xAI grok CLI 0.2.56, uv (astral), ruff, mypy, pytest(-asyncio/-cov/-xdist), vulture, bandit, pip-audit, radon, xenon, deptry, import-linter, mkdocs-material, pymarkdownlnt, make, git, jq +- External: python>=3.13,<3.15, pydantic / pydantic-settings, fastapi / uvicorn[standard] / websockets / sse-starlette, sqlalchemy[asyncio] / asyncpg / alembic, redis / hiredis, anthropic / openai / tiktoken / claude-agent-sdk, mcp / tomli-w, httpx / python-multipart / python-jose[cryptography] / passlib[bcrypt] / tenacity / structlog, cryptography / packaging / pyyaml / tree-sitter(-python/-typescript), docker (compose, cli, daemon socket mount), nginx:alpine, pgvector/pgvector:pg16, ollama/ollama:latest, curlimages/curl:latest, redis:8-alpine, node:22-alpine (panel), python:3.13-slim-bookworm (orchestrator + agent-base), @anthropic-ai/claude-code, pnpm, Playwright, chromium, xAI grok CLI (latest-at-build), uv (astral), ruff, mypy, pytest(-asyncio/-cov/-xdist), vulture, bandit, pip-audit, radon, xenon, deptry, import-linter, mkdocs-material, pymarkdownlnt, make, git, jq ## Entry Points diff --git a/docs/map/runtime-providers.md b/docs/map/runtime-providers.md index e63500ae..08c14147 100644 --- a/docs/map/runtime-providers.md +++ b/docs/map/runtime-providers.md @@ -1,5 +1,5 @@ ## Purpose -This slice is the agent-runtime + LLM-provider seam plus the in-container agent SDK. The provider layer (roboco/llm/providers/) abstracts how agents are spawned/stopped/health-checked/removed across LLM backends (Claude Code default, Grok CLI, Codex CLI, Gemini CLI) behind an AgentProvider ABC + ProviderRegistry, with a Grok auth-token refresh loop keeping the SuperGrok credential live and an orchestrator-side Codex refresh loop keeping the ChatGPT-subscription credential live (Gemini needs neither — its OAuth refresh token is reusable, so each container refreshes its own local copy in-process). The agent SDK (roboco/agent_sdk/) is the FastAPI sidecar running inside every agent container handling A2A messaging, tool-budget/loop/verb-circuit breakers, token-usage capture, and the interactive intake/secretary chat drivers (Claude SDK + Grok CLI — Codex and Gemini are one-shot delivery roles only, no interactive intake/secretary support). The runtime helpers (spawn_manifest, streaming, transcript_retention) build the per-role tool manifest, wire reasoning-stream callbacks, and select old agent transcripts to prune. +This slice is the agent-runtime + LLM-provider seam plus the in-container agent SDK. The provider layer (roboco/llm/providers/) abstracts how agents are spawned/stopped/health-checked/removed across LLM backends (Claude Code default, Grok CLI, Codex CLI, Gemini CLI, Kimi CLI) behind an AgentProvider ABC + ProviderRegistry, with a Grok auth-token refresh loop keeping the SuperGrok credential live and an orchestrator-side Codex refresh loop keeping the ChatGPT-subscription credential live (Gemini needs neither — its OAuth refresh token is reusable, so each container refreshes its own local copy in-process; Kimi also needs no orchestrator daemon, but for the opposite reason — its refresh is rotation-with-short-reuse-grace, so every container shares ONE host-mounted rotating chain instead of refreshing a private copy). The agent SDK (roboco/agent_sdk/) is the FastAPI sidecar running inside every agent container handling A2A messaging, tool-budget/loop/verb-circuit breakers, token-usage capture, and the interactive intake/secretary chat drivers (Claude SDK + Grok CLI — Codex, Gemini, and Kimi are one-shot delivery roles only, no interactive intake/secretary support). The runtime helpers (spawn_manifest, streaming, transcript_retention) build the per-role tool manifest, wire reasoning-stream callbacks, and select old agent transcripts to prune. ## Files @@ -31,6 +31,10 @@ This slice is the agent-runtime + LLM-provider seam plus the in-container agent | roboco/llm/providers/gemini.py | GeminiCliProvider: spawns roboco-agent-gemini container, copies host ~/.gemini OAuth creds into a container-local writable copy + usage dir + gemini env | 264 | | roboco/llm/providers/gemini_cli_config.py | Entrypoint renderer: mcp-config -> ~/.gemini/settings.json + per-role TOML Policy Engine deny rules (no native tool-removal flag), GEMINI.md blueprint | 293 | | roboco/llm/providers/gemini_cli_usage.py | Capture token usage from gemini --output-format stream-json terminal result event -> usage.json (per-GA-model pricing); remaps quota/rate-limit errors to exit 75 | 290 | +| roboco/llm/providers/kimi.py | KimiCliProvider: spawns roboco-agent-kimi container, mounts host ~/.kimi-code dir (RW, shared credential chain) at a fixed staging path + usage dir + kimi env | 266 | +| roboco/llm/providers/kimi_cli_config.py | Entrypoint renderer: renders login-managed config.toml provider/model/service blocks as constants + per-role [[permission.rules]] deny set + bash-guard [[hooks]] wiring, mcp.json (near-passthrough of Claude's schema), AGENTS.md; also the `--check` auth preflight CLI (no separate kimi_auth.py — no orchestrator refresh daemon exists) | 441 | +| roboco/llm/providers/kimi_cli_usage.py | Capture token usage from wire.jsonl usage.record events -> usage.json (real inputOther/output/inputCacheRead/inputCacheCreation 4-bucket split, priced per-bucket); session id from stdout resume_hint, falling back to newest on-disk session dir | 280 | +| roboco/llm/providers/kimi_cli_sniff.py | Classify a kimi run's terminal state (rate_limit/auth/none) from ONLY a structured error field off any JSONL event + stderr, never the model's own echoed assistant/tool content | 150 | | roboco/agent_sdk/__init__.py | Package docstring only | 10 | | roboco/agent_sdk/models.py | Pydantic models: A2A messages, budget/terminal/verb-circuit/token-usage request+status | 258 | | roboco/agent_sdk/prompt_guard.py | Prompt-injection detector (5 patterns) + CLI for grok entrypoint turn scan | 93 | @@ -95,6 +99,21 @@ This slice is the agent-runtime + LLM-provider seam plus the in-container agent | extract_model_stats / usage_and_cost (gemini) | functions | roboco/llm/providers/gemini_cli_usage.py:130 / 146 | Reads the run's own `--output-format stream-json` terminal `result` event for per-model FLAT token stats — no session-file scraping — and prices each of the three GA models (`gemini-2.5-pro`/`-flash`/`-flash-lite`) at its own rate | | classify_exit_code (gemini) | function | roboco/llm/providers/gemini_cli_usage.py:232 | Remaps a quota/rate-limit error (no dedicated CLI exit code — parsed from the run's JSON `error.type`) to exit 75, mirroring codex/grok's park signal; exit 41 (the CLI's own auth-failure code) passes straight through | | gemini_cli_usage.main | function | roboco/llm/providers/gemini_cli_usage.py:268 | Entrypoint: write usage.json for the run | +| KimiCliProvider | class | roboco/llm/providers/kimi.py:135 | Spawns roboco-agent-kimi container: reuse shared mount/auth/git assembly, blank provider routing fields, add the kimi RW auth mount + usage mount + env | +| KimiCliProvider._append_kimi_auth_mount | staticmethod | roboco/llm/providers/kimi.py:197 | Mount host ~/.kimi-code DIRECTORY RW to a fixed staging path (/home/agent/.kimi-code-auth) — RW because Moonshot's refresh token is rotation-with-short-reuse-grace, not truly reusable like gemini's; warn (never fail) if credentials/kimi-code.json is missing | +| KimiCliProvider._append_usage_mount | staticmethod | roboco/llm/providers/kimi.py:224 | Mount per-agent data dir so the entrypoint writes usage.json the orchestrator reads at finalize | +| permission_rules_for_role | function | roboco/llm/providers/kimi_cli_config.py:226 | Per-role [[permission.rules]] deny set (fleet-wide subagent/web/cron/skill deny + Bash for non-bash roles + Write/Edit for non-author roles + the git-mutation/destructive/raw-package-manager prefix set for bash roles) — kimi has no CLI-flag tool-removal equivalent to grok's --disallowed-tools | +| kimi_hooks_config | function | roboco/llm/providers/kimi_cli_config.py:251 | Build the [[hooks]] TOML entry pointing at kimi-bash-guard-wrapper.sh (not the hook script directly) — a kimi hooks entry silently drops the WHOLE section on an extra field like env, so ROBOCO_GUARD_SKIP_GIT=1 rides the wrapper's own export instead | +| render_config_toml (kimi) | function | roboco/llm/providers/kimi_cli_config.py:278 | Renders the login-managed [providers."managed:kimi-code"]/[models."kimi-code/"]/[services.moonshot_*] blocks as constants (not read off any mount) + telemetry/upgrade knobs + permission rules + hooks | +| render_mcp_json | function | roboco/llm/providers/kimi_cli_config.py:322 | Near-passthrough translation of the mounted Claude Code mcp-config.json into kimi's mcp.json — Claude-identical mcpServers schema, unlike grok's TOML or codex's config.toml translation | +| write_agents_md (kimi) | function | roboco/llm/providers/kimi_cli_config.py:344 | Install the composed role blueprint as ~/.kimi-code/AGENTS.md (grok's proven additive-instruction-file mechanism; SYSTEM.md would fully replace kimi's own built-in prompt and is deliberately not used) | +| is_valid / seconds_until_expiry (kimi) | functions | roboco/llm/providers/kimi_cli_config.py:399 / 383 | The `--check` auth preflight backstop (entrypoint refuses to start on a missing/expired credentials/kimi-code.json) — folded into this renderer rather than a separate kimi_auth.py, since no orchestrator-side refresh loop exists for Kimi | +| kimi_cli_config.main | function | roboco/llm/providers/kimi_cli_config.py:416 | Entrypoint renderer: config.toml + mcp.json + AGENTS.md, or `--check` auth preflight | +| extract_error_text / is_rate_limited / is_auth_failure / classify (kimi) | functions | roboco/llm/providers/kimi_cli_sniff.py:59-120 | Classifies a kimi run's terminal state (rate_limit/auth/none) from ONLY a structured error field off any JSONL event + stderr — the model's own echoed assistant/tool content can never reach the classifier, mirroring codex_cli_sniff's false-positive-safe design | +| session_id_from_run_log / resolve_session_dir (kimi) | functions | roboco/llm/providers/kimi_cli_usage.py:76 / 128 | Session id from the run's own terminal stdout resume_hint event (no file scraping for the id, unlike grok); falls back to the newest session dir under the workdir-keyed sessions/wd__*/ glob | +| aggregate_usage_from_wire | function | roboco/llm/providers/kimi_cli_usage.py:164 | Sums wire.jsonl usage.record events' real inputOther/output/inputCacheRead/inputCacheCreation 4-bucket split — already-disjoint buckets, unlike codex's cached_input_tokens subset | +| capture_run_usage (kimi) | function | roboco/llm/providers/kimi_cli_usage.py:195 | Entrypoint: write the grok-shaped usage.json for the run | +| kimi_cli_usage.main | function | roboco/llm/providers/kimi_cli_usage.py:249 | Entrypoint: write usage.json for the run | | refresh_if_stale | function | roboco/llm/providers/grok_auth.py:307 | Mint fresh access token from refresh_token grant if expiry within skew; acquires _refresh_lock then delegates to _recheck_or_refresh to prevent concurrent double-rotation; returns fresh/refreshed/missing/no_refresh_token/failed (best-effort, never raises) | | _recheck_or_refresh | function | roboco/llm/providers/grok_auth.py:283 | Locked body of refresh_if_stale: re-load bundle + re-check staleness inside _refresh_lock, then call _do_refresh if still stale (prevents concurrent double-rotation of the single-use refresh grant, #94) | | _atomic_write | function | roboco/llm/providers/grok_auth.py:138 | Rewrite auth.json atomically (tmp+replace) with direct-write fallback so a rotated single-use refresh_token is never lost (F006) | @@ -173,7 +192,9 @@ The streaming.py callback is set once at bootstrap (websocket_bridge) and invoke **Gemini spawn/refresh.** `ProviderRegistry` also registers `ModelProvider.GEMINI` → `GeminiCliProvider`. Spawn stages the host `~/.gemini` (from a one-time interactive `gemini` login, `ROBOCO_HOST_GEMINI_DIR`) read-only, then 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 — Google's refresh token is REUSABLE (unlike grok's single-use one), so each container refreshing its own copy independently is safe with NO orchestrator refresh daemon for Gemini at all (a deliberate contrast the module docstring spells out against grok/codex). Tool scoping is expressed entirely through a rendered TOML Policy Engine (`~/.gemini/policies/roboco.toml`, deny-only rules keyed by `toolName`/`commandPrefix`) plus `settings.json` (`experimental.enableAgents=false` fleet-wide subagent ban); `--approval-mode yolo` is universal headless auto-approval. `gemini_cli_usage.py` reads the run's own `--output-format stream-json` terminal `result` event for per-model FLAT token stats (no session-file scraping), prices each of the three GA models at its own rate, and remaps a quota/rate-limit error (parsed from the run's JSON `error.type`, no dedicated CLI exit code) to exit 75 — exit 41 (the CLI's own auth-failure code) passes straight through. Both Codex and Gemini are one-shot delivery-role runtimes only in this release — no interactive Intake/Secretary support (only Claude and Grok drive those chats). -**Interactive-role exemption (post-finale sweep, #661).** GLOBAL/ROLE routing rows on `OPENAI`/`GEMINI` are not-applicable to `intake-1`/`secretary-1` — those two agents stay on Anthropic regardless of a fleet-wide mode switch to Codex or Gemini, since neither provider supports the interactive chat driver; an explicit `AGENT_SLUG` pin attempting to route either of them onto `OPENAI`/`GEMINI` is refused loudly by the spawn guard rather than silently spawning a broken interactive session. +**Kimi spawn/refresh.** `ProviderRegistry` also registers `ModelProvider.KIMI` → `KimiCliProvider`. Unlike codex (RO directory) and gemini (RO staged + local copy), the host `~/.kimi-code` (`ROBOCO_HOST_KIMI_DIR`, from a one-time `kimi login`) is mounted **read-write** and SHARED across every container plus the orchestrator — Moonshot's refresh token is rotation-with-short-reuse-grace, not truly reusable (live-verified: two isolated per-container copies of one credential snapshot eventually cross-invalidated each other and the CLI wiped the stored tokens outright). The entrypoint keeps a container-local writable `~/.kimi-code` for config.toml/mcp.json/AGENTS.md (rendered fresh each spawn) but symlinks only `credentials/` and `oauth/` (the lock dir) in from the shared RW mount, so every container redeems the SAME chain and the CLI's own cross-process lock (`oauth/kimi-code.lock`) serializes refreshes — still **no orchestrator refresh daemon** (the CLI refreshes itself, same "no daemon" outcome as gemini but for the opposite structural reason). `kimi login`'s managed config.toml blocks (`[providers."managed:kimi-code"]`/`[models."kimi-code/"]`/`[services.moonshot_*]`) are account-fixed and rendered as constants by `kimi_cli_config.py` rather than read off any mount (the symlink step deliberately does not carry the host's own config.toml forward). Tool scoping is the rendered `[[permission.rules]]` deny-first array (no CLI-flag tool-removal equivalent, like gemini's TOML policy engine); the same `bash-guard-hook.sh` is wired as a `[[hooks]]` entry via a wrapper script, since a kimi hooks entry silently drops the whole section on an extra field like `env`. Kimi has no exit-code taxonomy for `-p` either (a claimed 75/1 split is unverified noise) — `kimi_cli_sniff.py` classifies from ONLY a structured `error` field off any JSONL event + stderr, the same false-positive-safe design as `codex_cli_sniff.py`. `kimi_cli_usage.py` sums `wire.jsonl`'s real 4-bucket usage split (a genuine disjoint split like codex's, unlike grok's output-only fallback). + +**Interactive-role exemption (post-finale sweep, #661; extended to Kimi).** GLOBAL/ROLE routing rows on `OPENAI`/`GEMINI`/`KIMI` are not-applicable to `intake-1`/`secretary-1` — those two agents stay on Anthropic regardless of a fleet-wide mode switch to Codex, Gemini, or Kimi, since none of the three providers supports the interactive chat driver; an explicit `AGENT_SLUG` pin attempting to route either of them onto `OPENAI`/`GEMINI`/`KIMI` is refused loudly by the spawn guard rather than silently spawning a broken interactive session. ## Mermaid ```mermaid @@ -330,6 +351,9 @@ runtime-providers - CODEX_HOME (codex_auth.default_auth_path) - ROBOCO_HOST_GEMINI_DIR (gemini.py host `~/.gemini` staging mount source, from a one-time interactive `gemini` login) - ROBOCO_GEMINI_CLI_MODEL (pins one of the three GA ids: `gemini-2.5-pro`/`-flash`/`-flash-lite`) +- ROBOCO_HOST_KIMI_DIR (kimi.py host `~/.kimi-code` RW shared-mount source, from a one-time `kimi login`) +- ROBOCO_KIMI_CLI_MODEL (login-managed alias, default `kimi-code/k3`; `kimi-code/kimi-for-coding` is the cost lever) +- ROBOCO_KIMI_RATE_LIMIT_RETRY_AFTER_SECONDS / ROBOCO_KIMI_AUTH_RETRY_AFTER_SECONDS (park-and-retry delays, gemini's tunable-Settings-field pattern rather than codex's hardcoded constants) ## Gotchas @@ -354,6 +378,8 @@ runtime-providers - gemini's refresh token is REUSABLE (unlike grok/codex) — this is the one structural asymmetry in the whole provider family: no orchestrator-side refresh daemon exists for Gemini at all, by design, because each container can safely refresh its own local copy independently without a single writer serializing rotation. - codex has NO exit-code taxonomy — every failure exits 1, so `codex_cli_sniff.py` must classify from structured JSONL `error.message` fields only; it deliberately never inspects the model's own transcript text, since this repo's own prompts legitimately use phrases like "quota-limited" that would false-positive a transcript-text classifier. - gemini's quota/rate-limit signal has no dedicated CLI exit code either — `gemini_cli_usage.classify_exit_code` parses the run's own JSON `error.type` and remaps to exit 75 (the same park signal grok's 429 and codex's rate-limit sniff produce), so the orchestrator's park-and-probe loop treats all three providers identically at that seam despite three different underlying signals. +- kimi's refresh token is rotation-with-short-reuse-grace, NOT truly reusable like gemini's — a first probe (two isolated copies redeeming the same token ~90s apart) looked reusable (a grace window), but the ORIGINAL credential home redeeming that same token ~40min later was refused as reuse-after-grace and the CLI wiped the stored credentials outright (empty-string tokens). The corrected design (a shared RW mount + symlinked-in `credentials/`+`oauth/`, one chain, the CLI's own cross-process lock serializing redemptions) avoids the cross-invalidation the naive per-container-copy design (gemini's shape) would have hit in production. +- kimi's `[[hooks]]` TOML entry silently drops the WHOLE hooks section on an unrecognized field (an `env` key produced a bare "Ignored invalid config" warning, run continues with NO hooks installed at all) — a live-verified, undocumented CLI behavior; `kimi_cli_config.py` routes anything a hook needs (`ROBOCO_GUARD_SKIP_GIT=1`) through a wrapper script's own `export` instead of a hooks `env` block to avoid tripping this. ## Drift from CLAUDE.md @@ -377,6 +403,8 @@ runtime-providers > > **Post-finale completeness sweep (#661, `d4b7e1e7`).** Closes gaps found after the Codex/Gemini rollout: one-click `apply_mode` entries + panel mode cards for both new providers (`docs/map/support-services.md`); the interactive-role exemption (GLOBAL/ROLE rows on OPENAI/GEMINI are not-applicable to `intake-1`/`secretary-1`, an explicit AGENT_SLUG pin onto either is refused loudly by the spawn guard rather than silently breaking the interactive chat driver); compose env passthrough for `ROBOCO_GUARD_TRUSTED_CHAIN_PEERS` and `ROBOCO_TASK_BUDGETS_ENABLED` (the provider host-mount dirs were already wired by the provider PRs themselves); and a `gt=0` validation tightening on the task/project budget fields (`docs/map/gateway-support.md`, `docs/map/orchestrator.md`) so a `0` budget — which would silently block everything — is rejected outright. > +> **Kimi CLI provider.** New `kimi.py`/`kimi_cli_config.py`/`kimi_cli_usage.py`/`kimi_cli_sniff.py` add `ModelProvider.KIMI` running Moonshot AI's official `kimi` (kimi-code) CLI on a Kimi subscription — `ProviderRegistry` now registers KIMI alongside GROK/OPENAI/GEMINI. No `kimi_auth.py`: the D2 spike found Moonshot's refresh token is rotation-with-short-reuse-grace rather than truly reusable, so the design landed on ONE shared RW host mount + symlinked-in `credentials/`+`oauth/` (the CLI's own cross-process lock serializes redemptions) instead of either grok/codex's orchestrator-refreshed single-use pattern or gemini's per-container-reusable-copy pattern — no orchestrator daemon either way, but for a different structural reason than gemini's. Fleet-wide alongside this: the existing grok/gemini/codex Dockerfiles drop their version pins (latest-at-build, always adapt — a CEO decision record) and gain the same resolved-version provenance stamp (`/etc/-version` + a `.cli.pinned="false"` label) kimi's own Dockerfile establishes. +> > **v0.18.0** (2026-07-04): Fable mode's grok side — `fable_honesty_nudge_hook_config`/`write_grok_fable_hooks` (grok_cli_config.py:308-345), gated by `fable_mode_enabled` (default off). Deliberately narrower than the Claude path's 5 hooks: only the never-denying PostToolUse honesty-nudge is ported, because a grok `PreToolUse`/`Stop` hook deny cancels the entire run (verified live) — the same asymmetry this file's Gotchas section already documents for the bash-guard's git-deny-vs-exfil-cancel split. ## Regression Risks @@ -391,6 +419,7 @@ runtime-providers | Missing auth.json now logs warning but still spawns doomed container | roboco/llm/providers/grok.py:185 | When ~/.grok/auth.json is absent, _append_grok_auth_mount now only logs a warning (was: silently skipped the mount). The spawn still proceeds and the container exits 78 at the entrypoint --check. Behavior of the spawn path is unchanged, but an operator who does not tail logs will still diagnose a later exit-78; the warning is only useful if someone reads it. | low | | codex_cli_sniff classifies from error.message text only | roboco/llm/providers/codex_cli_sniff.py:94 | Codex has no exit-code taxonomy, so every quota/rate-limit/auth distinction rests entirely on the shape of a structured JSONL `error.message` field. If a future codex CLI version changes that field's wording or moves the signal elsewhere, the sniff silently falls through to "none" and the orchestrator crash-retries straight back into the same rate limit instead of parking the provider — the same failure mode the overload-break feature exists to prevent for the other providers. | medium | | Gemini has no orchestrator-side refresh daemon at all | roboco/llm/providers/gemini.py:135 | The reusable-refresh-token design is safe under the CURRENT assumption (each container's local copy refreshes independently, no shared writer to serialize). If Google ever changes the refresh grant to single-use (matching grok/codex), every container refreshing concurrently would race to burn the same grant with no lock protecting it, unlike grok_auth/codex_auth's process-wide lock — this provider has no equivalent safety net because none was ever needed under the current contract. | low | +| Kimi's shared RW auth mount widens exposure vs a per-agent copy | roboco/llm/providers/kimi.py:207 | Every Kimi agent container mounts the SAME host `~/.kimi-code` directory read-write (not a per-agent copy or an RO staging mount like codex/gemini) so every container can redeem the one rotating refresh chain. A container that could escape its own sandbox could read or corrupt the shared credential state for every OTHER Kimi agent and the host — the tradeoff the D2 spike accepted after the naive per-container-copy design was found to cross-invalidate tokens in production. Mitigated only by container sandboxing, not by the mount itself. | medium | ## Health This slice is coherent and well-factored: the provider ABC + registry cleanly isolates the Grok backend while the Anthropic/Ollama/LOCAL paths stay on the built-in spawn (additive seam, no destabilization), and the agent_sdk sidecar centralizes budget/loop/verb-circuit/token state that hooks share. The single baseline-to-HEAD commit (15effce0) landed three genuine hardening fixes — the F006 refresh_token-loss guard with direct-write fallback, the JWT-exp decode so a refreshed token isn't forever rejected, and the /usage/sync path-traversal guard — plus the grok directory-mount fix that resolves the inode-pinning hang. The main integrity concerns are operational rather than structural: the grok directory mount widens RO exposure to host grok state, the 6h expires_at default can burn the single-use refresh_token on the rare double-miss, the in-process SDK state is lost on every container restart (by design, but means verb-circuit/budget counters reset), and ClaudeCodeProvider is dead reference code whose 'default' label in CLAUDE.md is misleading. Interactive intake/secretary parity between Claude and Grok is real (shared IntakeDriver, only the SessionFactory differs). No obviously broken logic was introduced; the regression risks are edge-case behavior shifts, not holes. Recommend re-running the grok auth refresh test against a token that omits expires_in to confirm the JWT-exp path, and a /usage/sync test with a symlinked transcript to confirm the new guard fails loud where appropriate. diff --git a/panel/src/components/settings/__tests__/ai-routing-card.test.tsx b/panel/src/components/settings/__tests__/ai-routing-card.test.tsx index 4dc5a254..45221e75 100644 --- a/panel/src/components/settings/__tests__/ai-routing-card.test.tsx +++ b/panel/src/components/settings/__tests__/ai-routing-card.test.tsx @@ -52,6 +52,11 @@ const { provider_type: "gemini", display_name: "Gemini 2.5 Pro", }, + { + model_name: "kimi-code/k3", + provider_type: "kimi", + display_name: "Kimi K3", + }, ]), getOllamaKey: vi.fn(async () => ({ has_key: false, enabled: true })), setOllamaKey: vi.fn(async () => ({ has_key: true, enabled: true })), @@ -893,6 +898,64 @@ describe("AIRoutingCard", () => { }); }); + describe("Kimi mode button", () => { + it("renders the Kimi button and applies mode='kimi' on confirm", async () => { + const confirmSpy = vi.spyOn(window, "confirm").mockReturnValue(true); + render(withQueryClient()); + await screen.findByText("Grok (xAI) API key"); + + fireEvent.click(screen.getByText("Kimi")); + + await waitFor(() => + expect(applyMode).toHaveBeenCalledWith({ mode: "kimi" }), + ); + confirmSpy.mockRestore(); + }); + + it("is not gated on a key (no key card exists for Kimi)", async () => { + render(withQueryClient()); + await screen.findByText("Grok (xAI) API key"); + + expect(screen.getByText("Kimi").closest("button")).not.toBeDisabled(); + }); + }); + + describe("Mix picker Kimi group visibility", () => { + it("shows the Kimi provider group for a delivery role's per-agent select", async () => { + render(withQueryClient()); + await screen.findByText("Per-agent override (mix mode)"); + + const beDevRow = mixRowFor("be-dev-1"); + expect( + await within(beDevRow).findByText("Kimi (Moonshot)"), + ).toBeInTheDocument(); + }); + + it("excludes Kimi from the Intake/Secretary/PR Review group", async () => { + render(withQueryClient()); + await screen.findByText("Per-agent override (mix mode)"); + + // Wait for the catalog query to resolve (an unrelated row's groups) + // before asserting absence on this group's rows below. + await within(mixRowFor("be-dev-1")).findByText("Kimi (Moonshot)"); + + const secretaryRow = mixRowFor("secretary-1"); + expect( + within(secretaryRow).queryByText("Kimi (Moonshot)"), + ).not.toBeInTheDocument(); + + const intakeRow = mixRowFor("intake-1"); + expect( + within(intakeRow).queryByText("Kimi (Moonshot)"), + ).not.toBeInTheDocument(); + + const prReviewerRow = mixRowFor("pr-reviewer-1"); + expect( + within(prReviewerRow).queryByText("Kimi (Moonshot)"), + ).not.toBeInTheDocument(); + }); + }); + describe("Mix picker Codex/Gemini group visibility", () => { it("shows Codex and Gemini provider groups for a delivery role's per-agent select", async () => { render(withQueryClient()); @@ -913,7 +976,7 @@ describe("AIRoutingCard", () => { await screen.findByText("Per-agent override (mix mode)"); expect( - screen.getByText(/Codex and Gemini are delivery-roles-only/i), + screen.getByText(/Codex, Gemini, and Kimi are delivery-roles-only/i), ).toBeInTheDocument(); // Wait for the catalog query to resolve (an unrelated row's groups) diff --git a/panel/src/components/settings/ai-routing-card.tsx b/panel/src/components/settings/ai-routing-card.tsx index bc0b2658..a544dfcc 100644 --- a/panel/src/components/settings/ai-routing-card.tsx +++ b/panel/src/components/settings/ai-routing-card.tsx @@ -46,6 +46,7 @@ import { Gem, Key, KeyRound, + Moon, Server, ShieldCheck, Sparkles, @@ -117,10 +118,11 @@ const AGENT_GROUP_DEFS: { }, ]; -// Codex/Gemini are V1 delivery-roles-only — no interactive Intake/Secretary -// support (see roboco.llm.providers.codex / .gemini). This group's per-agent -// picker excludes both providers below instead of offering a route that -// would silently misroute the persistent Intake/Secretary session at spawn. +// Codex/Gemini/Kimi are V1 delivery-roles-only — no interactive Intake/ +// Secretary support (see roboco.llm.providers.codex / .gemini / .kimi). This +// group's per-agent picker excludes all three providers below instead of +// offering a route that would silently misroute the persistent Intake/ +// Secretary session at spawn. const INTERACTIVE_ONLY_GROUP_TITLE = "Intake / Secretary / PR Review"; // Stable within-group ordering (PM/lead first, devs, QA, doc, reviewer last) @@ -296,6 +298,10 @@ export function AIRoutingCard() { (c: { provider_type: ModelProvider }) => c.provider_type === ModelProvider.GEMINI, ); + const catalogKimiOnly = catalog.filter( + (c: { provider_type: ModelProvider }) => + c.provider_type === ModelProvider.KIMI, + ); const catalogAnthropicOnly = catalog.filter( (c: { provider_type: ModelProvider }) => c.provider_type === ModelProvider.ANTHROPIC, @@ -382,6 +388,26 @@ export function AIRoutingCard() { } }; + const flipToKimi = async () => { + if ( + !confirm( + "Switch every agent to Kimi? Per-agent pins and complexity " + + "overrides are kept; other role/global assignments are replaced. " + + "Intake and Secretary stay on Anthropic (Kimi has no interactive " + + "chat support).", + ) + ) + return; + try { + await applyMode.mutateAsync({ mode: "kimi" }); + toast.success( + "Role/global routing now on Kimi — pins/overrides kept, Intake & Secretary stay on Anthropic", + ); + } catch (e) { + toast.error("Switch failed: " + errMsg(e)); + } + }; + const flipToOllama = async () => { if (!hasOllamaKey) { toast.error("Save an Ollama API key first"); @@ -720,6 +746,23 @@ export function AIRoutingCard() { )} + {/* Kimi (Moonshot) models — excluded for the interactive-only group */} + {!restrictInteractiveOnly && catalogKimiOnly.length > 0 && ( + + + + Kimi (Moonshot) + + {catalogKimiOnly.map( + (c: { model_name: string; display_name: string }) => ( + + {c.display_name} + + ), + )} + + )} + {/* Ollama Cloud models */} {catalogOllamaOnly.length > 0 && ( @@ -773,9 +816,9 @@ export function AIRoutingCard() { Decide which model backs each agent. Anthropic uses the mounted ~/.claude auth; Grok (xAI) and Ollama - Cloud use the API keys you save below; Codex and Gemini authenticate - via their own mounted CLI subscriptions (no key needed) — V1: - delivery roles only, not Intake/Secretary; Self-Hosted connects to + Cloud use the API keys you save below; Codex, Gemini, and Kimi + authenticate via their own mounted CLI subscriptions (no key needed) — + V1: delivery roles only, not Intake/Secretary; Self-Hosted connects to any OpenAI-compatible endpoint you run locally. @@ -915,10 +958,10 @@ export function AIRoutingCard() { {/* -------- Mode toggle -------- */}
- + -
+
} label="Anthropic" @@ -957,6 +1000,15 @@ export function AIRoutingCard() { disabled={applyMode.isPending} labelHint="Gemini authenticates via a mounted ~/.gemini OAuth login (no API key) — always available once the CLI is logged in on the host. V1: delivery roles only, not offered for Intake/Secretary." /> + } + label="Kimi" + description="Every agent uses Kimi (kimi-code/k3)." + active={currentMode === "kimi"} + onClick={flipToKimi} + disabled={applyMode.isPending} + labelHint="Kimi authenticates via a shared, symlinked-in ~/.kimi-code subscription credential (Moonshot, no API key) — always available once the CLI is logged in on the host. V1: delivery roles only, not offered for Intake/Secretary." + /> } label="Ollama" @@ -1042,6 +1094,14 @@ export function AIRoutingCard() { roles only — not available for Intake/Secretary.

) : null} + {currentMode === "kimi" || currentMode === "mix" ? ( +

+ Kimi agents run on Moonshot's official kimi (kimi-code) CLI + (subscription auth, shared ~/.kimi-code credential); the same + guards apply. V1: delivery roles only — not available for + Intake/Secretary. +

+ ) : null}
{/* -------- Self-Hosted model picker (when self_hosted mode active) -------- */} @@ -1237,8 +1297,9 @@ export function AIRoutingCard() { {restrictInteractiveOnly ? (

- Codex and Gemini are delivery-roles-only (V1) — not - offered here (no interactive Intake/Secretary support). + Codex, Gemini, and Kimi are delivery-roles-only (V1) — + not offered here (no interactive Intake/Secretary + support).

) : null}
@@ -1431,6 +1492,7 @@ function ProviderBadge({ | "grok" | "openai" | "gemini" + | "kimi" | "ollama" | "self-hosted"; }) { @@ -1441,6 +1503,7 @@ function ProviderBadge({ grok: "bg-teal-500/20 text-teal-700 dark:text-teal-400", openai: "bg-emerald-500/20 text-emerald-700 dark:text-emerald-400", gemini: "bg-sky-500/20 text-sky-700 dark:text-sky-400", + kimi: "bg-amber-500/20 text-amber-700 dark:text-amber-400", }; const labels: Record = { anthropic: "A", @@ -1449,6 +1512,7 @@ function ProviderBadge({ grok: "G", openai: "C", gemini: "Ge", + kimi: "K", }; return ( `). + # Aliases are namespaced under the login-managed "kimi-code" provider — + # see roboco.llm.providers.kimi_cli_config for the rendered config.toml. + kimi_cli_model: str = Field( + default="kimi-code/k3", + description=( + "Kimi CLI model alias passed to `kimi -p -m`; override via " + "ROBOCO_KIMI_CLI_MODEL" + ), + ) + # Retry_after tunables for parking the KIMI provider — a real Settings + # field (gemini's tunable pattern), not codex's hardcoded module + # constants: an operator may want a different cadence for Moonshot's own + # 5h request-counted quota window than the flat 60s codex/gemini default. + kimi_rate_limit_retry_after_seconds: float = Field( + default=60.0, + ge=1.0, + description=( + "Base retry_after (seconds) when parking the KIMI provider on a " + "quota/rate-limit exit; override via " + "ROBOCO_KIMI_RATE_LIMIT_RETRY_AFTER_SECONDS" + ), + ) + kimi_auth_retry_after_seconds: float = Field( + default=60.0, + ge=1.0, + description=( + "retry_after (seconds) when parking the KIMI provider on a " + "missing/expired subscription credential (entrypoint preflight " + "exit 78); override via ROBOCO_KIMI_AUTH_RETRY_AFTER_SECONDS" + ), + ) # An interactive intake/secretary chat the human abandoned (closed the tab # without confirming/stopping) otherwise leaks its container until the # orchestrator restarts. The sweeper reaps a live session whose diff --git a/roboco/llm/providers/__init__.py b/roboco/llm/providers/__init__.py index c5599f8b..67464ded 100644 --- a/roboco/llm/providers/__init__.py +++ b/roboco/llm/providers/__init__.py @@ -16,6 +16,10 @@ Backends: - :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). +- :class:`KimiCliProvider` — Moonshot AI's Kimi K3 via the official ``kimi`` + (kimi-code) CLI on a Kimi subscription (mounted ``~/.kimi-code`` auth, + one-shot delivery roles only — see :mod:`roboco.llm.providers.kimi` for the + V1 scope). """ from roboco.llm.providers.base import AgentProvider, ProviderError, SpawnResult @@ -23,6 +27,7 @@ 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.kimi import KimiCliProvider from roboco.llm.providers.registry import ProviderNotRegisteredError, ProviderRegistry __all__ = [ @@ -31,6 +36,7 @@ __all__ = [ "CodexCliProvider", "GeminiCliProvider", "GrokCliProvider", + "KimiCliProvider", "ProviderError", "ProviderNotRegisteredError", "ProviderRegistry", diff --git a/roboco/llm/providers/kimi.py b/roboco/llm/providers/kimi.py new file mode 100644 index 00000000..50d15286 --- /dev/null +++ b/roboco/llm/providers/kimi.py @@ -0,0 +1,266 @@ +"""Kimi CLI provider — Moonshot AI's Kimi K3 via the official ``kimi`` CLI. + +Moonshot ships an official terminal coding agent (the ``kimi`` / kimi-code CLI) +authenticated by a Kimi subscription (OAuth device-code login), not a metered +key — the same posture as Grok (SuperGrok), Codex (ChatGPT), and Gemini +(Google OAuth). RoboCo runs Kimi agents on it the same way it runs those: +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 subscription auth mount (``~/.kimi-code``) and the runtime +env the kimi-cli entrypoint reads, then launches the ``roboco-agent-kimi`` +image — whose entrypoint copies the mounted credential in, renders +``~/.kimi-code/config.toml`` + ``mcp.json`` + ``AGENTS.md`` from the mounted +mcp-config.json (see :mod:`roboco.llm.providers.kimi_cli_config`), and runs +``kimi -p`` headless. + +Two things differ from the Claude Code spawn (mirrors ``CodexCliProvider``): + 1. **Auth** — the host's ``~/.kimi-code`` (subscription credential from + ``kimi login``) is mounted READ-WRITE, shared across every container AND + the host. Moonshot's refresh token is rotation-with-short-reuse-grace, + NOT truly reusable: a first probe (two isolated copies of one credential + snapshot both redeeming the same refresh token ~90s apart) looked + reusable, but redeeming that SAME token again from the ORIGINAL home + ~40min later was refused as reuse-after-grace and the CLI wiped the + stored credentials outright (empty-string tokens — a real login died and + needed a fresh device-code approval to recover). Per-container COPIES of + the credential are therefore unsafe: N containers each refreshing a + private snapshot will eventually cross-invalidate each other's tokens. + The corrected design is ONE shared rotating chain: the entrypoint keeps + a container-local writable ``~/.kimi-code`` for + config.toml/mcp.json/AGENTS.md (rendered fresh — see + :mod:`roboco.llm.providers.kimi_cli_config`) but SYMLINKS + ``credentials/`` AND ``oauth/`` (the lock directory) into the shared RW + mount, so every container plus the host redeem the same chain and the + CLI's own cross-process lock (``oauth/kimi-code.lock``) serializes + refreshes — the exact mechanism the CLI ships for multi-process sharing. + Still NO orchestrator refresh daemon (the CLI refreshes itself; the + orchestrator does nothing) — contrast + :mod:`roboco.llm.providers.grok_auth` / :mod:`roboco.llm.providers.codex_auth`, + which exist ONLY because their provider's refresh token has no + multi-process sharing story at all. The provider routing fields are + blanked before the shared mount step so the shared builder never + injects them as ``ANTHROPIC_*`` (the wrong runtime) — kimi authenticates + from the shared credential, not a provider key. + 2. **Runtime** — the ``roboco-agent-kimi`` image (kimi-code CLI) instead of + ``claude``. + +The initial prompt is passed via an **env var, not a positional CLI arg** +(the entrypoint folds it into the rendered prompt handoff), which +structurally avoids a flag-injection vector. + +**V1 scope**: one-shot delivery roles only (developer / qa / documenter / +cell_pm / main_pm / pr_reviewer / board). No interactive intake/secretary +support — there is no ``roboco-agent-kimi-prompter`` / ``-secretary`` image. +""" + +from __future__ import annotations + +import asyncio +import dataclasses +import logging +from pathlib import Path +from typing import TYPE_CHECKING, Protocol + +from roboco.config import settings +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 Kimi agent image (own image, like every other agent role). +_DEFAULT_KIMI_IMAGE = "roboco-agent-kimi:latest" + +# The kimi CLI model alias, pinned via Settings (parity with codex_cli_model / +# gemini_cli_model — Kimi's login-managed aliases have no reliable "pick for +# me" default worth trusting blind). +_KIMI_CLI_MODEL = settings.kimi_cli_model + +# Host directory holding the Kimi subscription auth (from `kimi login`). +# Mounted into the agent's staging path like the codex/gemini paths mount +# their own host auth dirs. +KIMI_AUTH_HOST_PATH = settings.host_kimi_dir + +# In-container paths. +_MCP_CONFIG_IN_CONTAINER = "/app/mcp-config.json" +# The host ~/.kimi-code DIRECTORY is mounted RW here, shared by every +# container AND the host; the entrypoint symlinks its credentials/ and +# oauth/ subdirectories into a container-local, writable ~/.kimi-code +# (config.toml/mcp.json/AGENTS.md are rendered fresh — see the module +# docstring and kimi_cli_config for why a shared RW mount + symlinks, not +# codex's live-symlinked single-file mount or a per-container copy). +_KIMI_AUTH_DIR_IN_CONTAINER = "/home/agent/.kimi-code-auth" +# 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 kimi analogue of the mounted Claude transcript. +_KIMI_USAGE_DIR_IN_CONTAINER = "/home/agent/.kimi-usage" +_KIMI_USAGE_FILE_IN_CONTAINER = f"{_KIMI_USAGE_DIR_IN_CONTAINER}/usage.json" + + +def _container_name(agent_id: str) -> str: + return f"roboco-agent-{agent_id}" + + +class _KimiHost(Protocol): + """The orchestrator surface KimiCliProvider 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_kimi_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 KimiCliProvider(AgentProvider): + """Spawn a Kimi (Moonshot, official CLI) agent as a gateway-wired container.""" + + def __init__(self, host: _KimiHost, image: str | None = None) -> None: + self._host = host + self._image = image or _DEFAULT_KIMI_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( + "KIMI 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_kimi_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 — kimi + # authenticates from the shared, symlinked-in ~/.kimi-code + # 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_kimi_auth_mount(cmd) + self._append_usage_mount(cmd, hosts) + self._append_kimi_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 Kimi container: {stderr.decode().strip()}", + agent_id=config.agent_id, + ) + return SpawnResult( + instance_id=container_name, + extra={"container_id": stdout.decode().strip(), "model": _KIMI_CLI_MODEL}, + ) + + @staticmethod + def _append_kimi_auth_mount(cmd: list[str]) -> None: + """Mount the host's Kimi subscription ``~/.kimi-code`` directory (read-write). + + RW, not RO: the refresh token is rotation-with-short-reuse-grace, so + every container must redeem the SAME chain the host uses (see the + module docstring) — the entrypoint symlinks ``credentials/`` and + ``oauth/`` forward from this mount into a container-local writable + ``~/.kimi-code`` — see ``_KIMI_AUTH_DIR_IN_CONTAINER``. + """ + auth_dir = Path(KIMI_AUTH_HOST_PATH) + if (auth_dir / "credentials" / "kimi-code.json").exists(): + cmd.extend(["-v", f"{auth_dir}:{_KIMI_AUTH_DIR_IN_CONTAINER}"]) + else: + # The mount is the Kimi subscription credential — without it the + # container starts but the entrypoint's auth preflight refuses to + # run (exit 78) and the agent is doomed. Fail loud at spawn time + # so the operator sees the missing credential immediately. + _log.warning( + "kimi host credentials/kimi-code.json not found at %s — spawn " + "will start the container but it is doomed to exit 78 (no " + "Kimi credential). Run `kimi login` on the host (or set " + "ROBOCO_HOST_KIMI_DIR to the directory holding credentials/" + "kimi-code.json) before spawning Kimi agents.", + auth_dir / "credentials" / "kimi-code.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["kimi_usage"]``); the + entrypoint writes ``usage.json`` here after the run. Without it a + Kimi agent finalizes at 0 tokens / $0. + """ + data_host = hosts.get("kimi_usage") + if data_host: + cmd.extend(["-v", f"{data_host}:{_KIMI_USAGE_DIR_IN_CONTAINER}"]) + + def _append_kimi_env( + self, cmd: list[str], config: AgentConfig, initial_prompt: str | None + ) -> None: + """Append the runtime env the kimi-cli entrypoint + renderer read. + + ``ROBOCO_AGENT_ID`` lets the renderer compute the per-role deny + rules; ``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={_KIMI_CLI_MODEL}", + "-e", + f"ROBOCO_MCP_CONFIG={_MCP_CONFIG_IN_CONTAINER}", + "-e", + f"ROBOCO_INITIAL_PROMPT={initial_prompt or ''}", + "-e", + f"ROBOCO_KIMI_USAGE_FILE={_KIMI_USAGE_FILE_IN_CONTAINER}", + ] + ) + + async def stop(self, instance_id: str, graceful: bool = True) -> None: + await stop_container(instance_id, graceful) + + async def health_check(self, instance_id: str) -> bool: + return await container_running(instance_id) + + async def remove(self, instance_id: str) -> None: + await self._host._remove_container(instance_id) diff --git a/roboco/llm/providers/kimi_cli_config.py b/roboco/llm/providers/kimi_cli_config.py new file mode 100644 index 00000000..dd040a91 --- /dev/null +++ b/roboco/llm/providers/kimi_cli_config.py @@ -0,0 +1,441 @@ +"""Render a Kimi CLI agent's runtime config + per-role rules at container start. + +The ``roboco-agent-kimi`` image's entrypoint runs ``python -m +roboco.llm.providers.kimi_cli_config`` to write ``~/.kimi-code/config.toml`` +(the login-managed provider/model/service blocks + telemetry/upgrade knobs + +the per-role ``[[permission.rules]]`` deny set + the bash-guard +``[[hooks]]`` wiring), ``~/.kimi-code/mcp.json`` (a near-passthrough of the +mounted Claude Code ``mcp-config.json`` — kimi's ``mcpServers`` schema is +Claude-identical, unlike grok's TOML or codex's config.toml translation), and +``~/.kimi-code/AGENTS.md`` (the composed role blueprint, grok's proven +additive-instruction-file mechanism). Keeping the translation in importable +Python (not a shell heredoc) makes it unit-testable, mirroring +:mod:`roboco.llm.providers.grok_cli_config` / +:mod:`roboco.llm.providers.gemini_cli_config`. + +Parity notes (where Kimi's runtime model differs from grok's/gemini's): + + * **managed config blocks** — ``kimi login`` writes a fixed set of + ``[providers."managed:kimi-code"]`` / ``[models."kimi-code/"]`` / + ``[services.moonshot_*]`` blocks keyed to the account's subscription, not + ours to discover per-container: this module renders them as constants + (:data:`_KIMI_MODELS` etc.) instead of reading them off the mounted host + config.toml (which the symlink step deliberately does NOT carry forward — + only ``credentials/`` and ``oauth/`` are shared, see + :mod:`roboco.llm.providers.kimi`'s module docstring). Per-alias + fields not pinned down verbatim (``max_context_size``/``capabilities`` + for aliases beyond the live-verified ``k3`` @ 262144) are conservative, + internally-consistent placeholders; the CLI's own ``config.invalid`` + error (verified to exit 1 with a clear message) is the fail-loud signal + if a real account's managed block ever disagrees. + * **permission model** — ``-p`` runs under unconditional auto-approval with + no CLI-flag tool-removal equivalent (unlike grok's ``--disallowed-tools``/ + ``--deny``), so scoping is entirely the rendered ``[[permission.rules]]`` + array (``decision`` allow/deny/ask + a ``pattern`` glob, e.g. + ``Bash(git push*)`` — evaluated deny-first/class-based, confirmed + graceful: a denied tool call returns a permission error and the agent + recovers, never cancels the run). + * **hooks** — the SAME ``bash-guard-hook.sh`` the Claude/grok paths install + (verified to accept kimi's Claude-schema snake_case ``PreToolUse`` stdin + payload with no changes) is wired as a TOML ``[[hooks]]`` entry, ``command`` + pointed at ``kimi-bash-guard-wrapper.sh`` rather than the hook script + directly: a ``[[hooks]]`` entry only tolerates ``event``/``matcher``/ + ``command``/``timeout`` (an ``env`` key silently drops the WHOLE hooks + section — live-verified), so ``ROBOCO_GUARD_SKIP_GIT=1`` rides the + wrapper's own ``export`` instead. Git ops are already denied gracefully by + the permission rules above, so the hook is defense-in-depth for the + exfil/identity-forgery categories the deny-rule globs don't reach. + Hooks fire BEFORE permission rules (both fire on a deny-ruled call); a + hook deny is ALSO graceful on kimi (unlike grok's run-cancelling hook + deny), so this is a pure tripwire, never the sole boundary. + * **system prompt** — ``$KIMI_CODE_HOME/AGENTS.md`` is additive (verified + headless-honored) like grok's global ``AGENTS.md``; ``SYSTEM.md`` fully + REPLACES the CLI's own built-in main-agent prompt and is deliberately not + used here. +""" + +from __future__ import annotations + +import contextlib +import json +import os +import sys +from datetime import UTC, datetime +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 + +# kimi reads its global config from $KIMI_CODE_HOME/config.toml (default +# ~/.kimi-code — the agent's HOME is /home/agent). The container-local, +# WRITABLE home the entrypoint copies credentials/ into before this renders. +KIMI_CODE_HOME = Path.home() / ".kimi-code" +KIMI_CONFIG_PATH = KIMI_CODE_HOME / "config.toml" +KIMI_MCP_PATH = KIMI_CODE_HOME / "mcp.json" +# kimi loads $KIMI_CODE_HOME/AGENTS.md as a GLOBAL, ADDITIVE instruction file +# (live-verified headless) — the parity analogue of grok's global AGENTS.md. +KIMI_AGENTS_MD_PATH = KIMI_CODE_HOME / "AGENTS.md" +# The credential file the entrypoint copies in from the RO staging mount +# (see roboco.llm.providers.kimi); the auth preflight below reads its +# expires_at field directly — a plain JSON field, no JWT decode needed. +KIMI_CREDENTIALS_PATH = KIMI_CODE_HOME / "credentials" / "kimi-code.json" +# 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 bash-guard PreToolUse hook script, baked into the agent base image — +# same script the Claude/grok paths install (verified to accept kimi's +# Claude-schema snake_case stdin payload unmodified). +BASH_GUARD_HOOK = os.environ.get( + "ROBOCO_BASH_GUARD_HOOK", "/app/scripts/bash-guard-hook.sh" +) +# A [[hooks]] entry has no `env` field (live-verified: one present drops the +# WHOLE hooks section silently — see kimi_hooks_config below), so +# ROBOCO_GUARD_SKIP_GIT=1 rides this wrapper's own export instead. Baked into +# the kimi image alongside the entrypoint (docker/agent-kimi.Dockerfile). +KIMI_BASH_GUARD_WRAPPER = os.environ.get( + "ROBOCO_KIMI_BASH_GUARD_WRAPPER", "/app/scripts/kimi-bash-guard-wrapper.sh" +) +# The entrypoint reads a small preflight ok/fail from `--check`'s exit code — +# no args file handoff is needed for kimi (unlike grok/codex/gemini's +# per-role flag-token file) since kimi's per-role scoping lives entirely in +# the rendered config.toml, not in CLI flags. + +# --- Managed config (login-written; see module docstring) ------------------- +_MANAGED_PROVIDER_KEY = "managed:kimi-code" +_MANAGED_BASE_URL = "https://api.kimi.com/coding/v1" +_MANAGED_DEFAULT_MODEL = "kimi-code/kimi-for-coding" +_MANAGED_OAUTH = {"storage": "file", "key": "oauth/kimi-code"} + +# These blocks mirror what a real membership login writes into config.toml, +# field-for-field (captured live on 0.29.2, Moderato). The `model` value is +# the CLI-side managed name (`k3`, NOT the raw API id `kimi-k3`) — it is what +# the CLI sends on the wire, so an invented value breaks every run. Context +# sizes/capabilities are the login-written values too; a tier upgrade would +# raise k3's window server-side, and 262144 stays a safe client-side cap. +_MANAGED_CONTEXT = 262_144 +_K3_CAPS = ["thinking", "always_thinking", "image_in", "video_in", "tool_use"] + +_KIMI_MODELS: dict[str, dict[str, Any]] = { + "kimi-code/k3": { + "provider": _MANAGED_PROVIDER_KEY, + "model": "k3", + "max_context_size": _MANAGED_CONTEXT, + "capabilities": _K3_CAPS, + "display_name": "K3", + "support_efforts": ["low", "high", "max"], + "default_effort": "high", + }, + "kimi-code/k3-256k": { + "provider": _MANAGED_PROVIDER_KEY, + "model": "k3-256k", + "max_context_size": _MANAGED_CONTEXT, + "capabilities": ["thinking", "always_thinking", "image_in", "tool_use"], + "display_name": "K3-256k", + "support_efforts": ["low", "high", "max"], + "default_effort": "high", + }, + "kimi-code/kimi-for-coding": { + "provider": _MANAGED_PROVIDER_KEY, + "model": "kimi-for-coding", + "max_context_size": _MANAGED_CONTEXT, + "capabilities": _K3_CAPS, + "display_name": "K2.7 Coding", + }, + "kimi-code/kimi-for-coding-highspeed": { + "provider": _MANAGED_PROVIDER_KEY, + "model": "kimi-for-coding-highspeed", + "max_context_size": _MANAGED_CONTEXT, + "capabilities": _K3_CAPS, + "display_name": "K2.7 Coding Highspeed", + }, +} + +# --- Permission model (deny-only; -p auto-approves everything else) -------- +# Fleet-wide, every role: subagent ban (CEO, 2026-07-09) + no direct web +# (gated web stays MCP-side) + no cron + no Skill. +_FLEET_WIDE_DENY: tuple[str, ...] = ( + "Agent", + "AgentSwarm", + "WebSearch", + "FetchURL", + "CronCreate", + "CronList", + "CronDelete", + "Skill", +) + +# Roles that legitimately run a shell. Review / board roles never do — the +# same set grok_cli_config._BASH_ROLES / gemini_cli_config._BASH_ROLES use. +_BASH_ROLES = frozenset({"developer", "documenter", "cell_pm", "main_pm"}) + +# Git network/branch/history mutation, destructive shell, and raw +# package-manager commands — the SAME canonical pattern set as grok's +# --deny rules / codex's execpolicy rules. Denied gracefully (live-verified: +# the agent gets a permission error and recovers, the run doesn't cancel). +_GIT_MUTATE_DENY: tuple[str, ...] = ( + "Bash(git push*)", + "Bash(git fetch*)", + "Bash(git pull*)", + "Bash(git clone*)", + "Bash(git commit*)", + "Bash(git remote*)", + "Bash(git reset*)", + "Bash(git ls-remote*)", + "Bash(git checkout*)", + "Bash(git merge*)", + "Bash(git rebase*)", + "Bash(git cherry-pick*)", + "Bash(git revert*)", + "Bash(git update-ref*)", + "Bash(git tag -d*)", + "Bash(git reflog delete*)", +) +_DESTRUCTIVE_DENY: tuple[str, ...] = ("Bash(rm -rf*)",) +_RAW_PM_DENY: tuple[str, ...] = ( + "Bash(uv run*)", + "Bash(uv sync*)", + "Bash(uv pip install*)", + "Bash(uv pip uninstall*)", + "Bash(uv lock*)", + "Bash(uv add*)", + "Bash(uv remove*)", + "Bash(pip install*)", + "Bash(pip3 install*)", + "Bash(pip uninstall*)", + "Bash(conda install*)", + "Bash(conda create*)", + "Bash(conda run*)", + "Bash(poetry run*)", + "Bash(poetry install*)", + "Bash(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 permission_rules_for_role(role: str) -> list[dict[str, str]]: + """The ``[[permission.rules]]`` entries (as dicts) gating one role. + + Deny-only — ``-p`` auto-approves everything the rules below don't + explicitly deny. Fleet-wide denies apply to every role; a non-bash-capable + role gets a blanket ``Bash`` deny (closing the Write-via-Bash bypass, so + it needs no command-scoped git/destructive/raw-PM rules underneath); a + bash-capable role keeps the shell but gets the git-mutation/destructive/ + raw-PM prefix denies. Non-author roles (``role_config.allows_write`` is + False) additionally get ``Write``/``Edit`` denied. + """ + rules: list[dict[str, str]] = [ + {"pattern": pattern, "decision": "deny"} for pattern in _FLEET_WIDE_DENY + ] + if not _allows_write(role): + rules.append({"pattern": "Write", "decision": "deny"}) + rules.append({"pattern": "Edit", "decision": "deny"}) + if role not in _BASH_ROLES: + rules.append({"pattern": "Bash", "decision": "deny"}) + return rules + for pattern in (*_DESTRUCTIVE_DENY, *_GIT_MUTATE_DENY, *_RAW_PM_DENY): + rules.append({"pattern": pattern, "decision": "deny"}) + return rules + + +def kimi_hooks_config( + hook_path: str = KIMI_BASH_GUARD_WRAPPER, +) -> list[dict[str, Any]]: + """The ``[[hooks]]`` entries installing the bash-guard as a PreToolUse hook. + + A ``[[hooks]]`` entry only tolerates ``event``/``matcher``/``command``/ + ``timeout`` — an ``env`` key (or any other extra field) makes the CLI + silently drop the WHOLE ``hooks`` section (live-verified: "Ignored + invalid config ... hooks", run continues with NO hooks installed at + all). ``ROBOCO_GUARD_SKIP_GIT=1`` therefore rides + ``kimi-bash-guard-wrapper.sh`` (its own ``export`` before exec'ing the + real hook) instead of an ``env`` field — git ops stay on the graceful + deny rules above (see :func:`permission_rules_for_role`); the hook + covers the credential-exfil/identity-forgery/env-dump categories the + deny-rule globs don't reach. A hook deny is graceful on kimi + (live-verified: "Blocked by PreToolUse hook", run continues) — a + tripwire, never the sole boundary. + """ + return [ + { + "event": "PreToolUse", + "matcher": "Bash", + "command": hook_path, + } + ] + + +def render_config_toml(role: str) -> str: + """Render ``config.toml``: managed blocks + telemetry/upgrade + per-role + permission rules + the bash-guard hook. + + The managed provider/model/service blocks are constants (see module + docstring) — never read off a mounted host file, since the symlink step + deliberately carries forward only ``credentials/`` and ``oauth/`` (see + :mod:`roboco.llm.providers.kimi`). + """ + config: dict[str, Any] = { + "default_model": _MANAGED_DEFAULT_MODEL, + # Runtime self-update is suppressed at the image + env level + # (KIMI_CODE_NO_AUTO_UPDATE=1); this belt-and-suspenders config knob + # keeps the CLI from even checking, and telemetry is off fleet-wide. + "telemetry": False, + "upgrade": {"auto_install": False}, + "providers": { + _MANAGED_PROVIDER_KEY: { + "type": "kimi", + "base_url": _MANAGED_BASE_URL, + "oauth": dict(_MANAGED_OAUTH), + } + }, + "models": {alias: dict(fields) for alias, fields in _KIMI_MODELS.items()}, + "services": { + "moonshot_search": {"type": "kimi", "oauth": dict(_MANAGED_OAUTH)}, + "moonshot_fetch": {"type": "kimi", "oauth": dict(_MANAGED_OAUTH)}, + }, + "permission": {"rules": permission_rules_for_role(role)}, + "hooks": kimi_hooks_config(), + } + return tomli_w.dumps(config) + + +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 render_mcp_json(mcp_config: dict[str, Any]) -> str: + """Render ``mcp.json`` — a near passthrough of the mounted mcp-config.json. + + Kimi's ``mcpServers`` schema (``command``/``args``/``env`` keyed by + server name) is Claude-identical (live-verified: tool namespacing is the + same ``mcp____`` shape too), so this is a structural + reshape rather than a translation — unlike grok's TOML ``[mcp_servers]`` + or codex's config.toml block. + """ + 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 json.dumps({"mcpServers": servers}, indent=2) + + +def write_agents_md( + *, source: Path = SYSTEM_PROMPT_PATH, dest: Path = KIMI_AGENTS_MD_PATH +) -> bool: + """Install the mounted role blueprint as kimi's global AGENTS.md. + + Copies the composed prompt to ``$KIMI_CODE_HOME/AGENTS.md`` (additive, + live-verified headless-honored — the grok-proven mechanism). 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 + + +# --- Auth preflight (D2 branch (a): no refresh loop, a plain expiry read) -- + + +def _parse_expires_at(value: object) -> datetime | None: + """Parse ``credentials/kimi-code.json``'s ``expires_at`` field. + + A plain JSON field (no JWT decode, unlike codex's access-token exp + claim) — tolerates either a unix-epoch number or an ISO-8601 string, + since the exact wire representation wasn't pinned down beyond "a real + expires_at sibling" in the spike. + """ + if isinstance(value, int | float): + return datetime.fromtimestamp(float(value), tz=UTC) + if isinstance(value, str): + with contextlib.suppress(ValueError): + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC) + return None + + +def seconds_until_expiry( + creds_path: Path = KIMI_CREDENTIALS_PATH, *, now: datetime | None = None +) -> float | None: + """Seconds until the Kimi credential expires, or ``None`` if unreadable/absent.""" + try: + data = json.loads(creds_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if not isinstance(data, dict): + return None + expires_at = _parse_expires_at(data.get("expires_at")) + if expires_at is None: + return None + return (expires_at - (now or datetime.now(UTC))).total_seconds() + + +def is_valid( + creds_path: Path = KIMI_CREDENTIALS_PATH, + *, + skew_seconds: int = 0, + now: datetime | None = None, +) -> bool: + """True when the symlinked-in shared credential exists and has more than + ``skew_seconds`` of life left. No orchestrator refresh loop exists for + Kimi (D2 resolved rotation-with-short-reuse-grace over ONE shared RW + auth mount, not per-container copies — each container self-refreshes + through the CLI's own cross-process lock, see + :mod:`roboco.llm.providers.kimi`); this is purely the entrypoint's + fail-fast backstop.""" + remaining = seconds_until_expiry(creds_path, now=now) + return remaining is not None and remaining > skew_seconds + + +def main(argv: list[str] | None = None) -> int: + """Entrypoint: ``--check`` runs the auth preflight; else renders config.toml + + mcp.json + AGENTS.md.""" + # Pass the module globals explicitly (not relying on is_valid's / + # write_agents_md'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). + args = argv if argv is not None else sys.argv[1:] + if "--check" in args: + return 0 if is_valid(KIMI_CREDENTIALS_PATH) else 1 + + 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 "" + + KIMI_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + KIMI_CONFIG_PATH.write_text(render_config_toml(role), encoding="utf-8") + KIMI_MCP_PATH.write_text( + render_mcp_json(_load_mcp_config(mcp_path)), encoding="utf-8" + ) + write_agents_md(source=SYSTEM_PROMPT_PATH, dest=KIMI_AGENTS_MD_PATH) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/roboco/llm/providers/kimi_cli_sniff.py b/roboco/llm/providers/kimi_cli_sniff.py new file mode 100644 index 00000000..2b51bdf9 --- /dev/null +++ b/roboco/llm/providers/kimi_cli_sniff.py @@ -0,0 +1,150 @@ +"""Classify a Kimi CLI run's terminal state from ONLY its machine-relevant +output — never the full transcript. + +Kimi has no documented exit-code taxonomy for ``kimi -p`` (a claimed 75/1 +split is unverified and suspiciously matches RoboCo's own park-exit +convention — treated as noise, not a real CLI contract). The entrypoint must +therefore sniff the run's output to tell a Moonshot rate-limit/quota error or +a membership/auth failure apart from any other error. Sniffing the FULL +captured stream-json stdout is unsafe: the model's own on-topic prose can +false-positive by construction — this repo's own role prompts use words like +"quota-limited", and a commit hash or item id can contain the substring +"429" (:mod:`roboco.llm.providers.codex_cli_sniff` documents this failure +class in detail — it is the template this module mirrors). + +The fix is structural, not a pattern tweak: extract ONLY a structured +``error`` field off any JSONL event that carries one (whichever of the few +plausible shapes it takes — a nested ``{"error": {"message": ...}}``, a bare +``{"error": "..."}`` string, or an ``{"type"/"role": "error", "message": +...}`` event) plus the run's raw stderr, and sniff THAT text. The model's own +echoed assistant/tool content (``{"role": "assistant", ...}`` / +``{"role": "tool", ...}``) can never reach the classifier, so it can never +trigger a false park by construction. + +Patterns (live-verified error text from the spike, plus the codex-proven +word-boundaried digit guard): + - rate-limit: "status code: 429", a bare ``\\b429\\b``, "engine is + currently overloaded", "usage limit for this period" / "usage limit for + this billing cycle" (a 403 quota-exhaustion, classified as rate_limit + per the same "try again later" semantics as a 429). + - auth failure: "API Key appears to be invalid" (401), "unable to verify + your membership benefits" (the live-verified subscription-gate message), + a bare ``\\b401\\b``. + +The entrypoint calls this as ``python -m roboco.llm.providers.kimi_cli_sniff + [err_log]``, printing ``rate_limit`` / ``auth`` / an empty line. +""" + +from __future__ import annotations + +import contextlib +import json +import re +import sys +from pathlib import Path +from typing import Any + +_RATE_LIMIT_PATTERN = re.compile( + r"(status code:\s*429|\b429\b|engine is currently overloaded|" + r"usage limit for this (?:period|billing cycle))", + re.IGNORECASE, +) +_AUTH_FAILURE_PATTERN = re.compile( + r"(api key appears to be invalid|" + r"unable to verify your membership benefits|\b401\b)", + re.IGNORECASE, +) + + +def _error_text_from_event(event: dict[str, Any]) -> str | None: + """Pull a structured error message off one JSONL event, or ``None``. + + Tolerates the few plausible shapes an error-bearing event could take + (kimi's real error-event schema wasn't pinned down beyond the raw + membership/rate-limit message text itself) — never reads + ``content``/``text`` off an ``assistant``/``tool`` event. + """ + error = event.get("error") + if isinstance(error, dict): + message = error.get("message") + return message if isinstance(message, str) and message else None + if isinstance(error, str) and error: + return error + if event.get("type") == "error" or event.get("role") == "error": + message = event.get("message") + return message if isinstance(message, str) and message else None + return None + + +def extract_error_text(run_log: Path) -> str: + """Pull ONLY structured error text from JSONL events in *run_log*. + + Every other event (``role: assistant`` / ``role: tool`` / the terminal + ``role: meta`` line, ...) is ignored regardless of its content — the + model's own prose never reaches this text. Best-effort: a missing/ + unreadable file returns "". + """ + messages: list[str] = [] + try: + with run_log.open(encoding="utf-8") as fh: + for raw in fh: + text = raw.strip() + if not text: + continue + try: + event: Any = json.loads(text) + except json.JSONDecodeError: + continue + if not isinstance(event, dict): + continue + message = _error_text_from_event(event) + if message: + messages.append(message) + except OSError: + return "" + return "\n".join(messages) + + +def is_rate_limited(text: str) -> bool: + """True if the (already-extracted, machine-only) *text* names a + 429/overload/usage-limit error.""" + return bool(_RATE_LIMIT_PATTERN.search(text)) + + +def is_auth_failure(text: str) -> bool: + """True if the (already-extracted, machine-only) *text* names an + auth/membership failure.""" + return bool(_AUTH_FAILURE_PATTERN.search(text)) + + +def classify(run_log: Path, err_log: Path | None = None) -> str: + """Return ``"rate_limit"`` / ``"auth"`` / ``""`` for a captured Kimi run. + + Sniffs ONLY the extracted JSONL error text plus the raw stderr — never + the full stdout transcript (see module docstring). + """ + text = extract_error_text(run_log) + if err_log is not None: + with contextlib.suppress(OSError): + text = f"{text}\n{err_log.read_text(encoding='utf-8')}" + if is_rate_limited(text): + return "rate_limit" + if is_auth_failure(text): + return "auth" + return "" + + +def main(argv: list[str] | None = None) -> int: + """CLI: prints the classification for `` [err_log]``.""" + args = argv if argv is not None else sys.argv[1:] + if not args: + print("") + return 0 + run_log = Path(args[0]) + err_log = Path(args[1]) if len(args) > 1 else None + print(classify(run_log, err_log)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/roboco/llm/providers/kimi_cli_usage.py b/roboco/llm/providers/kimi_cli_usage.py new file mode 100644 index 00000000..a0ce064b --- /dev/null +++ b/roboco/llm/providers/kimi_cli_usage.py @@ -0,0 +1,280 @@ +"""Capture token usage from a Kimi CLI run for the usage / cost dashboard. + +Unlike codex/gemini (usage summarized directly in their own captured +stdout), kimi reports NOTHING usage-shaped in ``kimi -p --output-format +stream-json``'s stdout — only assistant/tool messages and a terminal +``{"role": "meta", "type": "session.resume_hint", "session_id": ...}`` line. +The real usage lives on disk, per session, at +``$KIMI_CODE_HOME/sessions///agents/main/wire.jsonl`` +(``workDirKey`` = ``wd__``, live-verified) as +``{"type": "usage.record", "model": ..., "usageScope": "turn", "usage": +{"inputOther", "output", "inputCacheRead", "inputCacheCreation"}}`` events — +a genuine 4-bucket split (unlike grok's output-only fallback; parity with +codex's real input/output/cache split, see +:mod:`roboco.llm.providers.codex_cli_usage`, the template this module +mirrors for the 4-bucket ``usage.json`` write). + +Session id resolution: the PRIMARY source is the run's own captured stdout +(the terminal ``session.resume_hint`` line) — no file scraping for the id +itself, unlike grok. When that's absent (a crashed run that never reached +the terminal event), the FALLBACK is the newest session directory under the +workdir-keyed ``sessions/wd__*/`` glob — the exact hash suffix +of ``workDirKey`` isn't reproducible without the CLI's own hash function, so +this globs on the cwd-basename prefix instead of computing it. + +``inputOther``/``inputCacheRead``/``inputCacheCreation`` are already +disjoint buckets (unlike codex's ``cached_input_tokens``, which is a SUBSET +of ``input_tokens``) — the field name ``inputOther`` ("input, other than +cached") is deliberately not-"input", so no subtraction is needed before +pricing. + +The agent entrypoint runs ``python -m roboco.llm.providers.kimi_cli_usage`` +after the run to write ``usage.json`` (the same grok-shaped 4-bucket shape +codex writes) into a per-agent dir the orchestrator reads back at finalize. +""" + +from __future__ import annotations + +import json +import logging +import os +import tempfile +from pathlib import Path + +from roboco.billing.pricing import calculate_cost + +logger = logging.getLogger(__name__) + +# Where the entrypoint writes the captured usage for the orchestrator to read. +USAGE_OUT_PATH = Path( + os.environ.get("ROBOCO_KIMI_USAGE_FILE") + or Path(tempfile.gettempdir()) / "roboco-kimi-usage.json" +) + +# kimi's global state dir (see roboco.llm.providers.kimi_cli_config). +KIMI_CODE_HOME = Path.home() / ".kimi-code" + +_DEFAULT_MODEL = "kimi-code/k3" +_USAGE_RECORD_TYPE = "usage.record" +_TURN_SCOPE = "turn" +_USAGE_FIELDS = ("inputOther", "output", "inputCacheRead", "inputCacheCreation") + + +def _as_int(value: object) -> int: + return int(value) if isinstance(value, int | float) else 0 + + +def _session_id_from_event(event: dict) -> str | None: + """Pull the session id off one ``session.resume_hint`` meta event, or + ``None`` for any other event type.""" + if event.get("role") != "meta" or event.get("type") != "session.resume_hint": + return None + sid = event.get("session_id") + return sid if isinstance(sid, str) and sid else None + + +def session_id_from_run_log(run_log: Path) -> str | None: + """Extract the session id from the run's terminal ``session.resume_hint`` + meta line. Returns the LAST matching line's id (a crashed/resumed run + could in principle emit more than one); ``None`` on a missing/unreadable/ + id-less log.""" + found: str | None = None + try: + with run_log.open(encoding="utf-8") as fh: + for raw in fh: + text = raw.strip() + if not text: + continue + try: + event = json.loads(text) + except json.JSONDecodeError: + continue + if not isinstance(event, dict): + continue + sid = _session_id_from_event(event) + if sid: + found = sid + except OSError: + return None + return found + + +def _cwd_basename(workdir: str) -> str: + return Path(workdir).name or "workspace" + + +def _find_session_by_id( + sessions_root: Path, workdir_pattern: str, session_id: str +) -> Path | None: + """The known ``session_id`` under any ``wd__*`` workdir key + (the hash suffix isn't reproducible client-side, so this globs the + prefix).""" + matches = list(sessions_root.glob(f"{workdir_pattern}/{session_id}")) + return matches[0] if matches else None + + +def _newest_session_dir(sessions_root: Path, workdir_pattern: str) -> Path | None: + """The most-recently-modified session dir under any matching workdir key.""" + candidates = [ + session_dir + for wd_dir in sessions_root.glob(workdir_pattern) + if wd_dir.is_dir() + for session_dir in wd_dir.iterdir() + if session_dir.is_dir() + ] + return max(candidates, key=lambda d: d.stat().st_mtime) if candidates else None + + +def resolve_session_dir( + *, + session_id: str | None, + workdir: str, + kimi_code_home: Path = KIMI_CODE_HOME, +) -> Path | None: + """Locate the session directory for this run. + + Primary: :func:`_find_session_by_id`. Fallback: :func:`_newest_session_dir`. + Returns ``None`` when nothing matches. + """ + sessions_root = kimi_code_home / "sessions" + if not sessions_root.is_dir(): + return None + workdir_pattern = f"wd_{_cwd_basename(workdir)}_*" + if session_id: + found = _find_session_by_id(sessions_root, workdir_pattern, session_id) + if found is not None: + return found + return _newest_session_dir(sessions_root, workdir_pattern) + + +def _usage_from_wire_event(event: dict) -> dict[str, int] | None: + """Pull the raw usage fields off one turn-scoped ``usage.record`` wire + event, or ``None`` for any other event (``llm.request``, a session-scoped + record, ...).""" + if event.get("type") != _USAGE_RECORD_TYPE or event.get("usageScope") != ( + _TURN_SCOPE + ): + return None + usage = event.get("usage") + if not isinstance(usage, dict): + return None + return {field: _as_int(usage.get(field, 0)) for field in _USAGE_FIELDS} + + +def aggregate_usage_from_wire(wire_log: Path) -> dict[str, int]: + """Sum ``usage.record`` (``usageScope == "turn"``) events in a session's + ``wire.jsonl``. Returns the summed raw fields plus ``turns`` (the event + count). Best-effort: a missing/unreadable/empty file returns all zeros. + """ + totals = dict.fromkeys(_USAGE_FIELDS, 0) + turns = 0 + try: + with wire_log.open(encoding="utf-8") as fh: + for raw in fh: + text = raw.strip() + if not text: + continue + try: + event = json.loads(text) + except json.JSONDecodeError: + continue + if not isinstance(event, dict): + continue + usage = _usage_from_wire_event(event) + if usage is None: + continue + turns += 1 + for field in _USAGE_FIELDS: + totals[field] += usage[field] + except OSError: + pass + totals["turns"] = turns + return totals + + +def capture_run_usage( + *, + run_log: Path, + workdir: str, + model: str, + out_path: Path, + kimi_code_home: Path = KIMI_CODE_HOME, +) -> tuple[int, int, int, int]: + """Write ``usage.json`` for one kimi run; return the token 4-tuple. + + Best-effort: never raises (returns all zeros and writes nothing on any + IO/lookup failure). + """ + try: + session_id = session_id_from_run_log(run_log) + session_dir = resolve_session_dir( + session_id=session_id, workdir=workdir, kimi_code_home=kimi_code_home + ) + agg: dict[str, int] = ( + aggregate_usage_from_wire(session_dir / "agents" / "main" / "wire.jsonl") + if session_dir is not None + else {**dict.fromkeys(_USAGE_FIELDS, 0), "turns": 0} + ) + tin = agg["inputOther"] + tout = agg["output"] + cache_read = agg["inputCacheRead"] + cache_write = agg["inputCacheCreation"] + cost = calculate_cost( + model, + tokens_input=tin, + tokens_output=tout, + tokens_cache_read=cache_read, + tokens_cache_write=cache_write, + ) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text( + json.dumps( + { + "model": model, + "tokens_input": tin, + "tokens_output": tout, + "tokens_cache_read": cache_read, + "tokens_cache_write": cache_write, + "cost_usd": cost, + "turns": agg.get("turns", 0), + } + ), + encoding="utf-8", + ) + return tin, tout, cache_read, cache_write + except OSError: + return 0, 0, 0, 0 + + +def main() -> int: + """Entrypoint: write ``usage.json`` (tokens split + cost) for the run.""" + model = os.environ.get("ROBOCO_AGENT_MODEL", _DEFAULT_MODEL) + run_log = os.environ.get("ROBOCO_KIMI_RUN_LOG", "") + workdir = os.environ.get("ROBOCO_KIMI_WORKDIR", "") + if not run_log: + logger.warning("ROBOCO_KIMI_RUN_LOG not set; usage will read 0") + return 0 + # kimi_code_home passed explicitly (not relying on capture_run_usage's own + # default, which binds at function-definition time and would go stale if + # a caller reassigns the module global after import — a real gap for e.g. + # a test module monkeypatching it post-import). + tin, tout, _cr, _cw = capture_run_usage( + run_log=Path(run_log), + workdir=workdir, + model=model, + out_path=USAGE_OUT_PATH, + kimi_code_home=KIMI_CODE_HOME, + ) + if not tin and not tout: + logger.warning( + "kimi agent finalized with no readable usage " + "(0 tokens / $0) — check the sessions mount / workdir env: " + "run_log=%s workdir=%s", + run_log, + workdir, + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/roboco/models/base.py b/roboco/models/base.py index a740eca6..9c9b4452 100644 --- a/roboco/models/base.py +++ b/roboco/models/base.py @@ -200,6 +200,13 @@ class ModelProvider(StrEnum): API key — the same subscription-style auth shape as GROK. Routes through a dedicated provider (roboco.llm.providers.gemini), never ANTHROPIC_BASE_URL injection. + `KIMI` is Moonshot AI's Kimi K3 via the official `kimi` (kimi-code) CLI, + authenticated by a Kimi subscription (OAuth device-code login, mounted + `~/.kimi-code/credentials/kimi-code.json`) rather than a metered key — the + same subscription-style auth shape as GROK/GEMINI. Routes through a + dedicated provider (roboco.llm.providers.kimi.KimiCliProvider), never + ANTHROPIC_BASE_URL injection. One-shot delivery roles only — no + interactive intake/secretary support. """ ANTHROPIC = "anthropic" @@ -208,6 +215,7 @@ class ModelProvider(StrEnum): LOCAL = "local" GROK = "grok" GEMINI = "gemini" + KIMI = "kimi" class AssignmentScope(StrEnum): diff --git a/roboco/models/llm_catalog.py b/roboco/models/llm_catalog.py index f7a8ca9c..9458f158 100644 --- a/roboco/models/llm_catalog.py +++ b/roboco/models/llm_catalog.py @@ -94,6 +94,22 @@ MODEL_CATALOG: tuple[CatalogEntry, ...] = ( CatalogEntry( "gemini-2.5-flash-lite", ModelProvider.GEMINI, "Gemini 2.5 Flash Lite" ), + # --- Kimi (Moonshot, official kimi/kimi-code CLI) --- + # Routes to the KIMI provider → KimiCliProvider spawn. Subscription auth + # (~/.kimi-code, from `kimi login`), no metered API key — parity with + # Grok/Codex. Aliases are namespaced under the login-managed "kimi-code" + # provider (see roboco.llm.providers.kimi_cli_config); default + # (ROBOCO_KIMI_CLI_MODEL) is k3 first, down to the cheaper K2.7 Code tier. + CatalogEntry("kimi-code/k3", ModelProvider.KIMI, "Kimi K3"), + CatalogEntry("kimi-code/k3-256k", ModelProvider.KIMI, "Kimi K3 (256k)"), + CatalogEntry( + "kimi-code/kimi-for-coding", ModelProvider.KIMI, "Kimi for Coding (K2.7)" + ), + CatalogEntry( + "kimi-code/kimi-for-coding-highspeed", + ModelProvider.KIMI, + "Kimi for Coding HighSpeed (K2.7)", + ), ) diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index a62bbda9..e9235b50 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -302,14 +302,15 @@ _INTAKE_WORKSPACE_AMBIENT = ( # time. Seeded in identity.AGENTS; see roboco/agent_sdk/secretary_main.py. SECRETARY_AGENT_ID = "secretary-1" -# Codex (OPENAI) and Gemini (GEMINI) are V1 delivery-roles-only (see -# roboco.llm.providers.codex / .gemini module docstrings) — neither supports -# the persistent interactive Intake/Secretary session (no CLI-flag equivalent -# to grok's --disallowed-tools/deny, no interactive-session driver image). +# Codex (OPENAI), Gemini (GEMINI), and Kimi (KIMI) are V1 delivery-roles-only +# (see roboco.llm.providers.codex / .gemini / .kimi module docstrings) — +# none supports the persistent interactive Intake/Secretary session (no +# CLI-flag equivalent to grok's --disallowed-tools/deny, no +# interactive-session driver image). # Unlike GROK (which has its own GROK_PROMPTER_IMAGE / GROK_SECRETARY_IMAGE), -# routing either of these to Intake/Secretary would fall through to the plain +# routing any of these to Intake/Secretary would fall through to the plain # Claude SDK-driver image with a mismatched provider env instead of refusing — -# so both spawn paths reject it explicitly instead of silently misbehaving. +# so all three spawn paths reject it explicitly instead of silently misbehaving. # Mirrors roboco.services.llm.INTERACTIVE_UNSUPPORTED_PROVIDERS (kept as a # literal here to avoid a runtime import cycle; parity is pinned by a test). # The resolver exempts interactive agents from GLOBAL/ROLE rows on these @@ -319,6 +320,7 @@ SECRETARY_AGENT_ID = "secretary-1" _INTERACTIVE_UNSUPPORTED_PROVIDERS: tuple[ModelProvider, ...] = ( ModelProvider.OPENAI, ModelProvider.GEMINI, + ModelProvider.KIMI, ) @@ -500,6 +502,26 @@ _GEMINI_REPARK_EPISODE_GAP_S = 1500.0 # host, exactly like grok's own auth-exit park. _GEMINI_AUTH_EXIT_CODE = 41 +# In-orchestrator path where each KIMI agent's usage capture is visible — the +# kimi analogue of GEMINI_USAGE_DATA_DIR (see there for the mount shape). +KIMI_USAGE_DATA_DIR = os.environ.get("ROBOCO_KIMI_USAGE_DIR", "/data/kimi-usage") + +# A one-shot Kimi container exits with these SAME codes for the SAME reasons +# (its entrypoint mirrors the codex/grok exit-code convention — see +# docker/scripts/kimi-cli-agent-entrypoint.sh): 75 (EX_TEMPFAIL) on a +# detected Moonshot rate-limit/quota error, 78 (EX_CONFIG) when the +# kimi_cli_config --check auth preflight finds the symlinked-in subscription +# credential missing/expired. Numeric reuse is fine — the checks are scoped +# by provider_type (ModelProvider.KIMI vs .OPENAI/.GROK), never by exit code +# alone. Flat retry_after (no exponential repark backoff, mirroring codex's +# simplicity — add backoff bookkeeping if Kimi is observed re-parking in a +# tight cycle in practice), but the retry_after itself is a tunable Settings +# field (gemini's pattern), not a hardcoded module constant — Moonshot's +# request-counted 5h quota window may want a different cadence than the flat +# 60s codex/gemini default. +_KIMI_RATE_LIMIT_EXIT_CODE = 75 +_KIMI_AUTH_EXIT_CODE = 78 + # ============================================================================= # ORCHESTRATOR @@ -1308,6 +1330,14 @@ class AgentOrchestrator: self._gemini_auth_retry_after_s: float = ( settings.gemini_auth_retry_after_seconds ) + # Configurable retry_after base for KIMI parks (gemini's tunable + # pattern, not codex's hardcoded module constants — see + # _KIMI_RATE_LIMIT_EXIT_CODE). No repark-backoff bookkeeping (codex's + # simplicity): add it if Kimi is observed re-parking in a tight cycle. + self._kimi_rate_limit_retry_after_s: float = ( + settings.kimi_rate_limit_retry_after_seconds + ) + self._kimi_auth_retry_after_s: float = settings.kimi_auth_retry_after_seconds def _init_engine_loop_task_slots(self) -> None: """Task handles for the default-off engine loops. Split out of @@ -1805,6 +1835,47 @@ class AgentOrchestrator: error=str(exc), ) + @staticmethod + def _kimi_usage_root() -> Path: + """The base dir all per-agent kimi usage dirs live under (no agent id). + + Same compose-vs-local branch as :meth:`_grok_usage_root`. + """ + if PROJECT_HOST_PATH: + return Path(KIMI_USAGE_DATA_DIR) + return Path(tempfile.gettempdir()) / "roboco-kimi-usage" + + @staticmethod + def _kimi_usage_dir(agent_id: str) -> Path: + """Per-agent kimi usage dir under :meth:`_kimi_usage_root`. + + Single source of truth for BOTH the pre-create/mount side + (``_ensure_kimi_usage_dir``) and the finalize read side + (``_kimi_usage_json``), mirroring ``_grok_usage_dir``. + """ + return AgentOrchestrator._kimi_usage_root() / ( + AgentOrchestrator._safe_agent_path_segment(agent_id) + ) + + def _ensure_kimi_usage_dir(self, agent_id: str) -> None: + """Pre-create the agent's kimi usage dir (world-writable) before the mount. + + Same EACCES concern as ``_ensure_grok_usage_dir``: a missing bind + source is auto-created ``root:root`` on Linux, which the non-root + ``agent`` user can't write into. + """ + target = self._kimi_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 kimi usage dir; kimi 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: @@ -3129,6 +3200,8 @@ class AgentOrchestrator: # 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}", + # Per-agent kimi usage dir (KIMI only); same shape. + "kimi_usage": f"{DATA_HOST_PATH}/kimi-usage/{config.agent_id}", "prompt": ( f"{DATA_HOST_PATH}/prompts-generated/{config.agent_id}-prompt.md" ), @@ -3157,6 +3230,9 @@ class AgentOrchestrator: "gemini_usage": str( Path(tempfile.gettempdir()) / "roboco-gemini-usage" / config.agent_id ), + "kimi_usage": str( + Path(tempfile.gettempdir()) / "roboco-kimi-usage" / config.agent_id + ), "prompt": str( Path(tempfile.gettempdir()) / "roboco-prompts" @@ -3534,15 +3610,18 @@ class AgentOrchestrator: Only providers that need a runtime other than the built-in Claude Code container are registered. Today that is GROK (xAI, OpenAI protocol), - OPENAI (Codex CLI, subscription-shaped like GROK), and GEMINI (Google, + OPENAI (Codex CLI, subscription-shaped like GROK), GEMINI (Google, official CLI, one-shot delivery roles only — see - roboco.llm.providers.gemini for the V1 scope). + roboco.llm.providers.gemini for the V1 scope), and KIMI (Moonshot, + official CLI, one-shot delivery roles only — see + roboco.llm.providers.kimi for the V1 scope). """ if self._provider_registry is None: from roboco.llm.providers import ( CodexCliProvider, GeminiCliProvider, GrokCliProvider, + KimiCliProvider, ProviderRegistry, ) from roboco.models.base import ModelProvider @@ -3567,6 +3646,10 @@ class AgentOrchestrator: self, image=_qualify_agent_image("roboco-agent-gemini") ), ) + registry.register( + ModelProvider.KIMI, + KimiCliProvider(self, image=_qualify_agent_image("roboco-agent-kimi")), + ) self._provider_registry = registry return self._provider_registry @@ -6590,6 +6673,15 @@ class AgentOrchestrator: """ return self._read_usage_json_contained(self._gemini_usage_root(), agent_id) + def _kimi_usage_json(self, agent_id: str) -> dict[str, Any] | None: + """Read a KIMI agent's ``usage.json`` (mirrors ``_codex_usage_json``). + + Written by the kimi-cli entrypoint (one-shot, post-run) to the + per-agent dir under ``_kimi_usage_dir``. Returns ``None`` when + absent / unreadable. + """ + return self._read_usage_json_contained(self._kimi_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``. @@ -6633,6 +6725,50 @@ class AgentOrchestrator: except (TypeError, ValueError): return 0 + def _kimi_usage_tokens(self, agent_id: str) -> tuple[int, int, int, int]: + """A KIMI agent's token usage from its ``usage.json``. + + Kimi's ``wire.jsonl`` carries a real, already-disjoint 4-bucket split + (see ``kimi_cli_usage``), so this returns the genuine tuple instead of + folding everything into output (parity with ``_codex_usage_tokens``). + A WARNING logs on a missing/zero read (a silent mount/uid failure is + otherwise indistinguishable from a genuine zero-cost run). + """ + data = self._kimi_usage_json(agent_id) + tokens = (0, 0, 0, 0) + if data: + try: + tokens = ( + int(data.get("tokens_input", 0)), + int(data.get("tokens_output", 0)), + int(data.get("tokens_cache_read", 0)), + int(data.get("tokens_cache_write", 0)), + ) + except (TypeError, ValueError): + tokens = (0, 0, 0, 0) + if not tokens[0] and not tokens[1]: + logger.warning( + "KIMI agent finalized with no readable usage " + "(0 tokens / $0) — check the sessions dir mount", + agent_id=agent_id, + ) + return tokens + + def _kimi_usage_turns(self, agent_id: str) -> int: + """A KIMI agent's turn count from its ``usage.json`` (0 if none). + + Kimi's wire.jsonl carries a real per-turn ``usage.record`` count + (see ``kimi_cli_usage``), the same parity ``_codex_usage_turns`` has + over grok's turn-less usage.json. + """ + data = self._kimi_usage_json(agent_id) + if not data: + return 0 + try: + return int(data.get("turns", 0)) + 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``. @@ -6735,7 +6871,7 @@ class AgentOrchestrator: ) -> tuple[int, int, int, int]: """Resolve final token counts for a stopping agent. - For a GROK, OPENAI (codex), or GEMINI agent, reads the captured + For a GROK, OPENAI (codex), GEMINI, or KIMI 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 @@ -6746,13 +6882,28 @@ class AgentOrchestrator: from roboco.models.base import ModelProvider provider = self.get_provider_for_agent(agent_id) - if provider == ModelProvider.GROK.value: - 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) + # One-shot CLIs (no SDK server / Claude transcript) each read their own + # captured usage.json — collapsed into a lookup (mirrors + # _resolve_active_tokens's usage_json_readers) so a fourth such + # provider is one dict entry, not another branch (keeps this + # function's xenon budget flat). + usage_json_readers = { + ModelProvider.GROK.value: self._grok_usage_tokens, + ModelProvider.OPENAI.value: self._codex_usage_tokens, + ModelProvider.GEMINI.value: self._gemini_usage_tokens, + ModelProvider.KIMI.value: self._kimi_usage_tokens, + } + read_usage_json = usage_json_readers.get(provider) if provider else None + if read_usage_json is not None: + return read_usage_json(agent_id) + return await self._resolve_final_token_usage_from_sdk(agent_id) + async def _resolve_final_token_usage_from_sdk( + self, agent_id: str + ) -> tuple[int, int, int, int]: + """SDK ``/usage/status`` + Claude-transcript fallback for a Claude-path + agent — split out of ``_resolve_final_token_usage`` to keep its own + xenon budget flat as one-shot-CLI providers accrete.""" tokens = (0, 0, 0, 0) sdk_url = f"http://roboco-agent-{agent_id}:{SDK_PORT}/usage/status" try: @@ -6791,9 +6942,10 @@ class AgentOrchestrator: 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 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. + ``(0, 0)``. Codex and Kimi agents each have a real per-turn count + (from their own 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 @@ -6802,6 +6954,8 @@ class AgentOrchestrator: return (0, 0) if provider == ModelProvider.OPENAI.value: return (self._codex_usage_turns(agent_id), 0) + if provider == ModelProvider.KIMI.value: + return (self._kimi_usage_turns(agent_id), 0) turns = tool_calls = 0 sdk_url = f"http://roboco-agent-{agent_id}:{SDK_PORT}/usage/status" @@ -6990,10 +7144,10 @@ 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) / 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 + source has any usage yet. GROK / OPENAI (codex) / GEMINI / KIMI 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/kimi 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). """ @@ -7004,12 +7158,13 @@ class AgentOrchestrator: 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 + # captured usage.json — collapsed into a lookup so a fourth 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, + ModelProvider.KIMI.value: self._kimi_usage_tokens, } read_usage_json = usage_json_readers.get(provider) if provider else None if read_usage_json is not None: @@ -8625,11 +8780,12 @@ Start by: ) -> 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. + Grok, Codex, Gemini, and Kimi 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), @@ -8638,6 +8794,8 @@ Start by: (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), + (self._is_kimi_rate_limit_exit, self._park_kimi_rate_limited), + (self._is_kimi_auth_exit, self._park_kimi_auth_unavailable), ) for is_exit, park in checks: if is_exit(instance, exit_code): @@ -8658,11 +8816,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/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). + # Grok/Codex/Gemini/Kimi 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 8 + # 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 @@ -10113,6 +10272,33 @@ Start by: and instance.config.provider_type == ModelProvider.GEMINI.value ) + @staticmethod + def _is_kimi_rate_limit_exit(instance: Any, exit_code: int | None) -> bool: + """True for a one-shot kimi container that exited 75 (Moonshot 429/quota).""" + from roboco.models.base import ModelProvider + + return ( + exit_code == _KIMI_RATE_LIMIT_EXIT_CODE + and instance.config is not None + and instance.config.provider_type == ModelProvider.KIMI.value + ) + + @staticmethod + def _is_kimi_auth_exit(instance: Any, exit_code: int | None) -> bool: + """True for a one-shot kimi container that exited 78 (auth missing/expired). + + The entrypoint runs ``kimi_cli_config --check`` as a backstop and + exits 78 when the symlinked-in subscription credential is missing or + expired — see ``_KIMI_AUTH_EXIT_CODE``. + """ + from roboco.models.base import ModelProvider + + return ( + exit_code == _KIMI_AUTH_EXIT_CODE + and instance.config is not None + and instance.config.provider_type == ModelProvider.KIMI.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. @@ -10430,6 +10616,49 @@ Start by: kind="auth_missing", ) + async def _park_kimi_rate_limited(self, agent_id: str, instance: Any) -> None: + """Park a kimi agent whose run hit a Moonshot 429/quota (entrypoint exit 75). + + Flat retry_after (no exponential re-park backoff like grok's/gemini's + — see ``_KIMI_RATE_LIMIT_EXIT_CODE``): add the same backoff + bookkeeping if Kimi is observed re-parking in a tight cycle in + practice. The base itself is a tunable Setting (gemini's pattern), + not a hardcoded constant (codex's pattern). + """ + from roboco.models.base import ModelProvider + + await self._park_provider_unavailable( + agent_id, + instance, + provider=ModelProvider.KIMI.value, + retry_after=getattr(self, "_kimi_rate_limit_retry_after_s", 60.0), + kind="rate_limited", + ) + + async def _park_kimi_auth_unavailable(self, agent_id: str, instance: Any) -> None: + """Park a kimi agent whose credential was missing/expired (entrypoint exit 78). + + Same park-and-probe shape as the codex auth path: the agent cannot + start without a valid credential, so crash-retrying burns tokens for + zero progress. Unlike codex/grok, no orchestrator-side refresher + daemon proactively mints a new token here — D2 resolved Kimi's + refresh token as rotation-with-short-reuse-grace over ONE shared RW + auth mount (symlinked into every container, not copied — see + roboco.llm.providers.kimi), refreshed IN-PROCESS by the CLI itself + through its own cross-process lock — so a genuinely bad/missing + credential re-parks flat until an operator fixes it on the host + (``kimi login``), exactly like gemini's own auth-exit park. + """ + from roboco.models.base import ModelProvider + + await self._park_provider_unavailable( + agent_id, + instance, + provider=ModelProvider.KIMI.value, + retry_after=getattr(self, "_kimi_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. diff --git a/roboco/services/llm.py b/roboco/services/llm.py index 8b046dea..780fdf4a 100644 --- a/roboco/services/llm.py +++ b/roboco/services/llm.py @@ -88,11 +88,13 @@ _COST_TIERED_SEED: tuple[tuple[str, str, str], ...] = () # row. A dict keeps derive_mode's branch count low (a chain of `if` returns # hits ruff's PLR0911 the moment a new provider is added, as GEMINI did). _SINGLE_GLOBAL_MODE_BY_PROVIDER: dict[ - ModelProvider, Literal["grok", "codex", "gemini", "ollama", "self_hosted"] + ModelProvider, + Literal["grok", "codex", "gemini", "kimi", "ollama", "self_hosted"], ] = { ModelProvider.GROK: "grok", ModelProvider.OPENAI: "codex", ModelProvider.GEMINI: "gemini", + ModelProvider.KIMI: "kimi", ModelProvider.OLLAMA_CLOUD: "ollama", ModelProvider.LOCAL: "self_hosted", } @@ -158,17 +160,19 @@ class _ResolvedAssignment: # Interactive agents (Intake chat, Secretary chat) have no V1 support on the -# Codex/Gemini providers. A GLOBAL/ROLE assignment pointing them there (e.g. -# the one-click Codex/Gemini mode) is treated as not-applicable at resolution -# time — they fall back to the legacy Anthropic path, so a fleet-wide mode -# switch always yields working chats. An EXPLICIT AGENT_SLUG pin is honored -# here and refused loudly by the orchestrator's spawn guard instead — a -# deliberate operator choice deserves an error, not a silent override. The -# orchestrator imports these as the single source of truth for that guard. +# Codex/Gemini/Kimi providers. A GLOBAL/ROLE assignment pointing them there +# (e.g. the one-click Codex/Gemini mode) is treated as not-applicable at +# resolution time — they fall back to the legacy Anthropic path, so a +# fleet-wide mode switch always yields working chats. An EXPLICIT AGENT_SLUG +# pin is honored here and refused loudly by the orchestrator's spawn guard +# instead — a deliberate operator choice deserves an error, not a silent +# override. The orchestrator imports these as the single source of truth for +# that guard. INTERACTIVE_AGENT_SLUGS: tuple[str, ...] = ("intake-1", "secretary-1") INTERACTIVE_UNSUPPORTED_PROVIDERS: tuple[ModelProvider, ...] = ( ModelProvider.OPENAI, ModelProvider.GEMINI, + ModelProvider.KIMI, ) @@ -447,16 +451,17 @@ class ModelRoutingService(BaseService): provider = await self._get_seeded_provider(entry.provider_type) provider_type_for_log = entry.provider_type - # Whenever an assignment resolves to LOCAL/GEMINI/OPENAI, ensure the - # provider row is enabled so resolve_for_agent() will actually use it - # instead of silently falling back to Anthropic. GROK is deliberately - # excluded — its enable state is gated on the xAI key - # (set_grok_api_key), unlike LOCAL/Codex/Gemini which have no key to - # gate on (self-hosted's own base_url + mounted-subscription auth). + # Whenever an assignment resolves to LOCAL/GEMINI/OPENAI/KIMI, ensure + # the provider row is enabled so resolve_for_agent() will actually use + # it instead of silently falling back to Anthropic. GROK is + # deliberately excluded — its enable state is gated on the xAI key + # (set_grok_api_key), unlike LOCAL/Codex/Gemini/Kimi which have no key + # to gate on (self-hosted's own base_url + mounted-subscription auth). if provider_type_for_log in ( ModelProvider.LOCAL, ModelProvider.GEMINI, ModelProvider.OPENAI, + ModelProvider.KIMI, ): provider_svc = ProviderService(self.session) await provider_svc.update_provider( @@ -489,7 +494,7 @@ class ModelRoutingService(BaseService): async def derive_mode( self, ) -> Literal[ - "anthropic", "grok", "codex", "gemini", "ollama", "mix", "self_hosted" + "anthropic", "grok", "codex", "gemini", "kimi", "ollama", "mix", "self_hosted" ]: """Return the current "mode" label for the Settings UI. @@ -500,6 +505,7 @@ class ModelRoutingService(BaseService): - only a global row, GROK → "grok" - only a global row, OPENAI → "codex" - only a global row, GEMINI → "gemini" + - only a global row, KIMI → "kimi" - anything else → "mix" """ assignments = await self.list_assignments() @@ -643,6 +649,10 @@ class ModelRoutingService(BaseService): provider, set the GLOBAL default to a Gemini model (default gemini-2.5-pro). No key check — subscription-CLI auth (~/.gemini), same shape as Grok/self_hosted. + - "kimi": wipe role/global assignments, force-enable the KIMI + provider, set the GLOBAL default to a Kimi model (default + kimi-code/k3). No key check — subscription-CLI auth + (~/.kimi-code), same shape as Codex/Gemini. - "mix": apply per-agent map verbatim. Any agent not in the map falls through to the GLOBAL default — which is whatever it was (preserves prior state). Self-hosted model names (not in the @@ -666,6 +676,8 @@ class ModelRoutingService(BaseService): await self._apply_codex(default_model) elif mode == "gemini": await self._apply_gemini(default_model) + elif mode == "kimi": + await self._apply_kimi(default_model) elif mode == "ollama": await self._apply_ollama(default_model) elif mode == "self_hosted": @@ -677,7 +689,7 @@ class ModelRoutingService(BaseService): else: raise ValueError( f"Unknown mode '{mode}'." - " Use 'anthropic', 'grok', 'codex', 'gemini', 'ollama'," + " Use 'anthropic', 'grok', 'codex', 'gemini', 'kimi', 'ollama'," " 'self_hosted', 'mix', or 'cost_tiered'." ) @@ -794,6 +806,32 @@ class ModelRoutingService(BaseService): ) self.log.info("Mode applied: gemini", default_model=model_name) + async def _apply_kimi(self, default_model: str | None) -> None: + """Wipe assignments, set the GLOBAL default to a Kimi (Moonshot) model. + + Migration 091 already seeds the KIMI provider row `enabled=true` + (there's no key to withhold behind a disabled row — subscription + auth via a shared, symlinked-in `~/.kimi-code` credential, same posture as + Codex), but this mode's own force-enable is belt-and-suspenders + against a row disabled by some other path, mirroring `_apply_codex`. + AGENT_SLUG pins and complexity overrides are preserved (see + `_wipe_mode_switch_assignments`). + """ + await self._wipe_mode_switch_assignments() + kimi = await self._get_seeded_provider(ModelProvider.KIMI) + provider_svc = ProviderService(self.session) + await provider_svc.update_provider( + require_uuid(kimi.id), + ProviderUpdate(enabled=True), + ) + model_name = default_model or "kimi-code/k3" + await self.upsert_assignment( + scope=AssignmentScope.GLOBAL, + scope_value=None, + model_name=model_name, + ) + self.log.info("Mode applied: kimi", default_model=model_name) + async def _apply_ollama(self, default_model: str | None) -> None: """Wipe role/global assignments, set GLOBAL to an Ollama Cloud model. diff --git a/tests/integration/test_llm_routing.py b/tests/integration/test_llm_routing.py index 198f24f6..7984c71d 100644 --- a/tests/integration/test_llm_routing.py +++ b/tests/integration/test_llm_routing.py @@ -67,7 +67,15 @@ async def llm_setup( type=ModelProvider.GEMINI, enabled=True, ) - db_session.add_all([anthropic, grok, ollama, openai, gemini]) + # Mirrors migration 091_seed_kimi_provider's contract: enabled=True at + # seed time (Codex's shape, not Gemini's disabled-then-flipped one) — no + # base_url, subscription auth only. + kimi = ProviderConfigTable( + name="kimi-test", + type=ModelProvider.KIMI, + enabled=True, + ) + db_session.add_all([anthropic, grok, ollama, openai, gemini, kimi]) await db_session.flush() yield {"svc": ModelRoutingService(db_session)} @@ -235,6 +243,18 @@ async def test_derive_mode_gemini_when_only_gemini_global(llm_setup: dict) -> No assert await svc.derive_mode() == "gemini" +@pytest.mark.asyncio +async def test_derive_mode_kimi_when_only_kimi_global(llm_setup: dict) -> None: + """A pure-KIMI global assignment reports "kimi", not the catch-all + "mix" — mirrors the codex/gemini branches derive_mode already carries.""" + svc = llm_setup["svc"] + kimi_model = _first_model_for_type(ModelProvider.KIMI) + await svc.upsert_assignment( + scope=AssignmentScope.GLOBAL, scope_value=None, model_name=kimi_model + ) + assert await svc.derive_mode() == "kimi" + + @pytest.mark.asyncio async def test_derive_mode_mix_with_per_agent(llm_setup: dict) -> None: svc = llm_setup["svc"] @@ -419,6 +439,40 @@ async def test_apply_mode_gemini_enables_gemini_provider(llm_setup: dict) -> Non assert refetched.enabled is True +@pytest.mark.asyncio +async def test_apply_mode_kimi_sets_global(llm_setup: dict) -> None: + svc = llm_setup["svc"] + await svc.apply_mode(mode="kimi") + assignments = await svc.list_assignments() + assert len(assignments) == 1 + assert assignments[0].scope == AssignmentScope.GLOBAL + assert assignments[0].provider.type == ModelProvider.KIMI + assert assignments[0].model_name == "kimi-code/k3" + + +@pytest.mark.asyncio +async def test_apply_mode_kimi_enables_kimi_provider(llm_setup: dict) -> None: + """apply_mode('kimi') force-enables the KIMI row — belt-and-suspenders + against a row disabled by some other path, mirroring the codex/gemini tests.""" + svc = llm_setup["svc"] + provider_svc = ProviderService(svc.session) + kimi = next( + p + for p in await provider_svc.list_providers(include_disabled=True) + if p.type == ModelProvider.KIMI + ) + await provider_svc.update_provider( + cast("UUID", kimi.id), ProviderUpdate(enabled=False) + ) + await svc.session.flush() + + await svc.apply_mode(mode="kimi") + + refetched = await provider_svc.get_provider(cast("UUID", kimi.id)) + assert refetched is not None + assert refetched.enabled is True + + @pytest.mark.asyncio async def test_apply_mode_mix_requires_per_agent(llm_setup: dict) -> None: svc = llm_setup["svc"] @@ -604,6 +658,78 @@ async def test_upsert_assignment_enables_disabled_gemini_provider( assert route.provider_type == ModelProvider.GEMINI +@pytest.mark.asyncio +async def test_upsert_and_resolve_kimi_assignment_roundtrip( + llm_setup: dict, +) -> None: + """kimi-code/k3 through upsert_assignment -> resolve_for_agent, against + the seeded KIMI row (migration 091). Proves resolve_for_agent actually + returns a KIMI spawn route — not a silent Anthropic fallback.""" + svc = llm_setup["svc"] + kimi_model = _first_model_for_type(ModelProvider.KIMI) + row = await svc.upsert_assignment( + scope=AssignmentScope.AGENT_SLUG, + scope_value="be-dev-1", + model_name=kimi_model, + ) + assert row.model_name == kimi_model + + route = await svc.resolve_for_agent("be-dev-1") + assert route.provider_type == ModelProvider.KIMI + assert route.model_name == kimi_model + # The seeded row carries no stored token — Kimi authenticates via the + # shared, symlinked-in ~/.kimi-code subscription credential, not a + # decrypted token. + assert route.auth_token is None + + +@pytest.mark.asyncio +async def test_upsert_assignment_enables_disabled_kimi_provider( + llm_setup: dict, +) -> None: + """Belt-and-suspenders: assigning a Kimi model via Mix (upsert_assignment) + force-enables the row even if it was disabled — not just apply_mode('kimi').""" + svc = llm_setup["svc"] + provider_svc = ProviderService(svc.session) + kimi = next( + p + for p in await provider_svc.list_providers(include_disabled=True) + if p.type == ModelProvider.KIMI + ) + await provider_svc.update_provider( + cast("UUID", kimi.id), ProviderUpdate(enabled=False) + ) + await svc.session.flush() + + kimi_model = _first_model_for_type(ModelProvider.KIMI) + await svc.upsert_assignment( + scope=AssignmentScope.AGENT_SLUG, + scope_value="be-dev-1", + model_name=kimi_model, + ) + + refetched = await provider_svc.get_provider(cast("UUID", kimi.id)) + assert refetched is not None + assert refetched.enabled is True + # And the route actually resolves to KIMI now that it's enabled. + route = await svc.resolve_for_agent("be-dev-1") + assert route.provider_type == ModelProvider.KIMI + + +@pytest.mark.asyncio +async def test_apply_mode_kimi_end_to_end_reachable(llm_setup: dict) -> None: + """The full reachability chain: apply_mode -> derive_mode reflects it -> + resolve_for_agent actually spawns Kimi, mirroring the Codex/Gemini tests.""" + svc = llm_setup["svc"] + await svc.apply_mode(mode="kimi") + + assert await svc.derive_mode() == "kimi" + + route = await svc.resolve_for_agent("be-dev-1") + assert route.provider_type == ModelProvider.KIMI + assert route.model_name == "kimi-code/k3" + + @pytest.mark.asyncio async def test_apply_mode_gemini_end_to_end_reachable(llm_setup: dict) -> None: """The full reachability chain the original drill missed: apply_mode @@ -632,12 +758,12 @@ async def test_apply_mode_codex_end_to_end_reachable(llm_setup: dict) -> None: @pytest.mark.asyncio -@pytest.mark.parametrize("mode", ["codex", "gemini"]) +@pytest.mark.parametrize("mode", ["codex", "gemini", "kimi"]) @pytest.mark.parametrize("interactive_slug", ["intake-1", "secretary-1"]) async def test_interactive_agents_exempt_from_delivery_only_global_mode( llm_setup: dict, mode: str, interactive_slug: str ) -> None: - """A fleet-wide Codex/Gemini mode must not capture Intake/Secretary — + """A fleet-wide Codex/Gemini/Kimi mode must not capture Intake/Secretary — they have no V1 support on those providers, so the resolver keeps them on the legacy Anthropic path (the completeness-drill gap: previously they resolved to the unsupported provider and the spawn guard left both diff --git a/tests/integration/test_migration_091_seed_kimi_provider.py b/tests/integration/test_migration_091_seed_kimi_provider.py new file mode 100644 index 00000000..da5c90ec --- /dev/null +++ b/tests/integration/test_migration_091_seed_kimi_provider.py @@ -0,0 +1,162 @@ +"""Migration 091 tests — seed_kimi_provider. + +Verifies the post-upgrade state and exercises the downgrade SQL ordering, +mirroring ``test_migration_083_seed_openai_provider.py``'s own shape (Kimi's +row is seeded ``enabled=true`` directly, the same posture as Codex's — there +is no ``apply_mode="kimi"``-only gate it needs to wait behind, unlike GROK's +disabled-until-key-set seed). + +NOT a real alembic round-trip — the suite builds the test DB via +Base.metadata.create_all (see conftest). Migration 091's upgrade()/downgrade() +bodies are reviewed here; the tests guard the resulting DB-level contract — +in particular ``enabled=True`` at seed time and NULL base_url/auth_token +(subscription auth, no stored secret). +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import uuid4 + +import pytest +from roboco.db.tables import ModelAssignmentTable, ProviderConfigTable +from roboco.models.base import AssignmentScope, ModelProvider +from sqlalchemy import text + +if TYPE_CHECKING: + from sqlalchemy.ext.asyncio import AsyncSession + +_INSERT_SQL = text( + """ + INSERT INTO provider_configs + (id, name, type, base_url, auth_token_encrypted, enabled, created_at) + VALUES + ( + gen_random_uuid(), + 'Kimi (Moonshot)', + 'kimi', + NULL, + NULL, + true, + now() + ) + ON CONFLICT (name) DO NOTHING + """ +) + + +@pytest.mark.asyncio +async def test_migration_091_upgrade_insert_contract( + db_session: AsyncSession, +) -> None: + """The upgrade INSERT SQL seeds the Kimi row ENABLED with no stored + secret (subscription auth), and is idempotent.""" + # --- First run: the row should be inserted. + await db_session.execute(_INSERT_SQL) + await db_session.flush() + + result = await db_session.execute( + text( + "SELECT name, type, enabled, base_url, auth_token_encrypted " + "FROM provider_configs " + "WHERE name = 'Kimi (Moonshot)'" + ) + ) + rows = list(result) + assert len(rows) == 1 + name, ptype, enabled, base_url, auth_token = rows[0] + assert name == "Kimi (Moonshot)" + assert ptype == "kimi" + # The load-bearing assertion: enabled=True at seed time (parity with + # Codex — there's no key-collection step gating this row). + assert enabled is True + assert base_url is None + assert auth_token is None + + # --- Second run: ON CONFLICT DO NOTHING must not create a duplicate. + await db_session.execute(_INSERT_SQL) + await db_session.flush() + + result = await db_session.execute( + text("SELECT id FROM provider_configs WHERE name = 'Kimi (Moonshot)'") + ) + assert len(list(result)) == 1, ( + "Expected exactly one 'Kimi (Moonshot)' row after two INSERT " + "executions; ON CONFLICT DO NOTHING must prevent duplicates." + ) + + +@pytest.mark.asyncio +async def test_migration_091_downgrade_deletes_assignments_before_config( + db_session: AsyncSession, +) -> None: + """Downgrade SQL deletes model_assignments before provider_configs. + + A FK RESTRICT constraint on model_assignments.provider_config_id means + deleting provider_configs first would raise an IntegrityError. + """ + suffix = uuid4().hex[:8] + kimi = ProviderConfigTable( + name=f"Kimi (Moonshot)-test-{suffix}", + type=ModelProvider.KIMI, + enabled=True, + ) + db_session.add(kimi) + await db_session.flush() + + assignment = ModelAssignmentTable( + scope=AssignmentScope.AGENT_SLUG, + scope_value=f"test-agent-{suffix}", + provider_config_id=kimi.id, + model_name="kimi-code/k3", + ) + db_session.add(assignment) + await db_session.flush() + + result = await db_session.execute( + text("SELECT id FROM provider_configs WHERE name = :name").bindparams( + name=kimi.name + ) + ) + assert result.scalar_one_or_none() is not None + + result = await db_session.execute( + text("SELECT id FROM model_assignments WHERE scope_value = :sv").bindparams( + sv=assignment.scope_value + ) + ) + assert result.scalar_one_or_none() is not None + + # Step 1: delete referencing model_assignments first. + await db_session.execute( + text( + "DELETE FROM model_assignments " + "WHERE provider_config_id IN (" + " SELECT id FROM provider_configs WHERE name = :name" + ")" + ).bindparams(name=kimi.name) + ) + # Step 2: now safe to delete the provider row. + await db_session.execute( + text("DELETE FROM provider_configs WHERE name = :name").bindparams( + name=kimi.name + ) + ) + + result = await db_session.execute( + text("SELECT id FROM provider_configs WHERE name = :name").bindparams( + name=kimi.name + ) + ) + assert result.scalar_one_or_none() is None, ( + "provider_configs row should be deleted by downgrade" + ) + + result = await db_session.execute( + text("SELECT id FROM model_assignments WHERE scope_value = :sv").bindparams( + sv=assignment.scope_value + ) + ) + assert result.scalar_one_or_none() is None, ( + "model_assignments row should be deleted before provider_configs" + ) diff --git a/tests/unit/billing/test_pricing.py b/tests/unit/billing/test_pricing.py index f59f9f57..14c3f391 100644 --- a/tests/unit/billing/test_pricing.py +++ b/tests/unit/billing/test_pricing.py @@ -77,6 +77,23 @@ _GLM_OUTPUT = 4.40 _GLM_CACHE_READ = 0.26 _GLM_CACHE_WRITE = 1.40 +# Moonshot Kimi — priced non-Anthropic (kimi-code CLI subscription, priced +# here for cost attribution like grok-build/gpt-5.3-codex). +_KIMI_K3_INPUT = 3.00 +_KIMI_K3_OUTPUT = 15.00 +_KIMI_K3_CACHE_READ = 0.30 +_KIMI_K3_CACHE_WRITE = 3.00 + +_KIMI_CODING_INPUT = 0.95 +_KIMI_CODING_OUTPUT = 4.00 +_KIMI_CODING_CACHE_READ = 0.19 +_KIMI_CODING_CACHE_WRITE = 0.95 + +_KIMI_CODING_HIGHSPEED_INPUT = 1.90 +_KIMI_CODING_HIGHSPEED_OUTPUT = 8.00 +_KIMI_CODING_HIGHSPEED_CACHE_READ = 0.38 +_KIMI_CODING_HIGHSPEED_CACHE_WRITE = 1.90 + # Tolerance for floating-point comparisons _TOL = 1e-4 @@ -395,6 +412,78 @@ class TestCodexTier: assert _CODEX_OUTPUT > _CODEX_INPUT +# --------------------------------------------------------------------------- +# Kimi tier (Moonshot — priced non-Anthropic, four login-managed aliases) +# --------------------------------------------------------------------------- + + +class TestKimiTier: + """kimi-code/* pricing — cache_write folds to the input rate, same + convention as grok-build/gpt-5.3-codex (no published cache-write discount).""" + + def test_k3_all_token_types(self) -> None: + cost = calculate_cost( + "kimi-code/k3", + tokens_input=_M, + tokens_output=_M, + tokens_cache_read=_M, + tokens_cache_write=_M, + ) + expected = ( + _KIMI_K3_INPUT + + _KIMI_K3_OUTPUT + + _KIMI_K3_CACHE_READ + + _KIMI_K3_CACHE_WRITE + ) + assert abs(cost - expected) < _TOL + + def test_k3_256k_prices_the_same_as_k3(self) -> None: + # "kimi-code/k3" is a PREFIX of "kimi-code/k3-256k" — longest-fragment + # wins in _lookup_prices must resolve the 256k alias to its own entry, + # not silently fall through to the bare k3 fragment (same rates here, + # but the resolution path is what's under test). + cost = calculate_cost("kimi-code/k3-256k", tokens_input=_M, tokens_output=0) + assert abs(cost - _KIMI_K3_INPUT) < _TOL + + def test_kimi_for_coding_all_token_types(self) -> None: + cost = calculate_cost( + "kimi-code/kimi-for-coding", + tokens_input=_M, + tokens_output=_M, + tokens_cache_read=_M, + tokens_cache_write=_M, + ) + expected = ( + _KIMI_CODING_INPUT + + _KIMI_CODING_OUTPUT + + _KIMI_CODING_CACHE_READ + + _KIMI_CODING_CACHE_WRITE + ) + assert abs(cost - expected) < _TOL + + def test_kimi_for_coding_highspeed_resolves_its_own_longer_fragment(self) -> None: + # "kimi-code/kimi-for-coding" is a PREFIX of + # "kimi-code/kimi-for-coding-highspeed" — longest-fragment-wins must + # resolve the highspeed alias to its OWN (pricier) rate, not the base + # coding tier's cheaper one. + cost = calculate_cost( + "kimi-code/kimi-for-coding-highspeed", tokens_input=_M, tokens_output=0 + ) + assert abs(cost - _KIMI_CODING_HIGHSPEED_INPUT) < _TOL + assert cost > calculate_cost( + "kimi-code/kimi-for-coding", tokens_input=_M, tokens_output=0 + ) + + def test_kimi_is_not_treated_as_anthropic(self) -> None: + assert _is_anthropic_model("kimi-code/k3") is False + assert calculate_cost("kimi-code/k3", tokens_input=_M, tokens_output=0) > 0.0 + + def test_output_is_pricier_than_input(self) -> None: + assert _KIMI_K3_OUTPUT > _KIMI_K3_INPUT + assert _KIMI_CODING_OUTPUT > _KIMI_CODING_INPUT + assert _KIMI_CODING_HIGHSPEED_OUTPUT > _KIMI_CODING_HIGHSPEED_INPUT + + # --------------------------------------------------------------------------- # GLM-5.2 tier (Ollama Cloud — priced non-Anthropic, grounded in a citable # published rate; see the module's pricing-table comment for the source). @@ -620,6 +709,12 @@ class TestCostResult: assert result.unpriced is False assert result.is_anthropic is False + def test_priced_non_anthropic_kimi_is_not_unpriced(self) -> None: + result = calculate_cost_result("kimi-code/k3", tokens_input=_M, tokens_output=0) + assert result.cost_usd > 0.0 + assert result.unpriced is False + assert result.is_anthropic is False + def test_calculate_cost_matches_structured_cost_usd(self) -> None: model = "claude-opus-5" assert ( diff --git a/tests/unit/llm/providers/test_kimi_cli_config.py b/tests/unit/llm/providers/test_kimi_cli_config.py new file mode 100644 index 00000000..b28248b3 --- /dev/null +++ b/tests/unit/llm/providers/test_kimi_cli_config.py @@ -0,0 +1,333 @@ +"""kimi_cli_config — config.toml (managed blocks + per-role permission rules ++ hooks) + mcp.json passthrough + AGENTS.md + the auth preflight.""" + +from __future__ import annotations + +import json +import tomllib +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING + +from roboco.llm.providers import kimi_cli_config as kc + +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"]}, + } +} + + +# --------------------------------------------------------------------------- +# permission_rules_for_role — deny-only, role-scoped +# --------------------------------------------------------------------------- + + +def test_fleet_wide_denies_present_for_every_role() -> None: + for role in ("developer", "qa", "pr_reviewer", "main_pm", "unknown-role-xyz"): + rules = kc.permission_rules_for_role(role) + patterns = {r["pattern"] for r in rules} + for fleet_wide in kc._FLEET_WIDE_DENY: + assert fleet_wide in patterns + assert all(r["decision"] == "deny" for r in rules) + + +def test_bash_capable_role_keeps_bash_and_denies_git_destructive_pm() -> None: + rules = kc.permission_rules_for_role("developer") + patterns = {r["pattern"] for r in rules} + assert "Bash" not in patterns # bash-capable: no blanket deny + assert "Bash(git push*)" in patterns + assert "Bash(rm -rf*)" in patterns + assert "Bash(uv run*)" in patterns + # Developer writes code — no edit-tool deny. + assert "Write" not in patterns + assert "Edit" not in patterns + + +def test_non_bash_role_gets_blanket_bash_deny_and_no_command_scoped_rules() -> None: + rules = kc.permission_rules_for_role("pr_reviewer") + patterns = {r["pattern"] for r in rules} + assert "Bash" in patterns + assert "Bash(git push*)" not in patterns # blanket deny — nothing left to scope + # Read-only reviewer doesn't write code either. + assert "Write" in patterns + assert "Edit" in patterns + + +def test_main_pm_keeps_bash_but_denies_write_edit() -> None: + rules = kc.permission_rules_for_role("main_pm") + patterns = {r["pattern"] for r in rules} + assert "Bash" not in patterns # PM keeps its shell + assert "Bash(git push*)" in patterns + assert "Write" in patterns # PM doesn't write code + assert "Edit" in patterns + + +def test_unknown_role_gets_every_deny_category() -> None: + rules = kc.permission_rules_for_role("unknown-role-xyz") + patterns = {r["pattern"] for r in rules} + assert "Write" in patterns + assert "Edit" in patterns + assert "Bash" in patterns + + +# --------------------------------------------------------------------------- +# kimi_hooks_config +# --------------------------------------------------------------------------- + + +def test_kimi_hooks_config_wires_bash_guard_wrapper_no_env_field() -> None: + # A [[hooks]] entry with an `env` key gets the WHOLE hooks section + # silently dropped by the CLI (live-verified) — env delivery must ride + # the wrapper script's own export, never a rendered `env` field. + hooks = kc.kimi_hooks_config("/app/scripts/kimi-bash-guard-wrapper.sh") + assert len(hooks) == 1 + hook = hooks[0] + assert hook["event"] == "PreToolUse" + assert hook["matcher"] == "Bash" + assert hook["command"] == "/app/scripts/kimi-bash-guard-wrapper.sh" + assert "env" not in hook + + +def test_kimi_hooks_config_default_points_at_wrapper() -> None: + hooks = kc.kimi_hooks_config() + assert hooks[0]["command"] == kc.KIMI_BASH_GUARD_WRAPPER + assert hooks[0]["command"].endswith("kimi-bash-guard-wrapper.sh") + + +def test_kimi_hooks_config_entries_only_carry_legal_keys() -> None: + # Pins the whole defect class: any future field addition to a rendered + # hook entry that isn't one of these four gets silently dropped by kimi. + legal_keys = {"event", "matcher", "command", "timeout"} + for hook in kc.kimi_hooks_config(): + assert set(hook.keys()) <= legal_keys + + +# --------------------------------------------------------------------------- +# render_config_toml — valid TOML, managed blocks + telemetry/upgrade + rules +# --------------------------------------------------------------------------- + + +def test_render_config_toml_is_valid_toml() -> None: + parsed = tomllib.loads(kc.render_config_toml("developer")) + assert parsed["telemetry"] is False + assert parsed["upgrade"]["auto_install"] is False + + +def test_render_config_toml_managed_provider_block() -> None: + parsed = tomllib.loads(kc.render_config_toml("developer")) + provider = parsed["providers"]["managed:kimi-code"] + assert provider["type"] == "kimi" + assert provider["base_url"] == "https://api.kimi.com/coding/v1" + assert provider["oauth"]["storage"] == "file" + assert provider["oauth"]["key"] == "oauth/kimi-code" + + +def test_render_config_toml_carries_all_four_model_aliases() -> None: + parsed = tomllib.loads(kc.render_config_toml("developer")) + models = parsed["models"] + for alias in ( + "kimi-code/k3", + "kimi-code/k3-256k", + "kimi-code/kimi-for-coding", + "kimi-code/kimi-for-coding-highspeed", + ): + assert alias in models + assert models[alias]["provider"] == "managed:kimi-code" + assert models[alias]["max_context_size"] > 0 + # The `model` value is the CLI-side managed name the wire sees — + # exactly the alias's last segment, never a raw API id like + # "kimi-k3" (a live-capture drift that would break every run). + assert models[alias]["model"] == alias.removeprefix("kimi-code/") + assert "thinking" in models[alias]["capabilities"] + # Only the K3 family exposes reasoning effort knobs. + assert models["kimi-code/k3"]["default_effort"] == "high" + assert "default_effort" not in models["kimi-code/kimi-for-coding"] + + +def test_render_config_toml_services_share_the_managed_oauth() -> None: + parsed = tomllib.loads(kc.render_config_toml("developer")) + for service in ("moonshot_search", "moonshot_fetch"): + assert parsed["services"][service]["oauth"]["key"] == "oauth/kimi-code" + + +def test_render_config_toml_permission_rules_vary_by_role() -> None: + # developer keeps its shell -> gets the full command-scoped git/destructive/ + # raw-PM deny list underneath it; pr_reviewer's blanket Bash deny needs no + # command-scoped rules at all, so it ends up with FEWER total rules despite + # also denying Write/Edit on top of the fleet-wide set. + dev_rules = tomllib.loads(kc.render_config_toml("developer"))["permission"]["rules"] + reviewer_rules = tomllib.loads(kc.render_config_toml("pr_reviewer"))["permission"][ + "rules" + ] + assert len(dev_rules) > len(reviewer_rules) + + +def test_render_config_toml_hooks_present() -> None: + parsed = tomllib.loads(kc.render_config_toml("developer")) + assert parsed["hooks"][0]["event"] == "PreToolUse" + + +# --------------------------------------------------------------------------- +# render_mcp_json — near-passthrough of the mounted mcp-config.json +# --------------------------------------------------------------------------- + + +def test_render_mcp_json_injects_env_and_omits_empty_env() -> None: + rendered = json.loads(kc.render_mcp_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_mcp_json_empty_servers() -> None: + assert json.loads(kc.render_mcp_json({})) == {"mcpServers": {}} + + +# --------------------------------------------------------------------------- +# write_agents_md +# --------------------------------------------------------------------------- + + +def test_write_agents_md_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 / ".kimi-code" / "AGENTS.md" + assert kc.write_agents_md(source=src, dest=dest) is True + assert dest.read_text(encoding="utf-8") == "You are a RoboCo backend developer." + + +def test_write_agents_md_noops_when_source_absent(tmp_path: Path) -> None: + dest = tmp_path / ".kimi-code" / "AGENTS.md" + assert kc.write_agents_md(source=tmp_path / "absent.md", dest=dest) is False + assert not dest.exists() + + +# --------------------------------------------------------------------------- +# Auth preflight — a plain expires_at JSON field, no JWT decode +# --------------------------------------------------------------------------- + + +def _write_creds(path: Path, *, expires_at: object) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + json.dumps( + { + "access_token": "at", + "refresh_token": "rt", + "expires_at": expires_at, + "expires_in": 900, + "scope": "chat", + "token_type": "Bearer", + } + ), + encoding="utf-8", + ) + + +def test_is_valid_true_for_future_unix_timestamp(tmp_path: Path) -> None: + creds = tmp_path / "credentials" / "kimi-code.json" + future = datetime.now(UTC) + timedelta(minutes=10) + _write_creds(creds, expires_at=future.timestamp()) + assert kc.is_valid(creds) is True + + +def test_is_valid_false_for_past_unix_timestamp(tmp_path: Path) -> None: + creds = tmp_path / "credentials" / "kimi-code.json" + past = datetime.now(UTC) - timedelta(minutes=10) + _write_creds(creds, expires_at=past.timestamp()) + assert kc.is_valid(creds) is False + + +def test_is_valid_accepts_iso8601_string(tmp_path: Path) -> None: + creds = tmp_path / "credentials" / "kimi-code.json" + future = datetime.now(UTC) + timedelta(minutes=10) + _write_creds(creds, expires_at=future.isoformat()) + assert kc.is_valid(creds) is True + + +def test_is_valid_false_for_missing_file(tmp_path: Path) -> None: + assert kc.is_valid(tmp_path / "credentials" / "kimi-code.json") is False + + +def test_is_valid_false_for_unparseable_expires_at(tmp_path: Path) -> None: + creds = tmp_path / "credentials" / "kimi-code.json" + _write_creds(creds, expires_at="not-a-timestamp") + assert kc.is_valid(creds) is False + + +def test_seconds_until_expiry_respects_skew(tmp_path: Path) -> None: + creds = tmp_path / "credentials" / "kimi-code.json" + soon = datetime.now(UTC) + timedelta(seconds=30) + _write_creds(creds, expires_at=soon.timestamp()) + assert kc.is_valid(creds, skew_seconds=60) is False + assert kc.is_valid(creds, skew_seconds=0) is True + + +# --------------------------------------------------------------------------- +# main() — render mode + --check mode +# --------------------------------------------------------------------------- + + +def test_main_writes_config_mcp_and_agents_md( + 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") + config_path = tmp_path / ".kimi-code" / "config.toml" + mcp_out_path = tmp_path / ".kimi-code" / "mcp.json" + agents_md_path = tmp_path / ".kimi-code" / "AGENTS.md" + system_prompt = tmp_path / "system-prompt.md" + system_prompt.write_text("blueprint", encoding="utf-8") + + monkeypatch.setattr(kc, "KIMI_CONFIG_PATH", config_path) + monkeypatch.setattr(kc, "KIMI_MCP_PATH", mcp_out_path) + monkeypatch.setattr(kc, "KIMI_AGENTS_MD_PATH", agents_md_path) + monkeypatch.setattr(kc, "SYSTEM_PROMPT_PATH", system_prompt) + monkeypatch.setenv("ROBOCO_AGENT_ID", "be-dev-1") + monkeypatch.setenv("ROBOCO_MCP_CONFIG", str(mcp_path)) + + assert kc.main([]) == 0 + + parsed = tomllib.loads(config_path.read_text(encoding="utf-8")) + assert parsed["providers"]["managed:kimi-code"]["type"] == "kimi" + rendered_mcp = json.loads(mcp_out_path.read_text(encoding="utf-8")) + assert rendered_mcp["mcpServers"]["roboco-flow"]["env"]["ROBOCO_AGENT_TOKEN"] == ( + "tok-123" + ) + assert agents_md_path.read_text(encoding="utf-8") == "blueprint" + + +def test_main_check_flag_runs_preflight_without_rendering( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + creds = tmp_path / "credentials" / "kimi-code.json" + future = datetime.now(UTC) + timedelta(minutes=10) + _write_creds(creds, expires_at=future.timestamp()) + config_path = tmp_path / ".kimi-code" / "config.toml" + + monkeypatch.setattr(kc, "KIMI_CREDENTIALS_PATH", creds) + monkeypatch.setattr(kc, "KIMI_CONFIG_PATH", config_path) + + assert kc.main(["--check"]) == 0 + assert not config_path.exists() # --check never renders + + +def test_main_check_flag_fails_on_missing_credential( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + kc, "KIMI_CREDENTIALS_PATH", tmp_path / "credentials" / "kimi-code.json" + ) + assert kc.main(["--check"]) == 1 diff --git a/tests/unit/llm/providers/test_kimi_cli_sniff.py b/tests/unit/llm/providers/test_kimi_cli_sniff.py new file mode 100644 index 00000000..10796e19 --- /dev/null +++ b/tests/unit/llm/providers/test_kimi_cli_sniff.py @@ -0,0 +1,186 @@ +"""kimi_cli_sniff — classify a Kimi run from ONLY its machine-relevant text. + +The structural guarantee under test: the model's own on-topic prose (which +can legitimately contain the words "quota-limited" or a "429"/"401" substring +inside a commit hash / id) must NEVER reach the classifier, because +extraction only pulls a structured ``error`` field off error-bearing JSONL +events plus raw stderr — never ``role: assistant`` / ``role: tool`` content. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING + +from roboco.llm.providers import kimi_cli_sniff as sniff + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + + +def _write_jsonl(path: Path, lines: list[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _error_event(message: str) -> str: + return json.dumps({"type": "error", "error": {"message": message}}) + + +# --------------------------------------------------------------------------- +# extract_error_text — structural isolation +# --------------------------------------------------------------------------- + + +def test_extract_error_text_pulls_only_structured_error_field(tmp_path: Path) -> None: + log = tmp_path / "run.jsonl" + _write_jsonl( + log, + [ + json.dumps( + { + "role": "assistant", + "content": "the quota-limited rollout ships this sprint", + } + ), + _error_event("real error text"), + ], + ) + assert sniff.extract_error_text(log) == "real error text" + + +def test_extract_error_text_accepts_bare_string_error() -> None: + assert sniff._error_text_from_event({"error": "bare string error"}) == ( + "bare string error" + ) + + +def test_extract_error_text_empty_for_missing_or_error_less_log( + tmp_path: Path, +) -> None: + assert sniff.extract_error_text(tmp_path / "nope.jsonl") == "" + log = tmp_path / "run.jsonl" + _write_jsonl(log, [json.dumps({"role": "assistant", "content": "hi"})]) + assert sniff.extract_error_text(log) == "" + + +# --------------------------------------------------------------------------- +# The false-positive class this module exists to kill +# --------------------------------------------------------------------------- + + +def test_benign_transcript_never_false_parks(tmp_path: Path) -> None: + """A transcript whose ONLY content is benign on-topic prose — mentioning + "quota-limited" work and a commit hash containing "429"/"401" — must + classify as "" (no park), because none of it lives in a structured error + field the extractor even looks at.""" + log = tmp_path / "run.jsonl" + _write_jsonl( + log, + [ + json.dumps( + { + "role": "assistant", + "content": ( + "Fixed the quota-limited rollout gate. Committed as " + "abc4291f, also touched item 40199." + ), + } + ), + json.dumps({"role": "tool", "tool_call_id": "1", "content": "ok"}), + ], + ) + err_log = tmp_path / "run.err" + err_log.write_text("", encoding="utf-8") + assert sniff.classify(log, err_log) == "" + + +def test_word_boundary_prevents_429_substring_false_positive() -> None: + assert not sniff.is_rate_limited("commit abc14293 deployed to prod") + assert not sniff.is_rate_limited("fix4297abc landed") + + +def test_word_boundary_prevents_401_substring_false_positive() -> None: + assert not sniff.is_auth_failure("item 40199 was resolved") + assert not sniff.is_auth_failure("ticket 14012 closed") + + +# --------------------------------------------------------------------------- +# True positives — the live-verified error text shapes from the spike +# --------------------------------------------------------------------------- + + +def test_status_code_429_classifies_rate_limit(tmp_path: Path) -> None: + log = tmp_path / "run.jsonl" + _write_jsonl(log, [_error_event("request failed with status code: 429")]) + assert sniff.classify(log) == "rate_limit" + + +def test_engine_overloaded_classifies_rate_limit(tmp_path: Path) -> None: + log = tmp_path / "run.jsonl" + _write_jsonl(log, [_error_event("the engine is currently overloaded")]) + assert sniff.classify(log) == "rate_limit" + + +def test_usage_limit_for_period_classifies_rate_limit(tmp_path: Path) -> None: + log = tmp_path / "run.jsonl" + _write_jsonl(log, [_error_event("usage limit for this period exceeded")]) + assert sniff.classify(log) == "rate_limit" + + +def test_usage_limit_for_billing_cycle_classifies_rate_limit(tmp_path: Path) -> None: + log = tmp_path / "run.jsonl" + _write_jsonl(log, [_error_event("usage limit for this billing cycle reached")]) + assert sniff.classify(log) == "rate_limit" + + +def test_api_key_invalid_classifies_auth(tmp_path: Path) -> None: + log = tmp_path / "run.jsonl" + _write_jsonl(log, [_error_event("API Key appears to be invalid")]) + assert sniff.classify(log) == "auth" + + +def test_membership_benefits_classifies_auth(tmp_path: Path) -> None: + log = tmp_path / "run.jsonl" + _write_jsonl( + log, + [_error_event("We're unable to verify your membership benefits at this time.")], + ) + assert sniff.classify(log) == "auth" + + +def test_classify_reads_stderr_too(tmp_path: Path) -> None: + log = tmp_path / "run.jsonl" + _write_jsonl(log, [json.dumps({"role": "assistant", "content": "ok"})]) + err_log = tmp_path / "run.err" + err_log.write_text("fatal: status code: 429\n", encoding="utf-8") + assert sniff.classify(log, err_log) == "rate_limit" + + +def test_classify_missing_files_returns_empty(tmp_path: Path) -> None: + assert sniff.classify(tmp_path / "nope.jsonl", tmp_path / "nope.err") == "" + + +def test_rate_limit_checked_before_auth_when_both_present(tmp_path: Path) -> None: + log = tmp_path / "run.jsonl" + _write_jsonl( + log, + [_error_event("status code: 429, and API Key appears to be invalid too")], + ) + assert sniff.classify(log) == "rate_limit" + + +def test_main_cli_prints_classification( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + log = tmp_path / "run.jsonl" + _write_jsonl(log, [_error_event("status code: 429")]) + assert sniff.main([str(log)]) == 0 + assert capsys.readouterr().out.strip() == "rate_limit" + + +def test_main_cli_no_args_prints_empty(capsys: pytest.CaptureFixture[str]) -> None: + assert sniff.main([]) == 0 + assert capsys.readouterr().out.strip() == "" diff --git a/tests/unit/llm/providers/test_kimi_cli_usage.py b/tests/unit/llm/providers/test_kimi_cli_usage.py new file mode 100644 index 00000000..dc26857b --- /dev/null +++ b/tests/unit/llm/providers/test_kimi_cli_usage.py @@ -0,0 +1,264 @@ +"""kimi_cli_usage — resolve the session dir from a run's stdout meta line (or +the newest matching session dir), then sum the real 4-bucket +``usage.record``/``usageScope=="turn"`` events in that session's wire.jsonl. +""" + +from __future__ import annotations + +import json +import time +from typing import TYPE_CHECKING + +from roboco.llm.providers import kimi_cli_usage as ku + +if TYPE_CHECKING: + from pathlib import Path + + import pytest + + +def _write_jsonl(path: Path, lines: list[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def _usage_record( + *, + input_other: int, + output: int, + cache_read: int = 0, + cache_creation: int = 0, + scope: str = "turn", +) -> str: + return json.dumps( + { + "type": "usage.record", + "model": "kimi-code/k3", + "usageScope": scope, + "usage": { + "inputOther": input_other, + "output": output, + "inputCacheRead": cache_read, + "inputCacheCreation": cache_creation, + }, + } + ) + + +def _resume_hint(session_id: str) -> str: + return json.dumps( + {"role": "meta", "type": "session.resume_hint", "session_id": session_id} + ) + + +# --------------------------------------------------------------------------- +# session_id_from_run_log +# --------------------------------------------------------------------------- + + +def test_session_id_from_run_log_finds_terminal_meta_line(tmp_path: Path) -> None: + log = tmp_path / "run.jsonl" + _write_jsonl( + log, + [ + json.dumps({"role": "assistant", "content": "working"}), + _resume_hint("session_abc123"), + ], + ) + assert ku.session_id_from_run_log(log) == "session_abc123" + + +def test_session_id_from_run_log_keeps_the_last_match(tmp_path: Path) -> None: + log = tmp_path / "run.jsonl" + _write_jsonl(log, [_resume_hint("session_first"), _resume_hint("session_second")]) + assert ku.session_id_from_run_log(log) == "session_second" + + +def test_session_id_from_run_log_none_when_absent(tmp_path: Path) -> None: + log = tmp_path / "run.jsonl" + _write_jsonl(log, [json.dumps({"role": "assistant", "content": "hi"})]) + assert ku.session_id_from_run_log(log) is None + assert ku.session_id_from_run_log(tmp_path / "nope.jsonl") is None + + +# --------------------------------------------------------------------------- +# resolve_session_dir — primary (known id) + fallback (newest under cwd basename) +# --------------------------------------------------------------------------- + + +def test_resolve_session_dir_finds_known_session_id(tmp_path: Path) -> None: + home = tmp_path / ".kimi-code" + session_dir = home / "sessions" / "wd_myrepo_ab12cd34ef56" / "session_abc123" + session_dir.mkdir(parents=True) + resolved = ku.resolve_session_dir( + session_id="session_abc123", + workdir="/data/workspaces/myrepo", + kimi_code_home=home, + ) + assert resolved == session_dir + + +def test_resolve_session_dir_falls_back_to_newest(tmp_path: Path) -> None: + home = tmp_path / ".kimi-code" + wd_dir = home / "sessions" / "wd_myrepo_ab12cd34ef56" + old_session = wd_dir / "session_old" + new_session = wd_dir / "session_new" + old_session.mkdir(parents=True) + time.sleep(0.01) + new_session.mkdir(parents=True) + resolved = ku.resolve_session_dir( + session_id=None, workdir="/data/workspaces/myrepo", kimi_code_home=home + ) + assert resolved == new_session + + +def test_resolve_session_dir_none_when_sessions_root_absent(tmp_path: Path) -> None: + home = tmp_path / ".kimi-code" + assert ( + ku.resolve_session_dir( + session_id=None, workdir="/x/myrepo", kimi_code_home=home + ) + is None + ) + + +def test_resolve_session_dir_falls_back_when_id_not_found(tmp_path: Path) -> None: + home = tmp_path / ".kimi-code" + wd_dir = home / "sessions" / "wd_myrepo_ab12cd34ef56" + only_session = wd_dir / "session_other" + only_session.mkdir(parents=True) + resolved = ku.resolve_session_dir( + session_id="session_missing", + workdir="/data/workspaces/myrepo", + kimi_code_home=home, + ) + assert resolved == only_session + + +# --------------------------------------------------------------------------- +# aggregate_usage_from_wire +# --------------------------------------------------------------------------- + + +def test_aggregate_sums_turn_scoped_usage_records(tmp_path: Path) -> None: + wire = tmp_path / "wire.jsonl" + _write_jsonl( + wire, + [ + _usage_record(input_other=100, output=50, cache_read=10), + json.dumps({"type": "llm.request", "model": "kimi-code/k3"}), + _usage_record(input_other=200, output=80, cache_read=20, cache_creation=5), + ], + ) + agg = ku.aggregate_usage_from_wire(wire) + assert agg["inputOther"] == 300 # noqa: PLR2004 + assert agg["output"] == 130 # noqa: PLR2004 + assert agg["inputCacheRead"] == 30 # noqa: PLR2004 + assert agg["inputCacheCreation"] == 5 # noqa: PLR2004 + assert agg["turns"] == 2 # noqa: PLR2004 + + +def test_aggregate_ignores_non_turn_scope_and_bad_lines(tmp_path: Path) -> None: + wire = tmp_path / "wire.jsonl" + _write_jsonl( + wire, + [ + "not json", + _usage_record(input_other=5, output=1, scope="session"), + _usage_record(input_other=10, output=5), + ], + ) + agg = ku.aggregate_usage_from_wire(wire) + assert agg["inputOther"] == 10 # noqa: PLR2004 + assert agg["turns"] == 1 + + +def test_aggregate_zero_for_missing_log(tmp_path: Path) -> None: + agg = ku.aggregate_usage_from_wire(tmp_path / "nope.jsonl") + assert agg["turns"] == 0 + assert all(v == 0 for k, v in agg.items() if k != "turns") + + +# --------------------------------------------------------------------------- +# capture_run_usage / main +# --------------------------------------------------------------------------- + + +def test_capture_run_usage_writes_usage_json(tmp_path: Path) -> None: + home = tmp_path / ".kimi-code" + session_dir = home / "sessions" / "wd_myrepo_hash1" / "session_abc" + (session_dir / "agents" / "main").mkdir(parents=True) + wire = session_dir / "agents" / "main" / "wire.jsonl" + _write_jsonl(wire, [_usage_record(input_other=100, output=50, cache_read=10)]) + + run_log = tmp_path / "run.jsonl" + _write_jsonl(run_log, [_resume_hint("session_abc")]) + + out = tmp_path / "usage.json" + tokens = ku.capture_run_usage( + run_log=run_log, + workdir="/data/workspaces/myrepo", + model="kimi-code/k3", + out_path=out, + kimi_code_home=home, + ) + assert tokens == (100, 50, 10, 0) + data = json.loads(out.read_text()) + assert data["model"] == "kimi-code/k3" + assert data["tokens_input"] == 100 # noqa: PLR2004 + assert data["tokens_output"] == 50 # noqa: PLR2004 + assert data["tokens_cache_read"] == 10 # noqa: PLR2004 + assert data["turns"] == 1 + assert data["cost_usd"] > 0.0 + + +def test_capture_run_usage_zero_when_no_session_found(tmp_path: Path) -> None: + home = tmp_path / ".kimi-code" + run_log = tmp_path / "run.jsonl" + run_log.write_text("", encoding="utf-8") + out = tmp_path / "usage.json" + tokens = ku.capture_run_usage( + run_log=run_log, + workdir="/data/workspaces/myrepo", + model="kimi-code/k3", + out_path=out, + kimi_code_home=home, + ) + assert tokens == (0, 0, 0, 0) + data = json.loads(out.read_text()) + assert data["tokens_input"] == 0 + assert data["turns"] == 0 + + +def test_main_writes_usage_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = tmp_path / ".kimi-code" + session_dir = home / "sessions" / "wd_myrepo_hash1" / "session_abc" + (session_dir / "agents" / "main").mkdir(parents=True) + wire = session_dir / "agents" / "main" / "wire.jsonl" + _write_jsonl(wire, [_usage_record(input_other=200, output=100)]) + + run_log = tmp_path / "run.jsonl" + _write_jsonl(run_log, [_resume_hint("session_abc")]) + + out = tmp_path / "usage.json" + monkeypatch.setattr(ku, "USAGE_OUT_PATH", out) + monkeypatch.setattr(ku, "KIMI_CODE_HOME", home) + monkeypatch.setenv("ROBOCO_KIMI_RUN_LOG", str(run_log)) + monkeypatch.setenv("ROBOCO_KIMI_WORKDIR", "/data/workspaces/myrepo") + monkeypatch.setenv("ROBOCO_AGENT_MODEL", "kimi-code/k3") + + assert ku.main() == 0 + data = json.loads(out.read_text()) + assert data["tokens_input"] == 200 # noqa: PLR2004 + assert data["tokens_output"] == 100 # noqa: PLR2004 + + +def test_main_warns_when_run_log_env_missing( + monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture +) -> None: + monkeypatch.delenv("ROBOCO_KIMI_RUN_LOG", raising=False) + with caplog.at_level("WARNING", logger="roboco.llm.providers.kimi_cli_usage"): + assert ku.main() == 0 + assert any("ROBOCO_KIMI_RUN_LOG" in r.message for r in caplog.records) diff --git a/tests/unit/llm/test_providers.py b/tests/unit/llm/test_providers.py index 0468f473..1b417841 100644 --- a/tests/unit/llm/test_providers.py +++ b/tests/unit/llm/test_providers.py @@ -21,6 +21,7 @@ from roboco.llm.providers import ( ClaudeCodeProvider, CodexCliProvider, GrokCliProvider, + KimiCliProvider, ProviderError, ProviderNotRegisteredError, ProviderRegistry, @@ -48,6 +49,14 @@ def _isolate_codex_auth(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path return codex_dir +@pytest.fixture(autouse=True) +def _isolate_kimi_auth(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Point KIMI_AUTH_HOST_PATH at a fresh tmp dir (parity with codex above).""" + kimi_dir = tmp_path / "kimi-auth" + monkeypatch.setattr("roboco.llm.providers.kimi.KIMI_AUTH_HOST_PATH", str(kimi_dir)) + return kimi_dir + + def _config( *, agent_id: str = "be-dev-1", @@ -99,6 +108,9 @@ class _FakeHost: def _ensure_codex_usage_dir(self, agent_id: str) -> None: self.data_dirs_ensured.append(agent_id) + def _ensure_kimi_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]: @@ -109,6 +121,7 @@ class _FakeHost: "settings": str(agent_settings_path) if agent_settings_path else None, "grok_usage": f"/host/data/grok-usage/{config.agent_id}", "codex_usage": f"/host/data/codex-usage/{config.agent_id}", + "kimi_usage": f"/host/data/kimi-usage/{config.agent_id}", } def _build_mount_args( @@ -477,6 +490,157 @@ async def test_codex_spawn_raises_on_docker_failure() -> None: await provider.spawn(_codex_config()) +# --------------------------------------------------------------------------- +# KimiCliProvider +# --------------------------------------------------------------------------- + + +def _kimi_config( + *, + agent_id: str = "be-dev-1", + provider_base_url: str | None = "https://api.x.ai/v1", + provider_auth_token: str | None = "should-not-leak", + 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="kimi-code/k3", + mcp_config_path=mcp_config_path, + claude_session_id="sess-1", + provider_type="kimi", + provider_base_url=provider_base_url, + provider_auth_token=provider_auth_token, + ) + + +async def test_kimi_spawn_requires_mcp_config() -> None: + provider = KimiCliProvider(_FakeHost()) + with pytest.raises(ProviderError, match="MCP config"): + await provider.spawn(_kimi_config(mcp_config_path=None)) + + +async def test_kimi_spawn_does_not_require_api_key() -> None: + # Subscription auth (mounted ~/.kimi-code) — a missing provider key is fine. + host = _FakeHost() + provider = KimiCliProvider(host) + with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())): + result = await provider.spawn(_kimi_config(provider_auth_token=None)) + assert result.instance_id == "roboco-agent-be-dev-1" + + +async def test_kimi_spawn_no_leaked_key_and_no_anthropic_leak() -> None: + host = _FakeHost() + provider = KimiCliProvider(host, image="roboco-agent-kimi:test") + with patch( + "asyncio.create_subprocess_exec", AsyncMock(return_value=_proc()) + ) as exec_mock: + await provider.spawn(_kimi_config(), initial_prompt="do the work") + cmd = list(exec_mock.call_args.args) + assert not any(c.startswith("MOONSHOT_API_KEY=") for c in cmd) + # 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) + 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_kimi_spawn_wires_gateway_env_and_image_last() -> None: + host = _FakeHost() + provider = KimiCliProvider(host, image="roboco-agent-kimi:test") + with patch( + "asyncio.create_subprocess_exec", AsyncMock(return_value=_proc()) + ) as exec_mock: + result = await provider.spawn(_kimi_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=kimi-code/k3" 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/kimi-usage/be-dev-1:/home/agent/.kimi-usage" in cmd + assert "ROBOCO_KIMI_USAGE_FILE=/home/agent/.kimi-usage/usage.json" in cmd + assert "ROBOCO_AGENT_TOKEN=hmac-be-dev-1" in cmd + assert cmd[-1] == "roboco-agent-kimi: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": "kimi-code/k3"}, + ) + + +async def test_kimi_spawn_mounts_auth_when_present(_isolate_kimi_auth: Path) -> None: + creds_dir = _isolate_kimi_auth / "credentials" + creds_dir.mkdir(parents=True, exist_ok=True) + (creds_dir / "kimi-code.json").write_text("{}", encoding="utf-8") + host = _FakeHost() + provider = KimiCliProvider(host) + with patch( + "asyncio.create_subprocess_exec", AsyncMock(return_value=_proc()) + ) as exec_mock: + await provider.spawn(_kimi_config()) + cmd = list(exec_mock.call_args.args) + # Mount the host ~/.kimi-code DIRECTORY read-write (rotation-with-grace, + # not truly reusable — every container must share ONE chain with the + # host, not a private copy) — the entrypoint symlinks credentials/ and + # oauth/ forward into a container-local, writable ~/.kimi-code. + expected = f"{_isolate_kimi_auth}:/home/agent/.kimi-code-auth" + assert expected in cmd + + +async def test_kimi_spawn_omits_auth_mount_when_absent() -> None: + host = _FakeHost() + provider = KimiCliProvider(host) + with patch( + "asyncio.create_subprocess_exec", AsyncMock(return_value=_proc()) + ) as exec_mock: + await provider.spawn(_kimi_config()) + cmd = list(exec_mock.call_args.args) + assert not any("/home/agent/.kimi-code-auth" in c for c in cmd) + + +async def test_kimi_spawn_warns_when_auth_absent( + caplog: pytest.LogCaptureFixture, +) -> None: + caplog.set_level("WARNING", logger="roboco.llm.providers.kimi") + host = _FakeHost() + provider = KimiCliProvider(host) + with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=_proc())): + await provider.spawn(_kimi_config()) + warnings = [r for r in caplog.records if r.levelname == "WARNING"] + assert warnings, "expected a spawn-time WARNING for the missing host credential" + msg = warnings[0].getMessage() + assert "kimi-code.json" in msg + assert "kimi login" in msg + + +async def test_kimi_spawn_prompt_is_injection_safe() -> None: + host = _FakeHost() + provider = KimiCliProvider(host) + nasty = "--model evil --session-id pwned" + with patch( + "asyncio.create_subprocess_exec", AsyncMock(return_value=_proc()) + ) as exec_mock: + await provider.spawn(_kimi_config(), initial_prompt=nasty) + cmd = list(exec_mock.call_args.args) + assert f"ROBOCO_INITIAL_PROMPT={nasty}" in cmd + assert nasty not in cmd + + +async def test_kimi_spawn_raises_on_docker_failure() -> None: + provider = KimiCliProvider(_FakeHost()) + with ( + patch( + "asyncio.create_subprocess_exec", + AsyncMock(return_value=_proc(returncode=1, stderr=b"boom")), + ), + pytest.raises(ProviderError, match="boom"), + ): + await provider.spawn(_kimi_config()) + + # --------------------------------------------------------------------------- # ClaudeCodeProvider # --------------------------------------------------------------------------- diff --git a/tests/unit/runtime/test_interactive_provider_guard.py b/tests/unit/runtime/test_interactive_provider_guard.py index 22b536c6..7e30df87 100644 --- a/tests/unit/runtime/test_interactive_provider_guard.py +++ b/tests/unit/runtime/test_interactive_provider_guard.py @@ -1,8 +1,9 @@ -"""Codex (OPENAI) and Gemini (GEMINI) are V1 delivery-roles-only — neither has -an interactive-session driver image (unlike GROK's dedicated -GROK_PROMPTER_IMAGE / GROK_SECRETARY_IMAGE). Routing either to the persistent -Intake/Secretary agent must refuse loudly instead of silently falling through -to the plain Claude SDK-driver image with a mismatched provider env. +"""Codex (OPENAI), Gemini (GEMINI), and Kimi (KIMI) are V1 delivery-roles-only +— none has an interactive-session driver image (unlike GROK's dedicated +GROK_PROMPTER_IMAGE / GROK_SECRETARY_IMAGE). Routing any of them to the +persistent Intake/Secretary agent must refuse loudly instead of silently +falling through to the plain Claude SDK-driver image with a mismatched +provider env. """ from __future__ import annotations @@ -67,7 +68,9 @@ class TestRejectInteractiveUnsupportedProvider: un-exempt a chat.""" assert set(INTERACTIVE_AGENT_SLUGS) == {INTAKE_AGENT_ID, SECRETARY_AGENT_ID} - @pytest.mark.parametrize("provider", [ModelProvider.OPENAI, ModelProvider.GEMINI]) + @pytest.mark.parametrize( + "provider", [ModelProvider.OPENAI, ModelProvider.GEMINI, ModelProvider.KIMI] + ) def test_raises_for_delivery_only_providers(self, provider: ModelProvider) -> None: with pytest.raises(RuntimeError, match="delivery-roles-only"): _reject_interactive_unsupported_provider(INTAKE_AGENT_ID, provider) @@ -93,7 +96,9 @@ class TestRejectInteractiveUnsupportedProvider: class TestIntakeSpawnRefusesDeliveryOnlyProvider: - @pytest.mark.parametrize("provider", [ModelProvider.OPENAI, ModelProvider.GEMINI]) + @pytest.mark.parametrize( + "provider", [ModelProvider.OPENAI, ModelProvider.GEMINI, ModelProvider.KIMI] + ) @pytest.mark.asyncio async def test_refuses_before_any_container_work( self, monkeypatch: pytest.MonkeyPatch, provider: ModelProvider @@ -149,7 +154,9 @@ class TestIntakeSpawnRefusesDeliveryOnlyProvider: class TestSecretarySpawnRefusesDeliveryOnlyProvider: - @pytest.mark.parametrize("provider", [ModelProvider.OPENAI, ModelProvider.GEMINI]) + @pytest.mark.parametrize( + "provider", [ModelProvider.OPENAI, ModelProvider.GEMINI, ModelProvider.KIMI] + ) @pytest.mark.asyncio async def test_refuses_before_any_container_work( self, monkeypatch: pytest.MonkeyPatch, provider: ModelProvider diff --git a/tests/unit/runtime/test_kimi_rate_limit.py b/tests/unit/runtime/test_kimi_rate_limit.py new file mode 100644 index 00000000..08b67f60 --- /dev/null +++ b/tests/unit/runtime/test_kimi_rate_limit.py @@ -0,0 +1,156 @@ +"""KIMI 429/auth parking: same exit-code convention as codex/grok, scoped to +ModelProvider.KIMI so a numeric-code collision with another provider's crash +can never mis-park (see ``_KIMI_RATE_LIMIT_EXIT_CODE`` / ``_KIMI_AUTH_EXIT_CODE`` +in ``roboco.runtime.orchestrator``). +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest +from roboco.models.runtime import AgentInstance +from roboco.runtime.orchestrator import ( + _KIMI_AUTH_EXIT_CODE, + _KIMI_RATE_LIMIT_EXIT_CODE, + AgentOrchestrator, + AgentState, +) + + +def _kimi_instance(provider_type: str = "kimi") -> AgentInstance: + cfg = type("C", (), {"provider_type": provider_type, "model": "kimi-code/k3"})() + 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, + } + + +def test_is_kimi_rate_limit_exit() -> None: + inst = _kimi_instance() + assert AgentOrchestrator._is_kimi_rate_limit_exit(inst, _KIMI_RATE_LIMIT_EXIT_CODE) + assert not AgentOrchestrator._is_kimi_rate_limit_exit(inst, 0) + assert not AgentOrchestrator._is_kimi_rate_limit_exit(inst, 1) + # A codex exit at the SAME numeric code must NOT be classified as kimi. + assert not AgentOrchestrator._is_kimi_rate_limit_exit( + _kimi_instance(provider_type="openai"), _KIMI_RATE_LIMIT_EXIT_CODE + ) + assert not AgentOrchestrator._is_kimi_rate_limit_exit( + _kimi_instance(provider_type="anthropic"), _KIMI_RATE_LIMIT_EXIT_CODE + ) + + +def test_is_kimi_auth_exit() -> None: + inst = _kimi_instance() + assert AgentOrchestrator._is_kimi_auth_exit(inst, _KIMI_AUTH_EXIT_CODE) + assert not AgentOrchestrator._is_kimi_auth_exit(inst, 0) + assert not AgentOrchestrator._is_kimi_auth_exit(inst, 1) + assert not AgentOrchestrator._is_kimi_auth_exit( + _kimi_instance(provider_type="openai"), _KIMI_AUTH_EXIT_CODE + ) + + +@pytest.mark.asyncio +async def test_park_kimi_rate_limited_activates_and_offlines( + monkeypatch: pytest.MonkeyPatch, +) -> None: + orch = AgentOrchestrator.__new__(AgentOrchestrator) + orch._waiting_records = {} + orch._rate_limit_ceo_notified = set() + inst = _kimi_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_kimi_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 429 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_kimi_auth_unavailable_activates_with_auth_missing_kind( + monkeypatch: pytest.MonkeyPatch, +) -> None: + orch = AgentOrchestrator.__new__(AgentOrchestrator) + orch._waiting_records = {} + orch._rate_limit_ceo_notified = set() + inst = _kimi_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_kimi_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_kimi_429( + monkeypatch: pytest.MonkeyPatch, +) -> None: + orch = AgentOrchestrator.__new__(AgentOrchestrator) + inst = _kimi_instance() + park = AsyncMock() + finalize = AsyncMock() + monkeypatch.setattr(orch, "_park_kimi_rate_limited", park) + monkeypatch.setattr(orch, "_finalize_spawn_session", finalize) + + await orch._handle_stopped_container("be-dev-1", inst, _KIMI_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_kimi_auth_exit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + orch = AgentOrchestrator.__new__(AgentOrchestrator) + inst = _kimi_instance() + park = AsyncMock() + finalize = AsyncMock() + monkeypatch.setattr(orch, "_park_kimi_auth_unavailable", park) + monkeypatch.setattr(orch, "_finalize_spawn_session", finalize) + + await orch._handle_stopped_container("be-dev-1", inst, _KIMI_AUTH_EXIT_CODE) + + park.assert_awaited_once_with("be-dev-1", inst) + finalize.assert_not_awaited() diff --git a/tests/unit/runtime/test_kimi_usage_finalize.py b/tests/unit/runtime/test_kimi_usage_finalize.py new file mode 100644 index 00000000..e2cf4035 --- /dev/null +++ b/tests/unit/runtime/test_kimi_usage_finalize.py @@ -0,0 +1,144 @@ +"""KIMI agents capture real input/output/cache-split token usage from their +captured ``usage.json`` — Kimi's wire.jsonl carries a genuine, already-disjoint +4-bucket split (see ``kimi_cli_usage``), so finalize must return the real +4-tuple instead of folding everything 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, **fields: object) -> None: + payload = { + "model": "kimi-code/k3", + "tokens_input": 0, + "tokens_output": 0, + "tokens_cache_read": 0, + "tokens_cache_write": 0, + "cost_usd": 0.0, + "turns": 1, + **fields, + } + path.write_text(json.dumps(payload), encoding="utf-8") + + +def test_kimi_usage_returns_real_split( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + usage = tmp_path / "usage.json" + _write_usage( + usage, tokens_input=300, tokens_output=130, tokens_cache_read=30, turns=2 + ) + orch = AgentOrchestrator.__new__(AgentOrchestrator) + monkeypatch.setattr( + orch, "_kimi_usage_json", lambda _aid: json.loads(usage.read_text()) + ) + + expected_turns = 2 + assert orch._kimi_usage_tokens("be-dev-1") == (300, 130, 30, 0) + assert orch._kimi_usage_turns("be-dev-1") == expected_turns + + +def test_kimi_usage_zero_when_store_missing(monkeypatch: pytest.MonkeyPatch) -> None: + orch = AgentOrchestrator.__new__(AgentOrchestrator) + monkeypatch.setattr(orch, "_kimi_usage_json", lambda _aid: None) + assert orch._kimi_usage_tokens("be-dev-1") == (0, 0, 0, 0) + assert orch._kimi_usage_turns("be-dev-1") == 0 + + +@pytest.mark.asyncio +async def test_resolve_final_usage_routes_kimi_to_usage_json( + monkeypatch: pytest.MonkeyPatch, +) -> None: + orch = AgentOrchestrator.__new__(AgentOrchestrator) + monkeypatch.setattr( + orch, + "_kimi_usage_json", + lambda _aid: { + "tokens_input": 12, + "tokens_output": 34, + "tokens_cache_read": 5, + "tokens_cache_write": 1, + }, + ) + cfg = type("C", (), {"provider_type": "kimi"})() + orch._instances = {"be-dev-1": AgentInstance(agent_id="be-dev-1", config=cfg)} + + assert await orch._resolve_final_token_usage("be-dev-1") == (12, 34, 5, 1) + + +@pytest.mark.asyncio +async def test_resolve_final_turns_tools_routes_kimi_to_usage_json( + monkeypatch: pytest.MonkeyPatch, +) -> None: + orch = AgentOrchestrator.__new__(AgentOrchestrator) + monkeypatch.setattr(orch, "_kimi_usage_turns", lambda _aid: 3) + cfg = type("C", (), {"provider_type": "kimi"})() + orch._instances = {"be-dev-1": AgentInstance(agent_id="be-dev-1", config=cfg)} + + # Kimi has no tool-call signal — tool_calls stays 0. + assert await orch._resolve_final_turns_tools("be-dev-1") == (3, 0) + + +@pytest.mark.asyncio +async def test_resolve_active_tokens_routes_kimi_to_usage_json( + monkeypatch: pytest.MonkeyPatch, +) -> None: + orch = AgentOrchestrator.__new__(AgentOrchestrator) + monkeypatch.setattr( + orch, + "_kimi_usage_json", + lambda _aid: {"tokens_input": 12, "tokens_output": 34}, + ) + cfg = type("C", (), {"provider_type": "kimi"})() + 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") == (12, 34, 0, 0) + + +def test_kimi_usage_dir_branches_compose_vs_local( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(orch_mod, "PROJECT_HOST_PATH", "") + local = AgentOrchestrator._kimi_usage_dir("be-dev-1") + assert "roboco-kimi-usage" in str(local) + assert local.name == "be-dev-1" + + monkeypatch.setattr(orch_mod, "PROJECT_HOST_PATH", "/volume1/roboco") + monkeypatch.setattr(orch_mod, "KIMI_USAGE_DATA_DIR", "/data/kimi-usage") + assert str(AgentOrchestrator._kimi_usage_dir("be-dev-1")) == ( + "/data/kimi-usage/be-dev-1" + ) + + +@pytest.mark.parametrize( + "bad", + ["..", ".", "../etc", "a/b", "a\\b", "", "be-dev-1/../x", "x\x00y"], +) +def test_kimi_usage_dir_rejects_path_traversal(bad: str) -> None: + with pytest.raises(ValueError, match="unsafe agent id"): + AgentOrchestrator._kimi_usage_dir(bad) + + +def test_kimi_usage_json_reads_the_real_local_dir( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(orch_mod, "PROJECT_HOST_PATH", "") + monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path)) + udir = tmp_path / "roboco-kimi-usage" / "be-dev-1" + udir.mkdir(parents=True) + _write_usage(udir / "usage.json", tokens_input=55, tokens_output=10) + orch = AgentOrchestrator.__new__(AgentOrchestrator) + assert orch._kimi_usage_tokens("be-dev-1") == (55, 10, 0, 0)