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,216 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
import uuid
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from agentproof.adapters.base import AgentRunResult
|
|
9
|
+
from agentproof.core.faults import AgentProofError, MutationSpec, UnsupportedMutationError
|
|
10
|
+
from agentproof.core.invariant import Invariant, evaluate_invariants
|
|
11
|
+
from agentproof.core.result import InvariantFailure, RunResult, SuiteResult
|
|
12
|
+
from agentproof.core.scenario import Scenario
|
|
13
|
+
from agentproof.core.world import World
|
|
14
|
+
from agentproof.mutations.base import Mutation
|
|
15
|
+
from agentproof.replay.recorder import write_repro_artifact
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ScenarioRunner:
|
|
19
|
+
def __init__(
|
|
20
|
+
self,
|
|
21
|
+
*,
|
|
22
|
+
adapter: Any,
|
|
23
|
+
scenarios: list[Scenario],
|
|
24
|
+
invariants: list[Invariant],
|
|
25
|
+
mutations: list[Mutation],
|
|
26
|
+
) -> None:
|
|
27
|
+
self.adapter = adapter
|
|
28
|
+
self.scenarios = scenarios
|
|
29
|
+
self.invariants = invariants
|
|
30
|
+
self.mutations = mutations
|
|
31
|
+
|
|
32
|
+
async def run(
|
|
33
|
+
self,
|
|
34
|
+
*,
|
|
35
|
+
scenario: str | None = None,
|
|
36
|
+
mutations: list[Mutation] | None = None,
|
|
37
|
+
mutation_name: str | None = None,
|
|
38
|
+
seed: int = 42,
|
|
39
|
+
artifacts_dir: Path = Path(".agentproof/runs"),
|
|
40
|
+
store_artifacts: bool = True,
|
|
41
|
+
source_path: str | None = None,
|
|
42
|
+
suite_name: str | None = None,
|
|
43
|
+
) -> SuiteResult:
|
|
44
|
+
selected_scenarios = [item for item in self.scenarios if scenario in (None, item.name)]
|
|
45
|
+
if not selected_scenarios:
|
|
46
|
+
raise ValueError(f"unknown scenario: {scenario}")
|
|
47
|
+
|
|
48
|
+
suite_run_id = f"ap_{uuid.uuid4().hex[:12]}"
|
|
49
|
+
results: list[RunResult] = []
|
|
50
|
+
for scenario_item in selected_scenarios:
|
|
51
|
+
baseline = await self._run_one(
|
|
52
|
+
scenario=scenario_item,
|
|
53
|
+
mutation=None,
|
|
54
|
+
seed=seed,
|
|
55
|
+
suite_run_id=suite_run_id,
|
|
56
|
+
artifacts_dir=artifacts_dir,
|
|
57
|
+
store_artifacts=store_artifacts,
|
|
58
|
+
source_path=source_path,
|
|
59
|
+
suite_name=suite_name,
|
|
60
|
+
)
|
|
61
|
+
results.append(baseline)
|
|
62
|
+
if baseline.status != "PASS":
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
candidate_mutations = mutations if mutations is not None else self.mutations
|
|
66
|
+
for mutation in candidate_mutations:
|
|
67
|
+
if mutation_name is not None and mutation.spec.type != mutation_name:
|
|
68
|
+
continue
|
|
69
|
+
expanded = await self._expand_mutation(scenario_item, mutation, seed)
|
|
70
|
+
for item in expanded:
|
|
71
|
+
results.append(
|
|
72
|
+
await self._run_one(
|
|
73
|
+
scenario=scenario_item,
|
|
74
|
+
mutation=item,
|
|
75
|
+
seed=seed,
|
|
76
|
+
suite_run_id=suite_run_id,
|
|
77
|
+
artifacts_dir=artifacts_dir,
|
|
78
|
+
store_artifacts=store_artifacts,
|
|
79
|
+
source_path=source_path,
|
|
80
|
+
suite_name=suite_name,
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
return SuiteResult(
|
|
84
|
+
run_id=suite_run_id,
|
|
85
|
+
results=results,
|
|
86
|
+
metadata={"source_path": source_path, "suite_name": suite_name},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
async def _expand_mutation(
|
|
90
|
+
self,
|
|
91
|
+
scenario: Scenario,
|
|
92
|
+
mutation: Mutation,
|
|
93
|
+
seed: int,
|
|
94
|
+
) -> list[Mutation]:
|
|
95
|
+
if mutation.target is not None or mutation.spec.type in {
|
|
96
|
+
"duplicate_user_request",
|
|
97
|
+
"delayed_event",
|
|
98
|
+
"duplicate_event",
|
|
99
|
+
"reorder_tool_results",
|
|
100
|
+
}:
|
|
101
|
+
return [mutation]
|
|
102
|
+
world = World(seed=seed)
|
|
103
|
+
await _maybe_await(scenario.func(world))
|
|
104
|
+
tools = world.tools.all()
|
|
105
|
+
if not tools:
|
|
106
|
+
return [mutation]
|
|
107
|
+
result: list[Mutation] = []
|
|
108
|
+
for tool in tools:
|
|
109
|
+
if mutation.spec.type == "timeout_after_commit" and tool.effect not in {
|
|
110
|
+
"write",
|
|
111
|
+
"delete",
|
|
112
|
+
"external",
|
|
113
|
+
"financial",
|
|
114
|
+
"privileged",
|
|
115
|
+
}:
|
|
116
|
+
continue
|
|
117
|
+
result.append(
|
|
118
|
+
type(mutation)(
|
|
119
|
+
target=tool.name,
|
|
120
|
+
occurrence=mutation.occurrence,
|
|
121
|
+
params=mutation.params,
|
|
122
|
+
severity=mutation.severity,
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
return result or [mutation]
|
|
126
|
+
|
|
127
|
+
async def _run_one(
|
|
128
|
+
self,
|
|
129
|
+
*,
|
|
130
|
+
scenario: Scenario,
|
|
131
|
+
mutation: Mutation | None,
|
|
132
|
+
seed: int,
|
|
133
|
+
suite_run_id: str,
|
|
134
|
+
artifacts_dir: Path,
|
|
135
|
+
store_artifacts: bool,
|
|
136
|
+
source_path: str | None,
|
|
137
|
+
suite_name: str | None,
|
|
138
|
+
) -> RunResult:
|
|
139
|
+
world = World(seed=seed, metadata={"source_path": source_path, "suite_name": suite_name})
|
|
140
|
+
run_id = world.next_id("run")
|
|
141
|
+
run_id = f"{suite_run_id}_{run_id}"
|
|
142
|
+
status = "PASS"
|
|
143
|
+
output: str | None = None
|
|
144
|
+
error_message: str | None = None
|
|
145
|
+
invariant_failures: list[InvariantFailure] = []
|
|
146
|
+
invariant_errors: list[InvariantFailure] = []
|
|
147
|
+
initial_snapshot: dict[str, Any] = {}
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
await _maybe_await(scenario.func(world))
|
|
151
|
+
initial_snapshot = world.snapshot()
|
|
152
|
+
if mutation is not None:
|
|
153
|
+
mutation.install(world)
|
|
154
|
+
inputs = world.faults.prepare_user_inputs(world.user_inputs)
|
|
155
|
+
for user_input in inputs:
|
|
156
|
+
adapter_result = await self.adapter.run(world=world, user_input=user_input)
|
|
157
|
+
output = _stringify_output(adapter_result)
|
|
158
|
+
world.trace.record(
|
|
159
|
+
"agent_output",
|
|
160
|
+
getattr(self.adapter, "name", type(self.adapter).__name__),
|
|
161
|
+
{"output": output, "metadata": getattr(adapter_result, "metadata", {})},
|
|
162
|
+
)
|
|
163
|
+
except UnsupportedMutationError as exc:
|
|
164
|
+
status = "UNSUPPORTED"
|
|
165
|
+
error_message = str(exc)
|
|
166
|
+
except AgentProofError as exc:
|
|
167
|
+
status = "AGENT_ERROR"
|
|
168
|
+
error_message = str(exc)
|
|
169
|
+
except Exception as exc:
|
|
170
|
+
status = "AGENT_ERROR"
|
|
171
|
+
error_message = f"{type(exc).__name__}: {exc}"
|
|
172
|
+
|
|
173
|
+
if status == "PASS":
|
|
174
|
+
invariant_failures, invariant_errors = await evaluate_invariants(self.invariants, world)
|
|
175
|
+
if invariant_errors:
|
|
176
|
+
status = "TEST_ERROR"
|
|
177
|
+
error_message = invariant_errors[0].message
|
|
178
|
+
elif invariant_failures:
|
|
179
|
+
status = "BASELINE_FAILURE" if mutation is None else "INVARIANT_FAILURE"
|
|
180
|
+
error_message = invariant_failures[0].message
|
|
181
|
+
|
|
182
|
+
mutation_spec: MutationSpec | None = mutation.spec if mutation is not None else None
|
|
183
|
+
result = RunResult(
|
|
184
|
+
run_id=run_id,
|
|
185
|
+
scenario=scenario.name,
|
|
186
|
+
adapter=getattr(self.adapter, "name", type(self.adapter).__name__),
|
|
187
|
+
seed=seed,
|
|
188
|
+
mutation=mutation_spec,
|
|
189
|
+
severity=mutation_spec.severity if mutation_spec else "high",
|
|
190
|
+
status=status, # type: ignore[arg-type]
|
|
191
|
+
final_output=output,
|
|
192
|
+
trace=world.trace.all(),
|
|
193
|
+
effects=world.effects.all(),
|
|
194
|
+
violated_invariants=[failure.name for failure in invariant_failures],
|
|
195
|
+
invariant_failures=invariant_failures + invariant_errors,
|
|
196
|
+
error_message=error_message,
|
|
197
|
+
initial_world_snapshot=initial_snapshot,
|
|
198
|
+
metadata={"source_path": source_path, "suite_name": suite_name},
|
|
199
|
+
)
|
|
200
|
+
if store_artifacts and result.failed:
|
|
201
|
+
result.artifact_path = write_repro_artifact(result, artifacts_dir)
|
|
202
|
+
return result
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
async def _maybe_await(value: Any) -> Any:
|
|
206
|
+
if inspect.isawaitable(value):
|
|
207
|
+
return await value
|
|
208
|
+
return value
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _stringify_output(adapter_result: AgentRunResult | Any) -> str | None:
|
|
212
|
+
if isinstance(adapter_result, AgentRunResult):
|
|
213
|
+
return adapter_result.final_output
|
|
214
|
+
if adapter_result is None:
|
|
215
|
+
return None
|
|
216
|
+
return str(adapter_result)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Awaitable, Callable
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
ScenarioFunc = Callable[[Any], Awaitable[None] | None]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Scenario:
|
|
12
|
+
name: str
|
|
13
|
+
func: ScenarioFunc
|
agentproof/core/trace.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
from agentproof.core.redaction import redact
|
|
8
|
+
|
|
9
|
+
TraceKind = Literal[
|
|
10
|
+
"user_input",
|
|
11
|
+
"tool_call",
|
|
12
|
+
"tool_result",
|
|
13
|
+
"tool_error",
|
|
14
|
+
"effect",
|
|
15
|
+
"fault",
|
|
16
|
+
"state_change",
|
|
17
|
+
"event_scheduled",
|
|
18
|
+
"event_delivered",
|
|
19
|
+
"agent_output",
|
|
20
|
+
"invariant_pass",
|
|
21
|
+
"invariant_fail",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class TraceEvent(BaseModel):
|
|
26
|
+
model_config = ConfigDict(extra="forbid")
|
|
27
|
+
|
|
28
|
+
seq: int
|
|
29
|
+
timestamp: float
|
|
30
|
+
kind: TraceKind
|
|
31
|
+
name: str
|
|
32
|
+
data: dict[str, Any] = Field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TraceRecorder:
|
|
36
|
+
def __init__(self, *, clock: Any) -> None:
|
|
37
|
+
self._clock = clock
|
|
38
|
+
self._events: list[TraceEvent] = []
|
|
39
|
+
|
|
40
|
+
def record(self, kind: TraceKind, name: str, data: dict[str, Any] | None = None) -> TraceEvent:
|
|
41
|
+
event = TraceEvent(
|
|
42
|
+
seq=len(self._events) + 1,
|
|
43
|
+
timestamp=float(self._clock.now()),
|
|
44
|
+
kind=kind,
|
|
45
|
+
name=name,
|
|
46
|
+
data=redact(data or {}),
|
|
47
|
+
)
|
|
48
|
+
self._events.append(event)
|
|
49
|
+
return event
|
|
50
|
+
|
|
51
|
+
def all(self) -> list[TraceEvent]:
|
|
52
|
+
return list(self._events)
|
|
53
|
+
|
|
54
|
+
def clear(self) -> None:
|
|
55
|
+
self._events.clear()
|
|
56
|
+
|
|
57
|
+
def snapshot(self) -> list[dict[str, Any]]:
|
|
58
|
+
return [event.model_dump(mode="json") for event in self._events]
|
agentproof/core/world.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import copy
|
|
4
|
+
import random
|
|
5
|
+
from collections.abc import Iterator, MutableMapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from agentproof.core.clock import VirtualClock
|
|
9
|
+
from agentproof.core.effects import EffectLedger
|
|
10
|
+
from agentproof.core.events import EventQueue
|
|
11
|
+
from agentproof.core.faults import FaultController
|
|
12
|
+
from agentproof.core.redaction import redact
|
|
13
|
+
from agentproof.core.trace import TraceRecorder
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class StateStore(MutableMapping[str, Any]):
|
|
17
|
+
def __init__(self, world: World, data: dict[str, Any] | None = None) -> None:
|
|
18
|
+
object.__setattr__(self, "_world", world)
|
|
19
|
+
object.__setattr__(self, "_data", copy.deepcopy(data or {}))
|
|
20
|
+
object.__setattr__(self, "_trace_enabled", True)
|
|
21
|
+
|
|
22
|
+
def __getitem__(self, key: str) -> Any:
|
|
23
|
+
return self._data[key]
|
|
24
|
+
|
|
25
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
26
|
+
self._data[key] = value
|
|
27
|
+
if self._trace_enabled:
|
|
28
|
+
self._world.trace.record(
|
|
29
|
+
"state_change", key, {"path": [key], "value": copy.deepcopy(value)}
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
def __delitem__(self, key: str) -> None:
|
|
33
|
+
del self._data[key]
|
|
34
|
+
if self._trace_enabled:
|
|
35
|
+
self._world.trace.record("state_change", key, {"path": [key], "deleted": True})
|
|
36
|
+
|
|
37
|
+
def __iter__(self) -> Iterator[str]:
|
|
38
|
+
return iter(self._data)
|
|
39
|
+
|
|
40
|
+
def __len__(self) -> int:
|
|
41
|
+
return len(self._data)
|
|
42
|
+
|
|
43
|
+
def __getattr__(self, key: str) -> Any:
|
|
44
|
+
try:
|
|
45
|
+
return self._data[key]
|
|
46
|
+
except KeyError as exc:
|
|
47
|
+
raise AttributeError(key) from exc
|
|
48
|
+
|
|
49
|
+
def __setattr__(self, key: str, value: Any) -> None:
|
|
50
|
+
if key.startswith("_"):
|
|
51
|
+
object.__setattr__(self, key, value)
|
|
52
|
+
else:
|
|
53
|
+
self[key] = value
|
|
54
|
+
|
|
55
|
+
def snapshot(self) -> dict[str, Any]:
|
|
56
|
+
return copy.deepcopy(self._data)
|
|
57
|
+
|
|
58
|
+
def replace(self, data: dict[str, Any]) -> None:
|
|
59
|
+
self._trace_enabled = False
|
|
60
|
+
try:
|
|
61
|
+
self._data.clear()
|
|
62
|
+
self._data.update(copy.deepcopy(data))
|
|
63
|
+
finally:
|
|
64
|
+
self._trace_enabled = True
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class World:
|
|
68
|
+
"""Stateful simulated environment exposed to an agent through virtual tools."""
|
|
69
|
+
|
|
70
|
+
def __init__(self, *, seed: int = 0, metadata: dict[str, Any] | None = None) -> None:
|
|
71
|
+
self.seed = seed
|
|
72
|
+
self.random = random.Random(seed)
|
|
73
|
+
self.clock = VirtualClock()
|
|
74
|
+
self.trace = TraceRecorder(clock=self.clock)
|
|
75
|
+
self.state = StateStore(self)
|
|
76
|
+
self.effects = EffectLedger()
|
|
77
|
+
self.faults = FaultController(self)
|
|
78
|
+
self.events = EventQueue(self)
|
|
79
|
+
self.metadata: dict[str, Any] = dict(metadata or {})
|
|
80
|
+
self.user_inputs: list[str] = []
|
|
81
|
+
self._id_counters: dict[str, int] = {}
|
|
82
|
+
|
|
83
|
+
from agentproof.tools.registry import ToolRegistry
|
|
84
|
+
|
|
85
|
+
self.tools = ToolRegistry(self)
|
|
86
|
+
|
|
87
|
+
def input(self, text: str) -> None:
|
|
88
|
+
self.user_inputs.append(text)
|
|
89
|
+
self.trace.record("user_input", "user", {"text": text})
|
|
90
|
+
|
|
91
|
+
def next_id(self, prefix: str) -> str:
|
|
92
|
+
self._id_counters[prefix] = self._id_counters.get(prefix, 0) + 1
|
|
93
|
+
return f"{prefix}_{self._id_counters[prefix]:03d}"
|
|
94
|
+
|
|
95
|
+
def snapshot(self) -> dict[str, Any]:
|
|
96
|
+
return {
|
|
97
|
+
"seed": self.seed,
|
|
98
|
+
"clock": self.clock.now(),
|
|
99
|
+
"state": self.state.snapshot(),
|
|
100
|
+
"inputs": copy.deepcopy(self.user_inputs),
|
|
101
|
+
"effects": self.effects.snapshot(),
|
|
102
|
+
"metadata": redact(copy.deepcopy(self.metadata)),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
def clone_from_initial(self) -> World:
|
|
106
|
+
cloned = World(seed=self.seed, metadata=copy.deepcopy(self.metadata))
|
|
107
|
+
cloned.state.replace(self.state.snapshot())
|
|
108
|
+
cloned.user_inputs = copy.deepcopy(self.user_inputs)
|
|
109
|
+
return cloned
|
|
110
|
+
|
|
111
|
+
def set_path(self, path: list[Any], value: Any) -> None:
|
|
112
|
+
if not path:
|
|
113
|
+
raise ValueError("path must not be empty")
|
|
114
|
+
cursor: Any = self.state._data
|
|
115
|
+
for key in path[:-1]:
|
|
116
|
+
cursor = cursor.setdefault(key, {}) if isinstance(cursor, dict) else cursor[key]
|
|
117
|
+
final_key = path[-1]
|
|
118
|
+
if isinstance(cursor, dict):
|
|
119
|
+
cursor[final_key] = value
|
|
120
|
+
else:
|
|
121
|
+
cursor[final_key] = value
|
|
122
|
+
self.trace.record(
|
|
123
|
+
"state_change",
|
|
124
|
+
".".join(str(item) for item in path),
|
|
125
|
+
{"path": path, "value": copy.deepcopy(value)},
|
|
126
|
+
)
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from agentproof.invariants.builtin import (
|
|
4
|
+
at_most_once,
|
|
5
|
+
forbid_resource,
|
|
6
|
+
max_effect_sum,
|
|
7
|
+
max_tool_calls,
|
|
8
|
+
never_call,
|
|
9
|
+
no_duplicate_effects,
|
|
10
|
+
requires_before,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"at_most_once",
|
|
15
|
+
"forbid_resource",
|
|
16
|
+
"max_effect_sum",
|
|
17
|
+
"max_tool_calls",
|
|
18
|
+
"never_call",
|
|
19
|
+
"no_duplicate_effects",
|
|
20
|
+
"requires_before",
|
|
21
|
+
]
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import fnmatch
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from agentproof.core.invariant import Invariant, invariant
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def at_most_once(tool_name: str, *, severity: str = "medium") -> Invariant:
|
|
10
|
+
return max_tool_calls(tool_name, 1, severity=severity)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def max_tool_calls(tool_name: str, count: int, *, severity: str = "medium") -> Invariant:
|
|
14
|
+
@invariant(name=f"max_tool_calls_{tool_name}_{count}", severity=severity)
|
|
15
|
+
def check(world: Any) -> None:
|
|
16
|
+
observed = len(
|
|
17
|
+
[
|
|
18
|
+
event
|
|
19
|
+
for event in world.trace.all()
|
|
20
|
+
if event.kind == "tool_call" and event.name == tool_name
|
|
21
|
+
]
|
|
22
|
+
)
|
|
23
|
+
assert observed <= count, f"{tool_name} called {observed} times, expected <= {count}"
|
|
24
|
+
|
|
25
|
+
return check
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def never_call(tool_name: str, *, severity: str = "high") -> Invariant:
|
|
29
|
+
@invariant(name=f"never_call_{tool_name}", severity=severity)
|
|
30
|
+
def check(world: Any) -> None:
|
|
31
|
+
observed = len(
|
|
32
|
+
[
|
|
33
|
+
event
|
|
34
|
+
for event in world.trace.all()
|
|
35
|
+
if event.kind == "tool_call" and event.name == tool_name
|
|
36
|
+
]
|
|
37
|
+
)
|
|
38
|
+
assert observed == 0, f"{tool_name} was called {observed} times"
|
|
39
|
+
|
|
40
|
+
return check
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def requires_before(required_tool: str, action_tool: str, *, severity: str = "high") -> Invariant:
|
|
44
|
+
@invariant(name=f"requires_{required_tool}_before_{action_tool}", severity=severity)
|
|
45
|
+
def check(world: Any) -> None:
|
|
46
|
+
required_seen = False
|
|
47
|
+
for event in world.trace.all():
|
|
48
|
+
if event.kind == "tool_call" and event.name == required_tool:
|
|
49
|
+
required_seen = True
|
|
50
|
+
if event.kind == "tool_call" and event.name == action_tool:
|
|
51
|
+
assert required_seen, f"{action_tool} called before {required_tool}"
|
|
52
|
+
|
|
53
|
+
return check
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def max_effect_sum(
|
|
57
|
+
effect_type: str,
|
|
58
|
+
field: str,
|
|
59
|
+
maximum: float,
|
|
60
|
+
*,
|
|
61
|
+
where: dict[str, Any] | None = None,
|
|
62
|
+
severity: str = "high",
|
|
63
|
+
) -> Invariant:
|
|
64
|
+
@invariant(name=f"max_effect_sum_{effect_type}_{field}", severity=severity)
|
|
65
|
+
def check(world: Any) -> None:
|
|
66
|
+
observed = world.effects.sum(type=effect_type, field=field, where=where)
|
|
67
|
+
assert observed <= maximum, f"{field} sum {observed:.2f}, expected <= {maximum:.2f}"
|
|
68
|
+
|
|
69
|
+
return check
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def no_duplicate_effects(
|
|
73
|
+
effect_type: str,
|
|
74
|
+
*,
|
|
75
|
+
key_fields: list[str],
|
|
76
|
+
severity: str = "high",
|
|
77
|
+
) -> Invariant:
|
|
78
|
+
@invariant(name=f"no_duplicate_effects_{effect_type}", severity=severity)
|
|
79
|
+
def check(world: Any) -> None:
|
|
80
|
+
duplicates = world.effects.duplicate_keys(type=effect_type, key_fields=key_fields)
|
|
81
|
+
assert not duplicates, f"duplicate {effect_type} effects for keys {duplicates}"
|
|
82
|
+
|
|
83
|
+
return check
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def forbid_resource(resource_pattern: str, *, severity: str = "critical") -> Invariant:
|
|
87
|
+
@invariant(name=f"forbid_resource_{resource_pattern}", severity=severity)
|
|
88
|
+
def check(world: Any) -> None:
|
|
89
|
+
forbidden = [
|
|
90
|
+
effect.resource
|
|
91
|
+
for effect in world.effects.all()
|
|
92
|
+
if effect.resource and fnmatch.fnmatch(effect.resource, resource_pattern)
|
|
93
|
+
]
|
|
94
|
+
assert not forbidden, f"forbidden resources affected: {forbidden}"
|
|
95
|
+
|
|
96
|
+
return check
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from agentproof.mutations.base import MUTATION_TYPES, Mutation, mutation_from_spec
|
|
4
|
+
from agentproof.mutations.duplication import DuplicateEvent, DuplicateUserRequest
|
|
5
|
+
from agentproof.mutations.ordering import ReorderToolResults
|
|
6
|
+
from agentproof.mutations.state import StaleState, StateChangedAfterRead
|
|
7
|
+
from agentproof.mutations.timing import DelayedEvent
|
|
8
|
+
from agentproof.mutations.tool_faults import (
|
|
9
|
+
DuplicateToolResult,
|
|
10
|
+
MalformedResponse,
|
|
11
|
+
MissingField,
|
|
12
|
+
PermissionDenied,
|
|
13
|
+
RateLimited,
|
|
14
|
+
TimeoutAfterCommit,
|
|
15
|
+
ToolError,
|
|
16
|
+
ToolLatency,
|
|
17
|
+
ToolTimeout,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def standard_reliability_pack() -> list[Mutation]:
|
|
22
|
+
"""Return stable single-fault mutations used by the default deterministic pack."""
|
|
23
|
+
|
|
24
|
+
return [
|
|
25
|
+
ToolTimeout(),
|
|
26
|
+
TimeoutAfterCommit(severity="high"),
|
|
27
|
+
ToolError(),
|
|
28
|
+
ToolLatency(params={"seconds": 1.0}),
|
|
29
|
+
RateLimited(),
|
|
30
|
+
MalformedResponse(),
|
|
31
|
+
MissingField(params={"field": "status"}),
|
|
32
|
+
DuplicateUserRequest(severity="high"),
|
|
33
|
+
StaleState(params={"value": {}}),
|
|
34
|
+
StateChangedAfterRead(params={"path": ["agentproof", "state_changed"], "value": True}),
|
|
35
|
+
PermissionDenied(),
|
|
36
|
+
DelayedEvent(),
|
|
37
|
+
DuplicateEvent(),
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"MUTATION_TYPES",
|
|
43
|
+
"DelayedEvent",
|
|
44
|
+
"DuplicateEvent",
|
|
45
|
+
"DuplicateToolResult",
|
|
46
|
+
"DuplicateUserRequest",
|
|
47
|
+
"MalformedResponse",
|
|
48
|
+
"MissingField",
|
|
49
|
+
"Mutation",
|
|
50
|
+
"PermissionDenied",
|
|
51
|
+
"RateLimited",
|
|
52
|
+
"ReorderToolResults",
|
|
53
|
+
"StaleState",
|
|
54
|
+
"StateChangedAfterRead",
|
|
55
|
+
"TimeoutAfterCommit",
|
|
56
|
+
"ToolError",
|
|
57
|
+
"ToolLatency",
|
|
58
|
+
"ToolTimeout",
|
|
59
|
+
"mutation_from_spec",
|
|
60
|
+
"standard_reliability_pack",
|
|
61
|
+
]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, ClassVar
|
|
6
|
+
|
|
7
|
+
from agentproof.core.faults import MutationSpec, Severity
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Mutation:
|
|
12
|
+
target: str | None = None
|
|
13
|
+
occurrence: int | None = 1
|
|
14
|
+
params: Mapping[str, Any] = field(default_factory=dict)
|
|
15
|
+
severity: Severity = "medium"
|
|
16
|
+
|
|
17
|
+
mutation_type: ClassVar[str] = "mutation"
|
|
18
|
+
description: ClassVar[str] = "Generic mutation."
|
|
19
|
+
stable: ClassVar[bool] = True
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def spec(self) -> MutationSpec:
|
|
23
|
+
return MutationSpec(
|
|
24
|
+
type=self.mutation_type,
|
|
25
|
+
target=self.target,
|
|
26
|
+
occurrence=self.occurrence,
|
|
27
|
+
params=dict(self.params),
|
|
28
|
+
severity=self.severity,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def install(self, world: Any) -> None:
|
|
32
|
+
world.faults.install(self.spec)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def mutation_from_spec(spec: MutationSpec) -> Mutation:
|
|
36
|
+
cls = MUTATION_TYPES.get(spec.type)
|
|
37
|
+
if cls is None:
|
|
38
|
+
return Mutation(
|
|
39
|
+
target=spec.target,
|
|
40
|
+
occurrence=spec.occurrence,
|
|
41
|
+
params=spec.params,
|
|
42
|
+
severity=spec.severity,
|
|
43
|
+
)
|
|
44
|
+
return cls(
|
|
45
|
+
target=spec.target,
|
|
46
|
+
occurrence=spec.occurrence,
|
|
47
|
+
params=spec.params,
|
|
48
|
+
severity=spec.severity,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
MUTATION_TYPES: dict[str, type[Mutation]] = {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def register_mutation(cls: type[Mutation]) -> type[Mutation]:
|
|
56
|
+
MUTATION_TYPES[cls.mutation_type] = cls
|
|
57
|
+
return cls
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from agentproof.mutations.base import Mutation, register_mutation
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@register_mutation
|
|
7
|
+
class DuplicateUserRequest(Mutation):
|
|
8
|
+
mutation_type = "duplicate_user_request"
|
|
9
|
+
description = "Deliver the same user input to the adapter twice in one run."
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@register_mutation
|
|
13
|
+
class DuplicateEvent(Mutation):
|
|
14
|
+
mutation_type = "duplicate_event"
|
|
15
|
+
description = "Deliver a scheduled event more than once."
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from agentproof.mutations.base import Mutation, register_mutation
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@register_mutation
|
|
7
|
+
class ReorderToolResults(Mutation):
|
|
8
|
+
mutation_type = "reorder_tool_results"
|
|
9
|
+
description = "Experimental placeholder for controlled parallel result reordering."
|
|
10
|
+
stable = False
|