mindzeval 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.
mindzeval/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ """MindzEval: evaluation framework for SQL agents.
2
+
3
+ Grades the data an agent returns rather than the SQL it writes, validates the
4
+ reference answers before trusting them, and scores behaviour (answer / clarify /
5
+ decline) in both directions.
6
+ """
7
+
8
+ from mindzeval._version import __version__
9
+ from mindzeval.db import Database, QueryResult
10
+ from mindzeval.errors import (
11
+ ConfigError,
12
+ DatabaseError,
13
+ Issue,
14
+ MindzEvalError,
15
+ QueryTimeoutError,
16
+ SQLGuardError,
17
+ )
18
+ from mindzeval.loading import Project, find_config, load_cases, load_config, load_project
19
+ from mindzeval.results import TurnResult, Verdict
20
+ from mindzeval.runner import RunResult, run, run_project, select_cases
21
+ from mindzeval.schema import (
22
+ AgentResponse,
23
+ Behavior,
24
+ Case,
25
+ CompareMode,
26
+ Config,
27
+ Expectation,
28
+ Tolerance,
29
+ ToleranceOverride,
30
+ Turn,
31
+ )
32
+
33
+ __all__ = [
34
+ "AgentResponse",
35
+ "Behavior",
36
+ "Case",
37
+ "CompareMode",
38
+ "Config",
39
+ "ConfigError",
40
+ "Database",
41
+ "DatabaseError",
42
+ "Expectation",
43
+ "Issue",
44
+ "MindzEvalError",
45
+ "Project",
46
+ "QueryResult",
47
+ "QueryTimeoutError",
48
+ "RunResult",
49
+ "SQLGuardError",
50
+ "Tolerance",
51
+ "ToleranceOverride",
52
+ "Turn",
53
+ "TurnResult",
54
+ "Verdict",
55
+ "__version__",
56
+ "find_config",
57
+ "load_cases",
58
+ "load_config",
59
+ "load_project",
60
+ "run",
61
+ "run_project",
62
+ "select_cases",
63
+ ]
mindzeval/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,58 @@
1
+ """Agent adapters: Python function, HTTP endpoint, or pre-generated outputs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from mindzeval.adapters.base import (
6
+ Adapter,
7
+ AgentError,
8
+ AgentErrorKind,
9
+ AgentRequest,
10
+ Message,
11
+ coerce_response,
12
+ )
13
+ from mindzeval.adapters.http import HttpAdapter
14
+ from mindzeval.adapters.outputs import OutputsAdapter
15
+ from mindzeval.adapters.python import PythonAdapter, load_target
16
+ from mindzeval.adapters.resilient import ResilientAdapter
17
+ from mindzeval.schema.config import (
18
+ Config,
19
+ HttpAgentConfig,
20
+ OutputsAgentConfig,
21
+ PythonAgentConfig,
22
+ )
23
+
24
+
25
+ def build_adapter(config: Config) -> Adapter:
26
+ """Create the adapter described by ``config.agent``, with retries and rate limiting."""
27
+ agent = config.agent
28
+ if isinstance(agent, OutputsAgentConfig):
29
+ return OutputsAdapter(config.resolve_path(agent.path))
30
+ inner: Adapter
31
+ if isinstance(agent, PythonAgentConfig):
32
+ inner = PythonAdapter(load_target(agent.target, config.base_dir), name=agent.target)
33
+ elif isinstance(agent, HttpAgentConfig):
34
+ inner = HttpAdapter(agent)
35
+ else: # pragma: no cover - the union is exhaustive
36
+ raise TypeError(f"unsupported agent config {type(agent).__name__}")
37
+ return ResilientAdapter(
38
+ inner,
39
+ timeout_s=agent.timeout_s,
40
+ retries=agent.retries,
41
+ requests_per_minute=agent.requests_per_minute,
42
+ )
43
+
44
+
45
+ __all__ = [
46
+ "Adapter",
47
+ "AgentError",
48
+ "AgentErrorKind",
49
+ "AgentRequest",
50
+ "HttpAdapter",
51
+ "Message",
52
+ "OutputsAdapter",
53
+ "PythonAdapter",
54
+ "ResilientAdapter",
55
+ "build_adapter",
56
+ "coerce_response",
57
+ "load_target",
58
+ ]
@@ -0,0 +1,101 @@
1
+ """The adapter interface: how MindzEval calls an agent, whatever it is built with."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
7
+ from enum import StrEnum
8
+ from typing import Any
9
+
10
+ from pydantic import ValidationError
11
+
12
+ from mindzeval.schema.response import AgentResponse
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Message:
17
+ """One message of the conversation so far."""
18
+
19
+ role: str # "user" or "assistant"
20
+ content: str
21
+
22
+ def as_dict(self) -> dict[str, str]:
23
+ return {"role": self.role, "content": self.content}
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class AgentRequest:
28
+ """Everything an adapter passes to the agent for one turn."""
29
+
30
+ question: str
31
+ history: list[Message] = field(default_factory=list)
32
+ session_id: str = ""
33
+ """Stable across the turns of one case, unique per case and run."""
34
+ case_id: str = ""
35
+ turn: int = 0
36
+ params: dict[str, Any] = field(default_factory=dict)
37
+ """Config ``params`` merged with the current matrix combination."""
38
+
39
+
40
+ class AgentErrorKind(StrEnum):
41
+ TIMEOUT = "timeout"
42
+ RATE_LIMITED = "rate_limited"
43
+ CONNECTION = "connection"
44
+ CRASH = "crash"
45
+ """The agent raised an exception or returned a server error."""
46
+ INVALID_RESPONSE = "invalid_response"
47
+ """The agent replied, but not with a valid result record."""
48
+ MISSING_OUTPUT = "missing_output"
49
+ """Pre-generated outputs have no entry for this turn."""
50
+
51
+
52
+ RETRYABLE = frozenset(
53
+ {AgentErrorKind.TIMEOUT, AgentErrorKind.RATE_LIMITED, AgentErrorKind.CONNECTION}
54
+ )
55
+
56
+
57
+ class AgentError(Exception):
58
+ """The agent call failed. These are agent-side failures, never MindzEval bugs."""
59
+
60
+ def __init__(self, kind: AgentErrorKind, message: str) -> None:
61
+ super().__init__(message)
62
+ self.kind = kind
63
+ self.message = message
64
+
65
+ @property
66
+ def retryable(self) -> bool:
67
+ return self.kind in RETRYABLE
68
+
69
+
70
+ class Adapter(ABC):
71
+ """Calls one agent. Implementations must be safe to call concurrently."""
72
+
73
+ name: str = "agent"
74
+
75
+ @abstractmethod
76
+ async def call(self, request: AgentRequest) -> AgentResponse:
77
+ """Return the agent's result for one turn, or raise :class:`AgentError`."""
78
+
79
+ async def aclose(self) -> None: # noqa: B027 - optional hook
80
+ """Release resources (connections, clients)."""
81
+
82
+
83
+ def coerce_response(value: Any) -> AgentResponse:
84
+ """Turn whatever an agent returned into an :class:`AgentResponse`, or raise AgentError."""
85
+ if isinstance(value, AgentResponse):
86
+ return value
87
+ if value is None:
88
+ raise AgentError(AgentErrorKind.INVALID_RESPONSE, "the agent returned nothing (None)")
89
+ if hasattr(value, "model_dump") and not isinstance(value, dict):
90
+ value = value.model_dump()
91
+ try:
92
+ return AgentResponse.model_validate(value)
93
+ except ValidationError as exc:
94
+ problems = "; ".join(
95
+ f"{'.'.join(str(p) for p in e['loc']) or 'response'}: "
96
+ f"{str(e['msg']).removeprefix('Value error, ')}"
97
+ for e in exc.errors(include_url=False)
98
+ )
99
+ raise AgentError(
100
+ AgentErrorKind.INVALID_RESPONSE, f"invalid result record: {problems}"
101
+ ) from None
@@ -0,0 +1,190 @@
1
+ """Call an agent over HTTP. Works for agents written in any language.
2
+
3
+ Request body: by default ``{"question", "history", "session_id", "params"}`` as
4
+ JSON. A custom ``body`` template is filled per call: a string that is exactly a
5
+ placeholder (``"{history}"``) is replaced by the value itself, keeping its type;
6
+ placeholders inside longer strings are substituted as text.
7
+
8
+ Placeholders: ``{question}``, ``{history}``, ``{session_id}``, ``{case_id}``,
9
+ ``{turn}``, ``{params}`` and ``{params.<name>}``.
10
+
11
+ Response: each result field is read from the JSON response with a dotted path
12
+ (``data.rows``, ``choices.0.text``). Agent status values can be translated with
13
+ ``status_map``.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+ from mindzeval.adapters.base import (
25
+ Adapter,
26
+ AgentError,
27
+ AgentErrorKind,
28
+ AgentRequest,
29
+ coerce_response,
30
+ )
31
+ from mindzeval.schema.config import HttpAgentConfig, ResponseMapping
32
+ from mindzeval.schema.response import AgentResponse
33
+
34
+ _PLACEHOLDER = re.compile(r"\{(question|history|session_id|case_id|turn|params(?:\.[\w.]+)?)\}")
35
+ _MISSING = object()
36
+
37
+
38
+ class HttpAdapter(Adapter):
39
+ def __init__(self, config: HttpAgentConfig, *, client: httpx.AsyncClient | None = None) -> None:
40
+ self._config = config
41
+ self.name = config.url
42
+ self._client = client or httpx.AsyncClient(timeout=config.timeout_s, follow_redirects=False)
43
+
44
+ async def call(self, request: AgentRequest) -> AgentResponse:
45
+ config = self._config
46
+ values = _template_values(request)
47
+ body = (
48
+ render_template(config.body, values)
49
+ if config.body is not None
50
+ else {
51
+ "question": request.question,
52
+ "history": values["history"],
53
+ "session_id": request.session_id,
54
+ "params": request.params,
55
+ }
56
+ )
57
+ headers = {"Accept": "application/json", **config.headers}
58
+ try:
59
+ if config.method == "GET":
60
+ params = body if isinstance(body, dict) else None
61
+ response = await self._client.get(
62
+ config.url, params=_flat(params), headers=headers, timeout=config.timeout_s
63
+ )
64
+ else:
65
+ response = await self._client.request(
66
+ config.method, config.url, json=body, headers=headers, timeout=config.timeout_s
67
+ )
68
+ except httpx.TimeoutException as exc:
69
+ raise AgentError(
70
+ AgentErrorKind.TIMEOUT, f"no response within {config.timeout_s:g}s"
71
+ ) from exc
72
+ except httpx.HTTPError as exc:
73
+ raise AgentError(AgentErrorKind.CONNECTION, f"{type(exc).__name__}: {exc}") from exc
74
+
75
+ if response.status_code == 429:
76
+ raise AgentError(AgentErrorKind.RATE_LIMITED, "the agent returned HTTP 429")
77
+ if response.status_code >= 400:
78
+ kind = (
79
+ AgentErrorKind.CRASH
80
+ if response.status_code >= 500
81
+ else AgentErrorKind.INVALID_RESPONSE
82
+ )
83
+ raise AgentError(kind, f"HTTP {response.status_code}: {response.text[:300]}")
84
+ try:
85
+ payload = response.json()
86
+ except (json.JSONDecodeError, ValueError):
87
+ raise AgentError(
88
+ AgentErrorKind.INVALID_RESPONSE,
89
+ f"the response is not JSON: {response.text[:200]!r}",
90
+ ) from None
91
+ return coerce_response(map_response(payload, config.response))
92
+
93
+ async def aclose(self) -> None:
94
+ await self._client.aclose()
95
+
96
+
97
+ def map_response(payload: Any, mapping: ResponseMapping) -> dict[str, Any]:
98
+ """Pick each result field out of a JSON payload using the mapping's dotted paths."""
99
+ out: dict[str, Any] = {}
100
+ for field in (
101
+ "answer",
102
+ "sql",
103
+ "params",
104
+ "rows",
105
+ "columns",
106
+ "status",
107
+ "reason",
108
+ "tool_calls",
109
+ "trace",
110
+ "usage",
111
+ ):
112
+ path = getattr(mapping, field)
113
+ if not path:
114
+ continue
115
+ value = get_path(payload, path, _MISSING)
116
+ if value is _MISSING or value is None:
117
+ continue
118
+ out[field] = value
119
+
120
+ status = out.get("status")
121
+ if status is not None:
122
+ mapped = mapping.status_map.get(str(status))
123
+ if mapped is not None:
124
+ out["status"] = mapped.value
125
+ elif str(status).lower() in ("answer", "clarify", "decline"):
126
+ out["status"] = str(status).lower()
127
+ else:
128
+ raise AgentError(
129
+ AgentErrorKind.INVALID_RESPONSE,
130
+ f"unknown status {status!r}; map it with agent.response.status_map",
131
+ )
132
+ return out
133
+
134
+
135
+ def get_path(data: Any, path: str, default: Any = None) -> Any:
136
+ """Follow a dotted path through dicts and lists (``a.b.0.c``)."""
137
+ current = data
138
+ for part in path.split("."):
139
+ if isinstance(current, dict) and part in current:
140
+ current = current[part]
141
+ elif (
142
+ isinstance(current, list)
143
+ and part.lstrip("-").isdigit()
144
+ and -len(current) <= int(part) < len(current)
145
+ ):
146
+ current = current[int(part)]
147
+ else:
148
+ return default
149
+ return current
150
+
151
+
152
+ def render_template(template: Any, values: dict[str, Any]) -> Any:
153
+ if isinstance(template, dict):
154
+ return {k: render_template(v, values) for k, v in template.items()}
155
+ if isinstance(template, list):
156
+ return [render_template(v, values) for v in template]
157
+ if not isinstance(template, str):
158
+ return template
159
+ whole = _PLACEHOLDER.fullmatch(template)
160
+ if whole:
161
+ return _lookup(values, whole.group(1))
162
+
163
+ def text(match: re.Match[str]) -> str:
164
+ value = _lookup(values, match.group(1))
165
+ return value if isinstance(value, str) else json.dumps(value, default=str)
166
+
167
+ return _PLACEHOLDER.sub(text, template)
168
+
169
+
170
+ def _template_values(request: AgentRequest) -> dict[str, Any]:
171
+ return {
172
+ "question": request.question,
173
+ "history": [m.as_dict() for m in request.history],
174
+ "session_id": request.session_id,
175
+ "case_id": request.case_id,
176
+ "turn": request.turn,
177
+ "params": dict(request.params),
178
+ }
179
+
180
+
181
+ def _lookup(values: dict[str, Any], name: str) -> Any:
182
+ if name.startswith("params."):
183
+ return get_path(values["params"], name[len("params.") :])
184
+ return values[name]
185
+
186
+
187
+ def _flat(params: dict[str, Any] | None) -> dict[str, str] | None:
188
+ if params is None:
189
+ return None
190
+ return {k: v if isinstance(v, str) else json.dumps(v, default=str) for k, v in params.items()}
@@ -0,0 +1,81 @@
1
+ """Score answers an agent produced earlier, without calling it.
2
+
3
+ The file is JSONL (one record per line) or a JSON list. Each record names the
4
+ case and turn, and holds the result record either under ``response`` or at the
5
+ top level::
6
+
7
+ {"case_id": "customers_de", "turn": 0, "response": {"answer": "...", "sql": "..."}}
8
+ {"case_id": "order_count", "answer": "4", "sql": "SELECT count(*) FROM orders"}
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from mindzeval.adapters.base import (
18
+ Adapter,
19
+ AgentError,
20
+ AgentErrorKind,
21
+ AgentRequest,
22
+ coerce_response,
23
+ )
24
+ from mindzeval.errors import ConfigError, Issue
25
+ from mindzeval.schema.response import AgentResponse
26
+
27
+
28
+ class OutputsAdapter(Adapter):
29
+ def __init__(self, path: Path) -> None:
30
+ self.name = f"outputs:{path.name}"
31
+ self._records = _load(path)
32
+
33
+ async def call(self, request: AgentRequest) -> AgentResponse:
34
+ record = self._records.get((request.case_id, request.turn))
35
+ if record is None:
36
+ raise AgentError(
37
+ AgentErrorKind.MISSING_OUTPUT,
38
+ f"no saved output for case '{request.case_id}' turn {request.turn}",
39
+ )
40
+ return coerce_response(record)
41
+
42
+
43
+ def _load(path: Path) -> dict[tuple[str, int], Any]:
44
+ source = str(path)
45
+ try:
46
+ text = path.read_text(encoding="utf-8")
47
+ except OSError as exc:
48
+ raise ConfigError([Issue(source, f"cannot read outputs file: {exc}")]) from None
49
+
50
+ items: list[tuple[int | None, Any]] = []
51
+ try:
52
+ if path.suffix.lower() == ".json":
53
+ loaded = json.loads(text)
54
+ if not isinstance(loaded, list):
55
+ raise ConfigError([Issue(source, "an outputs JSON file must contain a list")])
56
+ items = [(None, item) for item in loaded]
57
+ else:
58
+ items = [
59
+ (n, json.loads(line)) for n, line in enumerate(text.splitlines(), 1) if line.strip()
60
+ ]
61
+ except json.JSONDecodeError as exc:
62
+ raise ConfigError([Issue(source, f"invalid JSON: {exc.msg}", line=exc.lineno)]) from None
63
+
64
+ records: dict[tuple[str, int], Any] = {}
65
+ issues: list[Issue] = []
66
+ for line, item in items:
67
+ if not isinstance(item, dict) or "case_id" not in item:
68
+ issues.append(Issue(source, "each output needs a 'case_id'", line=line))
69
+ continue
70
+ key = (str(item["case_id"]), int(item.get("turn", 0)))
71
+ if key in records:
72
+ issues.append(
73
+ Issue(source, f"duplicate output for case '{key[0]}' turn {key[1]}", line=line)
74
+ )
75
+ continue
76
+ records[key] = item.get(
77
+ "response", {k: v for k, v in item.items() if k not in ("case_id", "turn")}
78
+ )
79
+ if issues:
80
+ raise ConfigError(issues)
81
+ return records
@@ -0,0 +1,140 @@
1
+ """Call an agent that is a Python function (LangChain, LangGraph, CrewAI, plain code...).
2
+
3
+ The function can take any subset of these keyword arguments, and MindzEval
4
+ passes only the ones it declares:
5
+
6
+ question: str the question for this turn
7
+ history: list[dict] earlier turns as {"role": "user"|"assistant", "content": str}
8
+ session_id: str stable across the turns of one case
9
+ case_id: str, turn: int identity of the turn being evaluated
10
+ params: dict config params plus the current matrix combination
11
+
12
+ Alternatively it can take a single ``request`` argument (an
13
+ :class:`~mindzeval.adapters.AgentRequest`). It may be sync or async, and may
14
+ return an :class:`~mindzeval.AgentResponse`, a dict, a pydantic model or a string.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import asyncio
20
+ import importlib
21
+ import inspect
22
+ import sys
23
+ from collections.abc import Callable
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from mindzeval.adapters.base import (
28
+ Adapter,
29
+ AgentError,
30
+ AgentErrorKind,
31
+ AgentRequest,
32
+ coerce_response,
33
+ )
34
+ from mindzeval.errors import ConfigError, Issue
35
+ from mindzeval.schema.response import AgentResponse
36
+
37
+ _ARGUMENTS = ("question", "history", "session_id", "case_id", "turn", "params")
38
+
39
+
40
+ def load_target(target: str, base_dir: Path | None = None) -> Callable[..., Any]:
41
+ """Import ``module.path:attribute``. ``base_dir`` is put on ``sys.path`` first."""
42
+ module_name, _, attribute = target.partition(":")
43
+ if base_dir is not None and str(base_dir) not in sys.path:
44
+ sys.path.insert(0, str(base_dir))
45
+ try:
46
+ obj: Any = importlib.import_module(module_name)
47
+ except ImportError as exc:
48
+ raise ConfigError([Issue("agent.target", f"cannot import '{module_name}': {exc}")]) from exc
49
+ for part in attribute.split("."):
50
+ if not hasattr(obj, part):
51
+ raise ConfigError([Issue("agent.target", f"'{module_name}' has no '{attribute}'")])
52
+ obj = getattr(obj, part)
53
+ if not callable(obj):
54
+ raise ConfigError([Issue("agent.target", f"'{target}' is not callable")])
55
+ return obj # type: ignore[no-any-return]
56
+
57
+
58
+ class PythonAdapter(Adapter):
59
+ def __init__(self, func: Callable[..., Any], *, name: str | None = None) -> None:
60
+ self._func = func
61
+ self.name = name or str(getattr(func, "__qualname__", "python-agent"))
62
+ self._is_async = inspect.iscoroutinefunction(func) or inspect.iscoroutinefunction(
63
+ getattr(func, "__call__", None) # noqa: B004 - callable objects
64
+ )
65
+ self._mode, self._accepted = _inspect_signature(func)
66
+
67
+ def _arguments(self, request: AgentRequest) -> dict[str, Any]:
68
+ if self._mode == "request":
69
+ return {"request": request}
70
+ values: dict[str, Any] = {
71
+ "question": request.question,
72
+ "history": [m.as_dict() for m in request.history],
73
+ "session_id": request.session_id,
74
+ "case_id": request.case_id,
75
+ "turn": request.turn,
76
+ "params": dict(request.params),
77
+ }
78
+ if self._mode == "kwargs":
79
+ return values
80
+ return {k: v for k, v in values.items() if k in self._accepted}
81
+
82
+ async def call(self, request: AgentRequest) -> AgentResponse:
83
+ kwargs = self._arguments(request)
84
+ try:
85
+ if self._is_async:
86
+ value = await self._func(**kwargs)
87
+ else:
88
+ value = await asyncio.to_thread(self._func, **kwargs)
89
+ if inspect.isawaitable(value):
90
+ value = await value
91
+ except AgentError:
92
+ raise
93
+ except Exception as exc: # the agent's own failure
94
+ raise AgentError(_classify(exc), f"{type(exc).__name__}: {exc}") from exc
95
+ return coerce_response(value)
96
+
97
+
98
+ def _inspect_signature(func: Callable[..., Any]) -> tuple[str, frozenset[str]]:
99
+ try:
100
+ signature = inspect.signature(func)
101
+ except (TypeError, ValueError):
102
+ return "kwargs", frozenset()
103
+ params = signature.parameters
104
+ if any(p.kind is inspect.Parameter.VAR_KEYWORD for p in params.values()):
105
+ return "kwargs", frozenset()
106
+ names = [n for n, p in params.items() if p.kind is not inspect.Parameter.VAR_POSITIONAL]
107
+ if names == ["request"]:
108
+ return "request", frozenset()
109
+ accepted = frozenset(n for n in names if n in _ARGUMENTS)
110
+ required = [
111
+ n
112
+ for n, p in params.items()
113
+ if p.default is inspect.Parameter.empty
114
+ and p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
115
+ ]
116
+ unknown = [n for n in required if n not in _ARGUMENTS]
117
+ if unknown or "question" not in accepted:
118
+ raise ConfigError(
119
+ [
120
+ Issue(
121
+ "agent.target",
122
+ f"the agent function must accept 'question' (and optionally "
123
+ f"{', '.join(_ARGUMENTS[1:])}); it has required parameters {required}",
124
+ )
125
+ ]
126
+ )
127
+ return "named", accepted
128
+
129
+
130
+ def _classify(exc: BaseException) -> AgentErrorKind:
131
+ if isinstance(exc, TimeoutError):
132
+ return AgentErrorKind.TIMEOUT
133
+ status = getattr(exc, "status_code", None) or getattr(
134
+ getattr(exc, "response", None), "status_code", None
135
+ )
136
+ if status == 429 or "ratelimit" in type(exc).__name__.lower():
137
+ return AgentErrorKind.RATE_LIMITED
138
+ if isinstance(exc, ConnectionError):
139
+ return AgentErrorKind.CONNECTION
140
+ return AgentErrorKind.CRASH