agentbox-rl 0.2.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 (89) hide show
  1. agentbox/__init__.py +52 -0
  2. agentbox/agent/__init__.py +6 -0
  3. agentbox/agent/agent.py +59 -0
  4. agentbox/agent/loop.py +201 -0
  5. agentbox/agent/prompts.py +44 -0
  6. agentbox/benchmark/__init__.py +29 -0
  7. agentbox/benchmark/compare.py +51 -0
  8. agentbox/benchmark/hash.py +59 -0
  9. agentbox/benchmark/loader.py +152 -0
  10. agentbox/benchmark/report.py +175 -0
  11. agentbox/benchmark/runner.py +131 -0
  12. agentbox/benchmark/schema.py +198 -0
  13. agentbox/benchmark/setup_check.py +57 -0
  14. agentbox/cli/__init__.py +1 -0
  15. agentbox/cli/bench.py +571 -0
  16. agentbox/cli/generate.py +314 -0
  17. agentbox/cli/main.py +276 -0
  18. agentbox/cli/traj.py +82 -0
  19. agentbox/config.py +84 -0
  20. agentbox/config_load.py +107 -0
  21. agentbox/errors.py +79 -0
  22. agentbox/jobs/__init__.py +15 -0
  23. agentbox/jobs/artifacts.py +65 -0
  24. agentbox/jobs/cache.py +39 -0
  25. agentbox/jobs/types.py +67 -0
  26. agentbox/metrics/__init__.py +5 -0
  27. agentbox/metrics/aggregate.py +63 -0
  28. agentbox/model/__init__.py +17 -0
  29. agentbox/model/base.py +32 -0
  30. agentbox/model/errors.py +5 -0
  31. agentbox/model/mock.py +43 -0
  32. agentbox/model/openai_compat.py +153 -0
  33. agentbox/model/probe.py +186 -0
  34. agentbox/py.typed +0 -0
  35. agentbox/runner/__init__.py +6 -0
  36. agentbox/runner/parallel.py +194 -0
  37. agentbox/runner/rollout.py +251 -0
  38. agentbox/sandbox/__init__.py +15 -0
  39. agentbox/sandbox/docker_backend.py +158 -0
  40. agentbox/sandbox/images.py +29 -0
  41. agentbox/sandbox/manager.py +265 -0
  42. agentbox/sandbox/paths.py +65 -0
  43. agentbox/sandbox/prune.py +42 -0
  44. agentbox/sandbox/snapshot.py +43 -0
  45. agentbox/sandbox/types.py +67 -0
  46. agentbox/tasks/__init__.py +18 -0
  47. agentbox/tasks/bulk.py +67 -0
  48. agentbox/tasks/filter.py +100 -0
  49. agentbox/tasks/generate/__init__.py +42 -0
  50. agentbox/tasks/generate/batch.py +308 -0
  51. agentbox/tasks/generate/constraints.py +41 -0
  52. agentbox/tasks/generate/dedup.py +109 -0
  53. agentbox/tasks/generate/generator.py +665 -0
  54. agentbox/tasks/generate/llm_validate.py +325 -0
  55. agentbox/tasks/generate/lm.py +79 -0
  56. agentbox/tasks/generate/prompts.py +7 -0
  57. agentbox/tasks/generate/review.py +111 -0
  58. agentbox/tasks/generate/signatures.py +178 -0
  59. agentbox/tasks/generate/static_qc.py +236 -0
  60. agentbox/tasks/generate/strip_impl.py +71 -0
  61. agentbox/tasks/generate/validate.py +212 -0
  62. agentbox/tasks/loader.py +15 -0
  63. agentbox/tasks/rewards.py +47 -0
  64. agentbox/tasks/schema.py +79 -0
  65. agentbox/tasks/seeder.py +78 -0
  66. agentbox/tasks/verifier.py +65 -0
  67. agentbox/tools/__init__.py +16 -0
  68. agentbox/tools/base.py +54 -0
  69. agentbox/tools/builtins.py +31 -0
  70. agentbox/tools/decorator.py +89 -0
  71. agentbox/tools/executor.py +87 -0
  72. agentbox/tools/file_tools.py +162 -0
  73. agentbox/tools/registry.py +99 -0
  74. agentbox/tools/shell.py +63 -0
  75. agentbox/tools/tests_tool.py +35 -0
  76. agentbox/trajectory/__init__.py +12 -0
  77. agentbox/trajectory/formats/__init__.py +6 -0
  78. agentbox/trajectory/formats/art.py +68 -0
  79. agentbox/trajectory/formats/jsonl.py +17 -0
  80. agentbox/trajectory/recorder.py +76 -0
  81. agentbox/trajectory/render.py +334 -0
  82. agentbox/trajectory/schema.py +124 -0
  83. agentbox/types.py +42 -0
  84. agentbox/version.py +3 -0
  85. agentbox_rl-0.2.0.dist-info/METADATA +427 -0
  86. agentbox_rl-0.2.0.dist-info/RECORD +89 -0
  87. agentbox_rl-0.2.0.dist-info/WHEEL +4 -0
  88. agentbox_rl-0.2.0.dist-info/entry_points.txt +2 -0
  89. agentbox_rl-0.2.0.dist-info/licenses/LICENSE +176 -0
agentbox/__init__.py ADDED
@@ -0,0 +1,52 @@
1
+ """AgentBox — local agentic virtual environment for trajectory building."""
2
+
3
+ from agentbox.agent.agent import Agent
4
+ from agentbox.benchmark import (
5
+ BenchmarkReport,
6
+ BenchmarkRunConfig,
7
+ BenchmarkRunner,
8
+ BenchmarkSuite,
9
+ ModelUnderTest,
10
+ compare_reports,
11
+ create_suite_from_tasks,
12
+ load_suite,
13
+ )
14
+ from agentbox.config import AgentConfig, ModelConfig, RolloutConfig, SandboxConfig
15
+ from agentbox.metrics import AggregateMetrics, aggregate_trajectories
16
+ from agentbox.runner.parallel import ParallelResult, ParallelRunner
17
+ from agentbox.runner.rollout import Rollout
18
+ from agentbox.sandbox.manager import SandboxManager
19
+ from agentbox.tasks.schema import Task, VerifierSpec
20
+ from agentbox.tools.base import BaseTool
21
+ from agentbox.tools.decorator import tool
22
+ from agentbox.trajectory.schema import Trajectory
23
+ from agentbox.version import __version__
24
+
25
+ __all__ = [
26
+ "__version__",
27
+ "Agent",
28
+ "AgentConfig",
29
+ "AggregateMetrics",
30
+ "BaseTool",
31
+ "BenchmarkReport",
32
+ "BenchmarkRunConfig",
33
+ "BenchmarkRunner",
34
+ "BenchmarkSuite",
35
+ "ModelConfig",
36
+ "ModelUnderTest",
37
+ "ParallelResult",
38
+ "ParallelRunner",
39
+ "Rollout",
40
+ "RolloutConfig",
41
+ "SandboxConfig",
42
+ "SandboxManager",
43
+ "Task",
44
+ "Trajectory",
45
+ "VerifierSpec",
46
+ "aggregate_trajectories",
47
+ "compare_reports",
48
+ "create_suite_from_tasks",
49
+ "load_suite",
50
+ "tool",
51
+ ]
52
+
@@ -0,0 +1,6 @@
1
+ """Agent loop and facade."""
2
+
3
+ from agentbox.agent.agent import Agent
4
+ from agentbox.agent.loop import AgentLoop, AgentLoopResult
5
+
6
+ __all__ = ["Agent", "AgentLoop", "AgentLoopResult"]
@@ -0,0 +1,59 @@
1
+ """High-level Agent facade."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from typing import Any
7
+
8
+ from agentbox.config import AgentConfig, ModelConfig
9
+ from agentbox.model.base import ModelClient
10
+ from agentbox.model.openai_compat import OpenAICompatClient
11
+ from agentbox.tools.base import BaseTool
12
+ from agentbox.types import ToolMode
13
+
14
+
15
+ class Agent:
16
+ """Ergonomic agent configuration for rollouts."""
17
+
18
+ def __init__(
19
+ self,
20
+ model: str | ModelConfig | ModelClient,
21
+ *,
22
+ base_url: str | None = None,
23
+ api_key: str | None = None,
24
+ tools: ToolMode | list[str] | str = ToolMode.STRUCTURED,
25
+ custom_tools: Sequence[BaseTool] | None = None,
26
+ builtins: bool = True,
27
+ max_steps: int = 40,
28
+ temperature: float = 0.7,
29
+ **kwargs: Any,
30
+ ) -> None:
31
+ if isinstance(model, str):
32
+ self.model_config = ModelConfig(
33
+ model=model,
34
+ base_url=base_url,
35
+ api_key=api_key,
36
+ temperature=temperature,
37
+ )
38
+ self.model_client: ModelClient = OpenAICompatClient(self.model_config)
39
+ elif isinstance(model, ModelConfig):
40
+ self.model_config = model
41
+ self.model_client = OpenAICompatClient(model)
42
+ else:
43
+ self.model_config = ModelConfig(model=getattr(model, "model", "unknown"))
44
+ self.model_client = model
45
+
46
+ tool_mode: ToolMode | list[str]
47
+ if isinstance(tools, str) and tools in {m.value for m in ToolMode}:
48
+ tool_mode = ToolMode(tools)
49
+ else:
50
+ tool_mode = tools # type: ignore[assignment]
51
+
52
+ agent_kwargs = {
53
+ "tools": tool_mode,
54
+ "custom_tools": list(custom_tools or []),
55
+ "builtins": builtins,
56
+ "max_steps": max_steps,
57
+ **kwargs,
58
+ }
59
+ self.config = AgentConfig(**agent_kwargs)
agentbox/agent/loop.py ADDED
@@ -0,0 +1,201 @@
1
+ """Multi-turn agent loop with OpenAI tool calling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import time
8
+ from collections.abc import Awaitable, Callable
9
+ from typing import Any
10
+
11
+ from pydantic import BaseModel, Field
12
+
13
+ from agentbox.config import AgentConfig
14
+ from agentbox.model.base import ModelClient, ModelResponse
15
+ from agentbox.tools.base import ToolContext
16
+ from agentbox.tools.executor import ToolExecutor
17
+ from agentbox.trajectory.schema import Message, ToolCallRecord
18
+ from agentbox.types import FinalStatus, MessageRole
19
+
20
+
21
+ class AgentLoopResult(BaseModel):
22
+ messages: list[Message]
23
+ tool_call_records: list[ToolCallRecord]
24
+ final_status: FinalStatus
25
+ steps: int
26
+ model_calls: int
27
+ stop_reason: str
28
+ error: str | None = None
29
+ usage_totals: dict[str, int] = Field(default_factory=dict)
30
+
31
+
32
+ class AgentLoop:
33
+ def __init__(
34
+ self,
35
+ model: ModelClient,
36
+ tools: ToolExecutor,
37
+ config: AgentConfig,
38
+ *,
39
+ on_step: Callable[[int, Message], Awaitable[None] | None] | None = None,
40
+ ) -> None:
41
+ self.model = model
42
+ self.tools = tools
43
+ self.config = config
44
+ self.on_step = on_step
45
+
46
+ async def run(
47
+ self,
48
+ *,
49
+ system_prompt: str,
50
+ user_prompt: str,
51
+ ctx: ToolContext,
52
+ episode_deadline: float | None = None,
53
+ ) -> AgentLoopResult:
54
+ messages: list[Message] = [
55
+ Message(role=MessageRole.SYSTEM, content=system_prompt),
56
+ Message(role=MessageRole.USER, content=user_prompt),
57
+ ]
58
+ tool_records: list[ToolCallRecord] = []
59
+ usage_totals = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
60
+ model_calls = 0
61
+ step = 0
62
+ schemas = self.tools.schemas()
63
+
64
+ try:
65
+ while step < self.config.max_steps:
66
+ if episode_deadline is not None and time.monotonic() >= episode_deadline:
67
+ return AgentLoopResult(
68
+ messages=messages,
69
+ tool_call_records=tool_records,
70
+ final_status=FinalStatus.TIMEOUT,
71
+ steps=step,
72
+ model_calls=model_calls,
73
+ stop_reason="timeout",
74
+ usage_totals=usage_totals,
75
+ )
76
+
77
+ openai_messages = [m.to_openai_dict() for m in messages]
78
+ try:
79
+ response = await asyncio.wait_for(
80
+ self.model.complete(
81
+ openai_messages,
82
+ tools=schemas or None,
83
+ tool_choice="auto",
84
+ ),
85
+ timeout=self.config.step_timeout_s,
86
+ )
87
+ except TimeoutError:
88
+ return AgentLoopResult(
89
+ messages=messages,
90
+ tool_call_records=tool_records,
91
+ final_status=FinalStatus.TIMEOUT,
92
+ steps=step,
93
+ model_calls=model_calls,
94
+ stop_reason="timeout",
95
+ error=f"step timed out after {self.config.step_timeout_s}s",
96
+ usage_totals=usage_totals,
97
+ )
98
+ model_calls += 1
99
+ self._accumulate_usage(usage_totals, response)
100
+
101
+ assistant = self._to_assistant_message(response)
102
+ messages.append(assistant)
103
+
104
+ if self.on_step:
105
+ maybe = self.on_step(step, assistant)
106
+ if maybe is not None:
107
+ await maybe
108
+
109
+ if not response.tool_calls:
110
+ return AgentLoopResult(
111
+ messages=messages,
112
+ tool_call_records=tool_records,
113
+ final_status=FinalStatus.SUCCESS, # provisional; verifier decides reward
114
+ steps=step,
115
+ model_calls=model_calls,
116
+ stop_reason="final_answer",
117
+ usage_totals=usage_totals,
118
+ )
119
+
120
+ ctx.step = step
121
+ try:
122
+ tool_messages = await asyncio.wait_for(
123
+ self.tools.execute_many(
124
+ response.tool_calls,
125
+ ctx,
126
+ parallel=self.config.parallel_tool_calls,
127
+ ),
128
+ timeout=self.config.step_timeout_s,
129
+ )
130
+ except TimeoutError:
131
+ return AgentLoopResult(
132
+ messages=messages,
133
+ tool_call_records=tool_records,
134
+ final_status=FinalStatus.TIMEOUT,
135
+ steps=step,
136
+ model_calls=model_calls,
137
+ stop_reason="timeout",
138
+ error=f"tool batch timed out after {self.config.step_timeout_s}s",
139
+ usage_totals=usage_totals,
140
+ )
141
+ for tc, tm in zip(response.tool_calls, tool_messages, strict=True):
142
+ try:
143
+ args = json.loads(tc.function.arguments or "{}")
144
+ except json.JSONDecodeError:
145
+ args = {"_raw": tc.function.arguments}
146
+ is_error = bool(
147
+ tm.content and str(tm.content).startswith("ERROR:")
148
+ )
149
+ tool_records.append(
150
+ ToolCallRecord(
151
+ step=step,
152
+ tool_call_id=tc.id,
153
+ name=tc.function.name,
154
+ arguments=args if isinstance(args, dict) else {},
155
+ result=tm.content or "",
156
+ is_error=is_error,
157
+ )
158
+ )
159
+ messages.extend(tool_messages)
160
+ step += 1
161
+
162
+ return AgentLoopResult(
163
+ messages=messages,
164
+ tool_call_records=tool_records,
165
+ final_status=FinalStatus.MAX_STEPS,
166
+ steps=step,
167
+ model_calls=model_calls,
168
+ stop_reason="max_steps",
169
+ usage_totals=usage_totals,
170
+ )
171
+ except Exception as exc:
172
+ return AgentLoopResult(
173
+ messages=messages,
174
+ tool_call_records=tool_records,
175
+ final_status=FinalStatus.ERROR,
176
+ steps=step,
177
+ model_calls=model_calls,
178
+ stop_reason="error",
179
+ error=str(exc),
180
+ usage_totals=usage_totals,
181
+ )
182
+
183
+ def _to_assistant_message(self, response: ModelResponse) -> Message:
184
+ return Message(
185
+ role=MessageRole.ASSISTANT,
186
+ content=response.content,
187
+ tool_calls=response.tool_calls or None,
188
+ reasoning_content=response.reasoning_content
189
+ if self.config.include_thinking
190
+ else None,
191
+ raw=response.raw_choice,
192
+ )
193
+
194
+ @staticmethod
195
+ def _accumulate_usage(
196
+ totals: dict[str, int], response: ModelResponse
197
+ ) -> None:
198
+ if not response.usage:
199
+ return
200
+ for key in ("prompt_tokens", "completion_tokens", "total_tokens"):
201
+ totals[key] = totals.get(key, 0) + int(response.usage.get(key, 0))
@@ -0,0 +1,44 @@
1
+ """Default system prompts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agentbox.types import ToolMode
6
+
7
+
8
+ STRUCTURED_SYSTEM_PROMPT = """\
9
+ You are a coding agent working inside an isolated Linux container.
10
+ Your workspace is {workspace_dir}.
11
+
12
+ You solve the user's task using the provided tools. Prefer small, correct edits.
13
+ When you are done, respond with a brief final message and NO tool calls.
14
+ Do not invent file paths. Always verify your work with tests or commands when possible.
15
+ """
16
+
17
+ SHELL_SYSTEM_PROMPT = """\
18
+ You are a coding agent working inside an isolated Linux container.
19
+ Your workspace is {workspace_dir}.
20
+
21
+ You only have the run_command tool. Use standard Unix tools to inspect and edit files.
22
+ When you are done, respond with a brief final message and NO tool calls.
23
+ """
24
+
25
+
26
+ def render_system_prompt(
27
+ *,
28
+ workspace_dir: str = "/workspace",
29
+ mode: ToolMode | str = ToolMode.STRUCTURED,
30
+ extra: str | None = None,
31
+ override: str | None = None,
32
+ ) -> str:
33
+ if override:
34
+ base = override.format(workspace_dir=workspace_dir)
35
+ else:
36
+ template = (
37
+ SHELL_SYSTEM_PROMPT
38
+ if mode in (ToolMode.SHELL, "shell")
39
+ else STRUCTURED_SYSTEM_PROMPT
40
+ )
41
+ base = template.format(workspace_dir=workspace_dir)
42
+ if extra:
43
+ base = base.rstrip() + "\n\n" + extra.strip()
44
+ return base
@@ -0,0 +1,29 @@
1
+ """Native real-rollout benchmark suites."""
2
+
3
+ from agentbox.benchmark.compare import compare_reports
4
+ from agentbox.benchmark.loader import create_suite_from_tasks, load_suite, save_suite
5
+ from agentbox.benchmark.runner import BenchmarkRunner
6
+ from agentbox.benchmark.schema import (
7
+ BenchmarkReport,
8
+ BenchmarkRunConfig,
9
+ BenchmarkSuite,
10
+ BenchmarkSuiteManifest,
11
+ ModelUnderTest,
12
+ ScoringConfig,
13
+ SetupCheckSpec,
14
+ )
15
+
16
+ __all__ = [
17
+ "BenchmarkReport",
18
+ "BenchmarkRunConfig",
19
+ "BenchmarkRunner",
20
+ "BenchmarkSuite",
21
+ "BenchmarkSuiteManifest",
22
+ "ModelUnderTest",
23
+ "ScoringConfig",
24
+ "SetupCheckSpec",
25
+ "compare_reports",
26
+ "create_suite_from_tasks",
27
+ "load_suite",
28
+ "save_suite",
29
+ ]
@@ -0,0 +1,51 @@
1
+ """Compare two benchmark reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agentbox.benchmark.schema import BenchmarkReport, ComparisonResult
6
+
7
+
8
+ def compare_reports(
9
+ a: BenchmarkReport,
10
+ b: BenchmarkReport,
11
+ *,
12
+ require_hash_match: bool = False,
13
+ ) -> ComparisonResult:
14
+ warnings: list[str] = []
15
+ if a.suite_id != b.suite_id:
16
+ warnings.append(f"suite_id differs: {a.suite_id!r} vs {b.suite_id!r}")
17
+ hash_match = (a.suite_content_hash or "") == (b.suite_content_hash or "")
18
+ if not hash_match:
19
+ warnings.append(
20
+ f"content_hash differs: {a.suite_content_hash!r} vs {b.suite_content_hash!r}"
21
+ )
22
+ if require_hash_match and not hash_match:
23
+ warnings.append("require_hash_match=True: comparison may be unfair")
24
+
25
+ a_models = {m.model_id: m for m in a.models}
26
+ b_models = {m.model_id: m for m in b.models}
27
+ deltas: list[dict] = []
28
+
29
+ for mid in sorted(set(a_models) | set(b_models)):
30
+ ma, mb = a_models.get(mid), b_models.get(mid)
31
+ if ma is None or mb is None:
32
+ warnings.append(f"model {mid!r} missing in one report")
33
+ continue
34
+ deltas.append(
35
+ {
36
+ "model_id": mid,
37
+ "success_rate_a": ma.aggregate.success_rate,
38
+ "success_rate_b": mb.aggregate.success_rate,
39
+ "delta_success_rate": mb.aggregate.success_rate - ma.aggregate.success_rate,
40
+ "mean_steps_a": ma.aggregate.mean_steps,
41
+ "mean_steps_b": mb.aggregate.mean_steps,
42
+ "delta_mean_steps": mb.aggregate.mean_steps - ma.aggregate.mean_steps,
43
+ }
44
+ )
45
+
46
+ return ComparisonResult(
47
+ suite_id=a.suite_id,
48
+ hash_match=hash_match,
49
+ warnings=warnings,
50
+ deltas=deltas,
51
+ )
@@ -0,0 +1,59 @@
1
+ """Stable content hashing for benchmark suite integrity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from typing import Any
8
+
9
+ from agentbox.benchmark.schema import BenchmarkSuiteManifest
10
+ from agentbox.tasks.schema import Task
11
+
12
+
13
+ def _canonical_json(obj: Any) -> str:
14
+ return json.dumps(obj, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
15
+
16
+
17
+ def _task_payload(task: Task) -> dict[str, Any]:
18
+ return {
19
+ "task_id": task.task_id,
20
+ "description": task.description,
21
+ "starter_files": task.starter_files,
22
+ "setup_commands": task.setup_commands,
23
+ "verifier": task.verifier.model_dump(mode="json"),
24
+ "metadata": task.metadata,
25
+ "system_prompt_extra": task.system_prompt_extra,
26
+ "allowed_tools": task.allowed_tools,
27
+ "max_steps": task.max_steps,
28
+ }
29
+
30
+
31
+ def _fairness_config_payload(manifest: BenchmarkSuiteManifest) -> dict[str, Any]:
32
+ """Config fields that affect evaluation fairness (exclude free-form notes)."""
33
+ agent = manifest.agent.model_dump(
34
+ mode="json",
35
+ exclude={"custom_tools"}, # non-serializable / not used in suite freeze
36
+ )
37
+ return {
38
+ "suite_id": manifest.suite_id,
39
+ "version": manifest.version,
40
+ "sandbox": manifest.sandbox.model_dump(mode="json"),
41
+ "agent": agent,
42
+ "n_per_task": manifest.n_per_task,
43
+ "seed": manifest.seed,
44
+ "scoring": manifest.scoring.model_dump(mode="json"),
45
+ }
46
+
47
+
48
+ def compute_suite_content_hash(
49
+ tasks: list[Task],
50
+ manifest: BenchmarkSuiteManifest,
51
+ ) -> str:
52
+ """SHA-256 hex over tasks + fairness-critical suite config."""
53
+ ordered = sorted(tasks, key=lambda t: t.task_id)
54
+ payload = {
55
+ "config": _fairness_config_payload(manifest),
56
+ "tasks": [_task_payload(t) for t in ordered],
57
+ }
58
+ raw = _canonical_json(payload).encode("utf-8")
59
+ return hashlib.sha256(raw).hexdigest()
@@ -0,0 +1,152 @@
1
+ """Load and save benchmark suite directory packs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ from agentbox.benchmark.schema import BenchmarkSuite, BenchmarkSuiteManifest
10
+ from agentbox.config import AgentConfig, SandboxConfig
11
+ from agentbox.errors import BenchmarkError, TaskValidationError
12
+ from agentbox.tasks.bulk import load_task_dataset
13
+ from agentbox.tasks.schema import Task
14
+ from agentbox.types import ToolMode
15
+
16
+
17
+ def load_suite(path: str | Path) -> BenchmarkSuite:
18
+ """Load suite.json + tasks from a directory pack."""
19
+ root = Path(path).resolve()
20
+ manifest_path = root / "suite.json"
21
+ if not manifest_path.is_file():
22
+ raise BenchmarkError(f"suite.json not found in {root}")
23
+ try:
24
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
25
+ manifest = BenchmarkSuiteManifest.model_validate(data)
26
+ except Exception as exc:
27
+ raise BenchmarkError(f"Invalid suite.json: {exc}") from exc
28
+
29
+ tasks_dir = root / manifest.tasks_path
30
+ if not tasks_dir.exists():
31
+ raise BenchmarkError(f"tasks path not found: {tasks_dir}")
32
+
33
+ tasks = load_task_dataset(tasks_dir)
34
+ if not tasks:
35
+ # try single-level task.json dirs only already covered; try direct json
36
+ for p in sorted(tasks_dir.glob("*.json")):
37
+ try:
38
+ tasks.append(Task.from_json(p))
39
+ except TaskValidationError:
40
+ continue
41
+ if not tasks:
42
+ raise BenchmarkError(f"No tasks found under {tasks_dir}")
43
+
44
+ # Fairness: benchmarks should not carry custom_tools blobs
45
+ agent = manifest.agent.model_copy(update={"custom_tools": [], "drop_tools_prob": 0.0})
46
+ manifest = manifest.model_copy(update={"agent": agent})
47
+ return BenchmarkSuite(root=root, manifest=manifest, tasks=tasks)
48
+
49
+
50
+ def save_suite(suite: BenchmarkSuite, path: str | Path) -> Path:
51
+ """Write suite pack to directory (suite.json + tasks/)."""
52
+ root = Path(path).resolve()
53
+ root.mkdir(parents=True, exist_ok=True)
54
+ tasks_dir = root / suite.manifest.tasks_path
55
+ tasks_dir.mkdir(parents=True, exist_ok=True)
56
+
57
+ for task in suite.tasks:
58
+ task_dir = tasks_dir / task.task_id
59
+ task_dir.mkdir(parents=True, exist_ok=True)
60
+ # Write starter files into files/ and strip from task.json for cleanliness
61
+ files_dir = task_dir / "files"
62
+ if task.starter_files:
63
+ files_dir.mkdir(parents=True, exist_ok=True)
64
+ for rel, content in task.starter_files.items():
65
+ fp = files_dir / rel
66
+ fp.parent.mkdir(parents=True, exist_ok=True)
67
+ fp.write_text(content, encoding="utf-8")
68
+ slim = task.model_copy(update={"starter_files": {}})
69
+ slim.save_json(task_dir / "task.json")
70
+
71
+ # Serialize agent without custom_tools
72
+ m = suite.manifest.model_copy(deep=True)
73
+ m.agent = m.agent.model_copy(update={"custom_tools": []})
74
+ (root / "suite.json").write_text(
75
+ m.model_dump_json(indent=2),
76
+ encoding="utf-8",
77
+ )
78
+ return root
79
+
80
+
81
+ def create_suite_from_tasks(
82
+ tasks_src: str | Path,
83
+ dest: str | Path,
84
+ *,
85
+ suite_id: str,
86
+ name: str,
87
+ version: str = "1.0.0",
88
+ description: str = "",
89
+ sandbox: SandboxConfig | None = None,
90
+ agent: AgentConfig | None = None,
91
+ n_per_task: int = 1,
92
+ concurrency: int = 8,
93
+ freeze: bool = True,
94
+ ) -> BenchmarkSuite:
95
+ """Create a suite directory from a tasks folder or dataset."""
96
+ src = Path(tasks_src)
97
+ tasks = load_task_dataset(src)
98
+ if not tasks and src.is_dir():
99
+ for p in sorted(src.rglob("task.json")):
100
+ try:
101
+ tasks.append(Task.from_dir(p.parent))
102
+ except TaskValidationError:
103
+ continue
104
+ for p in sorted(src.glob("*.json")):
105
+ try:
106
+ tasks.append(Task.from_json(p))
107
+ except TaskValidationError:
108
+ continue
109
+ # de-dupe
110
+ seen: set[str] = set()
111
+ unique: list[Task] = []
112
+ for t in tasks:
113
+ if t.task_id in seen:
114
+ continue
115
+ seen.add(t.task_id)
116
+ unique.append(t)
117
+ if not unique:
118
+ raise BenchmarkError(f"No tasks found in {src}")
119
+
120
+ manifest = BenchmarkSuiteManifest(
121
+ suite_id=suite_id,
122
+ name=name,
123
+ description=description,
124
+ version=version,
125
+ sandbox=sandbox or SandboxConfig(),
126
+ agent=agent
127
+ or AgentConfig(
128
+ tools=ToolMode.STRUCTURED,
129
+ max_steps=40,
130
+ drop_tools_prob=0.0,
131
+ custom_tools=[],
132
+ ),
133
+ n_per_task=n_per_task,
134
+ concurrency=concurrency,
135
+ )
136
+ suite = BenchmarkSuite(manifest=manifest, tasks=unique)
137
+ if freeze:
138
+ suite = suite.freeze()
139
+ save_suite(suite, dest)
140
+ suite.root = Path(dest).resolve()
141
+ return suite
142
+
143
+
144
+ def snapshot_suite(suite: BenchmarkSuite, dest: str | Path) -> Path:
145
+ """Copy frozen suite into results for audit."""
146
+ dest_p = Path(dest)
147
+ if suite.root and suite.root.is_dir():
148
+ if dest_p.exists():
149
+ shutil.rmtree(dest_p)
150
+ shutil.copytree(suite.root, dest_p)
151
+ return dest_p
152
+ return save_suite(suite, dest_p)