agentproof-sim 0.1.1__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.
- agentproof/__init__.py +41 -0
- agentproof/adapters/__init__.py +14 -0
- agentproof/adapters/base.py +19 -0
- agentproof/adapters/langchain.py +95 -0
- agentproof/adapters/native.py +23 -0
- agentproof/adapters/openai_agents.py +62 -0
- agentproof/api.py +99 -0
- agentproof/cli.py +172 -0
- agentproof/core/clock.py +18 -0
- agentproof/core/effects.py +109 -0
- agentproof/core/events.py +63 -0
- agentproof/core/faults.py +294 -0
- agentproof/core/invariant.py +109 -0
- agentproof/core/redaction.py +32 -0
- agentproof/core/result.py +109 -0
- agentproof/core/runner.py +216 -0
- agentproof/core/scenario.py +13 -0
- agentproof/core/trace.py +58 -0
- agentproof/core/world.py +126 -0
- agentproof/invariants/__init__.py +21 -0
- agentproof/invariants/builtin.py +96 -0
- agentproof/invariants/temporal.py +5 -0
- agentproof/mutations/__init__.py +61 -0
- agentproof/mutations/base.py +57 -0
- agentproof/mutations/duplication.py +15 -0
- agentproof/mutations/ordering.py +10 -0
- agentproof/mutations/state.py +15 -0
- agentproof/mutations/timing.py +9 -0
- agentproof/mutations/tool_faults.py +58 -0
- agentproof/py.typed +1 -0
- agentproof/pytest_plugin.py +40 -0
- agentproof/replay/player.py +74 -0
- agentproof/replay/recorder.py +29 -0
- agentproof/replay/schema.py +24 -0
- agentproof/reporting/__init__.py +7 -0
- agentproof/reporting/console.py +34 -0
- agentproof/reporting/json_report.py +47 -0
- agentproof/reporting/junit.py +66 -0
- agentproof/tools/definition.py +26 -0
- agentproof/tools/invocation.py +11 -0
- agentproof/tools/registry.py +154 -0
- agentproof_sim-0.1.1.dist-info/METADATA +407 -0
- agentproof_sim-0.1.1.dist-info/RECORD +46 -0
- agentproof_sim-0.1.1.dist-info/WHEEL +4 -0
- agentproof_sim-0.1.1.dist-info/entry_points.txt +5 -0
- agentproof_sim-0.1.1.dist-info/licenses/LICENSE +18 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ScheduledEvent(BaseModel):
|
|
10
|
+
model_config = ConfigDict(extra="forbid")
|
|
11
|
+
|
|
12
|
+
id: str
|
|
13
|
+
name: str
|
|
14
|
+
payload: dict[str, Any] = Field(default_factory=dict)
|
|
15
|
+
deliver_at: float
|
|
16
|
+
delivered: bool = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class EventQueue:
|
|
20
|
+
def __init__(self, world: Any) -> None:
|
|
21
|
+
self._world = world
|
|
22
|
+
self._events: list[ScheduledEvent] = []
|
|
23
|
+
|
|
24
|
+
def schedule(self, *, name: str, payload: dict[str, Any], delay: float = 0.0) -> ScheduledEvent:
|
|
25
|
+
event = ScheduledEvent(
|
|
26
|
+
id=self._world.next_id("event"),
|
|
27
|
+
name=name,
|
|
28
|
+
payload=copy.deepcopy(payload),
|
|
29
|
+
deliver_at=float(self._world.clock.now()) + float(delay),
|
|
30
|
+
)
|
|
31
|
+
self._events.append(event)
|
|
32
|
+
self._world.trace.record(
|
|
33
|
+
"event_scheduled",
|
|
34
|
+
name,
|
|
35
|
+
{"event_id": event.id, "payload": payload, "deliver_at": event.deliver_at},
|
|
36
|
+
)
|
|
37
|
+
return event
|
|
38
|
+
|
|
39
|
+
def deliver_due(self) -> list[ScheduledEvent]:
|
|
40
|
+
delivered: list[ScheduledEvent] = []
|
|
41
|
+
for event in self._events:
|
|
42
|
+
if event.delivered or event.deliver_at > self._world.clock.now():
|
|
43
|
+
continue
|
|
44
|
+
if self._world.faults.before_event_delivery(event):
|
|
45
|
+
continue
|
|
46
|
+
event.delivered = True
|
|
47
|
+
for index in range(self._world.faults.duplicate_event_count(event)):
|
|
48
|
+
payload = copy.deepcopy(event.payload)
|
|
49
|
+
delivered_event = event.model_copy(deep=True)
|
|
50
|
+
delivered_event.id = event.id if index == 0 else f"{event.id}_dup_{index}"
|
|
51
|
+
delivered.append(delivered_event)
|
|
52
|
+
self._world.trace.record(
|
|
53
|
+
"event_delivered",
|
|
54
|
+
event.name,
|
|
55
|
+
{"event_id": delivered_event.id, "payload": payload},
|
|
56
|
+
)
|
|
57
|
+
return delivered
|
|
58
|
+
|
|
59
|
+
def pending(self) -> list[ScheduledEvent]:
|
|
60
|
+
return [event.model_copy(deep=True) for event in self._events if not event.delivered]
|
|
61
|
+
|
|
62
|
+
def snapshot(self) -> list[dict[str, Any]]:
|
|
63
|
+
return [event.model_dump(mode="json") for event in self._events]
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
Severity = Literal["low", "medium", "high", "critical"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AgentProofError(Exception):
|
|
12
|
+
"""Base exception for AgentProof-controlled errors."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AgentProofToolError(AgentProofError):
|
|
16
|
+
code = "tool_error"
|
|
17
|
+
|
|
18
|
+
def __init__(self, tool_name: str, message: str | None = None) -> None:
|
|
19
|
+
self.tool_name = tool_name
|
|
20
|
+
super().__init__(message or f"{self.code}: {tool_name}")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ToolTimeoutError(AgentProofToolError, TimeoutError):
|
|
24
|
+
code = "timeout"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class ToolRateLimitError(AgentProofToolError):
|
|
28
|
+
code = "rate_limited"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ToolPermissionDeniedError(AgentProofToolError):
|
|
32
|
+
code = "permission_denied"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ToolInjectedError(AgentProofToolError):
|
|
36
|
+
code = "tool_error"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ToolValidationError(AgentProofToolError):
|
|
40
|
+
code = "validation_error"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class UnsupportedMutationError(AgentProofError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MutationSpec(BaseModel):
|
|
48
|
+
model_config = ConfigDict(extra="forbid")
|
|
49
|
+
|
|
50
|
+
type: str
|
|
51
|
+
target: str | None = None
|
|
52
|
+
occurrence: int | None = 1
|
|
53
|
+
params: dict[str, Any] = Field(default_factory=dict)
|
|
54
|
+
severity: Severity = "medium"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class PreCallAction:
|
|
59
|
+
return_value: Any = None
|
|
60
|
+
exception: AgentProofToolError | None = None
|
|
61
|
+
handled: bool = False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class FaultController:
|
|
65
|
+
def __init__(self, world: Any) -> None:
|
|
66
|
+
self._world = world
|
|
67
|
+
self._mutations: list[MutationSpec] = []
|
|
68
|
+
self._counts: dict[tuple[str, str, str | None], int] = {}
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def mutations(self) -> list[MutationSpec]:
|
|
72
|
+
return list(self._mutations)
|
|
73
|
+
|
|
74
|
+
def install(self, spec: MutationSpec) -> None:
|
|
75
|
+
if spec.type == "reorder_tool_results":
|
|
76
|
+
raise UnsupportedMutationError(
|
|
77
|
+
"reorder_tool_results requires a controlled parallel tool-result scheduler and "
|
|
78
|
+
"is not stable in this MVP"
|
|
79
|
+
)
|
|
80
|
+
self._mutations.append(spec)
|
|
81
|
+
|
|
82
|
+
def prepare_user_inputs(self, inputs: list[str]) -> list[str]:
|
|
83
|
+
spec = self._match("before_agent_input", None, "duplicate_user_request")
|
|
84
|
+
if spec is None:
|
|
85
|
+
return list(inputs)
|
|
86
|
+
if not inputs:
|
|
87
|
+
return []
|
|
88
|
+
index = int(spec.params.get("index", 0))
|
|
89
|
+
index = min(max(index, 0), len(inputs) - 1)
|
|
90
|
+
duplicated = list(inputs)
|
|
91
|
+
duplicated.insert(index + 1, inputs[index])
|
|
92
|
+
self._world.trace.record(
|
|
93
|
+
"fault",
|
|
94
|
+
"duplicate_user_request",
|
|
95
|
+
{"mutation": spec.model_dump(mode="json"), "input": inputs[index]},
|
|
96
|
+
)
|
|
97
|
+
self._world.trace.record("user_input", "user", {"text": inputs[index], "duplicate": True})
|
|
98
|
+
return duplicated
|
|
99
|
+
|
|
100
|
+
def before_tool_call(
|
|
101
|
+
self,
|
|
102
|
+
*,
|
|
103
|
+
tool_name: str,
|
|
104
|
+
args: dict[str, Any],
|
|
105
|
+
invocation_id: str,
|
|
106
|
+
) -> PreCallAction | None:
|
|
107
|
+
del args
|
|
108
|
+
latency = self._match("before_tool_call", tool_name, "tool_latency")
|
|
109
|
+
if latency is not None:
|
|
110
|
+
seconds = float(latency.params.get("seconds", 1.0))
|
|
111
|
+
self._world.clock.advance(seconds)
|
|
112
|
+
self._world.trace.record(
|
|
113
|
+
"fault",
|
|
114
|
+
"tool_latency",
|
|
115
|
+
{
|
|
116
|
+
"mutation": latency.model_dump(mode="json"),
|
|
117
|
+
"invocation_id": invocation_id,
|
|
118
|
+
"advanced_seconds": seconds,
|
|
119
|
+
},
|
|
120
|
+
)
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
timeout = self._match("before_tool_call", tool_name, "tool_timeout")
|
|
124
|
+
if timeout is not None:
|
|
125
|
+
self._world.trace.record(
|
|
126
|
+
"fault",
|
|
127
|
+
"tool_timeout",
|
|
128
|
+
{"mutation": timeout.model_dump(mode="json"), "invocation_id": invocation_id},
|
|
129
|
+
)
|
|
130
|
+
return PreCallAction(
|
|
131
|
+
exception=ToolTimeoutError(tool_name, "tool timed out before commit"),
|
|
132
|
+
handled=True,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
injected = self._match("before_tool_call", tool_name, "tool_error")
|
|
136
|
+
if injected is not None:
|
|
137
|
+
self._world.trace.record(
|
|
138
|
+
"fault",
|
|
139
|
+
"tool_error",
|
|
140
|
+
{"mutation": injected.model_dump(mode="json"), "invocation_id": invocation_id},
|
|
141
|
+
)
|
|
142
|
+
message = str(injected.params.get("message", "injected tool error"))
|
|
143
|
+
return PreCallAction(exception=ToolInjectedError(tool_name, message), handled=True)
|
|
144
|
+
|
|
145
|
+
rate_limited = self._match("before_tool_call", tool_name, "rate_limited")
|
|
146
|
+
if rate_limited is not None:
|
|
147
|
+
self._world.trace.record(
|
|
148
|
+
"fault",
|
|
149
|
+
"rate_limited",
|
|
150
|
+
{"mutation": rate_limited.model_dump(mode="json"), "invocation_id": invocation_id},
|
|
151
|
+
)
|
|
152
|
+
return PreCallAction(
|
|
153
|
+
exception=ToolRateLimitError(tool_name, "tool call was rate limited"),
|
|
154
|
+
handled=True,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
denied = self._match("before_tool_call", tool_name, "permission_denied")
|
|
158
|
+
if denied is not None:
|
|
159
|
+
self._world.trace.record(
|
|
160
|
+
"fault",
|
|
161
|
+
"permission_denied",
|
|
162
|
+
{"mutation": denied.model_dump(mode="json"), "invocation_id": invocation_id},
|
|
163
|
+
)
|
|
164
|
+
return PreCallAction(
|
|
165
|
+
exception=ToolPermissionDeniedError(tool_name, "permission denied"),
|
|
166
|
+
handled=True,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
stale = self._match("before_tool_call", tool_name, "stale_state")
|
|
170
|
+
if stale is not None:
|
|
171
|
+
if "value" not in stale.params:
|
|
172
|
+
raise UnsupportedMutationError("stale_state requires params['value']")
|
|
173
|
+
self._world.trace.record(
|
|
174
|
+
"fault",
|
|
175
|
+
"stale_state",
|
|
176
|
+
{"mutation": stale.model_dump(mode="json"), "invocation_id": invocation_id},
|
|
177
|
+
)
|
|
178
|
+
return PreCallAction(return_value=stale.params["value"], handled=True)
|
|
179
|
+
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
def after_commit_before_response(self, *, tool_name: str, invocation_id: str) -> None:
|
|
183
|
+
spec = self._match("after_commit_before_response", tool_name, "timeout_after_commit")
|
|
184
|
+
if spec is None:
|
|
185
|
+
return
|
|
186
|
+
self._world.trace.record(
|
|
187
|
+
"fault",
|
|
188
|
+
"timeout_after_commit",
|
|
189
|
+
{"mutation": spec.model_dump(mode="json"), "invocation_id": invocation_id},
|
|
190
|
+
)
|
|
191
|
+
raise ToolTimeoutError(tool_name, "tool timed out after commit")
|
|
192
|
+
|
|
193
|
+
def after_tool_response(
|
|
194
|
+
self,
|
|
195
|
+
*,
|
|
196
|
+
tool_name: str,
|
|
197
|
+
invocation_id: str,
|
|
198
|
+
response: Any,
|
|
199
|
+
) -> Any:
|
|
200
|
+
malformed = self._match("after_tool_response", tool_name, "malformed_response")
|
|
201
|
+
if malformed is not None:
|
|
202
|
+
self._world.trace.record(
|
|
203
|
+
"fault",
|
|
204
|
+
"malformed_response",
|
|
205
|
+
{"mutation": malformed.model_dump(mode="json"), "invocation_id": invocation_id},
|
|
206
|
+
)
|
|
207
|
+
return malformed.params.get("value", "<<agentproof:malformed-response>>")
|
|
208
|
+
|
|
209
|
+
missing = self._match("after_tool_response", tool_name, "missing_field")
|
|
210
|
+
if missing is not None:
|
|
211
|
+
field = missing.params.get("field")
|
|
212
|
+
if not isinstance(field, str):
|
|
213
|
+
raise UnsupportedMutationError("missing_field requires params['field']")
|
|
214
|
+
self._world.trace.record(
|
|
215
|
+
"fault",
|
|
216
|
+
"missing_field",
|
|
217
|
+
{
|
|
218
|
+
"mutation": missing.model_dump(mode="json"),
|
|
219
|
+
"invocation_id": invocation_id,
|
|
220
|
+
"field": field,
|
|
221
|
+
},
|
|
222
|
+
)
|
|
223
|
+
if isinstance(response, dict):
|
|
224
|
+
cloned = dict(response)
|
|
225
|
+
cloned.pop(field, None)
|
|
226
|
+
return cloned
|
|
227
|
+
return response
|
|
228
|
+
|
|
229
|
+
duplicate = self._match("after_tool_response", tool_name, "duplicate_tool_result")
|
|
230
|
+
if duplicate is not None:
|
|
231
|
+
self._world.trace.record(
|
|
232
|
+
"fault",
|
|
233
|
+
"duplicate_tool_result",
|
|
234
|
+
{"mutation": duplicate.model_dump(mode="json"), "invocation_id": invocation_id},
|
|
235
|
+
)
|
|
236
|
+
return {"agentproof_duplicate_tool_result": [response, response]}
|
|
237
|
+
|
|
238
|
+
changed = self._match("after_tool_response", tool_name, "state_changed_after_read")
|
|
239
|
+
if changed is not None:
|
|
240
|
+
path = changed.params.get("path")
|
|
241
|
+
if not isinstance(path, list) or not path:
|
|
242
|
+
raise UnsupportedMutationError("state_changed_after_read requires params['path']")
|
|
243
|
+
value = changed.params.get("value")
|
|
244
|
+
self._world.set_path(path, value)
|
|
245
|
+
self._world.trace.record(
|
|
246
|
+
"fault",
|
|
247
|
+
"state_changed_after_read",
|
|
248
|
+
{
|
|
249
|
+
"mutation": changed.model_dump(mode="json"),
|
|
250
|
+
"invocation_id": invocation_id,
|
|
251
|
+
"path": path,
|
|
252
|
+
"value": value,
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
return response
|
|
256
|
+
|
|
257
|
+
return response
|
|
258
|
+
|
|
259
|
+
def before_event_delivery(self, event: Any) -> bool:
|
|
260
|
+
delayed = self._match("before_event_delivery", event.name, "delayed_event")
|
|
261
|
+
if delayed is None:
|
|
262
|
+
return False
|
|
263
|
+
delay = float(delayed.params.get("seconds", 1.0))
|
|
264
|
+
event.deliver_at += delay
|
|
265
|
+
self._world.trace.record(
|
|
266
|
+
"fault",
|
|
267
|
+
"delayed_event",
|
|
268
|
+
{"mutation": delayed.model_dump(mode="json"), "event_id": event.id, "seconds": delay},
|
|
269
|
+
)
|
|
270
|
+
return True
|
|
271
|
+
|
|
272
|
+
def duplicate_event_count(self, event: Any) -> int:
|
|
273
|
+
duplicate = self._match("on_event_delivery", event.name, "duplicate_event")
|
|
274
|
+
if duplicate is None:
|
|
275
|
+
return 1
|
|
276
|
+
count = int(duplicate.params.get("count", 2))
|
|
277
|
+
self._world.trace.record(
|
|
278
|
+
"fault",
|
|
279
|
+
"duplicate_event",
|
|
280
|
+
{"mutation": duplicate.model_dump(mode="json"), "event_id": event.id, "count": count},
|
|
281
|
+
)
|
|
282
|
+
return max(count, 1)
|
|
283
|
+
|
|
284
|
+
def _match(self, stage: str, target: str | None, mutation_type: str) -> MutationSpec | None:
|
|
285
|
+
for spec in self._mutations:
|
|
286
|
+
if spec.type != mutation_type:
|
|
287
|
+
continue
|
|
288
|
+
if spec.target is not None and spec.target != target:
|
|
289
|
+
continue
|
|
290
|
+
key = (stage, mutation_type, spec.target)
|
|
291
|
+
self._counts[key] = self._counts.get(key, 0) + 1
|
|
292
|
+
if spec.occurrence is None or self._counts[key] == spec.occurrence:
|
|
293
|
+
return spec
|
|
294
|
+
return None
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import traceback
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from agentproof.core.result import InvariantFailure
|
|
10
|
+
|
|
11
|
+
InvariantFunc = Callable[[Any], Awaitable[None] | None]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Invariant:
|
|
16
|
+
name: str
|
|
17
|
+
func: InvariantFunc
|
|
18
|
+
severity: str = "high"
|
|
19
|
+
|
|
20
|
+
async def check(self, world: Any) -> InvariantFailure | None:
|
|
21
|
+
try:
|
|
22
|
+
result = self.func(world)
|
|
23
|
+
if inspect.isawaitable(result):
|
|
24
|
+
await result
|
|
25
|
+
except AssertionError as exc:
|
|
26
|
+
return InvariantFailure(
|
|
27
|
+
name=self.name,
|
|
28
|
+
message=str(exc) or "assertion failed",
|
|
29
|
+
exception_type=type(exc).__name__,
|
|
30
|
+
traceback="".join(
|
|
31
|
+
traceback.format_exception(type(exc), exc, exc.__traceback__, limit=6)
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
async def check_programming_error(self, world: Any) -> InvariantFailure | None:
|
|
37
|
+
try:
|
|
38
|
+
result = self.func(world)
|
|
39
|
+
if inspect.isawaitable(result):
|
|
40
|
+
await result
|
|
41
|
+
except AssertionError:
|
|
42
|
+
raise
|
|
43
|
+
except Exception as exc:
|
|
44
|
+
return InvariantFailure(
|
|
45
|
+
name=self.name,
|
|
46
|
+
message=str(exc),
|
|
47
|
+
exception_type=type(exc).__name__,
|
|
48
|
+
traceback="".join(
|
|
49
|
+
traceback.format_exception(type(exc), exc, exc.__traceback__, limit=6)
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def invariant(
|
|
56
|
+
func: InvariantFunc | None = None, *, name: str | None = None, severity: str = "high"
|
|
57
|
+
) -> Any:
|
|
58
|
+
def wrap(inner: InvariantFunc) -> Invariant:
|
|
59
|
+
return Invariant(name=name or inner.__name__, func=inner, severity=severity)
|
|
60
|
+
|
|
61
|
+
if func is None:
|
|
62
|
+
return wrap
|
|
63
|
+
return wrap(func)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def evaluate_invariants(
|
|
67
|
+
invariants: list[Invariant],
|
|
68
|
+
world: Any,
|
|
69
|
+
) -> tuple[list[InvariantFailure], list[InvariantFailure]]:
|
|
70
|
+
assertion_failures: list[InvariantFailure] = []
|
|
71
|
+
programming_errors: list[InvariantFailure] = []
|
|
72
|
+
for item in invariants:
|
|
73
|
+
try:
|
|
74
|
+
result = item.func(world)
|
|
75
|
+
if inspect.isawaitable(result):
|
|
76
|
+
await result
|
|
77
|
+
except AssertionError as exc:
|
|
78
|
+
failure = InvariantFailure(
|
|
79
|
+
name=item.name,
|
|
80
|
+
message=str(exc) or "assertion failed",
|
|
81
|
+
exception_type=type(exc).__name__,
|
|
82
|
+
traceback="".join(
|
|
83
|
+
traceback.format_exception(type(exc), exc, exc.__traceback__, limit=6)
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
assertion_failures.append(failure)
|
|
87
|
+
world.trace.record(
|
|
88
|
+
"invariant_fail",
|
|
89
|
+
item.name,
|
|
90
|
+
{"message": failure.message, "exception_type": failure.exception_type},
|
|
91
|
+
)
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
failure = InvariantFailure(
|
|
94
|
+
name=item.name,
|
|
95
|
+
message=str(exc),
|
|
96
|
+
exception_type=type(exc).__name__,
|
|
97
|
+
traceback="".join(
|
|
98
|
+
traceback.format_exception(type(exc), exc, exc.__traceback__, limit=6)
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
programming_errors.append(failure)
|
|
102
|
+
world.trace.record(
|
|
103
|
+
"invariant_fail",
|
|
104
|
+
item.name,
|
|
105
|
+
{"message": failure.message, "exception_type": failure.exception_type},
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
world.trace.record("invariant_pass", item.name, {})
|
|
109
|
+
return assertion_failures, programming_errors
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
SECRET_KEY_FRAGMENTS = ("api_key", "authorization", "token", "password", "secret")
|
|
7
|
+
SECRET_STRING_PREFIXES = ("sk-", "Bearer ")
|
|
8
|
+
REDACTED = "[REDACTED]"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def redact(value: Any) -> Any:
|
|
12
|
+
"""Return a JSON-safe copy with obvious secrets removed."""
|
|
13
|
+
|
|
14
|
+
if isinstance(value, Mapping):
|
|
15
|
+
redacted: dict[str, Any] = {}
|
|
16
|
+
for key, item in value.items():
|
|
17
|
+
key_text = str(key)
|
|
18
|
+
if _is_secret_key(key_text):
|
|
19
|
+
redacted[key_text] = REDACTED
|
|
20
|
+
else:
|
|
21
|
+
redacted[key_text] = redact(item)
|
|
22
|
+
return redacted
|
|
23
|
+
if isinstance(value, str):
|
|
24
|
+
return REDACTED if value.startswith(SECRET_STRING_PREFIXES) else value
|
|
25
|
+
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
|
26
|
+
return [redact(item) for item in value]
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _is_secret_key(key: str) -> bool:
|
|
31
|
+
lowered = key.lower().replace("-", "_")
|
|
32
|
+
return any(fragment in lowered for fragment in SECRET_KEY_FRAGMENTS)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Literal
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
7
|
+
|
|
8
|
+
from agentproof.core.effects import Effect
|
|
9
|
+
from agentproof.core.faults import MutationSpec, Severity
|
|
10
|
+
from agentproof.core.trace import TraceEvent
|
|
11
|
+
|
|
12
|
+
RunStatus = Literal[
|
|
13
|
+
"PASS",
|
|
14
|
+
"INVARIANT_FAILURE",
|
|
15
|
+
"BASELINE_FAILURE",
|
|
16
|
+
"AGENT_ERROR",
|
|
17
|
+
"TOOL_ERROR_EXPECTED",
|
|
18
|
+
"TEST_ERROR",
|
|
19
|
+
"ADAPTER_ERROR",
|
|
20
|
+
"UNSUPPORTED",
|
|
21
|
+
"SKIPPED",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
SEVERITY_ORDER: dict[Severity, int] = {
|
|
25
|
+
"low": 0,
|
|
26
|
+
"medium": 1,
|
|
27
|
+
"high": 2,
|
|
28
|
+
"critical": 3,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class InvariantFailure(BaseModel):
|
|
33
|
+
model_config = ConfigDict(extra="forbid")
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
message: str
|
|
37
|
+
exception_type: str
|
|
38
|
+
traceback: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class RunResult(BaseModel):
|
|
42
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
43
|
+
|
|
44
|
+
run_id: str
|
|
45
|
+
scenario: str
|
|
46
|
+
adapter: str
|
|
47
|
+
seed: int
|
|
48
|
+
mutation: MutationSpec | None = None
|
|
49
|
+
severity: Severity = "medium"
|
|
50
|
+
status: RunStatus = "PASS"
|
|
51
|
+
final_output: str | None = None
|
|
52
|
+
trace: list[TraceEvent] = Field(default_factory=list)
|
|
53
|
+
effects: list[Effect] = Field(default_factory=list)
|
|
54
|
+
violated_invariants: list[str] = Field(default_factory=list)
|
|
55
|
+
invariant_failures: list[InvariantFailure] = Field(default_factory=list)
|
|
56
|
+
error_message: str | None = None
|
|
57
|
+
artifact_path: Path | None = None
|
|
58
|
+
initial_world_snapshot: dict[str, Any] = Field(default_factory=dict)
|
|
59
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def failed(self) -> bool:
|
|
63
|
+
return self.status in {
|
|
64
|
+
"INVARIANT_FAILURE",
|
|
65
|
+
"BASELINE_FAILURE",
|
|
66
|
+
"AGENT_ERROR",
|
|
67
|
+
"TEST_ERROR",
|
|
68
|
+
"ADAPTER_ERROR",
|
|
69
|
+
"UNSUPPORTED",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def name(self) -> str:
|
|
74
|
+
if self.mutation is None:
|
|
75
|
+
return f"{self.scenario}:baseline"
|
|
76
|
+
target = f":{self.mutation.target}" if self.mutation.target else ""
|
|
77
|
+
return f"{self.scenario}:{self.mutation.type}{target}"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class SuiteResult(BaseModel):
|
|
81
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
82
|
+
|
|
83
|
+
schema_version: int = 1
|
|
84
|
+
run_id: str
|
|
85
|
+
results: list[RunResult]
|
|
86
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def failures(self) -> list[RunResult]:
|
|
90
|
+
return [result for result in self.results if result.failed]
|
|
91
|
+
|
|
92
|
+
@property
|
|
93
|
+
def failed_count(self) -> int:
|
|
94
|
+
return len(self.failures)
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def passed_count(self) -> int:
|
|
98
|
+
return len([result for result in self.results if result.status == "PASS"])
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def total_count(self) -> int:
|
|
102
|
+
return len(self.results)
|
|
103
|
+
|
|
104
|
+
def exit_code(self, fail_on: Severity = "high") -> int:
|
|
105
|
+
threshold = SEVERITY_ORDER[fail_on]
|
|
106
|
+
for result in self.failures:
|
|
107
|
+
if SEVERITY_ORDER[result.severity] >= threshold:
|
|
108
|
+
return 1
|
|
109
|
+
return 0
|