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 ADDED
@@ -0,0 +1,3 @@
1
+ """faultbench: fake, stateful worlds with fault injection for testing tool-using AI agents."""
2
+
3
+ __version__ = "0.1.0"
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)