agentshim 0.10.0__tar.gz → 0.11.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.
- {agentshim-0.10.0 → agentshim-0.11.0}/CHANGELOG.md +39 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/PKG-INFO +10 -2
- {agentshim-0.10.0 → agentshim-0.11.0}/README.md +9 -1
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/__init__.py +3 -1
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/agent.py +8 -1
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/__init__.py +2 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/errors.py +49 -7
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/provider.py +4 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/__init__.py +36 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/claude/__init__.py +2 -1
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/claude/events.py +13 -2
- agentshim-0.11.0/agentshim/providers/claude/failures.py +74 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/claude/parser.py +36 -1
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/claude/provider.py +13 -6
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/claude/scripted.py +52 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/codex/__init__.py +2 -1
- agentshim-0.11.0/agentshim/providers/codex/failures.py +43 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/codex/mcp_scope.py +36 -16
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/codex/parser.py +8 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/codex/provider.py +20 -4
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/codex/scripted.py +39 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/gemini/provider.py +6 -1
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/opencode/provider.py +6 -1
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/testing/__init__.py +30 -1
- {agentshim-0.10.0 → agentshim-0.11.0}/pyproject.toml +1 -1
- {agentshim-0.10.0 → agentshim-0.11.0}/.gitignore +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/_files.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/env.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/events.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/mcp.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/pricing.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/profile.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/schema.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/skills.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/stream.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/turn.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/usage.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/execution/__init__.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/execution/executor.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/execution/host.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/execution/transform.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/claude/hooks/__init__.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/claude/sandbox.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/claude/user_hooks.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/codex/_toml.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/codex/events.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/codex/rules.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/codex/sandbox.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/codex/skills.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/copilot/__init__.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/copilot/events.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/copilot/parser.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/copilot/provider.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/copilot/scripted.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/gemini/__init__.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/gemini/events.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/gemini/parser.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/gemini/scripted.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/opencode/__init__.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/opencode/events.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/opencode/parser.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/opencode/scripted.py +0 -0
- {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/py.typed +0 -0
|
@@ -1,5 +1,44 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.11.0 (2026-10-03)
|
|
4
|
+
|
|
5
|
+
A failed turn says why it failed. Additive except that a classified failure of
|
|
6
|
+
a resumed Claude turn is no longer a `SessionResumeError`.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- `FailureKind` (`TRANSIENT`, `USAGE_LIMIT`, `AUTH`, `OTHER`) on
|
|
11
|
+
`CliExitError.kind`, and the stream's error text on `CliExitError.detail`.
|
|
12
|
+
Claude Code classifies from the assistant frame's `error` kind, the result
|
|
13
|
+
frame's `api_error_status`, and `API Error: <status>` text; Codex from its
|
|
14
|
+
`error` and `turn.failed` messages. Copilot, Gemini and opencode report
|
|
15
|
+
`OTHER`.
|
|
16
|
+
- `ParsedTurn.error_kind`, set by a provider's parser.
|
|
17
|
+
- `agentshim.testing.scripted_failure(provider, kind)` and
|
|
18
|
+
`providers.get_failure_lines`: a real failed Claude or Codex run of each kind.
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
|
|
22
|
+
- A resumed Claude turn that failed with a classified kind (an overload, a
|
|
23
|
+
usage limit, a login problem) raises `CliExitError` with that kind instead of
|
|
24
|
+
`SessionResumeError`, and the session keeps the conversation.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- `CliExitError`'s message was empty when Claude reported the failure only in
|
|
29
|
+
its stream (for example `error_max_structured_output_retries`): the message
|
|
30
|
+
now carries the stream's error text as well as stderr.
|
|
31
|
+
|
|
32
|
+
## 0.10.1 (2026-10-03)
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- Codex `McpScope.SESSION` failed the whole turn with `invalid transport`
|
|
37
|
+
when the CLI could not see a config file agentshim had scanned (a confined
|
|
38
|
+
or containerized home): `enabled=false` alone defines an entry with no
|
|
39
|
+
transport. Each disabled server now restates its own `command` or `url`, so
|
|
40
|
+
the override is a complete, disabled entry whether or not the file is seen.
|
|
41
|
+
|
|
3
42
|
## 0.10.0 (2026-10-03)
|
|
4
43
|
|
|
5
44
|
MCP isolation. Additive: the default scope keeps today's behaviour. Adds a
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agentshim
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: Provider-agnostic coding-agent CLI shims
|
|
5
5
|
Requires-Python: >=3.10
|
|
6
6
|
Requires-Dist: tomli>=2; python_full_version < '3.11'
|
|
@@ -188,7 +188,8 @@ Everything that escapes `turn()` is an `AgentShimError`:
|
|
|
188
188
|
AgentShimError
|
|
189
189
|
CliNotFoundError binary not on PATH
|
|
190
190
|
CliCheckError binary found but the health check failed
|
|
191
|
-
CliExitError nonzero exit: argv, returncode, stdout, stderr
|
|
191
|
+
CliExitError nonzero exit: argv, returncode, stdout, stderr,
|
|
192
|
+
kind (FailureKind), detail (the stream's error text)
|
|
192
193
|
SessionResumeError the conversation is gone: session_id
|
|
193
194
|
CliTimeoutError argv, timeout
|
|
194
195
|
ProviderCapabilityError the provider cannot do what the request asked
|
|
@@ -196,6 +197,13 @@ AgentShimError
|
|
|
196
197
|
McpConfigError config file unreadable or not an object
|
|
197
198
|
```
|
|
198
199
|
|
|
200
|
+
`CliExitError.kind` says why a turn failed, so a caller can pick a policy
|
|
201
|
+
without knowing how any CLI words its errors: `TRANSIENT` (an overload, rate
|
|
202
|
+
limit or server error that waiting may outlast), `USAGE_LIMIT` (a quota or
|
|
203
|
+
billing limit), `AUTH` (credentials), or `OTHER`. Claude Code and Codex
|
|
204
|
+
classify their failures; the other providers report `OTHER`.
|
|
205
|
+
`agentshim.testing.scripted_failure(provider, kind)` scripts each one.
|
|
206
|
+
|
|
199
207
|
## Adding a provider
|
|
200
208
|
|
|
201
209
|
See [`docs/extending.md`](docs/extending.md) and
|
|
@@ -178,7 +178,8 @@ Everything that escapes `turn()` is an `AgentShimError`:
|
|
|
178
178
|
AgentShimError
|
|
179
179
|
CliNotFoundError binary not on PATH
|
|
180
180
|
CliCheckError binary found but the health check failed
|
|
181
|
-
CliExitError nonzero exit: argv, returncode, stdout, stderr
|
|
181
|
+
CliExitError nonzero exit: argv, returncode, stdout, stderr,
|
|
182
|
+
kind (FailureKind), detail (the stream's error text)
|
|
182
183
|
SessionResumeError the conversation is gone: session_id
|
|
183
184
|
CliTimeoutError argv, timeout
|
|
184
185
|
ProviderCapabilityError the provider cannot do what the request asked
|
|
@@ -186,6 +187,13 @@ AgentShimError
|
|
|
186
187
|
McpConfigError config file unreadable or not an object
|
|
187
188
|
```
|
|
188
189
|
|
|
190
|
+
`CliExitError.kind` says why a turn failed, so a caller can pick a policy
|
|
191
|
+
without knowing how any CLI words its errors: `TRANSIENT` (an overload, rate
|
|
192
|
+
limit or server error that waiting may outlast), `USAGE_LIMIT` (a quota or
|
|
193
|
+
billing limit), `AUTH` (credentials), or `OTHER`. Claude Code and Codex
|
|
194
|
+
classify their failures; the other providers report `OTHER`.
|
|
195
|
+
`agentshim.testing.scripted_failure(provider, kind)` scripts each one.
|
|
196
|
+
|
|
189
197
|
## Adding a provider
|
|
190
198
|
|
|
191
199
|
See [`docs/extending.md`](docs/extending.md) and
|
|
@@ -21,6 +21,7 @@ from .core import (
|
|
|
21
21
|
ConfigFileInstallation,
|
|
22
22
|
ConsoleEventHandler,
|
|
23
23
|
EventHandlerBase,
|
|
24
|
+
FailureKind,
|
|
24
25
|
FlagsInstallation,
|
|
25
26
|
HttpMcpServer,
|
|
26
27
|
Lifecycle,
|
|
@@ -98,7 +99,7 @@ from .providers.copilot import CopilotProvider
|
|
|
98
99
|
from .providers.gemini import GeminiProvider
|
|
99
100
|
from .providers.opencode import OpencodeProvider
|
|
100
101
|
|
|
101
|
-
__version__ = "0.
|
|
102
|
+
__version__ = "0.11.0"
|
|
102
103
|
|
|
103
104
|
__all__ = [
|
|
104
105
|
"AgentEvent",
|
|
@@ -127,6 +128,7 @@ __all__ = [
|
|
|
127
128
|
"ConsoleEventHandler",
|
|
128
129
|
"CopilotProvider",
|
|
129
130
|
"EventHandlerBase",
|
|
131
|
+
"FailureKind",
|
|
130
132
|
"FlagsInstallation",
|
|
131
133
|
"GeminiProvider",
|
|
132
134
|
"HostCommandExecutor",
|
|
@@ -397,7 +397,14 @@ class AgentSession:
|
|
|
397
397
|
error: AgentShimError | None = None
|
|
398
398
|
if result.returncode != 0:
|
|
399
399
|
error = self._agent.provider.classify_exit(
|
|
400
|
-
CliExitError(
|
|
400
|
+
CliExitError(
|
|
401
|
+
argv,
|
|
402
|
+
result.returncode,
|
|
403
|
+
result.stdout,
|
|
404
|
+
result.stderr,
|
|
405
|
+
kind=parsed.error_kind,
|
|
406
|
+
detail=parsed.error or "",
|
|
407
|
+
),
|
|
401
408
|
resumed=resumed,
|
|
402
409
|
)
|
|
403
410
|
if parsed.session_id and not isinstance(error, SessionResumeError):
|
|
@@ -14,6 +14,7 @@ from .errors import (
|
|
|
14
14
|
CliExitError,
|
|
15
15
|
CliNotFoundError,
|
|
16
16
|
CliTimeoutError,
|
|
17
|
+
FailureKind,
|
|
17
18
|
McpConfigError,
|
|
18
19
|
ProviderCapabilityError,
|
|
19
20
|
SchemaDialectError,
|
|
@@ -83,6 +84,7 @@ __all__ = [
|
|
|
83
84
|
"ConfigFileInstallation",
|
|
84
85
|
"ConsoleEventHandler",
|
|
85
86
|
"EventHandlerBase",
|
|
87
|
+
"FailureKind",
|
|
86
88
|
"FlagsInstallation",
|
|
87
89
|
"HttpMcpServer",
|
|
88
90
|
"Lifecycle",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from enum import Enum
|
|
5
6
|
from typing import TYPE_CHECKING
|
|
6
7
|
|
|
7
8
|
if TYPE_CHECKING:
|
|
@@ -14,6 +15,24 @@ class AgentShimError(Exception):
|
|
|
14
15
|
"""Base class for every error agentshim raises."""
|
|
15
16
|
|
|
16
17
|
|
|
18
|
+
class FailureKind(str, Enum):
|
|
19
|
+
"""Why a provider turn failed, as far as the provider let agentshim tell.
|
|
20
|
+
|
|
21
|
+
The provider package decides the kind from what its CLI reported (a
|
|
22
|
+
structured error field, an HTTP status, or its own error text), so a caller
|
|
23
|
+
can choose a policy without knowing how any CLI phrases its failures.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
#: An overload, rate limit, or server error that waiting may outlast.
|
|
27
|
+
TRANSIENT = "transient"
|
|
28
|
+
#: A usage quota, spend limit, or billing problem: waiting minutes will not help.
|
|
29
|
+
USAGE_LIMIT = "usage_limit"
|
|
30
|
+
#: Missing, expired, or rejected credentials.
|
|
31
|
+
AUTH = "auth"
|
|
32
|
+
#: Anything else, including every failure a provider cannot classify.
|
|
33
|
+
OTHER = "other"
|
|
34
|
+
|
|
35
|
+
|
|
17
36
|
class CliNotFoundError(AgentShimError):
|
|
18
37
|
"""The provider binary is not on PATH."""
|
|
19
38
|
|
|
@@ -45,44 +64,58 @@ class CliCheckError(AgentShimError):
|
|
|
45
64
|
class CliExitError(AgentShimError):
|
|
46
65
|
"""The provider CLI exited nonzero."""
|
|
47
66
|
|
|
48
|
-
def __init__(
|
|
67
|
+
def __init__( # noqa: PLR0913 # one field per part of the outcome; a bundle would hide them
|
|
49
68
|
self,
|
|
50
69
|
argv: Sequence[str],
|
|
51
70
|
returncode: int,
|
|
52
71
|
stdout: str = "",
|
|
53
72
|
stderr: str = "",
|
|
54
73
|
message: str | None = None,
|
|
74
|
+
*,
|
|
75
|
+
kind: FailureKind = FailureKind.OTHER,
|
|
76
|
+
detail: str = "",
|
|
55
77
|
) -> None:
|
|
56
78
|
"""Capture the whole outcome of a nonzero CLI exit.
|
|
57
79
|
|
|
58
80
|
Both streams are retained because a provider may report the real cause
|
|
59
81
|
on either one, and ``argv`` is copied so the error stays accurate after
|
|
60
|
-
the caller mutates its command list.
|
|
61
|
-
|
|
82
|
+
the caller mutates its command list. ``kind`` is the provider's
|
|
83
|
+
classification of the failure and ``detail`` the error text its stream
|
|
84
|
+
reported; the generated summary carries ``detail`` because a CLI that
|
|
85
|
+
reports its failure in the stream leaves stderr empty. Passing
|
|
86
|
+
``message`` replaces the generated summary.
|
|
62
87
|
"""
|
|
63
88
|
self.argv = tuple(argv)
|
|
64
89
|
self.returncode = returncode
|
|
65
90
|
self.stdout = stdout
|
|
66
91
|
self.stderr = stderr
|
|
92
|
+
self.kind = kind
|
|
93
|
+
self.detail = detail
|
|
67
94
|
binary = self.argv[0] if self.argv else "cli"
|
|
68
|
-
super().__init__(
|
|
95
|
+
super().__init__(
|
|
96
|
+
message or f"{binary} exited with code {returncode}: {_describe(detail, stderr)}"
|
|
97
|
+
)
|
|
69
98
|
|
|
70
99
|
|
|
71
100
|
class SessionResumeError(CliExitError):
|
|
72
101
|
"""A resumed turn failed because the provider conversation is gone."""
|
|
73
102
|
|
|
74
|
-
def __init__(
|
|
103
|
+
def __init__( # noqa: PLR0913 # the parent's fields plus the session id
|
|
75
104
|
self,
|
|
76
105
|
argv: Sequence[str],
|
|
77
106
|
returncode: int,
|
|
78
107
|
session_id: str,
|
|
79
108
|
stdout: str = "",
|
|
80
109
|
stderr: str = "",
|
|
110
|
+
*,
|
|
111
|
+
detail: str = "",
|
|
81
112
|
) -> None:
|
|
82
113
|
"""Attach the conversation id that the provider refused to resume.
|
|
83
114
|
|
|
84
115
|
Raised instead of a plain ``CliExitError`` so a caller can tell a dead
|
|
85
|
-
conversation apart from a failed turn and retry from scratch.
|
|
116
|
+
conversation apart from a failed turn and retry from scratch. Its kind
|
|
117
|
+
is always ``OTHER``: a provider that classified the failure as anything
|
|
118
|
+
else has shown that the conversation is not what failed.
|
|
86
119
|
"""
|
|
87
120
|
self.session_id = session_id
|
|
88
121
|
super().__init__(
|
|
@@ -90,7 +123,11 @@ class SessionResumeError(CliExitError):
|
|
|
90
123
|
returncode,
|
|
91
124
|
stdout,
|
|
92
125
|
stderr,
|
|
93
|
-
message=
|
|
126
|
+
message=(
|
|
127
|
+
f"cannot resume session {session_id!r} (exit code {returncode}): "
|
|
128
|
+
f"{_describe(detail, stderr)}"
|
|
129
|
+
),
|
|
130
|
+
detail=detail,
|
|
94
131
|
)
|
|
95
132
|
|
|
96
133
|
|
|
@@ -135,3 +172,8 @@ class SchemaDialectError(ProviderCapabilityError):
|
|
|
135
172
|
|
|
136
173
|
class McpConfigError(AgentShimError):
|
|
137
174
|
"""An MCP config file is unreadable, unwritable, or is not a JSON object."""
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _describe(detail: str, stderr: str) -> str:
|
|
178
|
+
"""Join the stream's error text and stderr, skipping whichever is empty."""
|
|
179
|
+
return "; ".join(part for part in (detail.strip(), stderr.strip()) if part)
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from dataclasses import dataclass, field
|
|
6
6
|
from typing import TYPE_CHECKING, Any, Protocol
|
|
7
7
|
|
|
8
|
+
from .errors import FailureKind
|
|
8
9
|
from .profile import McpScope, SkillScope
|
|
9
10
|
from .usage import ProviderUsage
|
|
10
11
|
|
|
@@ -59,7 +60,10 @@ class ParsedTurn:
|
|
|
59
60
|
session_id: str | None = None
|
|
60
61
|
usage: ProviderUsage = field(default_factory=ProviderUsage)
|
|
61
62
|
cost_usd: float | None = None
|
|
63
|
+
#: The error text the stream reported, or ``None`` when it reported none.
|
|
62
64
|
error: str | None = None
|
|
65
|
+
#: The parser's classification of ``error``; ``OTHER`` when it cannot tell.
|
|
66
|
+
error_kind: FailureKind = FailureKind.OTHER
|
|
63
67
|
|
|
64
68
|
|
|
65
69
|
class StreamParser(Protocol):
|
|
@@ -11,9 +11,11 @@ from __future__ import annotations
|
|
|
11
11
|
from typing import TYPE_CHECKING, Any, Protocol
|
|
12
12
|
|
|
13
13
|
from .claude import ClaudeProvider
|
|
14
|
+
from .claude import failure_lines as _claude_failure
|
|
14
15
|
from .claude import resume_failure_lines as _claude_resume_failure
|
|
15
16
|
from .claude import scripted_lines as _claude_scripted
|
|
16
17
|
from .codex import CodexProvider
|
|
18
|
+
from .codex import failure_lines as _codex_failure
|
|
17
19
|
from .codex import resume_failure_lines as _codex_resume_failure
|
|
18
20
|
from .codex import scripted_lines as _codex_scripted
|
|
19
21
|
from .copilot import CopilotProvider
|
|
@@ -29,6 +31,7 @@ from .opencode import scripted_lines as _opencode_scripted
|
|
|
29
31
|
if TYPE_CHECKING:
|
|
30
32
|
from collections.abc import Callable, Mapping, Sequence
|
|
31
33
|
|
|
34
|
+
from agentshim.core.errors import FailureKind
|
|
32
35
|
from agentshim.core.provider import Provider
|
|
33
36
|
from agentshim.core.usage import TokenUsage
|
|
34
37
|
|
|
@@ -74,6 +77,20 @@ class ResumeFailureLines(Protocol):
|
|
|
74
77
|
...
|
|
75
78
|
|
|
76
79
|
|
|
80
|
+
class FailureLines(Protocol):
|
|
81
|
+
"""Builds the stdout, stderr and exit code of a turn that failed with a given kind."""
|
|
82
|
+
|
|
83
|
+
def __call__(
|
|
84
|
+
self, kind: FailureKind, *, session_id: str | None = None
|
|
85
|
+
) -> tuple[Sequence[str], Sequence[str], int]:
|
|
86
|
+
"""Return ``(stdout, stderr, returncode)`` for a ``FakeRun``.
|
|
87
|
+
|
|
88
|
+
The run must classify as *kind* through the provider's real parser.
|
|
89
|
+
``session_id`` is the conversation the failed run names, if any.
|
|
90
|
+
"""
|
|
91
|
+
...
|
|
92
|
+
|
|
93
|
+
|
|
77
94
|
# To add a provider: implement providers/<name>/ (provider.py, parser.py,
|
|
78
95
|
# events.py, scripted.py) and add one entry to each dict below.
|
|
79
96
|
_FACTORIES: dict[str, Callable[[], Provider]] = {
|
|
@@ -101,6 +118,14 @@ _RESUME_FAILURES: dict[str, ResumeFailureLines] = {
|
|
|
101
118
|
}
|
|
102
119
|
|
|
103
120
|
|
|
121
|
+
# Only the providers whose stream reports why a turn failed: every other
|
|
122
|
+
# provider classifies each failure as ``FailureKind.OTHER``.
|
|
123
|
+
_FAILURES: dict[str, FailureLines] = {
|
|
124
|
+
"claude": _claude_failure,
|
|
125
|
+
"codex": _codex_failure,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
|
|
104
129
|
def provider_names() -> list[str]:
|
|
105
130
|
"""Return the supported provider names, sorted."""
|
|
106
131
|
return sorted(_FACTORIES)
|
|
@@ -135,14 +160,25 @@ def get_resume_failure_lines(name: str) -> ResumeFailureLines:
|
|
|
135
160
|
return resume_failure
|
|
136
161
|
|
|
137
162
|
|
|
163
|
+
def get_failure_lines(name: str) -> FailureLines:
|
|
164
|
+
"""Return the test-double classified-failure builder for *name*."""
|
|
165
|
+
failure = _FAILURES.get(name)
|
|
166
|
+
if failure is None:
|
|
167
|
+
msg = f"no classified-failure stream for provider {name!r}; available: {sorted(_FAILURES)}"
|
|
168
|
+
raise ValueError(msg)
|
|
169
|
+
return failure
|
|
170
|
+
|
|
171
|
+
|
|
138
172
|
__all__ = [
|
|
139
173
|
"ClaudeProvider",
|
|
140
174
|
"CodexProvider",
|
|
141
175
|
"CopilotProvider",
|
|
176
|
+
"FailureLines",
|
|
142
177
|
"GeminiProvider",
|
|
143
178
|
"OpencodeProvider",
|
|
144
179
|
"ResumeFailureLines",
|
|
145
180
|
"ScriptedLines",
|
|
181
|
+
"get_failure_lines",
|
|
146
182
|
"get_provider",
|
|
147
183
|
"get_resume_failure_lines",
|
|
148
184
|
"get_scripted_lines",
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
from .parser import ClaudeStreamParser
|
|
6
6
|
from .provider import PROFILE, ClaudeProvider, mcp_entry
|
|
7
7
|
from .sandbox import SandboxConfig, build_settings, resolve_sandbox
|
|
8
|
-
from .scripted import resume_failure_lines, scripted_lines
|
|
8
|
+
from .scripted import failure_lines, resume_failure_lines, scripted_lines
|
|
9
9
|
from .user_hooks import ClaudeHook
|
|
10
10
|
|
|
11
11
|
__all__ = [
|
|
@@ -15,6 +15,7 @@ __all__ = [
|
|
|
15
15
|
"ClaudeStreamParser",
|
|
16
16
|
"SandboxConfig",
|
|
17
17
|
"build_settings",
|
|
18
|
+
"failure_lines",
|
|
18
19
|
"mcp_entry",
|
|
19
20
|
"resolve_sandbox",
|
|
20
21
|
"resume_failure_lines",
|
|
@@ -60,6 +60,10 @@ class AssistantMessage:
|
|
|
60
60
|
|
|
61
61
|
blocks: Sequence[ContentBlock] = ()
|
|
62
62
|
usage: Mapping[str, Any] | None = None
|
|
63
|
+
#: The API error kind Claude stamps on the synthetic message it writes
|
|
64
|
+
#: when a request fails (``overloaded``, ``rate_limit``, ...); ``None`` on
|
|
65
|
+
#: a normal reply.
|
|
66
|
+
error: str | None = None
|
|
63
67
|
|
|
64
68
|
|
|
65
69
|
@dataclass(frozen=True)
|
|
@@ -83,6 +87,10 @@ class ResultFrame:
|
|
|
83
87
|
duration_ms: int | None = None
|
|
84
88
|
is_error: bool = False
|
|
85
89
|
subtype: str | None = None
|
|
90
|
+
#: The HTTP status of the API error that ended the turn, if one did.
|
|
91
|
+
api_error_status: int | None = None
|
|
92
|
+
#: The messages an ``error_*`` subtype reports instead of ``result`` text.
|
|
93
|
+
errors: tuple[str, ...] = ()
|
|
86
94
|
|
|
87
95
|
|
|
88
96
|
ClaudeFrame = SystemInit | AssistantMessage | ToolResultBlock | ResultFrame
|
|
@@ -111,6 +119,8 @@ def parse_frame(data: Mapping[str, Any]) -> ClaudeFrame | None:
|
|
|
111
119
|
duration_ms=_int_or_none(data.get("duration_ms")),
|
|
112
120
|
is_error=bool(data.get("is_error")),
|
|
113
121
|
subtype=_str_or_none(data.get("subtype")),
|
|
122
|
+
api_error_status=_int_or_none(data.get("api_error_status")),
|
|
123
|
+
errors=_str_tuple_or_none(data.get("errors")) or (),
|
|
114
124
|
)
|
|
115
125
|
return None
|
|
116
126
|
|
|
@@ -128,9 +138,10 @@ def _assistant(data: Mapping[str, Any]) -> AssistantMessage | None:
|
|
|
128
138
|
if block is not None:
|
|
129
139
|
blocks.append(block)
|
|
130
140
|
usage = _mapping_or_none(payload.get("usage"))
|
|
131
|
-
|
|
141
|
+
error = _str_or_none(data.get("error"))
|
|
142
|
+
if not blocks and usage is None and error is None:
|
|
132
143
|
return None
|
|
133
|
-
return AssistantMessage(blocks=tuple(blocks), usage=usage)
|
|
144
|
+
return AssistantMessage(blocks=tuple(blocks), usage=usage, error=error)
|
|
134
145
|
|
|
135
146
|
|
|
136
147
|
def _content_block(raw: object) -> ContentBlock | None:
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""How Claude Code says why a turn failed, mapped to ``FailureKind``.
|
|
2
|
+
|
|
3
|
+
Claude reports a failed API request three ways, newest first: an ``error``
|
|
4
|
+
kind on the synthetic assistant message it writes for the failure, an
|
|
5
|
+
``api_error_status`` on the final ``result`` frame, and the text
|
|
6
|
+
``API Error: <status> <body>`` in that frame's ``result``. Older builds only
|
|
7
|
+
have the text, so all three are read.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
from agentshim.core.errors import FailureKind
|
|
15
|
+
|
|
16
|
+
#: Assistant ``error`` kinds that a later request may not hit.
|
|
17
|
+
_TRANSIENT_KINDS = frozenset({"overloaded", "rate_limit", "server_error"})
|
|
18
|
+
#: Assistant ``error`` kinds that need the user to sign in or fix an account.
|
|
19
|
+
_AUTH_KINDS = frozenset(
|
|
20
|
+
{
|
|
21
|
+
"authentication_failed",
|
|
22
|
+
"oauth_org_not_allowed",
|
|
23
|
+
"account_on_hold",
|
|
24
|
+
"verification_required",
|
|
25
|
+
"cloud_credential_error",
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
_USAGE_LIMIT_KINDS = frozenset({"billing_error"})
|
|
29
|
+
|
|
30
|
+
#: Anthropic API error types that name an overload or a server fault.
|
|
31
|
+
_TRANSIENT_TYPES = re.compile(r"\b(?:overloaded_error|rate_limit_error|api_error)\b")
|
|
32
|
+
_AUTH_TYPES = re.compile(r"\b(?:authentication_error|permission_error)\b")
|
|
33
|
+
_API_ERROR_STATUS = re.compile(r"\bAPI Error: (\d{3})\b")
|
|
34
|
+
#: A 429 that is a subscription or spend quota rather than a rate limit.
|
|
35
|
+
#: Claude words these itself ("You've hit your limit", "usage limit reached")
|
|
36
|
+
#: or relays a gateway's own sentence about a spend limit or credit balance.
|
|
37
|
+
_QUOTA_TEXT = re.compile(r"hit your limit|usage limit|credit balance|spend limit", re.IGNORECASE)
|
|
38
|
+
_AUTH_STATUSES = frozenset({401, 403})
|
|
39
|
+
#: A request timeout or a rate limit; every 5xx (529 is an overload) is transient too.
|
|
40
|
+
_TRANSIENT_STATUSES = frozenset({408, 429})
|
|
41
|
+
_SERVER_ERRORS = range(500, 600)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def classify_failure(*, api_error: str | None, status: int | None, text: str) -> FailureKind:
|
|
45
|
+
"""Classify one failed turn from what Claude reported about it.
|
|
46
|
+
|
|
47
|
+
``api_error`` is the assistant frame's ``error`` kind, ``status`` the
|
|
48
|
+
result frame's ``api_error_status``, and ``text`` the error text the
|
|
49
|
+
stream (or, when the stream had none, stderr) carried. Tool output is
|
|
50
|
+
never part of ``text``, so a tool that printed an API error cannot make
|
|
51
|
+
the turn look like one.
|
|
52
|
+
"""
|
|
53
|
+
if api_error in _AUTH_KINDS:
|
|
54
|
+
return FailureKind.AUTH
|
|
55
|
+
if api_error in _USAGE_LIMIT_KINDS:
|
|
56
|
+
return FailureKind.USAGE_LIMIT
|
|
57
|
+
if status is None:
|
|
58
|
+
match = _API_ERROR_STATUS.search(text)
|
|
59
|
+
status = int(match.group(1)) if match else None
|
|
60
|
+
if (
|
|
61
|
+
api_error in _TRANSIENT_KINDS
|
|
62
|
+
or _is_transient_status(status)
|
|
63
|
+
or _TRANSIENT_TYPES.search(text)
|
|
64
|
+
):
|
|
65
|
+
return FailureKind.USAGE_LIMIT if _QUOTA_TEXT.search(text) else FailureKind.TRANSIENT
|
|
66
|
+
if status in _AUTH_STATUSES or _AUTH_TYPES.search(text):
|
|
67
|
+
return FailureKind.AUTH
|
|
68
|
+
if _QUOTA_TEXT.search(text):
|
|
69
|
+
return FailureKind.USAGE_LIMIT
|
|
70
|
+
return FailureKind.OTHER
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _is_transient_status(status: int | None) -> bool:
|
|
74
|
+
return status is not None and (status in _TRANSIENT_STATUSES or status in _SERVER_ERRORS)
|
|
@@ -32,10 +32,12 @@ from .events import (
|
|
|
32
32
|
ToolResultBlock,
|
|
33
33
|
parse_frame,
|
|
34
34
|
)
|
|
35
|
+
from .failures import classify_failure
|
|
35
36
|
|
|
36
37
|
if TYPE_CHECKING:
|
|
37
38
|
from collections.abc import Callable, Mapping
|
|
38
39
|
|
|
40
|
+
from agentshim.core.errors import FailureKind
|
|
39
41
|
from agentshim.core.events import AgentEvent
|
|
40
42
|
|
|
41
43
|
PROVIDER_NAME = "claude"
|
|
@@ -136,6 +138,11 @@ class ClaudeStreamParser:
|
|
|
136
138
|
self._usage = ProviderUsage(provider=PROVIDER_NAME)
|
|
137
139
|
self._cost_usd: float | None = None
|
|
138
140
|
self._error: str | None = None
|
|
141
|
+
# What the stream said about a failed API request, for classifying it.
|
|
142
|
+
self._api_error: str | None = None
|
|
143
|
+
self._api_error_text: str | None = None
|
|
144
|
+
self._api_error_status: int | None = None
|
|
145
|
+
self._stderr: list[str] = []
|
|
139
146
|
|
|
140
147
|
def feed_stdout(self, line: str) -> None:
|
|
141
148
|
"""Consume one stdout line, emitting the events its frame implies.
|
|
@@ -158,6 +165,7 @@ class ClaudeStreamParser:
|
|
|
158
165
|
"""Emit one non-blank stderr line as a ``Stderr`` event."""
|
|
159
166
|
stripped = line.rstrip("\n")
|
|
160
167
|
if stripped:
|
|
168
|
+
self._stderr.append(stripped)
|
|
161
169
|
self._emit(Stderr(stripped))
|
|
162
170
|
|
|
163
171
|
def finish(self) -> ParsedTurn:
|
|
@@ -170,8 +178,19 @@ class ClaudeStreamParser:
|
|
|
170
178
|
usage=self._usage,
|
|
171
179
|
cost_usd=self._cost_usd,
|
|
172
180
|
error=self._error,
|
|
181
|
+
error_kind=self._error_kind(),
|
|
173
182
|
)
|
|
174
183
|
|
|
184
|
+
def _error_kind(self) -> FailureKind:
|
|
185
|
+
"""Classify the failure from the stream, or from stderr if it had none.
|
|
186
|
+
|
|
187
|
+
A CLI that dies before writing a ``result`` frame leaves stderr as the
|
|
188
|
+
only account of why, so stderr is read only then.
|
|
189
|
+
"""
|
|
190
|
+
reported = [text for text in (self._error, self._api_error_text) if text]
|
|
191
|
+
text = "\n".join(reported) if reported else "\n".join(self._stderr)
|
|
192
|
+
return classify_failure(api_error=self._api_error, status=self._api_error_status, text=text)
|
|
193
|
+
|
|
175
194
|
def _handle(self, frame: object) -> None:
|
|
176
195
|
if isinstance(frame, SystemInit):
|
|
177
196
|
if self._session_id is None and frame.session_id:
|
|
@@ -187,6 +206,11 @@ class ClaudeStreamParser:
|
|
|
187
206
|
self._result(frame)
|
|
188
207
|
|
|
189
208
|
def _assistant(self, frame: AssistantMessage) -> None:
|
|
209
|
+
if frame.error is not None:
|
|
210
|
+
self._api_error = frame.error
|
|
211
|
+
self._api_error_text = "\n".join(
|
|
212
|
+
block.text for block in frame.blocks if isinstance(block, TextBlock)
|
|
213
|
+
)
|
|
190
214
|
if frame.usage is not None:
|
|
191
215
|
self._emit(
|
|
192
216
|
UsageReport(
|
|
@@ -237,7 +261,8 @@ class ClaudeStreamParser:
|
|
|
237
261
|
)
|
|
238
262
|
self._emit(UsageReport(self._usage, frame.total_cost_usd))
|
|
239
263
|
if frame.is_error:
|
|
240
|
-
self.
|
|
264
|
+
self._api_error_status = frame.api_error_status
|
|
265
|
+
self._error = _error_text(frame)
|
|
241
266
|
self._emit(ProviderError(self._error))
|
|
242
267
|
|
|
243
268
|
def _structured_payload(self, frame: ResultFrame) -> object | None:
|
|
@@ -255,3 +280,13 @@ class ClaudeStreamParser:
|
|
|
255
280
|
return json.loads(frame.text)
|
|
256
281
|
except (json.JSONDecodeError, ValueError):
|
|
257
282
|
return None
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _error_text(frame: ResultFrame) -> str:
|
|
286
|
+
"""Say what an error ``result`` frame reported.
|
|
287
|
+
|
|
288
|
+
An ``error_*`` subtype carries its messages in ``errors`` and often no
|
|
289
|
+
``result`` text at all, so both are read before falling back to the
|
|
290
|
+
subtype, which is the only part that names some failures.
|
|
291
|
+
"""
|
|
292
|
+
return frame.text or "; ".join(frame.errors) or frame.subtype or "claude reported an error"
|
|
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import json
|
|
6
6
|
from typing import TYPE_CHECKING, Any
|
|
7
7
|
|
|
8
|
-
from agentshim.core.errors import ProviderCapabilityError, SessionResumeError
|
|
8
|
+
from agentshim.core.errors import FailureKind, ProviderCapabilityError, SessionResumeError
|
|
9
9
|
from agentshim.core.mcp import HttpMcpServer, NoopInstallation, StdioMcpServer, install_config_file
|
|
10
10
|
from agentshim.core.profile import (
|
|
11
11
|
McpMechanism,
|
|
@@ -178,17 +178,24 @@ class ClaudeProvider:
|
|
|
178
178
|
)
|
|
179
179
|
|
|
180
180
|
def classify_exit(self, error: CliExitError, *, resumed: bool) -> AgentShimError:
|
|
181
|
-
"""
|
|
181
|
+
"""A resumed turn that failed for no reason the stream named lost its conversation.
|
|
182
182
|
|
|
183
183
|
``claude --resume`` gives no distinguishable exit code for a missing
|
|
184
|
-
transcript,
|
|
185
|
-
|
|
184
|
+
transcript, so a resumed turn whose failure the parser could not
|
|
185
|
+
classify is treated as a dead conversation. A failure the stream did
|
|
186
|
+
classify (an overload, a usage limit, a login problem) happened inside
|
|
187
|
+
a conversation that resumed, so it keeps its own kind.
|
|
186
188
|
"""
|
|
187
|
-
if resumed:
|
|
189
|
+
if resumed and error.kind is FailureKind.OTHER:
|
|
188
190
|
session_id = _resumed_session_id(error.argv)
|
|
189
191
|
if session_id is not None:
|
|
190
192
|
return SessionResumeError(
|
|
191
|
-
error.argv,
|
|
193
|
+
error.argv,
|
|
194
|
+
error.returncode,
|
|
195
|
+
session_id,
|
|
196
|
+
error.stdout,
|
|
197
|
+
error.stderr,
|
|
198
|
+
detail=error.detail,
|
|
192
199
|
)
|
|
193
200
|
return error
|
|
194
201
|
|
|
@@ -10,6 +10,8 @@ from __future__ import annotations
|
|
|
10
10
|
import json
|
|
11
11
|
from typing import TYPE_CHECKING, Any
|
|
12
12
|
|
|
13
|
+
from agentshim.core.errors import FailureKind
|
|
14
|
+
|
|
13
15
|
if TYPE_CHECKING:
|
|
14
16
|
from collections.abc import Mapping, Sequence
|
|
15
17
|
|
|
@@ -117,6 +119,56 @@ def resume_failure_lines(*, session_id: str | None = None) -> tuple[list[str], l
|
|
|
117
119
|
return [], stderr, 1
|
|
118
120
|
|
|
119
121
|
|
|
122
|
+
#: What Claude writes for a failed request of each kind: the assistant
|
|
123
|
+
#: ``error`` kind, the HTTP status, and the message. ``OTHER`` is a turn that
|
|
124
|
+
#: ran but could not satisfy its output schema, the subtype Claude reports
|
|
125
|
+
#: when no StructuredOutput call validated.
|
|
126
|
+
_FAILURES: dict[FailureKind, tuple[str, int, str]] = {
|
|
127
|
+
FailureKind.TRANSIENT: (
|
|
128
|
+
"overloaded",
|
|
129
|
+
529,
|
|
130
|
+
'API Error: 529 {"type":"error","error":{"type":"overloaded_error","message":"Overloaded"}}',
|
|
131
|
+
),
|
|
132
|
+
FailureKind.USAGE_LIMIT: ("rate_limit", 429, "You've hit your limit \u00b7 resets 3pm (UTC)"),
|
|
133
|
+
FailureKind.AUTH: ("authentication_failed", 401, "Invalid API key \u00b7 Please run /login"),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def failure_lines(
|
|
138
|
+
kind: FailureKind, *, session_id: str | None = None
|
|
139
|
+
) -> tuple[list[str], list[str], int]:
|
|
140
|
+
"""Build the stdout, stderr and exit code of a turn that fails with *kind*.
|
|
141
|
+
|
|
142
|
+
An API failure is the synthetic assistant message Claude writes for it,
|
|
143
|
+
then an error ``result`` frame with the HTTP status. ``OTHER`` is an
|
|
144
|
+
``error_max_structured_output_retries`` result, which names no API error.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
kind: The classification the scripted failure must produce.
|
|
148
|
+
session_id: Conversation id the ``init`` frame names, if any.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
``(stdout, stderr, returncode)`` for a ``FakeRun``.
|
|
152
|
+
"""
|
|
153
|
+
lines: list[str] = []
|
|
154
|
+
if session_id is not None:
|
|
155
|
+
lines.append(_line({"type": "system", "subtype": "init", "session_id": session_id}))
|
|
156
|
+
result: dict[str, Any] = {"type": "result", "is_error": True, "num_turns": 1}
|
|
157
|
+
if session_id is not None:
|
|
158
|
+
result["session_id"] = session_id
|
|
159
|
+
failure = _FAILURES.get(kind)
|
|
160
|
+
if failure is None:
|
|
161
|
+
result["subtype"] = "error_max_structured_output_retries"
|
|
162
|
+
result["errors"] = ["no StructuredOutput call produced a valid output"]
|
|
163
|
+
else:
|
|
164
|
+
error, status, text = failure
|
|
165
|
+
message = {"role": "assistant", "content": [{"type": "text", "text": text}]}
|
|
166
|
+
lines.append(_line({"type": "assistant", "message": message, "error": error}))
|
|
167
|
+
result.update(subtype="success", result=text, api_error_status=status)
|
|
168
|
+
lines.append(_line(result))
|
|
169
|
+
return lines, [], 1
|
|
170
|
+
|
|
171
|
+
|
|
120
172
|
def _usage_payload(usage: TokenUsage | None) -> dict[str, Any]:
|
|
121
173
|
if usage is None:
|
|
122
174
|
return {
|
|
@@ -13,7 +13,7 @@ from .provider import (
|
|
|
13
13
|
)
|
|
14
14
|
from .rules import RULES_FILENAME, install_rules, parse_rules, render_rules
|
|
15
15
|
from .sandbox import SANDBOX_MODES, CodexSandboxConfig, SandboxMode
|
|
16
|
-
from .scripted import resume_failure_lines, scripted_lines
|
|
16
|
+
from .scripted import failure_lines, resume_failure_lines, scripted_lines
|
|
17
17
|
|
|
18
18
|
__all__ = [
|
|
19
19
|
"BYPASS_FLAG",
|
|
@@ -25,6 +25,7 @@ __all__ = [
|
|
|
25
25
|
"CodexSandboxConfig",
|
|
26
26
|
"CodexStreamParser",
|
|
27
27
|
"SandboxMode",
|
|
28
|
+
"failure_lines",
|
|
28
29
|
"install_rules",
|
|
29
30
|
"parse_mcp_servers",
|
|
30
31
|
"parse_rules",
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""How Codex says why a turn failed, mapped to ``FailureKind``.
|
|
2
|
+
|
|
3
|
+
``codex exec --json`` reports a failure only as the text of an ``error`` or
|
|
4
|
+
``turn.failed`` event, so the classification reads that text. The phrases are
|
|
5
|
+
Codex's own: it names a usage limit ("You've hit your usage limit",
|
|
6
|
+
``usage_limit_reached``), retries a lost stream ("stream disconnected",
|
|
7
|
+
"Reconnecting...") and, once its retries run out, reports
|
|
8
|
+
"exceeded retry limit, last status: <status>".
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
|
|
15
|
+
from agentshim.core.errors import FailureKind
|
|
16
|
+
|
|
17
|
+
_USAGE_LIMIT = re.compile(
|
|
18
|
+
r"hit your usage limit|usage_limit_(?:reached|exceeded)|insufficient_quota", re.IGNORECASE
|
|
19
|
+
)
|
|
20
|
+
_AUTH = re.compile(r"\b(?:401|403)\b|unauthorized|forbidden|not logged in", re.IGNORECASE)
|
|
21
|
+
_TRANSIENT = re.compile(
|
|
22
|
+
r"exceeded retry limit|stream disconnected|reconnecting|server_overloaded|overloaded"
|
|
23
|
+
r"|internal_server_error|internal server error|service unavailable|bad gateway"
|
|
24
|
+
r"|too many requests|rate limit|at capacity|http_connection_failed"
|
|
25
|
+
r"|\bstatus:? (?:429|5\d\d)\b",
|
|
26
|
+
re.IGNORECASE,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def classify_failure(text: str) -> FailureKind:
|
|
31
|
+
"""Classify the error text of one failed Codex turn.
|
|
32
|
+
|
|
33
|
+
The order matters: a usage limit is also reported with a 429 status, and
|
|
34
|
+
an exhausted retry can end on a 401, so the specific kinds are checked
|
|
35
|
+
before the generic transient one.
|
|
36
|
+
"""
|
|
37
|
+
if _USAGE_LIMIT.search(text):
|
|
38
|
+
return FailureKind.USAGE_LIMIT
|
|
39
|
+
if _AUTH.search(text):
|
|
40
|
+
return FailureKind.AUTH
|
|
41
|
+
if _TRANSIENT.search(text):
|
|
42
|
+
return FailureKind.TRANSIENT
|
|
43
|
+
return FailureKind.OTHER
|
|
@@ -22,7 +22,7 @@ else: # pragma: no cover - exercised on the 3.10 CI leg
|
|
|
22
22
|
import tomli as tomllib # pyright: ignore[reportMissingImports]
|
|
23
23
|
|
|
24
24
|
if TYPE_CHECKING:
|
|
25
|
-
from collections.abc import Collection,
|
|
25
|
+
from collections.abc import Collection, Mapping
|
|
26
26
|
|
|
27
27
|
_SYSTEM_CONFIG = "/etc/codex/config.toml"
|
|
28
28
|
_BARE_KEY = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_-")
|
|
@@ -32,32 +32,47 @@ _BARE_KEY = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz01234
|
|
|
32
32
|
_MCP_FEATURES = ("plugins", "apps")
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
def
|
|
36
|
-
"""Every ``mcp_servers`` key
|
|
35
|
+
def configured_servers(env: Mapping[str, str], cwd: str | None) -> dict[str, str]:
|
|
36
|
+
"""Every configured ``mcp_servers`` key, with a ``--config`` flag pinning its transport.
|
|
37
37
|
|
|
38
38
|
Reads the user's ``$CODEX_HOME/config.toml`` (default ``~/.codex``), the
|
|
39
39
|
system config, and ``<cwd>/.codex/config.toml``. A file that is missing or
|
|
40
40
|
not valid TOML contributes nothing (Codex reports a broken config itself).
|
|
41
|
-
The
|
|
42
|
-
|
|
41
|
+
The value is the entry's own ``command`` or ``url`` rendered as an
|
|
42
|
+
override (empty when it has neither). Disabling an entry with
|
|
43
|
+
``enabled=false`` alone fails the whole turn ("invalid transport") when
|
|
44
|
+
the CLI cannot see the file agentshim scanned, as in a container or a
|
|
45
|
+
confined home; restating the transport makes the override a complete,
|
|
46
|
+
disabled entry either way.
|
|
43
47
|
"""
|
|
44
48
|
home = env.get("HOME") or os.path.expanduser("~") # noqa: PTH111 - a str contract, not a Path
|
|
45
49
|
codex_home = env.get("CODEX_HOME") or os.path.join(home, ".codex") # noqa: PTH118
|
|
46
50
|
paths = [os.path.join(codex_home, "config.toml"), _SYSTEM_CONFIG] # noqa: PTH118
|
|
47
51
|
if cwd is not None:
|
|
48
52
|
paths.append(os.path.join(cwd, ".codex", "config.toml")) # noqa: PTH118
|
|
49
|
-
|
|
53
|
+
servers: dict[str, str] = {}
|
|
50
54
|
for path in paths:
|
|
51
|
-
|
|
52
|
-
return
|
|
55
|
+
servers.update(_servers(path))
|
|
56
|
+
return servers
|
|
53
57
|
|
|
54
58
|
|
|
55
|
-
def
|
|
59
|
+
def _servers(path: str) -> dict[str, str]:
|
|
56
60
|
config = _read_toml(path)
|
|
57
|
-
|
|
58
|
-
if not isinstance(
|
|
59
|
-
return
|
|
60
|
-
|
|
61
|
+
table: object = config.get("mcp_servers")
|
|
62
|
+
if not isinstance(table, dict):
|
|
63
|
+
return {}
|
|
64
|
+
found: dict[str, str] = {}
|
|
65
|
+
for key, entry in cast("dict[object, object]", table).items():
|
|
66
|
+
pinned = ""
|
|
67
|
+
if isinstance(entry, dict):
|
|
68
|
+
fields = cast("dict[str, object]", entry)
|
|
69
|
+
for field in ("command", "url"):
|
|
70
|
+
value = fields.get(field)
|
|
71
|
+
if isinstance(value, str):
|
|
72
|
+
pinned = f"{field}={toml_str(value)}"
|
|
73
|
+
break
|
|
74
|
+
found[str(key)] = pinned
|
|
75
|
+
return found
|
|
61
76
|
|
|
62
77
|
|
|
63
78
|
def _read_toml(path: str) -> dict[str, Any]:
|
|
@@ -85,9 +100,14 @@ def session_scope_overrides(
|
|
|
85
100
|
names the user's config does not use.
|
|
86
101
|
"""
|
|
87
102
|
flags: list[str] = []
|
|
88
|
-
for key in
|
|
89
|
-
if key
|
|
90
|
-
|
|
103
|
+
for key, pinned in configured_servers(env, cwd).items():
|
|
104
|
+
if key in given_keys:
|
|
105
|
+
continue
|
|
106
|
+
prefix = f"mcp_servers.{_table_key(key)}"
|
|
107
|
+
if pinned:
|
|
108
|
+
name, _, value = pinned.partition("=")
|
|
109
|
+
flags += ["--config", f"{prefix}.{name}={value}"]
|
|
110
|
+
flags += ["--config", f"{prefix}.enabled=false"]
|
|
91
111
|
for feature in _MCP_FEATURES:
|
|
92
112
|
flags += ["--config", f"features.{feature}=false"]
|
|
93
113
|
return flags
|
|
@@ -34,6 +34,7 @@ from .events import (
|
|
|
34
34
|
parse_frame,
|
|
35
35
|
summarize_item,
|
|
36
36
|
)
|
|
37
|
+
from .failures import classify_failure
|
|
37
38
|
from .skills import skill_reads
|
|
38
39
|
|
|
39
40
|
if TYPE_CHECKING:
|
|
@@ -96,6 +97,7 @@ class CodexStreamParser:
|
|
|
96
97
|
self._tokens = TokenUsage()
|
|
97
98
|
self._usage = ProviderUsage(provider=PROVIDER_NAME)
|
|
98
99
|
self._error: str | None = None
|
|
100
|
+
self._stderr: list[str] = []
|
|
99
101
|
|
|
100
102
|
def feed_stdout(self, line: str) -> None:
|
|
101
103
|
"""Parse one stdout line, emitting whatever events it carries."""
|
|
@@ -114,6 +116,7 @@ class CodexStreamParser:
|
|
|
114
116
|
"""Emit one stderr line as a ``Stderr`` event."""
|
|
115
117
|
stripped = line.rstrip("\n")
|
|
116
118
|
if stripped:
|
|
119
|
+
self._stderr.append(stripped)
|
|
117
120
|
self._emit(Stderr(stripped))
|
|
118
121
|
|
|
119
122
|
def finish(self) -> ParsedTurn:
|
|
@@ -125,6 +128,11 @@ class CodexStreamParser:
|
|
|
125
128
|
usage=self._usage,
|
|
126
129
|
cost_usd=None,
|
|
127
130
|
error=self._error,
|
|
131
|
+
# The last error event is the one that ended the turn; a CLI that
|
|
132
|
+
# died before writing one left its reason on stderr.
|
|
133
|
+
error_kind=classify_failure(
|
|
134
|
+
self._error if self._error is not None else "\n".join(self._stderr)
|
|
135
|
+
),
|
|
128
136
|
)
|
|
129
137
|
|
|
130
138
|
def _handle(self, frame: CodexFrame) -> None:
|
|
@@ -202,7 +202,12 @@ class CodexProvider:
|
|
|
202
202
|
if session_id is None:
|
|
203
203
|
return error
|
|
204
204
|
return SessionResumeError(
|
|
205
|
-
error.argv,
|
|
205
|
+
error.argv,
|
|
206
|
+
error.returncode,
|
|
207
|
+
session_id,
|
|
208
|
+
error.stdout,
|
|
209
|
+
error.stderr,
|
|
210
|
+
detail=error.detail,
|
|
206
211
|
)
|
|
207
212
|
|
|
208
213
|
|
|
@@ -334,6 +339,10 @@ _CONFIG_OVERRIDE_RE = re.compile(r"^mcp_servers\.([^.=]+)\.(.+)$")
|
|
|
334
339
|
_TOML_STRING_RE = re.compile(r'"((?:[^"\\]|\\.)*)"')
|
|
335
340
|
|
|
336
341
|
|
|
342
|
+
_DISABLED = "\0disabled"
|
|
343
|
+
"""Scratch key marking an entry an ``enabled=false`` override switched off."""
|
|
344
|
+
|
|
345
|
+
|
|
337
346
|
def parse_mcp_servers(argv: Sequence[str]) -> dict[str, dict[str, Any]]:
|
|
338
347
|
"""Recover the MCP servers rendered into *argv* by ``_server_flags``.
|
|
339
348
|
|
|
@@ -355,9 +364,14 @@ def parse_mcp_servers(argv: Sequence[str]) -> dict[str, dict[str, Any]]:
|
|
|
355
364
|
servers: dict[str, dict[str, Any]] = {}
|
|
356
365
|
for key, field, raw_value in _mcp_config_overrides(argv):
|
|
357
366
|
_apply_mcp_override(servers.setdefault(key, {}), field, raw_value)
|
|
358
|
-
# ``McpScope.SESSION`` also emits
|
|
359
|
-
#
|
|
360
|
-
servers
|
|
367
|
+
# ``McpScope.SESSION`` also emits overrides that disable the servers the
|
|
368
|
+
# user configured (their transport plus ``enabled=false``); those are not
|
|
369
|
+
# servers the turn was given, so they are not reported.
|
|
370
|
+
servers = {
|
|
371
|
+
key: entry
|
|
372
|
+
for key, entry in servers.items()
|
|
373
|
+
if not entry.pop(_DISABLED, False) and ("command" in entry or "url" in entry)
|
|
374
|
+
}
|
|
361
375
|
for entry in servers.values():
|
|
362
376
|
if "url" not in entry:
|
|
363
377
|
entry.setdefault("args", [])
|
|
@@ -385,6 +399,8 @@ def _apply_mcp_override(entry: dict[str, Any], field: str, raw_value: str) -> No
|
|
|
385
399
|
elif field == "url":
|
|
386
400
|
entry["url"] = _parsetoml_str(raw_value)
|
|
387
401
|
entry["transport"] = "http"
|
|
402
|
+
elif field == "enabled":
|
|
403
|
+
entry[_DISABLED] = raw_value.strip() == "false"
|
|
388
404
|
elif field == "tool_timeout_sec":
|
|
389
405
|
entry["tool_timeout_s"] = float(raw_value)
|
|
390
406
|
elif field == "startup_timeout_sec":
|
|
@@ -10,6 +10,8 @@ from __future__ import annotations
|
|
|
10
10
|
import json
|
|
11
11
|
from typing import TYPE_CHECKING, Any
|
|
12
12
|
|
|
13
|
+
from agentshim.core.errors import FailureKind
|
|
14
|
+
|
|
13
15
|
if TYPE_CHECKING:
|
|
14
16
|
from collections.abc import Mapping, Sequence
|
|
15
17
|
|
|
@@ -120,6 +122,43 @@ def resume_failure_lines(*, session_id: str | None = None) -> tuple[list[str], l
|
|
|
120
122
|
return [], stderr, 1
|
|
121
123
|
|
|
122
124
|
|
|
125
|
+
#: The message Codex reports for a failed turn of each kind.
|
|
126
|
+
_FAILURES: dict[FailureKind, str] = {
|
|
127
|
+
FailureKind.TRANSIENT: "exceeded retry limit, last status: 500 Internal Server Error",
|
|
128
|
+
FailureKind.USAGE_LIMIT: (
|
|
129
|
+
"You've hit your usage limit. Upgrade to Plus to continue using Codex "
|
|
130
|
+
"(https://chatgpt.com/explore/plus), or try again in 2 hours."
|
|
131
|
+
),
|
|
132
|
+
FailureKind.AUTH: "unexpected status 401 Unauthorized: Missing bearer authentication",
|
|
133
|
+
FailureKind.OTHER: "model response did not match the output schema",
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def failure_lines(
|
|
138
|
+
kind: FailureKind, *, session_id: str | None = None
|
|
139
|
+
) -> tuple[list[str], list[str], int]:
|
|
140
|
+
"""Build the stdout, stderr and exit code of a turn that fails with *kind*.
|
|
141
|
+
|
|
142
|
+
Codex reports the failure as an ``error`` event followed by
|
|
143
|
+
``turn.failed``, both carrying the same message.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
kind: The classification the scripted failure must produce.
|
|
147
|
+
session_id: Thread id ``thread.started`` names, if any.
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
``(stdout, stderr, returncode)`` for a ``FakeRun``.
|
|
151
|
+
"""
|
|
152
|
+
message = _FAILURES[kind]
|
|
153
|
+
lines: list[str] = []
|
|
154
|
+
if session_id is not None:
|
|
155
|
+
lines.append(_line({"type": "thread.started", "thread_id": session_id}))
|
|
156
|
+
lines.append(_line({"type": "turn.started"}))
|
|
157
|
+
lines.append(_line({"type": "error", "message": message}))
|
|
158
|
+
lines.append(_line({"type": "turn.failed", "error": {"message": message}}))
|
|
159
|
+
return lines, [], 1
|
|
160
|
+
|
|
161
|
+
|
|
123
162
|
def _usage_payload(usage: TokenUsage | None) -> dict[str, int]:
|
|
124
163
|
if usage is None:
|
|
125
164
|
return {
|
|
@@ -133,7 +133,12 @@ class GeminiProvider:
|
|
|
133
133
|
session_id = _resumed_session_id(error.argv, "--resume")
|
|
134
134
|
if session_id is not None:
|
|
135
135
|
return SessionResumeError(
|
|
136
|
-
error.argv,
|
|
136
|
+
error.argv,
|
|
137
|
+
error.returncode,
|
|
138
|
+
session_id,
|
|
139
|
+
error.stdout,
|
|
140
|
+
error.stderr,
|
|
141
|
+
detail=error.detail,
|
|
137
142
|
)
|
|
138
143
|
return error
|
|
139
144
|
|
|
@@ -136,7 +136,12 @@ class OpencodeProvider:
|
|
|
136
136
|
session_id = _resumed_session_id(error.argv, "--session")
|
|
137
137
|
if session_id is not None:
|
|
138
138
|
return SessionResumeError(
|
|
139
|
-
error.argv,
|
|
139
|
+
error.argv,
|
|
140
|
+
error.returncode,
|
|
141
|
+
session_id,
|
|
142
|
+
error.stdout,
|
|
143
|
+
error.stderr,
|
|
144
|
+
detail=error.detail,
|
|
140
145
|
)
|
|
141
146
|
return error
|
|
142
147
|
|
|
@@ -14,7 +14,12 @@ from agentshim.core.errors import CliNotFoundError, CliTimeoutError
|
|
|
14
14
|
from agentshim.core.profile import McpMechanism
|
|
15
15
|
from agentshim.core.usage import TokenUsage
|
|
16
16
|
from agentshim.execution.executor import CommandResult
|
|
17
|
-
from agentshim.providers import
|
|
17
|
+
from agentshim.providers import (
|
|
18
|
+
get_failure_lines,
|
|
19
|
+
get_provider,
|
|
20
|
+
get_resume_failure_lines,
|
|
21
|
+
get_scripted_lines,
|
|
22
|
+
)
|
|
18
23
|
from agentshim.providers.claude import provider as _claude
|
|
19
24
|
from agentshim.providers.codex.provider import parse_mcp_servers as _parse_codex_mcp_servers
|
|
20
25
|
from agentshim.providers.copilot.provider import parse_mcp_servers as _parse_copilot_mcp_servers
|
|
@@ -25,6 +30,7 @@ if TYPE_CHECKING:
|
|
|
25
30
|
from collections.abc import Callable, Mapping, Sequence
|
|
26
31
|
from pathlib import Path
|
|
27
32
|
|
|
33
|
+
from agentshim.core.errors import FailureKind
|
|
28
34
|
from agentshim.core.events import AgentEvent
|
|
29
35
|
from agentshim.execution.executor import CommandRequest, CommandStreamSink
|
|
30
36
|
|
|
@@ -212,6 +218,28 @@ def scripted_resume_failure(provider: str, *, session_id: str | None = None) ->
|
|
|
212
218
|
return FakeRun(stdout=list(stdout), stderr=list(stderr), returncode=returncode)
|
|
213
219
|
|
|
214
220
|
|
|
221
|
+
def scripted_failure(provider: str, kind: FailureKind, *, session_id: str | None = None) -> FakeRun:
|
|
222
|
+
"""Build a ``FakeRun`` for a turn that fails and classifies as *kind*.
|
|
223
|
+
|
|
224
|
+
The run is *provider*'s real report of such a failure (an overload, a
|
|
225
|
+
usage limit, a rejected login, or an unclassifiable failure), so the
|
|
226
|
+
raised ``CliExitError`` carries ``kind`` through the real parser. A
|
|
227
|
+
consumer can therefore test its retry policy without knowing how any
|
|
228
|
+
CLI phrases a failure.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
provider: A provider whose stream reports failure kinds (``claude``
|
|
232
|
+
or ``codex``); any other raises ``ValueError``.
|
|
233
|
+
kind: The ``FailureKind`` the raised error must carry.
|
|
234
|
+
session_id: The conversation the failed run names, if any.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
A ``FakeRun`` with a nonzero exit code.
|
|
238
|
+
"""
|
|
239
|
+
stdout, stderr, returncode = get_failure_lines(provider)(kind, session_id=session_id)
|
|
240
|
+
return FakeRun(stdout=list(stdout), stderr=list(stderr), returncode=returncode)
|
|
241
|
+
|
|
242
|
+
|
|
215
243
|
def installed_mcp_servers(
|
|
216
244
|
provider: str, request: CommandRequest, workspace: Path
|
|
217
245
|
) -> dict[str, dict[str, Any]]:
|
|
@@ -325,6 +353,7 @@ __all__ = [
|
|
|
325
353
|
"RecordingEventHandler",
|
|
326
354
|
"TokenUsage",
|
|
327
355
|
"installed_mcp_servers",
|
|
356
|
+
"scripted_failure",
|
|
328
357
|
"scripted_resume_failure",
|
|
329
358
|
"scripted_turn",
|
|
330
359
|
]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|