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:
@@ -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
@@ -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": []}
|
||||
|
||||
Reference in New Issue
Block a user