diff --git a/CHANGELOG.md b/CHANGELOG.md index f082ce48..b2ec2449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added -- **Pluggable agent providers + a Grok (xAI) backend.** A new `roboco/llm/providers/` seam (an `AgentProvider` lifecycle ABC + a `ProviderRegistry` keyed by `ModelProvider`) lets the orchestrator drive agent backends other than Claude Code. The first is `GrokProvider` for xAI's `grok-build-0.1`: xAI is OpenAI-compatible only (no Anthropic-Messages endpoint), so a Grok agent runs an OpenAI-protocol runtime pointed at `https://api.x.ai/v1` rather than the `ANTHROPIC_BASE_URL` injection the other providers use — and it reuses the orchestrator's existing mount/auth assembly, so it gets the same MCP gateway + tool-manifest wiring as every other agent by construction (and passes its prompt via env, never an argv positional). The change is purely additive: only `GROK` routes through the registry; Anthropic / Ollama Cloud / self-hosted spawns are untouched. Includes the `grok` enum value (migration 038), a seeded Grok provider row (migration 039), a `grok-build-0.1` catalog entry, and `GET/PUT /api/providers/grok-key` to store the xAI key (Fernet-encrypted, reusing the existing provider-key machinery). The dedicated `roboco-agent-grok` image plus the exact OpenAI-protocol CLI invocation are the remaining piece to finalize together with xAI. +- **Pluggable agent providers + a Grok (xAI) backend.** A new `roboco/llm/providers/` seam (an `AgentProvider` lifecycle ABC + a `ProviderRegistry` keyed by `ModelProvider`) lets the orchestrator drive agent backends other than Claude Code. The first is `GrokProvider` for xAI's `grok-build-0.1`: xAI is OpenAI-compatible only (no Anthropic-Messages endpoint), so a Grok agent runs an OpenAI-protocol runtime pointed at `https://api.x.ai/v1` rather than the `ANTHROPIC_BASE_URL` injection the other providers use — and it reuses the orchestrator's existing mount/auth assembly, so it gets the same MCP gateway + tool-manifest wiring as every other agent by construction (and passes its prompt via env, never an argv positional). The change is purely additive: only `GROK` routes through the registry; Anthropic / Ollama Cloud / self-hosted spawns are untouched. Includes the `grok` enum value (migration 038), a seeded Grok provider row (migration 039), a `grok-build-0.1` catalog entry, and `GET/PUT /api/providers/grok-key` + a Settings panel card to store the xAI key (Fernet-encrypted, reusing the existing provider-key machinery). Ships the full native runtime: a first-class `roboco-agent-grok` image (built `FROM` agent-base, adds opencode; wired into both compose files, the registry compose, and the release workflow) whose entrypoint renders an `opencode.json` at spawn — translating RoboCo's MCP gateway servers into opencode's config and declaring the xAI provider — then runs opencode. KNOWN PARITY GAP: RoboCo's bash-guard (PAT-scrub) and transcript-based usage/cost capture are Claude Code hooks that do not transfer to the opencode runtime; the `bash` permission is operator-tunable so a deployment can fail closed until a security/usage-parity opencode plugin lands. That plugin and live end-to-end validation are the remaining work to finalize with xAI. ## [0.6.0] - 2026-06-17 diff --git a/docker-compose.registry.yml b/docker-compose.registry.yml index 1784396c..f2088150 100644 --- a/docker-compose.registry.yml +++ b/docker-compose.registry.yml @@ -160,6 +160,11 @@ services: entrypoint: ["/bin/sh", "-c", "echo 'agent-pr-reviewer image present'"] restart: "no" + agent-grok-image: + image: ${ROBOCO_REGISTRY:-ghcr.io/rennf93}/roboco-agent-grok:${ROBOCO_VERSION:-latest} + entrypoint: ["/bin/sh", "-c", "echo 'agent-grok image present'"] + restart: "no" + # -------------------------------------------------------------------------- # Orchestrator — API server + agent spawner # -------------------------------------------------------------------------- diff --git a/docker-compose.yaml b/docker-compose.yaml index 6391c268..33bda725 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -229,6 +229,19 @@ services: depends_on: - agent-base-image + # ========================================================================== + # Agent Grok Image Builder (xAI grok-build-0.1 via opencode, OpenAI protocol) + # ========================================================================== + agent-grok-image: + build: + context: . + dockerfile: docker/agent-grok.Dockerfile + image: roboco-agent-grok + entrypoint: ["/bin/sh", "-c", 'echo "Agent Grok image built"'] + restart: "no" + depends_on: + - agent-base-image + # ========================================================================== # Orchestrator - API Server + Agent Spawner # ========================================================================== diff --git a/docker-compose.yml b/docker-compose.yml index 6391c268..33bda725 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -229,6 +229,19 @@ services: depends_on: - agent-base-image + # ========================================================================== + # Agent Grok Image Builder (xAI grok-build-0.1 via opencode, OpenAI protocol) + # ========================================================================== + agent-grok-image: + build: + context: . + dockerfile: docker/agent-grok.Dockerfile + image: roboco-agent-grok + entrypoint: ["/bin/sh", "-c", 'echo "Agent Grok image built"'] + restart: "no" + depends_on: + - agent-base-image + # ========================================================================== # Orchestrator - API Server + Agent Spawner # ========================================================================== diff --git a/docker/agent-grok.Dockerfile b/docker/agent-grok.Dockerfile new file mode 100644 index 00000000..8ff54ccc --- /dev/null +++ b/docker/agent-grok.Dockerfile @@ -0,0 +1,32 @@ +# Grok (xAI) Agent Image +# ============================================================================= +# Runs grok-build-0.1 through the opencode CLI (OpenAI protocol) instead of +# Claude Code, while reusing the base image's roboco venv + uv + the RoboCo MCP +# gateway servers. The entrypoint renders opencode.json from the spawn env + +# mounted mcp-config.json (see roboco.llm.providers.opencode_config) and runs +# opencode. One runtime image serves every role — role behaviour comes from the +# mounted system prompt / manifest / mcp-config, exactly as on the Claude path. +# ============================================================================= + +FROM roboco-agent-base + +USER root + +# opencode — the OpenAI-protocol agent runtime. The @ai-sdk/openai-compatible +# package backs the custom xAI provider declared in the generated opencode.json; +# opencode also resolves it at runtime, but pre-installing keeps first spawn off +# the network. +RUN npm install -g opencode-ai @ai-sdk/openai-compatible \ + && npm cache clean --force \ + && rm -rf /root/.npm /tmp/* + +# Entrypoint: render opencode.json, then run opencode (overrides base's `claude`). +COPY docker/scripts/grok-agent-entrypoint.sh /app/scripts/grok-agent-entrypoint.sh +RUN chmod 0755 /app/scripts/grok-agent-entrypoint.sh + +USER agent + +LABEL role="grok-runtime" +LABEL description="Grok (xAI) agent runtime — grok-build-0.1 via opencode (OpenAI protocol)" + +ENTRYPOINT ["/app/scripts/grok-agent-entrypoint.sh"] diff --git a/docker/scripts/grok-agent-entrypoint.sh b/docker/scripts/grok-agent-entrypoint.sh new file mode 100755 index 00000000..f3c97da2 --- /dev/null +++ b/docker/scripts/grok-agent-entrypoint.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# Entrypoint for the roboco-agent-grok image. +# +# Renders opencode.json from the RoboCo spawn env (OPENAI_* + ROBOCO_*, set by +# GrokProvider) plus the mounted Claude Code mcp-config.json, then runs opencode +# non-interactively. opencode speaks the OpenAI protocol, so grok-build-0.1 runs +# natively against api.x.ai/v1 with no shim, while still reaching the RoboCo MCP +# gateway (roboco-flow / roboco-do / ...) translated into opencode's mcp config. +set -euo pipefail + +# Generate opencode.json (provider + model + MCP gateway + permissions + +# instructions). Writes to opencode's global config dir by default. +python -m roboco.llm.providers.opencode_config + +# Run the agent. The prompt comes from an env var (never an untrusted argv +# positional); `--` separates it from flags so a prompt starting with `--` +# cannot be parsed as CLI options. The model also comes from the rendered +# config; --model is passed explicitly as belt-and-suspenders. +exec opencode run \ + --model "xai/${ROBOCO_AGENT_MODEL:-grok-build-0.1}" \ + -- "${ROBOCO_INITIAL_PROMPT:-}" diff --git a/panel/src/components/settings/ai-routing-card.tsx b/panel/src/components/settings/ai-routing-card.tsx index 538fea69..15cd8c1b 100644 --- a/panel/src/components/settings/ai-routing-card.tsx +++ b/panel/src/components/settings/ai-routing-card.tsx @@ -4,8 +4,10 @@ import { useEffect, useMemo, useState, useCallback } from "react"; import { useApplyMode, useCatalog, + useGrokKey, useOllamaKey, useRoutingMode, + useSetGrokKey, useSetOllamaKey, useSelfHostedModels, } from "@/hooks/use-providers"; @@ -124,6 +126,33 @@ export function AIRoutingCard() { } }; + // --- Grok (xAI) API key --- + const { data: grokKeyStatus } = useGrokKey(); + const setGrokKeyMut = useSetGrokKey(); + const hasGrokKey = !!grokKeyStatus?.has_key; + const [grokKey, setGrokKey] = useState(""); + const [clearGrokKey, setClearGrokKey] = useState(false); + + const saveGrokKey = async () => { + try { + if (clearGrokKey) { + await setGrokKeyMut.mutateAsync(""); + toast.success("Grok key cleared"); + } else { + if (!grokKey.trim()) { + toast.error("Enter a key first"); + return; + } + await setGrokKeyMut.mutateAsync(grokKey); + toast.success("Grok key saved"); + } + setGrokKey(""); + setClearGrokKey(false); + } catch (e) { + toast.error("Save failed: " + errMsg(e)); + } + }; + // --- Mix mode state: agent_slug → model_name --- const initialMix = useMemo(() => { const map: Record = {}; @@ -300,6 +329,56 @@ export function AIRoutingCard() { + {/* -------- Grok (xAI) key -------- */} +
+
+ + {hasGrokKey ? ( + + key set + + ) : ( + + not set + + )} +
+
+ setGrokKey(e.target.value)} + placeholder={ + hasGrokKey ? "•••••••••••• (leave blank to keep)" : "xai-…" + } + disabled={clearGrokKey} + /> + +
+ {hasGrokKey ? ( + + ) : ( +

+ Used for grok-build-0.1 at api.x.ai/v1. Stored Fernet-encrypted + server-side; never returned by the API. +

+ )} +
+ + + {/* -------- Self-Hosted LLM -------- */} [...providerKeys.all, "catalog"] as const, ollamaKey: () => [...providerKeys.all, "ollama-key"] as const, + grokKey: () => [...providerKeys.all, "grok-key"] as const, mode: () => [...providerKeys.all, "mode"] as const, selfHostedConfig: () => [...providerKeys.all, "self-hosted-config"] as const, selfHostedModels: () => [...providerKeys.all, "self-hosted-models"] as const, @@ -43,6 +44,25 @@ export function useSetOllamaKey() { }); } +export function useGrokKey() { + return useQuery({ + queryKey: providerKeys.grokKey(), + queryFn: () => providersApi.getGrokKey(), + staleTime: 60_000, + }); +} + +export function useSetGrokKey() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (apiKey: string) => providersApi.setGrokKey(apiKey), + onSuccess: () => { + qc.invalidateQueries({ queryKey: providerKeys.grokKey() }); + qc.invalidateQueries({ queryKey: providerKeys.mode() }); + }, + }); +} + export function useRoutingMode() { return useQuery({ queryKey: providerKeys.mode(), diff --git a/panel/src/lib/api/providers.ts b/panel/src/lib/api/providers.ts index 3012ffd9..91a8659e 100644 --- a/panel/src/lib/api/providers.ts +++ b/panel/src/lib/api/providers.ts @@ -16,6 +16,11 @@ export interface OllamaKeyStatus { enabled: boolean; } +export interface GrokKeyStatus { + has_key: boolean; + enabled: boolean; +} + export interface ModelAssignment { id: string; scope: AssignmentScope; @@ -86,6 +91,18 @@ export const providersApi = { return data; }, + getGrokKey: async (): Promise => { + const { data } = await api.get("/providers/grok-key"); + return data; + }, + + setGrokKey: async (apiKey: string): Promise => { + const { data } = await api.put("/providers/grok-key", { + api_key: apiKey, + }); + return data; + }, + getMode: async (): Promise => { const { data } = await api.get("/providers"); return data; diff --git a/panel/src/types/index.ts b/panel/src/types/index.ts index 8984087a..91e50817 100644 --- a/panel/src/types/index.ts +++ b/panel/src/types/index.ts @@ -104,6 +104,7 @@ export enum ModelProvider { OLLAMA_CLOUD = "ollama_cloud", OPENAI = "openai", LOCAL = "local", + GROK = "grok", } export enum AssignmentScope { diff --git a/roboco/llm/providers/opencode_config.py b/roboco/llm/providers/opencode_config.py new file mode 100644 index 00000000..c6df197d --- /dev/null +++ b/roboco/llm/providers/opencode_config.py @@ -0,0 +1,142 @@ +"""Generate an ``opencode.json`` for a Grok (xAI) agent at container start. + +The ``roboco-agent-grok`` image's entrypoint runs ``python -m +roboco.llm.providers.opencode_config`` to turn the env contract ``GrokProvider`` +sets (``OPENAI_*`` + ``ROBOCO_*``) plus the mounted Claude Code +``mcp-config.json`` into the ``opencode.json`` that opencode reads. Keeping this +as importable Python (not a shell heredoc) makes the translation unit-testable. + +Config shape per opencode docs (https://opencode.ai/docs/config): + * ``provider.`` — ``@ai-sdk/openai-compatible`` with ``options.baseURL`` / + ``options.apiKey``; ``model`` selects ``/``. + * ``mcp.`` — ``{type:"local", command:[...], environment:{...}}``; this + is where RoboCo's gateway servers (roboco-flow / roboco-do / ...) are wired, + translated from Claude Code's ``mcpServers`` (``command`` + ``args`` + ``env``). + * ``permission.{bash,edit}`` and ``instructions`` (system prompt + briefing). + +KNOWN PARITY GAP (tracked for the opencode-plugin follow-up with xAI): RoboCo's +bash-guard (PAT-scrub) and transcript-based usage/cost capture are Claude Code +hooks; they do not transfer to the opencode runtime. ``bash`` permission is +operator-tunable (``ROBOCO_GROK_BASH_PERMISSION``) so a deployment can fail +closed (``deny``/``ask``) until a security-parity opencode plugin lands. +""" + +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +_OPENCODE_SCHEMA = "https://opencode.ai/config.json" +_PROVIDER_ID = "xai" +_OPENAI_COMPAT_NPM = "@ai-sdk/openai-compatible" + + +@dataclass(frozen=True) +class XaiTarget: + """The xAI endpoint a Grok agent talks to.""" + + base_url: str + api_key: str + model: str + + +def translate_mcp_servers(mcp_config: dict[str, Any]) -> dict[str, Any]: + """Translate Claude Code ``mcpServers`` into opencode's ``mcp`` block. + + ``{"command": "uv", "args": [...], "env": {...}}`` becomes + ``{"type": "local", "command": ["uv", ...], "environment": {...}, + "enabled": True}``. + """ + servers = mcp_config.get("mcpServers", {}) + out: dict[str, Any] = {} + for name, spec in servers.items(): + command = spec.get("command") + args = list(spec.get("args", [])) + cmd_list = [command, *args] if command else args + entry: dict[str, Any] = { + "type": "local", + "command": cmd_list, + "enabled": True, + } + env = spec.get("env") + if env: + entry["environment"] = env + out[name] = entry + return out + + +def build_opencode_config( + mcp_config: dict[str, Any], + target: XaiTarget, + *, + instruction_paths: list[str], + bash_permission: str = "allow", + edit_permission: str = "allow", +) -> dict[str, Any]: + """Build the full ``opencode.json`` dict for a Grok agent.""" + return { + "$schema": _OPENCODE_SCHEMA, + "provider": { + _PROVIDER_ID: { + "npm": _OPENAI_COMPAT_NPM, + "name": "xAI", + "options": {"baseURL": target.base_url, "apiKey": target.api_key}, + "models": {target.model: {"name": target.model}}, + } + }, + "model": f"{_PROVIDER_ID}/{target.model}", + "mcp": translate_mcp_servers(mcp_config), + "permission": {"bash": bash_permission, "edit": edit_permission}, + "instructions": instruction_paths, + } + + +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() as fh: + data: dict[str, Any] = json.load(fh) + return data + except (OSError, json.JSONDecodeError): + return {} + + +def main() -> int: + """Entrypoint: read env + mounted mcp-config.json, write opencode.json.""" + target = XaiTarget( + base_url=os.environ.get("OPENAI_BASE_URL", "https://api.x.ai/v1"), + api_key=os.environ.get("OPENAI_API_KEY", ""), + model=os.environ.get("ROBOCO_AGENT_MODEL", "grok-build-0.1"), + ) + mcp_path = os.environ.get("ROBOCO_MCP_CONFIG", "/app/mcp-config.json") + system_prompt = os.environ.get("ROBOCO_SYSTEM_PROMPT", "/app/system-prompt.md") + # Default to opencode's global config location so it is found regardless of + # the agent's working directory (cwd is the per-agent workspace at spawn). + out_path = os.environ.get( + "ROBOCO_OPENCODE_CONFIG", + str(Path.home() / ".config" / "opencode" / "opencode.json"), + ) + bash_perm = os.environ.get("ROBOCO_GROK_BASH_PERMISSION", "allow") + + # Instructions = system prompt + the SessionStart briefing when mounted. + candidates = [system_prompt, "/app/briefing.md"] + instructions = [p for p in candidates if p and Path(p).exists()] + + config = build_opencode_config( + _load_mcp_config(mcp_path), + target, + instruction_paths=instructions, + bash_permission=bash_perm, + ) + out = Path(out_path) + out.parent.mkdir(parents=True, exist_ok=True) + with out.open("w") as fh: + json.dump(config, fh, indent=2) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index 8817fe66..1f6ee157 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -1972,7 +1972,13 @@ class AgentOrchestrator: from roboco.models.base import ModelProvider registry = ProviderRegistry() - registry.register(ModelProvider.GROK, GrokProvider(self)) + # Qualify the grok image with the registry namespace + tag so it + # resolves in both local-build and registry deploys (parity with + # get_agent_image for the Claude path). + registry.register( + ModelProvider.GROK, + GrokProvider(self, image=_qualify_agent_image("roboco-agent-grok")), + ) self._provider_registry = registry return self._provider_registry diff --git a/tests/unit/llm/test_opencode_config.py b/tests/unit/llm/test_opencode_config.py new file mode 100644 index 00000000..55d926fb --- /dev/null +++ b/tests/unit/llm/test_opencode_config.py @@ -0,0 +1,93 @@ +"""Tests for the Grok opencode.json generator (RoboCo MCP -> opencode config).""" + +from __future__ import annotations + +from roboco.llm.providers.opencode_config import ( + XaiTarget, + build_opencode_config, + translate_mcp_servers, +) + +_TARGET = XaiTarget( + base_url="https://api.x.ai/v1", api_key="xai-key", model="grok-build-0.1" +) + +_MCP = { + "mcpServers": { + "roboco-flow": { + "command": "uv", + "args": ["run", "--no-sync", "python", "-m", "roboco.mcp.flow_server"], + "env": { + "ROBOCO_AGENT_ID": "uuid-1", + "UV_PROJECT_ENVIRONMENT": "/app/.venv", + }, + }, + "roboco-do": { + "command": "uv", + "args": ["run", "--no-sync", "python", "-m", "roboco.mcp.do_server"], + "env": {"ROBOCO_AGENT_ID": "uuid-1"}, + }, + } +} + + +def test_translate_mcp_servers_shape() -> None: + out = translate_mcp_servers(_MCP) + flow = out["roboco-flow"] + assert flow["type"] == "local" + assert flow["enabled"] is True + # command + args collapse into a single command array (opencode shape). + assert flow["command"] == [ + "uv", + "run", + "--no-sync", + "python", + "-m", + "roboco.mcp.flow_server", + ] + # env -> environment (opencode key). + assert flow["environment"]["ROBOCO_AGENT_ID"] == "uuid-1" + assert "env" not in flow + assert set(out) == {"roboco-flow", "roboco-do"} + + +def test_translate_mcp_servers_empty() -> None: + assert translate_mcp_servers({}) == {} + assert translate_mcp_servers({"mcpServers": {}}) == {} + + +def test_translate_mcp_servers_omits_environment_when_no_env() -> None: + out = translate_mcp_servers( + {"mcpServers": {"x": {"command": "uv", "args": ["run"]}}} + ) + assert "environment" not in out["x"] + assert out["x"]["command"] == ["uv", "run"] + + +def test_build_opencode_config_provider_and_model() -> None: + cfg = build_opencode_config( + _MCP, + _TARGET, + instruction_paths=["/app/system-prompt.md"], + ) + provider = cfg["provider"]["xai"] + assert provider["npm"] == "@ai-sdk/openai-compatible" + assert provider["options"]["baseURL"] == "https://api.x.ai/v1" + assert provider["options"]["apiKey"] == "xai-key" + assert "grok-build-0.1" in provider["models"] + # Top-level model selector is "/". + assert cfg["model"] == "xai/grok-build-0.1" + # Gateway servers carried through. + assert "roboco-flow" in cfg["mcp"] + assert cfg["instructions"] == ["/app/system-prompt.md"] + + +def test_build_opencode_config_bash_permission_is_tunable() -> None: + cfg = build_opencode_config( + {}, + _TARGET, + instruction_paths=[], + bash_permission="deny", + ) + assert cfg["permission"]["bash"] == "deny" + assert cfg["permission"]["edit"] == "allow"