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.
- ai4sci_bench/__init__.py +3 -0
- ai4sci_bench/adapters/__init__.py +27 -0
- ai4sci_bench/adapters/antigravity_cli.py +470 -0
- ai4sci_bench/adapters/api_proxy.py +2127 -0
- ai4sci_bench/adapters/claude_code_cli.py +651 -0
- ai4sci_bench/adapters/cli_agent.py +77 -0
- ai4sci_bench/adapters/codewhale_agent.py +392 -0
- ai4sci_bench/adapters/codex_cli.py +804 -0
- ai4sci_bench/adapters/direct_llm.py +380 -0
- ai4sci_bench/adapters/docker_agent.py +154 -0
- ai4sci_bench/adapters/hermes_agent.py +278 -0
- ai4sci_bench/adapters/http_agent.py +179 -0
- ai4sci_bench/adapters/kimi_code_cli.py +604 -0
- ai4sci_bench/adapters/mimo_code_cli.py +453 -0
- ai4sci_bench/adapters/openhands_agent.py +571 -0
- ai4sci_bench/adapters/subprocess_base.py +309 -0
- ai4sci_bench/analysis/__init__.py +5 -0
- ai4sci_bench/analysis/error_analyzer.py +241 -0
- ai4sci_bench/analysis/report_generator.py +89 -0
- ai4sci_bench/analysis/reviewer.py +768 -0
- ai4sci_bench/analysis/templates/general.txt +36 -0
- ai4sci_bench/auth/__init__.py +28 -0
- ai4sci_bench/auth/credentials.py +94 -0
- ai4sci_bench/auth/device_login.py +98 -0
- ai4sci_bench/auth/resolve.py +59 -0
- ai4sci_bench/branding.py +134 -0
- ai4sci_bench/cli.py +3939 -0
- ai4sci_bench/core/__init__.py +29 -0
- ai4sci_bench/core/agent_interface.py +42 -0
- ai4sci_bench/core/logger.py +53 -0
- ai4sci_bench/core/result_schema.py +54 -0
- ai4sci_bench/core/scorer.py +53 -0
- ai4sci_bench/core/task.py +307 -0
- ai4sci_bench/core/trajectory.py +163 -0
- ai4sci_bench/core/types.py +196 -0
- ai4sci_bench/generators/__init__.py +6 -0
- ai4sci_bench/generators/contamination.py +178 -0
- ai4sci_bench/generators/instance_generator.py +760 -0
- ai4sci_bench/generators/param_space.py +145 -0
- ai4sci_bench/hf/__init__.py +24 -0
- ai4sci_bench/hf/pull.py +181 -0
- ai4sci_bench/reporting/__init__.py +6 -0
- ai4sci_bench/reporting/aggregator.py +172 -0
- ai4sci_bench/reporting/batch_records.py +290 -0
- ai4sci_bench/reporting/difficulty_report.py +459 -0
- ai4sci_bench/reporting/leaderboard.py +171 -0
- ai4sci_bench/reporting/result_loader.py +450 -0
- ai4sci_bench/reporting/results.py +372 -0
- ai4sci_bench/reporting/visualizer.py +236 -0
- ai4sci_bench/runner/__init__.py +15 -0
- ai4sci_bench/runner/linux_ns_sandbox.py +179 -0
- ai4sci_bench/runner/metadata.py +212 -0
- ai4sci_bench/runner/orchestrator.py +1431 -0
- ai4sci_bench/runner/os_sandbox.py +768 -0
- ai4sci_bench/runner/parallel.py +189 -0
- ai4sci_bench/runner/proc_util.py +88 -0
- ai4sci_bench/runner/sandbox_support.py +144 -0
- ai4sci_bench/runner/task_env.py +451 -0
- ai4sci_bench/runner/task_image.py +428 -0
- ai4sci_bench/scorers/__init__.py +24 -0
- ai4sci_bench/scorers/_judge_common.py +164 -0
- ai4sci_bench/scorers/_parse_utils.py +105 -0
- ai4sci_bench/scorers/agent_judge.py +295 -0
- ai4sci_bench/scorers/code_analysis.py +291 -0
- ai4sci_bench/scorers/composite.py +62 -0
- ai4sci_bench/scorers/custom.py +23 -0
- ai4sci_bench/scorers/file_match.py +148 -0
- ai4sci_bench/scorers/llm_judge.py +302 -0
- ai4sci_bench/scorers/multimodal.py +385 -0
- ai4sci_bench/scorers/numerical.py +425 -0
- ai4sci_bench/submission/__init__.py +30 -0
- ai4sci_bench/submission/bundle.py +313 -0
- ai4sci_bench/submission/endpoints.py +96 -0
- ai4sci_bench/submission/upload.py +102 -0
- ai4sci_bench/tracking/__init__.py +19 -0
- ai4sci_bench/tracking/difficulty_scores.py +224 -0
- ai4sci_bench/trajectory/__init__.py +5 -0
- ai4sci_bench/trajectory/claude_extractor.py +207 -0
- ai4sci_bench/trajectory/codex_extractor.py +293 -0
- ai4sci_bench/trajectory/direct_llm_extractor.py +127 -0
- ai4sci_bench/trajectory/file_history.py +210 -0
- ai4sci_bench/trajectory/generic_extractor.py +23 -0
- ai4sci_bench/trajectory/validator.py +44 -0
- ai4sci_bench/validators/__init__.py +1 -0
- ai4sci_bench/validators/static_validator.py +501 -0
- asibench-0.1.0.dist-info/METADATA +511 -0
- asibench-0.1.0.dist-info/RECORD +90 -0
- asibench-0.1.0.dist-info/WHEEL +4 -0
- asibench-0.1.0.dist-info/entry_points.txt +3 -0
- asibench-0.1.0.dist-info/licenses/LICENSE +201 -0
ai4sci_bench/__init__.py
ADDED
|
@@ -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
|