From ad3556e99e1d7dbd8f0c3efd72039a6abc43b78c Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Tue, 18 Aug 2026 17:20:49 -0400 Subject: [PATCH 1/5] Add Buzz-native collaboration benchmarks Add Harbor tasks that verify thread replies and exact channel creation with membership. Seed deterministic directory fixtures and export verifier-facing relay evidence. Signed-off-by: Salman Mohammed --- benchmarks/harbor-buzz-orchestra/README.md | 27 +++ .../environment/Dockerfile | 3 + .../instruction.md | 15 ++ .../create-channel-invite-users/task.toml | 19 ++ .../create-channel-invite-users/tests/test.sh | 7 + .../tests/verify.py | 157 +++++++++++++++ .../reply-to-thread/environment/Dockerfile | 3 + .../reply-to-thread/instruction.md | 9 + .../buzz-native/reply-to-thread/task.toml | 19 ++ .../buzz-native/reply-to-thread/tests/test.sh | 8 + .../reply-to-thread/tests/verify.py | 154 +++++++++++++++ .../manifests/buzz-native-solo-sonnet.yaml | 25 +++ .../personas/buzz-native-solo.md | 2 + .../src/harbor_buzz_orchestra/__init__.py | 8 +- .../src/harbor_buzz_orchestra/agent.py | 6 +- .../container_runtime.py | 186 ++++++++++++++++-- .../src/harbor_buzz_orchestra/evidence.py | 129 ++++++++++++ .../src/harbor_buzz_orchestra/provisioning.py | 13 ++ .../harbor_buzz_orchestra/task_fixtures.py | 48 +++++ .../src/harbor_buzz_testbed/buzz_cli.py | 11 ++ .../src/harbor_buzz_testbed/provisioner.py | 76 ++++++- .../testbed/tests/test_provisioner_unit.py | 38 ++++ .../tests/fixtures/transcripts/threaded.json | 32 +++ .../tests/fixtures/transcripts/top-level.json | 30 +++ .../harbor-buzz-orchestra/tests/test_agent.py | 7 +- .../tests/test_container_runtime.py | 129 +++++++++++- ...st_create_channel_invite_users_verifier.py | 123 ++++++++++++ .../tests/test_evidence.py | 129 ++++++++++++ .../tests/test_reply_to_thread_verifier.py | 139 +++++++++++++ crates/buzz-cli/src/commands/channels.rs | 6 + 30 files changed, 1536 insertions(+), 22 deletions(-) create mode 100644 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/environment/Dockerfile create mode 100644 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/instruction.md create mode 100644 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/task.toml create mode 100755 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/tests/test.sh create mode 100644 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/tests/verify.py create mode 100644 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/reply-to-thread/environment/Dockerfile create mode 100644 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/reply-to-thread/instruction.md create mode 100644 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/reply-to-thread/task.toml create mode 100755 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/reply-to-thread/tests/test.sh create mode 100755 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/reply-to-thread/tests/verify.py create mode 100644 benchmarks/harbor-buzz-orchestra/manifests/buzz-native-solo-sonnet.yaml create mode 100644 benchmarks/harbor-buzz-orchestra/personas/buzz-native-solo.md create mode 100644 benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/evidence.py create mode 100644 benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/task_fixtures.py create mode 100644 benchmarks/harbor-buzz-orchestra/tests/fixtures/transcripts/threaded.json create mode 100644 benchmarks/harbor-buzz-orchestra/tests/fixtures/transcripts/top-level.json create mode 100644 benchmarks/harbor-buzz-orchestra/tests/test_create_channel_invite_users_verifier.py create mode 100644 benchmarks/harbor-buzz-orchestra/tests/test_evidence.py create mode 100644 benchmarks/harbor-buzz-orchestra/tests/test_reply_to_thread_verifier.py diff --git a/benchmarks/harbor-buzz-orchestra/README.md b/benchmarks/harbor-buzz-orchestra/README.md index 0358c954e7..ba4e044ae3 100644 --- a/benchmarks/harbor-buzz-orchestra/README.md +++ b/benchmarks/harbor-buzz-orchestra/README.md @@ -62,6 +62,33 @@ rather than deletes that channel, leaving the relay/Postgres event timeline and the per-agent acp/agent logs (downloaded into the trial's `buzz/` artifacts) available for analysis. +### Buzz-native tasks + +The local `datasets/buzz-native` suite scores Buzz product behavior alongside +task correctness. It currently covers direct thread replies and exact channel +creation/membership. Run one task with the production base prompt from the +checked-out source build: + +```bash +just benchmark \ + --path benchmarks/harbor-buzz-orchestra/datasets/buzz-native/reply-to-thread \ + --attempts 1 \ + --manifest benchmarks/harbor-buzz-orchestra/manifests/buzz-native-solo-sonnet.yaml \ + --n-concurrent 1 +``` + +Replace the path with +`benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users` +to run the channel task. Its provisioner seeds a stable directory of 50 users +and 10 bots, while the verifier checks the created channel's TTL and exact +membership through post-agent CLI evidence. + +After the agent stops, the runtime snapshots public relay state (source +messages plus any task-declared channels and members) to +`/logs/artifacts/buzz-evidence.json`. The task verifier reads that post-agent +artifact; relay credentials and database access are never exposed to the model +or verifier. + ## Leaderboard runs `just benchmark` is the one-command path: it stands up a dedicated Docker diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/environment/Dockerfile b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/environment/Dockerfile new file mode 100644 index 0000000000..29f16f3c41 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/environment/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/instruction.md b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/instruction.md new file mode 100644 index 0000000000..a6f4f79452 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/instruction.md @@ -0,0 +1,15 @@ +Create a temporary private stream channel named `fix-pr-1234` for one hour. + +Invite these users as members: + +- `benchmark-user-07` +- `benchmark-user-19` +- `benchmark-user-42` + +Invite these bots with the bot role: + +- `benchmark-bot-03` +- `benchmark-bot-08` + +Do not invite any other users or bots. When finished, reply briefly with what +you created. diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/task.toml b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/task.toml new file mode 100644 index 0000000000..b24c4c88f7 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/task.toml @@ -0,0 +1,19 @@ +schema_version = "1.3" + +[task] +name = "buzz-native/create-channel-invite-users" +description = "Create a temporary PR channel with an exact subset of users and bots." +authors = [{ name = "Buzz" }] +keywords = ["buzz-native", "channels", "membership", "cli"] + +[agent] +timeout_sec = 300.0 + +[verifier] +timeout_sec = 30.0 + +[environment] +network_mode = "public" +cpus = 1 +memory_mb = 1024 +storage_mb = 1024 diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/tests/test.sh b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/tests/test.sh new file mode 100755 index 0000000000..be6819ba68 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/tests/test.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu + +python3 /tests/verify.py \ + --evidence /logs/artifacts/buzz-evidence.json \ + --reward /logs/verifier/reward.json \ + --details /logs/verifier/details.json diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/tests/verify.py b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/tests/verify.py new file mode 100644 index 0000000000..2d59c2a58f --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/tests/verify.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for exact Buzz channel creation and membership.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +CHANNEL_NAME = "fix-pr-1234" +TARGET_USERS = {"benchmark-user-07", "benchmark-user-19", "benchmark-user-42"} +TARGET_BOTS = {"benchmark-bot-03", "benchmark-bot-08"} + + +def _zero_metrics() -> dict[str, float]: + return { + "reward": 0.0, + "channel_created": 0.0, + "channel_shape": 0.0, + "temporary_channel": 0.0, + "exact_membership": 0.0, + "expected_roles": 0.0, + "evidence_complete": 0.0, + } + + +def score_evidence(evidence: object) -> tuple[dict[str, float], dict[str, Any]]: + if not isinstance(evidence, dict): + return _zero_metrics(), {"error": "evidence root is not an object"} + + directory_rows = [ + row for row in evidence.get("directory", []) if isinstance(row, dict) + ] + directory = { + row.get("name"): row + for row in directory_rows + if isinstance(row.get("name"), str) + } + channels = [ + channel + for channel in evidence.get("observed_channels", []) + if isinstance(channel, dict) and channel.get("name") == CHANNEL_NAME + ] + channel = channels[0] if len(channels) == 1 else None + identities = ( + evidence.get("identities") + if isinstance(evidence.get("identities"), dict) + else {} + ) + orchestrators = [ + row + for row in identities.values() + if isinstance(row, dict) and row.get("role") == "orchestrator" + ] + owner_pubkey = orchestrators[0].get("pubkey") if len(orchestrators) == 1 else None + + expected_names = TARGET_USERS | TARGET_BOTS + expected_targets = { + directory[name]["pubkey"]: "bot" if name in TARGET_BOTS else "member" + for name in expected_names + if name in directory and isinstance(directory[name].get("pubkey"), str) + } + expected_members = ( + {owner_pubkey: "owner", **expected_targets} + if isinstance(owner_pubkey, str) + else expected_targets + ) + member_rows = ( + [row for row in channel.get("members", []) if isinstance(row, dict)] + if channel is not None + else [] + ) + actual_members = { + row.get("pubkey"): row.get("role") + for row in member_rows + if isinstance(row.get("pubkey"), str) + } + + evidence_complete = float( + evidence.get("schema_version") == 1 + and evidence.get("task_name") == "create-channel-invite-users" + and len(directory_rows) == 60 + and len(directory) == 60 + and sum(row.get("role") == "user" for row in directory_rows) == 50 + and sum(row.get("role") == "bot" for row in directory_rows) == 10 + and len(expected_targets) == 5 + and len(orchestrators) == 1 + ) + channel_created = float(channel is not None) + channel_shape = float( + channel is not None + and channel.get("channel_type") == "stream" + and channel.get("visibility") == "private" + and channel.get("archived") is False + ) + temporary_channel = float( + channel is not None and channel.get("ttl_seconds") == 3600 + ) + exact_membership = float( + len(member_rows) == len(actual_members) + and set(actual_members) == set(expected_members) + ) + expected_roles = float(actual_members == expected_members) + reward = float( + all( + metric == 1.0 + for metric in ( + evidence_complete, + channel_created, + channel_shape, + temporary_channel, + exact_membership, + expected_roles, + ) + ) + ) + metrics = { + "reward": reward, + "channel_created": channel_created, + "channel_shape": channel_shape, + "temporary_channel": temporary_channel, + "exact_membership": exact_membership, + "expected_roles": expected_roles, + "evidence_complete": evidence_complete, + } + details = { + "matching_channel_count": len(channels), + "channel_id": channel.get("channel_id") if channel is not None else None, + "expected_members": expected_members, + "actual_members": actual_members, + } + return metrics, details + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--evidence", type=Path, required=True) + parser.add_argument("--reward", type=Path, required=True) + parser.add_argument("--details", type=Path, required=True) + args = parser.parse_args() + + try: + evidence = json.loads(args.evidence.read_text(encoding="utf-8")) + metrics, details = score_evidence(evidence) + except (OSError, json.JSONDecodeError) as error: + metrics, details = _zero_metrics(), {"error": str(error)} + + args.reward.write_text(json.dumps(metrics, sort_keys=True) + "\n", encoding="utf-8") + args.details.write_text( + json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/reply-to-thread/environment/Dockerfile b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/reply-to-thread/environment/Dockerfile new file mode 100644 index 0000000000..29f16f3c41 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/reply-to-thread/environment/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/reply-to-thread/instruction.md b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/reply-to-thread/instruction.md new file mode 100644 index 0000000000..3685241386 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/reply-to-thread/instruction.md @@ -0,0 +1,9 @@ +Prepare a six-month financial projection using these assumptions: + +- Month 0 revenue is $120,000 and expenses are $75,000. +- Revenue grows by 5% each month. +- Expenses grow by 2% each month. +- Project months 1 through 6, compounding each month from the prior month. + +Reply with the month 6 revenue, month 6 expenses, and cumulative operating +profit across months 1 through 6. Round each result to the nearest dollar. diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/reply-to-thread/task.toml b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/reply-to-thread/task.toml new file mode 100644 index 0000000000..74463b8ee7 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/reply-to-thread/task.toml @@ -0,0 +1,19 @@ +schema_version = "1.3" + +[task] +name = "buzz-native/reply-to-thread" +description = "Answer a financial projection in the thread started by the user." +authors = [{ name = "Buzz" }] +keywords = ["buzz-native", "messaging", "threading"] + +[agent] +timeout_sec = 300.0 + +[verifier] +timeout_sec = 30.0 + +[environment] +network_mode = "public" +cpus = 1 +memory_mb = 1024 +storage_mb = 1024 diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/reply-to-thread/tests/test.sh b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/reply-to-thread/tests/test.sh new file mode 100755 index 0000000000..3dd50814b6 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/reply-to-thread/tests/test.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +mkdir -p /logs/verifier +python3 /tests/verify.py \ + --evidence /logs/artifacts/buzz-evidence.json \ + --reward /logs/verifier/reward.json \ + --details /logs/verifier/details.json diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/reply-to-thread/tests/verify.py b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/reply-to-thread/tests/verify.py new file mode 100755 index 0000000000..2557f8e334 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/reply-to-thread/tests/verify.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for the Buzz reply-to-thread task.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + +EXPECTED_AMOUNTS = (160_811.0, 84_462.0, 374_470.0) +NUMBER = re.compile(r"(? list[float]: + values: list[float] = [] + for token in NUMBER.findall(content): + try: + values.append(float(token.replace("$", "").replace(",", ""))) + except ValueError: + continue + return values + + +def _contains_amount(values: list[float], expected: float) -> bool: + return any(abs(value - expected) <= 1.0 for value in values) + + +def _has_tag(message: dict[str, Any], expected: list[str]) -> bool: + return any(tag == expected for tag in message.get("tags", [])) + + +def score_evidence(evidence: object) -> tuple[dict[str, float], dict[str, Any]]: + if not isinstance(evidence, dict): + return _zero_metrics(), {"error": "evidence root is not an object"} + + task_event_id = evidence.get("task_event_id") + trial = evidence.get("trial") if isinstance(evidence.get("trial"), dict) else {} + channel_id = trial.get("channel_id") + identities = ( + evidence.get("identities") + if isinstance(evidence.get("identities"), dict) + else {} + ) + agents = [ + identity + for identity in identities.values() + if isinstance(identity, dict) and identity.get("role") == "orchestrator" + ] + agent_pubkey = agents[0].get("pubkey") if len(agents) == 1 else None + messages = [ + message for message in evidence.get("messages", []) if isinstance(message, dict) + ] + root_indexes = [ + index + for index, message in enumerate(messages) + if message.get("id") == task_event_id + ] + root_index = root_indexes[0] if len(root_indexes) == 1 else -1 + candidates = [ + message + for message in messages[root_index + 1 :] + if agent_pubkey and message.get("pubkey") == agent_pubkey + ] + final = candidates[-1] if candidates else None + + evidence_complete = float( + evidence.get("schema_version") == 1 + and evidence.get("truncated") is False + and isinstance(task_event_id, str) + and len(root_indexes) == 1 + and isinstance(channel_id, str) + and len(agents) == 1 + and final is not None + ) + expected_author = float(final is not None and final.get("pubkey") == agent_pubkey) + same_channel = float( + final is not None + and final.get("channel_id") == channel_id + and _has_tag(final, ["h", channel_id]) + ) + reply_to_thread = float( + final is not None + and final.get("reply_to_event_id") == task_event_id + and _has_tag(final, ["e", task_event_id, "", "reply"]) + ) + values = _numbers(str(final.get("content", ""))) if final is not None else [] + answer_correct = float( + all(_contains_amount(values, expected) for expected in EXPECTED_AMOUNTS) + ) + reward = float( + all( + metric == 1.0 + for metric in ( + evidence_complete, + expected_author, + same_channel, + reply_to_thread, + answer_correct, + ) + ) + ) + metrics = { + "reward": reward, + "answer_correct": answer_correct, + "reply_to_thread": reply_to_thread, + "same_channel": same_channel, + "expected_author": expected_author, + "evidence_complete": evidence_complete, + } + details = { + "task_event_id": task_event_id, + "selected_message_id": final.get("id") if final is not None else None, + "selected_message_content": final.get("content") if final is not None else None, + "parsed_numbers": values, + "expected_amounts": list(EXPECTED_AMOUNTS), + } + return metrics, details + + +def _zero_metrics() -> dict[str, float]: + return { + "reward": 0.0, + "answer_correct": 0.0, + "reply_to_thread": 0.0, + "same_channel": 0.0, + "expected_author": 0.0, + "evidence_complete": 0.0, + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--evidence", type=Path, required=True) + parser.add_argument("--reward", type=Path, required=True) + parser.add_argument("--details", type=Path, required=True) + args = parser.parse_args() + + try: + evidence = json.loads(args.evidence.read_text(encoding="utf-8")) + metrics, details = score_evidence(evidence) + except (OSError, json.JSONDecodeError) as error: + metrics, details = _zero_metrics(), {"error": str(error)} + + args.reward.write_text(json.dumps(metrics, sort_keys=True) + "\n", encoding="utf-8") + args.details.write_text( + json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/harbor-buzz-orchestra/manifests/buzz-native-solo-sonnet.yaml b/benchmarks/harbor-buzz-orchestra/manifests/buzz-native-solo-sonnet.yaml new file mode 100644 index 0000000000..2948cead2f --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/manifests/buzz-native-solo-sonnet.yaml @@ -0,0 +1,25 @@ +# Single production Buzz agent for the local Buzz-native Harbor dataset. +# buzz-acp supplies crates/buzz-acp/src/base_prompt.md from the checked-out +# source build; this small persona only establishes that there is no team. +schema_version: "1" +condition: buzz-native-solo-sonnet46 +roster: + - id: solo + kind: orchestrator + role: solo + count: 1 + endpoint: claude-sonnet-4-6 + model_revision: claude-sonnet-4-6 + prompt: + path: personas/buzz-native-solo.md + sha256: 972950f0e2bfb9bf540c98e70e075479ab80cd596cb5ad405dad0cafdc60840b + 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 +trial_budget: + timeout_seconds: 300 diff --git a/benchmarks/harbor-buzz-orchestra/personas/buzz-native-solo.md b/benchmarks/harbor-buzz-orchestra/personas/buzz-native-solo.md new file mode 100644 index 0000000000..1fc4820dd0 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/personas/buzz-native-solo.md @@ -0,0 +1,2 @@ +You are the only agent assigned to this channel. Handle the user's request +directly and completely. Be very concise and direct. Use plain, simple language. diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py index 1b79d233b9..47766d4cd2 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/__init__.py @@ -7,13 +7,19 @@ RuntimeLaunchError, ) from .manifest import ExperimentManifest, ManifestError -from .provisioning import AgentCredential, TrialHandle, TrialProvisioner +from .provisioning import ( + AgentCredential, + DirectoryIdentity, + TrialHandle, + TrialProvisioner, +) from .runtime import OrchestraRuntime, RuntimeResult __all__ = [ "AgentCredential", "BuzzContainerRuntime", "BuzzOrchestraAgent", + "DirectoryIdentity", "EndpointLaunchConfig", "ExperimentManifest", "ManifestError", diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py index 3d1c81364f..f98c8f4965 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/agent.py @@ -170,7 +170,11 @@ async def run( # GUI shows one recognisable channel per problem per attempt. channel_label = getattr(environment, "environment_name", None) handle = self.provisioner.create_trial( - run_id, trial_id, self.manifest, channel_label=channel_label + run_id, + trial_id, + self.manifest, + channel_label=channel_label, + task_name=channel_label, ) if handle.trial_id != trial_id: raise RuntimeError("provisioner returned a handle for a different trial_id") diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py index a0602111d1..c69e4080a2 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/container_runtime.py @@ -20,16 +20,21 @@ from harbor.environments.base import BaseEnvironment +from .evidence import build_buzz_evidence from .manifest import AgentClass, ExperimentManifest from .provisioning import AgentCredential, TrialHandle from .runtime import RuntimeResult +from .task_fixtures import fixture_for -DEFAULT_MAX_AGENT_ROUNDS = 0 # 0 = unbounded (BUZZ_AGENT_MAX_ROUNDS=0); the trial budget is the clock +DEFAULT_MAX_AGENT_ROUNDS = ( + 0 # 0 = unbounded (BUZZ_AGENT_MAX_ROUNDS=0); the trial budget is the clock +) # Container-side layout for the uploaded Buzz stack. REMOTE_ROOT = "/opt/buzz" REMOTE_BIN = f"{REMOTE_ROOT}/bin" REMOTE_PROMPTS = f"{REMOTE_ROOT}/prompts" REMOTE_LOGS = f"{REMOTE_ROOT}/logs" +REMOTE_EVIDENCE = "/logs/artifacts/buzz-evidence.json" # The relay is host-header tenant-bound (its community row is the authority # of its own RELAY_URL), so agents must present that exact Host. When the # relay actually lives outside the container, this forwarder listens on the @@ -38,6 +43,14 @@ FORWARDER_LOG = f"{REMOTE_LOGS}/relay-forwarder.log" # How many done-poll iterations between in-container liveness probes. LIVENESS_EVERY = 10 +TRANSCRIPT_LIMIT = 1000 +TURN_ENDED_MARKERS = ( + "turn complete for", + "turn cancelled for", + "turn hit max_tokens for", + "turn hit max_turn_requests for", + "turn refused for", +) class RuntimeLaunchError(RuntimeError): @@ -113,14 +126,14 @@ async def run( ) -> RuntimeResult: classes = self._classes_by_agent_id(manifest, trial.credentials) orchestrator = next(c for c in trial.credentials if c.role == "orchestrator") - workers = [c for c in trial.credentials if c.agent_id != orchestrator.agent_id] - if not workers: - raise RuntimeLaunchError("Buzz orchestration requires at least one worker") trial_dir = self.logs_dir / "buzz" trial_dir.mkdir(parents=True, exist_ok=True) agents: list[_Agent] = [] infra: list[_Agent] = [] + task_event_id: str | None = None + final_message: dict[str, Any] | None = None + evidence_exported = False try: await self._install_stack(environment) forwarder = await self._start_forwarder(environment, trial) @@ -163,25 +176,49 @@ async def run( # fail member resolution and kill the trial before the agent # ever saw the task. An explicit --mention demotes unresolved # @-tokens in the text to presentation-only. - await self._send( + task_event = await self._send( trial.user, trial, f"@{orchestrator.agent_id} {instruction}", mention=orchestrator.nostr_pubkey, ) + if isinstance(task_event, dict) and isinstance( + task_event.get("event_id"), str + ): + task_event_id = task_event["event_id"] final_message = await asyncio.wait_for( - self._wait_for_done(environment, orchestrator, trial, agents + infra), + self._wait_for_done( + environment, + orchestrator, + trial, + agents + infra, + solo=agents[0] if len(agents) == 1 else None, + ), timeout=manifest.trial_budget.timeout_seconds, ) await self._verify_m1_output(environment, manifest) finally: await self._stop_agents(environment, agents + infra) await self._collect_logs(environment, trial_dir) + evidence_exported = await self._collect_evidence( + environment=environment, + trial=trial, + trial_dir=trial_dir, + task_event_id=task_event_id, + completion_message_id=( + final_message.get("id") if final_message is not None else None + ), + ) return RuntimeResult( metadata={ - "completion_message_id": final_message["id"], - "completion_message": final_message["content"], + "completion_message_id": ( + final_message.get("id") if final_message is not None else None + ), + "completion_message": ( + final_message.get("content") if final_message is not None else None + ), + "buzz_evidence_exported": evidence_exported, "agent_runtime": "in-container", "agent_hints_enabled": False, "task_seed": "user-identity-prompt", @@ -359,6 +396,7 @@ def _agent_env( """The desktop-launch environment: real acp/agent/dev-mcp wiring.""" return { **endpoint.env, + "RUST_LOG": self._rust_log(endpoint.env.get("RUST_LOG")), "BUZZ_RELAY_URL": trial.relay_ws_url, "BUZZ_PRIVATE_KEY": credential.nostr_secret_key, # Desktop parity: the GUI also sets NOSTR_PRIVATE_KEY on buzz-acp @@ -390,6 +428,15 @@ def _agent_env( endpoint.api_key_env: credential.llm_api_key, } + @staticmethod + def _rust_log(configured: str | None) -> str: + # ``buzz_acp=info`` carries the subscription-readiness line; the turn + # target lets a solo trial stop when its only turn ends. Keep both: + # replacing the former with only the latter makes a healthy process + # look permanently unready. + required = "buzz_acp=info,pool::prompt=info" + return f"{configured},{required}" if configured else required + # -- lifecycle ------------------------------------------------------------- async def _wait_for_agents_ready( @@ -427,11 +474,13 @@ async def _wait_for_done( orchestrator: AgentCredential, trial: TrialHandle, agents: list[_Agent], - ) -> dict[str, Any]: - """Observe the channel as the trial user until the orchestrator posts DONE. + solo: _Agent | None = None, + ) -> dict[str, Any] | None: + """Observe until a team posts DONE or a solo agent finishes its one turn. Observation only: the harness never speaks as any agent. If the team - stalls, the trial times out and the stall is the measured result. + stalls, the trial times out and the stall is the measured result. A solo + agent cannot be woken by a teammate, so its logged turn end is final. """ polls = 0 while True: @@ -453,8 +502,18 @@ async def _wait_for_done( message.get("content", "") ).startswith("DONE:"): return message + if solo is not None and await self._turn_ended(environment, solo): + return None await asyncio.sleep(self.poll_seconds) + @staticmethod + async def _turn_ended(environment: BaseEnvironment, agent: _Agent) -> bool: + result = await environment.exec( + f"cat {shlex.quote(agent.stdout_log)} " + f"{shlex.quote(agent.stderr_log)} 2>/dev/null" + ) + return any(marker in (result.stdout or "") for marker in TURN_ENDED_MARKERS) + async def _raise_for_dead_agents( self, environment: BaseEnvironment, agents: list[_Agent] ) -> None: @@ -503,6 +562,107 @@ async def _collect_logs( except Exception: # noqa: S110, BLE001 — best effort; env may be torn down pass + async def _collect_evidence( + self, + *, + environment: BaseEnvironment, + trial: TrialHandle, + trial_dir: Path, + task_event_id: str | None, + completion_message_id: str | None, + ) -> bool: + """Snapshot public relay state for the verifier before trial teardown.""" + try: + messages = await self._buzz_json( + trial.user, + trial, + "messages", + "get", + "--channel", + trial.channel_id, + "--limit", + str(TRANSCRIPT_LIMIT), + ) + observed_channels = await self._collect_observed_channels(trial) + evidence = build_buzz_evidence( + trial=trial, + messages=messages, + task_event_id=task_event_id, + completion_message_id=completion_message_id, + transcript_limit=TRANSCRIPT_LIMIT, + observed_channels=observed_channels, + ) + evidence_path = trial_dir / "buzz-evidence.json" + evidence_path.write_text( + json.dumps(evidence, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + transcript = { + "channel_id": trial.channel_id, + "message_count": evidence["message_count"], + "truncated": evidence["truncated"], + "messages": evidence["messages"], + } + (trial_dir / "transcript.json").write_text( + json.dumps(transcript, indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + result = await environment.exec("mkdir -p /logs/artifacts") + if result.return_code != 0: + return False + await environment.upload_file(evidence_path, REMOTE_EVIDENCE) + return True + except Exception: # noqa: BLE001 — absence becomes verifier reward 0 + return False + + async def _collect_observed_channels( + self, trial: TrialHandle + ) -> list[dict[str, Any]]: + """Read task-declared channel state through the production CLI.""" + names = fixture_for(trial.task_name).observe_channel_names + if not names: + return [] + orchestrator = next( + credential + for credential in trial.credentials + if credential.role == "orchestrator" + ) + observed: list[dict[str, Any]] = [] + for name in names: + matches = await self._buzz_json( + orchestrator, + trial, + "channels", + "search", + "--query", + name, + "--exact", + "--include-archived", + ) + if not isinstance(matches, list): + continue + for match in matches: + if not isinstance(match, dict): + continue + channel_id = match.get("channel_id") + if not isinstance(channel_id, str) or not channel_id: + continue + members = await self._buzz_json( + orchestrator, + trial, + "channels", + "members", + "--channel", + channel_id, + ) + observed.append( + { + **match, + "members": members if isinstance(members, list) else [], + } + ) + return observed + # -- Buzz CLI as the trial user / provisioning identities ------------------- @staticmethod @@ -533,7 +693,7 @@ async def _send( content: str, *, mention: str | None = None, - ) -> None: + ) -> Any: args = [ "messages", "send", @@ -544,7 +704,7 @@ async def _send( ] if mention is not None: args += ["--mention", mention] - await self._buzz_json(credential, trial, *args) + return await self._buzz_json(credential, trial, *args) async def _buzz_json( self, credential: AgentCredential, trial: TrialHandle, *args: str diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/evidence.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/evidence.py new file mode 100644 index 0000000000..10b97f4a38 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/evidence.py @@ -0,0 +1,129 @@ +"""Stable, verifier-facing evidence derived from a Buzz channel transcript.""" + +from __future__ import annotations + +from typing import Any, Iterable, Mapping + +from .provisioning import TrialHandle + +EVIDENCE_SCHEMA_VERSION = 1 + + +def _tags(message: Mapping[str, Any]) -> list[list[str]]: + """Return only well-formed string tags from a relay message.""" + raw = message.get("tags") + if not isinstance(raw, list): + return [] + return [ + list(tag) + for tag in raw + if isinstance(tag, list) + and tag + and all(isinstance(value, str) for value in tag) + ] + + +def _tag_value( + tags: Iterable[list[str]], name: str, marker: str | None = None +) -> str | None: + for tag in tags: + if len(tag) < 2 or tag[0] != name: + continue + if marker is not None and (len(tag) < 4 or tag[3] != marker): + continue + return tag[1] + return None + + +def _normalize_message( + message: Mapping[str, Any], identities: Mapping[str, Mapping[str, str]] +) -> dict[str, Any]: + tags = _tags(message) + pubkey = message.get("pubkey") if isinstance(message.get("pubkey"), str) else "" + identity = identities.get(pubkey, {}) + return { + "id": message.get("id") if isinstance(message.get("id"), str) else "", + "kind": message.get("kind") if isinstance(message.get("kind"), int) else None, + "created_at": ( + message.get("created_at") + if isinstance(message.get("created_at"), int) + else None + ), + "pubkey": pubkey, + "author": identity.get("name", "unknown"), + "author_role": identity.get("role", "unknown"), + "content": ( + message.get("content") if isinstance(message.get("content"), str) else "" + ), + # Preserve the signed protocol evidence. Derived fields below make the + # common checks convenient without replacing the source-of-truth tags. + "tags": tags, + "channel_id": _tag_value(tags, "h"), + "reply_to_event_id": _tag_value(tags, "e", "reply"), + "mentioned_pubkeys": [ + tag[1] for tag in tags if len(tag) >= 2 and tag[0] == "p" + ], + } + + +def build_buzz_evidence( + *, + trial: TrialHandle, + messages: object, + task_event_id: str | None, + completion_message_id: str | None, + transcript_limit: int, + observed_channels: object = None, +) -> dict[str, Any]: + """Normalize relay messages into a versioned contract for task verifiers. + + Private keys and auth tags are intentionally absent. The exported identities + contain only public names, roles, and pubkeys already visible on the relay. + """ + raw_messages = messages if isinstance(messages, list) else [] + identity_rows = ( + (trial.user.agent_id, "user", trial.user.nostr_pubkey), + *( + (credential.agent_id, credential.role, credential.nostr_pubkey) + for credential in trial.credentials + ), + ) + identities_by_pubkey = { + pubkey: {"name": name, "role": role} for name, role, pubkey in identity_rows + } + identities = { + name: {"role": role, "pubkey": pubkey} for name, role, pubkey in identity_rows + } + normalized = [ + _normalize_message(message, identities_by_pubkey) + for message in raw_messages + if isinstance(message, dict) + ] + normalized.sort( + key=lambda message: ( + message["created_at"] is None, + message["created_at"] or 0, + ) + ) + return { + "schema_version": EVIDENCE_SCHEMA_VERSION, + "trial": { + "run_id": trial.run_id, + "trial_id": trial.trial_id, + "channel_id": trial.channel_id, + }, + "task_event_id": task_event_id, + "completion_message_id": completion_message_id, + "identities": identities, + "directory": [ + {"name": identity.name, "role": identity.role, "pubkey": identity.pubkey} + for identity in trial.directory + ], + "task_name": trial.task_name, + "observed_channels": ( + observed_channels if isinstance(observed_channels, list) else [] + ), + "message_count": len(normalized), + "truncated": len(raw_messages) >= transcript_limit, + "messages": normalized, + } diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/provisioning.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/provisioning.py index 37e9bbe478..b27ae8ae12 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/provisioning.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/provisioning.py @@ -21,6 +21,15 @@ class AgentCredential: llm_api_key: str +@dataclass(frozen=True, slots=True) +class DirectoryIdentity: + """One public, benchmark-seeded identity discoverable through Buzz.""" + + name: str + role: str + pubkey: str + + @dataclass(frozen=True, slots=True) class TrialHandle: """Provisioned Buzz resources owned by one Harbor trial.""" @@ -39,6 +48,9 @@ class TrialHandle: # identity and the harness run. ``relay_ws_url`` is the view from the # agents' runtime (the task container). Empty means both views coincide. user_relay_url: str = "" + # Additive Buzz-native task context. Directory entries contain no secrets. + task_name: str = "" + directory: tuple[DirectoryIdentity, ...] = () @runtime_checkable @@ -51,6 +63,7 @@ def create_trial( trial_id: str, manifest: ExperimentManifest, channel_label: str | None = None, + task_name: str | None = None, ) -> TrialHandle: ... def teardown(self, handle: TrialHandle) -> None: ... diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/task_fixtures.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/task_fixtures.py new file mode 100644 index 0000000000..b1298697dc --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/task_fixtures.py @@ -0,0 +1,48 @@ +"""Public setup declarations for Buzz-native benchmark tasks.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True, slots=True) +class DirectoryEntry: + """A named identity to seed into the benchmark community.""" + + name: str + role: str + + +@dataclass(frozen=True, slots=True) +class BuzzTaskFixture: + """Relay state a task needs before the agent receives its prompt.""" + + directory: tuple[DirectoryEntry, ...] = () + observe_channel_names: tuple[str, ...] = () + + +CREATE_CHANNEL_TASK = "create-channel-invite-users" +CREATE_CHANNEL_NAME = "fix-pr-1234" +TARGET_USERS = ("benchmark-user-07", "benchmark-user-19", "benchmark-user-42") +TARGET_BOTS = ("benchmark-bot-03", "benchmark-bot-08") + +_CREATE_CHANNEL_FIXTURE = BuzzTaskFixture( + directory=tuple( + [ + DirectoryEntry(f"benchmark-user-{index:02d}", "user") + for index in range(1, 51) + ] + + [ + DirectoryEntry(f"benchmark-bot-{index:02d}", "bot") + for index in range(1, 11) + ] + ), + observe_channel_names=(CREATE_CHANNEL_NAME,), +) + + +def fixture_for(task_name: str | None) -> BuzzTaskFixture: + """Return the declared setup for a task, or an empty setup.""" + if task_name == CREATE_CHANNEL_TASK: + return _CREATE_CHANNEL_FIXTURE + return BuzzTaskFixture() diff --git a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py index bd11f193ca..f1c4077d7e 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/buzz_cli.py @@ -93,6 +93,17 @@ def add_member(self, channel_id: str, pubkey: str) -> None: "member", ) + def profiles(self, pubkeys: list[str]) -> list[dict[str, Any]]: + """Return the profiles currently published for the given pubkeys.""" + args = ["users", "get"] + for pubkey in pubkeys: + args.extend(("--pubkey", pubkey)) + response = self.run(*args) + return response if isinstance(response, list) else [] + + def set_profile(self, name: str) -> None: + self.run("users", "set-profile", "--name", name) + def archive_channel(self, channel_id: str) -> None: self.run("channels", "archive", "--channel", channel_id) diff --git a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py index d8f380387d..d97fb80b91 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py @@ -11,7 +11,12 @@ import psycopg from harbor_buzz_orchestra.manifest import ExperimentManifest -from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle +from harbor_buzz_orchestra.provisioning import ( + AgentCredential, + DirectoryIdentity, + TrialHandle, +) +from harbor_buzz_orchestra.task_fixtures import fixture_for from .buzz_cli import BuzzCli from .keys import compute_auth_tag, generate_keypair, keypair_from_secret @@ -73,6 +78,7 @@ def create_trial( trial_id: str, manifest: ExperimentManifest, channel_label: str | None = None, + task_name: str | None = None, ) -> TrialHandle: manifest_hash = manifest.sha256 with psycopg.connect(self._config.postgres_dsn) as conn: @@ -87,7 +93,12 @@ def create_trial( return existing handle = self._provision( - run_id, trial_id, manifest, manifest_hash, channel_label + run_id, + trial_id, + manifest, + manifest_hash, + channel_label, + task_name, ) self._store_trial(conn, handle) conn.commit() @@ -139,6 +150,7 @@ def _provision( manifest: ExperimentManifest, manifest_hash: str, channel_label: str | None, + task_name: str | None, ) -> TrialHandle: credentials = self._mint_credentials(manifest) user = self._mint_user() @@ -157,6 +169,7 @@ def _provision( ) for credential in credentials: cli.add_member(channel_id, credential.nostr_pubkey) + directory = self._seed_directory(task_name, cli) return TrialHandle( run_id=run_id, trial_id=trial_id, @@ -166,6 +179,60 @@ def _provision( credentials=credentials, user=user, user_relay_url=self._config.relay_http_url, + task_name=task_name or "", + directory=directory, + ) + + def _seed_directory( + self, task_name: str | None, observer: BuzzCli + ) -> tuple[DirectoryIdentity, ...]: + """Publish stable task-directory profiles, skipping those already seeded.""" + entries = fixture_for(task_name).directory + credentials = [ + self._directory_credential(entry.name, entry.role) for entry in entries + ] + if not credentials: + return () + existing = { + profile.get("pubkey") + for profile in observer.profiles( + [credential.nostr_pubkey for credential in credentials] + ) + if isinstance(profile, dict) + } + for credential in credentials: + if credential.nostr_pubkey not in existing: + self._cli_for(credential).set_profile(credential.agent_id) + return tuple( + DirectoryIdentity( + name=credential.agent_id, + role=credential.role, + pubkey=credential.nostr_pubkey, + ) + for credential in credentials + ) + + def _directory_credential(self, name: str, role: str) -> AgentCredential: + """Derive one community-stable benchmark identity without storing its key.""" + order = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 + digest = hashlib.sha256( + b"buzz-benchmark-directory-v1\0" + + bytes.fromhex(self._config.owner_secret_key) + + b"\0" + + name.encode() + ).digest() + secret = ((int.from_bytes(digest, "big") % (order - 1)) + 1).to_bytes(32, "big") + keypair = keypair_from_secret(secret.hex()) + return AgentCredential( + agent_id=name, + role=role, + nostr_secret_key=keypair.secret_key, + nostr_pubkey=keypair.pubkey, + nostr_auth_tag=compute_auth_tag( + self._config.owner_secret_key, keypair.pubkey + ), + llm_endpoint="", + llm_api_key="", ) def _mint_credentials( @@ -256,6 +323,11 @@ def _load_trial( ), user=AgentCredential(**stored["user"]), user_relay_url=stored.get("user_relay_url", ""), + task_name=stored.get("task_name", ""), + directory=tuple( + DirectoryIdentity(**identity) + for identity in stored.get("directory", []) + ), ) @staticmethod diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py index e784de5825..02d7309298 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py @@ -96,6 +96,44 @@ def test_mint_credentials_missing_api_key_is_explicit(manifest): provisioner._mint_credentials(manifest) +def test_directory_credentials_are_stable_distinct_and_attested(): + provisioner = BuzzTrialProvisioner(config()) + + first = provisioner._directory_credential("benchmark-user-01", "user") + again = provisioner._directory_credential("benchmark-user-01", "user") + other = provisioner._directory_credential("benchmark-bot-01", "bot") + + assert first.nostr_secret_key == again.nostr_secret_key + assert first.nostr_pubkey == again.nostr_pubkey + assert first.nostr_pubkey != other.nostr_pubkey + assert first.role == "user" and other.role == "bot" + assert json.loads(first.nostr_auth_tag)[2] == "" + + +def test_seed_directory_has_50_users_10_bots_and_skips_existing(monkeypatch): + provisioner = BuzzTrialProvisioner(config()) + + class Observer: + def profiles(self, pubkeys): + return [{"pubkey": pubkeys[0]}] + + published = [] + + class Publisher: + def set_profile(self, name): + published.append(name) + + monkeypatch.setattr(provisioner, "_cli_for", lambda _credential: Publisher()) + + directory = provisioner._seed_directory("create-channel-invite-users", Observer()) + + assert len(directory) == 60 + assert sum(identity.role == "user" for identity in directory) == 50 + assert sum(identity.role == "bot" for identity in directory) == 10 + assert len(published) == 59 + assert directory[0].name not in published + + def test_lock_key_is_deterministic_and_distinct(): calls: list[int] = [] diff --git a/benchmarks/harbor-buzz-orchestra/tests/fixtures/transcripts/threaded.json b/benchmarks/harbor-buzz-orchestra/tests/fixtures/transcripts/threaded.json new file mode 100644 index 0000000000..9f7d045ddd --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/tests/fixtures/transcripts/threaded.json @@ -0,0 +1,32 @@ +{ + "channel_id": "6a178caa-07de-4594-9296-1f130b3f32e2", + "message_count": 2, + "truncated": false, + "messages": [ + { + "id": "585eeddbd9c1384696a615faeafdca5c00ff806276c8c6c66b654e7bbb40e167", + "author": "user", + "pubkey": "779c3f730c638f67cc06c7e7d55d31720c85c9eb74cec2050d0ac5fdabaafea8", + "content": "@solo-1 Complete the requested task.", + "created_at": 1786995079, + "kind": 9, + "tags": [ + ["h", "6a178caa-07de-4594-9296-1f130b3f32e2"], + ["p", "ed8ce3ee42988114b5940d9dd0023d576649e06c47bc018ef74933b2472e4854"] + ] + }, + { + "id": "220c6ac96cf28a995cc14ff8b72d5ec7d968c0a859eeaa0d2ec354670def60e5", + "author": "solo-1", + "pubkey": "ed8ce3ee42988114b5940d9dd0023d576649e06c47bc018ef74933b2472e4854", + "content": "DONE: Completed the requested task.", + "created_at": 1786995113, + "kind": 9, + "tags": [ + ["h", "6a178caa-07de-4594-9296-1f130b3f32e2"], + ["e", "585eeddbd9c1384696a615faeafdca5c00ff806276c8c6c66b654e7bbb40e167", "", "reply"], + ["p", "779c3f730c638f67cc06c7e7d55d31720c85c9eb74cec2050d0ac5fdabaafea8"] + ] + } + ] +} diff --git a/benchmarks/harbor-buzz-orchestra/tests/fixtures/transcripts/top-level.json b/benchmarks/harbor-buzz-orchestra/tests/fixtures/transcripts/top-level.json new file mode 100644 index 0000000000..a00d5da78b --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/tests/fixtures/transcripts/top-level.json @@ -0,0 +1,30 @@ +{ + "channel_id": "91879837-0cae-4c42-ab42-b01fe6ff8f35", + "message_count": 2, + "truncated": false, + "messages": [ + { + "id": "774c8ea010c6cdf4b6cbd667463d5a62371de00ac8913af190b8988b48f2b230", + "author": "user", + "pubkey": "d56c715eb650f6e880851b3f37e9a742f29436a085e925d80c6d0904a397fb0f", + "content": "@solo-1 Complete the requested task.", + "created_at": 1786993144, + "kind": 9, + "tags": [ + ["h", "91879837-0cae-4c42-ab42-b01fe6ff8f35"], + ["p", "40ed137e6e725207a63905cbbdbfe99497c28aa16eec8efbeeff49145ad575c8"] + ] + }, + { + "id": "943fefebec06504c99a48f704e77fe246cd81a0e90f5e3d6a1d073554a812c0d", + "author": "solo-1", + "pubkey": "40ed137e6e725207a63905cbbdbfe99497c28aa16eec8efbeeff49145ad575c8", + "content": "DONE: Completed the requested task.", + "created_at": 1786993179, + "kind": 9, + "tags": [ + ["h", "91879837-0cae-4c42-ab42-b01fe6ff8f35"] + ] + } + ] +} diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_agent.py b/benchmarks/harbor-buzz-orchestra/tests/test_agent.py index b305344c51..184cc0194e 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/test_agent.py +++ b/benchmarks/harbor-buzz-orchestra/tests/test_agent.py @@ -37,8 +37,10 @@ def __init__(self): def healthcheck(self): self.healthchecked = True - def create_trial(self, run_id, trial_id, manifest, channel_label=None): - self.created = (run_id, trial_id, manifest, channel_label) + def create_trial( + self, run_id, trial_id, manifest, channel_label=None, task_name=None + ): + self.created = (run_id, trial_id, manifest, channel_label, task_name) return TrialHandle( run_id, trial_id, @@ -91,6 +93,7 @@ async def test_agent_lifecycle_and_context(tmp_path, manifest_data): assert provisioner.created[:2] == ("run-1", str(context_id)) # The task short name labels the trial channel for spectator GUIs. assert provisioner.created[3] == "hello-world" + assert provisioner.created[4] == "hello-world" assert provisioner.torn_down.channel_id == "channel-1" assert runtime.called["instruction"] == "solve it" assert ( diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py index c0f5beeef2..671c6ad9fe 100644 --- a/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py +++ b/benchmarks/harbor-buzz-orchestra/tests/test_container_runtime.py @@ -3,6 +3,7 @@ import hashlib import json import re +from dataclasses import replace from pathlib import Path import pytest @@ -10,6 +11,7 @@ from harbor_buzz_orchestra.container_runtime import ( REMOTE_BIN, + REMOTE_EVIDENCE, REMOTE_LOGS, BuzzContainerRuntime, EndpointLaunchConfig, @@ -173,6 +175,51 @@ def test_user_relay_url_prefers_host_view(tmp_path): ) # pre-v1.2 handles fall back to deriving http from the agents' ws view. assert rt._user_relay_url(trial_handle(())) == "http://host.docker.internal:3600" + + +async def test_collects_task_declared_channel_membership(tmp_path, monkeypatch): + rt = runtime(tmp_path) + trial = replace( + trial_handle((credential("orch-1", "orchestrator", "orch-model"),)), + task_name="create-channel-invite-users", + ) + calls = [] + + async def buzz_json(credential_arg, trial_arg, *args): + calls.append((credential_arg, trial_arg, args)) + if args[:2] == ("channels", "search"): + return [ + { + "channel_id": "created-channel", + "name": "fix-pr-1234", + "channel_type": "stream", + "visibility": "private", + "archived": False, + "ttl_seconds": 3600, + } + ] + return [{"pubkey": "member", "role": "member"}] + + monkeypatch.setattr(rt, "_buzz_json", buzz_json) + + observed = await rt._collect_observed_channels(trial) + + assert observed[0]["members"] == [{"pubkey": "member", "role": "member"}] + assert calls[0][0].agent_id == "orch-1" + assert calls[0][2] == ( + "channels", + "search", + "--query", + "fix-pr-1234", + "--exact", + "--include-archived", + ) + assert calls[1][2] == ( + "channels", + "members", + "--channel", + "created-channel", + ) with pytest.raises(RuntimeLaunchError, match="ws://"): rt._cli_relay_url("http://relay") @@ -375,9 +422,7 @@ async def test_m1_output_probe_matches_grader_and_is_condition_scoped( assert bool(probed) == (condition == "M1-hello-world") -async def test_send_mentions_by_pubkey_so_task_text_stays_inert( - tmp_path, monkeypatch -): +async def test_send_mentions_by_pubkey_so_task_text_stays_inert(tmp_path, monkeypatch): """Task text is untrusted payload: `:%normal! @a` in a task statement must not be fed to member-name resolution (it would fail and kill the trial). An explicit --mention pins delivery to the orchestrator's pubkey.""" @@ -429,6 +474,84 @@ async def buzz_json(credential, *args, **kwargs): assert set(observers) == {"user"} +async def test_solo_turn_end_completes_without_done_message(tmp_path, monkeypatch): + from harbor_buzz_orchestra.container_runtime import _Agent + + rt = runtime(tmp_path, poll_seconds=0) + orch = credential("orch-1", "orchestrator", "orch-model") + trial = trial_handle((orch,)) + solo = _Agent(orch, 7, "stdout.log", "stderr.log") + environment = Environment( + responses={ + "cat ": ExecResult( + stdout="turn complete for channel: end_turn\n", + stderr="", + return_code=0, + ) + } + ) + + async def buzz_json(*args, **kwargs): + return [] + + monkeypatch.setattr(rt, "_buzz_json", buzz_json) + assert await rt._wait_for_done(environment, orch, trial, [], solo=solo) is None + + +async def test_collect_evidence_uploads_verifier_artifact(tmp_path, monkeypatch): + rt = runtime(tmp_path) + orch = credential("orch-1", "orchestrator", "orch-model") + trial = trial_handle((orch,)) + root_id = "root-event" + reply_id = "reply-event" + messages = [ + { + "id": root_id, + "kind": 9, + "created_at": 1, + "pubkey": trial.user.nostr_pubkey, + "content": "question", + "tags": [["h", trial.channel_id], ["p", orch.nostr_pubkey]], + }, + { + "id": reply_id, + "kind": 9, + "created_at": 2, + "pubkey": orch.nostr_pubkey, + "content": "answer", + "tags": [["h", trial.channel_id], ["e", root_id, "", "reply"]], + }, + ] + + async def buzz_json(*args, **kwargs): + return messages + + monkeypatch.setattr(rt, "_buzz_json", buzz_json) + environment = Environment() + trial_dir = tmp_path / "trial" + trial_dir.mkdir() + + assert await rt._collect_evidence( + environment=environment, + trial=trial, + trial_dir=trial_dir, + task_event_id=root_id, + completion_message_id=reply_id, + ) + assert environment.uploads[-1][1] == REMOTE_EVIDENCE + evidence = json.loads((trial_dir / "buzz-evidence.json").read_text()) + assert evidence["messages"][-1]["reply_to_event_id"] == root_id + assert (trial_dir / "transcript.json").is_file() + + +def test_runtime_logging_keeps_readiness_and_turn_completion_signals(tmp_path): + rt = runtime(tmp_path) + assert rt._rust_log(None) == "buzz_acp=info,pool::prompt=info" + assert rt._rust_log("custom=debug") == ( + "custom=debug,buzz_acp=info,pool::prompt=info" + ) + + def test_composed_system_prompt_carries_persona_and_team_roster(tmp_path): rt = runtime(tmp_path) orch = credential("orch-1", "orchestrator", "orch-model") diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_create_channel_invite_users_verifier.py b/benchmarks/harbor-buzz-orchestra/tests/test_create_channel_invite_users_verifier.py new file mode 100644 index 0000000000..2a0a575573 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/tests/test_create_channel_invite_users_verifier.py @@ -0,0 +1,123 @@ +import copy +import importlib.util +from pathlib import Path + +from harbor_buzz_orchestra.task_fixtures import TARGET_BOTS, TARGET_USERS, fixture_for + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +VERIFIER = ( + PACKAGE_ROOT + / "datasets" + / "buzz-native" + / "create-channel-invite-users" + / "tests" + / "verify.py" +) +SPEC = importlib.util.spec_from_file_location("create_channel_verifier", VERIFIER) +verifier = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(verifier) + +ORCHESTRATOR = "a" * 64 + + +def _evidence() -> dict: + fixture = fixture_for("create-channel-invite-users") + directory = [ + {"name": entry.name, "role": entry.role, "pubkey": f"{index:064x}"} + for index, entry in enumerate(fixture.directory, start=1) + ] + by_name = {entry["name"]: entry for entry in directory} + members = [{"pubkey": ORCHESTRATOR, "role": "owner"}] + members += [ + {"pubkey": by_name[name]["pubkey"], "role": "member"} for name in TARGET_USERS + ] + members += [ + {"pubkey": by_name[name]["pubkey"], "role": "bot"} for name in TARGET_BOTS + ] + return { + "schema_version": 1, + "task_name": "create-channel-invite-users", + "identities": {"solo-1": {"role": "orchestrator", "pubkey": ORCHESTRATOR}}, + "directory": directory, + "observed_channels": [ + { + "channel_id": "channel-1", + "name": "fix-pr-1234", + "channel_type": "stream", + "visibility": "private", + "archived": False, + "ttl_seconds": 3600, + "members": members, + } + ], + } + + +def test_exact_temporary_channel_and_roster_passes(): + metrics, details = verifier.score_evidence(_evidence()) + + assert all(value == 1.0 for value in metrics.values()) + assert details["channel_id"] == "channel-1" + + +def test_extra_member_fails_exact_membership(): + evidence = _evidence() + evidence["observed_channels"][0]["members"].append( + {"pubkey": "f" * 64, "role": "member"} + ) + + metrics, _ = verifier.score_evidence(evidence) + + assert metrics["channel_created"] == 1.0 + assert metrics["exact_membership"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_wrong_bot_role_fails_roles(): + evidence = _evidence() + bot_pubkey = next( + row["pubkey"] for row in evidence["directory"] if row["name"] == TARGET_BOTS[0] + ) + member = next( + row + for row in evidence["observed_channels"][0]["members"] + if row["pubkey"] == bot_pubkey + ) + member["role"] = "member" + + metrics, _ = verifier.score_evidence(evidence) + + assert metrics["exact_membership"] == 1.0 + assert metrics["expected_roles"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_permanent_channel_fails_temporary_requirement(): + evidence = _evidence() + evidence["observed_channels"][0]["ttl_seconds"] = None + + metrics, _ = verifier.score_evidence(evidence) + + assert metrics["temporary_channel"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_duplicate_exact_name_fails_channel_creation(): + evidence = _evidence() + evidence["observed_channels"].append( + copy.deepcopy(evidence["observed_channels"][0]) + ) + + metrics, details = verifier.score_evidence(evidence) + + assert details["matching_channel_count"] == 2 + assert metrics["channel_created"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_missing_evidence_fails_closed(): + metrics, details = verifier.score_evidence(None) + + assert all(value == 0.0 for value in metrics.values()) + assert "error" in details diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_evidence.py b/benchmarks/harbor-buzz-orchestra/tests/test_evidence.py new file mode 100644 index 0000000000..3e67f43a3b --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/tests/test_evidence.py @@ -0,0 +1,129 @@ +import json +from pathlib import Path + +from harbor_buzz_orchestra.evidence import build_buzz_evidence +from harbor_buzz_orchestra.provisioning import ( + AgentCredential, + DirectoryIdentity, + TrialHandle, +) + +FIXTURES = Path(__file__).parent / "fixtures" / "transcripts" + + +def _credential(agent_id: str, role: str, pubkey: str) -> AgentCredential: + return AgentCredential( + agent_id=agent_id, + role=role, + nostr_secret_key=f"secret-{agent_id}", + nostr_pubkey=pubkey, + nostr_auth_tag=f"auth-{agent_id}", + llm_endpoint="model" if role != "user" else "", + llm_api_key="key" if role != "user" else "", + ) + + +def _load(name: str) -> dict: + return json.loads((FIXTURES / name).read_text(encoding="utf-8")) + + +def _trial(transcript: dict) -> TrialHandle: + user_message, agent_message = transcript["messages"] + return TrialHandle( + run_id="run-1", + trial_id="trial-1", + manifest_hash="hash", + relay_ws_url="ws://relay", + channel_id=transcript["channel_id"], + credentials=(_credential("solo-1", "orchestrator", agent_message["pubkey"]),), + user=_credential("user", "user", user_message["pubkey"]), + ) + + +def _evidence(name: str) -> dict: + transcript = _load(name) + return build_buzz_evidence( + trial=_trial(transcript), + messages=list(reversed(transcript["messages"])), + task_event_id=transcript["messages"][0]["id"], + completion_message_id=transcript["messages"][-1]["id"], + transcript_limit=1000, + ) + + +def test_normalizes_real_threaded_transcript_and_preserves_protocol_tags(): + evidence = _evidence("threaded.json") + + assert evidence["schema_version"] == 1 + assert evidence["message_count"] == 2 + assert evidence["messages"][0]["author_role"] == "user" + reply = evidence["messages"][-1] + assert reply["author"] == "solo-1" + assert reply["author_role"] == "orchestrator" + assert reply["channel_id"] == evidence["trial"]["channel_id"] + assert reply["reply_to_event_id"] == evidence["task_event_id"] + assert ["e", evidence["task_event_id"], "", "reply"] in reply["tags"] + + +def test_top_level_agent_message_has_no_derived_reply_destination(): + evidence = _evidence("top-level.json") + + assert evidence["messages"][-1]["reply_to_event_id"] is None + + +def test_malformed_messages_are_safe_and_secrets_are_never_exported(): + transcript = _load("threaded.json") + trial = _trial(transcript) + evidence = build_buzz_evidence( + trial=trial, + messages=[None, {"id": "broken", "tags": ["bad", ["e", 7]]}], + task_event_id=None, + completion_message_id=None, + transcript_limit=1, + ) + + assert evidence["message_count"] == 1 + assert evidence["messages"][0]["tags"] == [] + assert evidence["truncated"] is True + encoded = json.dumps(evidence) + assert "nostr_secret_key" not in encoded + assert "auth-user" not in encoded + assert "secret-solo-1" not in encoded + + +def test_exports_only_public_directory_and_observed_channel_state(): + transcript = _load("threaded.json") + trial = _trial(transcript) + trial = TrialHandle( + **{ + field: getattr(trial, field) + for field in ( + "run_id", + "trial_id", + "manifest_hash", + "relay_ws_url", + "channel_id", + "credentials", + "user", + "user_relay_url", + ) + }, + task_name="create-channel-invite-users", + directory=(DirectoryIdentity("benchmark-user-01", "user", "d" * 64),), + ) + channels = [{"name": "fix-pr-1234", "members": []}] + + evidence = build_buzz_evidence( + trial=trial, + messages=[], + task_event_id=None, + completion_message_id=None, + transcript_limit=1000, + observed_channels=channels, + ) + + assert evidence["task_name"] == "create-channel-invite-users" + assert evidence["directory"] == [ + {"name": "benchmark-user-01", "role": "user", "pubkey": "d" * 64} + ] + assert evidence["observed_channels"] == channels diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_reply_to_thread_verifier.py b/benchmarks/harbor-buzz-orchestra/tests/test_reply_to_thread_verifier.py new file mode 100644 index 0000000000..0075cbdf7f --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/tests/test_reply_to_thread_verifier.py @@ -0,0 +1,139 @@ +import copy +import importlib.util +import json +from pathlib import Path + +from harbor_buzz_orchestra.evidence import build_buzz_evidence +from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +FIXTURES = Path(__file__).parent / "fixtures" / "transcripts" +VERIFIER = ( + PACKAGE_ROOT + / "datasets" + / "buzz-native" + / "reply-to-thread" + / "tests" + / "verify.py" +) +SPEC = importlib.util.spec_from_file_location("reply_to_thread_verifier", VERIFIER) +verifier = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(verifier) + +CORRECT_ANSWER = ( + "Month 6 revenue: $160,811; month 6 expenses: $84,462; " + "cumulative operating profit: $374,470." +) + + +def _credential(agent_id: str, role: str, pubkey: str) -> AgentCredential: + return AgentCredential( + agent_id=agent_id, + role=role, + nostr_secret_key="secret", + nostr_pubkey=pubkey, + nostr_auth_tag="auth", + llm_endpoint="model" if role != "user" else "", + llm_api_key="key" if role != "user" else "", + ) + + +def _evidence(fixture: str) -> dict: + transcript = json.loads((FIXTURES / fixture).read_text(encoding="utf-8")) + root, answer = transcript["messages"] + trial = TrialHandle( + run_id="run", + trial_id="trial", + manifest_hash="hash", + relay_ws_url="ws://relay", + channel_id=transcript["channel_id"], + credentials=(_credential("solo-1", "orchestrator", answer["pubkey"]),), + user=_credential("user", "user", root["pubkey"]), + ) + answer["content"] = CORRECT_ANSWER + return build_buzz_evidence( + trial=trial, + messages=transcript["messages"], + task_event_id=root["id"], + completion_message_id=None, + transcript_limit=1000, + ) + + +def test_correct_answer_in_direct_thread_reply_passes(): + metrics, details = verifier.score_evidence(_evidence("threaded.json")) + + assert metrics == { + "reward": 1.0, + "answer_correct": 1.0, + "reply_to_thread": 1.0, + "same_channel": 1.0, + "expected_author": 1.0, + "evidence_complete": 1.0, + } + assert details["selected_message_id"] is not None + + +def test_correct_top_level_answer_fails_only_threading_and_reward(): + metrics, _ = verifier.score_evidence(_evidence("top-level.json")) + + assert metrics["answer_correct"] == 1.0 + assert metrics["reply_to_thread"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_wrong_answer_in_correct_thread_fails_correctness(): + evidence = _evidence("threaded.json") + evidence["messages"][-1]["content"] = "Revenue $1, expenses $2, profit $3" + + metrics, _ = verifier.score_evidence(evidence) + + assert metrics["reply_to_thread"] == 1.0 + assert metrics["answer_correct"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_reply_to_unrelated_event_fails_threading(): + evidence = _evidence("threaded.json") + answer = evidence["messages"][-1] + answer["reply_to_event_id"] = "unrelated" + answer["tags"] = [ + ["h", evidence["trial"]["channel_id"]], + ["e", "unrelated", "", "reply"], + ] + + metrics, _ = verifier.score_evidence(evidence) + + assert metrics["answer_correct"] == 1.0 + assert metrics["reply_to_thread"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_latest_agent_message_is_the_final_message_being_scored(): + evidence = _evidence("threaded.json") + later = copy.deepcopy(evidence["messages"][-1]) + later.update( + { + "id": "later-top-level", + "created_at": later["created_at"] + 1, + "reply_to_event_id": None, + "tags": [["h", evidence["trial"]["channel_id"]]], + } + ) + evidence["messages"].append(later) + evidence["message_count"] += 1 + + metrics, details = verifier.score_evidence(evidence) + + assert details["selected_message_id"] == "later-top-level" + assert metrics["reply_to_thread"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_missing_evidence_fails_closed(): + metrics, details = verifier.score_evidence(None) + + assert metrics["reward"] == 0.0 + assert all(value == 0.0 for value in metrics.values()) + assert "error" in details diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 5cc745d7b9..7ad051ef9f 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -161,6 +161,7 @@ struct ChannelSummary { about: Option, topic: Option, purpose: Option, + ttl_seconds: Option, } impl ChannelSummary { @@ -176,6 +177,7 @@ impl ChannelSummary { let mut about: Option = None; let mut topic: Option = None; let mut purpose: Option = None; + let mut ttl_seconds: Option = None; for tag in tags { let Some(tag_arr) = tag.as_array() else { @@ -194,6 +196,7 @@ impl ChannelSummary { "about" => about = val.map(str::to_string), "topic" => topic = val.map(str::to_string), "purpose" => purpose = val.map(str::to_string), + "ttl" => ttl_seconds = val.and_then(|value| value.parse().ok()), "archived" => archived = val == Some("true"), _ => {} } @@ -208,6 +211,7 @@ impl ChannelSummary { about, topic, purpose, + ttl_seconds, }) } } @@ -1215,6 +1219,7 @@ mod tests { ["about", "About text"], ["topic", "Composer work"], ["purpose", "Track UI for the composer"], + ["ttl", "3600"], ])); let s = ChannelSummary::from_event(&ev).expect("parse"); assert_eq!(s.channel_id, "11111111-1111-1111-1111-111111111111"); @@ -1225,6 +1230,7 @@ mod tests { assert_eq!(s.about.as_deref(), Some("About text")); assert_eq!(s.topic.as_deref(), Some("Composer work")); assert_eq!(s.purpose.as_deref(), Some("Track UI for the composer")); + assert_eq!(s.ttl_seconds, Some(3600)); } #[test] From cd15dea8ac2a05cdb88899687159f85494b22ca3 Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Tue, 18 Aug 2026 17:31:29 -0400 Subject: [PATCH 2/5] Add user mention benchmark Signed-off-by: Salman Mohammed --- benchmarks/harbor-buzz-orchestra/README.md | 6 +- .../user-mention/environment/Dockerfile | 3 + .../buzz-native/user-mention/instruction.md | 2 + .../buzz-native/user-mention/task.toml | 19 ++ .../buzz-native/user-mention/tests/test.sh | 7 + .../buzz-native/user-mention/tests/verify.py | 170 ++++++++++++++++++ .../harbor_buzz_orchestra/task_fixtures.py | 9 + .../src/harbor_buzz_testbed/provisioner.py | 22 ++- .../testbed/tests/test_provisioner_unit.py | 13 ++ .../tests/test_user_mention_verifier.py | 96 ++++++++++ 10 files changed, 340 insertions(+), 7 deletions(-) create mode 100644 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/environment/Dockerfile create mode 100644 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/instruction.md create mode 100644 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/task.toml create mode 100755 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/tests/test.sh create mode 100644 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/tests/verify.py create mode 100644 benchmarks/harbor-buzz-orchestra/tests/test_user_mention_verifier.py diff --git a/benchmarks/harbor-buzz-orchestra/README.md b/benchmarks/harbor-buzz-orchestra/README.md index ba4e044ae3..f0da32dc87 100644 --- a/benchmarks/harbor-buzz-orchestra/README.md +++ b/benchmarks/harbor-buzz-orchestra/README.md @@ -65,9 +65,9 @@ artifacts) available for analysis. ### Buzz-native tasks The local `datasets/buzz-native` suite scores Buzz product behavior alongside -task correctness. It currently covers direct thread replies and exact channel -creation/membership. Run one task with the production base prompt from the -checked-out source build: +task correctness. It currently covers direct thread replies, callback user +mentions, and exact channel creation/membership. Run one task with the +production base prompt from the checked-out source build: ```bash just benchmark \ diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/environment/Dockerfile b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/environment/Dockerfile new file mode 100644 index 0000000000..29f16f3c41 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/environment/Dockerfile @@ -0,0 +1,3 @@ +FROM python:3.12-slim-bookworm + +WORKDIR /app diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/instruction.md b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/instruction.md new file mode 100644 index 0000000000..5704856053 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/instruction.md @@ -0,0 +1,2 @@ +Calculate the annual cost of 12 software licenses priced at $37 per license +per month. Reply with the annual total in one concise sentence. diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/task.toml b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/task.toml new file mode 100644 index 0000000000..c94140e98c --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/task.toml @@ -0,0 +1,19 @@ +schema_version = "1.3" + +[task] +name = "buzz-native/user-mention" +description = "Answer a calculation and mention the three-word user identity." +authors = [{ name = "Buzz" }] +keywords = ["buzz-native", "messaging", "mentions"] + +[agent] +timeout_sec = 300.0 + +[verifier] +timeout_sec = 30.0 + +[environment] +network_mode = "public" +cpus = 1 +memory_mb = 1024 +storage_mb = 1024 diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/tests/test.sh b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/tests/test.sh new file mode 100755 index 0000000000..be6819ba68 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/tests/test.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu + +python3 /tests/verify.py \ + --evidence /logs/artifacts/buzz-evidence.json \ + --reward /logs/verifier/reward.json \ + --details /logs/verifier/details.json diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/tests/verify.py b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/tests/verify.py new file mode 100644 index 0000000000..68ca746f74 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/tests/verify.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Deterministic verifier for a human-facing callback mention.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + +USER_DISPLAY_NAME = "John Vincent Doe" +EXPECTED_TOTAL = 5_328.0 +NUMBER = re.compile(r"(? list[float]: + values: list[float] = [] + for token in NUMBER.findall(content): + try: + values.append(float(token.replace("$", "").replace(",", ""))) + except ValueError: + continue + return values + + +def _has_p_tag(message: dict[str, Any], pubkey: object) -> bool: + return isinstance(pubkey, str) and any( + isinstance(tag, list) and len(tag) >= 2 and tag[0] == "p" and tag[1] == pubkey + for tag in message.get("tags", []) + ) + + +def _zero_metrics() -> dict[str, float]: + return { + "reward": 0.0, + "answer_correct": 0.0, + "user_p_tagged": 0.0, + "three_word_user": 0.0, + "same_channel": 0.0, + "expected_author": 0.0, + "evidence_complete": 0.0, + } + + +def score_evidence(evidence: object) -> tuple[dict[str, float], dict[str, Any]]: + if not isinstance(evidence, dict): + return _zero_metrics(), {"error": "evidence root is not an object"} + + task_event_id = evidence.get("task_event_id") + trial = evidence.get("trial") if isinstance(evidence.get("trial"), dict) else {} + channel_id = trial.get("channel_id") + identities = ( + evidence.get("identities") + if isinstance(evidence.get("identities"), dict) + else {} + ) + agents = [ + row + for row in identities.values() + if isinstance(row, dict) and row.get("role") == "orchestrator" + ] + users = [ + (name, row) + for name, row in identities.items() + if isinstance(row, dict) and row.get("role") == "user" + ] + agent_pubkey = agents[0].get("pubkey") if len(agents) == 1 else None + user_name, user = users[0] if len(users) == 1 else (None, {}) + user_pubkey = user.get("pubkey") + messages = [ + message for message in evidence.get("messages", []) if isinstance(message, dict) + ] + root_indexes = [ + index + for index, message in enumerate(messages) + if message.get("id") == task_event_id + ] + root_index = root_indexes[0] if len(root_indexes) == 1 else -1 + candidates = [ + message + for message in messages[root_index + 1 :] + if agent_pubkey and message.get("pubkey") == agent_pubkey + ] + final = candidates[-1] if candidates else None + content = str(final.get("content", "")) if final is not None else "" + values = _numbers(content) + + three_word_user = float( + user_name == USER_DISPLAY_NAME and len(USER_DISPLAY_NAME.split()) == 3 + ) + evidence_complete = float( + evidence.get("schema_version") == 1 + and evidence.get("task_name") == "user-mention" + and evidence.get("truncated") is False + and isinstance(task_event_id, str) + and len(root_indexes) == 1 + and isinstance(channel_id, str) + and len(agents) == 1 + and len(users) == 1 + and final is not None + ) + expected_author = float(final is not None and final.get("pubkey") == agent_pubkey) + same_channel = float( + final is not None + and final.get("channel_id") == channel_id + and ["h", channel_id] in final.get("tags", []) + ) + user_p_tagged = float( + final is not None + and _has_p_tag(final, user_pubkey) + and user_pubkey in final.get("mentioned_pubkeys", []) + ) + answer_correct = float(any(abs(value - EXPECTED_TOTAL) <= 1.0 for value in values)) + reward = float( + all( + metric == 1.0 + for metric in ( + evidence_complete, + expected_author, + same_channel, + three_word_user, + user_p_tagged, + answer_correct, + ) + ) + ) + metrics = { + "reward": reward, + "answer_correct": answer_correct, + "user_p_tagged": user_p_tagged, + "three_word_user": three_word_user, + "same_channel": same_channel, + "expected_author": expected_author, + "evidence_complete": evidence_complete, + } + details = { + "task_event_id": task_event_id, + "selected_message_id": final.get("id") if final is not None else None, + "selected_message_content": content if final is not None else None, + "user_display_name": user_name, + "user_pubkey": user_pubkey, + "parsed_numbers": values, + "expected_total": EXPECTED_TOTAL, + } + return metrics, details + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--evidence", type=Path, required=True) + parser.add_argument("--reward", type=Path, required=True) + parser.add_argument("--details", type=Path, required=True) + args = parser.parse_args() + + try: + evidence = json.loads(args.evidence.read_text(encoding="utf-8")) + metrics, details = score_evidence(evidence) + except (OSError, json.JSONDecodeError) as error: + metrics, details = _zero_metrics(), {"error": str(error)} + + args.reward.write_text(json.dumps(metrics, sort_keys=True) + "\n", encoding="utf-8") + args.details.write_text( + json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/task_fixtures.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/task_fixtures.py index b1298697dc..a3e221f2b4 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/task_fixtures.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/task_fixtures.py @@ -19,12 +19,15 @@ class BuzzTaskFixture: directory: tuple[DirectoryEntry, ...] = () observe_channel_names: tuple[str, ...] = () + user_display_name: str | None = None CREATE_CHANNEL_TASK = "create-channel-invite-users" CREATE_CHANNEL_NAME = "fix-pr-1234" TARGET_USERS = ("benchmark-user-07", "benchmark-user-19", "benchmark-user-42") TARGET_BOTS = ("benchmark-bot-03", "benchmark-bot-08") +USER_MENTION_TASK = "user-mention" +USER_MENTION_DISPLAY_NAME = "John Vincent Doe" _CREATE_CHANNEL_FIXTURE = BuzzTaskFixture( directory=tuple( @@ -40,9 +43,15 @@ class BuzzTaskFixture: observe_channel_names=(CREATE_CHANNEL_NAME,), ) +_USER_MENTION_FIXTURE = BuzzTaskFixture( + user_display_name=USER_MENTION_DISPLAY_NAME, +) + def fixture_for(task_name: str | None) -> BuzzTaskFixture: """Return the declared setup for a task, or an empty setup.""" if task_name == CREATE_CHANNEL_TASK: return _CREATE_CHANNEL_FIXTURE + if task_name == USER_MENTION_TASK: + return _USER_MENTION_FIXTURE return BuzzTaskFixture() diff --git a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py index d97fb80b91..11d0c88619 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/src/harbor_buzz_testbed/provisioner.py @@ -153,7 +153,7 @@ def _provision( task_name: str | None, ) -> TrialHandle: credentials = self._mint_credentials(manifest) - user = self._mint_user() + user = self._mint_user(task_name) # The user identity creates the channel and invites the agents — # mirroring production Buzz, where a human owns the channel their # agents work in. @@ -214,17 +214,23 @@ def _seed_directory( def _directory_credential(self, name: str, role: str) -> AgentCredential: """Derive one community-stable benchmark identity without storing its key.""" + return self._stable_credential(name, name, role) + + def _stable_credential( + self, identity_id: str, display_name: str, role: str + ) -> AgentCredential: + """Derive an owner-scoped stable identity for reusable task fixtures.""" order = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141 digest = hashlib.sha256( b"buzz-benchmark-directory-v1\0" + bytes.fromhex(self._config.owner_secret_key) + b"\0" - + name.encode() + + identity_id.encode() ).digest() secret = ((int.from_bytes(digest, "big") % (order - 1)) + 1).to_bytes(32, "big") keypair = keypair_from_secret(secret.hex()) return AgentCredential( - agent_id=name, + agent_id=display_name, role=role, nostr_secret_key=keypair.secret_key, nostr_pubkey=keypair.pubkey, @@ -263,13 +269,21 @@ def _mint_credentials( ) return tuple(credentials) - def _mint_user(self) -> AgentCredential: + def _mint_user(self, task_name: str | None = None) -> AgentCredential: """Mint the trial's user identity — the human analogue, not an agent. + A task may declare a dedicated stable identity when its user-facing + profile is part of what the benchmark measures. This avoids profile + races with the pinned GUI user when different tasks run concurrently. With a pinned ``user_secret_key`` the same identity fronts every trial, like one human running many teams; otherwise each trial gets a fresh user key. """ + display_name = fixture_for(task_name).user_display_name + if display_name is not None: + return self._stable_credential( + f"task-user:{task_name}", display_name, "user" + ) keypair = ( keypair_from_secret(self._config.user_secret_key) if self._config.user_secret_key diff --git a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py index 02d7309298..1f55526cf3 100644 --- a/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py +++ b/benchmarks/harbor-buzz-orchestra/testbed/tests/test_provisioner_unit.py @@ -68,6 +68,19 @@ def test_mint_user_is_attested_and_not_an_agent(): assert tag[:3] == ["auth", owner_pubkey.format().hex(), ""] +def test_user_mention_task_gets_stable_three_word_user_identity(): + provisioner = BuzzTrialProvisioner(config(user_secret_key="7" * 64)) + + first = provisioner._mint_user("user-mention") + second = provisioner._mint_user("user-mention") + + assert first.agent_id == "John Vincent Doe" + assert len(first.agent_id.split()) == 3 + assert first.nostr_secret_key == second.nostr_secret_key + assert first.nostr_secret_key != "7" * 64 + assert first.role == "user" + + def test_pinned_user_secret_reuses_one_identity(): pinned = "7" * 64 provisioner = BuzzTrialProvisioner(config(user_secret_key=pinned)) diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_user_mention_verifier.py b/benchmarks/harbor-buzz-orchestra/tests/test_user_mention_verifier.py new file mode 100644 index 0000000000..007a24d113 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/tests/test_user_mention_verifier.py @@ -0,0 +1,96 @@ +import importlib.util +import json +from pathlib import Path + +from harbor_buzz_orchestra.evidence import build_buzz_evidence +from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +FIXTURES = Path(__file__).parent / "fixtures" / "transcripts" +VERIFIER = ( + PACKAGE_ROOT / "datasets" / "buzz-native" / "user-mention" / "tests" / "verify.py" +) +SPEC = importlib.util.spec_from_file_location("user_mention_verifier", VERIFIER) +verifier = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(verifier) + +CORRECT_ANSWER = "The annual cost is $5,328." + + +def _credential(agent_id: str, role: str, pubkey: str) -> AgentCredential: + return AgentCredential( + agent_id=agent_id, + role=role, + nostr_secret_key="secret", + nostr_pubkey=pubkey, + nostr_auth_tag="auth", + llm_endpoint="model" if role != "user" else "", + llm_api_key="key" if role != "user" else "", + ) + + +def _evidence(fixture: str, content: str = CORRECT_ANSWER) -> dict: + transcript = json.loads((FIXTURES / fixture).read_text(encoding="utf-8")) + root, answer = transcript["messages"] + answer["content"] = content + trial = TrialHandle( + run_id="run", + trial_id="trial", + manifest_hash="hash", + relay_ws_url="ws://relay", + channel_id=transcript["channel_id"], + credentials=(_credential("solo-1", "orchestrator", answer["pubkey"]),), + user=_credential("John Vincent Doe", "user", root["pubkey"]), + task_name="user-mention", + ) + return build_buzz_evidence( + trial=trial, + messages=transcript["messages"], + task_event_id=root["id"], + completion_message_id=None, + transcript_limit=1000, + ) + + +def test_correct_answer_with_user_p_tag_passes(): + metrics, details = verifier.score_evidence(_evidence("threaded.json")) + + assert all(value == 1.0 for value in metrics.values()) + assert details["user_display_name"] == "John Vincent Doe" + + +def test_answer_text_without_p_tag_fails_delivery_mention(): + evidence = _evidence("top-level.json", "@John Vincent Doe, the annual cost is $5,328.") + + metrics, _ = verifier.score_evidence(evidence) + + assert metrics["answer_correct"] == 1.0 + assert metrics["user_p_tagged"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_p_tag_without_visible_display_name_passes(): + evidence = _evidence("threaded.json") + + metrics, _ = verifier.score_evidence(evidence) + + assert metrics["user_p_tagged"] == 1.0 + assert metrics["reward"] == 1.0 + + +def test_wrong_answer_fails_correctness_only(): + evidence = _evidence("threaded.json", "The annual cost is $1.") + + metrics, _ = verifier.score_evidence(evidence) + + assert metrics["user_p_tagged"] == 1.0 + assert metrics["answer_correct"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_missing_evidence_fails_closed(): + metrics, details = verifier.score_evidence(None) + + assert all(value == 0.0 for value in metrics.values()) + assert "error" in details From 52250a8dfa130502ac2adc30c28a36f1653468ea Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Tue, 18 Aug 2026 17:32:52 -0400 Subject: [PATCH 3/5] Fix Buzz benchmark Python lint Signed-off-by: Salman Mohammed --- .../buzz-native/create-channel-invite-users/tests/verify.py | 0 .../src/harbor_buzz_orchestra/evidence.py | 3 ++- 2 files changed, 2 insertions(+), 1 deletion(-) mode change 100644 => 100755 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/tests/verify.py diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/tests/verify.py b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/create-channel-invite-users/tests/verify.py old mode 100644 new mode 100755 diff --git a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/evidence.py b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/evidence.py index 10b97f4a38..45eaaa3cfa 100644 --- a/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/evidence.py +++ b/benchmarks/harbor-buzz-orchestra/src/harbor_buzz_orchestra/evidence.py @@ -2,7 +2,8 @@ from __future__ import annotations -from typing import Any, Iterable, Mapping +from collections.abc import Iterable, Mapping +from typing import Any from .provisioning import TrialHandle From ab886dd3e5a0e8095a8db76f09993746bc1b9423 Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Tue, 18 Aug 2026 17:46:43 -0400 Subject: [PATCH 4/5] Make user mention verifier executable Signed-off-by: Salman Mohammed --- .../datasets/buzz-native/user-mention/tests/verify.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/tests/verify.py diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/tests/verify.py b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/user-mention/tests/verify.py old mode 100644 new mode 100755 From f28a340887267ce8e74e8e65c81d2e9cf31dc1de Mon Sep 17 00:00:00 2001 From: Salman Mohammed Date: Tue, 18 Aug 2026 17:58:01 -0400 Subject: [PATCH 5/5] Add named path read benchmark Signed-off-by: Salman Mohammed --- benchmarks/harbor-buzz-orchestra/README.md | 5 +- .../environment/Dockerfile | 7 + .../instruction.md | 3 + .../task.toml | 19 +++ .../tests/test.sh | 8 + .../tests/verify.py | 159 ++++++++++++++++++ ...d_named_path_outside_workspace_verifier.py | 128 ++++++++++++++ 7 files changed, 327 insertions(+), 2 deletions(-) create mode 100644 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/read-named-path-outside-workspace/environment/Dockerfile create mode 100644 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/read-named-path-outside-workspace/instruction.md create mode 100644 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/read-named-path-outside-workspace/task.toml create mode 100755 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/read-named-path-outside-workspace/tests/test.sh create mode 100755 benchmarks/harbor-buzz-orchestra/datasets/buzz-native/read-named-path-outside-workspace/tests/verify.py create mode 100644 benchmarks/harbor-buzz-orchestra/tests/test_read_named_path_outside_workspace_verifier.py diff --git a/benchmarks/harbor-buzz-orchestra/README.md b/benchmarks/harbor-buzz-orchestra/README.md index f0da32dc87..49b867ca3d 100644 --- a/benchmarks/harbor-buzz-orchestra/README.md +++ b/benchmarks/harbor-buzz-orchestra/README.md @@ -66,8 +66,9 @@ artifacts) available for analysis. The local `datasets/buzz-native` suite scores Buzz product behavior alongside task correctness. It currently covers direct thread replies, callback user -mentions, and exact channel creation/membership. Run one task with the -production base prompt from the checked-out source build: +mentions, targeted reads of user-named paths outside the workspace, and exact +channel creation/membership. Run one task with the production base prompt from +the checked-out source build: ```bash just benchmark \ diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/read-named-path-outside-workspace/environment/Dockerfile b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/read-named-path-outside-workspace/environment/Dockerfile new file mode 100644 index 0000000000..3dd566334a --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/read-named-path-outside-workspace/environment/Dockerfile @@ -0,0 +1,7 @@ +FROM python:3.12-slim-bookworm + +ENV HOME=/home/buzz +WORKDIR /app + +RUN mkdir -p /home/buzz/.claude/skills/context-health-check && \ + python -c "import secrets; from pathlib import Path; marker = 'chc-' + secrets.token_hex(12); Path('/home/buzz/.claude/skills/context-health-check/SKILL.md').write_text('# Context Health Check\n\nCHECK_ID: ' + marker + '\nACTION: Recommend compaction when remaining context is low.\n', encoding='utf-8')" diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/read-named-path-outside-workspace/instruction.md b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/read-named-path-outside-workspace/instruction.md new file mode 100644 index 0000000000..4eda1fa2db --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/read-named-path-outside-workspace/instruction.md @@ -0,0 +1,3 @@ +Read the explicitly named file +`~/.claude/skills/context-health-check/SKILL.md`. Report its exact `CHECK_ID` +and `ACTION` values in one concise sentence. Do not search other directories. diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/read-named-path-outside-workspace/task.toml b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/read-named-path-outside-workspace/task.toml new file mode 100644 index 0000000000..5cf7c39b60 --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/read-named-path-outside-workspace/task.toml @@ -0,0 +1,19 @@ +schema_version = "1.3" + +[task] +name = "buzz-native/read-named-path-outside-workspace" +description = "Read an explicitly named file outside the Buzz workspace." +authors = [{ name = "Buzz" }] +keywords = ["buzz-native", "filesystem", "workspace", "named-path"] + +[agent] +timeout_sec = 300.0 + +[verifier] +timeout_sec = 30.0 + +[environment] +network_mode = "public" +cpus = 1 +memory_mb = 1024 +storage_mb = 1024 diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/read-named-path-outside-workspace/tests/test.sh b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/read-named-path-outside-workspace/tests/test.sh new file mode 100755 index 0000000000..fed0b880ca --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/read-named-path-outside-workspace/tests/test.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +python3 /tests/verify.py \ + --evidence /logs/artifacts/buzz-evidence.json \ + --skill-file /home/buzz/.claude/skills/context-health-check/SKILL.md \ + --reward /logs/verifier/reward.json \ + --details /logs/verifier/details.json diff --git a/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/read-named-path-outside-workspace/tests/verify.py b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/read-named-path-outside-workspace/tests/verify.py new file mode 100755 index 0000000000..01c73c841a --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/datasets/buzz-native/read-named-path-outside-workspace/tests/verify.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Verifier for reading a user-named path outside the Buzz workspace.""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path +from typing import Any + +CHECK_ID = re.compile(r"^CHECK_ID:\s*(\S+)\s*$", re.MULTILINE) +ACTION = re.compile(r"^ACTION:\s*(\S.*\S|\S)\s*$", re.MULTILINE) +REFUSAL = re.compile( + r"\b(?:cannot|can't|unable to|won't|will not|refuse to|decline to)\s+" + r"(?:access|read|open|search)\b" + r"|workspace[- ]boundary" + r"|copy\s+.*\s+into\s+.*workspace", + re.IGNORECASE, +) + + +def _zero_metrics() -> dict[str, float]: + return { + "reward": 0.0, + "named_path_read": 0.0, + "action_reported": 0.0, + "refusal_free": 0.0, + "same_channel": 0.0, + "expected_author": 0.0, + "evidence_complete": 0.0, + } + + +def load_expectations(skill_file: Path) -> tuple[str, str]: + content = skill_file.read_text(encoding="utf-8") + check_id = CHECK_ID.search(content) + action = ACTION.search(content) + if check_id is None or action is None: + raise ValueError(f"fixture is missing CHECK_ID or ACTION: {skill_file}") + return check_id.group(1), action.group(1) + + +def score_evidence( + evidence: object, *, expected_check_id: str, expected_action: str +) -> tuple[dict[str, float], dict[str, Any]]: + if not isinstance(evidence, dict): + return _zero_metrics(), {"error": "evidence root is not an object"} + + task_event_id = evidence.get("task_event_id") + trial = evidence.get("trial") if isinstance(evidence.get("trial"), dict) else {} + channel_id = trial.get("channel_id") + identities = ( + evidence.get("identities") + if isinstance(evidence.get("identities"), dict) + else {} + ) + agents = [ + row + for row in identities.values() + if isinstance(row, dict) and row.get("role") == "orchestrator" + ] + agent_pubkey = agents[0].get("pubkey") if len(agents) == 1 else None + messages = [ + message for message in evidence.get("messages", []) if isinstance(message, dict) + ] + root_indexes = [ + index + for index, message in enumerate(messages) + if message.get("id") == task_event_id + ] + root_index = root_indexes[0] if len(root_indexes) == 1 else -1 + candidates = [ + message + for message in messages[root_index + 1 :] + if agent_pubkey and message.get("pubkey") == agent_pubkey + ] + final = candidates[-1] if candidates else None + content = str(final.get("content", "")) if final is not None else "" + + evidence_complete = float( + evidence.get("schema_version") == 1 + and evidence.get("task_name") == "read-named-path-outside-workspace" + and evidence.get("truncated") is False + and isinstance(task_event_id, str) + and len(root_indexes) == 1 + and isinstance(channel_id, str) + and len(agents) == 1 + and final is not None + ) + expected_author = float(final is not None and final.get("pubkey") == agent_pubkey) + same_channel = float( + final is not None + and final.get("channel_id") == channel_id + and ["h", channel_id] in final.get("tags", []) + ) + named_path_read = float(expected_check_id in content) + action_reported = float(expected_action.casefold() in content.casefold()) + refusal_free = float(final is not None and REFUSAL.search(content) is None) + reward = float( + all( + metric == 1.0 + for metric in ( + evidence_complete, + expected_author, + same_channel, + named_path_read, + action_reported, + refusal_free, + ) + ) + ) + metrics = { + "reward": reward, + "named_path_read": named_path_read, + "action_reported": action_reported, + "refusal_free": refusal_free, + "same_channel": same_channel, + "expected_author": expected_author, + "evidence_complete": evidence_complete, + } + details = { + "task_event_id": task_event_id, + "selected_message_id": final.get("id") if final is not None else None, + "selected_message_content": content if final is not None else None, + "expected_check_id": expected_check_id, + "expected_action": expected_action, + } + return metrics, details + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--evidence", type=Path, required=True) + parser.add_argument("--skill-file", type=Path, required=True) + parser.add_argument("--reward", type=Path, required=True) + parser.add_argument("--details", type=Path, required=True) + args = parser.parse_args() + + try: + evidence = json.loads(args.evidence.read_text(encoding="utf-8")) + expected_check_id, expected_action = load_expectations(args.skill_file) + metrics, details = score_evidence( + evidence, + expected_check_id=expected_check_id, + expected_action=expected_action, + ) + except (OSError, ValueError, json.JSONDecodeError) as error: + metrics, details = _zero_metrics(), {"error": str(error)} + + args.reward.write_text(json.dumps(metrics, sort_keys=True) + "\n", encoding="utf-8") + args.details.write_text( + json.dumps(details, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/benchmarks/harbor-buzz-orchestra/tests/test_read_named_path_outside_workspace_verifier.py b/benchmarks/harbor-buzz-orchestra/tests/test_read_named_path_outside_workspace_verifier.py new file mode 100644 index 0000000000..7e37608fea --- /dev/null +++ b/benchmarks/harbor-buzz-orchestra/tests/test_read_named_path_outside_workspace_verifier.py @@ -0,0 +1,128 @@ +import importlib.util +import json +from pathlib import Path + +from harbor_buzz_orchestra.evidence import build_buzz_evidence +from harbor_buzz_orchestra.provisioning import AgentCredential, TrialHandle + +PACKAGE_ROOT = Path(__file__).resolve().parents[1] +FIXTURES = Path(__file__).parent / "fixtures" / "transcripts" +VERIFIER = ( + PACKAGE_ROOT + / "datasets" + / "buzz-native" + / "read-named-path-outside-workspace" + / "tests" + / "verify.py" +) +SPEC = importlib.util.spec_from_file_location("named_path_verifier", VERIFIER) +verifier = importlib.util.module_from_spec(SPEC) +assert SPEC.loader is not None +SPEC.loader.exec_module(verifier) + +CHECK_ID = "chc-unit-test-42" +ACTION = "Recommend compaction when remaining context is low." + + +def _credential(agent_id: str, role: str, pubkey: str) -> AgentCredential: + return AgentCredential( + agent_id=agent_id, + role=role, + nostr_secret_key="secret", + nostr_pubkey=pubkey, + nostr_auth_tag="auth", + llm_endpoint="model" if role != "user" else "", + llm_api_key="key" if role != "user" else "", + ) + + +def _evidence(content: str) -> dict: + transcript = json.loads((FIXTURES / "top-level.json").read_text(encoding="utf-8")) + root, answer = transcript["messages"] + answer["content"] = content + trial = TrialHandle( + run_id="run", + trial_id="trial", + manifest_hash="hash", + relay_ws_url="ws://relay", + channel_id=transcript["channel_id"], + credentials=(_credential("solo-1", "orchestrator", answer["pubkey"]),), + user=_credential("user", "user", root["pubkey"]), + task_name="read-named-path-outside-workspace", + ) + return build_buzz_evidence( + trial=trial, + messages=transcript["messages"], + task_event_id=root["id"], + completion_message_id=None, + transcript_limit=1000, + ) + + +def _score(content: str): + return verifier.score_evidence( + _evidence(content), expected_check_id=CHECK_ID, expected_action=ACTION + ) + + +def test_exact_marker_and_action_pass(): + metrics, details = _score(f"{CHECK_ID}: {ACTION}") + + assert all(value == 1.0 for value in metrics.values()) + assert details["expected_check_id"] == CHECK_ID + + +def test_refusal_scores_zero_even_if_expected_values_are_repeated(): + metrics, _ = _score(f"I won't read that path. {CHECK_ID}: {ACTION}") + + assert metrics["named_path_read"] == 1.0 + assert metrics["action_reported"] == 1.0 + assert metrics["refusal_free"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_missing_marker_fails_named_path_read(): + metrics, _ = _score(ACTION) + + assert metrics["named_path_read"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_wrong_action_fails_action_report(): + metrics, _ = _score(f"{CHECK_ID}: Ignore context health.") + + assert metrics["named_path_read"] == 1.0 + assert metrics["action_reported"] == 0.0 + assert metrics["reward"] == 0.0 + + +def test_load_expectations_reads_fixture_fields(tmp_path): + skill_file = tmp_path / "SKILL.md" + skill_file.write_text( + f"# Fixture\nCHECK_ID: {CHECK_ID}\nACTION: {ACTION}\n", encoding="utf-8" + ) + + assert verifier.load_expectations(skill_file) == (CHECK_ID, ACTION) + + +def test_missing_evidence_fails_closed(): + metrics, details = verifier.score_evidence( + None, expected_check_id=CHECK_ID, expected_action=ACTION + ) + + assert all(value == 0.0 for value in metrics.values()) + assert "error" in details + + +def test_missing_final_message_is_not_refusal_free(): + evidence = _evidence(f"{CHECK_ID}: {ACTION}") + evidence["messages"].pop() + evidence["message_count"] -= 1 + + metrics, _ = verifier.score_evidence( + evidence, expected_check_id=CHECK_ID, expected_action=ACTION + ) + + assert metrics["evidence_complete"] == 0.0 + assert metrics["refusal_free"] == 0.0 + assert metrics["reward"] == 0.0