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.
Files changed (46) hide show
  1. agentproof/__init__.py +41 -0
  2. agentproof/adapters/__init__.py +14 -0
  3. agentproof/adapters/base.py +19 -0
  4. agentproof/adapters/langchain.py +95 -0
  5. agentproof/adapters/native.py +23 -0
  6. agentproof/adapters/openai_agents.py +62 -0
  7. agentproof/api.py +99 -0
  8. agentproof/cli.py +172 -0
  9. agentproof/core/clock.py +18 -0
  10. agentproof/core/effects.py +109 -0
  11. agentproof/core/events.py +63 -0
  12. agentproof/core/faults.py +294 -0
  13. agentproof/core/invariant.py +109 -0
  14. agentproof/core/redaction.py +32 -0
  15. agentproof/core/result.py +109 -0
  16. agentproof/core/runner.py +216 -0
  17. agentproof/core/scenario.py +13 -0
  18. agentproof/core/trace.py +58 -0
  19. agentproof/core/world.py +126 -0
  20. agentproof/invariants/__init__.py +21 -0
  21. agentproof/invariants/builtin.py +96 -0
  22. agentproof/invariants/temporal.py +5 -0
  23. agentproof/mutations/__init__.py +61 -0
  24. agentproof/mutations/base.py +57 -0
  25. agentproof/mutations/duplication.py +15 -0
  26. agentproof/mutations/ordering.py +10 -0
  27. agentproof/mutations/state.py +15 -0
  28. agentproof/mutations/timing.py +9 -0
  29. agentproof/mutations/tool_faults.py +58 -0
  30. agentproof/py.typed +1 -0
  31. agentproof/pytest_plugin.py +40 -0
  32. agentproof/replay/player.py +74 -0
  33. agentproof/replay/recorder.py +29 -0
  34. agentproof/replay/schema.py +24 -0
  35. agentproof/reporting/__init__.py +7 -0
  36. agentproof/reporting/console.py +34 -0
  37. agentproof/reporting/json_report.py +47 -0
  38. agentproof/reporting/junit.py +66 -0
  39. agentproof/tools/definition.py +26 -0
  40. agentproof/tools/invocation.py +11 -0
  41. agentproof/tools/registry.py +154 -0
  42. agentproof_sim-0.1.1.dist-info/METADATA +407 -0
  43. agentproof_sim-0.1.1.dist-info/RECORD +46 -0
  44. agentproof_sim-0.1.1.dist-info/WHEEL +4 -0
  45. agentproof_sim-0.1.1.dist-info/entry_points.txt +5 -0
  46. 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()
@@ -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]