mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
bench: add a todo-enforcement A/B cell for Terminal-Bench
buzz-dev-mcp already exposes the `todo` tool in every benchmark cell, and base_prompt.md already instructs the agent to use it. What is missing is enforcement: MCP_HOOK_SERVERS is unset, so hook_servers resolves to HookServers::None and the _Stop/_PostCompact hooks never fire. The list is advisory — an agent can open items and still end its turn. Add `todo_enforcement` to ExperimentManifest (default false, matching buzz-agent's own default). When on, _agent_env sets MCP_HOOK_SERVERS=* and BUZZ_AGENT_STOP_MAX_REJECTIONS=2 — fewer retries than buzz-agent's default of 3, because trials run against a hard wall clock. Ship it as a paired cell rather than a change to every run: tb-cobol-sonnet-haiku-criteria.yaml is byte-identical to tb-cobol-sonnet-haiku.yaml except for the two intervention dimensions (todo_enforcement, and -criteria personas that add the acceptance-criteria rules). Control personas are untouched so existing manifests keep verifying against their recorded sha256. Measured against 179 archived trials (53.1% pass, 62% of failures partial): hand-analysis of 18 failures put the prose intervention at 5 yes / 10 no / 3 unclear. This lands it as a runnable comparison, not as a claimed improvement. Signed-off-by: Atish Patel <atish@squareup.com> Co-authored-by: Claude Code <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Code
parent
39ce3dfc3c
commit
0376fcedd8
@@ -0,0 +1,49 @@
|
||||
# Treatment cell paired with tb-cobol-sonnet-haiku.yaml. Identical staffing,
|
||||
# endpoints, prices, and budget; the only two differences are the intervention:
|
||||
# 1. the -criteria personas, which add the acceptance-criteria rules (write
|
||||
# the task's stated conditions into `todo`, then show the command and its
|
||||
# literal output for each before finishing), and
|
||||
# 2. todo_enforcement: true, which turns on buzz-dev-mcp's _Stop hook so an
|
||||
# agent cannot end its turn while a todo item is still open.
|
||||
# Run this against the control on the same task set to read the effect. Keep
|
||||
# every other field byte-identical to the control or the comparison is
|
||||
# confounded.
|
||||
schema_version: "1"
|
||||
condition: tb-cobol-sonnet46-2haiku45-criteria
|
||||
todo_enforcement: true
|
||||
roster:
|
||||
- id: orch
|
||||
kind: orchestrator
|
||||
role: lead
|
||||
count: 1
|
||||
endpoint: claude-sonnet-4-6
|
||||
model_revision: claude-sonnet-4-6
|
||||
prompt:
|
||||
path: personas/orchestrator-tb-criteria.md
|
||||
sha256: b208b077d9758f1343d14c6a4a2fae8bfad5625905d43f085d209f3a55d463ea
|
||||
generation:
|
||||
max_output_tokens: 4096
|
||||
context_window_tokens: 200000
|
||||
- id: worker
|
||||
kind: worker
|
||||
role: implementer
|
||||
count: 2
|
||||
endpoint: claude-haiku-4-5
|
||||
model_revision: claude-haiku-4-5
|
||||
prompt:
|
||||
path: personas/worker-tb-criteria.md
|
||||
sha256: 2df5fad04298ad85dd3e661e69668b7e660d95fa1a30fb33d62282d784746d97
|
||||
generation:
|
||||
max_output_tokens: 4096
|
||||
context_window_tokens: 200000
|
||||
prices:
|
||||
claude-sonnet-4-6:
|
||||
input_per_million_usd: 3
|
||||
cached_input_per_million_usd: 0.3
|
||||
output_per_million_usd: 15
|
||||
claude-haiku-4-5:
|
||||
input_per_million_usd: 1
|
||||
cached_input_per_million_usd: 0.1
|
||||
output_per_million_usd: 5
|
||||
trial_budget:
|
||||
timeout_seconds: 900
|
||||
@@ -0,0 +1,54 @@
|
||||
# Orchestrator — Terminal-Bench team
|
||||
|
||||
You are the orchestrator of a small team solving a terminal task. You do not
|
||||
run commands yourself; your workers do. You coordinate over a Buzz channel.
|
||||
Your team, your channel id, and the user you report to are listed in the
|
||||
"Your team" section below.
|
||||
|
||||
Your `shell` tool has the `buzz` CLI on PATH, already authenticated as
|
||||
you. Nothing you write is visible to anyone unless you publish it: every
|
||||
message — step assignments, verification requests, the final `DONE:`
|
||||
report — must be sent with
|
||||
`buzz messages send --channel <channel-id> --content <text>`. Your turn is
|
||||
not complete until you have published your message. Do not use the shell
|
||||
for task work — that is your workers' job.
|
||||
|
||||
Tasks arrive as a channel message from the user @mentioning you. Address
|
||||
each assignment to a specific worker by @mention, exactly one worker per
|
||||
step. You may assign independent steps to different workers, but never give
|
||||
two workers overlapping or conflicting work — they share one task
|
||||
environment and one filesystem.
|
||||
|
||||
Rules:
|
||||
1. Read the task instruction. Break it into the smallest concrete steps.
|
||||
2. Assign each step to a worker with an @mention. One step per message.
|
||||
State the exact goal and the success check, not just the command to run.
|
||||
Relay the task's requirements verbatim — use the paths the task states,
|
||||
and do not add constraints the task does not state (paths, encodings,
|
||||
byte-level rules). Where the task is silent, let standard tool defaults
|
||||
apply.
|
||||
3. Wait for the worker's report before assigning the next dependent step.
|
||||
4. When a worker reports output, verify it against the task's success
|
||||
criteria before moving on: assign a verification step that runs the
|
||||
task's own success check and shows real output. Assign each verification
|
||||
step to a different worker than the one whose work is being verified —
|
||||
independent verification, never self-review. Do not report completion on
|
||||
a worker's claim alone.
|
||||
5. When the task is complete and verified, report back to the user: publish
|
||||
a final message starting with `DONE:` that @mentions the user and
|
||||
summarizes what was produced and how it was verified. The task is not
|
||||
finished until this message is published — never conclude silently.
|
||||
6. Before you assign the first step, write the task's stated success
|
||||
conditions into your `todo` list — one item per condition, quoted from the
|
||||
task. Every path, every filename, every count, every threshold, and every
|
||||
"all", "each", and "both". These items are the definition of done; a step
|
||||
is not a substitute for a condition.
|
||||
7. Before you publish `DONE:`, put next to each condition the command a worker
|
||||
ran and its literal output. If an output contradicts a condition, you are
|
||||
not done: assign the work to close it. Do not explain the contradiction
|
||||
away, and never accept a check that is easier to pass than the one the task
|
||||
stated — "it imports" is not "it works", and "no errors reported" is not
|
||||
"no leaks reported".
|
||||
|
||||
Keep messages short. Never fabricate command output. If a worker's report is
|
||||
ambiguous, ask them to re-run with the exact verification command.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Worker — Terminal-Bench team
|
||||
|
||||
You are a worker agent with terminal access, coordinating over a Buzz
|
||||
channel. Your team, your channel id, and your orchestrator are listed in
|
||||
the "Your team" section below.
|
||||
|
||||
You work directly in the task environment: your `shell` tool runs
|
||||
commands in it, and your file tools read and edit its files. The same
|
||||
shell has the `buzz` CLI on PATH, already authenticated as you; reports go
|
||||
to the channel with
|
||||
`buzz messages send --channel <channel-id> --content <text>`. Your turn is
|
||||
not complete until you have published your report.
|
||||
|
||||
The orchestrator only wakes for messages that @mention it. Every report
|
||||
you publish must start with an @mention of the agent that assigned you the
|
||||
step (use their name exactly as it appears in their message). If the
|
||||
assignment's event id is visible in your context, also pass
|
||||
`--reply-to <event-id>` to thread the report. A report that mentions
|
||||
nobody is invisible and the task will stall.
|
||||
|
||||
Rules:
|
||||
1. Act only on steps assigned to you by the orchestrator's @mention. If a
|
||||
message assigns a step to a different worker, ignore it.
|
||||
2. Execute the requested step in the terminal BEFORE writing any report.
|
||||
Never describe output you have not yet produced. Prefer the smallest
|
||||
command that achieves the stated goal. Use the paths the task or the
|
||||
assignment states; do not invent paths.
|
||||
3. Report back in one message: the command you ran, its exit code, and the
|
||||
relevant output (trimmed, never invented).
|
||||
4. If a command fails, report the failure verbatim and stop — do not
|
||||
improvise a different approach without the orchestrator's direction.
|
||||
5. Never claim success without showing the verifying output.
|
||||
6. Before you start, write the step's stated success conditions into your
|
||||
`todo` list — one item per condition, quoted from the assignment. Every
|
||||
path, every filename, every count, every threshold, and every "all",
|
||||
"each", and "both".
|
||||
7. Before you report, put next to each condition the command you ran and its
|
||||
literal output. If an output contradicts a condition, you are not done:
|
||||
keep working. Do not explain the contradiction away, and never substitute a
|
||||
check that is easier to pass than the one stated — "it imports" is not "it
|
||||
works", and "no errors reported" is not "no leaks reported".
|
||||
@@ -38,6 +38,11 @@ FORWARDER = f"{REMOTE_BIN}/relay-forwarder"
|
||||
FORWARDER_LOG = f"{REMOTE_LOGS}/relay-forwarder.log"
|
||||
# How many done-poll iterations between in-container liveness probes.
|
||||
LIVENESS_EVERY = 10
|
||||
# How many times _Stop may refuse end_turn before the agent is allowed to
|
||||
# finish anyway, when todo_enforcement is on. buzz-agent's own default is 3;
|
||||
# trials run against a hard wall clock and 13% of observed failures were
|
||||
# AgentTimeoutError, so the benchmark buys fewer retries than production.
|
||||
TODO_STOP_MAX_REJECTIONS = 2
|
||||
|
||||
|
||||
class RuntimeLaunchError(RuntimeError):
|
||||
@@ -150,6 +155,7 @@ class BuzzContainerRuntime:
|
||||
credential=credential,
|
||||
agent_class=classes[credential.agent_id],
|
||||
trial_dir=trial_dir,
|
||||
todo_enforcement=manifest.todo_enforcement,
|
||||
)
|
||||
)
|
||||
await self._wait_for_agents_ready(
|
||||
@@ -176,6 +182,7 @@ class BuzzContainerRuntime:
|
||||
"completion_message": final_message["content"],
|
||||
"agent_runtime": "in-container",
|
||||
"agent_hints_enabled": False,
|
||||
"agent_todo_enforcement": manifest.todo_enforcement,
|
||||
"task_seed": "user-identity-prompt",
|
||||
"agent_max_rounds": {
|
||||
credential.agent_id: (
|
||||
@@ -296,6 +303,7 @@ class BuzzContainerRuntime:
|
||||
credential: AgentCredential,
|
||||
agent_class: AgentClass,
|
||||
trial_dir: Path,
|
||||
todo_enforcement: bool = False,
|
||||
) -> _Agent:
|
||||
if not credential.llm_endpoint:
|
||||
raise RuntimeLaunchError("credential llm_endpoint must not be empty")
|
||||
@@ -324,6 +332,7 @@ class BuzzContainerRuntime:
|
||||
agent_class=agent_class,
|
||||
endpoint=endpoint,
|
||||
remote_prompt=remote_prompt,
|
||||
todo_enforcement=todo_enforcement,
|
||||
)
|
||||
command = (
|
||||
f"{shlex.quote(f'{REMOTE_BIN}/buzz-acp')} </dev/null "
|
||||
@@ -347,9 +356,10 @@ class BuzzContainerRuntime:
|
||||
agent_class: AgentClass,
|
||||
endpoint: EndpointLaunchConfig,
|
||||
remote_prompt: str,
|
||||
todo_enforcement: bool = False,
|
||||
) -> dict[str, str]:
|
||||
"""The desktop-launch environment: real acp/agent/dev-mcp wiring."""
|
||||
return {
|
||||
env = {
|
||||
**endpoint.env,
|
||||
"BUZZ_RELAY_URL": trial.relay_ws_url,
|
||||
"BUZZ_PRIVATE_KEY": credential.nostr_secret_key,
|
||||
@@ -381,6 +391,15 @@ class BuzzContainerRuntime:
|
||||
"BUZZ_AGENT_NO_HINTS": "1",
|
||||
endpoint.api_key_env: credential.llm_api_key,
|
||||
}
|
||||
if todo_enforcement:
|
||||
# buzz-dev-mcp already exposes the `todo` tool in every cell, but
|
||||
# its _Stop/_PostCompact hooks are never called unless a server is
|
||||
# named here, which leaves the todo list advisory: an agent can
|
||||
# open items and still end its turn. "*" enables hooks for every
|
||||
# attached MCP server.
|
||||
env["MCP_HOOK_SERVERS"] = "*"
|
||||
env["BUZZ_AGENT_STOP_MAX_REJECTIONS"] = str(TODO_STOP_MAX_REJECTIONS)
|
||||
return env
|
||||
|
||||
# -- lifecycle -------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -92,6 +92,13 @@ class ExperimentManifest(StrictModel):
|
||||
roster: tuple[AgentClass, ...] = Field(min_length=1)
|
||||
prices: dict[str, Price]
|
||||
trial_budget: TrialBudget
|
||||
# Enable buzz-dev-mcp's _Stop/_PostCompact lifecycle hooks, which make the
|
||||
# `todo` tool binding instead of advisory: _Stop refuses end_turn while any
|
||||
# item is open, and _PostCompact re-injects the list after a context
|
||||
# handoff. Off by default, matching buzz-agent's own default (hook_servers
|
||||
# is HookServers::None when MCP_HOOK_SERVERS is unset), so this stays an
|
||||
# opt-in experimental condition rather than a silent change to every cell.
|
||||
todo_enforcement: bool = False
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
|
||||
@@ -290,6 +290,37 @@ async def test_launch_wires_the_desktop_environment(tmp_path, configured, expect
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("todo_enforcement", "expected_hooks"), [(False, None), (True, "*")]
|
||||
)
|
||||
async def test_todo_enforcement_gates_the_mcp_lifecycle_hooks(
|
||||
tmp_path, todo_enforcement, expected_hooks
|
||||
):
|
||||
manifest = write_manifest(tmp_path)
|
||||
orch = credential("orch-1", "orchestrator", "orch-model")
|
||||
trial = trial_handle((orch,))
|
||||
environment = Environment(
|
||||
responses={"buzz-acp": ExecResult(stdout="4242\n", stderr="", return_code=0)}
|
||||
)
|
||||
await runtime(tmp_path)._launch_agent(
|
||||
environment=environment,
|
||||
trial=trial,
|
||||
credential=orch,
|
||||
agent_class=manifest.roster[0],
|
||||
trial_dir=tmp_path,
|
||||
todo_enforcement=todo_enforcement,
|
||||
)
|
||||
_, env = environment.commands[-1]
|
||||
# buzz-dev-mcp is attached either way, so the `todo` tool is callable in
|
||||
# both arms; only the _Stop/_PostCompact enforcement is gated.
|
||||
assert env["BUZZ_ACP_MCP_COMMAND"] == f"{REMOTE_BIN}/buzz-dev-mcp"
|
||||
assert env.get("MCP_HOOK_SERVERS") == expected_hooks
|
||||
if todo_enforcement:
|
||||
assert env["BUZZ_AGENT_STOP_MAX_REJECTIONS"] == "2"
|
||||
else:
|
||||
assert "BUZZ_AGENT_STOP_MAX_REJECTIONS" not in env
|
||||
|
||||
|
||||
def test_runtime_rejects_unbounded_agent_rounds(tmp_path):
|
||||
with pytest.raises(ValueError, match="positive"):
|
||||
runtime(tmp_path, max_agent_rounds=0)
|
||||
|
||||
@@ -27,6 +27,15 @@ def test_hash_changes_when_staffing_changes(manifest_data):
|
||||
assert ExperimentManifest.load(changed).sha256 != first.sha256
|
||||
|
||||
|
||||
def test_todo_enforcement_defaults_off_and_changes_condition_identity(manifest_data):
|
||||
control = ExperimentManifest.load(manifest_data)
|
||||
assert control.todo_enforcement is False
|
||||
treatment = ExperimentManifest.load({**manifest_data, "todo_enforcement": True})
|
||||
assert treatment.todo_enforcement is True
|
||||
# The flag is part of the condition, so the two arms hash differently.
|
||||
assert treatment.sha256 != control.sha256
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mutation", "match"),
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user