asibench 0.1.0__py3-none-any.whl

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 (90) hide show
  1. ai4sci_bench/__init__.py +3 -0
  2. ai4sci_bench/adapters/__init__.py +27 -0
  3. ai4sci_bench/adapters/antigravity_cli.py +470 -0
  4. ai4sci_bench/adapters/api_proxy.py +2127 -0
  5. ai4sci_bench/adapters/claude_code_cli.py +651 -0
  6. ai4sci_bench/adapters/cli_agent.py +77 -0
  7. ai4sci_bench/adapters/codewhale_agent.py +392 -0
  8. ai4sci_bench/adapters/codex_cli.py +804 -0
  9. ai4sci_bench/adapters/direct_llm.py +380 -0
  10. ai4sci_bench/adapters/docker_agent.py +154 -0
  11. ai4sci_bench/adapters/hermes_agent.py +278 -0
  12. ai4sci_bench/adapters/http_agent.py +179 -0
  13. ai4sci_bench/adapters/kimi_code_cli.py +604 -0
  14. ai4sci_bench/adapters/mimo_code_cli.py +453 -0
  15. ai4sci_bench/adapters/openhands_agent.py +571 -0
  16. ai4sci_bench/adapters/subprocess_base.py +309 -0
  17. ai4sci_bench/analysis/__init__.py +5 -0
  18. ai4sci_bench/analysis/error_analyzer.py +241 -0
  19. ai4sci_bench/analysis/report_generator.py +89 -0
  20. ai4sci_bench/analysis/reviewer.py +768 -0
  21. ai4sci_bench/analysis/templates/general.txt +36 -0
  22. ai4sci_bench/auth/__init__.py +28 -0
  23. ai4sci_bench/auth/credentials.py +94 -0
  24. ai4sci_bench/auth/device_login.py +98 -0
  25. ai4sci_bench/auth/resolve.py +59 -0
  26. ai4sci_bench/branding.py +134 -0
  27. ai4sci_bench/cli.py +3939 -0
  28. ai4sci_bench/core/__init__.py +29 -0
  29. ai4sci_bench/core/agent_interface.py +42 -0
  30. ai4sci_bench/core/logger.py +53 -0
  31. ai4sci_bench/core/result_schema.py +54 -0
  32. ai4sci_bench/core/scorer.py +53 -0
  33. ai4sci_bench/core/task.py +307 -0
  34. ai4sci_bench/core/trajectory.py +163 -0
  35. ai4sci_bench/core/types.py +196 -0
  36. ai4sci_bench/generators/__init__.py +6 -0
  37. ai4sci_bench/generators/contamination.py +178 -0
  38. ai4sci_bench/generators/instance_generator.py +760 -0
  39. ai4sci_bench/generators/param_space.py +145 -0
  40. ai4sci_bench/hf/__init__.py +24 -0
  41. ai4sci_bench/hf/pull.py +181 -0
  42. ai4sci_bench/reporting/__init__.py +6 -0
  43. ai4sci_bench/reporting/aggregator.py +172 -0
  44. ai4sci_bench/reporting/batch_records.py +290 -0
  45. ai4sci_bench/reporting/difficulty_report.py +459 -0
  46. ai4sci_bench/reporting/leaderboard.py +171 -0
  47. ai4sci_bench/reporting/result_loader.py +450 -0
  48. ai4sci_bench/reporting/results.py +372 -0
  49. ai4sci_bench/reporting/visualizer.py +236 -0
  50. ai4sci_bench/runner/__init__.py +15 -0
  51. ai4sci_bench/runner/linux_ns_sandbox.py +179 -0
  52. ai4sci_bench/runner/metadata.py +212 -0
  53. ai4sci_bench/runner/orchestrator.py +1431 -0
  54. ai4sci_bench/runner/os_sandbox.py +768 -0
  55. ai4sci_bench/runner/parallel.py +189 -0
  56. ai4sci_bench/runner/proc_util.py +88 -0
  57. ai4sci_bench/runner/sandbox_support.py +144 -0
  58. ai4sci_bench/runner/task_env.py +451 -0
  59. ai4sci_bench/runner/task_image.py +428 -0
  60. ai4sci_bench/scorers/__init__.py +24 -0
  61. ai4sci_bench/scorers/_judge_common.py +164 -0
  62. ai4sci_bench/scorers/_parse_utils.py +105 -0
  63. ai4sci_bench/scorers/agent_judge.py +295 -0
  64. ai4sci_bench/scorers/code_analysis.py +291 -0
  65. ai4sci_bench/scorers/composite.py +62 -0
  66. ai4sci_bench/scorers/custom.py +23 -0
  67. ai4sci_bench/scorers/file_match.py +148 -0
  68. ai4sci_bench/scorers/llm_judge.py +302 -0
  69. ai4sci_bench/scorers/multimodal.py +385 -0
  70. ai4sci_bench/scorers/numerical.py +425 -0
  71. ai4sci_bench/submission/__init__.py +30 -0
  72. ai4sci_bench/submission/bundle.py +313 -0
  73. ai4sci_bench/submission/endpoints.py +96 -0
  74. ai4sci_bench/submission/upload.py +102 -0
  75. ai4sci_bench/tracking/__init__.py +19 -0
  76. ai4sci_bench/tracking/difficulty_scores.py +224 -0
  77. ai4sci_bench/trajectory/__init__.py +5 -0
  78. ai4sci_bench/trajectory/claude_extractor.py +207 -0
  79. ai4sci_bench/trajectory/codex_extractor.py +293 -0
  80. ai4sci_bench/trajectory/direct_llm_extractor.py +127 -0
  81. ai4sci_bench/trajectory/file_history.py +210 -0
  82. ai4sci_bench/trajectory/generic_extractor.py +23 -0
  83. ai4sci_bench/trajectory/validator.py +44 -0
  84. ai4sci_bench/validators/__init__.py +1 -0
  85. ai4sci_bench/validators/static_validator.py +501 -0
  86. asibench-0.1.0.dist-info/METADATA +511 -0
  87. asibench-0.1.0.dist-info/RECORD +90 -0
  88. asibench-0.1.0.dist-info/WHEEL +4 -0
  89. asibench-0.1.0.dist-info/entry_points.txt +3 -0
  90. asibench-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,3 @@
1
+ """ASI-Bench: LLM Agent benchmark for AI for Science."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,27 @@
1
+ """Built-in agent adapters."""
2
+
3
+ from ai4sci_bench.adapters.subprocess_base import SubprocessAgentAdapter
4
+ from ai4sci_bench.adapters.direct_llm import DirectLLMAdapter
5
+ from ai4sci_bench.adapters.claude_code_cli import ClaudeCodeCLIAdapter
6
+ from ai4sci_bench.adapters.cli_agent import CLIAgentAdapter
7
+ from ai4sci_bench.adapters.codex_cli import CodexCLIAdapter
8
+ from ai4sci_bench.adapters.openhands_agent import OpenHandsAdapter
9
+ from ai4sci_bench.adapters.hermes_agent import HermesAgentAdapter
10
+ from ai4sci_bench.adapters.codewhale_agent import CodeWhaleAdapter
11
+ from ai4sci_bench.adapters.kimi_code_cli import KimiCodeCLIAdapter
12
+ from ai4sci_bench.adapters.antigravity_cli import AntigravityCLIAdapter
13
+ from ai4sci_bench.adapters.mimo_code_cli import MiMoCodeCLIAdapter
14
+
15
+ __all__ = [
16
+ "SubprocessAgentAdapter",
17
+ "DirectLLMAdapter",
18
+ "ClaudeCodeCLIAdapter",
19
+ "CLIAgentAdapter",
20
+ "CodexCLIAdapter",
21
+ "OpenHandsAdapter",
22
+ "HermesAgentAdapter",
23
+ "CodeWhaleAdapter",
24
+ "KimiCodeCLIAdapter",
25
+ "AntigravityCLIAdapter",
26
+ "MiMoCodeCLIAdapter",
27
+ ]
@@ -0,0 +1,470 @@
1
+ """Antigravity CLI adapter — runs Google's Antigravity (agy) as the evaluated agent."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import shutil
9
+ import tempfile
10
+ import threading
11
+ import time
12
+ from pathlib import Path
13
+
14
+ from ai4sci_bench.adapters.subprocess_base import (
15
+ SubprocessAgentAdapter,
16
+ collect_output_files,
17
+ )
18
+ from ai4sci_bench.core.types import AgentOutput, CostInfo, RunStatus, TaskInstance, ToolMode
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class AntigravityCLIAdapter(SubprocessAgentAdapter):
24
+ """Run Google Antigravity CLI (agy) as the evaluated agent.
25
+
26
+ Antigravity CLI autonomously reads files, writes code, executes it,
27
+ debugs failures, and produces output files — Google's terminal-based
28
+ coding agent, the successor to Gemini CLI.
29
+
30
+ Supports three authentication modes:
31
+
32
+ 1. **Local login** (default): No ``api_key`` / ``api_base`` — uses
33
+ Google account auth or ``GEMINI_API_KEY`` from environment.
34
+ 2. **Gemini API Key**: Only ``api_key`` — injects ``GEMINI_API_KEY``
35
+ env var, uses Google's official Gemini endpoint.
36
+ 3. **Third-party API**: ``api_base`` is set — requires ``api_key``
37
+ and ``api_protocol`` (``openai`` or ``anthropic``). Starts a
38
+ local litellm proxy that translates between the protocol ``agy``
39
+ speaks and the target backend. ``model`` is the bare model name
40
+ as the target service understands it.
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ model: str = "gemini-3.5-flash",
46
+ timeout_seconds: int = 10800,
47
+ allow_external_tools: bool = False,
48
+ tool_mode: str | ToolMode | None = None,
49
+ api_key: str | None = None,
50
+ api_base: str | None = None,
51
+ api_protocol: str | None = None,
52
+ ):
53
+ super().__init__(
54
+ timeout_seconds=timeout_seconds,
55
+ supported_sandbox_modes=("none", "task", "os", "linux_ns"),
56
+ )
57
+ if api_base is not None:
58
+ raise ValueError(
59
+ "AntigravityCLIAdapter does not support third-party API endpoints. "
60
+ "agy 1.0.12 only supports Google authentication (GEMINI_API_KEY or "
61
+ "Google account login). For third-party models, use "
62
+ "ClaudeCodeCLIAdapter or OpenHandsAdapter instead."
63
+ )
64
+ self.model = model
65
+ self.allow_external_tools = allow_external_tools
66
+ self.tool_mode = self._resolve_tool_mode(tool_mode, allow_external_tools)
67
+ self.api_key = api_key
68
+ self.api_base = None
69
+ self.api_protocol = None
70
+ self._uses_proxy = False
71
+ self._proxy: object | None = None
72
+ self._proxy_lock = threading.Lock()
73
+ self._temp_config_dir: str | None = None
74
+ self._os_sandbox: object | None = None
75
+ self._sandbox_image_identity: str | None = None
76
+
77
+ @staticmethod
78
+ def _resolve_tool_mode(
79
+ tool_mode: str | ToolMode | None,
80
+ allow_external_tools: bool,
81
+ ) -> ToolMode:
82
+ if tool_mode is not None:
83
+ if isinstance(tool_mode, str):
84
+ return ToolMode(tool_mode)
85
+ return tool_mode
86
+ return ToolMode.SEARCH if allow_external_tools else ToolMode.RESTRICTED
87
+
88
+ # ── Proxy lifecycle ───────────────────────────────────────
89
+
90
+ def _ensure_proxy(self) -> str:
91
+ with self._proxy_lock:
92
+ if self._proxy is not None:
93
+ return self._proxy.local_url # type: ignore[union-attr]
94
+ from ai4sci_bench.adapters.api_proxy import LiteLLMProxy, resolve_litellm_model
95
+ litellm_model = resolve_litellm_model(self.model, self.api_protocol)
96
+ self._proxy = LiteLLMProxy(
97
+ model=litellm_model,
98
+ api_base=self.api_base,
99
+ api_key=self.api_key,
100
+ )
101
+ return self._proxy.start() # type: ignore[union-attr]
102
+
103
+ def _ensure_config_dir(self) -> str:
104
+ """Create a temp config dir for agy with custom settings."""
105
+ if self._temp_config_dir:
106
+ return self._temp_config_dir
107
+
108
+ tmpdir = tempfile.mkdtemp(prefix="agy_config_")
109
+ os.chmod(tmpdir, 0o700)
110
+
111
+ settings = {}
112
+ if self._uses_proxy:
113
+ proxy_url = self._ensure_proxy()
114
+ settings["apiBaseUrl"] = proxy_url
115
+ logger.info("antigravity_cli: using litellm proxy at %s", proxy_url)
116
+ elif self.api_base:
117
+ settings["apiBaseUrl"] = self.api_base
118
+
119
+ settings_path = os.path.join(tmpdir, "settings.json")
120
+ with open(settings_path, "w", encoding="utf-8") as f:
121
+ json.dump(settings, f, indent=2)
122
+
123
+ self._temp_config_dir = tmpdir
124
+ logger.info("antigravity_cli: generated config at %s", tmpdir)
125
+ return tmpdir
126
+
127
+ def _build_api_env(self) -> dict[str, str]:
128
+ env: dict[str, str] = {}
129
+ if self._uses_proxy:
130
+ self._ensure_config_dir()
131
+ env["GEMINI_API_KEY"] = "sk-proxy-placeholder"
132
+ if self._temp_config_dir:
133
+ env["ANTIGRAVITY_CONFIG_DIR"] = self._temp_config_dir
134
+ logger.info("antigravity_cli: using proxy mode")
135
+ elif self.api_key:
136
+ env["GEMINI_API_KEY"] = self.api_key
137
+ return env
138
+
139
+ # ── Adapter lifecycle ──────────────────────────────────────
140
+
141
+ def setup(self, config: dict) -> None:
142
+ super().setup(config)
143
+ if self.sandbox == "os":
144
+ from ai4sci_bench.runner.os_sandbox import OSSandbox
145
+ self._os_sandbox = OSSandbox(self.repo_root)
146
+
147
+ def teardown(self) -> None:
148
+ if self._proxy is not None:
149
+ self._proxy.stop() # type: ignore[union-attr]
150
+ self._proxy = None
151
+ if self._temp_config_dir is not None:
152
+ shutil.rmtree(self._temp_config_dir, ignore_errors=True)
153
+ self._temp_config_dir = None
154
+
155
+ # ── Env override ───────────────────────────────────────────
156
+
157
+ def _build_run_env(self, task_instance, task_env) -> dict[str, str] | None:
158
+ base_env = super()._build_run_env(task_instance, task_env)
159
+ api_env = self._build_api_env()
160
+ if not api_env:
161
+ return base_env
162
+ env = dict(base_env) if base_env else dict(os.environ)
163
+ env.update(api_env)
164
+ return env
165
+
166
+ # ── Solve ──────────────────────────────────────────────────
167
+
168
+ def solve(self, task_instance: TaskInstance) -> AgentOutput:
169
+ if self.sandbox != "os":
170
+ output = super().solve(task_instance)
171
+ if output.cost is None and output.raw_stdout:
172
+ output.cost = self._extract_usage_from_jsonl(output.raw_stdout)
173
+ return output
174
+
175
+ eff_timeout = self._get_effective_timeout(task_instance)
176
+ workspace = task_instance.workspace_dir
177
+ cmd = self._build_os_agent_cmd(workspace)
178
+ extra_env: dict[str, str] | None = self._build_api_env() or None
179
+
180
+ t0 = time.time()
181
+ assert self._os_sandbox is not None
182
+ success, log, raw_stdout, raw_stderr, image_identity = (
183
+ self._os_sandbox.run_agent(
184
+ task_metadata=task_instance.metadata,
185
+ agent_cmd=cmd,
186
+ workspace=workspace,
187
+ timeout=eff_timeout,
188
+ agent_type="agy",
189
+ allow_external_tools=self.allow_external_tools,
190
+ extra_env=extra_env,
191
+ )
192
+ )
193
+ elapsed = time.time() - t0
194
+ self._sandbox_image_identity = image_identity
195
+
196
+ produced_files = collect_output_files(workspace, task_instance)
197
+ parsed_log = self._parse_log(raw_stdout or "") if raw_stdout else log
198
+
199
+ if "timed out" in log:
200
+ status = RunStatus.TIMEOUT
201
+ elif success:
202
+ status = RunStatus.COMPLETED
203
+ else:
204
+ status = RunStatus.FAILED
205
+
206
+ return AgentOutput(
207
+ instance_id=task_instance.instance_id,
208
+ output_dir=workspace,
209
+ code_files=[f for f in produced_files if f.endswith(".py")],
210
+ data_files=[f for f in produced_files if not f.endswith(".py")],
211
+ log=parsed_log,
212
+ execution_time_seconds=elapsed,
213
+ status=status,
214
+ error_message=None if success else log,
215
+ raw_stdout=raw_stdout,
216
+ raw_stderr=raw_stderr,
217
+ raw_stdout_format="text" if raw_stdout else None,
218
+ cost=self._extract_usage_from_jsonl(raw_stdout) if raw_stdout else None,
219
+ )
220
+
221
+ def _build_os_agent_cmd(self, workspace) -> list[str]:
222
+ """Build agy command for Docker execution."""
223
+ return [
224
+ "agy",
225
+ "-p",
226
+ "--model", self.model,
227
+ "--dangerously-skip-permissions",
228
+ ]
229
+
230
+ def _build_command(self, task_instance: TaskInstance, task_env):
231
+ cmd = [
232
+ "agy",
233
+ "-p",
234
+ "--model", self.model,
235
+ "--dangerously-skip-permissions",
236
+ ]
237
+ return cmd
238
+
239
+ def _get_stdin_input(self, task_instance=None, task_env=None) -> str | None:
240
+ if task_instance is None:
241
+ return None
242
+ prompt_path = task_instance.workspace_dir / "prompt.md"
243
+ if prompt_path.exists():
244
+ return prompt_path.read_text(encoding="utf-8")
245
+ return None
246
+
247
+ def _parse_log(self, stdout: str) -> str:
248
+ return self._parse_agy_log(stdout)
249
+
250
+ def _raw_stdout_format(self) -> str:
251
+ return "text"
252
+
253
+ # ── Log parsing ────────────────────────────────────────────
254
+
255
+ _TOOL_USE_VALUE_KEYS = (
256
+ "file_path", "path", "command", "pattern", "url",
257
+ )
258
+
259
+ def _parse_agy_log(self, stdout: str) -> str:
260
+ lines: list[str] = []
261
+ turn_counter = 0
262
+ first_ts: float | None = None
263
+ for line in stdout.splitlines():
264
+ stripped = line.strip()
265
+ if not stripped:
266
+ continue
267
+ try:
268
+ event = json.loads(stripped)
269
+ except json.JSONDecodeError:
270
+ lines.append(line)
271
+ continue
272
+
273
+ etype = event.get("type", "")
274
+
275
+ # Claude Code style events
276
+ if etype == "assistant":
277
+ turn_counter += 1
278
+ ts = event.get("timestamp")
279
+ try:
280
+ ts_float = float(ts) if ts is not None else None
281
+ except (ValueError, TypeError):
282
+ ts_float = None
283
+ if ts_float is not None:
284
+ if first_ts is None:
285
+ first_ts = ts_float
286
+ relative = ts_float - first_ts
287
+ lines.append(f"=== Turn {turn_counter} [+{relative:.1f}s] ===")
288
+ else:
289
+ lines.append(f"=== Turn {turn_counter} ===")
290
+ lines.extend(self._format_assistant_blocks(event))
291
+ # Codex / generic message style events
292
+ elif etype == "message" and event.get("role") == "assistant":
293
+ turn_counter += 1
294
+ ts = event.get("timestamp")
295
+ try:
296
+ ts_float = float(ts) if ts is not None else None
297
+ except (ValueError, TypeError):
298
+ ts_float = None
299
+ if ts_float is not None:
300
+ if first_ts is None:
301
+ first_ts = ts_float
302
+ relative = ts_float - first_ts
303
+ lines.append(f"=== Turn {turn_counter} [+{relative:.1f}s] ===")
304
+ else:
305
+ lines.append(f"=== Turn {turn_counter} ===")
306
+ lines.extend(self._format_message_blocks(event))
307
+ elif etype == "message":
308
+ lines.extend(self._format_message_blocks(event))
309
+ elif etype == "user":
310
+ lines.extend(self._format_user_blocks(event))
311
+ elif etype == "system":
312
+ subtype = event.get("subtype") or ""
313
+ msg = event.get("message") or event.get("text") or ""
314
+ label = f"[system:{subtype}]" if subtype else "[system]"
315
+ if msg:
316
+ lines.append(f"{label} {str(msg)}")
317
+ elif etype in ("function_call", "tool_use"):
318
+ lines.append(self._format_tool_use(event))
319
+ elif etype in ("function_call_output", "tool_result"):
320
+ output = event.get("output", event.get("content", ""))
321
+ if output:
322
+ lines.append(f"[ToolResult] {str(output)}")
323
+ elif etype in ("error", "rate_limit_event", "rate_limit"):
324
+ msg = event.get("message") or event.get("error") or event
325
+ label = "[error]" if etype == "error" else "[rate_limit]"
326
+ lines.append(f"{label} {str(msg)}")
327
+ elif etype in ("reasoning", "thinking"):
328
+ text = event.get("text") or event.get("content") or ""
329
+ if text:
330
+ lines.append(f"[thinking] {str(text)}")
331
+ elif etype == "result":
332
+ subtype = event.get("subtype") or ""
333
+ status_bits = []
334
+ if subtype:
335
+ status_bits.append(subtype)
336
+ if "is_error" in event:
337
+ status_bits.append(f"is_error={event['is_error']}")
338
+ if "num_turns" in event:
339
+ status_bits.append(f"turns={event['num_turns']}")
340
+ lines.append(f"[result] {' '.join(status_bits)}".rstrip())
341
+ elif etype:
342
+ lines.append(f"[{etype}] {stripped[:500]}")
343
+ return "\n".join(lines)
344
+
345
+ def _format_assistant_blocks(self, event: dict) -> list[str]:
346
+ out: list[str] = []
347
+ for block in event.get("message", {}).get("content", []):
348
+ btype = block.get("type")
349
+ if btype == "text":
350
+ text = str(block.get("text", ""))
351
+ out.append(f"[Agy] {text}")
352
+ elif btype == "tool_use":
353
+ out.append(self._format_tool_use(block))
354
+ elif btype == "thinking":
355
+ text = str(block.get("thinking", ""))
356
+ if text:
357
+ out.append(f"[thinking] {text}")
358
+ return out
359
+
360
+ def _format_message_blocks(self, event: dict) -> list[str]:
361
+ role = event.get("role", "")
362
+ content = event.get("content", "")
363
+ out: list[str] = []
364
+ if isinstance(content, str):
365
+ if content:
366
+ out.append(f"[{role}] {content}")
367
+ return out
368
+ if isinstance(content, list):
369
+ for block in content:
370
+ if not isinstance(block, dict):
371
+ continue
372
+ btype = block.get("type")
373
+ if btype == "text":
374
+ text = str(block.get("text", ""))
375
+ if text:
376
+ out.append(f"[{role}] {text}")
377
+ elif btype == "tool_use":
378
+ out.append(self._format_tool_use(block))
379
+ elif btype == "tool_result":
380
+ inner = block.get("content", "")
381
+ if isinstance(inner, list):
382
+ parts = [
383
+ str(item.get("text", ""))
384
+ for item in inner
385
+ if isinstance(item, dict) and item.get("type") == "text"
386
+ ]
387
+ text = "\n".join(parts)
388
+ else:
389
+ text = str(inner)
390
+ label = "[ToolResult:err]" if block.get("is_error") else "[ToolResult]"
391
+ out.append(f"{label} {text}")
392
+ return out
393
+
394
+ def _format_user_blocks(self, event: dict) -> list[str]:
395
+ out: list[str] = []
396
+ content = event.get("message", {}).get("content", [])
397
+ if not isinstance(content, list):
398
+ return out
399
+ for block in content:
400
+ if not isinstance(block, dict):
401
+ continue
402
+ if block.get("type") == "tool_result":
403
+ inner = block.get("content", "")
404
+ if isinstance(inner, list):
405
+ parts = []
406
+ for item in inner:
407
+ if isinstance(item, dict) and item.get("type") == "text":
408
+ parts.append(str(item.get("text", "")))
409
+ text = "\n".join(parts)
410
+ else:
411
+ text = str(inner)
412
+ is_error = block.get("is_error")
413
+ label = "[ToolResult:err]" if is_error else "[ToolResult]"
414
+ out.append(f"{label} {text}")
415
+ return out
416
+
417
+ def _format_tool_use(self, block: dict) -> str:
418
+ name = block.get("name", "unknown")
419
+ inputs = block.get("input", {}) or block.get("arguments", {}) or {}
420
+ if isinstance(inputs, str):
421
+ try:
422
+ inputs = json.loads(inputs)
423
+ except (json.JSONDecodeError, ValueError):
424
+ return f"[Tool] {name}({inputs[:200]})"
425
+ if not isinstance(inputs, dict):
426
+ return f"[Tool] {name}"
427
+ highlights: list[str] = []
428
+ for key in self._TOOL_USE_VALUE_KEYS:
429
+ if key in inputs:
430
+ val = str(inputs[key])
431
+ if len(val) > 200:
432
+ val = val[:200] + "..."
433
+ highlights.append(f"{key}={val}")
434
+ if highlights:
435
+ return f"[Tool] {name}({', '.join(highlights)})"
436
+ return f"[Tool] {name}({list(inputs.keys())})"
437
+
438
+ # ── Usage extraction ───────────────────────────────────────
439
+
440
+ @staticmethod
441
+ def _extract_usage_from_jsonl(stdout: str) -> CostInfo | None:
442
+ for line in stdout.splitlines():
443
+ stripped = line.strip()
444
+ if not stripped:
445
+ continue
446
+ try:
447
+ event = json.loads(stripped)
448
+ except json.JSONDecodeError:
449
+ continue
450
+ if event.get("type") == "result":
451
+ usage = event.get("usage")
452
+ if usage and isinstance(usage, dict):
453
+ input_tokens = usage.get("input_tokens", 0)
454
+ output_tokens = usage.get("output_tokens", 0)
455
+ return CostInfo(
456
+ input_tokens=input_tokens,
457
+ output_tokens=output_tokens,
458
+ total_tokens=input_tokens + output_tokens,
459
+ )
460
+ usage = event.get("usage")
461
+ if usage and isinstance(usage, dict):
462
+ input_tokens = usage.get("input_tokens", usage.get("prompt_tokens", 0))
463
+ output_tokens = usage.get("output_tokens", usage.get("completion_tokens", 0))
464
+ if input_tokens or output_tokens:
465
+ return CostInfo(
466
+ input_tokens=input_tokens,
467
+ output_tokens=output_tokens,
468
+ total_tokens=input_tokens + output_tokens,
469
+ )
470
+ return None