diff --git a/docker/scripts/grok-cli-agent-entrypoint.sh b/docker/scripts/grok-cli-agent-entrypoint.sh index 33ee3ddd..a9e7fe59 100755 --- a/docker/scripts/grok-cli-agent-entrypoint.sh +++ b/docker/scripts/grok-cli-agent-entrypoint.sh @@ -36,6 +36,10 @@ fi RUN_LOG="/tmp/grok-run.json" ERR_LOG="/tmp/grok-run.err" 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 # 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. diff --git a/roboco/agent_sdk/grok_cli_session.py b/roboco/agent_sdk/grok_cli_session.py index c2fcfdaf..b699140e 100644 --- a/roboco/agent_sdk/grok_cli_session.py +++ b/roboco/agent_sdk/grok_cli_session.py @@ -193,6 +193,9 @@ class GrokCliSession: # pragma: no cover - needs the live grok binary *self._role_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: argv += ["-r", self._session_id] return argv diff --git a/roboco/agent_sdk/grok_intake_main.py b/roboco/agent_sdk/grok_intake_main.py index 71b9ae8a..2e060180 100644 --- a/roboco/agent_sdk/grok_intake_main.py +++ b/roboco/agent_sdk/grok_intake_main.py @@ -28,7 +28,11 @@ from roboco.agent_sdk.intake_main import ( make_message_source, 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: 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") _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() client = httpx.AsyncClient(timeout=30.0) diff --git a/roboco/agent_sdk/grok_secretary_main.py b/roboco/agent_sdk/grok_secretary_main.py index a4576fe5..e2d3aa4f 100644 --- a/roboco/agent_sdk/grok_secretary_main.py +++ b/roboco/agent_sdk/grok_secretary_main.py @@ -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_main import build_receiver, make_message_source 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: 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") _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() client = httpx.AsyncClient(timeout=30.0) diff --git a/roboco/llm/providers/grok_cli_config.py b/roboco/llm/providers/grok_cli_config.py index 5beebae1..91251511 100644 --- a/roboco/llm/providers/grok_cli_config.py +++ b/roboco/llm/providers/grok_cli_config.py @@ -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 # is ``/home/agent``; the host ``auth.json`` is mounted alongside it). 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. 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 {} +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: - """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", "") mcp_path = os.environ.get("ROBOCO_MCP_CONFIG", "/app/mcp-config.json") try: @@ -193,6 +222,7 @@ def main() -> int: GROK_CONFIG_PATH.write_text( render_config_toml(_load_mcp_config(mcp_path)), encoding="utf-8" ) + write_agents_md() GROK_ARGS_PATH.write_text( "\n".join(grok_cli_args(agent_id, max_turns=max_turns)) + "\n", encoding="utf-8" ) diff --git a/tests/unit/llm/providers/test_grok_cli_config.py b/tests/unit/llm/providers/test_grok_cli_config.py index d6a61ab9..9833468f 100644 --- a/tests/unit/llm/providers/test_grok_cli_config.py +++ b/tests/unit/llm/providers/test_grok_cli_config.py @@ -8,6 +8,8 @@ from typing import TYPE_CHECKING from roboco.llm.providers import grok_cli_config as gc if TYPE_CHECKING: + from pathlib import Path + import pytest _SAMPLE_MCP = { @@ -42,6 +44,20 @@ def test_render_config_toml_empty_when_no_servers() -> None: 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: monkeypatch.delenv("ROBOCO_GROK_REASONING_EFFORT", raising=False) args = gc.grok_cli_args("be-dev-1")