faultbench 0.1.0__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.
- faultbench/__init__.py +3 -0
- faultbench/cli.py +60 -0
- faultbench/faults/__init__.py +16 -0
- faultbench/faults/clock.py +32 -0
- faultbench/faults/injector.py +151 -0
- faultbench/faults/profile.py +104 -0
- faultbench/integrations/__init__.py +6 -0
- faultbench/integrations/pydantic_ai.py +55 -0
- faultbench/pytest_plugin.py +300 -0
- faultbench/server/__init__.py +6 -0
- faultbench/server/__main__.py +41 -0
- faultbench/server/mcp_server.py +298 -0
- faultbench/server/operations.py +62 -0
- faultbench/trace/__init__.py +6 -0
- faultbench/trace/queries.py +55 -0
- faultbench/trace/recorder.py +58 -0
- faultbench/world/__init__.py +29 -0
- faultbench/world/engine.py +252 -0
- faultbench/world/schema.py +154 -0
- faultbench/world/seed.py +79 -0
- faultbench-0.1.0.dist-info/METADATA +137 -0
- faultbench-0.1.0.dist-info/RECORD +25 -0
- faultbench-0.1.0.dist-info/WHEEL +4 -0
- faultbench-0.1.0.dist-info/entry_points.txt +5 -0
- faultbench-0.1.0.dist-info/licenses/LICENSE +175 -0
faultbench/__init__.py
ADDED
faultbench/cli.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""`faultbench` command line. `serve` runs a world.yaml over MCP (stdio or HTTP)."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from faultbench import __version__
|
|
7
|
+
from faultbench.faults import FaultProfile
|
|
8
|
+
from faultbench.server import serve_http, serve_stdio
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv: list[str] | None = None) -> int:
|
|
12
|
+
parser = argparse.ArgumentParser(prog="faultbench", description=__doc__)
|
|
13
|
+
parser.add_argument("--version", action="version", version=f"faultbench {__version__}")
|
|
14
|
+
sub = parser.add_subparsers(dest="command")
|
|
15
|
+
|
|
16
|
+
serve = sub.add_parser("serve", help="serve a world.yaml over MCP (stdio or HTTP)")
|
|
17
|
+
serve.add_argument("world", help="path to world.yaml")
|
|
18
|
+
serve.add_argument(
|
|
19
|
+
"--http", action="store_true", help="serve over streamable HTTP instead of stdio"
|
|
20
|
+
)
|
|
21
|
+
serve.add_argument("--host", default="127.0.0.1", help="HTTP host (default 127.0.0.1)")
|
|
22
|
+
serve.add_argument("--port", type=int, default=8000, help="HTTP port (default 8000)")
|
|
23
|
+
serve.add_argument("--faults", action="store_true", help="apply the world file's faults: block")
|
|
24
|
+
serve.add_argument("--run-index", type=int, default=0, help="run index for the fault RNG")
|
|
25
|
+
serve.add_argument("--state-file", help="mirror world state to this JSON file after each call")
|
|
26
|
+
|
|
27
|
+
args = parser.parse_args(argv)
|
|
28
|
+
if args.command == "serve":
|
|
29
|
+
return _serve(args)
|
|
30
|
+
parser.print_help()
|
|
31
|
+
return 0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _serve(args: argparse.Namespace) -> int:
|
|
35
|
+
faults = FaultProfile.from_world_file(args.world) if args.faults else None
|
|
36
|
+
if args.http:
|
|
37
|
+
print(
|
|
38
|
+
f"faultbench: serving {args.world} at http://{args.host}:{args.port}/mcp",
|
|
39
|
+
file=sys.stderr,
|
|
40
|
+
)
|
|
41
|
+
serve_http(
|
|
42
|
+
args.world,
|
|
43
|
+
host=args.host,
|
|
44
|
+
port=args.port,
|
|
45
|
+
faults=faults,
|
|
46
|
+
run_index=args.run_index,
|
|
47
|
+
state_file=args.state_file,
|
|
48
|
+
)
|
|
49
|
+
else:
|
|
50
|
+
serve_stdio(
|
|
51
|
+
args.world,
|
|
52
|
+
faults=faults,
|
|
53
|
+
run_index=args.run_index,
|
|
54
|
+
state_file=args.state_file,
|
|
55
|
+
)
|
|
56
|
+
return 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
if __name__ == "__main__":
|
|
60
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""faultbench.faults — see docs/ARCHITECTURE.md."""
|
|
2
|
+
|
|
3
|
+
from .clock import DEFAULT_NOW, FakeClock
|
|
4
|
+
from .injector import FaultError, FaultTimeout, Injector
|
|
5
|
+
from .profile import FaultProfile, FaultRule, parse_duration
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"FakeClock",
|
|
9
|
+
"DEFAULT_NOW",
|
|
10
|
+
"FaultProfile",
|
|
11
|
+
"FaultRule",
|
|
12
|
+
"parse_duration",
|
|
13
|
+
"Injector",
|
|
14
|
+
"FaultTimeout",
|
|
15
|
+
"FaultError",
|
|
16
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""FakeClock: the only source of time inside a world. Milestone 4.
|
|
2
|
+
|
|
3
|
+
The engine, handlers, and injector read "now" only from a FakeClock, so time-dependent
|
|
4
|
+
behaviour ("this order is 2 hours old") is testable without waiting. A FakeClock never
|
|
5
|
+
advances on its own — a test moves it with `advance()` or `set()`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
|
|
12
|
+
# A fixed default "now", so a clock with no explicit start is still deterministic.
|
|
13
|
+
DEFAULT_NOW = datetime(2025, 6, 1)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FakeClock:
|
|
17
|
+
def __init__(self, now: datetime | None = None) -> None:
|
|
18
|
+
self._now = now or DEFAULT_NOW
|
|
19
|
+
|
|
20
|
+
def now(self) -> datetime:
|
|
21
|
+
return self._now
|
|
22
|
+
|
|
23
|
+
def advance(self, seconds: float) -> datetime:
|
|
24
|
+
self._now += timedelta(seconds=seconds)
|
|
25
|
+
return self._now
|
|
26
|
+
|
|
27
|
+
def set(self, when: datetime) -> datetime:
|
|
28
|
+
self._now = when
|
|
29
|
+
return self._now
|
|
30
|
+
|
|
31
|
+
def __repr__(self) -> str:
|
|
32
|
+
return f"FakeClock({self._now.isoformat()})"
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Fault injector: wraps an operation call and applies latency + errors. Milestones 4, 7.
|
|
2
|
+
|
|
3
|
+
Determinism: all randomness comes from `Random(f"{seed}:{run_index}")`, so the same profile,
|
|
4
|
+
seed, and run index produce the same fault sequence across processes. Conditional faults and
|
|
5
|
+
rate limits are deterministic (data- and clock-driven, no RNG).
|
|
6
|
+
|
|
7
|
+
Order of checks per call: latency → conditional not-found → rate limit → probabilistic error.
|
|
8
|
+
|
|
9
|
+
Error timing models real failures:
|
|
10
|
+
- `timeout`: the request reached the server and RAN (its side effect happened), but the
|
|
11
|
+
client gave up waiting. So the op executes, then a FaultTimeout is raised. A client that
|
|
12
|
+
retries will run it again — this is how a timeout becomes a double write.
|
|
13
|
+
- `http_500` / `http_429` / `not_found`: the request is rejected BEFORE running, so there
|
|
14
|
+
is no side effect.
|
|
15
|
+
- `empty_result` / `malformed_json`: the response is corrupted; the op does NOT run.
|
|
16
|
+
|
|
17
|
+
Conditional (milestone 7):
|
|
18
|
+
- `not_found_if_newer_than: 2h`: a read of a record whose timestamp is newer than 2h before
|
|
19
|
+
`clock.now()` returns not_found — models sync lag (recent writes not yet visible).
|
|
20
|
+
- `rate_limit: {calls, per_seconds}`: after `calls` calls within the window, returns
|
|
21
|
+
http_429 until the window rolls forward (measured on the clock).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import random
|
|
27
|
+
import sys
|
|
28
|
+
import time
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
from typing import Any
|
|
32
|
+
|
|
33
|
+
from .profile import FaultProfile, FaultRule
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FaultTimeout(Exception):
|
|
37
|
+
"""The operation ran, but the client 'timed out' before getting the result."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FaultError(Exception):
|
|
41
|
+
"""The operation was rejected by an injected fault before it ran (or its response was
|
|
42
|
+
corrupted)."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, kind: str, message: str | None = None) -> None:
|
|
45
|
+
self.kind = kind
|
|
46
|
+
super().__init__(message or f"injected {kind}")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Injector:
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
profile: FaultProfile,
|
|
53
|
+
seed: int = 0,
|
|
54
|
+
run_index: int = 0,
|
|
55
|
+
clock: Any = None,
|
|
56
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
57
|
+
timeout_seconds: float = 0.0,
|
|
58
|
+
on_event: Callable[[dict], None] | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
self.profile = profile
|
|
61
|
+
self.clock = clock
|
|
62
|
+
self._rng = random.Random(f"{seed}:{run_index}")
|
|
63
|
+
self._sleep = sleep
|
|
64
|
+
self._timeout_seconds = timeout_seconds
|
|
65
|
+
self._on_event = on_event
|
|
66
|
+
self._rl_calls: dict[str, list[float]] = {} # service.op -> recent call times (rate limit)
|
|
67
|
+
|
|
68
|
+
def call(
|
|
69
|
+
self,
|
|
70
|
+
service: str,
|
|
71
|
+
op: str,
|
|
72
|
+
fn: Callable[[], Any],
|
|
73
|
+
on_decision: Callable[[str | None, float], None] | None = None,
|
|
74
|
+
record_time: datetime | None = None,
|
|
75
|
+
) -> Any:
|
|
76
|
+
"""Run `fn` under the fault rule for `service.op`.
|
|
77
|
+
|
|
78
|
+
`on_decision(fault_kind, latency_ms)` is called once the fault is decided, before the
|
|
79
|
+
operation runs — so a recorder can log what was injected even if the call then fails.
|
|
80
|
+
`record_time` is the target record's timestamp, used by conditional not-found rules.
|
|
81
|
+
"""
|
|
82
|
+
rule = self.profile.resolve(service, op)
|
|
83
|
+
latency_ms = self._apply_latency(rule)
|
|
84
|
+
kind = self._conditional(rule, record_time) or self._rate_limited(service, op, rule)
|
|
85
|
+
if kind is None:
|
|
86
|
+
kind = self._roll(rule.errors)
|
|
87
|
+
if on_decision:
|
|
88
|
+
on_decision(kind, latency_ms)
|
|
89
|
+
self._emit(service, op, kind)
|
|
90
|
+
if kind is None:
|
|
91
|
+
return fn()
|
|
92
|
+
if kind == "timeout":
|
|
93
|
+
fn() # side effect happens; the result is lost because the client "times out"
|
|
94
|
+
if self._timeout_seconds:
|
|
95
|
+
self._sleep(self._timeout_seconds)
|
|
96
|
+
raise FaultTimeout(f"{op} timed out after executing")
|
|
97
|
+
if kind == "empty_result":
|
|
98
|
+
return []
|
|
99
|
+
if kind == "malformed_json":
|
|
100
|
+
return "}{ not valid json"
|
|
101
|
+
raise FaultError(kind, f"{op}: injected {kind}")
|
|
102
|
+
|
|
103
|
+
# --- internals ---------------------------------------------------------------
|
|
104
|
+
def _conditional(self, rule: FaultRule, record_time: datetime | None) -> str | None:
|
|
105
|
+
"""Return 'not_found' when the record is newer than the sync-lag window."""
|
|
106
|
+
window = rule.conditional.get("not_found_if_newer_than")
|
|
107
|
+
if window is None or record_time is None or self.clock is None:
|
|
108
|
+
return None
|
|
109
|
+
age = (self.clock.now() - record_time).total_seconds()
|
|
110
|
+
return "not_found" if age < window else None
|
|
111
|
+
|
|
112
|
+
def _rate_limited(self, service: str, op: str, rule: FaultRule) -> str | None:
|
|
113
|
+
"""Return 'http_429' once more than `calls` calls happen within the window."""
|
|
114
|
+
limit = rule.rate_limit
|
|
115
|
+
if limit is None:
|
|
116
|
+
return None
|
|
117
|
+
now = self.clock.now().timestamp() if self.clock is not None else time.monotonic()
|
|
118
|
+
key = f"{service}.{op}"
|
|
119
|
+
recent = [t for t in self._rl_calls.get(key, []) if now - t < limit.per_seconds]
|
|
120
|
+
if len(recent) >= limit.calls:
|
|
121
|
+
self._rl_calls[key] = recent
|
|
122
|
+
return "http_429"
|
|
123
|
+
recent.append(now)
|
|
124
|
+
self._rl_calls[key] = recent
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
def _apply_latency(self, rule: FaultRule) -> float:
|
|
128
|
+
if not rule.latency_ms:
|
|
129
|
+
return 0.0
|
|
130
|
+
lo, hi = rule.latency_ms
|
|
131
|
+
ms = self._rng.uniform(lo, hi)
|
|
132
|
+
self._sleep(ms / 1000.0)
|
|
133
|
+
return ms
|
|
134
|
+
|
|
135
|
+
def _roll(self, errors: dict[str, float]) -> str | None:
|
|
136
|
+
if not errors:
|
|
137
|
+
return None
|
|
138
|
+
r = self._rng.random()
|
|
139
|
+
cumulative = 0.0
|
|
140
|
+
for kind, prob in errors.items(): # insertion order (from YAML) => deterministic
|
|
141
|
+
cumulative += prob
|
|
142
|
+
if r < cumulative:
|
|
143
|
+
return kind
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
def _emit(self, service: str, op: str, kind: str | None) -> None:
|
|
147
|
+
event = {"service": service, "op": op, "fault": kind}
|
|
148
|
+
if kind:
|
|
149
|
+
print(f"[faultbench:fault] {service}.{op} -> {kind}", file=sys.stderr)
|
|
150
|
+
if self._on_event:
|
|
151
|
+
self._on_event(event)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Fault profile: the parsed `faults:` block. Milestone 4.
|
|
2
|
+
|
|
3
|
+
A profile maps a key to a rule. The key is either `default` (applies to every operation) or
|
|
4
|
+
`service.operation` (applies to one). A rule carries a latency range and a map of error kind
|
|
5
|
+
-> probability. `resolve(service, op)` merges the default rule with the specific one.
|
|
6
|
+
|
|
7
|
+
Conditional errors (e.g. `not_found_if_newer_than: 2h`) and rate limits are parsed and kept
|
|
8
|
+
but NOT enforced yet — that is milestone 7. Keeping them here means shop.yaml still loads.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
# Probabilistic error kinds the injector knows how to produce (milestone 4).
|
|
20
|
+
KNOWN_ERROR_KINDS = frozenset(
|
|
21
|
+
{"timeout", "http_500", "http_429", "malformed_json", "empty_result", "not_found"}
|
|
22
|
+
)
|
|
23
|
+
# Keys inside `errors:` that are conditions, not probabilities (enforced in milestone 7).
|
|
24
|
+
CONDITIONAL_KEYS = frozenset({"not_found_if_newer_than"})
|
|
25
|
+
|
|
26
|
+
_DURATION_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*([smhd])\s*$")
|
|
27
|
+
_UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_duration(text: str) -> float:
|
|
31
|
+
""" "2h" -> 7200.0 seconds."""
|
|
32
|
+
m = _DURATION_RE.match(text)
|
|
33
|
+
if not m:
|
|
34
|
+
raise ValueError(f"bad duration {text!r} (expected e.g. '30s', '2h', '1d')")
|
|
35
|
+
return float(m.group(1)) * _UNIT_SECONDS[m.group(2)]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RateLimit(BaseModel):
|
|
39
|
+
calls: int
|
|
40
|
+
per_seconds: float
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FaultRule(BaseModel):
|
|
44
|
+
latency_ms: tuple[int, int] | None = None
|
|
45
|
+
errors: dict[str, float] = Field(default_factory=dict) # kind -> probability
|
|
46
|
+
conditional: dict[str, float] = Field(default_factory=dict) # cond -> seconds
|
|
47
|
+
rate_limit: RateLimit | None = None
|
|
48
|
+
|
|
49
|
+
@classmethod
|
|
50
|
+
def from_body(cls, body: dict) -> FaultRule:
|
|
51
|
+
latency = body.get("latency_ms")
|
|
52
|
+
if latency is not None:
|
|
53
|
+
latency = tuple(latency)
|
|
54
|
+
if len(latency) != 2 or latency[0] > latency[1]:
|
|
55
|
+
raise ValueError(f"latency_ms must be [lo, hi] with lo <= hi, got {latency}")
|
|
56
|
+
errors: dict[str, float] = {}
|
|
57
|
+
conditional: dict[str, float] = {}
|
|
58
|
+
for kind, value in (body.get("errors") or {}).items():
|
|
59
|
+
if kind in CONDITIONAL_KEYS:
|
|
60
|
+
conditional[kind] = parse_duration(str(value))
|
|
61
|
+
elif kind in KNOWN_ERROR_KINDS:
|
|
62
|
+
errors[kind] = float(value)
|
|
63
|
+
else:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"unknown error kind {kind!r} (known: {sorted(KNOWN_ERROR_KINDS)}, "
|
|
66
|
+
f"conditional: {sorted(CONDITIONAL_KEYS)})"
|
|
67
|
+
)
|
|
68
|
+
total = sum(errors.values())
|
|
69
|
+
if total > 1.0 + 1e-9:
|
|
70
|
+
raise ValueError(f"error probabilities sum to {total} > 1.0")
|
|
71
|
+
rate_limit = RateLimit(**body["rate_limit"]) if body.get("rate_limit") else None
|
|
72
|
+
return cls(
|
|
73
|
+
latency_ms=latency, errors=errors, conditional=conditional, rate_limit=rate_limit
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class FaultProfile(BaseModel):
|
|
78
|
+
rules: dict[str, FaultRule] = Field(default_factory=dict)
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_dict(cls, faults: dict | None) -> FaultProfile:
|
|
82
|
+
rules = {key: FaultRule.from_body(body or {}) for key, body in (faults or {}).items()}
|
|
83
|
+
return cls(rules=rules)
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_world_file(cls, path: str | Path) -> FaultProfile:
|
|
87
|
+
data = yaml.safe_load(Path(path).read_text())
|
|
88
|
+
return cls.from_dict(data.get("faults"))
|
|
89
|
+
|
|
90
|
+
def resolve(self, service: str, op: str) -> FaultRule:
|
|
91
|
+
"""The effective rule for one operation: default merged with its specific rule."""
|
|
92
|
+
default = self.rules.get("default", FaultRule())
|
|
93
|
+
specific = self.rules.get(f"{service}.{op}")
|
|
94
|
+
if specific is None:
|
|
95
|
+
return default
|
|
96
|
+
return FaultRule(
|
|
97
|
+
latency_ms=specific.latency_ms or default.latency_ms,
|
|
98
|
+
errors={**default.errors, **specific.errors},
|
|
99
|
+
conditional={**default.conditional, **specific.conditional},
|
|
100
|
+
rate_limit=specific.rate_limit or default.rate_limit,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def is_empty(self) -> bool:
|
|
104
|
+
return not self.rules
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Optional, framework-specific adapters for connecting an agent to a faultbench server.
|
|
2
|
+
|
|
3
|
+
These are convenience only — faultbench's core is framework-neutral (it serves standard MCP).
|
|
4
|
+
Import the adapter for your framework, e.g. `from faultbench.integrations.pydantic_ai import
|
|
5
|
+
run_agent`. Each adapter needs its framework installed (an optional dependency).
|
|
6
|
+
"""
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Pydantic AI adapter: connect an agent to a faultbench server in one line.
|
|
2
|
+
|
|
3
|
+
Needs Pydantic AI: pip install 'faultbench[pydantic-ai]'
|
|
4
|
+
|
|
5
|
+
`mcp` is whatever the pytest fixtures give you — an in-process server (`mcp_server`) or an
|
|
6
|
+
HTTP URL (`mcp_url`); the same helpers work with both, so tests read the same either way.
|
|
7
|
+
|
|
8
|
+
from faultbench.integrations.pydantic_ai import run_agent
|
|
9
|
+
|
|
10
|
+
@pytest.mark.world("world.yaml")
|
|
11
|
+
async def test_refund(world, mcp_server):
|
|
12
|
+
order = world.orders.pick(status="delivered")
|
|
13
|
+
await run_agent("openai:gpt-5-mini", f"Refund order {order.id}", mcp=mcp_server,
|
|
14
|
+
system_prompt="You are a refund agent.")
|
|
15
|
+
assert len(world.refunds.where(order_id=order.id)) == 1
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
from pydantic_ai import Agent
|
|
24
|
+
from pydantic_ai.mcp import FastMCPClient, MCPToolset
|
|
25
|
+
except ImportError as exc: # pragma: no cover - exercised only without a suitable version
|
|
26
|
+
raise ImportError(
|
|
27
|
+
"faultbench.integrations.pydantic_ai needs a recent Pydantic AI (v2+, tested with "
|
|
28
|
+
"2.45+). Install it with: pip install 'faultbench[pydantic-ai]'"
|
|
29
|
+
) from exc
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def toolset(mcp: Any) -> MCPToolset:
|
|
33
|
+
"""A Pydantic AI toolset for a faultbench server. `mcp` is an in-process MCPServer (the
|
|
34
|
+
`mcp_server` fixture) or an HTTP URL string (the `mcp_url` fixture)."""
|
|
35
|
+
return MCPToolset(FastMCPClient(str(mcp) if not _is_server(mcp) else mcp))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def agent(model: str, mcp: Any, *, system_prompt: str = "", **agent_kwargs: Any) -> Agent:
|
|
39
|
+
"""A Pydantic AI Agent wired to a faultbench server."""
|
|
40
|
+
return Agent(model, system_prompt=system_prompt, toolsets=[toolset(mcp)], **agent_kwargs)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def run_agent(
|
|
44
|
+
model: str, prompt: str, *, mcp: Any, system_prompt: str = "", **agent_kwargs: Any
|
|
45
|
+
) -> str:
|
|
46
|
+
"""Run one request end to end against a faultbench server; return the agent's reply."""
|
|
47
|
+
the_agent = agent(model, mcp, system_prompt=system_prompt, **agent_kwargs)
|
|
48
|
+
async with the_agent:
|
|
49
|
+
result = await the_agent.run(prompt)
|
|
50
|
+
return result.output
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_server(mcp: Any) -> bool:
|
|
54
|
+
# A URL/path is a str; anything else is treated as an in-process server object.
|
|
55
|
+
return not isinstance(mcp, str)
|