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
@@ -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 StaleState(Mutation):
8
+ mutation_type = "stale_state"
9
+ description = "Return configured stale data for a read tool without changing canonical state."
10
+
11
+
12
+ @register_mutation
13
+ class StateChangedAfterRead(Mutation):
14
+ mutation_type = "state_changed_after_read"
15
+ description = "Mutate configured world state after a read response and before later actions."
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+ from agentproof.mutations.base import Mutation, register_mutation
4
+
5
+
6
+ @register_mutation
7
+ class DelayedEvent(Mutation):
8
+ mutation_type = "delayed_event"
9
+ description = "Push event delivery later on the virtual clock."
@@ -0,0 +1,58 @@
1
+ from __future__ import annotations
2
+
3
+ from agentproof.mutations.base import Mutation, register_mutation
4
+
5
+
6
+ @register_mutation
7
+ class ToolTimeout(Mutation):
8
+ mutation_type = "tool_timeout"
9
+ description = "Raise a timeout before the virtual tool handler commits."
10
+
11
+
12
+ @register_mutation
13
+ class TimeoutAfterCommit(Mutation):
14
+ mutation_type = "timeout_after_commit"
15
+ description = "Commit the virtual tool outcome, then surface a timeout to the agent."
16
+
17
+
18
+ @register_mutation
19
+ class ToolError(Mutation):
20
+ mutation_type = "tool_error"
21
+ description = "Raise a configured tool error before the handler runs."
22
+
23
+
24
+ @register_mutation
25
+ class ToolLatency(Mutation):
26
+ mutation_type = "tool_latency"
27
+ description = "Advance the virtual clock without sleeping wall-clock time."
28
+
29
+
30
+ @register_mutation
31
+ class RateLimited(Mutation):
32
+ mutation_type = "rate_limited"
33
+ description = "Raise a recognizable rate-limit error before the handler runs."
34
+
35
+
36
+ @register_mutation
37
+ class MalformedResponse(Mutation):
38
+ mutation_type = "malformed_response"
39
+ description = "Replace a successful tool response with malformed deterministic data."
40
+
41
+
42
+ @register_mutation
43
+ class MissingField(Mutation):
44
+ mutation_type = "missing_field"
45
+ description = "Remove one configured field from a dictionary tool response."
46
+
47
+
48
+ @register_mutation
49
+ class DuplicateToolResult(Mutation):
50
+ mutation_type = "duplicate_tool_result"
51
+ description = "Return a deterministic envelope containing the same tool result twice."
52
+ stable = False
53
+
54
+
55
+ @register_mutation
56
+ class PermissionDenied(Mutation):
57
+ mutation_type = "permission_denied"
58
+ description = "Raise a permission-denied error before the handler runs."
agentproof/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import pytest
7
+
8
+
9
+ def pytest_addoption(parser: Any) -> None:
10
+ group = parser.getgroup("agentproof")
11
+ group.addoption(
12
+ "--agentproof-artifacts",
13
+ action="store",
14
+ default=None,
15
+ help="Directory for AgentProof artifacts during pytest runs.",
16
+ )
17
+
18
+
19
+ def pytest_configure(config: Any) -> None:
20
+ config.addinivalue_line("markers", "agentproof: AgentProof framework tests")
21
+ config.addinivalue_line("markers", "live: opt-in live model/provider tests")
22
+ config.addinivalue_line("markers", "openai: OpenAI Agents SDK integration tests")
23
+ config.addinivalue_line("markers", "langchain: LangChain/LangGraph integration tests")
24
+
25
+
26
+ @pytest.fixture
27
+ def agentproof_artifacts(tmp_path: Path, request: Any) -> Path:
28
+ configured = request.config.getoption("--agentproof-artifacts")
29
+ path = Path(configured) if configured else tmp_path / ".agentproof" / "runs"
30
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
31
+ return path
32
+
33
+
34
+ @pytest.fixture
35
+ def run_agentproof_suite(agentproof_artifacts: Path) -> Any:
36
+ async def run(suite: Any, **kwargs: Any) -> Any:
37
+ kwargs.setdefault("artifacts_dir", agentproof_artifacts)
38
+ return await suite.run(**kwargs)
39
+
40
+ return run
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import importlib.util
5
+ import sys
6
+ from contextlib import suppress
7
+ from pathlib import Path
8
+ from types import ModuleType
9
+
10
+ from agentproof.api import AgentTest
11
+ from agentproof.core.result import SuiteResult
12
+ from agentproof.mutations import mutation_from_spec
13
+ from agentproof.replay.schema import RUN_ARTIFACT_SCHEMA_VERSION, RunArtifact
14
+
15
+
16
+ def load_artifact(path: str | Path) -> RunArtifact:
17
+ artifact = RunArtifact.model_validate_json(Path(path).read_text(encoding="utf-8"))
18
+ if artifact.schema_version != RUN_ARTIFACT_SCHEMA_VERSION:
19
+ raise ValueError(
20
+ f"unsupported artifact schema {artifact.schema_version}; "
21
+ f"expected {RUN_ARTIFACT_SCHEMA_VERSION}"
22
+ )
23
+ return artifact
24
+
25
+
26
+ async def replay_artifact(path: str | Path) -> SuiteResult:
27
+ artifact = load_artifact(path)
28
+ source_path = artifact.metadata.get("source_path")
29
+ suite_name = artifact.metadata.get("suite_name")
30
+ if not isinstance(source_path, str):
31
+ raise ValueError("artifact does not contain metadata.source_path for deterministic replay")
32
+ suite = _load_suite(Path(source_path), suite_name if isinstance(suite_name, str) else None)
33
+ mutation = mutation_from_spec(artifact.mutation) if artifact.mutation else None
34
+ return await suite.run(
35
+ scenario=artifact.scenario,
36
+ mutations=[] if mutation is None else [mutation],
37
+ seed=artifact.seed,
38
+ artifacts_dir=Path(path).parent / "replay",
39
+ store_artifacts=False,
40
+ source_path=source_path,
41
+ suite_name=suite_name if isinstance(suite_name, str) else None,
42
+ )
43
+
44
+
45
+ def replay_artifact_sync(path: str | Path) -> SuiteResult:
46
+ return asyncio.run(replay_artifact(path))
47
+
48
+
49
+ def _load_suite(source_path: Path, suite_name: str | None) -> AgentTest:
50
+ module = _load_module_from_path(source_path)
51
+ suites = [
52
+ (name, value)
53
+ for name, value in vars(module).items()
54
+ if isinstance(value, AgentTest) and (suite_name is None or name == suite_name)
55
+ ]
56
+ if not suites:
57
+ raise ValueError(f"no AgentTest suite found in {source_path}")
58
+ return suites[0][1]
59
+
60
+
61
+ def _load_module_from_path(source_path: Path) -> ModuleType:
62
+ module_name = f"agentproof_replay_{abs(hash(source_path))}"
63
+ spec = importlib.util.spec_from_file_location(module_name, source_path)
64
+ if spec is None or spec.loader is None:
65
+ raise ValueError(f"cannot import {source_path}")
66
+ module = importlib.util.module_from_spec(spec)
67
+ sys.modules[module_name] = module
68
+ sys.path.insert(0, str(source_path.parent))
69
+ try:
70
+ spec.loader.exec_module(module)
71
+ finally:
72
+ with suppress(ValueError):
73
+ sys.path.remove(str(source_path.parent))
74
+ return module
@@ -0,0 +1,29 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from contextlib import suppress
5
+ from pathlib import Path
6
+
7
+ from agentproof.core.redaction import redact
8
+ from agentproof.core.result import RunResult
9
+ from agentproof.replay.schema import RunArtifact
10
+
11
+
12
+ def write_repro_artifact(result: RunResult, artifacts_dir: Path) -> Path:
13
+ run_dir = artifacts_dir / result.run_id
14
+ run_dir.mkdir(mode=0o700, parents=True, exist_ok=True)
15
+ artifact = RunArtifact(
16
+ run_id=result.run_id,
17
+ scenario=result.scenario,
18
+ seed=result.seed,
19
+ mutation=result.mutation,
20
+ initial_world_snapshot=redact(result.initial_world_snapshot),
21
+ trace=result.trace,
22
+ violated_invariants=result.violated_invariants,
23
+ metadata=redact(result.metadata),
24
+ )
25
+ path = run_dir / "repro.json"
26
+ path.write_text(artifact.model_dump_json(indent=2), encoding="utf-8")
27
+ with suppress(OSError):
28
+ os.chmod(path, 0o600)
29
+ return path
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field
6
+
7
+ from agentproof.core.faults import MutationSpec
8
+ from agentproof.core.trace import TraceEvent
9
+
10
+ RUN_ARTIFACT_SCHEMA_VERSION = 1
11
+
12
+
13
+ class RunArtifact(BaseModel):
14
+ model_config = ConfigDict(extra="forbid")
15
+
16
+ schema_version: int = RUN_ARTIFACT_SCHEMA_VERSION
17
+ run_id: str
18
+ scenario: str
19
+ seed: int
20
+ mutation: MutationSpec | None
21
+ initial_world_snapshot: dict[str, Any]
22
+ trace: list[TraceEvent]
23
+ violated_invariants: list[str] = Field(default_factory=list)
24
+ metadata: dict[str, Any] = Field(default_factory=dict)
@@ -0,0 +1,7 @@
1
+ from __future__ import annotations
2
+
3
+ from agentproof.reporting.console import render_suite_result
4
+ from agentproof.reporting.json_report import suite_to_json, write_json_report
5
+ from agentproof.reporting.junit import write_junit_report
6
+
7
+ __all__ = ["render_suite_result", "suite_to_json", "write_json_report", "write_junit_report"]
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+
6
+ from agentproof.core.result import SuiteResult
7
+
8
+
9
+ def render_suite_result(result: SuiteResult, *, console: Console | None = None) -> None:
10
+ output = console or Console()
11
+ output.print("[bold]AgentProof[/bold]")
12
+ output.print(
13
+ f"{result.total_count} runs, {result.passed_count} passed, {result.failed_count} failed",
14
+ )
15
+ table = Table(show_header=True, header_style="bold")
16
+ table.add_column("Run")
17
+ table.add_column("Status")
18
+ table.add_column("Severity")
19
+ table.add_column("Failure")
20
+ for item in result.results:
21
+ failure_text = item.error_message or ", ".join(item.violated_invariants)
22
+ table.add_row(item.name, item.status, item.severity, failure_text)
23
+ output.print(table)
24
+ for item in result.failures:
25
+ output.print(f"\n[bold red]{item.status}[/bold red] {item.name}")
26
+ if item.invariant_failures:
27
+ for failure in item.invariant_failures:
28
+ output.print(f"Invariant violated: {failure.name}")
29
+ output.print(failure.message)
30
+ if item.effects:
31
+ output.print(f"Committed effects: {len(item.effects)}")
32
+ if item.artifact_path:
33
+ output.print(f"Reproduce: agentproof replay {item.artifact_path}")
34
+ output.print(f"Seed: {item.seed}")
@@ -0,0 +1,47 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from agentproof.core.redaction import redact
8
+ from agentproof.core.result import SuiteResult
9
+
10
+
11
+ def suite_to_json(result: SuiteResult) -> dict[str, Any]:
12
+ return redact(
13
+ {
14
+ "schema_version": result.schema_version,
15
+ "run_id": result.run_id,
16
+ "summary": {
17
+ "total": result.total_count,
18
+ "passed": result.passed_count,
19
+ "failed": result.failed_count,
20
+ },
21
+ "results": [
22
+ {
23
+ "name": item.name,
24
+ "scenario": item.scenario,
25
+ "adapter": item.adapter,
26
+ "seed": item.seed,
27
+ "mutation": item.mutation.model_dump(mode="json") if item.mutation else None,
28
+ "severity": item.severity,
29
+ "status": item.status,
30
+ "violated_invariants": item.violated_invariants,
31
+ "invariant_failures": [
32
+ failure.model_dump(mode="json") for failure in item.invariant_failures
33
+ ],
34
+ "error_message": item.error_message,
35
+ "effects": [effect.model_dump(mode="json") for effect in item.effects],
36
+ "trace": [event.model_dump(mode="json") for event in item.trace],
37
+ "artifact_path": str(item.artifact_path) if item.artifact_path else None,
38
+ }
39
+ for item in result.results
40
+ ],
41
+ "metadata": result.metadata,
42
+ }
43
+ )
44
+
45
+
46
+ def write_json_report(result: SuiteResult, path: str | Path) -> None:
47
+ Path(path).write_text(json.dumps(suite_to_json(result), indent=2), encoding="utf-8")
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from xml.etree import ElementTree as ET
5
+
6
+ from agentproof.core.result import SuiteResult
7
+
8
+
9
+ def write_junit_report(result: SuiteResult, path: str | Path) -> None:
10
+ testsuite = ET.Element(
11
+ "testsuite",
12
+ {
13
+ "name": "agentproof",
14
+ "tests": str(result.total_count),
15
+ "failures": str(
16
+ len(
17
+ [
18
+ item
19
+ for item in result.results
20
+ if item.status in {"INVARIANT_FAILURE", "BASELINE_FAILURE"}
21
+ ]
22
+ )
23
+ ),
24
+ "errors": str(
25
+ len(
26
+ [
27
+ item
28
+ for item in result.results
29
+ if item.status
30
+ in {"AGENT_ERROR", "TEST_ERROR", "ADAPTER_ERROR", "UNSUPPORTED"}
31
+ ]
32
+ )
33
+ ),
34
+ "skipped": str(len([item for item in result.results if item.status == "SKIPPED"])),
35
+ },
36
+ )
37
+ for item in result.results:
38
+ testcase = ET.SubElement(
39
+ testsuite,
40
+ "testcase",
41
+ {
42
+ "classname": f"agentproof.{item.scenario}",
43
+ "name": item.name,
44
+ },
45
+ )
46
+ message = item.error_message or ",".join(item.violated_invariants)
47
+ body = _trace_body(item)
48
+ if item.status in {"INVARIANT_FAILURE", "BASELINE_FAILURE"}:
49
+ failure = ET.SubElement(testcase, "failure", {"message": message or item.status})
50
+ failure.text = body
51
+ elif item.status in {"AGENT_ERROR", "TEST_ERROR", "ADAPTER_ERROR", "UNSUPPORTED"}:
52
+ error = ET.SubElement(testcase, "error", {"message": message or item.status})
53
+ error.text = body
54
+ elif item.status == "SKIPPED":
55
+ ET.SubElement(testcase, "skipped", {"message": message or "skipped"})
56
+ tree = ET.ElementTree(testsuite)
57
+ ET.indent(tree, space=" ")
58
+ tree.write(path, encoding="utf-8", xml_declaration=True)
59
+
60
+
61
+ def _trace_body(item: object) -> str:
62
+ trace = getattr(item, "trace", [])
63
+ lines = []
64
+ for event in trace:
65
+ lines.append(f"{event.seq} {event.kind} {event.name} {event.data}")
66
+ return "\n".join(lines)
@@ -0,0 +1,26 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Literal
6
+
7
+ from agentproof.core.effects import EffectDraft
8
+
9
+ ToolEffect = Literal["read", "write", "delete", "external", "financial", "privileged"]
10
+ ToolHandler = Callable[..., Awaitable[Any] | Any]
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class ToolOutcome:
15
+ value: Any
16
+ effects: list[EffectDraft | dict[str, Any]] = field(default_factory=list)
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class ToolDefinition:
21
+ name: str
22
+ description: str
23
+ input_schema: dict[str, Any]
24
+ handler: ToolHandler
25
+ effect: ToolEffect | None = None
26
+ idempotent: bool | None = None
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ class ToolClient:
7
+ def __init__(self, world: Any) -> None:
8
+ self._world = world
9
+
10
+ async def call(self, name: str, **kwargs: Any) -> Any:
11
+ return await self._world.tools.invoke(name, kwargs)
@@ -0,0 +1,154 @@
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ from typing import Any
5
+
6
+ from agentproof.core.faults import AgentProofToolError, ToolValidationError
7
+ from agentproof.tools.definition import ToolDefinition, ToolOutcome
8
+
9
+
10
+ class ToolRegistry:
11
+ def __init__(self, world: Any) -> None:
12
+ self._world = world
13
+ self._tools: dict[str, ToolDefinition] = {}
14
+
15
+ def register(
16
+ self,
17
+ *,
18
+ name: str,
19
+ description: str,
20
+ input_schema: dict[str, Any],
21
+ handler: Any,
22
+ effect: str | None = None,
23
+ idempotent: bool | None = None,
24
+ ) -> ToolDefinition:
25
+ definition = ToolDefinition(
26
+ name=name,
27
+ description=description,
28
+ input_schema=input_schema,
29
+ handler=handler,
30
+ effect=effect, # type: ignore[arg-type]
31
+ idempotent=idempotent,
32
+ )
33
+ self._tools[name] = definition
34
+ return definition
35
+
36
+ def get(self, name: str) -> ToolDefinition:
37
+ return self._tools[name]
38
+
39
+ def all(self) -> list[ToolDefinition]:
40
+ return list(self._tools.values())
41
+
42
+ async def invoke(self, name: str, arguments: dict[str, Any] | None = None) -> Any:
43
+ if name not in self._tools:
44
+ raise KeyError(f"unknown tool: {name}")
45
+ args = dict(arguments or {})
46
+ definition = self._tools[name]
47
+ self._validate(definition, args)
48
+ invocation_id = self._world.next_id("invocation")
49
+ self._world.trace.record(
50
+ "tool_call",
51
+ name,
52
+ {"arguments": args, "invocation_id": invocation_id},
53
+ )
54
+
55
+ action = self._world.faults.before_tool_call(
56
+ tool_name=name,
57
+ args=args,
58
+ invocation_id=invocation_id,
59
+ )
60
+ if action is not None and action.handled:
61
+ if action.exception is not None:
62
+ self._world.trace.record(
63
+ "tool_error",
64
+ name,
65
+ {
66
+ "error_type": type(action.exception).__name__,
67
+ "message": str(action.exception),
68
+ "invocation_id": invocation_id,
69
+ },
70
+ )
71
+ raise action.exception
72
+ self._world.trace.record(
73
+ "tool_result",
74
+ name,
75
+ {"result": action.return_value, "invocation_id": invocation_id},
76
+ )
77
+ return action.return_value
78
+
79
+ try:
80
+ raw = await self._call_handler(definition, args)
81
+ except AgentProofToolError:
82
+ raise
83
+ except Exception as exc:
84
+ self._world.trace.record(
85
+ "tool_error",
86
+ name,
87
+ {
88
+ "error_type": type(exc).__name__,
89
+ "message": str(exc),
90
+ "invocation_id": invocation_id,
91
+ },
92
+ )
93
+ raise
94
+
95
+ outcome = raw if isinstance(raw, ToolOutcome) else ToolOutcome(value=raw)
96
+ for draft in outcome.effects:
97
+ effect = self._world.effects.commit(
98
+ draft,
99
+ tool_name=name,
100
+ invocation_id=invocation_id,
101
+ committed_at=self._world.clock.now(),
102
+ )
103
+ self._world.trace.record(
104
+ "effect",
105
+ effect.type,
106
+ {"effect": effect.model_dump(mode="json"), "invocation_id": invocation_id},
107
+ )
108
+
109
+ try:
110
+ self._world.faults.after_commit_before_response(
111
+ tool_name=name,
112
+ invocation_id=invocation_id,
113
+ )
114
+ except AgentProofToolError as exc:
115
+ self._world.trace.record(
116
+ "tool_error",
117
+ name,
118
+ {
119
+ "error_type": type(exc).__name__,
120
+ "message": str(exc),
121
+ "invocation_id": invocation_id,
122
+ },
123
+ )
124
+ raise
125
+
126
+ response = self._world.faults.after_tool_response(
127
+ tool_name=name,
128
+ invocation_id=invocation_id,
129
+ response=outcome.value,
130
+ )
131
+ self._world.trace.record(
132
+ "tool_result",
133
+ name,
134
+ {"result": response, "invocation_id": invocation_id},
135
+ )
136
+ return response
137
+
138
+ async def _call_handler(self, definition: ToolDefinition, args: dict[str, Any]) -> Any:
139
+ signature = inspect.signature(definition.handler)
140
+ kwargs = dict(args)
141
+ if "world" in signature.parameters:
142
+ kwargs["world"] = self._world
143
+ result = definition.handler(**kwargs)
144
+ if inspect.isawaitable(result):
145
+ return await result
146
+ return result
147
+
148
+ @staticmethod
149
+ def _validate(definition: ToolDefinition, args: dict[str, Any]) -> None:
150
+ schema = definition.input_schema or {}
151
+ required = schema.get("required", [])
152
+ for field in required:
153
+ if field not in args:
154
+ raise ToolValidationError(definition.name, f"missing required field: {field}")