diff --git a/docs/protocol.md b/docs/protocol.md index 9117ccf..fb48667 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -288,7 +288,10 @@ steps = [["python", "demo.py", "--agent", "naive", "--model", "stable"]] ``` Every step is an argv list. Steps run in order, and the first one that exits non-zero ends the -action. Unknown keys are errors. Limits: 50 actions, 20 steps each, 100 arguments per step. +action. Each step's output is followed by a line saying how it ended: `[done · 2.1 s]`, +`[exit 3 · 0.4 s]`, `[stopped · 5.0 s]` or `[timed out · 1.0 s]`. Actions run with `FORCE_COLOR=1` +and `CLICOLOR_FORCE=1` (unless already set), so output may contain ANSI color codes; a page showing it +should render or strip them. Unknown keys are errors. Limits: 50 actions, 20 steps each, 100 arguments per step. ### Prepare a Lab diff --git a/src/agent_runtime/labs.py b/src/agent_runtime/labs.py index f2cd939..6d22ccc 100644 --- a/src/agent_runtime/labs.py +++ b/src/agent_runtime/labs.py @@ -506,6 +506,11 @@ async def _execute(self, run: Run, lab: PreparedLab, action: Action) -> None: env["PATH"] = f"{lab.bin_dir}{os.pathsep}{env.get('PATH', '')}" env["VIRTUAL_ENV"] = str(lab.bin_dir.parent) env["PYTHONUNBUFFERED"] = "1" + # Output goes to a pipe, not a terminal, so programs would print no colors. The page that + # started the action renders ANSI colors, so ask for them (FORCE_COLOR and CLICOLOR_FORCE + # are the common conventions); a learner's NO_COLOR, if set, still wins in most tools. + env.setdefault("FORCE_COLOR", "1") + env.setdefault("CLICOLOR_FORCE", "1") deadline = time.monotonic() + action.timeout watchdog = asyncio.create_task(self._watch(run, deadline)) try: diff --git a/tests/test_labs.py b/tests/test_labs.py index d6677da..75ced2d 100644 --- a/tests/test_labs.py +++ b/tests/test_labs.py @@ -43,6 +43,13 @@ "import time\\nfor i in range(400):\\n print(i, flush=True)\\n time.sleep(0.05)", ]] +[actions.env] +label = "Prints its color settings" +steps = [[ + "python", "-c", + "import os; print(os.environ.get('FORCE_COLOR'), os.environ.get('CLICOLOR_FORCE'))", +]] + [actions.hangs] label = "Times out" timeout = 1 @@ -141,7 +148,7 @@ class TestManifest: def test_valid(self): manifest = labs.parse_manifest(MANIFEST) assert manifest.lab_id == "demo-lab" - assert list(manifest.actions) == ["hello", "two", "slow", "hangs"] + assert list(manifest.actions) == ["hello", "two", "slow", "env", "hangs"] assert manifest.actions["two"].steps[1] == ("python", "-c", "import sys; sys.exit(3)") assert manifest.actions["hello"].timeout == labs.DEFAULT_TIMEOUT assert manifest.actions["hangs"].timeout == 1 @@ -213,7 +220,7 @@ class TestPrepare: def test_asks_once_per_lab_version(self, client, lab_repo): lab = _prepare(client, lab_repo) assert lab["lab_id"] == "demo-lab" and lab["commit"] == lab_repo[1] - assert [a["name"] for a in lab["actions"]] == ["hello", "two", "slow", "hangs"] + assert [a["name"] for a in lab["actions"]] == ["hello", "two", "slow", "env", "hangs"] assert len(client.prompts) == 1 prompt = client.prompts[0] assert SITE in prompt and lab_repo[1] in prompt @@ -279,6 +286,13 @@ def test_stop_and_one_run_at_a_time(self, client, lab_repo): assert re.search(r"\[stopped · \d+\.\d s\]\n$", stopped["output"]) assert _start(client, lab, "hello").status_code == 200 # the lab is free again + def test_actions_are_asked_for_colors(self, client, lab_repo, monkeypatch): + monkeypatch.delenv("FORCE_COLOR", raising=False) + monkeypatch.delenv("CLICOLOR_FORCE", raising=False) + lab = _prepare(client, lab_repo) + done = _wait(client, _start(client, lab, "env").json()["run_id"]) + assert "\n1 1\n" in done["output"], done["output"] + def test_timeout(self, client, lab_repo): lab = _prepare(client, lab_repo) done = _wait(client, _start(client, lab, "hangs").json()["run_id"])