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.
Files changed (64) hide show
  1. {agentshim-0.10.0 → agentshim-0.11.0}/CHANGELOG.md +39 -0
  2. {agentshim-0.10.0 → agentshim-0.11.0}/PKG-INFO +10 -2
  3. {agentshim-0.10.0 → agentshim-0.11.0}/README.md +9 -1
  4. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/__init__.py +3 -1
  5. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/agent.py +8 -1
  6. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/__init__.py +2 -0
  7. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/errors.py +49 -7
  8. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/provider.py +4 -0
  9. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/__init__.py +36 -0
  10. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/claude/__init__.py +2 -1
  11. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/claude/events.py +13 -2
  12. agentshim-0.11.0/agentshim/providers/claude/failures.py +74 -0
  13. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/claude/parser.py +36 -1
  14. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/claude/provider.py +13 -6
  15. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/claude/scripted.py +52 -0
  16. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/codex/__init__.py +2 -1
  17. agentshim-0.11.0/agentshim/providers/codex/failures.py +43 -0
  18. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/codex/mcp_scope.py +36 -16
  19. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/codex/parser.py +8 -0
  20. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/codex/provider.py +20 -4
  21. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/codex/scripted.py +39 -0
  22. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/gemini/provider.py +6 -1
  23. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/opencode/provider.py +6 -1
  24. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/testing/__init__.py +30 -1
  25. {agentshim-0.10.0 → agentshim-0.11.0}/pyproject.toml +1 -1
  26. {agentshim-0.10.0 → agentshim-0.11.0}/.gitignore +0 -0
  27. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/_files.py +0 -0
  28. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/env.py +0 -0
  29. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/events.py +0 -0
  30. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/mcp.py +0 -0
  31. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/pricing.py +0 -0
  32. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/profile.py +0 -0
  33. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/schema.py +0 -0
  34. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/skills.py +0 -0
  35. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/stream.py +0 -0
  36. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/turn.py +0 -0
  37. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/core/usage.py +0 -0
  38. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/execution/__init__.py +0 -0
  39. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/execution/executor.py +0 -0
  40. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/execution/host.py +0 -0
  41. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/execution/transform.py +0 -0
  42. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/claude/hooks/__init__.py +0 -0
  43. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
  44. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/claude/sandbox.py +0 -0
  45. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/claude/user_hooks.py +0 -0
  46. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/codex/_toml.py +0 -0
  47. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/codex/events.py +0 -0
  48. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/codex/rules.py +0 -0
  49. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/codex/sandbox.py +0 -0
  50. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/codex/skills.py +0 -0
  51. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/copilot/__init__.py +0 -0
  52. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/copilot/events.py +0 -0
  53. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/copilot/parser.py +0 -0
  54. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/copilot/provider.py +0 -0
  55. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/copilot/scripted.py +0 -0
  56. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/gemini/__init__.py +0 -0
  57. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/gemini/events.py +0 -0
  58. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/gemini/parser.py +0 -0
  59. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/gemini/scripted.py +0 -0
  60. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/opencode/__init__.py +0 -0
  61. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/opencode/events.py +0 -0
  62. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/opencode/parser.py +0 -0
  63. {agentshim-0.10.0 → agentshim-0.11.0}/agentshim/providers/opencode/scripted.py +0 -0
  64. {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.10.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.10.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(argv, result.returncode, result.stdout, result.stderr),
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. Passing ``message`` replaces the
61
- generated summary.
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__(message or f"{binary} exited with code {returncode}: {stderr.strip()}")
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=f"cannot resume session {session_id!r} (exit code {returncode}): {stderr.strip()}",
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
- if not blocks and usage is None:
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._error = frame.text or (frame.subtype or "claude reported an error")
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
- """Any nonzero exit on a resumed turn means the conversation is gone.
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, and a resumed turn that fails is unusable either way:
185
- the caller has to start a fresh conversation.
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, error.returncode, session_id, error.stdout, error.stderr
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, Iterable, Mapping
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 configured_server_keys(env: Mapping[str, str], cwd: str | None) -> list[str]:
36
- """Every ``mcp_servers`` key the config files Codex would read define.
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 scan runs where agentshim runs, so a CLI in a container is only
42
- covered when the container shares these files.
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
- keys: list[str] = []
53
+ servers: dict[str, str] = {}
50
54
  for path in paths:
51
- keys += _server_keys(path)
52
- return list(dict.fromkeys(keys))
55
+ servers.update(_servers(path))
56
+ return servers
53
57
 
54
58
 
55
- def _server_keys(path: str) -> Iterable[str]:
59
+ def _servers(path: str) -> dict[str, str]:
56
60
  config = _read_toml(path)
57
- servers: object = config.get("mcp_servers")
58
- if not isinstance(servers, dict):
59
- return []
60
- return [str(key) for key in cast("dict[object, object]", servers)]
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 configured_server_keys(env, cwd):
89
- if key not in given_keys:
90
- flags += ["--config", f"mcp_servers.{_table_key(key)}.enabled=false"]
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, error.returncode, session_id, error.stdout, error.stderr
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 ``enabled=false`` overrides for servers
359
- # the user configured; those define no server, so they are not reported.
360
- servers = {key: entry for key, entry in servers.items() if "command" in entry or "url" in entry}
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, error.returncode, session_id, error.stdout, error.stderr
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, error.returncode, session_id, error.stdout, error.stderr
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 get_provider, get_resume_failure_lines, get_scripted_lines
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
  ]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentshim"
3
- version = "0.10.0"
3
+ version = "0.11.0"
4
4
  description = "Provider-agnostic coding-agent CLI shims"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
File without changes