fix(grok): stop opencode subagent-stream hang at the config layer

The Grok pr_reviewer wedged in_progress forever: opencode's default agent ran
with the subagent `task` tool enabled, spawned an Explore subagent on
grok-build-0.1 whose model call opened an SSE stream that went idle, and the
run hung with no timeout.

- Hard-disable opencode's subagent `task` tool in the generated opencode.json.
  No RoboCo role uses opencode-internal subagents — work flows through the
  gateway verbs — so removing the tool kills the hang trigger outright.
- Set provider.xai.options.timeout + chunkTimeout (operator-tunable via
  ROBOCO_GROK_REQUEST_TIMEOUT_MS / ROBOCO_GROK_CHUNK_TIMEOUT_MS) as the
  defence-in-depth backstop; chunkTimeout aborts an idle stream.
- Bundle the permission + timeout + subagent knobs into an OpencodeGuards
  dataclass (keeps the builder under the arg-count gate).
- Drop the dead ROBOCO_AGENT_TOOLS spawn env (it had no consumer); opencode
  tool restriction lives in the rendered config now.
This commit is contained in:
Renn F
2026-06-18 11:31:13 +02:00
parent 4720e4129d
commit ff8867dd1f
4 changed files with 143 additions and 17 deletions
-6
View File
@@ -64,10 +64,6 @@ _DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1"
_MCP_CONFIG_IN_CONTAINER = "/app/mcp-config.json"
_SYSTEM_PROMPT_IN_CONTAINER = "/app/system-prompt.md"
# Mirrors the Claude Code tool set. The grok image entrypoint applies this to
# the OpenAI-protocol CLI via the recognised `--tools` flag (not --allowed-tools).
_DEFAULT_TOOLS = "Read,Write,Edit,Bash,Grep,Glob,TodoWrite"
# Reasoning effort by role. grok-build-0.1 reasons heavily by default, and
# reasoning bills at the output rate — it dominates cost (a live "say ok" call
# emitted ~300 reasoning tokens). Code-quality roles (developer, qa, pr_reviewer)
@@ -215,8 +211,6 @@ class GrokProvider(AgentProvider):
"-e",
f"ROBOCO_SYSTEM_PROMPT={_SYSTEM_PROMPT_IN_CONTAINER}",
"-e",
f"ROBOCO_AGENT_TOOLS={_DEFAULT_TOOLS}",
"-e",
f"ROBOCO_INITIAL_PROMPT={initial_prompt or ''}",
]
)
+79 -9
View File
@@ -7,12 +7,18 @@ sets (``OPENAI_*`` + ``ROBOCO_*``) plus the mounted Claude Code
as importable Python (not a shell heredoc) makes the translation unit-testable.
Config shape per opencode docs (https://opencode.ai/docs/config):
* ``provider.<id>`` — ``@ai-sdk/openai-compatible`` with ``options.baseURL`` /
``options.apiKey``; ``model`` selects ``<id>/<model>``.
* ``provider.<id>`` — ``@ai-sdk/openai`` (the Responses API; see ``_PROVIDER_NPM``)
with ``options.baseURL`` / ``options.apiKey`` / ``options.timeout`` /
``options.chunkTimeout``; ``model`` selects ``<id>/<model>``.
* ``mcp.<name>`` — ``{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).
* ``tools`` — opencode's subagent ``task`` tool is hard-disabled. No RoboCo role
uses opencode-internal subagents (work is driven through the gateway verbs),
and a ``task``-spawned subagent on ``grok-build-0.1`` whose model call opens an
idle stream hangs the parent run with no recovery (observed live on a PR
review). The request/stream timeouts below are the defence-in-depth backstop.
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
@@ -41,6 +47,34 @@ _PROVIDER_NPM = "@ai-sdk/openai"
# secret-scrub ports the bash-guard deny rules to opencode's tool.execute.before.
_PLUGINS = ["/app/opencode-plugins/secret-scrub.js"]
# opencode's built-in subagent-spawning tool. Hard-disabled in the generated
# config (see the module docstring): a RoboCo agent never spawns opencode's own
# subagents, and one that does can wedge the parent run on an idle stream.
_SUBAGENT_TOOL = "task"
# Request / stream timeouts (ms) written into ``provider.xai.options``. ``timeout``
# bounds a single model call; ``chunkTimeout`` aborts a stream that goes idle for
# this long (no chunk arrives) — the backstop for the idle-SSE hang. Both are
# operator-tunable via env (see ``main``).
_DEFAULT_REQUEST_TIMEOUT_MS = 300_000
_DEFAULT_CHUNK_TIMEOUT_MS = 120_000
def _env_int(name: str, default: int) -> int:
"""Read a positive int from env ``name``; fall back to ``default``.
A missing, blank, non-integer, or non-positive value yields ``default`` so a
typo in an operator override can never disable the timeout entirely.
"""
raw = os.environ.get(name, "").strip()
if not raw:
return default
try:
value = int(raw)
except ValueError:
return default
return value if value > 0 else default
@dataclass(frozen=True)
class XaiTarget:
@@ -51,6 +85,22 @@ class XaiTarget:
model: str
@dataclass(frozen=True)
class OpencodeGuards:
"""Tunable runtime guards baked into a Grok ``opencode.json``.
``bash``/``edit`` gate the command/file tools; the timeouts bound a single
model call and abort an idle stream; ``disable_subagents`` removes the
subagent ``task`` tool entirely.
"""
bash_permission: str = "allow"
edit_permission: str = "allow"
request_timeout_ms: int = _DEFAULT_REQUEST_TIMEOUT_MS
chunk_timeout_ms: int = _DEFAULT_CHUNK_TIMEOUT_MS
disable_subagents: bool = True
def translate_mcp_servers(mcp_config: dict[str, Any]) -> dict[str, Any]:
"""Translate Claude Code ``mcpServers`` into opencode's ``mcp`` block.
@@ -81,27 +131,39 @@ def build_opencode_config(
target: XaiTarget,
*,
instruction_paths: list[str],
bash_permission: str = "allow",
edit_permission: str = "allow",
guards: OpencodeGuards | None = None,
) -> dict[str, Any]:
"""Build the full ``opencode.json`` dict for a Grok agent."""
return {
guards = guards or OpencodeGuards()
config: dict[str, Any] = {
"$schema": _OPENCODE_SCHEMA,
"provider": {
_PROVIDER_ID: {
"npm": _PROVIDER_NPM,
"name": "xAI",
"options": {"baseURL": target.base_url, "apiKey": target.api_key},
"options": {
"baseURL": target.base_url,
"apiKey": target.api_key,
"timeout": guards.request_timeout_ms,
"chunkTimeout": guards.chunk_timeout_ms,
},
"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},
"permission": {
"bash": guards.bash_permission,
"edit": guards.edit_permission,
},
"instructions": instruction_paths,
# Command guard / secret-scrub (bash-guard parity). Baked into the image.
"plugin": list(_PLUGINS),
}
if guards.disable_subagents:
# Remove the subagent tool entirely so the model can never invoke it.
config["tools"] = {_SUBAGENT_TOOL: False}
return config
def _load_mcp_config(path: str) -> dict[str, Any]:
@@ -129,7 +191,15 @@ def main() -> int:
"ROBOCO_OPENCODE_CONFIG",
str(Path.home() / ".config" / "opencode" / "opencode.json"),
)
bash_perm = os.environ.get("ROBOCO_GROK_BASH_PERMISSION", "allow")
guards = OpencodeGuards(
bash_permission=os.environ.get("ROBOCO_GROK_BASH_PERMISSION", "allow"),
request_timeout_ms=_env_int(
"ROBOCO_GROK_REQUEST_TIMEOUT_MS", _DEFAULT_REQUEST_TIMEOUT_MS
),
chunk_timeout_ms=_env_int(
"ROBOCO_GROK_CHUNK_TIMEOUT_MS", _DEFAULT_CHUNK_TIMEOUT_MS
),
)
# Instructions = system prompt + the SessionStart briefing when mounted.
candidates = [system_prompt, "/app/briefing.md"]
@@ -139,7 +209,7 @@ def main() -> int:
_load_mcp_config(mcp_path),
target,
instruction_paths=instructions,
bash_permission=bash_perm,
guards=guards,
)
out = Path(out_path)
out.parent.mkdir(parents=True, exist_ok=True)
+61 -1
View File
@@ -2,8 +2,15 @@
from __future__ import annotations
import os
from unittest.mock import patch
from roboco.llm.providers.opencode_config import (
_DEFAULT_CHUNK_TIMEOUT_MS,
_DEFAULT_REQUEST_TIMEOUT_MS,
OpencodeGuards,
XaiTarget,
_env_int,
build_opencode_config,
translate_mcp_servers,
)
@@ -90,7 +97,60 @@ def test_build_opencode_config_bash_permission_is_tunable() -> None:
{},
_TARGET,
instruction_paths=[],
bash_permission="deny",
guards=OpencodeGuards(bash_permission="deny"),
)
assert cfg["permission"]["bash"] == "deny"
assert cfg["permission"]["edit"] == "allow"
def test_build_opencode_config_disables_subagent_task_tool_by_default() -> None:
# The subagent `task` tool must be hard-disabled: a RoboCo role never uses
# opencode-internal subagents, and one spawned on grok-build-0.1 hung the run.
cfg = build_opencode_config(_MCP, _TARGET, instruction_paths=[])
assert cfg["tools"] == {"task": False}
def test_build_opencode_config_subagents_can_be_re_enabled() -> None:
cfg = build_opencode_config(
_MCP,
_TARGET,
instruction_paths=[],
guards=OpencodeGuards(disable_subagents=False),
)
assert "tools" not in cfg
def test_build_opencode_config_sets_default_timeouts() -> None:
# Both timeouts land under provider.<id>.options so opencode aborts a stalled
# request / idle stream instead of hanging the parent run forever.
opts = build_opencode_config(_MCP, _TARGET, instruction_paths=[])["provider"][
"xai"
]["options"]
assert opts["timeout"] == _DEFAULT_REQUEST_TIMEOUT_MS
assert opts["chunkTimeout"] == _DEFAULT_CHUNK_TIMEOUT_MS
def test_build_opencode_config_timeouts_are_tunable() -> None:
req_ms, chunk_ms = 111_000, 22_000
opts = build_opencode_config(
_MCP,
_TARGET,
instruction_paths=[],
guards=OpencodeGuards(request_timeout_ms=req_ms, chunk_timeout_ms=chunk_ms),
)["provider"]["xai"]["options"]
assert opts["timeout"] == req_ms
assert opts["chunkTimeout"] == chunk_ms
def test_env_int_parses_and_falls_back() -> None:
fallback = 999
parsed = 45_000
with patch.dict(os.environ, {"X_MS": str(parsed)}):
assert _env_int("X_MS", fallback) == parsed
# Missing, blank, non-integer, and non-positive all fall back to the default
# so a bad operator override can never disable the timeout entirely.
with patch.dict(os.environ, {}, clear=True):
assert _env_int("X_MS", fallback) == fallback
for bad in ("", " ", "abc", "0", "-5"):
with patch.dict(os.environ, {"X_MS": bad}):
assert _env_int("X_MS", fallback) == fallback
+3 -1
View File
@@ -197,7 +197,9 @@ async def test_grok_spawn_wires_gateway_and_image_last() -> None:
# Gateway + operational env the grok image entrypoint consumes.
assert "ROBOCO_MCP_CONFIG=/app/mcp-config.json" in cmd
assert "ROBOCO_SYSTEM_PROMPT=/app/system-prompt.md" in cmd
assert "ROBOCO_AGENT_TOOLS=Read,Write,Edit,Bash,Grep,Glob,TodoWrite" in cmd
# Tool restriction lives in the rendered opencode.json (opencode `tools`),
# not a spawn env var — no ROBOCO_AGENT_TOOLS is injected.
assert not any(c.startswith("ROBOCO_AGENT_TOOLS=") for c in cmd)
# Identity wiring from the shared host helpers is present.
assert "ROBOCO_AGENT_TOKEN=hmac-be-dev-1" in cmd
# The image is the final docker-run argument.