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,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,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}")
|