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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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)]