crashlab 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. crashlab/__init__.py +144 -0
  2. crashlab/__main__.py +3 -0
  3. crashlab/_ids.py +51 -0
  4. crashlab/_version.py +1 -0
  5. crashlab/bench/__init__.py +18 -0
  6. crashlab/bench/catalog.py +117 -0
  7. crashlab/bench/harness.py +423 -0
  8. crashlab/capture/__init__.py +52 -0
  9. crashlab/capture/_chat.py +297 -0
  10. crashlab/capture/_proxy.py +64 -0
  11. crashlab/capture/_steps.py +176 -0
  12. crashlab/capture/_util.py +57 -0
  13. crashlab/capture/agent.py +347 -0
  14. crashlab/capture/anthropic.py +100 -0
  15. crashlab/capture/litellm.py +107 -0
  16. crashlab/capture/normalize.py +780 -0
  17. crashlab/capture/openai.py +120 -0
  18. crashlab/capture/streams.py +397 -0
  19. crashlab/capture/tools.py +179 -0
  20. crashlab/checks/__init__.py +40 -0
  21. crashlab/checks/config.py +231 -0
  22. crashlab/checks/detectors.py +619 -0
  23. crashlab/checks/engine.py +240 -0
  24. crashlab/checks/schema.py +115 -0
  25. crashlab/cli.py +1791 -0
  26. crashlab/compare.py +213 -0
  27. crashlab/context.py +332 -0
  28. crashlab/diagnose/__init__.py +42 -0
  29. crashlab/diagnose/compact.py +155 -0
  30. crashlab/diagnose/diagnoser.py +324 -0
  31. crashlab/diagnose/evidence.py +141 -0
  32. crashlab/diagnose/fields.py +123 -0
  33. crashlab/diagnose/fixes.py +399 -0
  34. crashlab/diagnose/heuristics.py +171 -0
  35. crashlab/diagnose/prompt.py +59 -0
  36. crashlab/diagnose/taxonomy.py +59 -0
  37. crashlab/export.py +215 -0
  38. crashlab/integrations/__init__.py +1 -0
  39. crashlab/integrations/litellm.py +5 -0
  40. crashlab/models.py +384 -0
  41. crashlab/patch.py +798 -0
  42. crashlab/pricing.py +330 -0
  43. crashlab/py.typed +0 -0
  44. crashlab/pytest_plugin.py +70 -0
  45. crashlab/replay/__init__.py +26 -0
  46. crashlab/replay/divergence.py +192 -0
  47. crashlab/replay/engine.py +687 -0
  48. crashlab/replay/rebuild.py +230 -0
  49. crashlab/replay/synth.py +267 -0
  50. crashlab/report_html.py +250 -0
  51. crashlab/store/__init__.py +185 -0
  52. crashlab/store/cassette.py +69 -0
  53. crashlab/store/redact.py +129 -0
  54. crashlab/store/sqlite.py +344 -0
  55. crashlab/verify/__init__.py +24 -0
  56. crashlab/verify/oracles.py +124 -0
  57. crashlab/verify/runner.py +295 -0
  58. crashlab/verify/stats.py +128 -0
  59. crashlab-0.0.1.dist-info/METADATA +447 -0
  60. crashlab-0.0.1.dist-info/RECORD +63 -0
  61. crashlab-0.0.1.dist-info/WHEEL +4 -0
  62. crashlab-0.0.1.dist-info/entry_points.txt +5 -0
  63. crashlab-0.0.1.dist-info/licenses/LICENSE +201 -0
crashlab/__init__.py ADDED
@@ -0,0 +1,144 @@
1
+ """crashlab: crash-test your AI agents.
2
+
3
+ Record every LLM and tool call from your existing agent loop into a local store
4
+ (``.crashlab/``), inspect runs with ``crashlab ls`` / ``crashlab show``, replay them
5
+ offline with ``crashlab.replay(run)``, judge them with ``@crashlab.check`` oracles, and
6
+ ask "what if" with ``crashlab.fork(run, at_step=N, patch=...)``. Built-in runtime checks
7
+ (loops, tool error streaks, bad tool arguments, suspicious tool results, budgets, length
8
+ cut-offs) run while recording and store :class:`Finding` s on the run.
9
+
10
+ import crashlab
11
+ from openai import OpenAI
12
+
13
+ client = crashlab.wrap(OpenAI()) # or anthropic.Anthropic()
14
+
15
+ @crashlab.tool()
16
+ def get_order(order_id: str) -> dict: ...
17
+
18
+ @crashlab.agent(name="refund-bot")
19
+ def run_agent(user_msg: str) -> str: ...
20
+
21
+ @crashlab.check("refund-bot")
22
+ def refund_not_above_total(run): ...
23
+
24
+ run = crashlab.load("last")
25
+ crashlab.replay(run) # offline; DivergenceError if it differs
26
+ branch = crashlab.fork(run, at_step=0,
27
+ patch=crashlab.patch.SystemPrompt(op="append", text="..."))
28
+ crashlab.diff(run, branch).summary()
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from . import checks, patch, pricing
34
+ from ._version import __version__
35
+ from .capture import agent, record, tool, wrap
36
+ from .checks import ChecksConfig, CircuitBreakerError
37
+ from .compare import RunDiff, diff
38
+ from .context import (
39
+ ContextThreadPoolExecutor,
40
+ bind_context,
41
+ copy_context_to_thread,
42
+ is_replaying,
43
+ run_in_thread,
44
+ )
45
+ from .diagnose import DiagnoseError, diagnose, propose_fixes, try_fix
46
+ from .models import (
47
+ CheckResult,
48
+ Diagnosis,
49
+ ErrorInfo,
50
+ Evidence,
51
+ Finding,
52
+ Fix,
53
+ Flag,
54
+ Run,
55
+ Step,
56
+ TaxonomyLabel,
57
+ Usage,
58
+ Verification,
59
+ )
60
+ from .patch import (
61
+ InjectMessagePatch,
62
+ MessagePatch,
63
+ ModelParamPatch,
64
+ PatchError,
65
+ PatchSet,
66
+ SystemPromptPatch,
67
+ ToolDescriptionPatch,
68
+ ToolResultPatch,
69
+ ToolSchemaPatch,
70
+ )
71
+ from .replay import (
72
+ DivergenceError,
73
+ ForkError,
74
+ PatchedToolError,
75
+ ReplayError,
76
+ ReplayNetworkError,
77
+ SideEffectBlockedError,
78
+ fork,
79
+ replay,
80
+ )
81
+ from .store import RunNotFoundError, Store, load
82
+ from .store import redact as redaction
83
+ from .verify import VerifyError, check, run_checks, verify
84
+
85
+ __all__ = [
86
+ "DiagnoseError",
87
+ "diagnose",
88
+ "propose_fixes",
89
+ "try_fix",
90
+ "verify",
91
+ "VerifyError",
92
+ "CheckResult",
93
+ "ChecksConfig",
94
+ "CircuitBreakerError",
95
+ "ContextThreadPoolExecutor",
96
+ "Diagnosis",
97
+ "DivergenceError",
98
+ "ErrorInfo",
99
+ "Evidence",
100
+ "Finding",
101
+ "Fix",
102
+ "Flag",
103
+ "ForkError",
104
+ "InjectMessagePatch",
105
+ "MessagePatch",
106
+ "ModelParamPatch",
107
+ "PatchError",
108
+ "PatchSet",
109
+ "PatchedToolError",
110
+ "ReplayError",
111
+ "ReplayNetworkError",
112
+ "Run",
113
+ "RunDiff",
114
+ "RunNotFoundError",
115
+ "SideEffectBlockedError",
116
+ "Step",
117
+ "Store",
118
+ "SystemPromptPatch",
119
+ "TaxonomyLabel",
120
+ "ToolDescriptionPatch",
121
+ "ToolResultPatch",
122
+ "ToolSchemaPatch",
123
+ "Usage",
124
+ "Verification",
125
+ "__version__",
126
+ "agent",
127
+ "bind_context",
128
+ "check",
129
+ "checks",
130
+ "copy_context_to_thread",
131
+ "diff",
132
+ "fork",
133
+ "is_replaying",
134
+ "load",
135
+ "patch",
136
+ "pricing",
137
+ "record",
138
+ "redaction",
139
+ "replay",
140
+ "run_checks",
141
+ "run_in_thread",
142
+ "tool",
143
+ "wrap",
144
+ ]
crashlab/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import app
2
+
3
+ app()
crashlab/_ids.py ADDED
@@ -0,0 +1,51 @@
1
+ """Small id helpers: ULID-like sortable run ids and OTel-style span ids (no extra deps)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import threading
7
+ import time
8
+
9
+ _CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
10
+ _lock = threading.Lock()
11
+ _last_ms = -1
12
+ _last_rand = 0
13
+
14
+
15
+ def _encode(value: int, length: int) -> str:
16
+ out = []
17
+ for _ in range(length):
18
+ value, rem = divmod(value, 32)
19
+ out.append(_CROCKFORD[rem])
20
+ return "".join(reversed(out))
21
+
22
+
23
+ def new_ulid() -> str:
24
+ """Return a 26-char Crockford base32 id: 48-bit ms timestamp + 80 random bits.
25
+
26
+ Ids are lexicographically sortable by creation time. Within the same millisecond
27
+ the random part is incremented, so ids stay strictly increasing in one process.
28
+ """
29
+ global _last_ms, _last_rand
30
+ with _lock:
31
+ ms = time.time_ns() // 1_000_000
32
+ if ms <= _last_ms:
33
+ ms = _last_ms
34
+ rand = (_last_rand + 1) & ((1 << 80) - 1)
35
+ else:
36
+ rand = int.from_bytes(os.urandom(10), "big")
37
+ _last_ms, _last_rand = ms, rand
38
+ return _encode(ms, 10) + _encode(rand, 16)
39
+
40
+
41
+ def ulid_timestamp_ms(ulid: str) -> int:
42
+ """Decode the millisecond timestamp from a ULID-like id."""
43
+ value = 0
44
+ for ch in ulid[:10].upper():
45
+ value = value * 32 + _CROCKFORD.index(ch)
46
+ return value
47
+
48
+
49
+ def new_span_id() -> str:
50
+ """Return a 16-hex-char span id (same size as an OpenTelemetry span id)."""
51
+ return os.urandom(8).hex()
crashlab/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
@@ -0,0 +1,18 @@
1
+ """The seed fault-injection benchmark (``crashlab bench``)."""
2
+
3
+ from .catalog import CONDITIONS, FAULTS, TASKS, Fault, Task
4
+ from .harness import load_runs_dir, render_md, run_live, score_run, score_runs, write_results
5
+
6
+ __all__ = [
7
+ "CONDITIONS",
8
+ "FAULTS",
9
+ "TASKS",
10
+ "Fault",
11
+ "Task",
12
+ "load_runs_dir",
13
+ "render_md",
14
+ "run_live",
15
+ "score_run",
16
+ "score_runs",
17
+ "write_results",
18
+ ]
@@ -0,0 +1,117 @@
1
+ """The seed benchmark's fault catalog and tasks.
2
+
3
+ Labels and culprit-step rules were fixed *before* the first benchmark run (pre-registered
4
+ in bench/README.md). ``split``: refund-bot is the **dev** agent (the heuristics and the
5
+ diagnosis prompt were developed on its runs); sql-analyst is the held-out **test** agent
6
+ (written for Day 10, never used for tuning).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib
12
+ import os
13
+ import sys
14
+ from dataclasses import dataclass, field
15
+ from typing import Any
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class Fault:
20
+ name: str
21
+ description: str
22
+ labels: frozenset[str] # accepted top-1 labels
23
+ reference_fix: str
24
+
25
+
26
+ FAULTS: dict[str, Fault] = {
27
+ f.name: f
28
+ for f in (
29
+ Fault(
30
+ "spec_drop",
31
+ "a rule is removed from the system prompt (refund window / revenue definition)",
32
+ frozenset({"TASK_SPEC_VIOLATION"}),
33
+ "restore or strengthen the instruction (system_prompt patch)",
34
+ ),
35
+ Fault(
36
+ "tool_5xx",
37
+ "a tool returns an HTML 502 page instead of data, every call",
38
+ frozenset({"EXTERNAL_FAILURE", "TOOL_ERROR_MISHANDLED", "STEP_REPETITION"}),
39
+ "tell the agent to stop and escalate on non-JSON / 5xx results",
40
+ ),
41
+ Fault(
42
+ "schema_drift",
43
+ "a field/column is renamed upstream (total -> amount_total)",
44
+ frozenset({"TOOL_ERROR_MISHANDLED", "BAD_TOOL_ARGUMENTS"}),
45
+ "update the tool description / prompt to the new field name",
46
+ ),
47
+ Fault(
48
+ "misleading_desc",
49
+ "a tool description is wrong (restocking fee / amounts in cents)",
50
+ frozenset({"BAD_TOOL_ARGUMENTS", "TASK_SPEC_VIOLATION"}),
51
+ "correct the tool description (tool_description patch)",
52
+ ),
53
+ )
54
+ }
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Task:
59
+ name: str # the @crashlab.agent name
60
+ module: str
61
+ split: str # "dev" or "test"
62
+ prompts: dict[str, list[str]] = field(default_factory=dict) # condition -> prompts
63
+ default_prompts: tuple[str, ...] = ()
64
+
65
+ def load(self) -> Any:
66
+ """The agent module; the example agents live in the repo checkout, so the
67
+ current directory is tried too (the ``crashlab`` script does not put it on
68
+ sys.path)."""
69
+ try:
70
+ return importlib.import_module(self.module)
71
+ except ModuleNotFoundError:
72
+ if os.getcwd() in sys.path:
73
+ raise
74
+ sys.path.insert(0, os.getcwd())
75
+ return importlib.import_module(self.module)
76
+
77
+ def prompts_for(self, condition: str) -> list[str]:
78
+ return list(self.prompts.get(condition) or self.default_prompts)
79
+
80
+ def agent_faults(self, condition: str) -> list[str]:
81
+ if condition == "clean":
82
+ return []
83
+ mapping = getattr(self.load(), "BENCH_FAULTS", None) or {condition: condition}
84
+ return [mapping[condition]]
85
+
86
+ def run(self, prompt: str, model: str, condition: str) -> Any:
87
+ mod = self.load()
88
+ faults = self.agent_faults(condition)
89
+ if self.name == "refund-bot":
90
+ return mod.run_agent(prompt, model=model, provider="openai", faults=faults)
91
+ return mod.run_agent(prompt, model=model, faults=faults)
92
+
93
+ def ground_truth(self, run: Any, condition: str) -> tuple[Any, Any]:
94
+ return self.load().ground_truth(run, condition)
95
+
96
+
97
+ A17 = "Hi, I'd like a refund for order A17 please, it arrived broken."
98
+ C03 = "Please refund order C03, the shoes fell apart."
99
+ SQL_QS = (
100
+ "What was ShopCo's revenue in March 2026?",
101
+ "How much revenue came from customers in Germany in 2026?",
102
+ "Which country generated the most revenue in Q1 2026 (January to March)?",
103
+ )
104
+
105
+ TASKS: dict[str, Task] = {
106
+ "refund-bot": Task(
107
+ "refund-bot",
108
+ "examples.refund_bot.agent",
109
+ "dev",
110
+ prompts={"spec_drop": [C03], "clean": [A17, C03]},
111
+ default_prompts=(A17,),
112
+ ),
113
+ "sql-analyst": Task(
114
+ "sql-analyst", "examples.sql_analyst.agent", "test", default_prompts=SQL_QS
115
+ ),
116
+ }
117
+ CONDITIONS = ("clean", *FAULTS)