fix(grok): deliver the role blueprint as grok's system prompt via ~/.grok/AGENTS.md

The blueprint was mounted at /app/system-prompt.md but never reached grok — a real
parity gap vs the Claude path (which passes --system-prompt-file). grok agents ran
only on the per-task prompt, missing their RoboCo role/org context.

Verified live on grok 0.2.56 that the obvious flags do NOT work headless:
`--system-prompt-override` and `--rules` are silently ignored under `grok -p`
(identical output with and without). What IS honoured is grok's instruction-file
discovery — and `$HOME/.grok/AGENTS.md` is loaded GLOBALLY regardless of --cwd
(a project AGENTS.md only loads from the cwd/project root, which would pollute the
agent's git workspace). Proven end to end: a blueprint written there makes grok
adopt the role ("I am the RoboCo intake interviewer ... -- intake-1").

write_agents_md() copies /app/system-prompt.md -> ~/.grok/AGENTS.md; the one-shot
render (grok_cli_config.main) and both interactive mains call it. No git pollution
(it lives in ~/.grok, not the workspace), and it covers repo-cwd and /app-cwd
roles alike. Reverted the non-working --system-prompt-override wiring.
This commit is contained in:
Renn F
2026-06-19 05:00:58 +02:00
parent a88045aacf
commit 414b3492c4
6 changed files with 68 additions and 3 deletions
@@ -36,6 +36,10 @@ fi
RUN_LOG="/tmp/grok-run.json" RUN_LOG="/tmp/grok-run.json"
ERR_LOG="/tmp/grok-run.err" ERR_LOG="/tmp/grok-run.err"
WORKSPACE="${ROBOCO_WORKSPACE:-$PWD}" WORKSPACE="${ROBOCO_WORKSPACE:-$PWD}"
# The role blueprint reaches grok as its system prompt via ~/.grok/AGENTS.md (a
# global instruction file grok loads regardless of --cwd, verified live — the
# `--system-prompt-override`/`--rules` flags are ignored in headless mode). The
# render step above (grok_cli_config) wrote it from the mounted system prompt.
# NOTE: grok generates its own session id and ignores a requested one (`-s` does # NOTE: grok generates its own session id and ignores a requested one (`-s` does
# not pin it), so we do NOT pass a session id in; usage capture below reads the # not pin it), so we do NOT pass a session id in; usage capture below reads the
# real id back out of the JSON run log instead. # real id back out of the JSON run log instead.
+3
View File
@@ -193,6 +193,9 @@ class GrokCliSession: # pragma: no cover - needs the live grok binary
*self._role_args, *self._role_args,
*self._extra_args, *self._extra_args,
] ]
# The role blueprint reaches grok as its system prompt via the global
# ~/.grok/AGENTS.md the main writes (not a per-turn flag — the headless
# --system-prompt-override is ignored).
if self._session_id: if self._session_id:
argv += ["-r", self._session_id] argv += ["-r", self._session_id]
return argv return argv
+7 -1
View File
@@ -28,7 +28,11 @@ from roboco.agent_sdk.intake_main import (
make_message_source, make_message_source,
make_relay_sink, make_relay_sink,
) )
from roboco.llm.providers.grok_cli_config import GROK_CONFIG_PATH, render_config_toml from roboco.llm.providers.grok_cli_config import (
GROK_CONFIG_PATH,
render_config_toml,
write_agents_md,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
@@ -80,6 +84,8 @@ async def main() -> None: # pragma: no cover - needs the live container + grok
cwd = os.environ.get("ROBOCO_WORKSPACE", "/data/workspace") cwd = os.environ.get("ROBOCO_WORKSPACE", "/data/workspace")
_render_grok_config(base_url, session_id) _render_grok_config(base_url, session_id)
# Install the role blueprint as grok's global system prompt (~/.grok/AGENTS.md).
write_agents_md()
queue: asyncio.Queue[str | None] = asyncio.Queue() queue: asyncio.Queue[str | None] = asyncio.Queue()
client = httpx.AsyncClient(timeout=30.0) client = httpx.AsyncClient(timeout=30.0)
+7 -1
View File
@@ -24,7 +24,11 @@ from roboco.agent_sdk.grok_cli_session import GrokCliSession
from roboco.agent_sdk.intake_driver import IntakeDriver from roboco.agent_sdk.intake_driver import IntakeDriver
from roboco.agent_sdk.intake_main import build_receiver, make_message_source from roboco.agent_sdk.intake_main import build_receiver, make_message_source
from roboco.agent_sdk.secretary_main import make_relay_sink from roboco.agent_sdk.secretary_main import make_relay_sink
from roboco.llm.providers.grok_cli_config import GROK_CONFIG_PATH, render_config_toml from roboco.llm.providers.grok_cli_config import (
GROK_CONFIG_PATH,
render_config_toml,
write_agents_md,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
@@ -78,6 +82,8 @@ async def main() -> None: # pragma: no cover - needs the live container + grok
cwd = os.environ.get("ROBOCO_WORKSPACE", "/app") cwd = os.environ.get("ROBOCO_WORKSPACE", "/app")
_render_grok_config(base_url) _render_grok_config(base_url)
# Install the role blueprint as grok's global system prompt (~/.grok/AGENTS.md).
write_agents_md()
queue: asyncio.Queue[str | None] = asyncio.Queue() queue: asyncio.Queue[str | None] = asyncio.Queue()
client = httpx.AsyncClient(timeout=30.0) client = httpx.AsyncClient(timeout=30.0)
+31 -1
View File
@@ -41,6 +41,16 @@ from roboco.services.gateway.role_config import get_role_config
# grok reads its global config from ``$HOME/.grok/config.toml`` (the agent's HOME # grok reads its global config from ``$HOME/.grok/config.toml`` (the agent's HOME
# is ``/home/agent``; the host ``auth.json`` is mounted alongside it). # is ``/home/agent``; the host ``auth.json`` is mounted alongside it).
GROK_CONFIG_PATH = Path.home() / ".grok" / "config.toml" GROK_CONFIG_PATH = Path.home() / ".grok" / "config.toml"
# grok loads ``$HOME/.grok/AGENTS.md`` as a GLOBAL instruction file regardless of
# --cwd (verified live; the --system-prompt-override / --rules flags are ignored
# in headless mode). This is how the RoboCo role blueprint becomes grok's system
# prompt — the parity analogue of the Claude path's --system-prompt-file — without
# writing into (and polluting) the agent's git workspace.
GROK_AGENTS_PATH = Path.home() / ".grok" / "AGENTS.md"
# The composed role blueprint the orchestrator mounts into every agent container.
SYSTEM_PROMPT_PATH = Path(
os.environ.get("ROBOCO_SYSTEM_PROMPT", "/app/system-prompt.md")
)
# The entrypoint reads the computed flags (one token per line) from this file. # The entrypoint reads the computed flags (one token per line) from this file.
GROK_ARGS_PATH = Path(os.environ.get("ROBOCO_GROK_ARGS_FILE", "/tmp/roboco-grok-args")) GROK_ARGS_PATH = Path(os.environ.get("ROBOCO_GROK_ARGS_FILE", "/tmp/roboco-grok-args"))
@@ -178,8 +188,27 @@ def _load_mcp_config(path: str) -> dict[str, Any]:
return {} return {}
def write_agents_md(
*, source: Path = SYSTEM_PROMPT_PATH, dest: Path = GROK_AGENTS_PATH
) -> bool:
"""Install the mounted role blueprint as grok's global system prompt.
Copies the composed prompt to ``~/.grok/AGENTS.md`` (the global instruction
file grok honours in headless mode). Best-effort: returns False and writes
nothing if the source is absent / unreadable, so a missing prompt never fails
the render.
"""
try:
blueprint = source.read_text(encoding="utf-8")
except OSError:
return False
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(blueprint, encoding="utf-8")
return True
def main() -> int: def main() -> int:
"""Entrypoint: write ``~/.grok/config.toml`` + the per-role args file.""" """Entrypoint: write ``~/.grok/config.toml`` + AGENTS.md + the per-role args."""
agent_id = os.environ.get("ROBOCO_AGENT_ID", "") agent_id = os.environ.get("ROBOCO_AGENT_ID", "")
mcp_path = os.environ.get("ROBOCO_MCP_CONFIG", "/app/mcp-config.json") mcp_path = os.environ.get("ROBOCO_MCP_CONFIG", "/app/mcp-config.json")
try: try:
@@ -193,6 +222,7 @@ def main() -> int:
GROK_CONFIG_PATH.write_text( GROK_CONFIG_PATH.write_text(
render_config_toml(_load_mcp_config(mcp_path)), encoding="utf-8" render_config_toml(_load_mcp_config(mcp_path)), encoding="utf-8"
) )
write_agents_md()
GROK_ARGS_PATH.write_text( GROK_ARGS_PATH.write_text(
"\n".join(grok_cli_args(agent_id, max_turns=max_turns)) + "\n", encoding="utf-8" "\n".join(grok_cli_args(agent_id, max_turns=max_turns)) + "\n", encoding="utf-8"
) )
@@ -8,6 +8,8 @@ from typing import TYPE_CHECKING
from roboco.llm.providers import grok_cli_config as gc from roboco.llm.providers import grok_cli_config as gc
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path
import pytest import pytest
_SAMPLE_MCP = { _SAMPLE_MCP = {
@@ -42,6 +44,20 @@ def test_render_config_toml_empty_when_no_servers() -> None:
assert gc.render_config_toml({"mcpServers": {}}) == "" assert gc.render_config_toml({"mcpServers": {}}) == ""
def test_write_agents_md_installs_the_blueprint(tmp_path: Path) -> None:
src = tmp_path / "system-prompt.md"
src.write_text("You are the RoboCo intake interviewer.", encoding="utf-8")
dest = tmp_path / ".grok" / "AGENTS.md"
assert gc.write_agents_md(source=src, dest=dest) is True
assert dest.read_text(encoding="utf-8") == "You are the RoboCo intake interviewer."
def test_write_agents_md_noops_when_source_absent(tmp_path: Path) -> None:
dest = tmp_path / ".grok" / "AGENTS.md"
assert gc.write_agents_md(source=tmp_path / "absent.md", dest=dest) is False
assert not dest.exists()
def test_developer_flags(monkeypatch: pytest.MonkeyPatch) -> None: def test_developer_flags(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("ROBOCO_GROK_REASONING_EFFORT", raising=False) monkeypatch.delenv("ROBOCO_GROK_REASONING_EFFORT", raising=False)
args = gc.grok_cli_args("be-dev-1") args = gc.grok_cli_args("be-dev-1")