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
agentproof/__init__.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
from agentproof.api import AgentTest
|
|
6
|
+
from agentproof.core.effects import Effect, EffectDraft
|
|
7
|
+
from agentproof.core.faults import MutationSpec
|
|
8
|
+
from agentproof.core.invariant import Invariant, invariant
|
|
9
|
+
from agentproof.core.result import RunResult, SuiteResult
|
|
10
|
+
from agentproof.core.scenario import Scenario
|
|
11
|
+
from agentproof.core.trace import TraceEvent
|
|
12
|
+
from agentproof.core.world import World
|
|
13
|
+
from agentproof.mutations.base import Mutation
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _distribution_version() -> str:
|
|
17
|
+
for distribution_name in ("agentproof-sim", "agentproof"):
|
|
18
|
+
try:
|
|
19
|
+
return version(distribution_name)
|
|
20
|
+
except PackageNotFoundError:
|
|
21
|
+
continue
|
|
22
|
+
return "0.1.1"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
__version__ = _distribution_version()
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"AgentTest",
|
|
29
|
+
"Effect",
|
|
30
|
+
"EffectDraft",
|
|
31
|
+
"Invariant",
|
|
32
|
+
"Mutation",
|
|
33
|
+
"MutationSpec",
|
|
34
|
+
"RunResult",
|
|
35
|
+
"Scenario",
|
|
36
|
+
"SuiteResult",
|
|
37
|
+
"TraceEvent",
|
|
38
|
+
"World",
|
|
39
|
+
"__version__",
|
|
40
|
+
"invariant",
|
|
41
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from agentproof.adapters.base import AgentAdapter, AgentRunResult
|
|
4
|
+
from agentproof.adapters.langchain import LangChainAdapter
|
|
5
|
+
from agentproof.adapters.native import NativeAdapter
|
|
6
|
+
from agentproof.adapters.openai_agents import OpenAIAgentsAdapter
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"AgentAdapter",
|
|
10
|
+
"AgentRunResult",
|
|
11
|
+
"LangChainAdapter",
|
|
12
|
+
"NativeAdapter",
|
|
13
|
+
"OpenAIAgentsAdapter",
|
|
14
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Protocol
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AgentRunResult(BaseModel):
|
|
9
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
10
|
+
|
|
11
|
+
final_output: str | None = None
|
|
12
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
13
|
+
raw_result: Any | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AgentAdapter(Protocol):
|
|
17
|
+
name: str
|
|
18
|
+
|
|
19
|
+
async def run(self, *, world: Any, user_input: str) -> AgentRunResult: ...
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel, create_model
|
|
7
|
+
|
|
8
|
+
from agentproof.adapters.base import AgentRunResult
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LangChainAdapter:
|
|
12
|
+
"""Adapter for LangChain v1 / LangGraph tools backed by AgentProof."""
|
|
13
|
+
|
|
14
|
+
name = "langchain-langgraph"
|
|
15
|
+
|
|
16
|
+
def __init__(self, agent: Any | None = None) -> None:
|
|
17
|
+
self.agent = agent
|
|
18
|
+
|
|
19
|
+
def build_tools(self, world: Any) -> list[Any]:
|
|
20
|
+
try:
|
|
21
|
+
from langchain_core.tools import StructuredTool
|
|
22
|
+
except ImportError as exc:
|
|
23
|
+
raise RuntimeError("Install agentproof-sim[langchain] to use LangChainAdapter") from exc
|
|
24
|
+
|
|
25
|
+
tools = []
|
|
26
|
+
for definition in world.tools.all():
|
|
27
|
+
args_schema = _pydantic_model_from_schema(definition.name, definition.input_schema)
|
|
28
|
+
|
|
29
|
+
tools.append(
|
|
30
|
+
StructuredTool.from_function(
|
|
31
|
+
coroutine=_make_coroutine(world, definition.name),
|
|
32
|
+
name=definition.name,
|
|
33
|
+
description=definition.description,
|
|
34
|
+
args_schema=args_schema,
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
return tools
|
|
38
|
+
|
|
39
|
+
def create_agent(self, *, model: Any, world: Any, system_prompt: str | None = None) -> Any:
|
|
40
|
+
try:
|
|
41
|
+
from langchain.agents import create_agent
|
|
42
|
+
except ImportError as exc:
|
|
43
|
+
raise RuntimeError("Install agentproof-sim[langchain] to use LangChainAdapter") from exc
|
|
44
|
+
return create_agent(model=model, tools=self.build_tools(world), system_prompt=system_prompt)
|
|
45
|
+
|
|
46
|
+
async def run(self, *, world: Any, user_input: str) -> AgentRunResult:
|
|
47
|
+
if self.agent is None:
|
|
48
|
+
raise ValueError("LangChainAdapter requires an agent/graph for run()")
|
|
49
|
+
result = await self.agent.ainvoke({"messages": [{"role": "user", "content": user_input}]})
|
|
50
|
+
return AgentRunResult(
|
|
51
|
+
final_output=json.dumps(_json_safe(result), sort_keys=True),
|
|
52
|
+
metadata={"framework": "langchain-langgraph"},
|
|
53
|
+
raw_result=result,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _pydantic_model_from_schema(name: str, schema: dict[str, Any]) -> type[BaseModel]:
|
|
58
|
+
properties = schema.get("properties", {})
|
|
59
|
+
required = set(schema.get("required", []))
|
|
60
|
+
fields: dict[str, tuple[Any, Any]] = {}
|
|
61
|
+
for field_name, field_schema in properties.items():
|
|
62
|
+
field_type = _python_type(field_schema)
|
|
63
|
+
default = ... if field_name in required else None
|
|
64
|
+
fields[field_name] = (field_type, default)
|
|
65
|
+
return create_model(f"AgentProof{name.title().replace('_', '')}Input", **fields) # type: ignore[call-overload]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _make_coroutine(world: Any, tool_name: str) -> Any:
|
|
69
|
+
async def call(**kwargs: Any) -> Any:
|
|
70
|
+
return await world.tools.invoke(tool_name, kwargs)
|
|
71
|
+
|
|
72
|
+
return call
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _python_type(schema: dict[str, Any]) -> Any:
|
|
76
|
+
schema_type = schema.get("type")
|
|
77
|
+
if schema_type == "integer":
|
|
78
|
+
return int
|
|
79
|
+
if schema_type == "number":
|
|
80
|
+
return float
|
|
81
|
+
if schema_type == "boolean":
|
|
82
|
+
return bool
|
|
83
|
+
if schema_type == "array":
|
|
84
|
+
return list
|
|
85
|
+
if schema_type == "object":
|
|
86
|
+
return dict
|
|
87
|
+
return str
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _json_safe(value: Any) -> Any:
|
|
91
|
+
try:
|
|
92
|
+
json.dumps(value)
|
|
93
|
+
except TypeError:
|
|
94
|
+
return str(value)
|
|
95
|
+
return value
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from agentproof.adapters.base import AgentRunResult
|
|
7
|
+
from agentproof.tools.invocation import ToolClient
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NativeAdapter:
|
|
11
|
+
name = "native"
|
|
12
|
+
|
|
13
|
+
def __init__(self, agent: Any | None = None) -> None:
|
|
14
|
+
self.agent = agent
|
|
15
|
+
|
|
16
|
+
async def run(self, *, world: Any, user_input: str) -> AgentRunResult:
|
|
17
|
+
if self.agent is None:
|
|
18
|
+
raise ValueError("NativeAdapter requires an agent callable")
|
|
19
|
+
tools = ToolClient(world)
|
|
20
|
+
result = self.agent(user_input, tools)
|
|
21
|
+
if inspect.isawaitable(result):
|
|
22
|
+
result = await result
|
|
23
|
+
return AgentRunResult(final_output=None if result is None else str(result))
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from agentproof.adapters.base import AgentRunResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OpenAIAgentsAdapter:
|
|
10
|
+
"""Adapter for the real OpenAI Agents SDK boundary."""
|
|
11
|
+
|
|
12
|
+
name = "openai-agents"
|
|
13
|
+
|
|
14
|
+
def __init__(self, agent: Any | None = None) -> None:
|
|
15
|
+
self.agent = agent
|
|
16
|
+
|
|
17
|
+
def build_function_tools(self, world: Any) -> list[Any]:
|
|
18
|
+
try:
|
|
19
|
+
from agents import FunctionTool
|
|
20
|
+
except ImportError as exc:
|
|
21
|
+
raise RuntimeError("Install agentproof-sim[openai] to use OpenAIAgentsAdapter") from exc
|
|
22
|
+
|
|
23
|
+
wrapped = []
|
|
24
|
+
for definition in world.tools.all():
|
|
25
|
+
|
|
26
|
+
async def on_invoke_tool(
|
|
27
|
+
ctx: Any, input: str, *, tool_name: str = definition.name
|
|
28
|
+
) -> Any:
|
|
29
|
+
del ctx
|
|
30
|
+
payload = json.loads(input or "{}")
|
|
31
|
+
return await world.tools.invoke(tool_name, payload)
|
|
32
|
+
|
|
33
|
+
wrapped.append(
|
|
34
|
+
FunctionTool(
|
|
35
|
+
name=definition.name,
|
|
36
|
+
description=definition.description,
|
|
37
|
+
params_json_schema=definition.input_schema,
|
|
38
|
+
on_invoke_tool=on_invoke_tool,
|
|
39
|
+
strict_json_schema=False,
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
return wrapped
|
|
43
|
+
|
|
44
|
+
async def run(self, *, world: Any, user_input: str) -> AgentRunResult:
|
|
45
|
+
if self.agent is None:
|
|
46
|
+
raise ValueError("OpenAIAgentsAdapter requires an Agents SDK Agent for run()")
|
|
47
|
+
try:
|
|
48
|
+
from agents import Runner
|
|
49
|
+
except ImportError as exc:
|
|
50
|
+
raise RuntimeError("Install agentproof-sim[openai] to use OpenAIAgentsAdapter") from exc
|
|
51
|
+
|
|
52
|
+
original_tools = list(getattr(self.agent, "tools", []))
|
|
53
|
+
self.agent.tools = self.build_function_tools(world)
|
|
54
|
+
try:
|
|
55
|
+
result = await Runner.run(self.agent, user_input)
|
|
56
|
+
finally:
|
|
57
|
+
self.agent.tools = original_tools
|
|
58
|
+
return AgentRunResult(
|
|
59
|
+
final_output=str(getattr(result, "final_output", "")),
|
|
60
|
+
metadata={"framework": "openai-agents"},
|
|
61
|
+
raw_result=result,
|
|
62
|
+
)
|
agentproof/api.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from agentproof.adapters.native import NativeAdapter
|
|
9
|
+
from agentproof.core.invariant import Invariant
|
|
10
|
+
from agentproof.core.result import SuiteResult
|
|
11
|
+
from agentproof.core.runner import ScenarioRunner
|
|
12
|
+
from agentproof.core.scenario import Scenario, ScenarioFunc
|
|
13
|
+
from agentproof.mutations.base import Mutation
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AgentTest:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
agent: Any,
|
|
21
|
+
adapter: str | Any = "native",
|
|
22
|
+
mutations: list[Mutation] | None = None,
|
|
23
|
+
invariants: list[Invariant] | None = None,
|
|
24
|
+
name: str | None = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self.name = name or "agentproof_suite"
|
|
27
|
+
self.agent = agent
|
|
28
|
+
self.adapter = self._coerce_adapter(adapter, agent)
|
|
29
|
+
self.mutations = list(mutations or [])
|
|
30
|
+
self.invariants = list(invariants or [])
|
|
31
|
+
self.scenarios: list[Scenario] = []
|
|
32
|
+
|
|
33
|
+
def scenario(self, func: ScenarioFunc | None = None, *, name: str | None = None) -> Any:
|
|
34
|
+
def wrap(inner: ScenarioFunc) -> ScenarioFunc:
|
|
35
|
+
self.scenarios.append(Scenario(name=name or inner.__name__, func=inner))
|
|
36
|
+
return inner
|
|
37
|
+
|
|
38
|
+
if func is None:
|
|
39
|
+
return wrap
|
|
40
|
+
return wrap(func)
|
|
41
|
+
|
|
42
|
+
def add_invariant(self, item: Invariant) -> Invariant:
|
|
43
|
+
self.invariants.append(item)
|
|
44
|
+
return item
|
|
45
|
+
|
|
46
|
+
async def run(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
scenario: str | None = None,
|
|
50
|
+
mutations: list[Mutation] | None = None,
|
|
51
|
+
mutation_name: str | None = None,
|
|
52
|
+
seed: int = 42,
|
|
53
|
+
artifacts_dir: str | Path = ".agentproof/runs",
|
|
54
|
+
store_artifacts: bool = True,
|
|
55
|
+
source_path: str | None = None,
|
|
56
|
+
suite_name: str | None = None,
|
|
57
|
+
) -> SuiteResult:
|
|
58
|
+
runner = ScenarioRunner(
|
|
59
|
+
adapter=self.adapter,
|
|
60
|
+
scenarios=self.scenarios,
|
|
61
|
+
invariants=self._all_invariants(),
|
|
62
|
+
mutations=self.mutations,
|
|
63
|
+
)
|
|
64
|
+
return await runner.run(
|
|
65
|
+
scenario=scenario,
|
|
66
|
+
mutations=mutations,
|
|
67
|
+
mutation_name=mutation_name,
|
|
68
|
+
seed=seed,
|
|
69
|
+
artifacts_dir=Path(artifacts_dir),
|
|
70
|
+
store_artifacts=store_artifacts,
|
|
71
|
+
source_path=source_path,
|
|
72
|
+
suite_name=suite_name or self.name,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def run_sync(self, **kwargs: Any) -> SuiteResult:
|
|
76
|
+
return asyncio.run(self.run(**kwargs))
|
|
77
|
+
|
|
78
|
+
def _all_invariants(self) -> list[Invariant]:
|
|
79
|
+
discovered: list[Invariant] = []
|
|
80
|
+
for scenario in self.scenarios:
|
|
81
|
+
module = inspect.getmodule(scenario.func)
|
|
82
|
+
if module is None:
|
|
83
|
+
continue
|
|
84
|
+
for value in vars(module).values():
|
|
85
|
+
if isinstance(value, Invariant) and value not in discovered:
|
|
86
|
+
discovered.append(value)
|
|
87
|
+
result = list(self.invariants)
|
|
88
|
+
for item in discovered:
|
|
89
|
+
if item not in result:
|
|
90
|
+
result.append(item)
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _coerce_adapter(adapter: str | Any, agent: Any) -> Any:
|
|
95
|
+
if adapter == "native":
|
|
96
|
+
return NativeAdapter(agent)
|
|
97
|
+
if isinstance(adapter, NativeAdapter) and adapter.agent is None:
|
|
98
|
+
adapter.agent = agent
|
|
99
|
+
return adapter
|
agentproof/cli.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.util
|
|
4
|
+
import sys
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
|
|
12
|
+
from agentproof.api import AgentTest
|
|
13
|
+
from agentproof.core.faults import MutationSpec, Severity
|
|
14
|
+
from agentproof.mutations import MUTATION_TYPES, mutation_from_spec
|
|
15
|
+
from agentproof.replay.player import replay_artifact_sync
|
|
16
|
+
from agentproof.reporting.console import render_suite_result
|
|
17
|
+
from agentproof.reporting.json_report import write_json_report
|
|
18
|
+
from agentproof.reporting.junit import write_junit_report
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.group()
|
|
22
|
+
def main() -> None:
|
|
23
|
+
"""AgentProof command line interface."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@main.command()
|
|
27
|
+
@click.option("--force", is_flag=True, help="Overwrite existing scaffold files.")
|
|
28
|
+
def init(force: bool) -> None:
|
|
29
|
+
files = {
|
|
30
|
+
"agentproof.toml": '[agentproof]\nseed = 42\nrepetitions = 1\nfail_on = "high"\n',
|
|
31
|
+
"tests/agentproof/.gitkeep": "",
|
|
32
|
+
}
|
|
33
|
+
for raw_path, content in files.items():
|
|
34
|
+
path = Path(raw_path)
|
|
35
|
+
if path.exists() and not force:
|
|
36
|
+
raise click.ClickException(f"{path} already exists; use --force to overwrite")
|
|
37
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
path.write_text(content, encoding="utf-8")
|
|
39
|
+
Path(".agentproof").mkdir(mode=0o700, exist_ok=True)
|
|
40
|
+
click.echo("Initialized AgentProof files.")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@main.command(name="mutations")
|
|
44
|
+
def list_mutations() -> None:
|
|
45
|
+
for name, cls in sorted(MUTATION_TYPES.items()):
|
|
46
|
+
stability = "stable" if cls.stable else "experimental"
|
|
47
|
+
click.echo(f"{name}\t{stability}\t{cls.description}")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@main.command()
|
|
51
|
+
@click.argument("path", required=False, default=".")
|
|
52
|
+
@click.option("--scenario", "scenario_name", default=None, help="Run only one scenario.")
|
|
53
|
+
@click.option(
|
|
54
|
+
"--mutation",
|
|
55
|
+
"mutation_value",
|
|
56
|
+
default=None,
|
|
57
|
+
help="Run only one mutation, optionally type:target.",
|
|
58
|
+
)
|
|
59
|
+
@click.option("--seed", default=42, type=int, show_default=True)
|
|
60
|
+
@click.option(
|
|
61
|
+
"--fail-on",
|
|
62
|
+
type=click.Choice(["low", "medium", "high", "critical"]),
|
|
63
|
+
default="high",
|
|
64
|
+
show_default=True,
|
|
65
|
+
)
|
|
66
|
+
@click.option("--json", "json_path", type=click.Path(dir_okay=False, path_type=Path))
|
|
67
|
+
@click.option("--junit", "junit_path", type=click.Path(dir_okay=False, path_type=Path))
|
|
68
|
+
@click.option("--no-color", is_flag=True)
|
|
69
|
+
def run(
|
|
70
|
+
path: str,
|
|
71
|
+
scenario_name: str | None,
|
|
72
|
+
mutation_value: str | None,
|
|
73
|
+
seed: int,
|
|
74
|
+
fail_on: Severity,
|
|
75
|
+
json_path: Path | None,
|
|
76
|
+
junit_path: Path | None,
|
|
77
|
+
no_color: bool,
|
|
78
|
+
) -> None:
|
|
79
|
+
suites = _load_suites(Path(path))
|
|
80
|
+
if not suites:
|
|
81
|
+
raise click.ClickException(f"no AgentTest suites found under {path}")
|
|
82
|
+
mutation_override = [_parse_mutation(mutation_value)] if mutation_value else None
|
|
83
|
+
aggregate = None
|
|
84
|
+
exit_code = 0
|
|
85
|
+
for source_path, suite_name, suite in suites:
|
|
86
|
+
result = suite.run_sync(
|
|
87
|
+
scenario=scenario_name,
|
|
88
|
+
mutations=mutation_override,
|
|
89
|
+
mutation_name=None,
|
|
90
|
+
seed=seed,
|
|
91
|
+
source_path=str(source_path),
|
|
92
|
+
suite_name=suite_name,
|
|
93
|
+
)
|
|
94
|
+
aggregate = result
|
|
95
|
+
render_suite_result(result, console=Console(no_color=no_color))
|
|
96
|
+
exit_code = max(exit_code, result.exit_code(fail_on))
|
|
97
|
+
if aggregate is not None and json_path is not None:
|
|
98
|
+
write_json_report(aggregate, json_path)
|
|
99
|
+
if aggregate is not None and junit_path is not None:
|
|
100
|
+
write_junit_report(aggregate, junit_path)
|
|
101
|
+
raise SystemExit(exit_code)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@main.command()
|
|
105
|
+
@click.argument("artifact", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
106
|
+
@click.option(
|
|
107
|
+
"--fail-on",
|
|
108
|
+
type=click.Choice(["low", "medium", "high", "critical"]),
|
|
109
|
+
default="high",
|
|
110
|
+
show_default=True,
|
|
111
|
+
)
|
|
112
|
+
def replay(artifact: Path, fail_on: Severity) -> None:
|
|
113
|
+
result = replay_artifact_sync(artifact)
|
|
114
|
+
render_suite_result(result)
|
|
115
|
+
raise SystemExit(result.exit_code(fail_on))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@main.command()
|
|
119
|
+
def doctor() -> None:
|
|
120
|
+
click.echo(f"Python: {sys.version.split()[0]}")
|
|
121
|
+
for module in ("agents", "langchain", "langgraph"):
|
|
122
|
+
try:
|
|
123
|
+
imported = __import__(module)
|
|
124
|
+
except Exception as exc:
|
|
125
|
+
click.echo(f"{module}: unavailable ({exc})")
|
|
126
|
+
else:
|
|
127
|
+
version = getattr(imported, "__version__", "installed")
|
|
128
|
+
click.echo(f"{module}: {version}")
|
|
129
|
+
Path(".agentproof").mkdir(mode=0o700, exist_ok=True)
|
|
130
|
+
click.echo(".agentproof: writable")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _parse_mutation(value: str) -> object:
|
|
134
|
+
mutation_type, _, target = value.partition(":")
|
|
135
|
+
severity: Severity = (
|
|
136
|
+
"high" if mutation_type in {"timeout_after_commit", "duplicate_user_request"} else "medium"
|
|
137
|
+
)
|
|
138
|
+
spec = MutationSpec(type=mutation_type, target=target or None, severity=severity)
|
|
139
|
+
return mutation_from_spec(spec)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _load_suites(path: Path) -> list[tuple[Path, str, AgentTest]]:
|
|
143
|
+
source_files = [path] if path.is_file() else sorted(path.rglob("*.py"))
|
|
144
|
+
suites: list[tuple[Path, str, AgentTest]] = []
|
|
145
|
+
for source in source_files:
|
|
146
|
+
if any(part.startswith(".") for part in source.parts):
|
|
147
|
+
continue
|
|
148
|
+
module = _load_module(source)
|
|
149
|
+
for name, value in vars(module).items():
|
|
150
|
+
if isinstance(value, AgentTest):
|
|
151
|
+
suites.append((source, name, value))
|
|
152
|
+
return suites
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _load_module(path: Path) -> ModuleType:
|
|
156
|
+
module_name = f"agentproof_cli_{abs(hash(path))}"
|
|
157
|
+
spec = importlib.util.spec_from_file_location(module_name, path)
|
|
158
|
+
if spec is None or spec.loader is None:
|
|
159
|
+
raise click.ClickException(f"cannot import {path}")
|
|
160
|
+
module = importlib.util.module_from_spec(spec)
|
|
161
|
+
sys.modules[module_name] = module
|
|
162
|
+
sys.path.insert(0, str(path.parent))
|
|
163
|
+
try:
|
|
164
|
+
spec.loader.exec_module(module)
|
|
165
|
+
finally:
|
|
166
|
+
with suppress(ValueError):
|
|
167
|
+
sys.path.remove(str(path.parent))
|
|
168
|
+
return module
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
main()
|
agentproof/core/clock.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class VirtualClock:
|
|
5
|
+
def __init__(self, start: float = 0.0) -> None:
|
|
6
|
+
self._now = float(start)
|
|
7
|
+
|
|
8
|
+
def now(self) -> float:
|
|
9
|
+
return self._now
|
|
10
|
+
|
|
11
|
+
def advance(self, seconds: float) -> float:
|
|
12
|
+
if seconds < 0:
|
|
13
|
+
raise ValueError("seconds must be non-negative")
|
|
14
|
+
self._now += float(seconds)
|
|
15
|
+
return self._now
|
|
16
|
+
|
|
17
|
+
def set(self, timestamp: float) -> None:
|
|
18
|
+
self._now = float(timestamp)
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EffectDraft(BaseModel):
|
|
9
|
+
model_config = ConfigDict(extra="forbid")
|
|
10
|
+
|
|
11
|
+
type: str
|
|
12
|
+
data: dict[str, Any] = Field(default_factory=dict)
|
|
13
|
+
operation: str | None = None
|
|
14
|
+
resource: str | None = None
|
|
15
|
+
idempotency_key: str | None = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Effect(BaseModel):
|
|
19
|
+
model_config = ConfigDict(extra="forbid")
|
|
20
|
+
|
|
21
|
+
id: str
|
|
22
|
+
type: str
|
|
23
|
+
tool_name: str
|
|
24
|
+
operation: str | None = None
|
|
25
|
+
resource: str | None = None
|
|
26
|
+
data: dict[str, Any]
|
|
27
|
+
committed_at: float
|
|
28
|
+
idempotency_key: str | None = None
|
|
29
|
+
invocation_id: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class EffectLedger:
|
|
33
|
+
"""Records simulated side effects explicitly committed by virtual tool handlers."""
|
|
34
|
+
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
self._effects: list[Effect] = []
|
|
37
|
+
|
|
38
|
+
def commit(
|
|
39
|
+
self,
|
|
40
|
+
draft: EffectDraft | dict[str, Any],
|
|
41
|
+
*,
|
|
42
|
+
tool_name: str,
|
|
43
|
+
invocation_id: str,
|
|
44
|
+
committed_at: float,
|
|
45
|
+
) -> Effect:
|
|
46
|
+
draft_model = draft if isinstance(draft, EffectDraft) else EffectDraft.model_validate(draft)
|
|
47
|
+
effect = Effect(
|
|
48
|
+
id=f"eff_{len(self._effects) + 1:03d}",
|
|
49
|
+
type=draft_model.type,
|
|
50
|
+
tool_name=tool_name,
|
|
51
|
+
operation=draft_model.operation,
|
|
52
|
+
resource=draft_model.resource,
|
|
53
|
+
data=draft_model.data,
|
|
54
|
+
committed_at=committed_at,
|
|
55
|
+
idempotency_key=draft_model.idempotency_key,
|
|
56
|
+
invocation_id=invocation_id,
|
|
57
|
+
)
|
|
58
|
+
self._effects.append(effect)
|
|
59
|
+
return effect
|
|
60
|
+
|
|
61
|
+
def append(self, effect: Effect) -> None:
|
|
62
|
+
self._effects.append(effect)
|
|
63
|
+
|
|
64
|
+
def all(self) -> list[Effect]:
|
|
65
|
+
return list(self._effects)
|
|
66
|
+
|
|
67
|
+
def filter(
|
|
68
|
+
self,
|
|
69
|
+
*,
|
|
70
|
+
type: str | None = None,
|
|
71
|
+
where: dict[str, Any] | None = None,
|
|
72
|
+
tool_name: str | None = None,
|
|
73
|
+
) -> list[Effect]:
|
|
74
|
+
effects = self._effects
|
|
75
|
+
if type is not None:
|
|
76
|
+
effects = [effect for effect in effects if effect.type == type]
|
|
77
|
+
if tool_name is not None:
|
|
78
|
+
effects = [effect for effect in effects if effect.tool_name == tool_name]
|
|
79
|
+
if where:
|
|
80
|
+
effects = [
|
|
81
|
+
effect
|
|
82
|
+
for effect in effects
|
|
83
|
+
if all(effect.data.get(key) == value for key, value in where.items())
|
|
84
|
+
]
|
|
85
|
+
return list(effects)
|
|
86
|
+
|
|
87
|
+
def sum(
|
|
88
|
+
self,
|
|
89
|
+
*,
|
|
90
|
+
type: str,
|
|
91
|
+
field: str,
|
|
92
|
+
where: dict[str, Any] | None = None,
|
|
93
|
+
) -> float:
|
|
94
|
+
total = 0.0
|
|
95
|
+
for effect in self.filter(type=type, where=where):
|
|
96
|
+
value = effect.data.get(field, 0)
|
|
97
|
+
if isinstance(value, int | float):
|
|
98
|
+
total += float(value)
|
|
99
|
+
return total
|
|
100
|
+
|
|
101
|
+
def duplicate_keys(self, *, type: str, key_fields: list[str]) -> dict[tuple[Any, ...], int]:
|
|
102
|
+
counts: dict[tuple[Any, ...], int] = {}
|
|
103
|
+
for effect in self.filter(type=type):
|
|
104
|
+
key = tuple(effect.data.get(field) for field in key_fields)
|
|
105
|
+
counts[key] = counts.get(key, 0) + 1
|
|
106
|
+
return {key: count for key, count in counts.items() if count > 1}
|
|
107
|
+
|
|
108
|
+
def snapshot(self) -> list[dict[str, Any]]:
|
|
109
|
+
return [effect.model_dump(mode="json") for effect in self._effects]
|