agentshim 0.11.0__tar.gz → 0.13.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.11.0 → agentshim-0.13.0}/CHANGELOG.md +42 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/PKG-INFO +6 -3
- {agentshim-0.11.0 → agentshim-0.13.0}/README.md +5 -2
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/__init__.py +5 -1
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/agent.py +24 -5
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/core/__init__.py +4 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/core/errors.py +4 -0
- agentshim-0.13.0/agentshim/core/home.py +90 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/core/profile.py +27 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/core/provider.py +4 -1
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/claude/failures.py +17 -3
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/claude/parser.py +30 -11
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/claude/provider.py +14 -1
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/claude/scripted.py +35 -10
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/codex/provider.py +36 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/codex/scripted.py +13 -3
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/testing/__init__.py +5 -2
- {agentshim-0.11.0 → agentshim-0.13.0}/pyproject.toml +1 -1
- {agentshim-0.11.0 → agentshim-0.13.0}/.gitignore +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/core/_files.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/core/env.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/core/events.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/core/mcp.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/core/pricing.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/core/schema.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/core/skills.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/core/stream.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/core/turn.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/core/usage.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/execution/__init__.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/execution/executor.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/execution/host.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/execution/transform.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/__init__.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/claude/__init__.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/claude/events.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/claude/hooks/__init__.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/claude/sandbox.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/claude/user_hooks.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/codex/__init__.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/codex/_toml.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/codex/events.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/codex/failures.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/codex/mcp_scope.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/codex/parser.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/codex/rules.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/codex/sandbox.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/codex/skills.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/copilot/__init__.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/copilot/events.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/copilot/parser.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/copilot/provider.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/copilot/scripted.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/gemini/__init__.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/gemini/events.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/gemini/parser.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/gemini/provider.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/gemini/scripted.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/opencode/__init__.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/opencode/events.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/opencode/parser.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/opencode/provider.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/providers/opencode/scripted.py +0 -0
- {agentshim-0.11.0 → agentshim-0.13.0}/agentshim/py.typed +0 -0
|
@@ -1,5 +1,47 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.0 (2026-10-03)
|
|
4
|
+
|
|
5
|
+
A session can leave out the user's own CLI configuration. Additive.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- `ConfigScope` (`ALL`, `PROJECT`) as a session option on `start_session` and
|
|
10
|
+
`run`, `ProviderProfile.config_scopes`, and `ArgvContext.config_scope`.
|
|
11
|
+
`PROJECT` keeps the user's settings, hooks, global instructions, notify
|
|
12
|
+
commands, profiles and memory out of every turn. Claude Code:
|
|
13
|
+
`--setting-sources project,local` and `autoMemoryEnabled=false`. Codex: a
|
|
14
|
+
dedicated `CODEX_HOME` plus `--ignore-user-config --disable memories`;
|
|
15
|
+
`build_argv` refuses the scope in the user's own home. Copilot, Gemini and
|
|
16
|
+
opencode refuse it.
|
|
17
|
+
- `ProviderProfile.config_home_files` and `prepare_config_home`, which build
|
|
18
|
+
that dedicated home holding only the login and return the environment that
|
|
19
|
+
points the CLI at it. The login is a symlink to the user's own `auth.json`,
|
|
20
|
+
so a refresh token Codex rotates in either home stays valid in both.
|
|
21
|
+
|
|
22
|
+
## 0.12.0 (2026-10-03)
|
|
23
|
+
|
|
24
|
+
A turn whose output never matched its schema says so. Additive except that
|
|
25
|
+
Claude's `error_max_structured_output_retries` is no longer `OTHER`.
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- `FailureKind.SCHEMA`: the provider gave up producing output that matches the
|
|
30
|
+
requested schema. Claude Code reports it for the
|
|
31
|
+
`error_max_structured_output_retries` result, with the validation errors of
|
|
32
|
+
the last rejected `StructuredOutput` call on `CliExitError.detail`. A resumed
|
|
33
|
+
turn that fails this way keeps its conversation. Codex never reports it:
|
|
34
|
+
`--output-schema` constrains decoding, so its output always parses against
|
|
35
|
+
the schema.
|
|
36
|
+
- `scripted_failure("claude", FailureKind.SCHEMA)`.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- `scripted_failure(provider, FailureKind.OTHER)` scripts a failure that is
|
|
41
|
+
not about the schema (Claude `error_during_execution`, Codex running out of
|
|
42
|
+
context). `scripted_failure("codex", FailureKind.SCHEMA)` raises
|
|
43
|
+
`ValueError`.
|
|
44
|
+
|
|
3
45
|
## 0.11.0 (2026-10-03)
|
|
4
46
|
|
|
5
47
|
A failed turn says why it failed. Additive except that a classified failure of
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agentshim
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.13.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'
|
|
@@ -200,8 +200,11 @@ AgentShimError
|
|
|
200
200
|
`CliExitError.kind` says why a turn failed, so a caller can pick a policy
|
|
201
201
|
without knowing how any CLI words its errors: `TRANSIENT` (an overload, rate
|
|
202
202
|
limit or server error that waiting may outlast), `USAGE_LIMIT` (a quota or
|
|
203
|
-
billing limit), `AUTH` (credentials),
|
|
204
|
-
|
|
203
|
+
billing limit), `AUTH` (credentials), `SCHEMA` (the provider gave up producing
|
|
204
|
+
output that matches the requested schema; `.detail` holds the last validation
|
|
205
|
+
errors and the conversation survives, so a correction can follow), or `OTHER`.
|
|
206
|
+
Claude Code and Codex classify their failures; the other providers report
|
|
207
|
+
`OTHER`. Codex never reports `SCHEMA`: it constrains decoding to the schema.
|
|
205
208
|
`agentshim.testing.scripted_failure(provider, kind)` scripts each one.
|
|
206
209
|
|
|
207
210
|
## Adding a provider
|
|
@@ -190,8 +190,11 @@ AgentShimError
|
|
|
190
190
|
`CliExitError.kind` says why a turn failed, so a caller can pick a policy
|
|
191
191
|
without knowing how any CLI words its errors: `TRANSIENT` (an overload, rate
|
|
192
192
|
limit or server error that waiting may outlast), `USAGE_LIMIT` (a quota or
|
|
193
|
-
billing limit), `AUTH` (credentials),
|
|
194
|
-
|
|
193
|
+
billing limit), `AUTH` (credentials), `SCHEMA` (the provider gave up producing
|
|
194
|
+
output that matches the requested schema; `.detail` holds the last validation
|
|
195
|
+
errors and the conversation survives, so a correction can follow), or `OTHER`.
|
|
196
|
+
Claude Code and Codex classify their failures; the other providers report
|
|
197
|
+
`OTHER`. Codex never reports `SCHEMA`: it constrains decoding to the schema.
|
|
195
198
|
`agentshim.testing.scripted_failure(provider, kind)` scripts each one.
|
|
196
199
|
|
|
197
200
|
## Adding a provider
|
|
@@ -19,6 +19,7 @@ from .core import (
|
|
|
19
19
|
CliTimeoutError,
|
|
20
20
|
CompositeEventHandler,
|
|
21
21
|
ConfigFileInstallation,
|
|
22
|
+
ConfigScope,
|
|
22
23
|
ConsoleEventHandler,
|
|
23
24
|
EventHandlerBase,
|
|
24
25
|
FailureKind,
|
|
@@ -79,6 +80,7 @@ from .core import (
|
|
|
79
80
|
normalize,
|
|
80
81
|
normalized_usage,
|
|
81
82
|
parse_json_object,
|
|
83
|
+
prepare_config_home,
|
|
82
84
|
price_for,
|
|
83
85
|
)
|
|
84
86
|
from .execution import (
|
|
@@ -99,7 +101,7 @@ from .providers.copilot import CopilotProvider
|
|
|
99
101
|
from .providers.gemini import GeminiProvider
|
|
100
102
|
from .providers.opencode import OpencodeProvider
|
|
101
103
|
|
|
102
|
-
__version__ = "0.
|
|
104
|
+
__version__ = "0.13.0"
|
|
103
105
|
|
|
104
106
|
__all__ = [
|
|
105
107
|
"AgentEvent",
|
|
@@ -125,6 +127,7 @@ __all__ = [
|
|
|
125
127
|
"CommandStreamSink",
|
|
126
128
|
"CompositeEventHandler",
|
|
127
129
|
"ConfigFileInstallation",
|
|
130
|
+
"ConfigScope",
|
|
128
131
|
"ConsoleEventHandler",
|
|
129
132
|
"CopilotProvider",
|
|
130
133
|
"EventHandlerBase",
|
|
@@ -194,6 +197,7 @@ __all__ = [
|
|
|
194
197
|
"normalize",
|
|
195
198
|
"normalized_usage",
|
|
196
199
|
"parse_json_object",
|
|
200
|
+
"prepare_config_home",
|
|
197
201
|
"price_for",
|
|
198
202
|
"provider_names",
|
|
199
203
|
]
|
|
@@ -24,6 +24,7 @@ from agentshim.core.errors import (
|
|
|
24
24
|
)
|
|
25
25
|
from agentshim.core.events import RunFinished, RunStarted, compose_event_handlers
|
|
26
26
|
from agentshim.core.profile import (
|
|
27
|
+
ConfigScope,
|
|
27
28
|
McpMechanism,
|
|
28
29
|
McpScope,
|
|
29
30
|
OutputSchemaStyle,
|
|
@@ -114,7 +115,7 @@ class CliAgent:
|
|
|
114
115
|
self.executor.check_binary(self.binary_path, self.env, timeout=check_timeout)
|
|
115
116
|
self.log(f"{self.profile.display_name} ready at {self.binary_path}")
|
|
116
117
|
|
|
117
|
-
def start_session(
|
|
118
|
+
def start_session( # noqa: PLR0913 - one keyword per independent session option
|
|
118
119
|
self,
|
|
119
120
|
*,
|
|
120
121
|
cwd: str | None = None,
|
|
@@ -122,11 +123,12 @@ class CliAgent:
|
|
|
122
123
|
session_id: str | None = None,
|
|
123
124
|
skill_scope: SkillScope = SkillScope.ALL,
|
|
124
125
|
mcp_scope: McpScope = McpScope.ALL,
|
|
126
|
+
config_scope: ConfigScope = ConfigScope.ALL,
|
|
125
127
|
) -> AgentSession:
|
|
126
128
|
"""Open a conversation whose turns resume one another.
|
|
127
129
|
|
|
128
|
-
``skill_scope`` and ``
|
|
129
|
-
``AgentSession``).
|
|
130
|
+
``skill_scope``, ``mcp_scope`` and ``config_scope`` are fixed for the
|
|
131
|
+
conversation (see ``AgentSession``).
|
|
130
132
|
"""
|
|
131
133
|
return AgentSession(
|
|
132
134
|
self,
|
|
@@ -135,9 +137,10 @@ class CliAgent:
|
|
|
135
137
|
session_id=session_id,
|
|
136
138
|
skill_scope=skill_scope,
|
|
137
139
|
mcp_scope=mcp_scope,
|
|
140
|
+
config_scope=config_scope,
|
|
138
141
|
)
|
|
139
142
|
|
|
140
|
-
def run(
|
|
143
|
+
def run( # noqa: PLR0913 - one keyword per independent session option
|
|
141
144
|
self,
|
|
142
145
|
request: TurnRequest | str,
|
|
143
146
|
*,
|
|
@@ -145,10 +148,15 @@ class CliAgent:
|
|
|
145
148
|
timeout: float | None = None,
|
|
146
149
|
skill_scope: SkillScope = SkillScope.ALL,
|
|
147
150
|
mcp_scope: McpScope = McpScope.ALL,
|
|
151
|
+
config_scope: ConfigScope = ConfigScope.ALL,
|
|
148
152
|
) -> TurnResult:
|
|
149
153
|
"""Run one turn in a throwaway session."""
|
|
150
154
|
session = self.start_session(
|
|
151
|
-
cwd=cwd,
|
|
155
|
+
cwd=cwd,
|
|
156
|
+
timeout=timeout,
|
|
157
|
+
skill_scope=skill_scope,
|
|
158
|
+
mcp_scope=mcp_scope,
|
|
159
|
+
config_scope=config_scope,
|
|
152
160
|
)
|
|
153
161
|
return session.turn(request)
|
|
154
162
|
|
|
@@ -169,6 +177,7 @@ class AgentSession:
|
|
|
169
177
|
session_id: str | None = None,
|
|
170
178
|
skill_scope: SkillScope = SkillScope.ALL,
|
|
171
179
|
mcp_scope: McpScope = McpScope.ALL,
|
|
180
|
+
config_scope: ConfigScope = ConfigScope.ALL,
|
|
172
181
|
) -> None:
|
|
173
182
|
"""Bind a conversation to one agent, with per-turn defaults.
|
|
174
183
|
|
|
@@ -183,6 +192,11 @@ class AgentSession:
|
|
|
183
192
|
limits which MCP servers every turn's CLI may connect to
|
|
184
193
|
(``McpScope.SESSION``: only those in ``TurnRequest.mcp_servers``) and
|
|
185
194
|
is checked against ``profile.mcp_scopes`` the same way.
|
|
195
|
+
``config_scope`` limits which of the user's own CLI configuration
|
|
196
|
+
(settings, hooks, global instructions, memory) every turn loads, and
|
|
197
|
+
is checked against ``profile.config_scopes``; a provider with
|
|
198
|
+
``profile.config_home_files`` additionally needs its state root
|
|
199
|
+
pointed at a home from ``prepare_config_home``.
|
|
186
200
|
"""
|
|
187
201
|
if skill_scope not in agent.profile.skill_scopes:
|
|
188
202
|
msg = f"{agent.profile.name} cannot limit skills to scope {skill_scope.value!r}"
|
|
@@ -190,7 +204,11 @@ class AgentSession:
|
|
|
190
204
|
if mcp_scope not in agent.profile.mcp_scopes:
|
|
191
205
|
msg = f"{agent.profile.name} cannot limit MCP servers to scope {mcp_scope.value!r}"
|
|
192
206
|
raise ProviderCapabilityError(msg)
|
|
207
|
+
if config_scope not in agent.profile.config_scopes:
|
|
208
|
+
msg = f"{agent.profile.name} cannot limit user configuration to scope {config_scope.value!r}"
|
|
209
|
+
raise ProviderCapabilityError(msg)
|
|
193
210
|
self.skill_scope = skill_scope
|
|
211
|
+
self.config_scope = config_scope
|
|
194
212
|
self.mcp_scope = mcp_scope
|
|
195
213
|
self._agent = agent
|
|
196
214
|
self._cwd = cwd
|
|
@@ -302,6 +320,7 @@ class AgentSession:
|
|
|
302
320
|
cwd=cwd,
|
|
303
321
|
skill_scope=self.skill_scope,
|
|
304
322
|
mcp_scope=self.mcp_scope,
|
|
323
|
+
config_scope=self.config_scope,
|
|
305
324
|
mcp_servers=tuple(req.mcp_servers),
|
|
306
325
|
)
|
|
307
326
|
)
|
|
@@ -43,6 +43,7 @@ from .events import (
|
|
|
43
43
|
UsageReport,
|
|
44
44
|
compose_event_handlers,
|
|
45
45
|
)
|
|
46
|
+
from .home import prepare_config_home
|
|
46
47
|
from .mcp import (
|
|
47
48
|
ConfigFileInstallation,
|
|
48
49
|
FlagsInstallation,
|
|
@@ -55,6 +56,7 @@ from .mcp import (
|
|
|
55
56
|
)
|
|
56
57
|
from .pricing import ModelPricing, PricingTable, cost_usd, default_pricing, price_for
|
|
57
58
|
from .profile import (
|
|
59
|
+
ConfigScope,
|
|
58
60
|
McpMechanism,
|
|
59
61
|
McpScope,
|
|
60
62
|
OutputSchemaStyle,
|
|
@@ -82,6 +84,7 @@ __all__ = [
|
|
|
82
84
|
"CliTimeoutError",
|
|
83
85
|
"CompositeEventHandler",
|
|
84
86
|
"ConfigFileInstallation",
|
|
87
|
+
"ConfigScope",
|
|
85
88
|
"ConsoleEventHandler",
|
|
86
89
|
"EventHandlerBase",
|
|
87
90
|
"FailureKind",
|
|
@@ -142,5 +145,6 @@ __all__ = [
|
|
|
142
145
|
"normalize",
|
|
143
146
|
"normalized_usage",
|
|
144
147
|
"parse_json_object",
|
|
148
|
+
"prepare_config_home",
|
|
145
149
|
"price_for",
|
|
146
150
|
]
|
|
@@ -29,6 +29,10 @@ class FailureKind(str, Enum):
|
|
|
29
29
|
USAGE_LIMIT = "usage_limit"
|
|
30
30
|
#: Missing, expired, or rejected credentials.
|
|
31
31
|
AUTH = "auth"
|
|
32
|
+
#: The provider gave up producing output that matches the requested output
|
|
33
|
+
#: schema. ``detail`` carries the last validation errors it reported, and
|
|
34
|
+
#: the conversation survives: a correction can be sent as the next turn.
|
|
35
|
+
SCHEMA = "schema"
|
|
32
36
|
#: Anything else, including every failure a provider cannot classify.
|
|
33
37
|
OTHER = "other"
|
|
34
38
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Dedicated CLI homes for ``ConfigScope.PROJECT``.
|
|
2
|
+
|
|
3
|
+
Some CLIs read the user's global instructions, hooks and memory from their
|
|
4
|
+
state root (Codex: ``$CODEX_HOME/AGENTS.md``, ``hooks.json``, ``memories/``)
|
|
5
|
+
with no flag to skip them. The only way to keep them out is to run the CLI
|
|
6
|
+
against another state root that holds nothing but the credentials. That root
|
|
7
|
+
is the caller's to own, because the CLI keeps its conversations there too: a
|
|
8
|
+
session resumes only from the home it started in.
|
|
9
|
+
|
|
10
|
+
The credentials are linked, not copied. Codex rotates its OAuth refresh token
|
|
11
|
+
on refresh (the server rejects a reused one with ``refresh_token_reused``) and
|
|
12
|
+
saves ``auth.json`` by opening it with ``O_TRUNC`` and writing in place
|
|
13
|
+
(codex-rs ``login/src/auth/storage.rs``, ``FileAuthStorage::save``), which
|
|
14
|
+
follows a symlink. A copy would strand one of the two logins after the first
|
|
15
|
+
refresh; a link keeps a single file that every process rereads before it
|
|
16
|
+
refreshes (``AuthManager::refresh_token``'s guarded reload).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
from ._files import real_path
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import Mapping
|
|
29
|
+
|
|
30
|
+
from .profile import ProviderProfile
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def state_root(profile: ProviderProfile, env: Mapping[str, str]) -> Path | None:
|
|
34
|
+
"""The state root the CLI run with *env* reads, or ``None`` if it has none.
|
|
35
|
+
|
|
36
|
+
``$<state_root_env>`` when set, else ``$HOME/<state_dirs[0]>``.
|
|
37
|
+
"""
|
|
38
|
+
if profile.state_root_env and env.get(profile.state_root_env):
|
|
39
|
+
return Path(env[profile.state_root_env]).expanduser()
|
|
40
|
+
if not profile.state_dirs:
|
|
41
|
+
return None
|
|
42
|
+
home = env.get("HOME") or os.path.expanduser("~") # noqa: PTH111 - a str contract, not a Path
|
|
43
|
+
return Path(home) / profile.state_dirs[0]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def prepare_config_home(
|
|
47
|
+
profile: ProviderProfile, home: Path, env: Mapping[str, str]
|
|
48
|
+
) -> dict[str, str]:
|
|
49
|
+
"""Make *home* a state root holding only the credentials, and point at it.
|
|
50
|
+
|
|
51
|
+
Links each of ``profile.config_home_files`` that exists in the state root
|
|
52
|
+
*env* selects into *home* as a symlink to that file's real path (replacing
|
|
53
|
+
whatever was there, such as a copy from an older agentshim), so a token
|
|
54
|
+
the CLI refreshes in either home is the one both read next. Returns the
|
|
55
|
+
environment overrides that make the CLI use *home*. A sandbox that runs
|
|
56
|
+
the CLI must expose the link's target read-write. Everything else in *home* (conversations, caches) is the
|
|
57
|
+
CLI's own and is left alone. A provider with no ``config_home_files``
|
|
58
|
+
isolates by flags alone: nothing is written and the result is empty.
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ValueError: *home* is not absolute, or is the state root *env*
|
|
62
|
+
already selects, which would isolate nothing.
|
|
63
|
+
"""
|
|
64
|
+
if not profile.config_home_files:
|
|
65
|
+
return {}
|
|
66
|
+
if not home.is_absolute():
|
|
67
|
+
msg = f"{profile.name} config home must be an absolute path, got {home}"
|
|
68
|
+
raise ValueError(msg)
|
|
69
|
+
assert profile.state_root_env is not None, "config_home_files needs a state_root_env" # noqa: S101 - profile invariant
|
|
70
|
+
source = state_root(profile, env)
|
|
71
|
+
if source is not None and real_path(source) == real_path(home):
|
|
72
|
+
msg = f"{profile.name} config home {home} is the user's own state root"
|
|
73
|
+
raise ValueError(msg)
|
|
74
|
+
home.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
for name in profile.config_home_files:
|
|
76
|
+
origin = None if source is None else source / name
|
|
77
|
+
if origin is not None and origin.is_file():
|
|
78
|
+
_link(home / name, real_path(origin))
|
|
79
|
+
return {profile.state_root_env: str(home)}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _link(link: Path, target: Path) -> None:
|
|
83
|
+
"""Point *link* at *target*, atomically replacing any file already there."""
|
|
84
|
+
if link.is_symlink() and link.readlink() == target:
|
|
85
|
+
return
|
|
86
|
+
link.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
staged = link.with_name(f".{link.name}.{os.getpid()}.link")
|
|
88
|
+
staged.unlink(missing_ok=True)
|
|
89
|
+
staged.symlink_to(target)
|
|
90
|
+
staged.replace(link)
|
|
@@ -93,6 +93,26 @@ class McpScope(Enum):
|
|
|
93
93
|
SESSION = "session"
|
|
94
94
|
|
|
95
95
|
|
|
96
|
+
class ConfigScope(Enum):
|
|
97
|
+
"""Which of the user's own CLI configuration a session loads.
|
|
98
|
+
|
|
99
|
+
``ALL``: whatever the CLI loads by default: the user's settings, hooks,
|
|
100
|
+
global instructions (``~/.claude/CLAUDE.md``, ``$CODEX_HOME/AGENTS.md``),
|
|
101
|
+
notify commands, profiles and memory. ``PROJECT``: none of that; the
|
|
102
|
+
session sees the workspace's own instructions and settings plus what
|
|
103
|
+
agentshim passes for the turn, so the user running agentshim does not
|
|
104
|
+
change what the agent does. Credentials keep working. A provider that
|
|
105
|
+
lists a ``ProviderProfile.config_home_files`` can only isolate through a
|
|
106
|
+
dedicated home: ``prepare_config_home`` builds it and returns the
|
|
107
|
+
environment that points the CLI at it. A provider declares the scopes it
|
|
108
|
+
can enforce in ``ProviderProfile.config_scopes``; asking for another one
|
|
109
|
+
is an error, never a silent ``ALL``.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
ALL = "all"
|
|
113
|
+
PROJECT = "project"
|
|
114
|
+
|
|
115
|
+
|
|
96
116
|
def _no_container_env() -> Mapping[str, str]:
|
|
97
117
|
"""Empty read-only default: a frozen spec must not carry a mutable one.
|
|
98
118
|
|
|
@@ -145,3 +165,10 @@ class ProviderProfile:
|
|
|
145
165
|
skill_scopes: frozenset[SkillScope] = frozenset({SkillScope.ALL})
|
|
146
166
|
#: The ``McpScope`` values a session on this provider may request.
|
|
147
167
|
mcp_scopes: frozenset[McpScope] = frozenset({McpScope.ALL})
|
|
168
|
+
#: The ``ConfigScope`` values a session on this provider may request.
|
|
169
|
+
config_scopes: frozenset[ConfigScope] = frozenset({ConfigScope.ALL})
|
|
170
|
+
#: Files, relative to the state root (``state_dirs[0]``, or
|
|
171
|
+
#: ``$<state_root_env>``), that ``prepare_config_home`` copies into a
|
|
172
|
+
#: dedicated home for ``ConfigScope.PROJECT``. Empty when the provider
|
|
173
|
+
#: isolates by flags alone and needs no home.
|
|
174
|
+
config_home_files: tuple[str, ...] = ()
|
|
@@ -6,7 +6,7 @@ from dataclasses import dataclass, field
|
|
|
6
6
|
from typing import TYPE_CHECKING, Any, Protocol
|
|
7
7
|
|
|
8
8
|
from .errors import FailureKind
|
|
9
|
-
from .profile import McpScope, SkillScope
|
|
9
|
+
from .profile import ConfigScope, McpScope, SkillScope
|
|
10
10
|
from .usage import ProviderUsage
|
|
11
11
|
|
|
12
12
|
if TYPE_CHECKING:
|
|
@@ -49,6 +49,9 @@ class ArgvContext:
|
|
|
49
49
|
#: A provider enforcing ``McpScope.SESSION`` renders them itself when its
|
|
50
50
|
#: installed config file is not enough to exclude the user's own.
|
|
51
51
|
mcp_servers: Sequence[McpServer] = ()
|
|
52
|
+
#: Which of the user's own CLI configuration the CLI may load; the
|
|
53
|
+
#: session has already checked that the provider's profile supports it.
|
|
54
|
+
config_scope: ConfigScope = ConfigScope.ALL
|
|
52
55
|
|
|
53
56
|
|
|
54
57
|
@dataclass(frozen=True)
|
|
@@ -4,7 +4,9 @@ Claude reports a failed API request three ways, newest first: an ``error``
|
|
|
4
4
|
kind on the synthetic assistant message it writes for the failure, an
|
|
5
5
|
``api_error_status`` on the final ``result`` frame, and the text
|
|
6
6
|
``API Error: <status> <body>`` in that frame's ``result``. Older builds only
|
|
7
|
-
have the text, so all three are read.
|
|
7
|
+
have the text, so all three are read. A turn whose ``StructuredOutput`` calls
|
|
8
|
+
never validated ends with the ``result`` subtype
|
|
9
|
+
``error_max_structured_output_retries`` instead.
|
|
8
10
|
"""
|
|
9
11
|
|
|
10
12
|
from __future__ import annotations
|
|
@@ -39,17 +41,29 @@ _AUTH_STATUSES = frozenset({401, 403})
|
|
|
39
41
|
#: A request timeout or a rate limit; every 5xx (529 is an overload) is transient too.
|
|
40
42
|
_TRANSIENT_STATUSES = frozenset({408, 429})
|
|
41
43
|
_SERVER_ERRORS = range(500, 600)
|
|
44
|
+
#: The ``result`` subtype of a turn that used up its output-schema retries.
|
|
45
|
+
SCHEMA_RETRIES_SUBTYPE = "error_max_structured_output_retries"
|
|
42
46
|
|
|
43
47
|
|
|
44
|
-
def classify_failure(
|
|
48
|
+
def classify_failure(
|
|
49
|
+
*, subtype: str | None, api_error: str | None, status: int | None, text: str
|
|
50
|
+
) -> FailureKind:
|
|
45
51
|
"""Classify one failed turn from what Claude reported about it.
|
|
46
52
|
|
|
47
|
-
``
|
|
53
|
+
``subtype`` is the error ``result`` frame's subtype, ``api_error`` the
|
|
54
|
+
assistant frame's ``error`` kind, ``status`` the
|
|
48
55
|
result frame's ``api_error_status``, and ``text`` the error text the
|
|
49
56
|
stream (or, when the stream had none, stderr) carried. Tool output is
|
|
50
57
|
never part of ``text``, so a tool that printed an API error cannot make
|
|
51
58
|
the turn look like one.
|
|
52
59
|
"""
|
|
60
|
+
if subtype == SCHEMA_RETRIES_SUBTYPE:
|
|
61
|
+
return FailureKind.SCHEMA
|
|
62
|
+
return _classify_api_failure(api_error=api_error, status=status, text=text)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _classify_api_failure(*, api_error: str | None, status: int | None, text: str) -> FailureKind:
|
|
66
|
+
"""Classify a failure from the API error Claude reported, if it reported one."""
|
|
53
67
|
if api_error in _AUTH_KINDS:
|
|
54
68
|
return FailureKind.AUTH
|
|
55
69
|
if api_error in _USAGE_LIMIT_KINDS:
|
|
@@ -32,7 +32,7 @@ from .events import (
|
|
|
32
32
|
ToolResultBlock,
|
|
33
33
|
parse_frame,
|
|
34
34
|
)
|
|
35
|
-
from .failures import classify_failure
|
|
35
|
+
from .failures import SCHEMA_RETRIES_SUBTYPE, classify_failure
|
|
36
36
|
|
|
37
37
|
if TYPE_CHECKING:
|
|
38
38
|
from collections.abc import Callable, Mapping
|
|
@@ -45,6 +45,10 @@ PROVIDER_NAME = "claude"
|
|
|
45
45
|
#: The built-in tool through which Claude Code loads a skill.
|
|
46
46
|
SKILL_TOOL = "Skill"
|
|
47
47
|
|
|
48
|
+
#: The tool through which Claude Code submits a schema-constrained answer;
|
|
49
|
+
#: its error result is the validation failure of one submission.
|
|
50
|
+
STRUCTURED_OUTPUT_TOOL = "StructuredOutput"
|
|
51
|
+
|
|
48
52
|
#: The built-in tool through which Claude Code reads a file.
|
|
49
53
|
READ_TOOL = "Read"
|
|
50
54
|
|
|
@@ -142,6 +146,9 @@ class ClaudeStreamParser:
|
|
|
142
146
|
self._api_error: str | None = None
|
|
143
147
|
self._api_error_text: str | None = None
|
|
144
148
|
self._api_error_status: int | None = None
|
|
149
|
+
self._subtype: str | None = None
|
|
150
|
+
# The validation errors of the last rejected StructuredOutput call.
|
|
151
|
+
self._schema_errors: str | None = None
|
|
145
152
|
self._stderr: list[str] = []
|
|
146
153
|
|
|
147
154
|
def feed_stdout(self, line: str) -> None:
|
|
@@ -189,7 +196,12 @@ class ClaudeStreamParser:
|
|
|
189
196
|
"""
|
|
190
197
|
reported = [text for text in (self._error, self._api_error_text) if text]
|
|
191
198
|
text = "\n".join(reported) if reported else "\n".join(self._stderr)
|
|
192
|
-
return classify_failure(
|
|
199
|
+
return classify_failure(
|
|
200
|
+
subtype=self._subtype,
|
|
201
|
+
api_error=self._api_error,
|
|
202
|
+
status=self._api_error_status,
|
|
203
|
+
text=text,
|
|
204
|
+
)
|
|
193
205
|
|
|
194
206
|
def _handle(self, frame: object) -> None:
|
|
195
207
|
if isinstance(frame, SystemInit):
|
|
@@ -238,6 +250,8 @@ class ClaudeStreamParser:
|
|
|
238
250
|
|
|
239
251
|
def _tool_result(self, frame: ToolResultBlock) -> None:
|
|
240
252
|
name = self._tools.name(frame.tool_id)
|
|
253
|
+
if name == STRUCTURED_OUTPUT_TOOL and frame.is_error:
|
|
254
|
+
self._schema_errors = frame.output
|
|
241
255
|
self._emit(
|
|
242
256
|
ToolResult(
|
|
243
257
|
tool_id=frame.tool_id,
|
|
@@ -262,7 +276,8 @@ class ClaudeStreamParser:
|
|
|
262
276
|
self._emit(UsageReport(self._usage, frame.total_cost_usd))
|
|
263
277
|
if frame.is_error:
|
|
264
278
|
self._api_error_status = frame.api_error_status
|
|
265
|
-
self.
|
|
279
|
+
self._subtype = frame.subtype
|
|
280
|
+
self._error = self._error_text(frame)
|
|
266
281
|
self._emit(ProviderError(self._error))
|
|
267
282
|
|
|
268
283
|
def _structured_payload(self, frame: ResultFrame) -> object | None:
|
|
@@ -281,12 +296,16 @@ class ClaudeStreamParser:
|
|
|
281
296
|
except (json.JSONDecodeError, ValueError):
|
|
282
297
|
return None
|
|
283
298
|
|
|
299
|
+
def _error_text(self, frame: ResultFrame) -> str:
|
|
300
|
+
"""Say what an error ``result`` frame reported.
|
|
284
301
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
302
|
+
A turn that used up its schema retries is described by the validation
|
|
303
|
+
errors of its last rejected submission, which only the
|
|
304
|
+
``StructuredOutput`` tool result carries. Otherwise an ``error_*``
|
|
305
|
+
subtype carries its messages in ``errors`` and often no ``result``
|
|
306
|
+
text at all, so both are read before falling back to the subtype,
|
|
307
|
+
which is the only part that names some failures.
|
|
308
|
+
"""
|
|
309
|
+
if frame.subtype == SCHEMA_RETRIES_SUBTYPE and self._schema_errors:
|
|
310
|
+
return self._schema_errors
|
|
311
|
+
return frame.text or "; ".join(frame.errors) or frame.subtype or "claude reported an error"
|
|
@@ -8,6 +8,7 @@ from typing import TYPE_CHECKING, Any
|
|
|
8
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
|
+
ConfigScope,
|
|
11
12
|
McpMechanism,
|
|
12
13
|
McpScope,
|
|
13
14
|
OutputSchemaStyle,
|
|
@@ -44,6 +45,14 @@ MCP_SERVER_KEY = "mcpServers"
|
|
|
44
45
|
#: account connectors are not user settings and stay.
|
|
45
46
|
PROJECT_SETTING_SOURCES = ("--setting-sources", "project,local")
|
|
46
47
|
|
|
48
|
+
#: ``ConfigScope.PROJECT``: the same ``--setting-sources`` drops the user's
|
|
49
|
+
#: settings (hooks, ``env``, permissions, default model), ``~/.claude/CLAUDE.md``
|
|
50
|
+
#: and plugins; auto-memory is not a setting source and is switched off in the
|
|
51
|
+
#: turn's ``--settings`` instead, so ``~/.claude/projects/*/memory`` is neither
|
|
52
|
+
#: read nor written. Built-in skills, the workspace's ``CLAUDE.md``,
|
|
53
|
+
#: ``.claude/`` settings and skills, and credentials keep working.
|
|
54
|
+
NO_AUTO_MEMORY_SETTINGS = {"autoMemoryEnabled": False}
|
|
55
|
+
|
|
47
56
|
#: ``McpScope.SESSION``: ``--strict-mcp-config`` makes ``--mcp-config`` the
|
|
48
57
|
#: only source of MCP servers. It drops the user's and the project's
|
|
49
58
|
#: (``.mcp.json``) servers, plugin servers and claude.ai account connectors,
|
|
@@ -75,6 +84,7 @@ PROFILE = ProviderProfile(
|
|
|
75
84
|
skill_invocation=SkillSignal.STRUCTURED,
|
|
76
85
|
skill_scopes=frozenset({SkillScope.ALL, SkillScope.PROJECT}),
|
|
77
86
|
mcp_scopes=frozenset({McpScope.ALL, McpScope.SESSION}),
|
|
87
|
+
config_scopes=frozenset({ConfigScope.ALL, ConfigScope.PROJECT}),
|
|
78
88
|
container_install=(
|
|
79
89
|
"apt-get update && apt-get install -y --no-install-recommends curl ca-certificates",
|
|
80
90
|
"curl -fsSL https://claude.ai/install.sh | bash",
|
|
@@ -138,7 +148,8 @@ class ClaudeProvider:
|
|
|
138
148
|
"stream-json",
|
|
139
149
|
"--verbose",
|
|
140
150
|
]
|
|
141
|
-
|
|
151
|
+
isolate_config = ctx.config_scope is ConfigScope.PROJECT
|
|
152
|
+
if ctx.skill_scope is SkillScope.PROJECT or isolate_config:
|
|
142
153
|
argv += PROJECT_SETTING_SOURCES
|
|
143
154
|
if ctx.model:
|
|
144
155
|
argv += ["--model", ctx.model]
|
|
@@ -147,6 +158,8 @@ class ClaudeProvider:
|
|
|
147
158
|
if ctx.schema_inline:
|
|
148
159
|
argv += ["--json-schema", ctx.schema_inline]
|
|
149
160
|
settings = build_settings(self.sandbox, hooks=self.hooks)
|
|
161
|
+
if isolate_config:
|
|
162
|
+
settings.update(NO_AUTO_MEMORY_SETTINGS)
|
|
150
163
|
if settings:
|
|
151
164
|
argv += ["--settings", json.dumps(settings)]
|
|
152
165
|
if ctx.mcp_scope is McpScope.SESSION:
|
|
@@ -120,9 +120,7 @@ def resume_failure_lines(*, session_id: str | None = None) -> tuple[list[str], l
|
|
|
120
120
|
|
|
121
121
|
|
|
122
122
|
#: What Claude writes for a failed request of each kind: the assistant
|
|
123
|
-
#: ``error`` kind, the HTTP status, and the message.
|
|
124
|
-
#: ran but could not satisfy its output schema, the subtype Claude reports
|
|
125
|
-
#: when no StructuredOutput call validated.
|
|
123
|
+
#: ``error`` kind, the HTTP status, and the message.
|
|
126
124
|
_FAILURES: dict[FailureKind, tuple[str, int, str]] = {
|
|
127
125
|
FailureKind.TRANSIENT: (
|
|
128
126
|
"overloaded",
|
|
@@ -133,6 +131,10 @@ _FAILURES: dict[FailureKind, tuple[str, int, str]] = {
|
|
|
133
131
|
FailureKind.AUTH: ("authentication_failed", 401, "Invalid API key \u00b7 Please run /login"),
|
|
134
132
|
}
|
|
135
133
|
|
|
134
|
+
#: The validation errors Claude's ``StructuredOutput`` tool returns for a
|
|
135
|
+
#: submission that does not match the schema.
|
|
136
|
+
SCHEMA_ERRORS = "Output does not match required schema: root: must have required property 'answer'"
|
|
137
|
+
|
|
136
138
|
|
|
137
139
|
def failure_lines(
|
|
138
140
|
kind: FailureKind, *, session_id: str | None = None
|
|
@@ -140,8 +142,10 @@ def failure_lines(
|
|
|
140
142
|
"""Build the stdout, stderr and exit code of a turn that fails with *kind*.
|
|
141
143
|
|
|
142
144
|
An API failure is the synthetic assistant message Claude writes for it,
|
|
143
|
-
then an error ``result`` frame with the HTTP status. ``
|
|
144
|
-
``
|
|
145
|
+
then an error ``result`` frame with the HTTP status. ``SCHEMA`` is a
|
|
146
|
+
``StructuredOutput`` call the tool rejects, then an
|
|
147
|
+
``error_max_structured_output_retries`` result. ``OTHER`` is an
|
|
148
|
+
``error_during_execution`` result, which names no API error.
|
|
145
149
|
|
|
146
150
|
Args:
|
|
147
151
|
kind: The classification the scripted failure must produce.
|
|
@@ -156,12 +160,13 @@ def failure_lines(
|
|
|
156
160
|
result: dict[str, Any] = {"type": "result", "is_error": True, "num_turns": 1}
|
|
157
161
|
if session_id is not None:
|
|
158
162
|
result["session_id"] = session_id
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
result
|
|
162
|
-
|
|
163
|
+
if kind is FailureKind.SCHEMA:
|
|
164
|
+
lines.extend(_rejected_structured_output())
|
|
165
|
+
result.update(subtype="error_max_structured_output_retries", errors=[])
|
|
166
|
+
elif kind is FailureKind.OTHER:
|
|
167
|
+
result.update(subtype="error_during_execution", errors=["the turn stopped unexpectedly"])
|
|
163
168
|
else:
|
|
164
|
-
error, status, text =
|
|
169
|
+
error, status, text = _FAILURES[kind]
|
|
165
170
|
message = {"role": "assistant", "content": [{"type": "text", "text": text}]}
|
|
166
171
|
lines.append(_line({"type": "assistant", "message": message, "error": error}))
|
|
167
172
|
result.update(subtype="success", result=text, api_error_status=status)
|
|
@@ -169,6 +174,26 @@ def failure_lines(
|
|
|
169
174
|
return lines, [], 1
|
|
170
175
|
|
|
171
176
|
|
|
177
|
+
def _rejected_structured_output() -> list[str]:
|
|
178
|
+
"""One ``StructuredOutput`` call and the validation error it got back."""
|
|
179
|
+
call: dict[str, Any] = {
|
|
180
|
+
"type": "tool_use",
|
|
181
|
+
"id": "toolu_schema",
|
|
182
|
+
"name": "StructuredOutput",
|
|
183
|
+
"input": {},
|
|
184
|
+
}
|
|
185
|
+
rejection = {
|
|
186
|
+
"type": "tool_result",
|
|
187
|
+
"tool_use_id": "toolu_schema",
|
|
188
|
+
"content": SCHEMA_ERRORS,
|
|
189
|
+
"is_error": True,
|
|
190
|
+
}
|
|
191
|
+
return [
|
|
192
|
+
_line({"type": "assistant", "message": {"role": "assistant", "content": [call]}}),
|
|
193
|
+
_line({"type": "user", "message": {"role": "user", "content": [rejection]}}),
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
|
|
172
197
|
def _usage_payload(usage: TokenUsage | None) -> dict[str, Any]:
|
|
173
198
|
if usage is None:
|
|
174
199
|
return {
|
|
@@ -7,8 +7,10 @@ import re
|
|
|
7
7
|
from typing import TYPE_CHECKING, Any
|
|
8
8
|
|
|
9
9
|
from agentshim.core.errors import ProviderCapabilityError, SessionResumeError
|
|
10
|
+
from agentshim.core.home import state_root
|
|
10
11
|
from agentshim.core.mcp import FlagsInstallation, HttpMcpServer, NoopInstallation
|
|
11
12
|
from agentshim.core.profile import (
|
|
13
|
+
ConfigScope,
|
|
12
14
|
McpMechanism,
|
|
13
15
|
McpScope,
|
|
14
16
|
OutputSchemaStyle,
|
|
@@ -85,6 +87,10 @@ PROFILE = ProviderProfile(
|
|
|
85
87
|
skill_invocation=SkillSignal.INFERRED,
|
|
86
88
|
skill_scopes=frozenset({SkillScope.ALL, SkillScope.PROJECT}),
|
|
87
89
|
mcp_scopes=frozenset({McpScope.ALL, McpScope.SESSION}),
|
|
90
|
+
config_scopes=frozenset({ConfigScope.ALL, ConfigScope.PROJECT}),
|
|
91
|
+
# Codex reads AGENTS.md, hooks.json and memories/ from $CODEX_HOME with no
|
|
92
|
+
# flag to skip them, so ConfigScope.PROJECT needs a home of its own.
|
|
93
|
+
config_home_files=("auth.json",),
|
|
88
94
|
container_install=(_NODE_INSTALL, _CODEX_INSTALL),
|
|
89
95
|
# Documented Codex CLI variable that relocates ~/.codex (``codex --help``:
|
|
90
96
|
# "Layer $CODEX_HOME/<name>.config.toml on top of the base user config";
|
|
@@ -105,6 +111,13 @@ BYPASS_FLAG = "--dangerously-bypass-approvals-and-sandbox"
|
|
|
105
111
|
#: Keeps user and project ``.rules`` files out of a sandboxed turn.
|
|
106
112
|
IGNORE_RULES_FLAG = "--ignore-rules"
|
|
107
113
|
|
|
114
|
+
#: ``ConfigScope.PROJECT``: ``$CODEX_HOME/config.toml`` (profiles, notify,
|
|
115
|
+
#: ``developer_instructions``, project trust) is not loaded, and the memories
|
|
116
|
+
#: Codex would otherwise write and reread across sessions in the home stay
|
|
117
|
+
#: off. The global ``AGENTS.md``, ``hooks.json`` and existing memories are
|
|
118
|
+
#: kept out by the dedicated home itself (``prepare_config_home``).
|
|
119
|
+
PROJECT_CONFIG_FLAGS = ("--ignore-user-config", "--disable", "memories")
|
|
120
|
+
|
|
108
121
|
|
|
109
122
|
class CodexProvider:
|
|
110
123
|
"""Codex (``codex exec --json``). ``sandbox`` is a provider option."""
|
|
@@ -136,6 +149,9 @@ class CodexProvider:
|
|
|
136
149
|
if ctx.model:
|
|
137
150
|
argv += ["--model", ctx.model]
|
|
138
151
|
argv += _shell_path_config(ctx.env)
|
|
152
|
+
if ctx.config_scope is ConfigScope.PROJECT:
|
|
153
|
+
_check_config_home(ctx)
|
|
154
|
+
argv += PROJECT_CONFIG_FLAGS
|
|
139
155
|
argv += _scope_overrides(ctx)
|
|
140
156
|
if ctx.reasoning_effort:
|
|
141
157
|
argv += ["--config", f"model_reasoning_effort={toml_str(ctx.reasoning_effort)}"]
|
|
@@ -250,6 +266,26 @@ def _writable_dirs(config: CodexSandboxConfig, ctx: ArgvContext) -> list[str]:
|
|
|
250
266
|
return dirs
|
|
251
267
|
|
|
252
268
|
|
|
269
|
+
def _check_config_home(ctx: ArgvContext) -> None:
|
|
270
|
+
"""Refuse ``ConfigScope.PROJECT`` unless the turn runs in a home of its own.
|
|
271
|
+
|
|
272
|
+
The user's global ``AGENTS.md``, hooks and memories live in the state root
|
|
273
|
+
and no flag skips them, so a turn still pointed at the user's root would
|
|
274
|
+
load them while claiming to be isolated.
|
|
275
|
+
"""
|
|
276
|
+
home = ctx.env.get("CODEX_HOME")
|
|
277
|
+
if not home or not os.path.isabs(home): # noqa: PTH117 - a str contract, not a Path
|
|
278
|
+
msg = (
|
|
279
|
+
"ConfigScope.PROJECT needs CODEX_HOME set to the absolute path of a "
|
|
280
|
+
f"dedicated home prepared with prepare_config_home (got {home!r})"
|
|
281
|
+
)
|
|
282
|
+
raise ProviderCapabilityError(msg)
|
|
283
|
+
user_root = state_root(PROFILE, {key: v for key, v in ctx.env.items() if key != "CODEX_HOME"})
|
|
284
|
+
if user_root is not None and os.path.realpath(home) == os.path.realpath(user_root):
|
|
285
|
+
msg = f"ConfigScope.PROJECT needs a dedicated CODEX_HOME, not the user's own {home}"
|
|
286
|
+
raise ProviderCapabilityError(msg)
|
|
287
|
+
|
|
288
|
+
|
|
253
289
|
def _is_within(path: str, directory: str) -> bool:
|
|
254
290
|
resolved, parent = os.path.realpath(path), os.path.realpath(directory)
|
|
255
291
|
return resolved == parent or resolved.startswith(parent.rstrip(os.sep) + os.sep)
|
|
@@ -130,17 +130,25 @@ _FAILURES: dict[FailureKind, str] = {
|
|
|
130
130
|
"(https://chatgpt.com/explore/plus), or try again in 2 hours."
|
|
131
131
|
),
|
|
132
132
|
FailureKind.AUTH: "unexpected status 401 Unauthorized: Missing bearer authentication",
|
|
133
|
-
FailureKind.OTHER: "
|
|
133
|
+
FailureKind.OTHER: "Codex ran out of room in the model's context window.",
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
|
|
137
|
+
#: Codex sends ``--output-schema`` to the model as a strict response format,
|
|
138
|
+
#: so decoding is constrained to the schema and a turn never fails over it.
|
|
139
|
+
_NO_SCHEMA_FAILURE = (
|
|
140
|
+
"codex never fails a turn over its output schema: decoding is constrained to the schema"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
137
144
|
def failure_lines(
|
|
138
145
|
kind: FailureKind, *, session_id: str | None = None
|
|
139
146
|
) -> tuple[list[str], list[str], int]:
|
|
140
147
|
"""Build the stdout, stderr and exit code of a turn that fails with *kind*.
|
|
141
148
|
|
|
142
149
|
Codex reports the failure as an ``error`` event followed by
|
|
143
|
-
``turn.failed``, both carrying the same message.
|
|
150
|
+
``turn.failed``, both carrying the same message. ``SCHEMA`` raises
|
|
151
|
+
``ValueError``: Codex has no such failure.
|
|
144
152
|
|
|
145
153
|
Args:
|
|
146
154
|
kind: The classification the scripted failure must produce.
|
|
@@ -149,7 +157,9 @@ def failure_lines(
|
|
|
149
157
|
Returns:
|
|
150
158
|
``(stdout, stderr, returncode)`` for a ``FakeRun``.
|
|
151
159
|
"""
|
|
152
|
-
message = _FAILURES
|
|
160
|
+
message = _FAILURES.get(kind)
|
|
161
|
+
if message is None:
|
|
162
|
+
raise ValueError(_NO_SCHEMA_FAILURE)
|
|
153
163
|
lines: list[str] = []
|
|
154
164
|
if session_id is not None:
|
|
155
165
|
lines.append(_line({"type": "thread.started", "thread_id": session_id}))
|
|
@@ -222,14 +222,17 @@ def scripted_failure(provider: str, kind: FailureKind, *, session_id: str | None
|
|
|
222
222
|
"""Build a ``FakeRun`` for a turn that fails and classifies as *kind*.
|
|
223
223
|
|
|
224
224
|
The run is *provider*'s real report of such a failure (an overload, a
|
|
225
|
-
usage limit, a rejected login,
|
|
225
|
+
usage limit, a rejected login, output that never matched the requested
|
|
226
|
+
schema, or an unclassifiable failure), so the
|
|
226
227
|
raised ``CliExitError`` carries ``kind`` through the real parser. A
|
|
227
228
|
consumer can therefore test its retry policy without knowing how any
|
|
228
229
|
CLI phrases a failure.
|
|
229
230
|
|
|
230
231
|
Args:
|
|
231
232
|
provider: A provider whose stream reports failure kinds (``claude``
|
|
232
|
-
or ``codex``); any other raises ``ValueError
|
|
233
|
+
or ``codex``); any other raises ``ValueError``, as does
|
|
234
|
+
``FailureKind.SCHEMA`` on ``codex``, whose decoding is
|
|
235
|
+
constrained to the schema.
|
|
233
236
|
kind: The ``FailureKind`` the raised error must carry.
|
|
234
237
|
session_id: The conversation the failed run names, if any.
|
|
235
238
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|