faultbench 0.1.0__tar.gz

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 (68) hide show
  1. faultbench-0.1.0/.env.example +4 -0
  2. faultbench-0.1.0/.github/workflows/ci.yml +27 -0
  3. faultbench-0.1.0/.gitignore +12 -0
  4. faultbench-0.1.0/.python-version +1 -0
  5. faultbench-0.1.0/CHANGELOG.md +4 -0
  6. faultbench-0.1.0/CLAUDE.md +52 -0
  7. faultbench-0.1.0/LICENSE +175 -0
  8. faultbench-0.1.0/PKG-INFO +137 -0
  9. faultbench-0.1.0/README.md +108 -0
  10. faultbench-0.1.0/conftest.py +4 -0
  11. faultbench-0.1.0/docs/ARCHITECTURE.md +50 -0
  12. faultbench-0.1.0/docs/DECISIONS.md +51 -0
  13. faultbench-0.1.0/docs/JOURNEY.md +82 -0
  14. faultbench-0.1.0/docs/STATUS.md +58 -0
  15. faultbench-0.1.0/docs/TESTING.md +133 -0
  16. faultbench-0.1.0/docs/WORLDS.md +157 -0
  17. faultbench-0.1.0/examples/bank/README.md +26 -0
  18. faultbench-0.1.0/examples/bank/bank_rules.py +37 -0
  19. faultbench-0.1.0/examples/bank/worlds/bank.yaml +39 -0
  20. faultbench-0.1.0/examples/shop/README.md +71 -0
  21. faultbench-0.1.0/examples/shop/agent.py +94 -0
  22. faultbench-0.1.0/examples/shop/conftest.py +21 -0
  23. faultbench-0.1.0/examples/shop/demo.py +127 -0
  24. faultbench-0.1.0/examples/shop/shop_rules.py +26 -0
  25. faultbench-0.1.0/examples/shop/test_openai_agents.py +40 -0
  26. faultbench-0.1.0/examples/shop/test_refunds.py +44 -0
  27. faultbench-0.1.0/examples/shop/worlds/shop.yaml +45 -0
  28. faultbench-0.1.0/examples/stripe/README.md +26 -0
  29. faultbench-0.1.0/examples/stripe/stripe_rules.py +69 -0
  30. faultbench-0.1.0/examples/stripe/worlds/stripe.yaml +69 -0
  31. faultbench-0.1.0/pyproject.toml +61 -0
  32. faultbench-0.1.0/src/faultbench/__init__.py +3 -0
  33. faultbench-0.1.0/src/faultbench/cli.py +60 -0
  34. faultbench-0.1.0/src/faultbench/faults/__init__.py +16 -0
  35. faultbench-0.1.0/src/faultbench/faults/clock.py +32 -0
  36. faultbench-0.1.0/src/faultbench/faults/injector.py +151 -0
  37. faultbench-0.1.0/src/faultbench/faults/profile.py +104 -0
  38. faultbench-0.1.0/src/faultbench/integrations/__init__.py +6 -0
  39. faultbench-0.1.0/src/faultbench/integrations/pydantic_ai.py +55 -0
  40. faultbench-0.1.0/src/faultbench/pytest_plugin.py +300 -0
  41. faultbench-0.1.0/src/faultbench/server/__init__.py +6 -0
  42. faultbench-0.1.0/src/faultbench/server/__main__.py +41 -0
  43. faultbench-0.1.0/src/faultbench/server/mcp_server.py +298 -0
  44. faultbench-0.1.0/src/faultbench/server/operations.py +62 -0
  45. faultbench-0.1.0/src/faultbench/trace/__init__.py +6 -0
  46. faultbench-0.1.0/src/faultbench/trace/queries.py +55 -0
  47. faultbench-0.1.0/src/faultbench/trace/recorder.py +58 -0
  48. faultbench-0.1.0/src/faultbench/world/__init__.py +29 -0
  49. faultbench-0.1.0/src/faultbench/world/engine.py +252 -0
  50. faultbench-0.1.0/src/faultbench/world/schema.py +154 -0
  51. faultbench-0.1.0/src/faultbench/world/seed.py +79 -0
  52. faultbench-0.1.0/tests/__init__.py +0 -0
  53. faultbench-0.1.0/tests/test_bank_example.py +66 -0
  54. faultbench-0.1.0/tests/test_errors.py +46 -0
  55. faultbench-0.1.0/tests/test_faults.py +193 -0
  56. faultbench-0.1.0/tests/test_http.py +36 -0
  57. faultbench-0.1.0/tests/test_integration_pydantic_ai.py +30 -0
  58. faultbench-0.1.0/tests/test_keyless.py +19 -0
  59. faultbench-0.1.0/tests/test_plugin.py +68 -0
  60. faultbench-0.1.0/tests/test_rules.py +130 -0
  61. faultbench-0.1.0/tests/test_runs.py +47 -0
  62. faultbench-0.1.0/tests/test_server.py +136 -0
  63. faultbench-0.1.0/tests/test_smoke.py +27 -0
  64. faultbench-0.1.0/tests/test_stripe_example.py +62 -0
  65. faultbench-0.1.0/tests/test_trace.py +80 -0
  66. faultbench-0.1.0/tests/test_world.py +289 -0
  67. faultbench-0.1.0/tests/worlds/mini.yaml +16 -0
  68. faultbench-0.1.0/uv.lock +2484 -0
@@ -0,0 +1,4 @@
1
+ # Copy to .env. Only the example agent needs a model key; faultbench itself makes no LLM calls.
2
+ # Pydantic AI reads the provider key for whichever model you name in examples/shop/agent.py.
3
+ OPENAI_API_KEY=
4
+ ANTHROPIC_API_KEY=
@@ -0,0 +1,27 @@
1
+ name: ci
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.11", "3.12"]
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - name: Install uv
17
+ uses: astral-sh/setup-uv@v5
18
+ with:
19
+ python-version: ${{ matrix.python-version }}
20
+ - name: Install
21
+ run: uv sync --all-extras
22
+ - name: Lint
23
+ run: uv run ruff check .
24
+ - name: Format check
25
+ run: uv run ruff format --check .
26
+ - name: Test (no API key needed)
27
+ run: uv run pytest -q
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ .traces/
10
+ .shop_state.json
11
+ .env
12
+ .DS_Store
@@ -0,0 +1 @@
1
+ 3.11
@@ -0,0 +1,4 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+ - Scaffold: layout, pyproject, CLAUDE.md, architecture/decisions/status docs, smoke tests.
@@ -0,0 +1,52 @@
1
+ # faultbench
2
+
3
+ Fake, stateful worlds with fault injection for testing tool-using AI agents.
4
+ A user declares services and records in `world.yaml`; faultbench serves them as MCP
5
+ tools, injects latency/errors/clock skew on demand, records every call, and lets
6
+ pytest assert on the world's end state.
7
+
8
+ **Read `docs/STATUS.md` before doing anything.** It says which milestone is active
9
+ and what the next session should do. Then read `docs/ARCHITECTURE.md` for the
10
+ component you are touching and `docs/DECISIONS.md` so you don't re-argue settled choices.
11
+
12
+ ## Non-goals (do not build these)
13
+ - No simulated user / persona engine. Users bring LangWatch Scenario, DeepEval or openevals.
14
+ - No LLM judge or transcript scoring. Assert on world state and the trace only.
15
+ - No dashboard or web UI. Stay inside pytest and the CLI.
16
+ - No clever YAML. Logic goes in Python handlers, not in templates or conditionals.
17
+ - No support for every framework at once. Pydantic AI first, one more via HTTP, then stop.
18
+
19
+ ## Stack
20
+ Python 3.11+, uv, pydantic v2, official `mcp` SDK, faker, pytest, ruff. No database.
21
+ Target: under 3,000 lines for v0.1.
22
+
23
+ ## Commands
24
+ uv sync --all-extras # install
25
+ uv run pytest # harness unit tests
26
+ uv run pytest examples/shop # example agent tests (needs a model API key)
27
+ uv run ruff check . && uv run ruff format .
28
+ uv run faultbench serve examples/shop/worlds/shop.yaml
29
+
30
+ ## Session protocol
31
+ 1. Read STATUS.md, ARCHITECTURE.md (relevant section), DECISIONS.md.
32
+ 2. Propose a short plan for this session's milestone; wait for approval.
33
+ 3. One component per session. Do not start the next milestone.
34
+ 4. Write tests alongside code in `tests/`.
35
+ 5. Before ending: run `uv run pytest` and `uv run ruff check .`, update STATUS.md
36
+ (state + "next session should"), append to DECISIONS.md if anything was decided,
37
+ and update docs/JOURNEY.md: flip the finished milestone to ✅ with a 2-line plain-language
38
+ "Built." note (what exists now, not how), move the "Now" marker to the next one. Commit.
39
+
40
+ ## Layout
41
+ src/faultbench/world/ schema.py engine.py seed.py # YAML -> stateful tables
42
+ src/faultbench/faults/ profile.py injector.py clock.py # latency, errors, fake time
43
+ src/faultbench/server/ mcp_server.py operations.py # world -> MCP tools
44
+ src/faultbench/trace/ recorder.py queries.py # JSONL trace of every call
45
+ src/faultbench/pytest_plugin.py # fixtures, markers, --runs
46
+ src/faultbench/cli.py # `faultbench serve`
47
+ examples/shop/ the refund agent demo (world, rules, agent, tests)
48
+ tests/ unit tests for faultbench itself
49
+ docs/ ARCHITECTURE, DECISIONS, STATUS, user docs
50
+
51
+ Background (why this exists, market landscape, 12-weekend plan):
52
+ https://claude.ai/code/artifact/058e73f3-39fa-4d36-8f96-f1b29efe8de2
@@ -0,0 +1,175 @@
1
+
2
+ Apache License
3
+ Version 2.0, January 2004
4
+ http://www.apache.org/licenses/
5
+
6
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7
+
8
+ 1. Definitions.
9
+
10
+ "License" shall mean the terms and conditions for use, reproduction,
11
+ and distribution as defined by Sections 1 through 9 of this document.
12
+
13
+ "Licensor" shall mean the copyright owner or entity authorized by
14
+ the copyright owner that is granting the License.
15
+
16
+ "Legal Entity" shall mean the union of the acting entity and all
17
+ other entities that control, are controlled by, or are under common
18
+ control with that entity. For the purposes of this definition,
19
+ "control" means (i) the power, direct or indirect, to cause the
20
+ direction or management of such entity, whether by contract or
21
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
22
+ outstanding shares, or (iii) beneficial ownership of such entity.
23
+
24
+ "You" (or "Your") shall mean an individual or Legal Entity
25
+ exercising permissions granted by this License.
26
+
27
+ "Source" form shall mean the preferred form for making modifications,
28
+ including but not limited to software source code, documentation
29
+ source, and configuration files.
30
+
31
+ "Object" form shall mean any form resulting from mechanical
32
+ transformation or translation of a Source form, including but
33
+ not limited to compiled object code, generated documentation,
34
+ and conversions to other media types.
35
+
36
+ "Work" shall mean the work of authorship, whether in Source or
37
+ Object form, made available under the License, as indicated by a
38
+ copyright notice that is included in or attached to the work
39
+ (an example is provided in the Appendix below).
40
+
41
+ "Derivative Works" shall mean any work, whether in Source or Object
42
+ form, that is based on (or derived from) the Work and for which the
43
+ editorial revisions, annotations, elaborations, or other modifications
44
+ represent, as a whole, an original work of authorship. For the purposes
45
+ of this License, Derivative Works shall not include works that remain
46
+ separable from, or merely link (or bind by name) to the interfaces of,
47
+ the Work and Derivative Works thereof.
48
+
49
+ "Contribution" shall mean any work of authorship, including
50
+ the original version of the Work and any modifications or additions
51
+ to that Work or Derivative Works thereof, that is intentionally
52
+ submitted to Licensor for inclusion in the Work by the copyright owner
53
+ or by an individual or Legal Entity authorized to submit on behalf of
54
+ the copyright owner. For the purposes of this definition, "submitted"
55
+ means any form of electronic, verbal, or written communication sent
56
+ to the Licensor or its representatives, including but not limited to
57
+ communication on electronic mailing lists, source code control systems,
58
+ and issue tracking systems that are managed by, or on behalf of, the
59
+ Licensor for the purpose of discussing and improving the Work, but
60
+ excluding communication that is conspicuously marked or otherwise
61
+ designated in writing by the copyright owner as "Not a Contribution."
62
+
63
+ "Contributor" shall mean Licensor and any individual or Legal Entity
64
+ on behalf of whom a Contribution has been received by Licensor and
65
+ subsequently incorporated within the Work.
66
+
67
+ 2. Grant of Copyright License. Subject to the terms and conditions of
68
+ this License, each Contributor hereby grants to You a perpetual,
69
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70
+ copyright license to reproduce, prepare Derivative Works of,
71
+ publicly display, publicly perform, sublicense, and distribute the
72
+ Work and such Derivative Works in Source or Object form.
73
+
74
+ 3. Grant of Patent License. Subject to the terms and conditions of
75
+ this License, each Contributor hereby grants to You a perpetual,
76
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77
+ (except as stated in this section) patent license to make, have made,
78
+ use, offer to sell, sell, import, and otherwise transfer the Work,
79
+ where such license applies only to those patent claims licensable
80
+ by such Contributor that are necessarily infringed by their
81
+ Contribution(s) alone or by combination of their Contribution(s)
82
+ with the Work to which such Contribution(s) was submitted. If You
83
+ institute patent litigation against any entity (including a
84
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
85
+ or a Contribution incorporated within the Work constitutes direct
86
+ or contributory patent infringement, then any patent licenses
87
+ granted to You under this License for that Work shall terminate
88
+ as of the date such litigation is filed.
89
+
90
+ 4. Redistribution. You may reproduce and distribute copies of the
91
+ Work or Derivative Works thereof in any medium, with or without
92
+ modifications, and in Source or Object form, provided that You
93
+ meet the following conditions:
94
+
95
+ (a) You must give any other recipients of the Work or
96
+ Derivative Works a copy of this License; and
97
+
98
+ (b) You must cause any modified files to carry prominent notices
99
+ stating that You changed the files; and
100
+
101
+ (c) You must retain, in the Source form of any Derivative Works
102
+ that You distribute, all copyright, patent, trademark, and
103
+ attribution notices from the Source form of the Work,
104
+ excluding those notices that do not pertain to any part of
105
+ the Derivative Works; and
106
+
107
+ (d) If the Work includes a "NOTICE" text file as part of its
108
+ distribution, then any Derivative Works that You distribute must
109
+ include a readable copy of the attribution notices contained
110
+ within such NOTICE file, excluding those notices that do not
111
+ pertain to any part of the Derivative Works, in at least one
112
+ of the following places: within a NOTICE text file distributed
113
+ as part of the Derivative Works; within the Source form or
114
+ documentation, if provided along with the Derivative Works; or,
115
+ within a display generated by the Derivative Works, if and
116
+ wherever such third-party notices normally appear. The contents
117
+ of the NOTICE file are for informational purposes only and
118
+ do not modify the License. You may add Your own attribution
119
+ notices within Derivative Works that You distribute, alongside
120
+ or as an addendum to the NOTICE text from the Work, provided
121
+ that such additional attribution notices cannot be construed
122
+ as modifying the License.
123
+
124
+ You may add Your own copyright statement to Your modifications and
125
+ may provide additional or different license terms and conditions
126
+ for use, reproduction, or distribution of Your modifications, or
127
+ for any such Derivative Works as a whole, provided Your use,
128
+ reproduction, and distribution of the Work otherwise complies with
129
+ the conditions stated in this License.
130
+
131
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
132
+ any Contribution intentionally submitted for inclusion in the Work
133
+ by You to the Licensor shall be under the terms and conditions of
134
+ this License, without any additional terms or conditions.
135
+ Notwithstanding the above, nothing herein shall supersede or modify
136
+ the terms of any separate license agreement you may have executed
137
+ with Licensor regarding such Contributions.
138
+
139
+ 6. Trademarks. This License does not grant permission to use the trade
140
+ names, trademarks, service marks, or product names of the Licensor,
141
+ except as required for reasonable and customary use in describing the
142
+ origin of the Work and reproducing the content of the NOTICE file.
143
+
144
+ 7. Disclaimer of Warranty. Unless required by applicable law or
145
+ agreed to in writing, Licensor provides the Work (and each
146
+ Contributor provides its Contributions) on an "AS IS" BASIS,
147
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148
+ implied, including, without limitation, any warranties or conditions
149
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150
+ PARTICULAR PURPOSE. You are solely responsible for determining the
151
+ appropriateness of using or redistributing the Work and assume any
152
+ risks associated with Your exercise of permissions under this License.
153
+
154
+ 8. Limitation of Liability. In no event and under no legal theory,
155
+ whether in tort (including negligence), contract, or otherwise,
156
+ unless required by applicable law (such as deliberate and grossly
157
+ negligent acts) or agreed to in writing, shall any Contributor be
158
+ liable to You for damages, including any direct, indirect, special,
159
+ incidental, or consequential damages of any character arising as a
160
+ result of this License or out of the use or inability to use the
161
+ Work (including but not limited to damages for loss of goodwill,
162
+ work stoppage, computer failure or malfunction, or any and all
163
+ other commercial damages or losses), even if such Contributor
164
+ has been advised of the possibility of such damages.
165
+
166
+ 9. Accepting Warranty or Additional Liability. While redistributing
167
+ the Work or Derivative Works thereof, You may choose to offer,
168
+ and charge a fee for, acceptance of support, warranty, indemnity,
169
+ or other liability obligations and/or rights consistent with this
170
+ License. However, in accepting such obligations, You may act only
171
+ on Your own behalf and on Your sole responsibility, not on behalf
172
+ of any other Contributor, and only if You agree to indemnify,
173
+ defend, and hold each Contributor harmless for any liability
174
+ incurred by, or claims asserted against, such Contributor by reason
175
+ of your accepting any such warranty or additional liability.
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.5
2
+ Name: faultbench
3
+ Version: 0.1.0
4
+ Summary: Fake, stateful worlds with fault injection for testing tool-using AI agents. Exposed over MCP, asserted from pytest.
5
+ Author: mithun
6
+ License-Expression: Apache-2.0
7
+ License-File: LICENSE
8
+ Keywords: ai-agents,fault-injection,mcp,pytest,simulation,testing
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Framework :: Pytest
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: Apache Software License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.11
18
+ Requires-Dist: faker>=25.0
19
+ Requires-Dist: mcp>=2.2
20
+ Requires-Dist: pydantic>=2.7
21
+ Requires-Dist: pyyaml>=6.0
22
+ Provides-Extra: examples
23
+ Requires-Dist: pydantic-ai>=2.0; extra == 'examples'
24
+ Provides-Extra: openai-agents
25
+ Requires-Dist: openai-agents>=0.2; extra == 'openai-agents'
26
+ Provides-Extra: pydantic-ai
27
+ Requires-Dist: pydantic-ai>=2.0; extra == 'pydantic-ai'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # faultbench
31
+
32
+ **Fake, stateful worlds with fault injection for testing tool-using AI agents.**
33
+
34
+ Declare your services in a YAML file. faultbench serves them as MCP tools your agent can call,
35
+ makes them slow or broken on purpose, records every call, and lets you assert on the world's
36
+ end state from pytest, twenty runs at a time.
37
+
38
+ > Status: pre-alpha, but it works end to end. Write a world, serve it over MCP (stdio or
39
+ > HTTP), inject faults, and assert on state from pytest across N seeded runs.
40
+ > **Guides:** [write a world](docs/WORLDS.md) · [test an agent](docs/TESTING.md) ·
41
+ > examples: [`examples/shop`](examples/shop) (refund agent), [`examples/bank`](examples/bank).
42
+ > Validated against **Pydantic AI** and the **OpenAI Agents SDK** (any MCP framework works).
43
+
44
+ ## The problem
45
+
46
+ Your support agent handles "return my order and refund me." It works when you test by hand.
47
+ In production the refund API times out once, the agent retries, and a customer is refunded twice.
48
+ You can't make the real payments API time out on command, so you never tested it.
49
+
50
+ ## What it looks like
51
+
52
+ ```yaml
53
+ # world.yaml
54
+ services:
55
+ orders:
56
+ records: { order: { id: str, status: enum[placed, delivered, returned], total: float } }
57
+ operations: { get_order: { kind: get, record: order } }
58
+ payments:
59
+ records: { refund: { id: str, order_id: str, amount: float } }
60
+ operations: { issue_refund: { kind: create, record: refund } }
61
+ faults:
62
+ payments.issue_refund: { errors: { timeout: 0.10 } }
63
+ ```
64
+
65
+ ```python
66
+ from faultbench.integrations.pydantic_ai import run_agent # or wire any MCP framework
67
+
68
+
69
+ @pytest.mark.world("world.yaml")
70
+ @pytest.mark.faults("world.yaml") # inject the faults: block above
71
+ @pytest.mark.runs(20)
72
+ @pytest.mark.min_pass_rate(0.95)
73
+ async def test_refund_issued_exactly_once(world, mcp_server, trace):
74
+ order = world.orders.pick(status="delivered")
75
+ await run_agent(
76
+ "openai:gpt-5-mini",
77
+ f"Return order {order.id} and refund me",
78
+ mcp=mcp_server,
79
+ system_prompt="You are a refund agent.",
80
+ )
81
+ assert len(world.refunds.where(order_id=order.id)) == 1 # a timeout+retry breaks this
82
+ ```
83
+
84
+ ```
85
+ faultbench: pass rate over runs
86
+ test_refund_issued_exactly_once: 17/20 passed (85%) min_pass_rate=95% -> FAIL
87
+ run4: get_order → create_return → issue_refund!timeout → issue_refund
88
+ run11: get_order → create_return → issue_refund!timeout → issue_refund
89
+ run18: get_order → create_return → issue_refund!timeout → issue_refund
90
+ ```
91
+
92
+ The timeout fired *after* the refund was written, the agent retried, and the customer was
93
+ refunded twice — the production bug you couldn't trigger on the real payments API, now a red
94
+ test with the trace that explains it.
95
+
96
+ ## What it can and can't model
97
+ faultbench models services as **flat records** (fields: `str/int/float/bool/datetime/enum/ref`)
98
+ with built-in CRUD plus **custom Python operations** for anything else.
99
+
100
+ - **Fits well:** entities with enums and `ref` relationships; CRUD and list-by-field; business
101
+ rules, state machines, and multi-record writes as custom handlers; array *inputs* via a custom
102
+ op that flattens into a related record type; money as integer minor-units. (See
103
+ [`examples/stripe`](examples/stripe) — a Stripe-style payments API with partial-refund rules.)
104
+ - **Caveat:** records are flat — there are **no nested objects or array fields**. Model a
105
+ one-to-many as a related record type + a `ref` (invoice ← line items); a GET returns the
106
+ parent without children inline, so if your agent's correctness depends on a nested *response*
107
+ shape, the fake's shape differs.
108
+ - **Not in 0.1:** pagination/cursors, non-equality filters, auth, webhooks, per-request
109
+ idempotency (that last is a bug faultbench helps you *catch*, not prevent). Generated seed
110
+ values are type-correct but not domain-aware — set realistic values in a handler or your test.
111
+
112
+ ## Security
113
+ A world file can name Python to import and run (custom handlers, `handler: module.func`), so
114
+ loading or serving one executes that code. Only use world files you trust, like any script.
115
+
116
+ ## Not in scope
117
+ Simulated users, LLM judges, dashboards. Use LangWatch Scenario / DeepEval for users and your own
118
+ judge for scoring; faultbench is the environment.
119
+
120
+ ## Quickstart
121
+
122
+ uv sync --all-extras
123
+ uv run pytest # the harness test suite (no API key)
124
+ uv run faultbench serve examples/shop/worlds/shop.yaml # serve a world over MCP (stdio)
125
+ uv run faultbench serve examples/bank/worlds/bank.yaml --http # ...or over HTTP
126
+
127
+ Then write your own: [docs/WORLDS.md](docs/WORLDS.md) and [docs/TESTING.md](docs/TESTING.md).
128
+
129
+ ## Development
130
+ See `CLAUDE.md` for the session protocol and `docs/` for architecture, decisions and status.
131
+
132
+ uv sync --all-extras
133
+ uv run pytest
134
+ uv run pytest examples/shop # the example agent test (needs a model API key)
135
+
136
+ ## License
137
+ Apache 2.0
@@ -0,0 +1,108 @@
1
+ # faultbench
2
+
3
+ **Fake, stateful worlds with fault injection for testing tool-using AI agents.**
4
+
5
+ Declare your services in a YAML file. faultbench serves them as MCP tools your agent can call,
6
+ makes them slow or broken on purpose, records every call, and lets you assert on the world's
7
+ end state from pytest, twenty runs at a time.
8
+
9
+ > Status: pre-alpha, but it works end to end. Write a world, serve it over MCP (stdio or
10
+ > HTTP), inject faults, and assert on state from pytest across N seeded runs.
11
+ > **Guides:** [write a world](docs/WORLDS.md) · [test an agent](docs/TESTING.md) ·
12
+ > examples: [`examples/shop`](examples/shop) (refund agent), [`examples/bank`](examples/bank).
13
+ > Validated against **Pydantic AI** and the **OpenAI Agents SDK** (any MCP framework works).
14
+
15
+ ## The problem
16
+
17
+ Your support agent handles "return my order and refund me." It works when you test by hand.
18
+ In production the refund API times out once, the agent retries, and a customer is refunded twice.
19
+ You can't make the real payments API time out on command, so you never tested it.
20
+
21
+ ## What it looks like
22
+
23
+ ```yaml
24
+ # world.yaml
25
+ services:
26
+ orders:
27
+ records: { order: { id: str, status: enum[placed, delivered, returned], total: float } }
28
+ operations: { get_order: { kind: get, record: order } }
29
+ payments:
30
+ records: { refund: { id: str, order_id: str, amount: float } }
31
+ operations: { issue_refund: { kind: create, record: refund } }
32
+ faults:
33
+ payments.issue_refund: { errors: { timeout: 0.10 } }
34
+ ```
35
+
36
+ ```python
37
+ from faultbench.integrations.pydantic_ai import run_agent # or wire any MCP framework
38
+
39
+
40
+ @pytest.mark.world("world.yaml")
41
+ @pytest.mark.faults("world.yaml") # inject the faults: block above
42
+ @pytest.mark.runs(20)
43
+ @pytest.mark.min_pass_rate(0.95)
44
+ async def test_refund_issued_exactly_once(world, mcp_server, trace):
45
+ order = world.orders.pick(status="delivered")
46
+ await run_agent(
47
+ "openai:gpt-5-mini",
48
+ f"Return order {order.id} and refund me",
49
+ mcp=mcp_server,
50
+ system_prompt="You are a refund agent.",
51
+ )
52
+ assert len(world.refunds.where(order_id=order.id)) == 1 # a timeout+retry breaks this
53
+ ```
54
+
55
+ ```
56
+ faultbench: pass rate over runs
57
+ test_refund_issued_exactly_once: 17/20 passed (85%) min_pass_rate=95% -> FAIL
58
+ run4: get_order → create_return → issue_refund!timeout → issue_refund
59
+ run11: get_order → create_return → issue_refund!timeout → issue_refund
60
+ run18: get_order → create_return → issue_refund!timeout → issue_refund
61
+ ```
62
+
63
+ The timeout fired *after* the refund was written, the agent retried, and the customer was
64
+ refunded twice — the production bug you couldn't trigger on the real payments API, now a red
65
+ test with the trace that explains it.
66
+
67
+ ## What it can and can't model
68
+ faultbench models services as **flat records** (fields: `str/int/float/bool/datetime/enum/ref`)
69
+ with built-in CRUD plus **custom Python operations** for anything else.
70
+
71
+ - **Fits well:** entities with enums and `ref` relationships; CRUD and list-by-field; business
72
+ rules, state machines, and multi-record writes as custom handlers; array *inputs* via a custom
73
+ op that flattens into a related record type; money as integer minor-units. (See
74
+ [`examples/stripe`](examples/stripe) — a Stripe-style payments API with partial-refund rules.)
75
+ - **Caveat:** records are flat — there are **no nested objects or array fields**. Model a
76
+ one-to-many as a related record type + a `ref` (invoice ← line items); a GET returns the
77
+ parent without children inline, so if your agent's correctness depends on a nested *response*
78
+ shape, the fake's shape differs.
79
+ - **Not in 0.1:** pagination/cursors, non-equality filters, auth, webhooks, per-request
80
+ idempotency (that last is a bug faultbench helps you *catch*, not prevent). Generated seed
81
+ values are type-correct but not domain-aware — set realistic values in a handler or your test.
82
+
83
+ ## Security
84
+ A world file can name Python to import and run (custom handlers, `handler: module.func`), so
85
+ loading or serving one executes that code. Only use world files you trust, like any script.
86
+
87
+ ## Not in scope
88
+ Simulated users, LLM judges, dashboards. Use LangWatch Scenario / DeepEval for users and your own
89
+ judge for scoring; faultbench is the environment.
90
+
91
+ ## Quickstart
92
+
93
+ uv sync --all-extras
94
+ uv run pytest # the harness test suite (no API key)
95
+ uv run faultbench serve examples/shop/worlds/shop.yaml # serve a world over MCP (stdio)
96
+ uv run faultbench serve examples/bank/worlds/bank.yaml --http # ...or over HTTP
97
+
98
+ Then write your own: [docs/WORLDS.md](docs/WORLDS.md) and [docs/TESTING.md](docs/TESTING.md).
99
+
100
+ ## Development
101
+ See `CLAUDE.md` for the session protocol and `docs/` for architecture, decisions and status.
102
+
103
+ uv sync --all-extras
104
+ uv run pytest
105
+ uv run pytest examples/shop # the example agent test (needs a model API key)
106
+
107
+ ## License
108
+ Apache 2.0
@@ -0,0 +1,4 @@
1
+ """Repo-root conftest: enable pytest's `pytester` fixture so tests/test_runs.py can run the
2
+ runs/pass-rate machinery in an inner pytest session."""
3
+
4
+ pytest_plugins = ["pytester"]
@@ -0,0 +1,50 @@
1
+ # Architecture
2
+
3
+ Five components. The agent under test never knows it is talking to a fake; it sees MCP tools.
4
+
5
+ Agent --MCP--> mcp_server --> faults.injector --> world.engine
6
+ |
7
+ +--> trace.recorder
8
+ pytest_plugin ----> engine (assert state), injector (configure), recorder (read trace)
9
+
10
+ ## 1. World engine (`world/`)
11
+ - `schema.py`: pydantic models for `world.yaml` (World, Service, RecordType, Field, Operation, SeedSpec).
12
+ Field types: `str`, `int`, `float`, `bool`, `datetime`, `enum[a, b, c]`, `ref[service.record]`.
13
+ - `engine.py`: `World.load(path) -> World`. Builds one in-memory `Table` per record type.
14
+ `Table` API: `get(id)`, `all()`, `where(**eq)`, `pick(**eq)` (deterministic first match),
15
+ `insert(record)`, `update(id, **fields)`, `delete(id)`.
16
+ `World` API: `snapshot() -> dict`, `diff(snapshot) -> Diff`, `revision: int` (bumps on every write).
17
+ - `seed.py`: faker-based generators per field type, seeded by `world.seed`. CSV/JSON loaders.
18
+ - Invariant: same YAML + same seed => identical initial state, byte for byte.
19
+
20
+ ## 2. Fault layer (`faults/`)
21
+ - `profile.py`: pydantic models for the `faults:` block. Keyed by `service.operation` or `default`.
22
+ Fields: `latency_ms: [lo, hi]`, `errors: {kind: probability}`, `rate_limit: {calls, per_seconds}`,
23
+ conditional errors (e.g. `not_found_if_newer_than: 2h`).
24
+ - `injector.py`: wraps an operation callable. Seeded RNG (`seed + run_index`). Error catalogue:
25
+ `timeout` (actually sleeps past the client deadline), `http_500`, `http_429` (with retry-after),
26
+ `malformed_json`, `empty_result`, `not_found`. Every decision is recorded to the trace.
27
+ - `clock.py`: `FakeClock` with `now()`, `advance(seconds)`. The engine and injector read time only from it.
28
+ - Invariant: same profile + same seed => identical fault sequence.
29
+
30
+ ## 3. MCP server (`server/`)
31
+ - `mcp_server.py`: builds a `mcp.server.Server` from a World + FaultProfile + Recorder.
32
+ One tool per operation, JSON schema derived from the record type. Transports: stdio, streamable HTTP.
33
+ - `operations.py`: built-in kinds `get`, `list`, `create`, `update`, `delete`; `custom` resolves
34
+ `handler: "module.func"` and calls `func(world, clock, **args)`.
35
+
36
+ ## 4. Trace (`trace/`)
37
+ - `recorder.py`: JSON Lines, one event per tool call:
38
+ `{run, seq, ts, tool, args, fault, latency_ms, ok, result|error, world_rev}`.
39
+ - `queries.py`: `Trace.count(tool)`, `calls_to(tool)`, `faults()`, `failures()`.
40
+
41
+ ## 5. pytest plugin (`pytest_plugin.py`)
42
+ - Markers: `@pytest.mark.world("path.yaml")`, `@pytest.mark.faults("path.yaml" | dict)`,
43
+ `@pytest.mark.runs(N)`, `@pytest.mark.min_pass_rate(0.9)`.
44
+ - Fixtures: `world`, `faults`, `clock`, `mcp_url` (server subprocess per test), `trace`.
45
+ - `--runs=N` overrides the marker; report shows `runs / passed / failed / pass rate` and a
46
+ per-failure trace summary. `min_pass_rate` turns the rate into pass/fail for CI.
47
+
48
+ ## Flow of one test
49
+ pytest builds World + FaultProfile -> starts MCP server subprocess -> yields `mcp_url`
50
+ -> test runs the agent -> test asserts on `world` and `trace` -> server stops, world discarded.
@@ -0,0 +1,51 @@
1
+ # Decisions
2
+
3
+ One line each. Append; do not rewrite history. Format: date, decision, reason.
4
+
5
+ - 2026-09-19 — Name: `faultbench`. Reason: describes the thing (fake world + benchmarking); free on PyPI/GitHub at time of check.
6
+ - 2026-09-19 — License: Apache 2.0. Reason: matches peers (Scenario, DeepEval, tau2-bench), patent grant, leaves room for a hosted product later.
7
+ - 2026-09-19 — Language: Python 3.11+, packaged with uv. Reason: largest agent ecosystem; pytest is the natural runner.
8
+ - 2026-09-19 — Scope: fake world + faults + trace + pytest. Explicitly NOT simulated users, judges, dashboards. Reason: those markets are full; the environment layer is empty (see market doc).
9
+ - 2026-09-19 — Fake services are hand-written from YAML, not LLM-emulated. Reason: determinism is the feature. `kind: emulated` may come later on request.
10
+ - 2026-09-19 — Logic lives in Python handlers (`kind: custom`), never in YAML templating. Reason: every YAML feature is something users must learn and something that breaks sharing.
11
+ - 2026-09-19 — Server runs as a subprocess per test, stdio by default, HTTP when the agent is remote. Reason: isolation over speed at this stage.
12
+ - 2026-09-19 — Tests report a pass rate over N seeded runs; `min_pass_rate` converts to pass/fail for CI. Reason: agents are probabilistic; a single pass/fail lies.
13
+ - 2026-09-19 — Toy agent uses Pydantic AI. Reason: small, typed, speaks MCP natively, model-agnostic.
14
+ - 2026-09-19 — Time is read only from `FakeClock`. Reason: "order is 2 hours old" must be testable without waiting.
15
+ - 2026-09-19 — Installed `mcp` is 2.x (2.2.0): `FastMCP` is now `mcp.server.mcpserver.MCPServer` (`@server.tool()`, `server.run("stdio"|"streamable-http")`). Weekend 3 targets this API; `pyproject` `mcp>=1.2` should be tightened to `>=2` before the server milestone.
16
+ - 2026-09-19 — Pydantic AI is 2.x (2.45): MCP client is `MCPToolset(FastMCPClient(StdioTransport(...)))`, not `MCPServerStdio`. Agent connects via `async with agent:`.
17
+ - 2026-09-19 — Example agent model is set via `FAULTBENCH_DEMO_MODEL` (default `openai:gpt-5-mini`; the key on hand this session was OpenAI). Provider prefix picks the required key. Reason: keep the example key-agnostic rather than pinning one provider.
18
+ - 2026-09-19 — Schema pydantic root is `WorldSpec` (with `ServiceSpec`/`RecordType`/`FieldType`/`OperationSpec`/`SeedSpec`), not `World`. Reason: avoid two classes named `World` — `engine.World` is the runtime, stateful world.
19
+ - 2026-09-19 — Tables are reached on the runtime `World` by record-type name (`world.orders`), so record names must be unique across the world (validated). `shop.yaml` records are named as collections (`orders`, `refunds`). Reason: matches the documented target test `world.orders.pick(...)` / `world.refunds.where(...)` with no pluralization magic ("no clever" rule).
20
+ - 2026-09-19 — Seeded `datetime` fields are drawn from a FIXED absolute window (2020–2025), never a now-relative one, and stored as ISO strings. Reason: the byte-identical invariant must hold on any day; verified identical across two separate processes.
21
+ - 2026-09-19 — Seeding does not bump `revision`; a freshly loaded world is at revision 0. Only insert/update/delete bump it. Reason: revision should count agent-caused writes, not initial state.
22
+ - 2026-09-19 — MCP tool schemas are derived by giving a generated wrapper function an explicit `__signature__` (mcp 2.x `func_metadata` reads `inspect.signature`), one tool per operation. Reason: operations are known only at runtime from YAML; this reuses the SDK's schema generation instead of hand-building JSON schema.
23
+ - 2026-09-19 — Tool params: get/delete → `id`; update → `id` + optional fields; create → record fields (optional, id auto-assigned); list → the operation's `filter` fields (optional); custom → the handler's params minus `world`/`clock`. Create/list fields are optional so the agent can call with only what it knows (e.g. `issue_refund(order_id, amount)`). Reason: keep the toy agent working unchanged.
24
+ - 2026-09-19 — Business-rule failures (ValueError/LookupError/KeyError from an op or handler) are converted to MCP `ToolError`; other exceptions propagate as crashes. Reason: the agent sees a clean, readable tool error for "not found"/"not delivered" without a server traceback, while real bugs stay loud.
25
+ - 2026-09-19 — Custom handler modules resolve by bare name; the server puts the world file's directory and its parent on `sys.path`. Reason: lets `shop.yaml` (in worlds/) reference `shop_rules` (in examples/shop/) without packaging.
26
+ - 2026-09-19 — `examples/shop/tools_dict.py` deleted; the agent now spawns `python -m faultbench.server <world.yaml>`. Reason: the generated server replaces the milestone-1 throwaway.
27
+ - 2026-09-19 — Fault timing models reality: `timeout` runs the op (side effect happens) THEN fails; `http_500`/`http_429`/`not_found` reject before running; `empty_result`/`malformed_json` corrupt the response without running. Reason: the double-refund bug requires the write to have happened before the client gives up and retries.
28
+ - 2026-09-19 — Injected timeouts/errors are surfaced to the client as MCP `ToolError`, not a real transport timeout. Reason: a retrying agent re-calls the op (reproducing the double write) without tests depending on real wall-clock deadlines; verified live (agent double-refunded order 2 at run_index 4).
29
+ - 2026-09-19 — Fault RNG is `Random(f"{seed}:{run_index}")`, one per server build; `seed` comes from the world. Reason: same profile + seed + run_index => identical fault sequence, and `run_index` walks distinct deterministic runs (for weekend-6 `--runs=N`).
30
+ - 2026-09-19 — Conditional faults (`not_found_if_newer_than`) and rate limits are parsed but NOT enforced in weekend 4; deferred to weekend 7 with the clock/seed-window eligibility work. Reason: keep milestone 4 to probabilistic faults + the demo; shop.yaml still loads.
31
+ - 2026-09-19 — pytest plugin fixture for the server is `mcp_server` (an in-process MCPServer sharing the `world` object), NOT `mcp_url`. Reason: our transport is stdio/in-memory, not HTTP; sharing the World object lets tests assert on state directly. `mcp_url`/HTTP is deferred to weekend 8.
32
+ - 2026-09-19 — The `mcp_server` fixture builds with `clock=None` (return-window check off), even though a `clock` fixture exists. Reason: threading the clock activates the 30-day window, but seed dates (2020–2025) are outside 30 days of the default clock (2025-06-01), so the refund test would fail; aligning them is weekend-7 eligibility work.
33
+ - 2026-09-19 — Example agent test (`examples/shop/test_refunds.py`) connects the agent to the fixture's in-process server via `run_agent(..., server=...)`, and lives outside `testpaths` so `uv run pytest` stays key-free; `uv run pytest examples/shop` runs it (skipped without a key, `.env` loaded by conftest). Reason: harness CI needs no API key; the real-agent test is opt-in.
34
+ - 2026-09-19 — Each of the N runs is a normal pytest item (`run_index` parametrised 0..N-1), not an internal loop. Reason: pytest-asyncio runs each item normally, so async tests work without fighting the pyfunc_call hook.
35
+ - 2026-09-19 — With `min_pass_rate(r)`, individual run failures are flipped to "passed" in `pytest_runtest_makereport` and the session fails (in `pytest_sessionfinish`) only if the aggregate rate < r. Without `min_pass_rate`, each run's pass/fail stands. Reason: DECISION that a single pass/fail lies — the aggregate is the CI verdict; but a strict test (no marker) should still go red on any failure.
36
+ - 2026-09-19 — Trace event per call carries the injector's decision via a `call(..., on_decision=)` hook; `build_server(..., recorder=)` owns writing the full event (adds ok/result-or-error/world_rev). Reason: one event per call assembled server-side, since fault/latency are known before the op and result/rev after.
37
+ - 2026-09-19 — Seed datetimes fall in the 20 days ending at `WORLD_EPOCH` (2025-06-01), and `faults.clock.DEFAULT_NOW == WORLD_EPOCH` (drift-guarded by a test, no cross-layer import). Reason: the deferred clock/seed alignment — so `create_return`'s 30-day window has eligible orders at the default clock. Threaded the clock into `mcp_server`/`serve_stdio`.
38
+ - 2026-09-19 — Injector check order per call: latency → conditional not-found → rate limit → probabilistic error → execute. Conditional (`not_found_if_newer_than`) and rate limits are deterministic (data/clock-driven, no RNG); the record's timestamp is supplied by the server (first datetime field of the target record, get/update/delete only). Reason: conditional/limit faults model real, non-random behaviour and must be reproducible from the clock.
39
+ - 2026-09-19 — HTTP transport is streamable-HTTP via `serve_http` / `faultbench serve --http`. The `mcp_url` pytest fixture serves the world in a subprocess and exposes end state through the world's state-file mirror (`.snapshot()`), since an out-of-process server can't share the `world` object. Reason: HTTP is for remote/other-framework agents; in-process `mcp_server` stays the fast path for state assertions.
40
+ - 2026-09-19 — Framework-neutrality is proven at the MCP protocol level: the reference `mcp.Client` (not Pydantic AI) drives the world over HTTP in CI, keyless. Reason: a real second agent framework needs another dependency + API key + flaky LLM calls; protocol-level proof is deterministic and per the "one more via HTTP, then stop" non-goal.
41
+ - 2026-09-19 — Second example world is `examples/bank/` (accounts + transfers, custom transfer rule). Reason: proves the world format generalises beyond the shop; validated keylessly in `tests/` so it can't rot.
42
+ - 2026-09-19 — User docs live in `docs/WORLDS.md` (authoring) and `docs/TESTING.md` (markers/fixtures/CLI); the README links them. Reason: separate the "how do I use it" guides from the internal ARCHITECTURE/DECISIONS/STATUS docs.
43
+ - 2026-09-19 — Framework adapters live in `faultbench.integrations.<framework>` as optional imports (each needs its own extra, e.g. `faultbench[pydantic-ai]`); the core stays framework-neutral. The Pydantic AI adapter's `run_agent(model, prompt, mcp=...)` accepts the in-process `mcp_server` or the `mcp_url` string. Reason: smooth the first-run experience without making pydantic-ai a core dependency.
44
+ - 2026-09-19 — Repo-local git identity is `mithun <mithunmsman@gmail.com>` (global untouched); the whole existing history was rewritten to this author before any push. Publishing (GitHub push, PyPI) is held for the `mit-hun-k` account token and explicit user confirmation — outward-facing/irreversible. Reason: correct attribution for the new owner; no accidental publish.
45
+ - 2026-09-19 — `faultbench.__version__` and `pyproject` version must match, guarded by `test_version_matches_pyproject`. Reason: they drifted (code 0.0.1 vs wheel 0.1.0) and shipped the wrong `--version`.
46
+ - 2026-09-19 — Pre-launch validation with a real second agent framework: the OpenAI Agents SDK drives the shop world over HTTP (`examples/shop/test_openai_agents.py`, opt-in; `openai-agents` extra). Fault-free it does a clean refund; under a forced timeout it retried and wrote 3 phantom refunds while reporting failure — faultbench caught them. Reason: substantiates "works with any framework" with a genuine rival agent, not just the reference client.
47
+ - 2026-09-19 — The server mirrors world state to the state file after EVERY attempt, including faulted ones (a timeout runs the op then fails). Found during the OpenAI Agents SDK validation: the success-only mirror under-reported write-then-timeout over HTTP — the exact bug class faultbench targets. Guarded by `test_faulted_write_is_mirrored_to_state_file`.
48
+ - 2026-09-19 — Records using `enum[a, b, c]` must be written in YAML block style; flow style (`{ status: enum[a, b, c] }`) is a YAML parse error (commas/brackets). Documented in WORLDS.md. Reason: real papercut hit while authoring a world.
49
+ - 2026-09-19 — Enum values are validated on writes: `create`/`update` reject a value outside the field's members (`run_builtin._check_enums`), and enum tool params advertise their members as a JSON-schema enum (Literal). Reason: real APIs reject bad enums; a docs-only tester found `status="banana"` was silently accepted.
50
+ - 2026-09-19 — Documented (README + WORLDS.md) that world files execute Python via custom handlers, so only trusted world files should be loaded/served. Reason: loading a world runs `handler: module.func` imports; users must know.
51
+ - 2026-09-19 — Documented the keyless testing path (`await mcp_server.call_tool(...)` + assert on `world`/`trace`), guarded by `tests/test_keyless.py`. Reason: docs-only tester couldn't test operations/faults without an LLM; the free deterministic loop was undocumented.