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,159 @@
|
|
|
1
|
+
"""Typed boundary for recording plain SDK tool-calling loops.
|
|
2
|
+
|
|
3
|
+
The adapter intentionally accepts the small mapping-shaped events commonly
|
|
4
|
+
produced by SDK wrappers. It keeps SDK-specific response classes out of the
|
|
5
|
+
recorder while preserving the loop's original event order.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
11
|
+
from typing import Any, TypeAlias, cast
|
|
12
|
+
|
|
13
|
+
from grudge.recorder.recorder import Recorder
|
|
14
|
+
from grudge.schema.trace import Step
|
|
15
|
+
|
|
16
|
+
RawEvent: TypeAlias = Mapping[str, Any]
|
|
17
|
+
RawLoop: TypeAlias = Iterable[RawEvent] | Callable[[], Iterable[RawEvent]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def record_raw_sdk(recorder: Recorder, events: RawLoop) -> list[Step]:
|
|
21
|
+
"""Record raw SDK events or the result of a zero-argument event loop.
|
|
22
|
+
|
|
23
|
+
Events use ``kind`` (or the equivalent ``type``) with one of the Phase 1
|
|
24
|
+
values: ``model_call``, ``tool_call``, ``tool_result``, ``state_diff``, or
|
|
25
|
+
``final_answer``. Event-specific fields are passed through to the typed
|
|
26
|
+
recorder methods, and the returned list contains the newly recorded steps.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
source = events() if callable(events) else events
|
|
30
|
+
recorded: list[Step] = []
|
|
31
|
+
for event in source:
|
|
32
|
+
kind = event.get("kind", event.get("type"))
|
|
33
|
+
if not isinstance(kind, str):
|
|
34
|
+
raise ValueError("raw SDK event must include a string kind")
|
|
35
|
+
if kind == "model_call":
|
|
36
|
+
step = cast(
|
|
37
|
+
Step,
|
|
38
|
+
recorder.record_model_call(
|
|
39
|
+
model=_optional_str(event, "model"),
|
|
40
|
+
messages=_optional_messages(event.get("messages")),
|
|
41
|
+
prompt=_optional_str(event, "prompt"),
|
|
42
|
+
response=event.get("response"),
|
|
43
|
+
parent_idx=_optional_int(event, "parent_idx"),
|
|
44
|
+
meta=_optional_mapping(event.get("meta")),
|
|
45
|
+
),
|
|
46
|
+
)
|
|
47
|
+
elif kind == "tool_call":
|
|
48
|
+
args = event.get("args", event.get("arguments", {}))
|
|
49
|
+
step = cast(
|
|
50
|
+
Step,
|
|
51
|
+
recorder.record_tool_call(
|
|
52
|
+
_required_str(event, "tool", alternative="name"),
|
|
53
|
+
_required_mapping(args, "tool call args"),
|
|
54
|
+
_optional_str(event, "call_id"),
|
|
55
|
+
parent_idx=_optional_int(event, "parent_idx"),
|
|
56
|
+
meta=_optional_mapping(event.get("meta")),
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
elif kind == "tool_result":
|
|
60
|
+
step = cast(
|
|
61
|
+
Step,
|
|
62
|
+
recorder.record_tool_result(
|
|
63
|
+
_required_str(event, "call_id", alternative="tool_call_id"),
|
|
64
|
+
event.get("content"),
|
|
65
|
+
_optional_str(event, "error"),
|
|
66
|
+
parent_idx=_optional_int(event, "parent_idx"),
|
|
67
|
+
meta=_optional_mapping(event.get("meta")),
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
elif kind == "state_diff":
|
|
71
|
+
step = cast(
|
|
72
|
+
Step,
|
|
73
|
+
recorder.record_state_diff(
|
|
74
|
+
_required_mapping(event.get("diff", {}), "state diff"),
|
|
75
|
+
parent_idx=_optional_int(event, "parent_idx"),
|
|
76
|
+
meta=_optional_mapping(event.get("meta")),
|
|
77
|
+
),
|
|
78
|
+
)
|
|
79
|
+
elif kind == "final_answer":
|
|
80
|
+
step = cast(
|
|
81
|
+
Step,
|
|
82
|
+
recorder.record_final_answer(
|
|
83
|
+
_optional_str(event, "answer"),
|
|
84
|
+
parent_idx=_optional_int(event, "parent_idx"),
|
|
85
|
+
meta=_optional_mapping(event.get("meta")),
|
|
86
|
+
),
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
raise ValueError(f"unsupported raw SDK event kind: {kind}")
|
|
90
|
+
recorded.append(step)
|
|
91
|
+
return recorded
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _required_str(
|
|
95
|
+
event: RawEvent,
|
|
96
|
+
key: str,
|
|
97
|
+
*,
|
|
98
|
+
alternative: str | None = None,
|
|
99
|
+
) -> str:
|
|
100
|
+
"""Read a required string field, with an SDK naming alias."""
|
|
101
|
+
|
|
102
|
+
value = event.get(key)
|
|
103
|
+
if value is None and alternative is not None:
|
|
104
|
+
value = event.get(alternative)
|
|
105
|
+
if not isinstance(value, str) or not value:
|
|
106
|
+
label = f"{key}/{alternative}" if alternative else key
|
|
107
|
+
raise ValueError(f"raw SDK event requires non-empty string {label}")
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _optional_str(event: RawEvent, key: str) -> str | None:
|
|
112
|
+
"""Read an optional string field."""
|
|
113
|
+
|
|
114
|
+
value = event.get(key)
|
|
115
|
+
if value is not None and not isinstance(value, str):
|
|
116
|
+
raise ValueError(f"raw SDK event field {key} must be a string")
|
|
117
|
+
return value
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _optional_int(event: RawEvent, key: str) -> int | None:
|
|
121
|
+
"""Read an optional integer field."""
|
|
122
|
+
|
|
123
|
+
value = event.get(key)
|
|
124
|
+
invalid = value is not None and (
|
|
125
|
+
isinstance(value, bool) or not isinstance(value, int)
|
|
126
|
+
)
|
|
127
|
+
if invalid:
|
|
128
|
+
raise ValueError(f"raw SDK event field {key} must be an integer")
|
|
129
|
+
return value
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _optional_mapping(value: Any) -> Mapping[str, Any] | None:
|
|
133
|
+
"""Read an optional mapping field."""
|
|
134
|
+
|
|
135
|
+
if value is not None and not isinstance(value, Mapping):
|
|
136
|
+
raise ValueError("raw SDK event metadata must be a mapping")
|
|
137
|
+
return value
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _required_mapping(value: Any, label: str) -> Mapping[str, Any]:
|
|
141
|
+
"""Read a required mapping field."""
|
|
142
|
+
|
|
143
|
+
if not isinstance(value, Mapping):
|
|
144
|
+
raise ValueError(f"{label} must be a mapping")
|
|
145
|
+
return value
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _optional_messages(value: Any) -> list[dict[str, Any]] | None:
|
|
149
|
+
"""Validate the Phase 1 message-list shape without SDK dependencies."""
|
|
150
|
+
|
|
151
|
+
if value is None:
|
|
152
|
+
return None
|
|
153
|
+
valid = isinstance(value, list) and all(isinstance(item, dict) for item in value)
|
|
154
|
+
if not valid:
|
|
155
|
+
raise ValueError("raw SDK event messages must be a list of mappings")
|
|
156
|
+
return cast(list[dict[str, Any]], value)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
__all__ = ["RawEvent", "RawLoop", "record_raw_sdk"]
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Capture an agent run as an ordered, canonical Phase 1 trace.
|
|
2
|
+
|
|
3
|
+
The recorder keeps steps in memory until the run is complete. This lets the
|
|
4
|
+
JSONL header contain the final outcome while the output itself is still
|
|
5
|
+
written append-only: once a record is written it is never sought, rewritten,
|
|
6
|
+
or truncated.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
import json
|
|
13
|
+
from collections.abc import Callable, Mapping
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from functools import wraps
|
|
16
|
+
from io import TextIOBase
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from types import TracebackType
|
|
19
|
+
from typing import Any, Literal, TypeVar, cast
|
|
20
|
+
|
|
21
|
+
from grudge.schema.trace import (
|
|
22
|
+
FinalAnswer,
|
|
23
|
+
ModelCall,
|
|
24
|
+
StateDiff,
|
|
25
|
+
Step,
|
|
26
|
+
ToolCall,
|
|
27
|
+
ToolResult,
|
|
28
|
+
Trace,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
Output = str | Path | TextIOBase
|
|
32
|
+
ReturnT = TypeVar("ReturnT")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class JsonlWriter:
|
|
36
|
+
"""Write JSON objects to a text sink without rewriting earlier records."""
|
|
37
|
+
|
|
38
|
+
def __init__(self, output: Output) -> None:
|
|
39
|
+
self._owned = isinstance(output, str | Path)
|
|
40
|
+
if self._owned:
|
|
41
|
+
stream = Path(cast(str | Path, output)).open("a", encoding="utf-8")
|
|
42
|
+
self._stream: TextIOBase = stream
|
|
43
|
+
else:
|
|
44
|
+
self._stream = cast(TextIOBase, output)
|
|
45
|
+
self._closed = False
|
|
46
|
+
|
|
47
|
+
def append(self, record: Mapping[str, Any]) -> None:
|
|
48
|
+
"""Append one canonical JSON object and flush it to the sink."""
|
|
49
|
+
|
|
50
|
+
if self._closed:
|
|
51
|
+
raise RuntimeError("cannot append to a closed JSONL writer")
|
|
52
|
+
line = json.dumps(
|
|
53
|
+
dict(record),
|
|
54
|
+
ensure_ascii=False,
|
|
55
|
+
sort_keys=True,
|
|
56
|
+
separators=(",", ":"),
|
|
57
|
+
default=str,
|
|
58
|
+
)
|
|
59
|
+
self._stream.write(line + "\n")
|
|
60
|
+
self._stream.flush()
|
|
61
|
+
|
|
62
|
+
def close(self) -> None:
|
|
63
|
+
"""Close an owned file, leaving caller-owned streams open."""
|
|
64
|
+
|
|
65
|
+
if self._closed:
|
|
66
|
+
return
|
|
67
|
+
self._stream.flush()
|
|
68
|
+
if self._owned:
|
|
69
|
+
self._stream.close()
|
|
70
|
+
self._closed = True
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Recorder:
|
|
74
|
+
"""Record one agent run and persist it as canonical JSONL.
|
|
75
|
+
|
|
76
|
+
``Recorder`` is normally used as a context manager. Steps receive a
|
|
77
|
+
strictly increasing index at the moment they are recorded, and an
|
|
78
|
+
exception escaping the context produces a failed trace before being
|
|
79
|
+
re-raised to the caller.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
def __init__(
|
|
83
|
+
self,
|
|
84
|
+
output: Output,
|
|
85
|
+
*,
|
|
86
|
+
run_id: str,
|
|
87
|
+
agent_version: str = "unknown",
|
|
88
|
+
task: str = "",
|
|
89
|
+
) -> None:
|
|
90
|
+
self.output = output
|
|
91
|
+
self.run_id = run_id
|
|
92
|
+
self.agent_version = agent_version
|
|
93
|
+
self.task = task
|
|
94
|
+
self._steps: list[Step] = []
|
|
95
|
+
self._next_idx = 0
|
|
96
|
+
self._trace: Trace | None = None
|
|
97
|
+
self._writer: JsonlWriter | None = None
|
|
98
|
+
self.exception: BaseException | None = None
|
|
99
|
+
|
|
100
|
+
def __enter__(self) -> Recorder:
|
|
101
|
+
"""Start a recording context."""
|
|
102
|
+
|
|
103
|
+
if self._trace is not None:
|
|
104
|
+
raise RuntimeError("a recorder can only be entered once")
|
|
105
|
+
return self
|
|
106
|
+
|
|
107
|
+
def __exit__(
|
|
108
|
+
self,
|
|
109
|
+
exc_type: type[BaseException] | None,
|
|
110
|
+
exc_value: BaseException | None,
|
|
111
|
+
traceback: TracebackType | None,
|
|
112
|
+
) -> Literal[False]:
|
|
113
|
+
"""Persist the trace and re-raise any exception from the context."""
|
|
114
|
+
|
|
115
|
+
if exc_value is not None:
|
|
116
|
+
self.exception = exc_value
|
|
117
|
+
self.close(outcome="failed" if exc_value is not None else "ok")
|
|
118
|
+
return False
|
|
119
|
+
|
|
120
|
+
@property
|
|
121
|
+
def steps(self) -> tuple[Step, ...]:
|
|
122
|
+
"""Return the recorded steps in their original order."""
|
|
123
|
+
|
|
124
|
+
return tuple(self._steps)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def trace(self) -> Trace:
|
|
128
|
+
"""Return the finalized trace, or raise while recording is active."""
|
|
129
|
+
|
|
130
|
+
if self._trace is None:
|
|
131
|
+
raise RuntimeError("trace is available after Recorder.close()")
|
|
132
|
+
return self._trace
|
|
133
|
+
|
|
134
|
+
def record_step(self, step: Step) -> Step:
|
|
135
|
+
"""Append a pre-built Phase 1 step at the next monotonic index."""
|
|
136
|
+
|
|
137
|
+
if self._trace is not None:
|
|
138
|
+
raise RuntimeError("cannot record after the recorder is closed")
|
|
139
|
+
if step.run_id != self.run_id:
|
|
140
|
+
raise ValueError("step run_id must match recorder run_id")
|
|
141
|
+
if step.idx != self._next_idx:
|
|
142
|
+
raise ValueError(f"step idx must be {self._next_idx}, received {step.idx}")
|
|
143
|
+
self._steps.append(step)
|
|
144
|
+
self._next_idx += 1
|
|
145
|
+
return step
|
|
146
|
+
|
|
147
|
+
def record_model_call(
|
|
148
|
+
self,
|
|
149
|
+
*,
|
|
150
|
+
model: str | None = None,
|
|
151
|
+
messages: list[dict[str, Any]] | None = None,
|
|
152
|
+
prompt: str | None = None,
|
|
153
|
+
response: Any = None,
|
|
154
|
+
parent_idx: int | None = None,
|
|
155
|
+
meta: Mapping[str, Any] | None = None,
|
|
156
|
+
) -> ModelCall:
|
|
157
|
+
"""Record a model request or response."""
|
|
158
|
+
|
|
159
|
+
return cast(
|
|
160
|
+
ModelCall,
|
|
161
|
+
self.record_step(
|
|
162
|
+
ModelCall(
|
|
163
|
+
idx=self._next_idx,
|
|
164
|
+
ts=_now(),
|
|
165
|
+
run_id=self.run_id,
|
|
166
|
+
parent_idx=parent_idx,
|
|
167
|
+
meta=dict(meta or {}),
|
|
168
|
+
model=model,
|
|
169
|
+
messages=messages,
|
|
170
|
+
prompt=prompt,
|
|
171
|
+
response=response,
|
|
172
|
+
)
|
|
173
|
+
),
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
def record_tool_call(
|
|
177
|
+
self,
|
|
178
|
+
tool: str,
|
|
179
|
+
args: Mapping[str, Any] | None = None,
|
|
180
|
+
call_id: str | None = None,
|
|
181
|
+
*,
|
|
182
|
+
parent_idx: int | None = None,
|
|
183
|
+
meta: Mapping[str, Any] | None = None,
|
|
184
|
+
) -> ToolCall:
|
|
185
|
+
"""Record a tool invocation, generating a call id when omitted."""
|
|
186
|
+
|
|
187
|
+
return cast(
|
|
188
|
+
ToolCall,
|
|
189
|
+
self.record_step(
|
|
190
|
+
ToolCall(
|
|
191
|
+
idx=self._next_idx,
|
|
192
|
+
ts=_now(),
|
|
193
|
+
run_id=self.run_id,
|
|
194
|
+
parent_idx=parent_idx,
|
|
195
|
+
meta=dict(meta or {}),
|
|
196
|
+
tool=tool,
|
|
197
|
+
args=dict(args or {}),
|
|
198
|
+
call_id=call_id or f"call-{self._next_idx}",
|
|
199
|
+
)
|
|
200
|
+
),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
def record_tool_result(
|
|
204
|
+
self,
|
|
205
|
+
call_id: str,
|
|
206
|
+
content: Any,
|
|
207
|
+
error: str | None = None,
|
|
208
|
+
*,
|
|
209
|
+
parent_idx: int | None = None,
|
|
210
|
+
meta: Mapping[str, Any] | None = None,
|
|
211
|
+
) -> ToolResult:
|
|
212
|
+
"""Record a tool result, including a tool-level error when present."""
|
|
213
|
+
|
|
214
|
+
return cast(
|
|
215
|
+
ToolResult,
|
|
216
|
+
self.record_step(
|
|
217
|
+
ToolResult(
|
|
218
|
+
idx=self._next_idx,
|
|
219
|
+
ts=_now(),
|
|
220
|
+
run_id=self.run_id,
|
|
221
|
+
parent_idx=parent_idx,
|
|
222
|
+
meta=dict(meta or {}),
|
|
223
|
+
call_id=call_id,
|
|
224
|
+
content=content,
|
|
225
|
+
error=error,
|
|
226
|
+
)
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def record_state_diff(
|
|
231
|
+
self,
|
|
232
|
+
diff: Mapping[str, Any] | None = None,
|
|
233
|
+
*,
|
|
234
|
+
parent_idx: int | None = None,
|
|
235
|
+
meta: Mapping[str, Any] | None = None,
|
|
236
|
+
) -> StateDiff:
|
|
237
|
+
"""Record a structured state change."""
|
|
238
|
+
|
|
239
|
+
return cast(
|
|
240
|
+
StateDiff,
|
|
241
|
+
self.record_step(
|
|
242
|
+
StateDiff(
|
|
243
|
+
idx=self._next_idx,
|
|
244
|
+
ts=_now(),
|
|
245
|
+
run_id=self.run_id,
|
|
246
|
+
parent_idx=parent_idx,
|
|
247
|
+
meta=dict(meta or {}),
|
|
248
|
+
diff=dict(diff or {}),
|
|
249
|
+
)
|
|
250
|
+
),
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
def record_final_answer(
|
|
254
|
+
self,
|
|
255
|
+
answer: str | None,
|
|
256
|
+
*,
|
|
257
|
+
parent_idx: int | None = None,
|
|
258
|
+
meta: Mapping[str, Any] | None = None,
|
|
259
|
+
) -> FinalAnswer:
|
|
260
|
+
"""Record the agent's terminal answer."""
|
|
261
|
+
|
|
262
|
+
return cast(
|
|
263
|
+
FinalAnswer,
|
|
264
|
+
self.record_step(
|
|
265
|
+
FinalAnswer(
|
|
266
|
+
idx=self._next_idx,
|
|
267
|
+
ts=_now(),
|
|
268
|
+
run_id=self.run_id,
|
|
269
|
+
parent_idx=parent_idx,
|
|
270
|
+
meta=dict(meta or {}),
|
|
271
|
+
answer=answer,
|
|
272
|
+
)
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# Short aliases make the recording API convenient in plain SDK loops.
|
|
277
|
+
model_call = record_model_call
|
|
278
|
+
tool_call = record_tool_call
|
|
279
|
+
tool_result = record_tool_result
|
|
280
|
+
state_diff = record_state_diff
|
|
281
|
+
final_answer = record_final_answer
|
|
282
|
+
|
|
283
|
+
def close(self, *, outcome: Literal["ok", "failed"] = "ok") -> Trace:
|
|
284
|
+
"""Finalize and append the trace header and steps to the output."""
|
|
285
|
+
|
|
286
|
+
if self._trace is not None:
|
|
287
|
+
return self._trace
|
|
288
|
+
trace = Trace(
|
|
289
|
+
run_id=self.run_id,
|
|
290
|
+
agent_version=self.agent_version,
|
|
291
|
+
task=self.task,
|
|
292
|
+
steps=list(self._steps),
|
|
293
|
+
outcome=outcome,
|
|
294
|
+
)
|
|
295
|
+
writer = JsonlWriter(self.output)
|
|
296
|
+
try:
|
|
297
|
+
for line in trace.to_jsonl().splitlines():
|
|
298
|
+
writer.append(json.loads(line))
|
|
299
|
+
finally:
|
|
300
|
+
writer.close()
|
|
301
|
+
self._writer = writer
|
|
302
|
+
self._trace = trace
|
|
303
|
+
return trace
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _now() -> datetime:
|
|
307
|
+
"""Return an aware UTC timestamp for a trace step."""
|
|
308
|
+
|
|
309
|
+
return datetime.now(UTC)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def record_run(
|
|
313
|
+
output: Output,
|
|
314
|
+
*,
|
|
315
|
+
run_id: str,
|
|
316
|
+
agent_version: str = "unknown",
|
|
317
|
+
task: str = "",
|
|
318
|
+
) -> Recorder:
|
|
319
|
+
"""Create a recorder; use it as ``with record_run(...) as recorder``."""
|
|
320
|
+
|
|
321
|
+
return Recorder(
|
|
322
|
+
output,
|
|
323
|
+
run_id=run_id,
|
|
324
|
+
agent_version=agent_version,
|
|
325
|
+
task=task,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def record(
|
|
330
|
+
output: Output,
|
|
331
|
+
*,
|
|
332
|
+
run_id: str,
|
|
333
|
+
agent_version: str = "unknown",
|
|
334
|
+
task: str = "",
|
|
335
|
+
) -> Callable[[Callable[..., ReturnT]], Callable[..., ReturnT]]:
|
|
336
|
+
"""Decorate a recorder-aware function with a recording context.
|
|
337
|
+
|
|
338
|
+
The wrapped function receives ``recorder=...`` when its signature accepts
|
|
339
|
+
that keyword. Functions that do not accept it still get outcome capture.
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
def decorate(function: Callable[..., ReturnT]) -> Callable[..., ReturnT]:
|
|
343
|
+
def wrapped(*args: Any, **kwargs: Any) -> ReturnT:
|
|
344
|
+
with Recorder(
|
|
345
|
+
output,
|
|
346
|
+
run_id=run_id,
|
|
347
|
+
agent_version=agent_version,
|
|
348
|
+
task=task,
|
|
349
|
+
) as recorder:
|
|
350
|
+
accepts_recorder = _accepts_keyword(function, "recorder")
|
|
351
|
+
if accepts_recorder:
|
|
352
|
+
kwargs = {**kwargs, "recorder": recorder}
|
|
353
|
+
return function(*args, **kwargs)
|
|
354
|
+
|
|
355
|
+
return cast(Callable[..., ReturnT], wraps(function)(wrapped))
|
|
356
|
+
|
|
357
|
+
return decorate
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def _accepts_keyword(function: Callable[..., Any], name: str) -> bool:
|
|
361
|
+
"""Check whether a callable can receive a named recorder argument."""
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
signature = inspect.signature(function)
|
|
365
|
+
except (TypeError, ValueError):
|
|
366
|
+
return False
|
|
367
|
+
parameter = signature.parameters.get(name)
|
|
368
|
+
return parameter is not None or any(
|
|
369
|
+
item.kind is inspect.Parameter.VAR_KEYWORD
|
|
370
|
+
for item in signature.parameters.values()
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
__all__ = ["JsonlWriter", "Recorder", "record", "record_run"]
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""Small, dependency-free redaction helpers for recorded trace values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Mapping, Sequence
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from typing import TypeVar, cast
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True, slots=True)
|
|
12
|
+
class RedactionRule:
|
|
13
|
+
"""A regular expression and replacement used by :func:`redact_text`."""
|
|
14
|
+
|
|
15
|
+
pattern: str | re.Pattern[str]
|
|
16
|
+
replacement: str = "[REDACTED]"
|
|
17
|
+
flags: int = 0
|
|
18
|
+
|
|
19
|
+
def compiled(self) -> re.Pattern[str]:
|
|
20
|
+
"""Return the rule's compiled regular expression."""
|
|
21
|
+
if isinstance(self.pattern, str):
|
|
22
|
+
return re.compile(self.pattern, self.flags)
|
|
23
|
+
if self.flags:
|
|
24
|
+
raise ValueError("flags cannot be used with a compiled pattern")
|
|
25
|
+
return self.pattern
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
DEFAULT_REDACTION_RULES: tuple[RedactionRule, ...] = (
|
|
29
|
+
RedactionRule(r"\bFAKE_KEY_[A-Za-z0-9_-]+\b"),
|
|
30
|
+
RedactionRule(r"\b(?:sk-ant-|sk-)[A-Za-z0-9_-]{12,}\b"),
|
|
31
|
+
RedactionRule(r"\b(?:AKIA|ASIA)[A-Z0-9]{16}\b"),
|
|
32
|
+
RedactionRule(r"\b(?:ghp|gho|ghs|ghr)_[A-Za-z0-9]{20,}\b"),
|
|
33
|
+
RedactionRule(r"\bgithub_pat_[A-Za-z0-9_]{20,}\b"),
|
|
34
|
+
RedactionRule(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"),
|
|
35
|
+
RedactionRule(
|
|
36
|
+
r"(?i)\b(?:api[_-]?key|access[_-]?token|secret|token|password)"
|
|
37
|
+
r"(\s*[:=]\s*)['\"]?[A-Za-z0-9_./+=-]{8,}['\"]?",
|
|
38
|
+
replacement=r"\1[REDACTED]",
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
# A short alias keeps the public rule list convenient to discover.
|
|
43
|
+
DEFAULT_RULES = DEFAULT_REDACTION_RULES
|
|
44
|
+
|
|
45
|
+
_T = TypeVar("_T")
|
|
46
|
+
_RuleLike = RedactionRule | str | re.Pattern[str]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _as_rule(rule: _RuleLike) -> RedactionRule:
|
|
50
|
+
"""Normalize a shorthand regex into a standard redaction rule."""
|
|
51
|
+
if isinstance(rule, RedactionRule):
|
|
52
|
+
return rule
|
|
53
|
+
return RedactionRule(rule)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def redact_text(
|
|
57
|
+
text: str,
|
|
58
|
+
*,
|
|
59
|
+
rules: Sequence[_RuleLike] | None = None,
|
|
60
|
+
enabled: bool = True,
|
|
61
|
+
) -> str:
|
|
62
|
+
"""Redact key-like substrings in ``text`` using rules in list order.
|
|
63
|
+
|
|
64
|
+
Passing ``enabled=False`` is an intentional no-op for tests that need to
|
|
65
|
+
inspect the original trace. Passing an empty rule sequence also disables
|
|
66
|
+
the default rules while leaving redaction enabled.
|
|
67
|
+
"""
|
|
68
|
+
if not enabled:
|
|
69
|
+
return text
|
|
70
|
+
|
|
71
|
+
selected_rules = DEFAULT_REDACTION_RULES if rules is None else rules
|
|
72
|
+
redacted = text
|
|
73
|
+
for rule in selected_rules:
|
|
74
|
+
normalized = _as_rule(rule)
|
|
75
|
+
redacted = normalized.compiled().sub(normalized.replacement, redacted)
|
|
76
|
+
return redacted
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def redact_trace(
|
|
80
|
+
trace: _T,
|
|
81
|
+
*,
|
|
82
|
+
rules: Sequence[_RuleLike] | None = None,
|
|
83
|
+
enabled: bool = True,
|
|
84
|
+
) -> _T:
|
|
85
|
+
"""Return ``trace`` with string values redacted recursively.
|
|
86
|
+
|
|
87
|
+
Mappings, lists, and tuples are copied so the supplied trace is not
|
|
88
|
+
mutated. Other values pass through unchanged. With ``enabled=False``
|
|
89
|
+
the original object is returned untouched.
|
|
90
|
+
"""
|
|
91
|
+
if not enabled:
|
|
92
|
+
return trace
|
|
93
|
+
|
|
94
|
+
def redact_value(value: object) -> object:
|
|
95
|
+
if isinstance(value, str):
|
|
96
|
+
return redact_text(value, rules=rules)
|
|
97
|
+
if isinstance(value, Mapping):
|
|
98
|
+
return {key: redact_value(item) for key, item in value.items()}
|
|
99
|
+
if isinstance(value, list):
|
|
100
|
+
return [redact_value(item) for item in value]
|
|
101
|
+
if isinstance(value, tuple):
|
|
102
|
+
return tuple(redact_value(item) for item in value)
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
return cast(_T, redact_value(trace))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Scar registry package; persistent storage remains a roadmap stub."""
|
grudge/registry/db.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Registry database boundary; this Phase 13 stretch item is a stub."""
|
|
2
|
+
|
|
3
|
+
from typing import NoReturn
|
|
4
|
+
|
|
5
|
+
from grudge._stubs import roadmap
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def open_registry() -> NoReturn:
|
|
9
|
+
"""Open the registry when the database stretch stage is implemented."""
|
|
10
|
+
roadmap("registry.db")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Scar fingerprint boundary; this Phase 13 stretch item is a stub."""
|
|
2
|
+
|
|
3
|
+
from typing import NoReturn
|
|
4
|
+
|
|
5
|
+
from grudge._stubs import roadmap
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def fingerprint() -> NoReturn:
|
|
9
|
+
"""Compute a fingerprint when registry work is implemented."""
|
|
10
|
+
roadmap("registry.fingerprint")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Flakiness tracking boundary; this Phase 13 stretch item is a stub."""
|
|
2
|
+
|
|
3
|
+
from typing import NoReturn
|
|
4
|
+
|
|
5
|
+
from grudge._stubs import roadmap
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def score_flakiness() -> NoReturn:
|
|
9
|
+
"""Score flakiness when registry work is implemented."""
|
|
10
|
+
roadmap("registry.flakiness")
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Scar retirement boundary; this Phase 13 stretch item is a stub."""
|
|
2
|
+
|
|
3
|
+
from typing import NoReturn
|
|
4
|
+
|
|
5
|
+
from grudge._stubs import roadmap
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def retire() -> NoReturn:
|
|
9
|
+
"""Retire a scar when registry work is implemented."""
|
|
10
|
+
roadmap("registry.retirement")
|