@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.
Files changed (139) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/HARNESS.md +302 -0
  3. package/README.md +26 -1
  4. package/fixtures/aec-bench.json +18 -0
  5. package/fixtures/agentbench-dbbench.json +22 -0
  6. package/fixtures/bfcl.json +45 -0
  7. package/fixtures/commit0.json +72 -0
  8. package/fixtures/crag.json +10 -0
  9. package/fixtures/dabstep.json +22 -0
  10. package/fixtures/enterpriseops-gym.json +103 -0
  11. package/fixtures/finresearchbench.json +21 -0
  12. package/fixtures/finsearchcomp.json +66 -0
  13. package/fixtures/frames.json +26 -0
  14. package/fixtures/hotpotqa.json +182 -0
  15. package/fixtures/nomiracl.json +26 -0
  16. package/fixtures/open-rag-bench.json +16 -0
  17. package/fixtures/pier-agent/no-model-task/environment/Dockerfile +16 -0
  18. package/fixtures/pier-agent/no-model-task/environment/seed/src/status.txt +1 -0
  19. package/fixtures/pier-agent/no-model-task/instruction.md +6 -0
  20. package/fixtures/pier-agent/no-model-task/pre_artifacts.sh +6 -0
  21. package/fixtures/pier-agent/no-model-task/task.toml +35 -0
  22. package/fixtures/pier-agent/no-model-task/tests/Dockerfile +17 -0
  23. package/fixtures/pier-agent/no-model-task/tests/seed/src/status.txt +1 -0
  24. package/fixtures/pier-agent/no-model-task/tests/test.sh +19 -0
  25. package/fixtures/programbench.json +17 -0
  26. package/fixtures/ragbench.json +21 -0
  27. package/fixtures/simpleqa.json +121 -0
  28. package/fixtures/t2-ragbench.json +13 -0
  29. package/fixtures/tau2-bench.json +16 -0
  30. package/fixtures/tau3-banking.json +16 -0
  31. package/fixtures/toollm.json +28 -0
  32. package/fixtures/webarena-verified.json +20 -0
  33. package/package.json +39 -15
  34. package/pier_agents/__init__.py +18 -0
  35. package/pier_agents/candidate_contract.py +755 -0
  36. package/pier_agents/process_boundary.py +321 -0
  37. package/pier_agents/tangle_candidate.py +907 -0
  38. package/pier_agents/workspace_boundary.py +368 -0
  39. package/scripts/appworld_driver.py +359 -0
  40. package/scripts/cadbench_prepare.py +22 -0
  41. package/scripts/cadgenbench_hard_parts.py +48 -0
  42. package/scripts/clbench_codebase_judge.py +73 -0
  43. package/scripts/commit0_judge.py +170 -0
  44. package/scripts/dabstep_judge.py +42 -0
  45. package/scripts/enterpriseops_gym_judge.py +281 -0
  46. package/scripts/programbench_judge.py +120 -0
  47. package/scripts/render-gate-chart.mjs +176 -0
  48. package/scripts/run-package-tests.mjs +56 -0
  49. package/scripts/terminate-pier-trial.mts +66 -0
  50. package/scripts/trata-hedge/README.md +56 -0
  51. package/scripts/trata-hedge/run.sh +60 -0
  52. package/scripts/trata-hedge/solve.py +83 -0
  53. package/scripts/verify-packed-consumer.mjs +224 -0
  54. package/scripts/verify-pier-agent.mts +715 -0
  55. package/scripts/verify-pier-pair.mts +74 -0
  56. package/scripts/verify-pier-recovery.mts +139 -0
  57. package/src/adapters.ts +26 -0
  58. package/src/benchmarks/_harness.test.mts +178 -0
  59. package/src/benchmarks/_harness.ts +239 -16
  60. package/src/benchmarks/agentbench.ts +163 -0
  61. package/src/benchmarks/appworld.test.mts +15 -9
  62. package/src/benchmarks/bfcl.ts +346 -0
  63. package/src/benchmarks/crag.ts +137 -0
  64. package/src/benchmarks/dabstep.test.mts +70 -0
  65. package/src/benchmarks/dabstep.ts +212 -0
  66. package/src/benchmarks/external-adapters.test.mts +150 -0
  67. package/src/benchmarks/finresearchbench.ts +269 -0
  68. package/src/benchmarks/humaneval.ts +20 -8
  69. package/src/benchmarks/nomiracl.ts +180 -0
  70. package/src/benchmarks/open-rag-bench.ts +153 -0
  71. package/src/benchmarks/rag-benchmarks.test.mts +138 -0
  72. package/src/benchmarks/rag-shared.ts +327 -0
  73. package/src/benchmarks/ragbench.ts +171 -0
  74. package/src/benchmarks/swe-bench.test.mts +61 -0
  75. package/src/benchmarks/swe-bench.ts +201 -19
  76. package/src/benchmarks/t2-ragbench.ts +166 -0
  77. package/src/benchmarks/tau-bench-shared.ts +214 -0
  78. package/src/benchmarks/tau2-bench.ts +30 -0
  79. package/src/benchmarks/tau3-banking.ts +29 -0
  80. package/src/benchmarks/terminal-bench.test.mts +33 -0
  81. package/src/benchmarks/terminal-bench.ts +23 -8
  82. package/src/benchmarks/toollm.ts +254 -0
  83. package/src/benchmarks/types.ts +42 -0
  84. package/src/benchmarks/webarena-verified.ts +200 -0
  85. package/src/commit0-prereqs.sh +0 -0
  86. package/src/coordination-mcp-container-reach.mts +181 -0
  87. package/src/decoder-live.mts +1 -1
  88. package/src/examples/README.md +103 -39
  89. package/src/examples/benchmark-matrix.mts +101 -0
  90. package/src/examples/lean-proof-gate.README.md +77 -0
  91. package/src/examples/lean-proof-gate.mts +162 -0
  92. package/src/examples/lean-verify.ts +95 -0
  93. package/src/examples/lean.Dockerfile +12 -0
  94. package/src/examples/math-demo.mts +9 -7
  95. package/src/examples/strategy-demo.mts +10 -12
  96. package/src/gate.ts +3 -2
  97. package/src/hev-eval.mts +69 -0
  98. package/src/hev-improve.mts +169 -0
  99. package/src/hev-structural.mts +688 -0
  100. package/src/index.ts +73 -0
  101. package/src/mbpp-structural.mts +662 -0
  102. package/src/pier-agent.test-fixtures.mts +19 -0
  103. package/src/pier-agent.test.mts +363 -0
  104. package/src/pier-agent.ts +657 -0
  105. package/src/pier-result-grader.mjs +30 -0
  106. package/src/pier-result-grader.test.mts +62 -0
  107. package/src/pier-result-grader.ts +108 -0
  108. package/src/pier-task-outcome.test.mts +117 -0
  109. package/src/pier-task-outcome.ts +240 -0
  110. package/src/pier-trial-controller.test.mts +412 -0
  111. package/src/pier-trial-controller.ts +858 -0
  112. package/src/pier-trial-supervisor.mjs +352 -0
  113. package/src/resolve-client.ts +25 -2
  114. package/src/run-benchmarks-cli.mts +72 -0
  115. package/src/run-benchmarks-report.ts +66 -0
  116. package/src/run-benchmarks.test.mts +231 -0
  117. package/src/run-benchmarks.ts +589 -0
  118. package/src/smoke-structural-rollout.mts +393 -0
  119. package/src/swe-bench-env.test.ts +207 -0
  120. package/src/swe-bench-env.ts +554 -0
  121. package/src/swe-jail.ts +293 -0
  122. package/src/swe-self-improve.mts +84 -0
  123. package/src/swe-structural-judge-policy.test.ts +117 -0
  124. package/src/swe-structural-judge-policy.ts +133 -0
  125. package/src/swe-structural-policy.test.ts +124 -0
  126. package/src/swe-structural-policy.ts +132 -0
  127. package/src/swe-structural-provenance.test.ts +93 -0
  128. package/src/swe-structural-provenance.ts +138 -0
  129. package/src/swe-structural.mts +1260 -0
  130. package/src/swe-temp.ts +14 -0
  131. package/src/tb-container-executor.mts +234 -0
  132. package/src/tb-container-executor.test.mts +99 -0
  133. package/src/tb-supervisor-sidecar.mts +222 -0
  134. package/src/trata-gepa.mts +1 -1
  135. package/steerers/eops-itsm-population.json +1 -0
  136. package/tb_agents/opencode_refine_agent.py +117 -0
  137. package/tb_agents/opencode_router_agent.py +406 -0
  138. package/tb_agents/opencode_supervisor_agent.py +239 -0
  139. 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
+ )