grudge-cli 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.
- examples/__init__.py +1 -0
- examples/leaky_agent/__init__.py +1 -0
- examples/leaky_agent/agent.py +250 -0
- examples/leaky_agent/agent_fixed.py +183 -0
- examples/leaky_agent/tools.py +153 -0
- grudge/__init__.py +5 -0
- grudge/__main__.py +6 -0
- grudge/_stubs.py +8 -0
- grudge/admission/__init__.py +6 -0
- grudge/admission/court.py +239 -0
- grudge/admission/verdict.py +26 -0
- grudge/cli/__init__.py +1 -0
- grudge/cli/_common.py +126 -0
- grudge/cli/admit.py +93 -0
- grudge/cli/capture.py +39 -0
- grudge/cli/compile.py +37 -0
- grudge/cli/demo.py +136 -0
- grudge/cli/main.py +54 -0
- grudge/cli/run.py +60 -0
- grudge/cli/scars.py +91 -0
- grudge/cli/shrink.py +108 -0
- grudge/config.py +35 -0
- grudge/demo/__init__.py +1 -0
- grudge/demo/events.py +228 -0
- grudge/demo/orchestrator.py +326 -0
- grudge/demo/server.py +222 -0
- grudge/errors.py +25 -0
- grudge/fuzzer/__init__.py +1 -0
- grudge/fuzzer/fingerprint_guard.py +10 -0
- grudge/fuzzer/mutators.py +10 -0
- grudge/gate/__init__.py +5 -0
- grudge/gate/report.py +125 -0
- grudge/gate/runner.py +323 -0
- grudge/gate/selector.py +10 -0
- grudge/gate/wilson.py +36 -0
- grudge/guard/__init__.py +5 -0
- grudge/guard/compile.py +251 -0
- grudge/guard/import_session.py +106 -0
- grudge/integrations/__init__.py +1 -0
- grudge/integrations/github_comment.py +10 -0
- grudge/integrations/pytest_plugin.py +10 -0
- grudge/llm/__init__.py +6 -0
- grudge/llm/cache.py +92 -0
- grudge/llm/client.py +82 -0
- grudge/locator/__init__.py +1 -0
- grudge/locator/fork.py +10 -0
- grudge/locator/point_of_no_return.py +10 -0
- grudge/predicates/__init__.py +25 -0
- grudge/predicates/call_shape.py +116 -0
- grudge/predicates/compiler.py +116 -0
- grudge/predicates/dsl.py +295 -0
- grudge/predicates/fallback_rules.py +10 -0
- grudge/predicates/ordering.py +33 -0
- grudge/predicates/outcome.py +14 -0
- grudge/predicates/prompts/synthesize.md +15 -0
- grudge/predicates/synthesizer.py +127 -0
- grudge/predicates/taint_flow.py +84 -0
- grudge/recorder/__init__.py +5 -0
- grudge/recorder/adapters/__init__.py +1 -0
- grudge/recorder/adapters/langgraph.py +8 -0
- grudge/recorder/adapters/mcp_proxy.py +8 -0
- grudge/recorder/adapters/raw_sdk.py +159 -0
- grudge/recorder/recorder.py +374 -0
- grudge/recorder/redact.py +105 -0
- grudge/registry/__init__.py +1 -0
- grudge/registry/db.py +10 -0
- grudge/registry/fingerprint.py +10 -0
- grudge/registry/flakiness.py +10 -0
- grudge/registry/retirement.py +10 -0
- grudge/replay/__init__.py +19 -0
- grudge/replay/cassette_player.py +159 -0
- grudge/replay/sandbox.py +340 -0
- grudge/replay/scripted_llm.py +382 -0
- grudge/schema/__init__.py +45 -0
- grudge/schema/cassette.py +206 -0
- grudge/schema/predicate.py +263 -0
- grudge/schema/scar.py +113 -0
- grudge/schema/trace.py +243 -0
- grudge/shrinker/__init__.py +12 -0
- grudge/shrinker/cassette_builder.py +328 -0
- grudge/shrinker/ddmin.py +243 -0
- grudge/shrinker/oracle.py +230 -0
- grudge/shrinker/reducers.py +331 -0
- grudge_cli-0.1.0.dist-info/LICENSE +21 -0
- grudge_cli-0.1.0.dist-info/METADATA +313 -0
- grudge_cli-0.1.0.dist-info/RECORD +89 -0
- grudge_cli-0.1.0.dist-info/WHEEL +5 -0
- grudge_cli-0.1.0.dist-info/entry_points.txt +2 -0
- grudge_cli-0.1.0.dist-info/top_level.txt +2 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Replay stage package for hermetic cassette-backed execution."""
|
|
2
|
+
|
|
3
|
+
from grudge.replay.cassette_player import CassettePlayer, ReplayMiss, play_cassette
|
|
4
|
+
from grudge.replay.sandbox import (
|
|
5
|
+
CassetteToolAdapter,
|
|
6
|
+
CassetteWorld,
|
|
7
|
+
run_agent,
|
|
8
|
+
world_from_trace,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"CassettePlayer",
|
|
13
|
+
"CassetteToolAdapter",
|
|
14
|
+
"CassetteWorld",
|
|
15
|
+
"ReplayMiss",
|
|
16
|
+
"play_cassette",
|
|
17
|
+
"run_agent",
|
|
18
|
+
"world_from_trace",
|
|
19
|
+
]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Phase 4 cassette-backed tool layer for deterministic replay."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Callable, Mapping
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from functools import partial
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from grudge.errors import ReplayMismatch
|
|
12
|
+
from grudge.schema.cassette import Cassette, CassetteEntry
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class ReplayMiss:
|
|
19
|
+
"""A tool invocation for which the cassette had no available response."""
|
|
20
|
+
|
|
21
|
+
tool: str
|
|
22
|
+
args: dict[str, Any]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CassettePlayer:
|
|
26
|
+
"""Serve each matching cassette response at most once.
|
|
27
|
+
|
|
28
|
+
Entries are selected by tool name, then by matcher precedence (``exact``,
|
|
29
|
+
``subset``, ``any``), with cassette order as the deterministic tie-breaker.
|
|
30
|
+
The player is callable and also exposes ``call``/``invoke`` plus bound
|
|
31
|
+
tool functions through :meth:`tool` and :attr:`tools`.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, cassette: Cassette, *, strict: bool = True) -> None:
|
|
35
|
+
self.cassette = cassette
|
|
36
|
+
self.strict = strict
|
|
37
|
+
self._consumed_indices: list[int] = []
|
|
38
|
+
self._misses: list[ReplayMiss] = []
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def consumed_indices(self) -> tuple[int, ...]:
|
|
42
|
+
"""Return cassette indexes in the order their entries were consumed."""
|
|
43
|
+
|
|
44
|
+
return tuple(self._consumed_indices)
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def consumed_entries(self) -> tuple[CassetteEntry, ...]:
|
|
48
|
+
"""Return consumed entries in invocation order."""
|
|
49
|
+
|
|
50
|
+
return tuple(self.cassette.world[index] for index in self._consumed_indices)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def consumed(self) -> tuple[CassetteEntry, ...]:
|
|
54
|
+
"""Compatibility alias for :attr:`consumed_entries`."""
|
|
55
|
+
|
|
56
|
+
return self.consumed_entries
|
|
57
|
+
|
|
58
|
+
@property
|
|
59
|
+
def remaining_entries(self) -> tuple[CassetteEntry, ...]:
|
|
60
|
+
"""Return entries that have not yet been consumed."""
|
|
61
|
+
|
|
62
|
+
consumed = set(self._consumed_indices)
|
|
63
|
+
return tuple(
|
|
64
|
+
entry
|
|
65
|
+
for index, entry in enumerate(self.cassette.world)
|
|
66
|
+
if index not in consumed
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def misses(self) -> tuple[ReplayMiss, ...]:
|
|
71
|
+
"""Return recorded misses in invocation order."""
|
|
72
|
+
|
|
73
|
+
return tuple(self._misses)
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def miss_log(self) -> tuple[ReplayMiss, ...]:
|
|
77
|
+
"""Compatibility alias for :attr:`misses`."""
|
|
78
|
+
|
|
79
|
+
return self.misses
|
|
80
|
+
|
|
81
|
+
def call(
|
|
82
|
+
self,
|
|
83
|
+
tool: str,
|
|
84
|
+
args: Mapping[str, Any] | None = None,
|
|
85
|
+
**keyword_args: Any,
|
|
86
|
+
) -> Any:
|
|
87
|
+
"""Invoke a cassette-backed tool.
|
|
88
|
+
|
|
89
|
+
Arguments may be supplied as one mapping or as keyword arguments. A
|
|
90
|
+
matching entry is consumed before its response is returned.
|
|
91
|
+
"""
|
|
92
|
+
|
|
93
|
+
if args is not None and keyword_args:
|
|
94
|
+
raise TypeError("provide tool arguments as a mapping or keywords, not both")
|
|
95
|
+
actual_args = dict(args) if args is not None else dict(keyword_args)
|
|
96
|
+
|
|
97
|
+
for kind in ("exact", "subset", "any"):
|
|
98
|
+
for index, entry in enumerate(self.cassette.world):
|
|
99
|
+
if index in self._consumed_indices:
|
|
100
|
+
continue
|
|
101
|
+
if entry.tool != tool or entry.match.kind != kind:
|
|
102
|
+
continue
|
|
103
|
+
if entry.match.matches(actual_args):
|
|
104
|
+
self._consumed_indices.append(index)
|
|
105
|
+
return entry.response
|
|
106
|
+
|
|
107
|
+
miss = ReplayMiss(tool=tool, args=actual_args)
|
|
108
|
+
self._misses.append(miss)
|
|
109
|
+
message = f"no cassette response for tool {tool!r} with args {actual_args!r}"
|
|
110
|
+
if self.strict:
|
|
111
|
+
raise ReplayMismatch(message)
|
|
112
|
+
logger.warning("cassette replay miss: %s", message)
|
|
113
|
+
return {}
|
|
114
|
+
|
|
115
|
+
def invoke(
|
|
116
|
+
self,
|
|
117
|
+
tool: str,
|
|
118
|
+
args: Mapping[str, Any] | None = None,
|
|
119
|
+
**keyword_args: Any,
|
|
120
|
+
) -> Any:
|
|
121
|
+
"""Alias for :meth:`call` suitable for tool-dispatch loops."""
|
|
122
|
+
|
|
123
|
+
return self.call(tool, args, **keyword_args)
|
|
124
|
+
|
|
125
|
+
def __call__(
|
|
126
|
+
self,
|
|
127
|
+
tool: str,
|
|
128
|
+
args: Mapping[str, Any] | None = None,
|
|
129
|
+
**keyword_args: Any,
|
|
130
|
+
) -> Any:
|
|
131
|
+
"""Alias allowing a player to be used directly as a tool dispatcher."""
|
|
132
|
+
|
|
133
|
+
return self.call(tool, args, **keyword_args)
|
|
134
|
+
|
|
135
|
+
def tool(self, tool: str) -> Callable[..., Any]:
|
|
136
|
+
"""Return a callable bound to one cassette tool name."""
|
|
137
|
+
|
|
138
|
+
return partial(self.call, tool)
|
|
139
|
+
|
|
140
|
+
def get_tool(self, tool: str) -> Callable[..., Any]:
|
|
141
|
+
"""Compatibility alias for :meth:`tool`."""
|
|
142
|
+
|
|
143
|
+
return self.tool(tool)
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def tools(self) -> dict[str, Callable[..., Any]]:
|
|
147
|
+
"""Return bound callables for every tool named in the cassette."""
|
|
148
|
+
|
|
149
|
+
names = dict.fromkeys(entry.tool for entry in self.cassette.world)
|
|
150
|
+
return {name: self.tool(name) for name in names}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def play_cassette(cassette: Cassette, *, strict: bool = True) -> CassettePlayer:
|
|
154
|
+
"""Build a cassette player through the legacy factory name."""
|
|
155
|
+
|
|
156
|
+
return CassettePlayer(cassette, strict=strict)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
__all__ = ["CassettePlayer", "ReplayMiss", "play_cassette"]
|
grudge/replay/sandbox.py
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
"""Hermetic replay of an agent against an in-memory cassette world.
|
|
2
|
+
|
|
3
|
+
The sandbox keeps the policy live while replacing every tool boundary with a
|
|
4
|
+
frozen cassette response. It never constructs the example world, and its
|
|
5
|
+
failure path always produces a valid :class:`~grudge.schema.trace.Trace`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import builtins
|
|
11
|
+
import inspect
|
|
12
|
+
import io
|
|
13
|
+
import os
|
|
14
|
+
import socket
|
|
15
|
+
import threading
|
|
16
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
17
|
+
from contextlib import contextmanager
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, NoReturn
|
|
21
|
+
|
|
22
|
+
from grudge.errors import ReplayMismatch
|
|
23
|
+
from grudge.schema.cassette import ArgMatcher, Cassette, CassetteEntry
|
|
24
|
+
from grudge.schema.trace import StateDiff, Step, ToolCall, ToolResult, Trace
|
|
25
|
+
|
|
26
|
+
_EPOCH = datetime(2026, 1, 1, tzinfo=UTC)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class CassetteWorld:
|
|
30
|
+
"""Expose cassette entries as methods compatible with ``_invoke_tool``.
|
|
31
|
+
|
|
32
|
+
Entries are consumed at most once. Among unconsumed entries, exact
|
|
33
|
+
matchers win over subset matchers, which win over wildcard matchers; ties
|
|
34
|
+
retain cassette order. In lenient mode a miss is recorded and returns an
|
|
35
|
+
empty mapping instead of raising.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, cassette: Cassette, *, strict: bool = True) -> None:
|
|
39
|
+
self.cassette = cassette
|
|
40
|
+
self.strict = strict
|
|
41
|
+
self._consumed: set[int] = set()
|
|
42
|
+
self._misses: list[tuple[str, dict[str, Any]]] = []
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def consumed(self) -> tuple[CassetteEntry, ...]:
|
|
46
|
+
"""Return consumed entries in cassette order."""
|
|
47
|
+
|
|
48
|
+
return tuple(
|
|
49
|
+
entry
|
|
50
|
+
for index, entry in enumerate(self.cassette.world)
|
|
51
|
+
if index in self._consumed
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def consumed_indexes(self) -> frozenset[int]:
|
|
56
|
+
"""Return the indexes of entries served by this world."""
|
|
57
|
+
|
|
58
|
+
return frozenset(self._consumed)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def misses(self) -> tuple[tuple[str, dict[str, Any]], ...]:
|
|
62
|
+
"""Return unmatched calls observed in lenient mode."""
|
|
63
|
+
|
|
64
|
+
return tuple((tool, dict(args)) for tool, args in self._misses)
|
|
65
|
+
|
|
66
|
+
def call(self, tool: str, args: Mapping[str, Any] | None = None) -> Any:
|
|
67
|
+
"""Serve one frozen response for ``tool`` and ``args``."""
|
|
68
|
+
|
|
69
|
+
actual = dict(args or {})
|
|
70
|
+
candidates = [
|
|
71
|
+
(index, entry)
|
|
72
|
+
for index, entry in enumerate(self.cassette.world)
|
|
73
|
+
if index not in self._consumed
|
|
74
|
+
and entry.tool == tool
|
|
75
|
+
and entry.match.matches(actual)
|
|
76
|
+
]
|
|
77
|
+
priorities = {"exact": 0, "subset": 1, "any": 2}
|
|
78
|
+
if candidates:
|
|
79
|
+
index, entry = min(
|
|
80
|
+
candidates,
|
|
81
|
+
key=lambda item: (
|
|
82
|
+
priorities[item[1].match.kind],
|
|
83
|
+
item[1].order_hint if item[1].order_hint is not None else item[0],
|
|
84
|
+
item[0],
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
self._consumed.add(index)
|
|
88
|
+
return entry.response
|
|
89
|
+
|
|
90
|
+
self._misses.append((tool, actual))
|
|
91
|
+
if self.strict:
|
|
92
|
+
raise ReplayMismatch(
|
|
93
|
+
f"no cassette response for {tool} with arguments {actual!r}"
|
|
94
|
+
)
|
|
95
|
+
return {}
|
|
96
|
+
|
|
97
|
+
def list_emails(self) -> Any:
|
|
98
|
+
"""Serve the ``list_emails`` tool."""
|
|
99
|
+
|
|
100
|
+
return self.call("list_emails")
|
|
101
|
+
|
|
102
|
+
def read_email(self, id: str) -> Any:
|
|
103
|
+
"""Serve the ``read_email`` tool."""
|
|
104
|
+
|
|
105
|
+
return self.call("read_email", {"id": id})
|
|
106
|
+
|
|
107
|
+
def list_files(self) -> Any:
|
|
108
|
+
"""Serve the ``list_files`` tool."""
|
|
109
|
+
|
|
110
|
+
return self.call("list_files")
|
|
111
|
+
|
|
112
|
+
def read_file(self, path: str) -> Any:
|
|
113
|
+
"""Serve the ``read_file`` tool."""
|
|
114
|
+
|
|
115
|
+
return self.call("read_file", {"path": path})
|
|
116
|
+
|
|
117
|
+
def send_http(self, url: str, body: str) -> Any:
|
|
118
|
+
"""Serve the ``send_http`` tool without opening a connection."""
|
|
119
|
+
|
|
120
|
+
return self.call("send_http", {"url": url, "body": body})
|
|
121
|
+
|
|
122
|
+
def __getattr__(self, name: str) -> Callable[..., Any]:
|
|
123
|
+
"""Allow compatible agents to request cassette-defined tool names."""
|
|
124
|
+
|
|
125
|
+
if name.startswith("_"):
|
|
126
|
+
raise AttributeError(name)
|
|
127
|
+
|
|
128
|
+
def invoke(**kwargs: Any) -> Any:
|
|
129
|
+
return self.call(name, kwargs)
|
|
130
|
+
|
|
131
|
+
return invoke
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# A descriptive alias for callers that think of this object as a tool layer.
|
|
135
|
+
CassetteToolAdapter = CassetteWorld
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@contextmanager
|
|
139
|
+
def _hermetic_guards() -> Iterator[None]:
|
|
140
|
+
"""Reject filesystem and socket access while an agent is running."""
|
|
141
|
+
|
|
142
|
+
def blocked_filesystem(*args: Any, **kwargs: Any) -> NoReturn:
|
|
143
|
+
del args, kwargs
|
|
144
|
+
raise PermissionError("replay sandbox forbids filesystem access")
|
|
145
|
+
|
|
146
|
+
def blocked_network(*args: Any, **kwargs: Any) -> NoReturn:
|
|
147
|
+
del args, kwargs
|
|
148
|
+
raise PermissionError("replay sandbox forbids network access")
|
|
149
|
+
|
|
150
|
+
owner_ident = threading.get_ident()
|
|
151
|
+
|
|
152
|
+
def guard(original: Any, blocked: Callable[..., NoReturn]) -> Callable[..., Any]:
|
|
153
|
+
def guarded(*args: Any, **kwargs: Any) -> Any:
|
|
154
|
+
if threading.get_ident() == owner_ident:
|
|
155
|
+
return blocked(*args, **kwargs)
|
|
156
|
+
return original(*args, **kwargs)
|
|
157
|
+
|
|
158
|
+
return guarded
|
|
159
|
+
|
|
160
|
+
patches: list[tuple[object, str, Any]] = [
|
|
161
|
+
(builtins, "open", blocked_filesystem),
|
|
162
|
+
(io, "open", blocked_filesystem),
|
|
163
|
+
(os, "open", blocked_filesystem),
|
|
164
|
+
(os, "fdopen", blocked_filesystem),
|
|
165
|
+
(Path, "open", blocked_filesystem),
|
|
166
|
+
(Path, "read_text", blocked_filesystem),
|
|
167
|
+
(Path, "read_bytes", blocked_filesystem),
|
|
168
|
+
(Path, "write_text", blocked_filesystem),
|
|
169
|
+
(Path, "write_bytes", blocked_filesystem),
|
|
170
|
+
(socket, "socket", blocked_network),
|
|
171
|
+
(socket, "create_connection", blocked_network),
|
|
172
|
+
(socket, "getaddrinfo", blocked_network),
|
|
173
|
+
(socket, "gethostbyname", blocked_network),
|
|
174
|
+
(socket, "gethostbyname_ex", blocked_network),
|
|
175
|
+
]
|
|
176
|
+
originals: list[tuple[object, str, Any]] = []
|
|
177
|
+
try:
|
|
178
|
+
for owner, name, replacement in patches:
|
|
179
|
+
if hasattr(owner, name):
|
|
180
|
+
originals.append((owner, name, getattr(owner, name)))
|
|
181
|
+
setattr(owner, name, guard(getattr(owner, name), replacement))
|
|
182
|
+
yield
|
|
183
|
+
finally:
|
|
184
|
+
for owner, name, original in reversed(originals):
|
|
185
|
+
setattr(owner, name, original)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def run_agent(
|
|
189
|
+
agent_fn: Callable[..., Any],
|
|
190
|
+
cassette: Cassette,
|
|
191
|
+
llm: Any,
|
|
192
|
+
*,
|
|
193
|
+
timeout: float,
|
|
194
|
+
max_steps: int,
|
|
195
|
+
) -> Trace:
|
|
196
|
+
"""Run ``agent_fn`` against ``cassette`` and always return a ``Trace``.
|
|
197
|
+
|
|
198
|
+
The agent runs in a daemon thread so a non-cooperative policy cannot block
|
|
199
|
+
the caller beyond ``timeout``. Agent functions accepting ``run_id`` and
|
|
200
|
+
``max_actions`` receive deterministic replay values; narrower compatible
|
|
201
|
+
callables receive only the parameters present in their signature.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
if timeout <= 0 or max_steps < 0:
|
|
205
|
+
return _failure_trace(cassette, agent_fn, "invalid replay limit", max_steps)
|
|
206
|
+
|
|
207
|
+
world = CassetteWorld(cassette)
|
|
208
|
+
result: list[Any] = []
|
|
209
|
+
failure: list[BaseException] = []
|
|
210
|
+
|
|
211
|
+
def target() -> None:
|
|
212
|
+
try:
|
|
213
|
+
with _hermetic_guards():
|
|
214
|
+
result.append(_call_agent(agent_fn, world, llm, max_steps))
|
|
215
|
+
except BaseException as exc: # The public contract converts all failures.
|
|
216
|
+
failure.append(exc)
|
|
217
|
+
|
|
218
|
+
worker = threading.Thread(target=target, name="grudge-replay", daemon=True)
|
|
219
|
+
worker.start()
|
|
220
|
+
worker.join(timeout)
|
|
221
|
+
if worker.is_alive():
|
|
222
|
+
return _failure_trace(
|
|
223
|
+
cassette, agent_fn, f"sandbox timeout after {timeout:g}s", max_steps
|
|
224
|
+
)
|
|
225
|
+
if failure:
|
|
226
|
+
return _failure_trace(
|
|
227
|
+
cassette, agent_fn, _failure_reason(failure[0]), max_steps
|
|
228
|
+
)
|
|
229
|
+
if not result or not isinstance(result[0], Trace):
|
|
230
|
+
return _failure_trace(
|
|
231
|
+
cassette, agent_fn, "agent did not return a Trace", max_steps
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
trace = result[0]
|
|
235
|
+
if len(trace.steps) > max_steps:
|
|
236
|
+
return trace.model_copy(
|
|
237
|
+
update={"steps": trace.steps[:max_steps], "outcome": "failed"}
|
|
238
|
+
)
|
|
239
|
+
return trace
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def world_from_trace(trace: Trace) -> Cassette:
|
|
243
|
+
"""Build a full, unshrunk cassette from every tool call in ``trace``."""
|
|
244
|
+
|
|
245
|
+
results: dict[str, list[ToolResult]] = {}
|
|
246
|
+
for step in trace.steps:
|
|
247
|
+
if isinstance(step, ToolResult):
|
|
248
|
+
results.setdefault(step.call_id, []).append(step)
|
|
249
|
+
|
|
250
|
+
entries: list[CassetteEntry] = []
|
|
251
|
+
for step in trace.steps:
|
|
252
|
+
if not isinstance(step, ToolCall):
|
|
253
|
+
continue
|
|
254
|
+
result_list = results.get(step.call_id, [])
|
|
255
|
+
result = result_list.pop(0) if result_list else None
|
|
256
|
+
response = result.content if result is not None else {"error": "missing result"}
|
|
257
|
+
entries.append(
|
|
258
|
+
CassetteEntry(
|
|
259
|
+
tool=step.tool,
|
|
260
|
+
match=ArgMatcher.exact(dict(step.args)),
|
|
261
|
+
response=response,
|
|
262
|
+
order_hint=step.idx,
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
return Cassette(task=trace.task, world=entries)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _call_agent(
|
|
269
|
+
agent_fn: Callable[..., Any],
|
|
270
|
+
world: CassetteWorld,
|
|
271
|
+
llm: Any,
|
|
272
|
+
max_steps: int,
|
|
273
|
+
) -> Any:
|
|
274
|
+
"""Call an agent with only the compatible optional keyword arguments."""
|
|
275
|
+
|
|
276
|
+
parameters: Mapping[str, inspect.Parameter]
|
|
277
|
+
try:
|
|
278
|
+
parameters = inspect.signature(agent_fn).parameters
|
|
279
|
+
except (TypeError, ValueError):
|
|
280
|
+
parameters = {}
|
|
281
|
+
kwargs: dict[str, Any] = {}
|
|
282
|
+
if "llm" in parameters or any(
|
|
283
|
+
parameter.kind is inspect.Parameter.VAR_KEYWORD
|
|
284
|
+
for parameter in parameters.values()
|
|
285
|
+
):
|
|
286
|
+
kwargs["llm"] = llm
|
|
287
|
+
if "run_id" in parameters:
|
|
288
|
+
kwargs["run_id"] = "replay-run"
|
|
289
|
+
if "max_actions" in parameters:
|
|
290
|
+
kwargs["max_actions"] = max_steps
|
|
291
|
+
return agent_fn(world, **kwargs)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def _failure_trace(
|
|
295
|
+
cassette: Cassette,
|
|
296
|
+
agent_fn: Callable[..., Any],
|
|
297
|
+
reason: str,
|
|
298
|
+
max_steps: int | None = None,
|
|
299
|
+
) -> Trace:
|
|
300
|
+
"""Create a deterministic schema-valid trace for a sandbox failure."""
|
|
301
|
+
|
|
302
|
+
run_id = "replay-run"
|
|
303
|
+
agent_name = getattr(
|
|
304
|
+
agent_fn, "__qualname__", getattr(agent_fn, "__name__", "agent")
|
|
305
|
+
)
|
|
306
|
+
module = getattr(agent_fn, "__module__", "__main__")
|
|
307
|
+
if max_steps == 0:
|
|
308
|
+
steps: list[Step] = []
|
|
309
|
+
else:
|
|
310
|
+
steps = [
|
|
311
|
+
StateDiff(
|
|
312
|
+
idx=0,
|
|
313
|
+
ts=_EPOCH,
|
|
314
|
+
run_id=run_id,
|
|
315
|
+
diff={},
|
|
316
|
+
meta={"failure": reason},
|
|
317
|
+
)
|
|
318
|
+
]
|
|
319
|
+
return Trace(
|
|
320
|
+
run_id=run_id,
|
|
321
|
+
agent_version=f"{module}.{agent_name}",
|
|
322
|
+
task=cassette.task,
|
|
323
|
+
steps=steps,
|
|
324
|
+
outcome="failed",
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _failure_reason(error: BaseException) -> str:
|
|
329
|
+
"""Format an exception without including unstable traceback locations."""
|
|
330
|
+
|
|
331
|
+
text = str(error)
|
|
332
|
+
return f"{type(error).__name__}: {text}" if text else type(error).__name__
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
__all__ = [
|
|
336
|
+
"CassetteToolAdapter",
|
|
337
|
+
"CassetteWorld",
|
|
338
|
+
"run_agent",
|
|
339
|
+
"world_from_trace",
|
|
340
|
+
]
|