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.
- crashlab/__init__.py +144 -0
- crashlab/__main__.py +3 -0
- crashlab/_ids.py +51 -0
- crashlab/_version.py +1 -0
- crashlab/bench/__init__.py +18 -0
- crashlab/bench/catalog.py +117 -0
- crashlab/bench/harness.py +423 -0
- crashlab/capture/__init__.py +52 -0
- crashlab/capture/_chat.py +297 -0
- crashlab/capture/_proxy.py +64 -0
- crashlab/capture/_steps.py +176 -0
- crashlab/capture/_util.py +57 -0
- crashlab/capture/agent.py +347 -0
- crashlab/capture/anthropic.py +100 -0
- crashlab/capture/litellm.py +107 -0
- crashlab/capture/normalize.py +780 -0
- crashlab/capture/openai.py +120 -0
- crashlab/capture/streams.py +397 -0
- crashlab/capture/tools.py +179 -0
- crashlab/checks/__init__.py +40 -0
- crashlab/checks/config.py +231 -0
- crashlab/checks/detectors.py +619 -0
- crashlab/checks/engine.py +240 -0
- crashlab/checks/schema.py +115 -0
- crashlab/cli.py +1791 -0
- crashlab/compare.py +213 -0
- crashlab/context.py +332 -0
- crashlab/diagnose/__init__.py +42 -0
- crashlab/diagnose/compact.py +155 -0
- crashlab/diagnose/diagnoser.py +324 -0
- crashlab/diagnose/evidence.py +141 -0
- crashlab/diagnose/fields.py +123 -0
- crashlab/diagnose/fixes.py +399 -0
- crashlab/diagnose/heuristics.py +171 -0
- crashlab/diagnose/prompt.py +59 -0
- crashlab/diagnose/taxonomy.py +59 -0
- crashlab/export.py +215 -0
- crashlab/integrations/__init__.py +1 -0
- crashlab/integrations/litellm.py +5 -0
- crashlab/models.py +384 -0
- crashlab/patch.py +798 -0
- crashlab/pricing.py +330 -0
- crashlab/py.typed +0 -0
- crashlab/pytest_plugin.py +70 -0
- crashlab/replay/__init__.py +26 -0
- crashlab/replay/divergence.py +192 -0
- crashlab/replay/engine.py +687 -0
- crashlab/replay/rebuild.py +230 -0
- crashlab/replay/synth.py +267 -0
- crashlab/report_html.py +250 -0
- crashlab/store/__init__.py +185 -0
- crashlab/store/cassette.py +69 -0
- crashlab/store/redact.py +129 -0
- crashlab/store/sqlite.py +344 -0
- crashlab/verify/__init__.py +24 -0
- crashlab/verify/oracles.py +124 -0
- crashlab/verify/runner.py +295 -0
- crashlab/verify/stats.py +128 -0
- crashlab-0.0.1.dist-info/METADATA +447 -0
- crashlab-0.0.1.dist-info/RECORD +63 -0
- crashlab-0.0.1.dist-info/WHEEL +4 -0
- crashlab-0.0.1.dist-info/entry_points.txt +5 -0
- 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
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)
|