Run opencode as build agent (plan injects READ-ONLY notice), flag workers with 0 tool calls

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 08:46:35 +02:00
co-authored by Claude Sonnet 5.5
parent c1c9b5c9f6
commit 60fb9150c4
2 changed files with 70 additions and 8 deletions
+20 -8
View File
@@ -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):
+50
View File
@@ -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": []}