@tangle-network/agent-bench 0.1.0 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/HARNESS.md +302 -0
- package/README.md +26 -1
- package/fixtures/aec-bench.json +18 -0
- package/fixtures/agentbench-dbbench.json +22 -0
- package/fixtures/bfcl.json +45 -0
- package/fixtures/commit0.json +72 -0
- package/fixtures/crag.json +10 -0
- package/fixtures/dabstep.json +22 -0
- package/fixtures/enterpriseops-gym.json +103 -0
- package/fixtures/finresearchbench.json +21 -0
- package/fixtures/finsearchcomp.json +66 -0
- package/fixtures/frames.json +26 -0
- package/fixtures/hotpotqa.json +182 -0
- package/fixtures/nomiracl.json +26 -0
- package/fixtures/open-rag-bench.json +16 -0
- package/fixtures/pier-agent/no-model-task/environment/Dockerfile +16 -0
- package/fixtures/pier-agent/no-model-task/environment/seed/src/status.txt +1 -0
- package/fixtures/pier-agent/no-model-task/instruction.md +6 -0
- package/fixtures/pier-agent/no-model-task/pre_artifacts.sh +6 -0
- package/fixtures/pier-agent/no-model-task/task.toml +35 -0
- package/fixtures/pier-agent/no-model-task/tests/Dockerfile +17 -0
- package/fixtures/pier-agent/no-model-task/tests/seed/src/status.txt +1 -0
- package/fixtures/pier-agent/no-model-task/tests/test.sh +19 -0
- package/fixtures/programbench.json +17 -0
- package/fixtures/ragbench.json +21 -0
- package/fixtures/simpleqa.json +121 -0
- package/fixtures/t2-ragbench.json +13 -0
- package/fixtures/tau2-bench.json +16 -0
- package/fixtures/tau3-banking.json +16 -0
- package/fixtures/toollm.json +28 -0
- package/fixtures/webarena-verified.json +20 -0
- package/package.json +39 -15
- package/pier_agents/__init__.py +18 -0
- package/pier_agents/candidate_contract.py +755 -0
- package/pier_agents/process_boundary.py +321 -0
- package/pier_agents/tangle_candidate.py +907 -0
- package/pier_agents/workspace_boundary.py +368 -0
- package/scripts/appworld_driver.py +359 -0
- package/scripts/cadbench_prepare.py +22 -0
- package/scripts/cadgenbench_hard_parts.py +48 -0
- package/scripts/clbench_codebase_judge.py +73 -0
- package/scripts/commit0_judge.py +170 -0
- package/scripts/dabstep_judge.py +42 -0
- package/scripts/enterpriseops_gym_judge.py +281 -0
- package/scripts/programbench_judge.py +120 -0
- package/scripts/render-gate-chart.mjs +176 -0
- package/scripts/run-package-tests.mjs +56 -0
- package/scripts/terminate-pier-trial.mts +66 -0
- package/scripts/trata-hedge/README.md +56 -0
- package/scripts/trata-hedge/run.sh +60 -0
- package/scripts/trata-hedge/solve.py +83 -0
- package/scripts/verify-packed-consumer.mjs +224 -0
- package/scripts/verify-pier-agent.mts +715 -0
- package/scripts/verify-pier-pair.mts +74 -0
- package/scripts/verify-pier-recovery.mts +139 -0
- package/src/adapters.ts +26 -0
- package/src/benchmarks/_harness.test.mts +178 -0
- package/src/benchmarks/_harness.ts +239 -16
- package/src/benchmarks/agentbench.ts +163 -0
- package/src/benchmarks/appworld.test.mts +15 -9
- package/src/benchmarks/bfcl.ts +346 -0
- package/src/benchmarks/crag.ts +137 -0
- package/src/benchmarks/dabstep.test.mts +70 -0
- package/src/benchmarks/dabstep.ts +212 -0
- package/src/benchmarks/external-adapters.test.mts +150 -0
- package/src/benchmarks/finresearchbench.ts +269 -0
- package/src/benchmarks/humaneval.ts +20 -8
- package/src/benchmarks/nomiracl.ts +180 -0
- package/src/benchmarks/open-rag-bench.ts +153 -0
- package/src/benchmarks/rag-benchmarks.test.mts +138 -0
- package/src/benchmarks/rag-shared.ts +327 -0
- package/src/benchmarks/ragbench.ts +171 -0
- package/src/benchmarks/swe-bench.test.mts +61 -0
- package/src/benchmarks/swe-bench.ts +201 -19
- package/src/benchmarks/t2-ragbench.ts +166 -0
- package/src/benchmarks/tau-bench-shared.ts +214 -0
- package/src/benchmarks/tau2-bench.ts +30 -0
- package/src/benchmarks/tau3-banking.ts +29 -0
- package/src/benchmarks/terminal-bench.test.mts +33 -0
- package/src/benchmarks/terminal-bench.ts +23 -8
- package/src/benchmarks/toollm.ts +254 -0
- package/src/benchmarks/types.ts +42 -0
- package/src/benchmarks/webarena-verified.ts +200 -0
- package/src/commit0-prereqs.sh +0 -0
- package/src/coordination-mcp-container-reach.mts +181 -0
- package/src/decoder-live.mts +1 -1
- package/src/examples/README.md +103 -39
- package/src/examples/benchmark-matrix.mts +101 -0
- package/src/examples/lean-proof-gate.README.md +77 -0
- package/src/examples/lean-proof-gate.mts +162 -0
- package/src/examples/lean-verify.ts +95 -0
- package/src/examples/lean.Dockerfile +12 -0
- package/src/examples/math-demo.mts +9 -7
- package/src/examples/strategy-demo.mts +10 -12
- package/src/gate.ts +3 -2
- package/src/hev-eval.mts +69 -0
- package/src/hev-improve.mts +169 -0
- package/src/hev-structural.mts +688 -0
- package/src/index.ts +73 -0
- package/src/mbpp-structural.mts +662 -0
- package/src/pier-agent.test-fixtures.mts +19 -0
- package/src/pier-agent.test.mts +363 -0
- package/src/pier-agent.ts +657 -0
- package/src/pier-result-grader.mjs +30 -0
- package/src/pier-result-grader.test.mts +62 -0
- package/src/pier-result-grader.ts +108 -0
- package/src/pier-task-outcome.test.mts +117 -0
- package/src/pier-task-outcome.ts +240 -0
- package/src/pier-trial-controller.test.mts +412 -0
- package/src/pier-trial-controller.ts +858 -0
- package/src/pier-trial-supervisor.mjs +352 -0
- package/src/resolve-client.ts +25 -2
- package/src/run-benchmarks-cli.mts +72 -0
- package/src/run-benchmarks-report.ts +66 -0
- package/src/run-benchmarks.test.mts +231 -0
- package/src/run-benchmarks.ts +589 -0
- package/src/smoke-structural-rollout.mts +393 -0
- package/src/swe-bench-env.test.ts +207 -0
- package/src/swe-bench-env.ts +554 -0
- package/src/swe-jail.ts +293 -0
- package/src/swe-self-improve.mts +84 -0
- package/src/swe-structural-judge-policy.test.ts +117 -0
- package/src/swe-structural-judge-policy.ts +133 -0
- package/src/swe-structural-policy.test.ts +124 -0
- package/src/swe-structural-policy.ts +132 -0
- package/src/swe-structural-provenance.test.ts +93 -0
- package/src/swe-structural-provenance.ts +138 -0
- package/src/swe-structural.mts +1260 -0
- package/src/swe-temp.ts +14 -0
- package/src/tb-container-executor.mts +234 -0
- package/src/tb-container-executor.test.mts +99 -0
- package/src/tb-supervisor-sidecar.mts +222 -0
- package/src/trata-gepa.mts +1 -1
- package/steerers/eops-itsm-population.json +1 -0
- package/tb_agents/opencode_refine_agent.py +117 -0
- package/tb_agents/opencode_router_agent.py +406 -0
- package/tb_agents/opencode_supervisor_agent.py +239 -0
- package/tb_agents/script_agent.py +66 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Refine-aware Terminal-Bench agent.
|
|
2
|
+
|
|
3
|
+
Subclasses Terminal-Bench's native `OpenCodeAgent` so the agent, the in-container
|
|
4
|
+
install, the router auth, and the task's own verifier are all the published
|
|
5
|
+
open-source path — unchanged. The ONLY added behavior is an evidence-gated refine
|
|
6
|
+
directive prepended to the task instruction when a prior attempt is present.
|
|
7
|
+
|
|
8
|
+
Round 1 of the runner passes no prior attempt (or an empty one), so this agent is
|
|
9
|
+
byte-for-byte the plain opencode agent and round 1 == a real blind tb run. On a
|
|
10
|
+
refine round the runner passes the prior attempt (a summary of the failed round +
|
|
11
|
+
the failing tests); this class injects the directive and otherwise runs opencode
|
|
12
|
+
exactly as the base agent does (same `_run_agent_commands`, same env).
|
|
13
|
+
|
|
14
|
+
The prior attempt is passed as `prior_attempt_hex` — a hex-encoded UTF-8 string
|
|
15
|
+
with a leading 'h' sentinel. tb's CLI parses `--agent-kwarg key=value` with a
|
|
16
|
+
naive `key, value = kwarg.split("=")` and then runs `ast.literal_eval(value)`, so
|
|
17
|
+
the value must contain no '=' and must not look like a Python literal. Hex with an
|
|
18
|
+
'h' prefix satisfies both: no '=', and `literal_eval('h46...')` raises, so tb keeps
|
|
19
|
+
it as a string. The agent strips 'h' and decodes back to the original text.
|
|
20
|
+
|
|
21
|
+
Usage:
|
|
22
|
+
tb run -d terminal-bench-core==0.1.1 \
|
|
23
|
+
--agent-import-path tb_agents.opencode_refine_agent:OpenCodeRefineAgent \
|
|
24
|
+
-m deepseek/deepseek-v4-pro --task-id <id> \
|
|
25
|
+
--agent-kwarg prior_attempt_hex=h<hex-of-utf8-text>
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
import inspect
|
|
29
|
+
import os
|
|
30
|
+
import tempfile
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from terminal_bench.agents.base_agent import AgentResult
|
|
34
|
+
from terminal_bench.agents.installed_agents.opencode.opencode_agent import (
|
|
35
|
+
OpenCodeAgent,
|
|
36
|
+
)
|
|
37
|
+
from terminal_bench.terminal.tmux_session import TmuxSession
|
|
38
|
+
from terminal_bench.utils.template_utils import render_setup_script
|
|
39
|
+
|
|
40
|
+
REFINE_DIRECTIVE = (
|
|
41
|
+
"REFINE PASS — your PREVIOUS attempt at this exact task FAILED the task's "
|
|
42
|
+
"automated tests. Evidence from that attempt is below.\n\n"
|
|
43
|
+
"<previous_attempt>\n{prior}\n</previous_attempt>\n\n"
|
|
44
|
+
"Re-examine the task from scratch in this fresh container. Keep whatever the "
|
|
45
|
+
"previous attempt got right, and fix the CONCRETE failure shown in the "
|
|
46
|
+
"evidence — do not repeat the same mistake. Do NOT rewrite or discard a part "
|
|
47
|
+
"that already worked. When done, verify your work against the task's stated "
|
|
48
|
+
"success criteria before finishing.\n\n"
|
|
49
|
+
"--- ORIGINAL TASK ---\n{instruction}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OpenCodeRefineAgent(OpenCodeAgent):
|
|
54
|
+
"""OpenCodeAgent + an evidence-gated refine-instruction injection.
|
|
55
|
+
|
|
56
|
+
`prior_attempt_hex` is decoded + consumed here and never forwarded to the base
|
|
57
|
+
agent, which only knows `model_name` / `version`. An empty/absent value degrades
|
|
58
|
+
the agent exactly to the plain opencode agent.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
model_name: str,
|
|
64
|
+
prior_attempt_hex: str | None = None,
|
|
65
|
+
**kwargs,
|
|
66
|
+
):
|
|
67
|
+
super().__init__(model_name, **kwargs)
|
|
68
|
+
self._prior_attempt = self._decode_prior(prior_attempt_hex)
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def _decode_prior(prior_attempt_hex: str | None) -> str:
|
|
72
|
+
if not prior_attempt_hex:
|
|
73
|
+
return ""
|
|
74
|
+
raw = str(prior_attempt_hex).strip()
|
|
75
|
+
if raw.startswith("h"):
|
|
76
|
+
raw = raw[1:]
|
|
77
|
+
if not raw:
|
|
78
|
+
return ""
|
|
79
|
+
try:
|
|
80
|
+
return bytes.fromhex(raw).decode("utf-8").strip()
|
|
81
|
+
except ValueError as e:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
f"prior_attempt_hex must be 'h' + hex-encoded UTF-8; got {raw[:40]!r}"
|
|
84
|
+
) from e
|
|
85
|
+
|
|
86
|
+
@staticmethod
|
|
87
|
+
def name() -> str:
|
|
88
|
+
return "agent-runtime-opencode-refine"
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def _install_agent_script_path(self) -> os.PathLike:
|
|
92
|
+
# The base method resolves the setup template via inspect.getfile(self.__class__),
|
|
93
|
+
# which points at THIS subclass's dir (tb_agents/) where the template does not
|
|
94
|
+
# live. Render opencode-setup.sh.j2 from the base OpenCodeAgent's own directory.
|
|
95
|
+
template_path = (
|
|
96
|
+
Path(inspect.getfile(OpenCodeAgent)).parent / "opencode-setup.sh.j2"
|
|
97
|
+
)
|
|
98
|
+
script_content = render_setup_script(
|
|
99
|
+
template_path, self._get_template_variables()
|
|
100
|
+
)
|
|
101
|
+
temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False)
|
|
102
|
+
temp_file.write(script_content)
|
|
103
|
+
temp_file.close()
|
|
104
|
+
os.chmod(temp_file.name, 0o755)
|
|
105
|
+
return Path(temp_file.name)
|
|
106
|
+
|
|
107
|
+
def perform_task(
|
|
108
|
+
self,
|
|
109
|
+
instruction: str,
|
|
110
|
+
session: TmuxSession,
|
|
111
|
+
logging_dir: Path | None = None,
|
|
112
|
+
) -> AgentResult:
|
|
113
|
+
if self._prior_attempt:
|
|
114
|
+
instruction = REFINE_DIRECTIVE.format(
|
|
115
|
+
prior=self._prior_attempt, instruction=instruction
|
|
116
|
+
)
|
|
117
|
+
return super().perform_task(instruction, session, logging_dir)
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""Router-aware, metered Terminal-Bench agent for the supervisor-vs-raw A/B.
|
|
2
|
+
|
|
3
|
+
Both arms of the harness-at-fixed-model A/B run opencode inside the Terminal-Bench
|
|
4
|
+
task container. The stock `OpenCodeAgent` cannot serve that A/B for two independent
|
|
5
|
+
reasons, both fixed here:
|
|
6
|
+
|
|
7
|
+
1. Routing. The target model id is provider-qualified — `zai-coding-plan/glm-5.2`
|
|
8
|
+
(and peers). opencode's built-in `zai-coding-plan` / `deepseek` / ... providers
|
|
9
|
+
point at each vendor's own API, NOT the Tangle router. The stock agent's `_env`
|
|
10
|
+
whitelist also raises `ValueError("Unknown provider zai-coding-plan")` for any
|
|
11
|
+
provider it doesn't hard-code, and for the `openai` provider it forwards only
|
|
12
|
+
`OPENAI_API_KEY`, never `OPENAI_BASE_URL`, so the router base url never reaches
|
|
13
|
+
the container. This agent injects an opencode.json (via `OPENCODE_CONFIG`) that
|
|
14
|
+
redefines the model's provider as an `@ai-sdk/openai-compatible` provider whose
|
|
15
|
+
`baseURL` is the Tangle router and whose `apiKey` is the router key — so
|
|
16
|
+
`opencode --model <provider>/<model> run ...` routes through the router for ANY
|
|
17
|
+
provider, with zero per-provider special-casing.
|
|
18
|
+
|
|
19
|
+
2. Metering. The stock agent runs opencode via a tmux `send_keys` command and
|
|
20
|
+
returns `AgentResult(total_input_tokens=0, total_output_tokens=0)` — the harness
|
|
21
|
+
then records zeros. This agent runs `opencode ... --format json`, redirects the
|
|
22
|
+
event stream to a file in the container, reads it back over the docker exec
|
|
23
|
+
channel, sums the per-message token usage, and returns a REAL
|
|
24
|
+
`AgentResult(total_input_tokens=N, total_output_tokens=M)`.
|
|
25
|
+
|
|
26
|
+
Everything else — the in-container npm install of opencode, the task's own published
|
|
27
|
+
verifier — is the unchanged open-source path.
|
|
28
|
+
|
|
29
|
+
The container id the tb verifier grades is published to a host file (path in
|
|
30
|
+
`TB_CONTAINER_ID_FILE`, default `<cwd>/.tb-container-id`) so a downstream worker
|
|
31
|
+
executor (arm B) can `docker exec` into the SAME container. This is best-effort and
|
|
32
|
+
never fails the task.
|
|
33
|
+
|
|
34
|
+
Usage:
|
|
35
|
+
tb run -d terminal-bench-core==0.1.1 -t crack-7z-hash.easy \
|
|
36
|
+
--agent-import-path tb_agents.opencode_router_agent:OpenCodeRouterAgent \
|
|
37
|
+
-m zai-coding-plan/glm-5.2 --output-path <dir> --run-id <id>
|
|
38
|
+
|
|
39
|
+
Env (forwarded from the tb-launching process, e.g. via dotenvx):
|
|
40
|
+
OPENAI_API_KEY router key (required)
|
|
41
|
+
OPENAI_BASE_URL router base url (optional; defaults to the Tangle router /v1)
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
import inspect
|
|
45
|
+
import json
|
|
46
|
+
import os
|
|
47
|
+
import shlex
|
|
48
|
+
import tempfile
|
|
49
|
+
from pathlib import Path
|
|
50
|
+
|
|
51
|
+
from terminal_bench.agents.base_agent import AgentResult
|
|
52
|
+
from terminal_bench.agents.failure_mode import FailureMode
|
|
53
|
+
from terminal_bench.agents.installed_agents.opencode.opencode_agent import (
|
|
54
|
+
OpenCodeAgent,
|
|
55
|
+
)
|
|
56
|
+
from terminal_bench.terminal.tmux_session import TmuxSession
|
|
57
|
+
from terminal_bench.utils.logger import logger
|
|
58
|
+
from terminal_bench.utils.template_utils import render_setup_script
|
|
59
|
+
|
|
60
|
+
DEFAULT_ROUTER_BASE_URL = "https://router.tangle.tools/v1"
|
|
61
|
+
|
|
62
|
+
# All paths live under the container dir the base install flow already creates.
|
|
63
|
+
_CONTAINER_DIR = "/installed-agent"
|
|
64
|
+
_CONFIG_PATH = f"{_CONTAINER_DIR}/opencode-router.json"
|
|
65
|
+
_USAGE_OUT_PATH = f"{_CONTAINER_DIR}/opencode-events.jsonl"
|
|
66
|
+
_USAGE_ERR_PATH = f"{_CONTAINER_DIR}/opencode-run.err"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class OpenCodeRouterAgent(OpenCodeAgent):
|
|
70
|
+
"""`OpenCodeAgent` + Tangle-router routing + real token metering.
|
|
71
|
+
|
|
72
|
+
Subclasses the published opencode agent so the install script, the tmux run
|
|
73
|
+
machinery, and the task verifier are all the stock open-source path. The added
|
|
74
|
+
behavior is confined to: (a) an injected provider config that points the model's
|
|
75
|
+
provider at the router, (b) forwarding the router key + base url into the
|
|
76
|
+
container env, and (c) capturing + parsing opencode's `--format json` usage into
|
|
77
|
+
a real `AgentResult`.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, model_name: str, *args, **kwargs):
|
|
81
|
+
# The stock __init__ does `self._provider, _ = model_name.split("/")`, which
|
|
82
|
+
# raises on a 3-segment or bare id. Derive the provider robustly ourselves,
|
|
83
|
+
# then hand the base class a value it can split without raising.
|
|
84
|
+
self._model_name = model_name
|
|
85
|
+
provider, _, sub = model_name.partition("/")
|
|
86
|
+
self._provider = provider
|
|
87
|
+
# Model id opencode sees, minus the provider segment (e.g. "glm-5.2").
|
|
88
|
+
self._provider_model = sub or provider
|
|
89
|
+
self._router_base_url = (
|
|
90
|
+
os.environ.get("OPENAI_BASE_URL")
|
|
91
|
+
or os.environ.get("ROUTER_BASE_URL")
|
|
92
|
+
or DEFAULT_ROUTER_BASE_URL
|
|
93
|
+
)
|
|
94
|
+
self._router_api_key = os.environ.get("OPENAI_API_KEY", "")
|
|
95
|
+
# Call the grandparent (AbstractInstalledAgent) init via OpenCodeAgent, but
|
|
96
|
+
# avoid the base split-crash by not delegating provider derivation to it.
|
|
97
|
+
super(OpenCodeAgent, self).__init__(*args, **kwargs)
|
|
98
|
+
self._version = kwargs.get("version", "latest")
|
|
99
|
+
self._logger = logger.getChild(__name__)
|
|
100
|
+
|
|
101
|
+
@staticmethod
|
|
102
|
+
def name() -> str:
|
|
103
|
+
return "agent-runtime-opencode-router"
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def _install_agent_script_path(self) -> os.PathLike:
|
|
107
|
+
"""Render opencode's install template.
|
|
108
|
+
|
|
109
|
+
The base `_get_templated_script_path` resolves the template relative to
|
|
110
|
+
`inspect.getfile(self.__class__)` — i.e. next to THIS subclass file, where
|
|
111
|
+
`opencode-setup.sh.j2` does not exist. Resolve it from the stock
|
|
112
|
+
`OpenCodeAgent`'s own directory instead (same fix as OpenCodeRefineAgent).
|
|
113
|
+
"""
|
|
114
|
+
template_path = (
|
|
115
|
+
Path(inspect.getfile(OpenCodeAgent)).parent / "opencode-setup.sh.j2"
|
|
116
|
+
)
|
|
117
|
+
script_content = render_setup_script(
|
|
118
|
+
template_path, self._get_template_variables()
|
|
119
|
+
)
|
|
120
|
+
temp_file = tempfile.NamedTemporaryFile(mode="w", suffix=".sh", delete=False)
|
|
121
|
+
temp_file.write(script_content)
|
|
122
|
+
temp_file.close()
|
|
123
|
+
os.chmod(temp_file.name, 0o755)
|
|
124
|
+
return Path(temp_file.name)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def _env(self) -> dict[str, str]:
|
|
128
|
+
"""Forward the router credential + base url + config path into the container.
|
|
129
|
+
|
|
130
|
+
Overrides the stock provider whitelist entirely: routing is done by the
|
|
131
|
+
injected provider config (openai-compatible -> router), so the container only
|
|
132
|
+
needs the router key, the router base url, and the OPENCODE_CONFIG pointer.
|
|
133
|
+
"""
|
|
134
|
+
env: dict[str, str] = {
|
|
135
|
+
"OPENAI_API_KEY": self._router_api_key,
|
|
136
|
+
"OPENAI_BASE_URL": self._router_base_url,
|
|
137
|
+
"OPENCODE_CONFIG": _CONFIG_PATH,
|
|
138
|
+
}
|
|
139
|
+
# Pass through any provider-native key the user already exported, so the same
|
|
140
|
+
# agent also works when pointed at a vendor endpoint rather than the router.
|
|
141
|
+
for passthrough in (
|
|
142
|
+
"ANTHROPIC_API_KEY",
|
|
143
|
+
"DEEPSEEK_API_KEY",
|
|
144
|
+
"GROQ_API_KEY",
|
|
145
|
+
"ZAI_API_KEY",
|
|
146
|
+
):
|
|
147
|
+
if passthrough in os.environ:
|
|
148
|
+
env.setdefault(passthrough, os.environ[passthrough])
|
|
149
|
+
return {k: v for k, v in env.items() if v}
|
|
150
|
+
|
|
151
|
+
def _build_router_config(self) -> str:
|
|
152
|
+
"""opencode.json that redefines the model's provider to hit the router.
|
|
153
|
+
|
|
154
|
+
`@ai-sdk/openai-compatible` makes opencode treat `<provider>/<model>` as a
|
|
155
|
+
plain OpenAI-compatible chat call to `options.baseURL` with `options.apiKey`.
|
|
156
|
+
The router then dispatches the provider-qualified model upstream.
|
|
157
|
+
"""
|
|
158
|
+
config = {
|
|
159
|
+
"$schema": "https://opencode.ai/config.json",
|
|
160
|
+
# Headless benchmark runs cannot answer interactive permission prompts.
|
|
161
|
+
# Keep this identical for raw and supervisor arms.
|
|
162
|
+
"permission": {
|
|
163
|
+
"edit": "allow",
|
|
164
|
+
"bash": "allow",
|
|
165
|
+
"webfetch": "allow",
|
|
166
|
+
"read": "allow",
|
|
167
|
+
"write": "allow",
|
|
168
|
+
"external_directory": "allow",
|
|
169
|
+
},
|
|
170
|
+
"provider": {
|
|
171
|
+
self._provider: {
|
|
172
|
+
"npm": "@ai-sdk/openai-compatible",
|
|
173
|
+
"name": f"Tangle Router ({self._provider})",
|
|
174
|
+
"options": {
|
|
175
|
+
"baseURL": self._router_base_url,
|
|
176
|
+
"apiKey": self._router_api_key,
|
|
177
|
+
},
|
|
178
|
+
"models": {
|
|
179
|
+
self._provider_model: {"name": self._provider_model},
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
return json.dumps(config)
|
|
185
|
+
|
|
186
|
+
def _run_agent_commands(self, instruction: str):
|
|
187
|
+
"""Same command as the stock agent, but `--format json` with the event
|
|
188
|
+
stream captured to a file so we can meter token usage after the run."""
|
|
189
|
+
from terminal_bench.terminal.models import TerminalCommand
|
|
190
|
+
|
|
191
|
+
escaped_instruction = shlex.quote(instruction)
|
|
192
|
+
command = (
|
|
193
|
+
f"opencode --model {self._model_name} --format json "
|
|
194
|
+
f"run {escaped_instruction} "
|
|
195
|
+
f"> {_USAGE_OUT_PATH} 2> {_USAGE_ERR_PATH}"
|
|
196
|
+
)
|
|
197
|
+
return [
|
|
198
|
+
TerminalCommand(
|
|
199
|
+
command=command,
|
|
200
|
+
min_timeout_sec=0.0,
|
|
201
|
+
max_timeout_sec=float("inf"),
|
|
202
|
+
block=True,
|
|
203
|
+
append_enter=True,
|
|
204
|
+
)
|
|
205
|
+
]
|
|
206
|
+
|
|
207
|
+
def _write_config_to_container(self, session: TmuxSession) -> None:
|
|
208
|
+
config_json = self._build_router_config()
|
|
209
|
+
session.container.exec_run(
|
|
210
|
+
[
|
|
211
|
+
"sh",
|
|
212
|
+
"-c",
|
|
213
|
+
(
|
|
214
|
+
f"mkdir -p {_CONTAINER_DIR} && "
|
|
215
|
+
f"printf '%s' {shlex.quote(config_json)} > {_CONFIG_PATH}"
|
|
216
|
+
),
|
|
217
|
+
]
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def _publish_container_id(self, session: TmuxSession) -> None:
|
|
221
|
+
"""Best-effort: write the graded container's id to a host file so a worker
|
|
222
|
+
executor (arm B) can docker-exec into the SAME container. Never raises."""
|
|
223
|
+
try:
|
|
224
|
+
container_id = getattr(session.container, "id", None) or getattr(
|
|
225
|
+
session.container, "short_id", None
|
|
226
|
+
)
|
|
227
|
+
if not container_id:
|
|
228
|
+
return
|
|
229
|
+
out_file = os.environ.get(
|
|
230
|
+
"TB_CONTAINER_ID_FILE",
|
|
231
|
+
str(Path.cwd() / ".tb-container-id"),
|
|
232
|
+
)
|
|
233
|
+
Path(out_file).write_text(str(container_id))
|
|
234
|
+
self._logger.info(
|
|
235
|
+
"Published tb container id %s -> %s", container_id, out_file
|
|
236
|
+
)
|
|
237
|
+
except Exception as e: # noqa: BLE001 - metadata publish is never fatal
|
|
238
|
+
self._logger.warning("Could not publish tb container id: %s", e)
|
|
239
|
+
|
|
240
|
+
def _read_container_file(self, session: TmuxSession, path: str) -> str:
|
|
241
|
+
result = session.container.exec_run(["cat", path])
|
|
242
|
+
output = getattr(result, "output", result)
|
|
243
|
+
if isinstance(output, tuple): # demuxed (stdout, stderr)
|
|
244
|
+
output = output[0] or b""
|
|
245
|
+
if isinstance(output, bytes):
|
|
246
|
+
return output.decode("utf-8", errors="replace")
|
|
247
|
+
return str(output or "")
|
|
248
|
+
|
|
249
|
+
def _parse_usage(self, raw: str) -> tuple[int, int]:
|
|
250
|
+
"""Sum per-message input/output token usage from opencode's json events.
|
|
251
|
+
|
|
252
|
+
opencode `run --format json` emits a stream of JSON events. Token usage lands
|
|
253
|
+
under a few shapes across versions:
|
|
254
|
+
- ev["usage"] = {input_tokens, output_tokens} | {prompt/completion}
|
|
255
|
+
- ev["tokens"] = {input, output} | {input_tokens, output_tokens}
|
|
256
|
+
- ev["part"]["tokens"] / ev["info"]["tokens"] / ev["message"]["tokens"]
|
|
257
|
+
|
|
258
|
+
We take the token dict per *assistant message* and sum across messages, keyed
|
|
259
|
+
by message id so a message emitted multiple times (part.updated then
|
|
260
|
+
updated) is counted once (last value wins). A final cumulative `usage` event,
|
|
261
|
+
if present, is preferred over the summed per-message figures.
|
|
262
|
+
"""
|
|
263
|
+
per_message: dict[str, tuple[int, int]] = {}
|
|
264
|
+
final_usage: tuple[int, int] | None = None
|
|
265
|
+
anonymous_total = [0, 0]
|
|
266
|
+
anon_count = 0
|
|
267
|
+
|
|
268
|
+
for line in raw.splitlines():
|
|
269
|
+
line = line.strip()
|
|
270
|
+
if not line or not (line.startswith("{") or line.startswith("[")):
|
|
271
|
+
continue
|
|
272
|
+
try:
|
|
273
|
+
ev = json.loads(line)
|
|
274
|
+
except (json.JSONDecodeError, ValueError):
|
|
275
|
+
continue
|
|
276
|
+
events = ev if isinstance(ev, list) else [ev]
|
|
277
|
+
for e in events:
|
|
278
|
+
if not isinstance(e, dict):
|
|
279
|
+
continue
|
|
280
|
+
tokens = self._extract_tokens(e)
|
|
281
|
+
if tokens is None:
|
|
282
|
+
continue
|
|
283
|
+
inp, out = tokens
|
|
284
|
+
msg_id = self._message_id(e)
|
|
285
|
+
etype = str(e.get("type", ""))
|
|
286
|
+
if etype in (
|
|
287
|
+
"session.completed",
|
|
288
|
+
"run.completed",
|
|
289
|
+
"turn.completed",
|
|
290
|
+
) and (inp or out):
|
|
291
|
+
final_usage = (inp, out)
|
|
292
|
+
elif msg_id:
|
|
293
|
+
per_message[msg_id] = (inp, out)
|
|
294
|
+
elif inp or out:
|
|
295
|
+
anonymous_total[0] += inp
|
|
296
|
+
anonymous_total[1] += out
|
|
297
|
+
anon_count += 1
|
|
298
|
+
|
|
299
|
+
if per_message:
|
|
300
|
+
total_in = sum(v[0] for v in per_message.values())
|
|
301
|
+
total_out = sum(v[1] for v in per_message.values())
|
|
302
|
+
return total_in, total_out
|
|
303
|
+
if final_usage is not None:
|
|
304
|
+
return final_usage
|
|
305
|
+
if anon_count:
|
|
306
|
+
return anonymous_total[0], anonymous_total[1]
|
|
307
|
+
return 0, 0
|
|
308
|
+
|
|
309
|
+
@staticmethod
|
|
310
|
+
def _message_id(e: dict) -> str:
|
|
311
|
+
for container_key in ("info", "message", "part"):
|
|
312
|
+
sub = e.get(container_key)
|
|
313
|
+
if isinstance(sub, dict):
|
|
314
|
+
for id_key in ("messageID", "message_id", "id"):
|
|
315
|
+
val = sub.get(id_key)
|
|
316
|
+
if isinstance(val, str) and val:
|
|
317
|
+
return val
|
|
318
|
+
for id_key in ("messageID", "message_id"):
|
|
319
|
+
val = e.get(id_key)
|
|
320
|
+
if isinstance(val, str) and val:
|
|
321
|
+
return val
|
|
322
|
+
return ""
|
|
323
|
+
|
|
324
|
+
@classmethod
|
|
325
|
+
def _extract_tokens(cls, e: dict) -> tuple[int, int] | None:
|
|
326
|
+
"""Pull an (input, output) token pair from one event dict, if present."""
|
|
327
|
+
# Search the event itself and one level of common nesting.
|
|
328
|
+
candidates: list[dict] = [e]
|
|
329
|
+
for key in ("usage", "tokens", "part", "info", "message"):
|
|
330
|
+
sub = e.get(key)
|
|
331
|
+
if isinstance(sub, dict):
|
|
332
|
+
candidates.append(sub)
|
|
333
|
+
for key2 in ("usage", "tokens"):
|
|
334
|
+
sub2 = sub.get(key2)
|
|
335
|
+
if isinstance(sub2, dict):
|
|
336
|
+
candidates.append(sub2)
|
|
337
|
+
for c in candidates:
|
|
338
|
+
pair = cls._token_pair(c)
|
|
339
|
+
if pair is not None:
|
|
340
|
+
return pair
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
@staticmethod
|
|
344
|
+
def _token_pair(d: dict) -> tuple[int, int] | None:
|
|
345
|
+
input_keys = ("input_tokens", "prompt_tokens", "input")
|
|
346
|
+
output_keys = ("output_tokens", "completion_tokens", "output")
|
|
347
|
+
inp = next(
|
|
348
|
+
(d[k] for k in input_keys if isinstance(d.get(k), (int, float))), None
|
|
349
|
+
)
|
|
350
|
+
out = next(
|
|
351
|
+
(d[k] for k in output_keys if isinstance(d.get(k), (int, float))), None
|
|
352
|
+
)
|
|
353
|
+
if inp is None and out is None:
|
|
354
|
+
return None
|
|
355
|
+
return int(inp or 0), int(out or 0)
|
|
356
|
+
|
|
357
|
+
def perform_task(
|
|
358
|
+
self,
|
|
359
|
+
instruction: str,
|
|
360
|
+
session: TmuxSession,
|
|
361
|
+
logging_dir: Path | None = None,
|
|
362
|
+
) -> AgentResult:
|
|
363
|
+
if not self._router_api_key:
|
|
364
|
+
self._logger.error(
|
|
365
|
+
"OPENAI_API_KEY (router key) is not set; opencode has no credential "
|
|
366
|
+
"to reach the router."
|
|
367
|
+
)
|
|
368
|
+
# Publish container id + inject router config BEFORE the base flow runs the
|
|
369
|
+
# agent. The base perform_task copies the install script into _CONTAINER_DIR,
|
|
370
|
+
# sources setup-env.sh (which exports OPENCODE_CONFIG), installs opencode, and
|
|
371
|
+
# runs the (json-capturing) command from _run_agent_commands.
|
|
372
|
+
self._publish_container_id(session)
|
|
373
|
+
self._write_config_to_container(session)
|
|
374
|
+
|
|
375
|
+
base_result = super().perform_task(instruction, session, logging_dir)
|
|
376
|
+
if base_result.failure_mode == FailureMode.AGENT_INSTALLATION_FAILED:
|
|
377
|
+
return base_result
|
|
378
|
+
|
|
379
|
+
raw_events = self._read_container_file(session, _USAGE_OUT_PATH)
|
|
380
|
+
raw_err = self._read_container_file(session, _USAGE_ERR_PATH)
|
|
381
|
+
# Persist the raw opencode event stream + stderr to the trajectory dir before
|
|
382
|
+
# the container is torn down, so the token metering is auditable and the run
|
|
383
|
+
# has a trajectory for the A/B. Best-effort — never fatal.
|
|
384
|
+
if logging_dir is not None:
|
|
385
|
+
try:
|
|
386
|
+
logging_dir.mkdir(parents=True, exist_ok=True)
|
|
387
|
+
(logging_dir / "opencode-events.jsonl").write_text(raw_events)
|
|
388
|
+
(logging_dir / "opencode-run.err").write_text(raw_err)
|
|
389
|
+
except Exception as e: # noqa: BLE001 - trajectory dump is never fatal
|
|
390
|
+
self._logger.warning("Could not persist opencode trajectory: %s", e)
|
|
391
|
+
input_tokens, output_tokens = self._parse_usage(raw_events)
|
|
392
|
+
if input_tokens == 0 and output_tokens == 0:
|
|
393
|
+
self._logger.warning(
|
|
394
|
+
"opencode reported zero usage; events=%d bytes, stderr tail: %s",
|
|
395
|
+
len(raw_events),
|
|
396
|
+
raw_err[-800:],
|
|
397
|
+
)
|
|
398
|
+
else:
|
|
399
|
+
self._logger.info(
|
|
400
|
+
"opencode usage: input=%d output=%d", input_tokens, output_tokens
|
|
401
|
+
)
|
|
402
|
+
return AgentResult(
|
|
403
|
+
total_input_tokens=input_tokens,
|
|
404
|
+
total_output_tokens=output_tokens,
|
|
405
|
+
failure_mode=base_result.failure_mode,
|
|
406
|
+
)
|