benchcraft-agent 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.
- benchcraft_agent-0.1.0.dist-info/METADATA +184 -0
- benchcraft_agent-0.1.0.dist-info/RECORD +7 -0
- benchcraft_agent-0.1.0.dist-info/WHEEL +4 -0
- benchcraft_lazyagent/__init__.py +67 -0
- benchcraft_lazyagent/adapter.py +244 -0
- benchcraft_lazyagent/benchmark.py +217 -0
- benchcraft_lazyagent/tasks.py +237 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: benchcraft-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Benchcraft Agent: a bring-your-own-agent AgentAdapter pattern -- a rule-based reference agent executes file-manipulation tool-use tasks through the shared lazycore sandbox executor, scored for success/latency and reported via OTel GenAI telemetry.
|
|
5
|
+
Author: Benchcraft
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
8
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Requires-Dist: benchcraft-core
|
|
12
|
+
Provides-Extra: dev
|
|
13
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# benchcraft-agent
|
|
17
|
+
|
|
18
|
+
LazyAgent's signature capability at this scaffold's depth (architecture
|
|
19
|
+
doc `Benchcraft_Unified_Architecture.md`, Part 3, "Module 8: LazyAgent"): a
|
|
20
|
+
minimal, real, **bring-your-own-agent task-execution benchmark loop**. A
|
|
21
|
+
plain Python callable -- standing in for a real framework-agnostic agent,
|
|
22
|
+
per MASEval's `AgentAdapter` interface (smolagents/LangGraph/AutoGen/CAMEL,
|
|
23
|
+
per the module survey) -- executes a small file-manipulation tool-use task
|
|
24
|
+
*inside the shared `lazycore.sandbox` executor*, and the run is scored for
|
|
25
|
+
success/failure with basic accuracy/latency metrics reported via
|
|
26
|
+
`lazycore.telemetry`'s OTel GenAI helpers.
|
|
27
|
+
|
|
28
|
+
## Scope
|
|
29
|
+
|
|
30
|
+
**In scope for this pass:**
|
|
31
|
+
|
|
32
|
+
- One `AgentAdapter` interface (`benchcraft_lazyagent.adapter`) with one
|
|
33
|
+
concrete implementation, `SandboxedAgentAdapter`, that always executes
|
|
34
|
+
the agent's chosen action through a caller-supplied
|
|
35
|
+
`lazycore.sandbox.BaseSandboxExecutor`.
|
|
36
|
+
- One concrete task family (`benchcraft_lazyagent.tasks`): a
|
|
37
|
+
file-manipulation tool-use task -- "create a file named X with content Y
|
|
38
|
+
in the sandboxed working directory" -- with two fixed variants: a
|
|
39
|
+
pass-designed task (write target inside the allowed sandbox path) and a
|
|
40
|
+
fail-designed sandbox-escape-attempt task (write target deliberately
|
|
41
|
+
outside the allowed sandbox path).
|
|
42
|
+
- One reference agent callable, `rule_based_agent`: a plain, deterministic
|
|
43
|
+
Python function (not an LLM, not a real framework integration) that
|
|
44
|
+
reads a task's structured fields and proposes a shell command. It exists
|
|
45
|
+
to exercise the loop end-to-end, not to be a capable agent.
|
|
46
|
+
- One tiny multi-task benchmark runner (`benchcraft_lazyagent.benchmark`)
|
|
47
|
+
that runs a small, fixed task suite and reports an aggregate pass rate +
|
|
48
|
+
mean wall-clock latency (measured via `time.perf_counter()`).
|
|
49
|
+
- OTel GenAI telemetry via `lazycore.telemetry` (`genai_span`,
|
|
50
|
+
`set_ml_metric` for `ml.metric.accuracy`, `add_transcript_event` for each
|
|
51
|
+
trajectory step) -- no parallel telemetry/reporting schema is built here.
|
|
52
|
+
|
|
53
|
+
**Explicitly deferred (not in this pass), and why:**
|
|
54
|
+
|
|
55
|
+
| Deferred | Why |
|
|
56
|
+
|---|---|
|
|
57
|
+
| Real agent framework integrations (smolagents, LangGraph, AutoGen, CAMEL) | "Bring your own agent" at this scaffold depth means the core loop accepts *any* Python callable matching `AgentFn`'s signature -- wiring in a specific framework's decision function is real future integration work, not core-loop plumbing. Per §2.8, this platform explicitly does not build a router/registry of supported frameworks. |
|
|
58
|
+
| Multi-Objective Pareto RAG Optimization loop (accuracy vs. latency vs. cost) | This is LazyAgent's headline capability in Part 3, but it's a full optimization loop over a RAG pipeline's chunking/indexing/reranking/model-choice search space -- a substantially larger scope than "prove the sandboxed benchmark-eval loop works end-to-end," which is this pass's goal. |
|
|
59
|
+
| DISCO-style sample condensation (~1% informative task subset) | A data-selection technique for the Pareto RAG optimization loop above; deferred along with it. |
|
|
60
|
+
| SWE-bench-style heavyweight task suites | Per the architecture doc's own v1 rescope note for this module: "given ARM64/Apple-Silicon friction with Docker-based SWE-bench-style isolation, the initial benchmark suite should prioritize tasks compatible with the Mac-first sandbox strategy" -- this pass follows that guidance literally by using a Seatbelt-compatible file-manipulation task instead of a Docker-dependent suite. |
|
|
61
|
+
| RAG pipeline tuning (chunking/indexing/reranking search space, reranker-latency tradeoffs, prod-vector-DB disconnects -- Appendix A) | Informational/deferred per the task brief; not implemented in this pass. |
|
|
62
|
+
| Cloud/remote agent targets | Locked out of v1 scope platform-wide for LazyAgent (architecture doc Part 4, Part 6). |
|
|
63
|
+
|
|
64
|
+
## Sandbox wiring
|
|
65
|
+
|
|
66
|
+
This package **reuses `lazycore.sandbox` for all containment** -- it does
|
|
67
|
+
not build a second sandbox mechanism (per CLAUDE.md's "fix what's there /
|
|
68
|
+
no duplication" rule and architecture doc §2.3). `SandboxedAgentAdapter`
|
|
69
|
+
always calls `executor.run_command(...)` on a caller-supplied
|
|
70
|
+
`lazycore.sandbox.BaseSandboxExecutor` (typically
|
|
71
|
+
`lazycore.sandbox.get_default_executor()`, which resolves to the real
|
|
72
|
+
`SeatbeltSandboxExecutor` on macOS).
|
|
73
|
+
|
|
74
|
+
LazyAgent layers its **own** mode-specific `SandboxPolicy` values on top of
|
|
75
|
+
that shared executor, per architecture doc §2.3's description of this
|
|
76
|
+
module's sandboxing research as "the platform's most rigorous":
|
|
77
|
+
|
|
78
|
+
- **`allow_network=False`** (default-deny egress) on every task policy --
|
|
79
|
+
this module's benchmark tasks have no legitimate reason to reach the
|
|
80
|
+
network, and per CLAUDE.md's "local-only, v1" constraint, no network
|
|
81
|
+
calls belong in the core path.
|
|
82
|
+
- **`allowed_write_paths` scoped to a single per-task temp workspace
|
|
83
|
+
directory** -- never the whole filesystem, never a shared directory
|
|
84
|
+
across tasks. The pass-designed task's write target is inside this
|
|
85
|
+
directory; the fail-designed task's write target is a sibling directory
|
|
86
|
+
deliberately *excluded* from `allowed_write_paths`, to prove containment
|
|
87
|
+
is real (see "Fail-designed task" below).
|
|
88
|
+
- **`allowed_read_paths` left at its default (unrestricted read)** --
|
|
89
|
+
matching `lazycore.sandbox`'s own documented default behavior (the
|
|
90
|
+
write/network surfaces are the actual enforcement points for this task
|
|
91
|
+
family; there is no sensitive read-only data these tasks need to be
|
|
92
|
+
isolated from).
|
|
93
|
+
- **`timeout_seconds` set** on every task policy, so a misbehaving agent
|
|
94
|
+
action can't hang the benchmark run indefinitely.
|
|
95
|
+
|
|
96
|
+
Per architecture doc §2.3.1's split-trust architecture, this package never
|
|
97
|
+
attempts to sandbox any GPU/Metal/MPS-bound process -- there is none in
|
|
98
|
+
this scaffold; the only thing ever run inside the sandbox is the agent's
|
|
99
|
+
proposed shell command.
|
|
100
|
+
|
|
101
|
+
### Fail-designed task proves containment is real, not decorative
|
|
102
|
+
|
|
103
|
+
`benchcraft_lazyagent.tasks.make_fail_task` builds a task whose target file
|
|
104
|
+
lives in a `forbidden/` directory that is a sibling of (but not included
|
|
105
|
+
in) the sandbox policy's `allowed_write_paths`. The rule-based reference
|
|
106
|
+
agent still *attempts* the write (it doesn't know the sandbox will block
|
|
107
|
+
it) -- the Seatbelt backend's default-deny write policy is what actually
|
|
108
|
+
stops it. The test suite (`tests/test_tasks.py`) asserts on the real
|
|
109
|
+
filesystem state after the run: not just that the task is scored `False`,
|
|
110
|
+
but that the forbidden file (and even its parent directory, since `mkdir
|
|
111
|
+
-p` on it is blocked too) was never created. This is the concrete
|
|
112
|
+
demonstration that sandbox containment drives the scored benchmark
|
|
113
|
+
outcome, not just decorates it.
|
|
114
|
+
|
|
115
|
+
## Public API
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from benchcraft_lazyagent import (
|
|
119
|
+
# adapter.py
|
|
120
|
+
AgentAdapter, SandboxedAgentAdapter, AgentAction, AgentFn,
|
|
121
|
+
AgentTrajectory, TrajectoryStep, TaskSpec, TaskResult,
|
|
122
|
+
# tasks.py
|
|
123
|
+
FileTaskSpec, rule_based_agent, score_file_task,
|
|
124
|
+
make_pass_task, make_fail_task, default_task_suite,
|
|
125
|
+
# benchmark.py
|
|
126
|
+
BenchmarkReport, ScorerFn, run_task, run_benchmark,
|
|
127
|
+
)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
- `AgentFn = Callable[[TaskSpec], AgentAction]` is the bring-your-own-agent
|
|
131
|
+
seam: any Python callable with this signature can stand in for "the
|
|
132
|
+
agent" -- a real framework's step function could be adapted to match
|
|
133
|
+
this signature without touching `SandboxedAgentAdapter` or
|
|
134
|
+
`run_benchmark` at all.
|
|
135
|
+
- `SandboxedAgentAdapter(agent_fn).run_task(task, executor)` runs one task
|
|
136
|
+
and returns an `AgentTrajectory` (a 3-step transcript: the task
|
|
137
|
+
description as a `"user"` turn, the agent's chosen action as an
|
|
138
|
+
`"assistant"` turn, and the sandbox's real `SandboxResult` as a `"tool"`
|
|
139
|
+
turn).
|
|
140
|
+
- `run_benchmark(tasks, agent_fn)` runs a small task suite end-to-end and
|
|
141
|
+
returns a `BenchmarkReport` with per-task `TaskResult`s plus an aggregate
|
|
142
|
+
`pass_rate` and `mean_latency_seconds`.
|
|
143
|
+
|
|
144
|
+
## Installation
|
|
145
|
+
|
|
146
|
+
```bash
|
|
147
|
+
pip install -e packages/lazycore
|
|
148
|
+
pip install -e "packages/lazyagent[dev]"
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`lazycore` is declared as a bare (unpinned) dependency in `pyproject.toml`,
|
|
152
|
+
matching the convention already established by `packages/lazytune`,
|
|
153
|
+
`packages/automl`, and `packages/lazyforecast`. It is a local sibling
|
|
154
|
+
package, not published to PyPI, so it still must be installed (or
|
|
155
|
+
otherwise made resolvable) first -- a plain `pip install -e
|
|
156
|
+
"packages/lazyagent[dev]"` without `lazycore` already installed/resolvable
|
|
157
|
+
will fail to resolve the dependency.
|
|
158
|
+
|
|
159
|
+
This package's own dependency surface is **stdlib + lazycore only** -- no
|
|
160
|
+
smolagents/langgraph/autogen/camel, per the "explicitly deferred" table
|
|
161
|
+
above.
|
|
162
|
+
|
|
163
|
+
## Running tests
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
pytest packages/lazyagent/tests
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The suite exercises the **real** `SeatbeltSandboxExecutor` on macOS (this
|
|
170
|
+
repo's reference platform) -- it is skipped, not mocked, on non-macOS
|
|
171
|
+
hosts via `pytest.mark.skipif`. Tests assert on real filesystem state
|
|
172
|
+
(files that should exist do; files that should have been blocked do not)
|
|
173
|
+
and on a real, finite, non-zero mean latency computed from
|
|
174
|
+
`time.perf_counter()` measurements.
|
|
175
|
+
|
|
176
|
+
## Running the example
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
python packages/lazyagent/examples/agent_benchmark_example.py
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Runs the two-task default suite (one pass-designed, one fail-designed) and
|
|
183
|
+
prints each task's pass/fail status plus the aggregate pass rate and mean
|
|
184
|
+
latency.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
benchcraft_lazyagent/__init__.py,sha256=PITDkIJe_7QnX4WGTotn_lT34cmI7vSHiG_7u5wOAL4,1948
|
|
2
|
+
benchcraft_lazyagent/adapter.py,sha256=nNf_M0_gKivUOSIqXaGiQ3H73mzwTZpNIGv0n2j-J0Y,10131
|
|
3
|
+
benchcraft_lazyagent/benchmark.py,sha256=3JvZCu_rE5-_CTeNIXXxKLFZCs7hWM59otC_FrsfGUs,9473
|
|
4
|
+
benchcraft_lazyagent/tasks.py,sha256=RYkr1tMksUqlhZ8Y8f4IhinDkwDn_rt5IAbADWFtx_g,10172
|
|
5
|
+
benchcraft_agent-0.1.0.dist-info/METADATA,sha256=Wj_kmtPafsvt7rh9el7vtpRDWacpDurmfEsHdYbGhCg,9898
|
|
6
|
+
benchcraft_agent-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
7
|
+
benchcraft_agent-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Benchcraft LazyAgent -- bring-your-own-agent task-execution benchmark loop.
|
|
2
|
+
|
|
3
|
+
Implements exactly one signature capability from the architecture doc's
|
|
4
|
+
"Module 8: LazyAgent" (see `Benchcraft_Unified_Architecture.md`, Part 3):
|
|
5
|
+
a minimal, real, bring-your-own-agent task-execution benchmark loop where a
|
|
6
|
+
plain Python callable (standing in for a real framework-agnostic agent, per
|
|
7
|
+
MASEval's `AgentAdapter` interface) executes a small file-manipulation
|
|
8
|
+
tool-use task inside the shared `lazycore.sandbox` executor, and the run is
|
|
9
|
+
scored for success/failure with accuracy/latency metrics reported via
|
|
10
|
+
`lazycore.telemetry`.
|
|
11
|
+
|
|
12
|
+
See this package's README for full scope, the sandbox policy used, and
|
|
13
|
+
everything explicitly deferred (real agent framework integrations, the
|
|
14
|
+
Multi-Objective Pareto RAG Optimization loop, DISCO-style sample
|
|
15
|
+
condensation, SWE-bench-style task suites).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from benchcraft_lazyagent.adapter import (
|
|
21
|
+
AgentAction,
|
|
22
|
+
AgentAdapter,
|
|
23
|
+
AgentFn,
|
|
24
|
+
AgentTrajectory,
|
|
25
|
+
SandboxedAgentAdapter,
|
|
26
|
+
TaskResult,
|
|
27
|
+
TaskSpec,
|
|
28
|
+
TrajectoryStep,
|
|
29
|
+
)
|
|
30
|
+
from benchcraft_lazyagent.benchmark import (
|
|
31
|
+
BenchmarkReport,
|
|
32
|
+
ScorerFn,
|
|
33
|
+
run_benchmark,
|
|
34
|
+
run_task,
|
|
35
|
+
)
|
|
36
|
+
from benchcraft_lazyagent.tasks import (
|
|
37
|
+
FileTaskSpec,
|
|
38
|
+
default_task_suite,
|
|
39
|
+
make_fail_task,
|
|
40
|
+
make_pass_task,
|
|
41
|
+
rule_based_agent,
|
|
42
|
+
score_file_task,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
# Alphabetically sorted (ruff RUF022) across adapter.py/benchmark.py/
|
|
47
|
+
# tasks.py -- see the import blocks above for which module each name
|
|
48
|
+
# actually comes from.
|
|
49
|
+
"AgentAction",
|
|
50
|
+
"AgentAdapter",
|
|
51
|
+
"AgentFn",
|
|
52
|
+
"AgentTrajectory",
|
|
53
|
+
"BenchmarkReport",
|
|
54
|
+
"FileTaskSpec",
|
|
55
|
+
"SandboxedAgentAdapter",
|
|
56
|
+
"ScorerFn",
|
|
57
|
+
"TaskResult",
|
|
58
|
+
"TaskSpec",
|
|
59
|
+
"TrajectoryStep",
|
|
60
|
+
"default_task_suite",
|
|
61
|
+
"make_fail_task",
|
|
62
|
+
"make_pass_task",
|
|
63
|
+
"rule_based_agent",
|
|
64
|
+
"run_benchmark",
|
|
65
|
+
"run_task",
|
|
66
|
+
"score_file_task",
|
|
67
|
+
]
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Bring-your-own-agent ``AgentAdapter`` pattern (architecture doc Part 3, "Module 8: LazyAgent").
|
|
2
|
+
|
|
3
|
+
The architecture doc's LazyAgent survey names MASEval's "framework-agnostic
|
|
4
|
+
``AgentAdapter`` interface spanning smolagents/LangGraph/AutoGen/CAMEL" as
|
|
5
|
+
the design this module should implement a Bring-Your-Own-Agent version of.
|
|
6
|
+
Per §2.8 ("no LLM router, no multi-provider abstraction... each module
|
|
7
|
+
takes a bare-minimum 'bring your own local model handle' approach"),
|
|
8
|
+
"bring your own agent" here means concretely: :class:`AgentAdapter` accepts
|
|
9
|
+
any Python callable matching the documented :data:`AgentFn` signature as
|
|
10
|
+
"the agent" -- it is *not* a registry/router of supported agent frameworks.
|
|
11
|
+
A caller could later plug in a real framework's decision function (e.g. a
|
|
12
|
+
thin wrapper around a smolagents/LangGraph step) without changing this
|
|
13
|
+
package's core loop at all.
|
|
14
|
+
|
|
15
|
+
Nothing here duplicates the shared sandbox executor (`lazycore.sandbox`) --
|
|
16
|
+
:class:`AgentAdapter` always executes the agent's chosen action *through*
|
|
17
|
+
a caller-supplied `lazycore.sandbox.BaseSandboxExecutor` instance. This
|
|
18
|
+
module only adds the LazyAgent-specific machinery layered on top: the task/
|
|
19
|
+
trajectory/result data shapes, and the adapter that wires an arbitrary
|
|
20
|
+
agent callable to the shared executor. Per §2.3, LazyAgent does not get its
|
|
21
|
+
own executor class -- only its own mode-specific `SandboxPolicy` values
|
|
22
|
+
(see `benchcraft_lazyagent.tasks`).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import abc
|
|
28
|
+
import time
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from typing import Callable, Sequence
|
|
31
|
+
|
|
32
|
+
from lazycore.sandbox import BaseSandboxExecutor, SandboxPolicy, SandboxResult
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"AgentAction",
|
|
36
|
+
"AgentAdapter",
|
|
37
|
+
"AgentFn",
|
|
38
|
+
"AgentTrajectory",
|
|
39
|
+
"SandboxedAgentAdapter",
|
|
40
|
+
"TaskResult",
|
|
41
|
+
"TaskSpec",
|
|
42
|
+
"TrajectoryStep",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class TaskSpec:
|
|
48
|
+
"""A single benchmark task the agent is asked to complete.
|
|
49
|
+
|
|
50
|
+
Deliberately minimal at this scaffold's depth (see README "Scope") --
|
|
51
|
+
one concrete task family is implemented in ``benchcraft_lazyagent.tasks``
|
|
52
|
+
(file-manipulation tool-use), not a general task-description schema for
|
|
53
|
+
arbitrary tool-use suites (SWE-bench-style task suites are explicitly
|
|
54
|
+
out of scope for this pass).
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
name: Short, unique task identifier (used in reports/telemetry).
|
|
58
|
+
description: Natural-language task description handed to the agent
|
|
59
|
+
callable -- e.g. "create a file named out.txt with content
|
|
60
|
+
'hello' in the sandboxed working directory".
|
|
61
|
+
sandbox_policy: The :class:`~lazycore.sandbox.SandboxPolicy` this
|
|
62
|
+
task's action is executed under. LazyAgent's own mode-specific
|
|
63
|
+
policy values (default-deny egress, restrictive write paths)
|
|
64
|
+
are constructed by ``benchcraft_lazyagent.tasks``, not by
|
|
65
|
+
`lazycore` itself -- see that module and the README for why.
|
|
66
|
+
expect_success: Whether this task variant is *designed* to succeed
|
|
67
|
+
(``True``) or *designed* to fail via sandbox containment
|
|
68
|
+
(``False``) -- used only for documentation/test clarity; the
|
|
69
|
+
actual pass/fail determination always comes from ``scorer``.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
name: str
|
|
73
|
+
description: str
|
|
74
|
+
sandbox_policy: SandboxPolicy
|
|
75
|
+
expect_success: bool = True
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class AgentAction:
|
|
80
|
+
"""What the agent callable decided to do for a given :class:`TaskSpec`.
|
|
81
|
+
|
|
82
|
+
At this scaffold's depth the only supported action shape is a single
|
|
83
|
+
shell command (argv-style, matching
|
|
84
|
+
:meth:`~lazycore.sandbox.BaseSandboxExecutor.run_command`'s contract) --
|
|
85
|
+
a real framework-agnostic adapter would eventually need to support
|
|
86
|
+
multi-step tool-call sequences, but a single action per task is enough
|
|
87
|
+
to exercise the sandbox-wiring and scoring loop that is this pass's
|
|
88
|
+
signature capability.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
command: Sequence[str]
|
|
92
|
+
rationale: str = ""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
#: The "bring your own agent" plug-in point. A caller supplies any Python
|
|
96
|
+
#: callable matching this signature -- it receives the task and must return
|
|
97
|
+
#: the action it wants executed. `benchcraft_lazyagent.tasks.rule_based_agent`
|
|
98
|
+
#: is the one reference implementation provided by this package (a plain,
|
|
99
|
+
#: deterministic, rule-based function -- not a real LLM-backed agent); a
|
|
100
|
+
#: real framework's step function could be adapted to this exact signature
|
|
101
|
+
#: without touching `AgentAdapter` itself.
|
|
102
|
+
AgentFn = Callable[[TaskSpec], AgentAction]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class TrajectoryStep:
|
|
107
|
+
"""One recorded step of an agent's execution trajectory.
|
|
108
|
+
|
|
109
|
+
Mirrors the shape `lazycore.telemetry.add_transcript_event` expects
|
|
110
|
+
(a ``role`` + ``content`` pair), so trajectories reported by this
|
|
111
|
+
module reuse the exact same OTel transcript-event convention LazyRed
|
|
112
|
+
uses for its own conversational transcripts (architecture doc §2.6).
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
role: str
|
|
116
|
+
content: str
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True)
|
|
120
|
+
class AgentTrajectory:
|
|
121
|
+
"""Everything the agent "did" for one task: its steps plus the sandboxed result."""
|
|
122
|
+
|
|
123
|
+
task_name: str
|
|
124
|
+
steps: tuple[TrajectoryStep, ...]
|
|
125
|
+
sandbox_result: SandboxResult
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class TaskResult:
|
|
130
|
+
"""The scored outcome of running one :class:`TaskSpec` through an adapter.
|
|
131
|
+
|
|
132
|
+
``trajectory`` is ``None`` exactly when the task never produced one --
|
|
133
|
+
i.e. ``agent_fn``/``adapter.run_task`` raised before returning an
|
|
134
|
+
:class:`AgentTrajectory` at all. `benchcraft_lazyagent.benchmark.run_task`
|
|
135
|
+
catches such exceptions at a per-task boundary (so one task's crash
|
|
136
|
+
never aborts the whole benchmark run) and reports them here as a
|
|
137
|
+
failed result: ``success=False``, ``latency_seconds`` set to the
|
|
138
|
+
wall-clock time elapsed up to the point of failure, ``trajectory=None``,
|
|
139
|
+
and the exception's type/message captured in ``detail`` -- the same
|
|
140
|
+
field already used for a scorer's human-readable pass/fail explanation,
|
|
141
|
+
rather than inventing a second, parallel error-reporting field.
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
task_name: str
|
|
145
|
+
success: bool
|
|
146
|
+
latency_seconds: float
|
|
147
|
+
trajectory: AgentTrajectory | None
|
|
148
|
+
detail: str = ""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class AgentAdapter(abc.ABC):
|
|
152
|
+
"""Minimal bring-your-own-agent interface.
|
|
153
|
+
|
|
154
|
+
``run_task`` is the one canonical entrypoint (per CLAUDE.md's "one
|
|
155
|
+
canonical adapter interface" rule) -- concrete subclasses decide *how*
|
|
156
|
+
the agent's chosen action actually gets executed. This pass provides
|
|
157
|
+
exactly one concrete implementation, :class:`SandboxedAgentAdapter`,
|
|
158
|
+
which always executes through the shared `lazycore.sandbox` executor.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
@abc.abstractmethod
|
|
162
|
+
def run_task(self, task: TaskSpec, executor: BaseSandboxExecutor) -> AgentTrajectory:
|
|
163
|
+
"""Run ``task`` via this adapter's agent, executing inside ``executor``.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
task: the task to attempt.
|
|
167
|
+
executor: a `lazycore.sandbox.BaseSandboxExecutor` instance
|
|
168
|
+
(e.g. `lazycore.sandbox.get_default_executor()`) that the
|
|
169
|
+
adapter must use for any containment of the agent's chosen
|
|
170
|
+
action -- this package never builds a second sandbox
|
|
171
|
+
mechanism.
|
|
172
|
+
"""
|
|
173
|
+
raise NotImplementedError
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class SandboxedAgentAdapter(AgentAdapter):
|
|
177
|
+
"""The one concrete :class:`AgentAdapter`: wires an arbitrary agent
|
|
178
|
+
callable's chosen action through the shared sandbox executor.
|
|
179
|
+
|
|
180
|
+
Construction takes ``agent_fn`` (see :data:`AgentFn`) -- this is the
|
|
181
|
+
"bring your own agent" seam. This package supplies exactly one
|
|
182
|
+
reference ``agent_fn`` (``benchcraft_lazyagent.tasks.rule_based_agent``)
|
|
183
|
+
but any callable with the same signature works, including one that
|
|
184
|
+
wraps a real agent framework's decision step.
|
|
185
|
+
"""
|
|
186
|
+
|
|
187
|
+
def __init__(self, agent_fn: AgentFn) -> None:
|
|
188
|
+
"""Wrap ``agent_fn`` -- the "bring your own agent" callable to drive.
|
|
189
|
+
|
|
190
|
+
Args:
|
|
191
|
+
agent_fn: a callable matching :data:`AgentFn` (task in, action
|
|
192
|
+
out). Stored as-is; not validated or wrapped further, so any
|
|
193
|
+
exception it raises during :meth:`run_task` propagates
|
|
194
|
+
unchanged to the caller (`benchcraft_lazyagent.benchmark.run_task`
|
|
195
|
+
is what converts such exceptions into a failed result).
|
|
196
|
+
"""
|
|
197
|
+
self._agent_fn = agent_fn
|
|
198
|
+
|
|
199
|
+
def run_task(self, task: TaskSpec, executor: BaseSandboxExecutor) -> AgentTrajectory:
|
|
200
|
+
"""Ask ``agent_fn`` for one action, run it in ``executor``, and record the trajectory.
|
|
201
|
+
|
|
202
|
+
Builds a three-step :class:`AgentTrajectory` -- a ``user`` step with
|
|
203
|
+
the task description, an ``assistant`` step with the chosen action's
|
|
204
|
+
command/rationale, and a ``tool`` step with the real
|
|
205
|
+
`lazycore.sandbox.SandboxResult` (exit code, whether the policy
|
|
206
|
+
blocked it, stdout/stderr) -- regardless of whether the sandboxed
|
|
207
|
+
command actually succeeded. Scoring the outcome is the caller's
|
|
208
|
+
responsibility (see `benchcraft_lazyagent.tasks.score_file_task`),
|
|
209
|
+
not this method's.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
task: the task to attempt.
|
|
213
|
+
executor: the sandbox executor to run the agent's chosen
|
|
214
|
+
command through, per :meth:`AgentAdapter.run_task`'s
|
|
215
|
+
contract.
|
|
216
|
+
"""
|
|
217
|
+
steps: list[TrajectoryStep] = [
|
|
218
|
+
TrajectoryStep(role="user", content=task.description)
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
action = self._agent_fn(task)
|
|
222
|
+
steps.append(
|
|
223
|
+
TrajectoryStep(
|
|
224
|
+
role="assistant",
|
|
225
|
+
content=f"command={list(action.command)!r} rationale={action.rationale!r}",
|
|
226
|
+
)
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
result = executor.run_command(action.command, policy=task.sandbox_policy)
|
|
230
|
+
steps.append(
|
|
231
|
+
TrajectoryStep(
|
|
232
|
+
role="tool",
|
|
233
|
+
content=(
|
|
234
|
+
f"exit_code={result.exit_code} policy_blocked={result.policy_blocked} "
|
|
235
|
+
f"stdout={result.stdout!r} stderr={result.stderr!r}"
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
return AgentTrajectory(
|
|
241
|
+
task_name=task.name,
|
|
242
|
+
steps=tuple(steps),
|
|
243
|
+
sandbox_result=result,
|
|
244
|
+
)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""A tiny multi-task benchmark runner (architecture doc Part 3, "Module 8: LazyAgent").
|
|
2
|
+
|
|
3
|
+
This is a deliberately minimal stand-in for the full "leaderboard" style
|
|
4
|
+
Multi-Objective Pareto RAG Optimization loop (accuracy vs. latency vs.
|
|
5
|
+
cost) described in Part 3 -- that loop, along with DISCO-style sample
|
|
6
|
+
condensation, is explicitly out of scope for this pass (see README). What
|
|
7
|
+
*is* implemented here: run a small, fixed set of :class:`TaskSpec` variants
|
|
8
|
+
through a :class:`~benchcraft_lazyagent.adapter.SandboxedAgentAdapter`,
|
|
9
|
+
score each one, and aggregate a pass rate + mean wall-clock latency --
|
|
10
|
+
enough to prove the sandboxed benchmark-eval loop end-to-end.
|
|
11
|
+
|
|
12
|
+
Reports each run via `lazycore.telemetry`'s OTel GenAI helpers
|
|
13
|
+
(`genai_span`, `set_ml_metric`, `add_transcript_event`) rather than a
|
|
14
|
+
parallel telemetry/reporting schema, per architecture doc §2.6.
|
|
15
|
+
|
|
16
|
+
**`add_transcript_event` call sites deliberately use the safe-by-default
|
|
17
|
+
metadata-only path** (no ``include_raw_content=True``, no ``sanitizer``).
|
|
18
|
+
This is a conscious choice, not an oversight: per "bring your own agent"
|
|
19
|
+
(see `benchcraft_lazyagent.adapter`), ``agent_fn`` is an arbitrary
|
|
20
|
+
caller-supplied callable, and its proposed command's real stdout/stderr
|
|
21
|
+
(captured in the "tool" trajectory step) can contain whatever that
|
|
22
|
+
command actually printed -- which, for a real agent (not just this
|
|
23
|
+
package's synthetic `rule_based_agent` reference), could include secrets,
|
|
24
|
+
credentials, or other sensitive tool output. Exporting that verbatim into
|
|
25
|
+
an OTel span by default would be exactly the credential/PII-leak risk
|
|
26
|
+
`lazycore.telemetry.add_transcript_event`'s safe-by-default contract
|
|
27
|
+
exists to prevent. A caller who wants full transcript content in their own
|
|
28
|
+
exported traces can wrap/re-emit these spans with ``include_raw_content=True``
|
|
29
|
+
or a ``sanitizer`` at their own telemetry layer; this package does not
|
|
30
|
+
opt in on their behalf.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import time
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
from typing import Callable, Sequence
|
|
38
|
+
|
|
39
|
+
from lazycore.sandbox import BaseSandboxExecutor, get_default_executor
|
|
40
|
+
from lazycore.telemetry import add_transcript_event, genai_span, set_ml_metric
|
|
41
|
+
|
|
42
|
+
from benchcraft_lazyagent.adapter import (
|
|
43
|
+
AgentFn,
|
|
44
|
+
AgentTrajectory,
|
|
45
|
+
SandboxedAgentAdapter,
|
|
46
|
+
TaskResult,
|
|
47
|
+
TaskSpec,
|
|
48
|
+
)
|
|
49
|
+
from benchcraft_lazyagent.tasks import score_file_task
|
|
50
|
+
|
|
51
|
+
__all__ = ["BenchmarkReport", "ScorerFn", "run_benchmark", "run_task"]
|
|
52
|
+
|
|
53
|
+
#: A scorer inspects the task and its recorded trajectory (which includes
|
|
54
|
+
#: the real `lazycore.sandbox.SandboxResult`) and returns
|
|
55
|
+
#: ``(success, human_readable_detail)``. `benchcraft_lazyagent.tasks.score_file_task`
|
|
56
|
+
#: is the one reference scorer this package ships, matching the one task
|
|
57
|
+
#: family it implements.
|
|
58
|
+
ScorerFn = Callable[[TaskSpec, AgentTrajectory], tuple[bool, str]]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class BenchmarkReport:
|
|
63
|
+
"""Aggregate result of running a small task suite through the benchmark loop."""
|
|
64
|
+
|
|
65
|
+
results: tuple[TaskResult, ...]
|
|
66
|
+
pass_rate: float
|
|
67
|
+
mean_latency_seconds: float
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def task_count(self) -> int:
|
|
71
|
+
"""Number of tasks included in this report (``len(self.results)``)."""
|
|
72
|
+
return len(self.results)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def run_task(
|
|
76
|
+
task: TaskSpec,
|
|
77
|
+
*,
|
|
78
|
+
agent_fn: AgentFn,
|
|
79
|
+
executor: BaseSandboxExecutor,
|
|
80
|
+
scorer: ScorerFn = score_file_task,
|
|
81
|
+
) -> TaskResult:
|
|
82
|
+
"""Run one task through a fresh :class:`SandboxedAgentAdapter` and score it.
|
|
83
|
+
|
|
84
|
+
Latency is measured via ``time.perf_counter()`` around the adapter's
|
|
85
|
+
``run_task`` call only (the actual agent-decide + sandboxed-execute
|
|
86
|
+
work), not around scoring/telemetry overhead.
|
|
87
|
+
|
|
88
|
+
This is the **one** per-task exception boundary for the whole benchmark
|
|
89
|
+
loop (`run_benchmark` deliberately does not add a second one around its
|
|
90
|
+
call to this function -- see that function's docstring). Any exception
|
|
91
|
+
raised by ``agent_fn`` (invoked indirectly via ``adapter.run_task``),
|
|
92
|
+
by ``adapter.run_task`` itself (e.g. a sandbox executor raising
|
|
93
|
+
`lazycore.sandbox.SandboxPolicyViolationError` for fail-fast policy
|
|
94
|
+
violations, or `lazycore.sandbox.SandboxBackendUnavailableError`), or
|
|
95
|
+
by ``scorer`` is caught here and converted into a failed
|
|
96
|
+
:class:`TaskResult` rather than propagating out and aborting every
|
|
97
|
+
other task in the suite. ``KeyboardInterrupt``/``SystemExit`` are
|
|
98
|
+
intentionally *not* caught (``except Exception``, not
|
|
99
|
+
``except BaseException``) -- a user's Ctrl-C or an explicit process
|
|
100
|
+
exit should still stop the run.
|
|
101
|
+
|
|
102
|
+
On such a failure, ``latency_seconds`` records wall-clock time up to
|
|
103
|
+
the point of failure (not ``NaN``/``0``) so `run_benchmark`'s mean-
|
|
104
|
+
latency aggregation stays a meaningful number rather than needing
|
|
105
|
+
special-casing, and ``trajectory`` is ``None`` if the failure happened
|
|
106
|
+
before ``adapter.run_task`` returned one (e.g. ``agent_fn`` itself
|
|
107
|
+
raised), or the real trajectory if only ``scorer`` raised afterwards.
|
|
108
|
+
"""
|
|
109
|
+
adapter = SandboxedAgentAdapter(agent_fn)
|
|
110
|
+
|
|
111
|
+
start = time.perf_counter()
|
|
112
|
+
trajectory: AgentTrajectory | None = None
|
|
113
|
+
try:
|
|
114
|
+
trajectory = adapter.run_task(task, executor)
|
|
115
|
+
latency_seconds = time.perf_counter() - start
|
|
116
|
+
success, detail = scorer(task, trajectory)
|
|
117
|
+
except Exception as exc: # noqa: BLE001 - deliberate: one task's failure must not crash the suite.
|
|
118
|
+
latency_seconds = time.perf_counter() - start
|
|
119
|
+
success = False
|
|
120
|
+
detail = f"task raised {type(exc).__name__}: {exc}"
|
|
121
|
+
|
|
122
|
+
with genai_span(
|
|
123
|
+
f"lazyagent.task.{task.name}",
|
|
124
|
+
attributes={"lazyagent.task.name": task.name},
|
|
125
|
+
) as span:
|
|
126
|
+
set_ml_metric(span, "accuracy", 0.0)
|
|
127
|
+
span.set_attribute("lazyagent.task.latency_seconds", latency_seconds)
|
|
128
|
+
span.set_attribute("lazyagent.task.detail", detail)
|
|
129
|
+
span.set_attribute("lazyagent.task.error", True)
|
|
130
|
+
if trajectory is not None:
|
|
131
|
+
for step in trajectory.steps:
|
|
132
|
+
add_transcript_event(span, step.role, step.content)
|
|
133
|
+
add_transcript_event(span, "error", detail)
|
|
134
|
+
|
|
135
|
+
return TaskResult(
|
|
136
|
+
task_name=task.name,
|
|
137
|
+
success=False,
|
|
138
|
+
latency_seconds=latency_seconds,
|
|
139
|
+
trajectory=trajectory,
|
|
140
|
+
detail=detail,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
with genai_span(
|
|
144
|
+
f"lazyagent.task.{task.name}",
|
|
145
|
+
attributes={"lazyagent.task.name": task.name},
|
|
146
|
+
) as span:
|
|
147
|
+
set_ml_metric(span, "accuracy", 1.0 if success else 0.0)
|
|
148
|
+
span.set_attribute("lazyagent.task.latency_seconds", latency_seconds)
|
|
149
|
+
span.set_attribute("lazyagent.task.detail", detail)
|
|
150
|
+
for step in trajectory.steps:
|
|
151
|
+
add_transcript_event(span, step.role, step.content)
|
|
152
|
+
|
|
153
|
+
return TaskResult(
|
|
154
|
+
task_name=task.name,
|
|
155
|
+
success=success,
|
|
156
|
+
latency_seconds=latency_seconds,
|
|
157
|
+
trajectory=trajectory,
|
|
158
|
+
detail=detail,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def run_benchmark(
|
|
163
|
+
tasks: Sequence[TaskSpec],
|
|
164
|
+
agent_fn: AgentFn,
|
|
165
|
+
*,
|
|
166
|
+
executor: BaseSandboxExecutor | None = None,
|
|
167
|
+
scorer: ScorerFn = score_file_task,
|
|
168
|
+
) -> BenchmarkReport:
|
|
169
|
+
"""Run ``tasks`` through ``agent_fn`` and report an aggregate pass rate + mean latency.
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
tasks: the small, fixed task suite to run (e.g.
|
|
173
|
+
`benchcraft_lazyagent.tasks.default_task_suite`).
|
|
174
|
+
agent_fn: the bring-your-own-agent callable (see
|
|
175
|
+
`benchcraft_lazyagent.adapter.AgentFn`).
|
|
176
|
+
executor: a `lazycore.sandbox.BaseSandboxExecutor`; defaults to
|
|
177
|
+
`lazycore.sandbox.get_default_executor()` (Seatbelt on macOS).
|
|
178
|
+
scorer: how to score each task's trajectory; defaults to
|
|
179
|
+
`benchcraft_lazyagent.tasks.score_file_task`, matching the one
|
|
180
|
+
task family this package implements.
|
|
181
|
+
|
|
182
|
+
A task whose ``agent_fn``/``adapter.run_task``/``scorer`` raises does
|
|
183
|
+
not abort the run: `run_task` is the one per-task exception boundary
|
|
184
|
+
(see its docstring) and converts such a failure into a failed
|
|
185
|
+
:class:`~benchcraft_lazyagent.adapter.TaskResult` before it ever
|
|
186
|
+
reaches this loop, so this function never needs a second try/except
|
|
187
|
+
around its call to `run_task`. That failed result's ``success=False``
|
|
188
|
+
is counted in ``pass_rate``'s denominator like any other failure, and
|
|
189
|
+
its ``latency_seconds`` (wall-clock time up to the point of failure)
|
|
190
|
+
is included in ``mean_latency_seconds`` like any other task's --
|
|
191
|
+
deliberately not excluded or treated as ``0``/``NaN``, so the
|
|
192
|
+
aggregate mean latency stays a single well-defined number regardless
|
|
193
|
+
of how many tasks in the suite raised.
|
|
194
|
+
"""
|
|
195
|
+
if not tasks:
|
|
196
|
+
raise ValueError("run_benchmark requires at least one task")
|
|
197
|
+
|
|
198
|
+
active_executor = executor or get_default_executor()
|
|
199
|
+
|
|
200
|
+
results = tuple(
|
|
201
|
+
run_task(task, agent_fn=agent_fn, executor=active_executor, scorer=scorer)
|
|
202
|
+
for task in tasks
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
pass_rate = sum(1 for r in results if r.success) / len(results)
|
|
206
|
+
mean_latency_seconds = sum(r.latency_seconds for r in results) / len(results)
|
|
207
|
+
|
|
208
|
+
with genai_span("lazyagent.benchmark.run") as span:
|
|
209
|
+
set_ml_metric(span, "accuracy", pass_rate)
|
|
210
|
+
span.set_attribute("lazyagent.benchmark.task_count", len(results))
|
|
211
|
+
span.set_attribute("lazyagent.benchmark.mean_latency_seconds", mean_latency_seconds)
|
|
212
|
+
|
|
213
|
+
return BenchmarkReport(
|
|
214
|
+
results=results,
|
|
215
|
+
pass_rate=pass_rate,
|
|
216
|
+
mean_latency_seconds=mean_latency_seconds,
|
|
217
|
+
)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""The one concrete benchmark task family: file-manipulation tool-use.
|
|
2
|
+
|
|
3
|
+
Per README "Scope", this pass implements exactly one task family -- create
|
|
4
|
+
a file with expected content in a sandboxed working directory -- rather
|
|
5
|
+
than a general task-description schema or a SWE-bench-style suite. Two
|
|
6
|
+
task variants are provided:
|
|
7
|
+
|
|
8
|
+
- :func:`make_pass_task` -- the agent's write target is inside the
|
|
9
|
+
sandbox's ``allowed_write_paths``. The rule-based reference agent
|
|
10
|
+
(:func:`rule_based_agent`) issues a shell command to create the file, the
|
|
11
|
+
shared `lazycore.sandbox` executor allows it, and the task scores as a
|
|
12
|
+
success.
|
|
13
|
+
- :func:`make_fail_task` -- the agent (deliberately, to exercise
|
|
14
|
+
containment) attempts to write *outside* ``allowed_write_paths``. The
|
|
15
|
+
shared sandbox executor's default-deny write policy blocks this, the
|
|
16
|
+
file is never created, and the task scores as a failure -- proving the
|
|
17
|
+
sandbox's containment genuinely drives the scored outcome, not just
|
|
18
|
+
decoration around it.
|
|
19
|
+
|
|
20
|
+
:func:`rule_based_agent` is the one reference "agent" callable this package
|
|
21
|
+
ships: a plain, deterministic Python function (not an LLM, not a real
|
|
22
|
+
framework) that reads a :class:`~benchcraft_lazyagent.adapter.FileTaskSpec`
|
|
23
|
+
and decides on a shell command satisfying (or, for the fail variant,
|
|
24
|
+
attempting to satisfy) the task description. Its signature matches
|
|
25
|
+
:data:`~benchcraft_lazyagent.adapter.AgentFn` exactly, so a caller could
|
|
26
|
+
swap in a real framework's decision function without changing
|
|
27
|
+
`benchcraft_lazyagent.adapter` or `benchcraft_lazyagent.benchmark` at all.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import shlex
|
|
33
|
+
import tempfile
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
from lazycore.sandbox import SandboxPolicy
|
|
38
|
+
|
|
39
|
+
from benchcraft_lazyagent.adapter import AgentAction, AgentTrajectory, TaskSpec
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"FileTaskSpec",
|
|
43
|
+
"default_task_suite",
|
|
44
|
+
"make_fail_task",
|
|
45
|
+
"make_pass_task",
|
|
46
|
+
"rule_based_agent",
|
|
47
|
+
"score_file_task",
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class FileTaskSpec(TaskSpec):
|
|
53
|
+
"""A :class:`TaskSpec` for the file-manipulation task family.
|
|
54
|
+
|
|
55
|
+
Attributes:
|
|
56
|
+
target_path: Absolute path of the file the agent is asked to
|
|
57
|
+
create. For :func:`make_pass_task` this is inside the sandbox
|
|
58
|
+
policy's ``allowed_write_paths``; for :func:`make_fail_task` it
|
|
59
|
+
is deliberately outside that allowlist.
|
|
60
|
+
expected_content: Exact file content required for the task to
|
|
61
|
+
score as a success.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
target_path: str = ""
|
|
65
|
+
expected_content: str = ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def rule_based_agent(task: TaskSpec) -> AgentAction:
|
|
69
|
+
"""The one reference "bring your own agent" callable this package ships.
|
|
70
|
+
|
|
71
|
+
Deliberately simple and deterministic: it does not parse
|
|
72
|
+
``task.description`` with any NLP -- it reads the structured fields off
|
|
73
|
+
a :class:`FileTaskSpec` directly and always proposes the same kind of
|
|
74
|
+
shell command (``mkdir -p`` the parent directory, then write the exact
|
|
75
|
+
expected content via ``printf``). This is intentional -- the point of
|
|
76
|
+
this scaffold is to exercise the sandbox-wiring and scoring loop, not
|
|
77
|
+
to build a capable agent. Whether the command actually succeeds is
|
|
78
|
+
entirely up to the sandbox policy the task executes under (see
|
|
79
|
+
``benchcraft_lazyagent.tasks`` module docstring).
|
|
80
|
+
|
|
81
|
+
Raises:
|
|
82
|
+
TypeError: if ``task`` is not a :class:`FileTaskSpec` -- this
|
|
83
|
+
reference agent only knows how to handle this one task family.
|
|
84
|
+
"""
|
|
85
|
+
if not isinstance(task, FileTaskSpec):
|
|
86
|
+
raise TypeError(
|
|
87
|
+
f"rule_based_agent only supports FileTaskSpec tasks, got {type(task).__name__}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
quoted_content = shlex.quote(task.expected_content)
|
|
91
|
+
quoted_path = shlex.quote(task.target_path)
|
|
92
|
+
parent_dir = shlex.quote(str(Path(task.target_path).parent))
|
|
93
|
+
shell_script = f"mkdir -p {parent_dir} && printf '%s' {quoted_content} > {quoted_path}"
|
|
94
|
+
|
|
95
|
+
return AgentAction(
|
|
96
|
+
command=["/bin/sh", "-c", shell_script],
|
|
97
|
+
rationale=f"write expected content to {task.target_path!r} via a shell one-liner",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def score_file_task(task: TaskSpec, trajectory: AgentTrajectory) -> tuple[bool, str]:
|
|
102
|
+
"""Score a :class:`FileTaskSpec` run by checking the real filesystem.
|
|
103
|
+
|
|
104
|
+
Success requires the target file to actually exist *and* contain
|
|
105
|
+
exactly ``task.expected_content`` -- deliberately re-checking the real
|
|
106
|
+
filesystem rather than trusting the sandbox's reported exit code, so
|
|
107
|
+
that a sandbox-blocked write (nonzero exit, or a zero exit that still
|
|
108
|
+
didn't actually create the file) is scored as a failure regardless of
|
|
109
|
+
what the agent or shell reported.
|
|
110
|
+
"""
|
|
111
|
+
if not isinstance(task, FileTaskSpec):
|
|
112
|
+
raise TypeError(f"score_file_task only supports FileTaskSpec, got {type(task).__name__}")
|
|
113
|
+
|
|
114
|
+
path = Path(task.target_path)
|
|
115
|
+
if not path.is_file():
|
|
116
|
+
return False, f"expected file {task.target_path!r} does not exist"
|
|
117
|
+
|
|
118
|
+
actual_content = path.read_text(encoding="utf-8")
|
|
119
|
+
if actual_content != task.expected_content:
|
|
120
|
+
return False, (
|
|
121
|
+
f"file {task.target_path!r} exists but content mismatch: "
|
|
122
|
+
f"expected {task.expected_content!r}, got {actual_content!r}"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return True, f"file {task.target_path!r} created with expected content"
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _fresh_task_root(base_dir: Path, name: str) -> Path:
|
|
129
|
+
"""Allocate a genuinely fresh, unique task-scoped directory under ``base_dir``.
|
|
130
|
+
|
|
131
|
+
Regression fix (CodeRabbit): the previous implementation derived the
|
|
132
|
+
task root deterministically as ``base_dir / name``. Re-invoking a
|
|
133
|
+
factory (:func:`make_pass_task`/:func:`make_fail_task`) with the same
|
|
134
|
+
``base_dir``/``name`` pair -- e.g. re-running a benchmark suite against
|
|
135
|
+
a persistent workspace directory instead of a brand-new
|
|
136
|
+
`tempfile.TemporaryDirectory` each time -- would silently reuse
|
|
137
|
+
whatever directory (and any stale files inside it) a prior run left
|
|
138
|
+
behind. Since :func:`score_file_task` scores by trusting real
|
|
139
|
+
filesystem state, a stale target file from a previous run could make a
|
|
140
|
+
task falsely "pass" without this run's agent ever having created it,
|
|
141
|
+
and could also mask a real containment regression in the fail-designed
|
|
142
|
+
task (a stale ``forbidden/`` directory from an old, unpatched sandbox
|
|
143
|
+
run could linger even after containment is fixed).
|
|
144
|
+
|
|
145
|
+
``tempfile.mkdtemp`` guarantees a new, unique directory on every call
|
|
146
|
+
(even for repeated ``base_dir``/``name`` pairs), so each task instance
|
|
147
|
+
always starts from a genuinely empty workspace.
|
|
148
|
+
"""
|
|
149
|
+
base_dir = Path(base_dir)
|
|
150
|
+
base_dir.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
return Path(tempfile.mkdtemp(prefix=f"{name}-", dir=str(base_dir)))
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def make_pass_task(base_dir: Path, *, name: str = "create_file_pass") -> FileTaskSpec:
|
|
155
|
+
"""A task the sandbox should allow: write inside ``allowed_write_paths``.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
base_dir: A directory this task family creates its own
|
|
159
|
+
task-scoped subdirectory under (the caller typically passes a
|
|
160
|
+
fresh `tempfile.TemporaryDirectory` path). Each call gets a
|
|
161
|
+
fresh, unique subdirectory (see :func:`_fresh_task_root`) even
|
|
162
|
+
if ``base_dir``/``name`` are reused across calls.
|
|
163
|
+
"""
|
|
164
|
+
task_root = _fresh_task_root(base_dir, name)
|
|
165
|
+
workspace = task_root / "workspace"
|
|
166
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
|
|
168
|
+
target_path = workspace / "hello.txt"
|
|
169
|
+
expected_content = "hello from benchcraft lazyagent"
|
|
170
|
+
|
|
171
|
+
policy = SandboxPolicy(
|
|
172
|
+
allow_network=False,
|
|
173
|
+
allowed_write_paths=(str(workspace),),
|
|
174
|
+
working_directory=str(workspace),
|
|
175
|
+
timeout_seconds=15.0,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
return FileTaskSpec(
|
|
179
|
+
name=name,
|
|
180
|
+
description=(
|
|
181
|
+
f"Create a file named {target_path.name!r} with content "
|
|
182
|
+
f"{expected_content!r} in the sandboxed working directory."
|
|
183
|
+
),
|
|
184
|
+
sandbox_policy=policy,
|
|
185
|
+
expect_success=True,
|
|
186
|
+
target_path=str(target_path),
|
|
187
|
+
expected_content=expected_content,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def make_fail_task(base_dir: Path, *, name: str = "create_file_escape_fail") -> FileTaskSpec:
|
|
192
|
+
"""A task designed to fail: the target path is outside ``allowed_write_paths``.
|
|
193
|
+
|
|
194
|
+
This exists specifically to prove sandbox containment is exercised and
|
|
195
|
+
genuinely affects the scored outcome (README acceptance criteria): the
|
|
196
|
+
rule-based agent attempts to write to ``forbidden/escape.txt``, a
|
|
197
|
+
sibling directory of the sandbox's allowed workspace that is *not*
|
|
198
|
+
included in ``allowed_write_paths``. The shared sandbox executor's
|
|
199
|
+
default-deny write policy should block the write, the file should
|
|
200
|
+
never be created, and :func:`score_file_task` should therefore score
|
|
201
|
+
this task as a failure. Like :func:`make_pass_task`, each call gets a
|
|
202
|
+
fresh, unique task root (see :func:`_fresh_task_root`) so a stale
|
|
203
|
+
``forbidden/`` directory from a previous run can never linger into
|
|
204
|
+
this run's containment check.
|
|
205
|
+
"""
|
|
206
|
+
task_root = _fresh_task_root(base_dir, name)
|
|
207
|
+
workspace = task_root / "workspace"
|
|
208
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
209
|
+
forbidden_dir = task_root / "forbidden" # deliberately NOT in allowed_write_paths
|
|
210
|
+
|
|
211
|
+
target_path = forbidden_dir / "escape.txt"
|
|
212
|
+
expected_content = "this should never be written"
|
|
213
|
+
|
|
214
|
+
policy = SandboxPolicy(
|
|
215
|
+
allow_network=False,
|
|
216
|
+
allowed_write_paths=(str(workspace),), # forbidden_dir is intentionally excluded
|
|
217
|
+
working_directory=str(workspace),
|
|
218
|
+
timeout_seconds=15.0,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return FileTaskSpec(
|
|
222
|
+
name=name,
|
|
223
|
+
description=(
|
|
224
|
+
f"Create a file at {str(target_path)!r} with content "
|
|
225
|
+
f"{expected_content!r} -- outside the sandboxed working "
|
|
226
|
+
"directory (a sandbox-escape attempt used to prove containment)."
|
|
227
|
+
),
|
|
228
|
+
sandbox_policy=policy,
|
|
229
|
+
expect_success=False,
|
|
230
|
+
target_path=str(target_path),
|
|
231
|
+
expected_content=expected_content,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def default_task_suite(base_dir: Path) -> list[FileTaskSpec]:
|
|
236
|
+
"""The small, fixed set of task variants the benchmark runner exercises by default."""
|
|
237
|
+
return [make_pass_task(base_dir), make_fail_task(base_dir)]
|