From 60fb9150c4a870a15ec7572cdcf8e7156f512df3 Mon Sep 17 00:00:00 2001 From: retoor Date: Mon, 12 Oct 2026 08:46:35 +0200 Subject: [PATCH] Run opencode as build agent (plan injects READ-ONLY notice), flag workers with 0 tool calls Co-Authored-By: Claude Sonnet 5.5 --- tai.py | 28 ++++++++++++++++++++-------- test_tai.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/tai.py b/tai.py index 4b63341..b5ea369 100755 --- a/tai.py +++ b/tai.py @@ -96,7 +96,7 @@ def _fromisoformat(text): return parsed.replace(tzinfo=tzinfo) raise ValueError("invalid datetime, use ISO like 2026-10-08T09:00") -VERSION = "1.26.0" +VERSION = "1.26.1" WORKER_STEPS = 12 CREATE_SKILL_STEPS = 40 FORK_MAX_DEPTH = 2 @@ -133,7 +133,7 @@ OPENROUTER_MODEL = "deepseek/deepseek-v4.1-flash" OPENCODE_LABEL = "opencode" OPENCODE_CHAT_URL = "opencode://run" OPENCODE_MODELS_URL = "opencode://models" -OPENCODE_AGENT = "plan" +OPENCODE_AGENT = "build" OPENCODE_PARSE_MAX_SCANS = 200 DEVPLACE_LABEL = "devplace" DEVPLACE_BASE = "https://devplace.net/openai/v1" @@ -1948,6 +1948,8 @@ class Glow: task = Glow.ellipsis(" ".join(str(record.get("task") or "").split()), 50, head=True) row = " #%d %s%s●%s %s %s· %s · %s%s" % (record.get("id"), pulse, dot, Ansi.RESET, task, Ansi.DIM, record.get("status"), format_delay(record.get("elapsed") or 0), Ansi.RESET) + if record.get("tools") == 0 and record.get("status") != "running": + row += " %s· 0 tools%s" % (FG_ORANGE, Ansi.RESET) if suffix: row += suffix return truncate_ansi(row, width) if width else row @@ -6268,6 +6270,7 @@ FISH_PLEXER_BLOCK = ( class Tools: def __init__(self, app): self.app = app + self.calls = 0 self.handlers = { "shell": self.run_shell, "read_file": self.run_read_file, @@ -6332,6 +6335,7 @@ class Tools: return "", "error: " + short_error(exc) def dispatch(self, name, raw_arguments): + self.calls += 1 try: args = raw_arguments if isinstance(raw_arguments, dict) else json.loads(raw_arguments or "{}") except (TypeError, ValueError): @@ -8337,14 +8341,18 @@ class Agent: return answer -def default_runner(config, seal, profile, depth, task, timeout, max_steps=WORKER_STEPS): +def default_runner(config, seal, profile, depth, task, timeout, max_steps=WORKER_STEPS, stats=None): store = Store(config, seal) try: worker = Agent(config, store, persist=False, quiet=True, depth=depth) if profile != worker.profile: worker.switch_profile(profile, silent=True) worker.deadline = time.time() + timeout - return worker.run_turn(task, capture=True, max_steps=max_steps) + try: + return worker.run_turn(task, capture=True, max_steps=max_steps) + finally: + if stats is not None: + stats["tools"] = worker.tools.calls finally: store.close() @@ -8353,13 +8361,13 @@ def spawn_agent(task, profile, timeout, config, seal, depth=0, runner=None): with AGENTS_LOCK: agent_id = AGENTS_NEXT[0] AGENTS_NEXT[0] += 1 - record = {"id": agent_id, "task": task, "profile": profile, "status": "running", "result": "", "started": time.time(), "ended": None, "thread": None, "spilled": None, "collected": False} + record = {"id": agent_id, "task": task, "profile": profile, "status": "running", "result": "", "started": time.time(), "ended": None, "thread": None, "spilled": None, "collected": False, "tools": None} AGENTS[agent_id] = record def target(): try: if runner is None: - result = default_runner(config, seal, profile, depth, task, timeout) + result = default_runner(config, seal, profile, depth, task, timeout, stats=record) else: result = runner(task, profile, timeout) status = "timeout" if "[time limit reached]" in result else "done" @@ -8392,16 +8400,20 @@ def poll_agent(agent_id, wait=0, profile=None): status = record["status"] result = record["result"] elapsed = (record["ended"] or time.time()) - record["started"] + idle = record.get("tools") == 0 if status == "running": return status, "agent %d still running (%ds elapsed)" % (agent_id, elapsed) if status == "error": return status, result - return status, truncate(result, 6000, 2000) or "(empty result)" + text = truncate(result, 6000, 2000) or "(empty result)" + if idle: + text += "\n[note: this worker ran 0 tool calls, so it changed nothing; check its answer before relying on it]" + return status, text def list_agents(profile=None): with AGENTS_LOCK: - return [{"id": record["id"], "task": record["task"], "status": record["status"], "elapsed": int((record["ended"] or time.time()) - record["started"]), "collected": record.get("collected", False)} for record in AGENTS.values() if profile is None or record.get("profile") == profile] + return [{"id": record["id"], "task": record["task"], "status": record["status"], "elapsed": int((record["ended"] or time.time()) - record["started"]), "collected": record.get("collected", False), "tools": record.get("tools")} for record in AGENTS.values() if profile is None or record.get("profile") == profile] def clear_agents(profile=None): diff --git a/test_tai.py b/test_tai.py index c44a980..d13156e 100644 --- a/test_tai.py +++ b/test_tai.py @@ -4377,6 +4377,56 @@ class ToolArgumentsTests(unittest.TestCase): self.assertEqual(agent.run_turn("check os", capture=True), "done") +class OpencodeAgentTests(unittest.TestCase): + def test_agent_is_not_plan(self): + # "plan" makes opencode inject "Plan mode ACTIVE - READ-ONLY" into the + # model, which then refuses to emit tai's write tool calls. + self.assertNotEqual(tai.OPENCODE_AGENT, "plan") + captured = {} + + def fake_run(argv, **kwargs): + captured["argv"] = argv + return mock.Mock(stdout='{"type":"text","part":{"text":"ok"}}\n', stderr="", returncode=0) + + with mock.patch.object(tai, "opencode_binary", return_value="opencode"), mock.patch.object(tai.subprocess, "run", side_effect=fake_run): + self.assertEqual(tai.Opencode.opencode_run("m", "hi", 5), "ok") + argv = captured["argv"] + self.assertEqual(argv[argv.index("--agent") + 1], tai.OPENCODE_AGENT) + + +class WorkerIdleTests(unittest.TestCase): + def test_idle_worker_is_flagged(self): + record = {"id": 1, "task": "t", "status": "done", "elapsed": 3, "tools": 0} + self.assertIn("0 tools", tai.strip_ansi(tai.Glow.agent_row(record))) + record["tools"] = 2 + self.assertNotIn("0 tools", tai.strip_ansi(tai.Glow.agent_row(record))) + record["tools"] = None + self.assertNotIn("0 tools", tai.strip_ansi(tai.Glow.agent_row(record))) + running = {"id": 1, "task": "t", "status": "running", "elapsed": 3, "tools": 0} + self.assertNotIn("0 tools", tai.strip_ansi(tai.Glow.agent_row(running))) + + def test_poll_notes_zero_tool_calls(self): + agent_id = tai.spawn_agent("idle", "t", 60, None, None, 0, lambda task, profile, timeout: "I am in plan mode") + tai.poll_agent(agent_id, wait=5) + with tai.AGENTS_LOCK: + tai.AGENTS[agent_id]["tools"] = 0 + status, text = tai.poll_agent(agent_id) + self.assertEqual(status, "done") + self.assertIn("ran 0 tool calls", text) + with tai.AGENTS_LOCK: + tai.AGENTS[agent_id]["tools"] = 3 + self.assertNotIn("0 tool calls", tai.poll_agent(agent_id)[1]) + + def test_tools_counts_dispatches(self): + tools = tai.Tools.__new__(tai.Tools) + tools.calls = 0 + tools.handlers = {"echo": lambda args: "x"} + tools.app = mock.Mock(store=None) + tools.dispatch("echo", {}) + tools.dispatch("nope", {}) + self.assertEqual(tools.calls, 2) + + class OpencodeParseTests(unittest.TestCase): def test_single_answer(self): answer = {"content": "hi", "tool_calls": []}