From 85b5537afc4ec225723455087a4edcf27d175b17 Mon Sep 17 00:00:00 2001 From: thinkinginmath Date: Sat, 19 Sep 2026 07:26:50 +0000 Subject: [PATCH] Lab actions run with FORCE_COLOR=1, so their output can be colored An action's output goes to a pipe, so programs print no colors. The scimigo.com lab page now renders ANSI colors, so actions run with FORCE_COLOR=1 and CLICOLOR_FORCE=1, the two common conventions, unless the learner's environment already sets them. NO_COLOR, if set, still wins in tools that honor it. protocol.md now documents the per-step result lines (#4) and that output may contain ANSI codes, which a page should render or strip. Test: an action printing both variables gets "1 1", even when the test process has FORCE_COLOR set. 174 passed; ruff, format and mypy clean. Co-Authored-By: Claude Opus 5 (1M context) --- docs/protocol.md | 5 ++++- src/agent_runtime/labs.py | 5 +++++ tests/test_labs.py | 18 ++++++++++++++++-- 3 files changed, 25 insertions(+), 3 deletions(-) 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"])