agentshim 0.6.0__tar.gz → 0.6.2__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.6.0 → agentshim-0.6.2}/CHANGELOG.md +59 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/PKG-INFO +1 -1
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/__init__.py +1 -1
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/mcp.py +20 -1
- agentshim-0.6.2/agentshim/core/profile.py +89 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/__init__.py +41 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/claude/__init__.py +2 -1
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/claude/provider.py +15 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/claude/scripted.py +21 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/codex/__init__.py +4 -2
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/codex/provider.py +111 -3
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/codex/scripted.py +19 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/copilot/__init__.py +4 -2
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/copilot/provider.py +52 -1
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/copilot/scripted.py +19 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/gemini/__init__.py +2 -1
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/gemini/provider.py +15 -2
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/gemini/scripted.py +21 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/opencode/__init__.py +2 -1
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/opencode/provider.py +15 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/opencode/scripted.py +20 -0
- agentshim-0.6.2/agentshim/testing/__init__.py +319 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/pyproject.toml +1 -1
- agentshim-0.6.0/agentshim/core/profile.py +0 -57
- agentshim-0.6.0/agentshim/testing/__init__.py +0 -174
- {agentshim-0.6.0 → agentshim-0.6.2}/.gitignore +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/README.md +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/agent.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/__init__.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/_files.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/env.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/errors.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/events.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/provider.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/schema.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/stream.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/turn.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/core/usage.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/execution/__init__.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/execution/executor.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/execution/host.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/execution/transform.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/claude/events.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/claude/hooks/__init__.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/claude/parser.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/claude/sandbox.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/codex/events.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/codex/parser.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/copilot/events.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/copilot/parser.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/gemini/events.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/gemini/parser.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/opencode/events.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/providers/opencode/parser.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.2}/agentshim/py.typed +0 -0
|
@@ -1,5 +1,64 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.2 (2026-09-26)
|
|
4
|
+
|
|
5
|
+
- Add optional positive finite `tool_timeout_s` to stdio and HTTP MCP server
|
|
6
|
+
specs. Codex renders it as an invocation-scoped `tool_timeout_sec` config
|
|
7
|
+
override; providers that cannot represent it reject the request explicitly.
|
|
8
|
+
|
|
9
|
+
## 0.6.1 (2026-09-10)
|
|
10
|
+
|
|
11
|
+
Additive only: every new field defaults, so a 0.6.0 consumer's existing
|
|
12
|
+
`ProviderProfile` construction and every shipped provider keep working
|
|
13
|
+
unchanged.
|
|
14
|
+
|
|
15
|
+
`ProviderProfile` gains four fields:
|
|
16
|
+
|
|
17
|
+
- `container_env: Mapping[str, str]` (default empty): environment a CLI
|
|
18
|
+
needs to run as root in a container, beyond auth. Claude Code sets
|
|
19
|
+
`IS_SANDBOX=1`, which it requires before accepting
|
|
20
|
+
`--dangerously-skip-permissions` as root; the other four providers need
|
|
21
|
+
nothing extra.
|
|
22
|
+
- `state_root_env: str | None` (default `None`): the CLI's own documented
|
|
23
|
+
variable for relocating its primary state directory (`state_dirs[0]`).
|
|
24
|
+
`CLAUDE_CONFIG_DIR` for Claude, `CODEX_HOME` for Codex, `COPILOT_HOME` for
|
|
25
|
+
Copilot; `None` for Gemini and opencode, which document no such variable.
|
|
26
|
+
- `auth_files: tuple[str, ...]` (default empty): home-relative paths to the
|
|
27
|
+
files that hold credentials and user configuration, the minimal set to
|
|
28
|
+
copy into another environment so the CLI is already logged in. Every entry
|
|
29
|
+
lies inside a `state_dirs` entry.
|
|
30
|
+
- `mcp_config_file: str | None` (default `None`): for a `CONFIG_FILE`
|
|
31
|
+
provider, the workspace-relative path its MCP config is written to for a
|
|
32
|
+
turn (`.mcp.json`, `.gemini/settings.json`, `opencode.json`). The provider
|
|
33
|
+
module defines it as the same constant `install_mcp` writes to, so the two
|
|
34
|
+
cannot drift. `None` for `CLI_FLAGS`/`NONE` providers.
|
|
35
|
+
|
|
36
|
+
`tests/unit/providers/test_conventions.py` pins these across every provider:
|
|
37
|
+
`auth_files` entries lie inside `state_dirs`, `mcp_config_file` is set
|
|
38
|
+
exactly when `mcp` is `CONFIG_FILE` and matches where `install_mcp` actually
|
|
39
|
+
writes, `state_root_env` is uppercase when set, and `container_env` keys are
|
|
40
|
+
uppercase.
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- `agentshim.testing.scripted_resume_failure(provider, *, session_id=None)`:
|
|
45
|
+
a `FakeRun` that fails the way *provider* recognises a lost resumed
|
|
46
|
+
conversation, so a consumer can test `SessionResumeError` handling without
|
|
47
|
+
knowing any provider's wire format. Backed by a required
|
|
48
|
+
`resume_failure_lines(...)` entry in every `providers/<name>/scripted.py`,
|
|
49
|
+
found through `providers.get_resume_failure_lines(name)` the same way
|
|
50
|
+
`scripted_turn` finds `scripted_lines`, so a new provider cannot ship
|
|
51
|
+
without one.
|
|
52
|
+
- `agentshim.testing.installed_mcp_servers(provider, request, workspace)`:
|
|
53
|
+
the MCP servers one turn installed, keyed by server name, each normalized
|
|
54
|
+
to `command`/`args`/`env` (stdio) or `url`/`transport` (HTTP) regardless of
|
|
55
|
+
how the provider itself renders it. Reads the config file for a
|
|
56
|
+
CONFIG_FILE provider and parses `request.argv` for a CLI_FLAGS one, via a
|
|
57
|
+
`parse_mcp_servers` kept next to the renderer it inverts
|
|
58
|
+
(`providers/codex/provider.py`, `providers/copilot/provider.py`). Meant to
|
|
59
|
+
be called from inside a `FakeExecutor` `run` callback: a CONFIG_FILE
|
|
60
|
+
provider's config exists only for the lifetime of the turn.
|
|
61
|
+
|
|
3
62
|
## 0.6.0
|
|
4
63
|
|
|
5
64
|
A restructure, not an upgrade. 0.6 replaces the whole public API of 0.5 and
|
|
@@ -14,6 +14,7 @@ each concurrent turn its own workspace.
|
|
|
14
14
|
from __future__ import annotations
|
|
15
15
|
|
|
16
16
|
import json
|
|
17
|
+
import math
|
|
17
18
|
from dataclasses import dataclass, field
|
|
18
19
|
from types import MappingProxyType
|
|
19
20
|
from typing import TYPE_CHECKING, Any, Literal, cast
|
|
@@ -37,12 +38,18 @@ def _no_env() -> Mapping[str, str]:
|
|
|
37
38
|
|
|
38
39
|
@dataclass(frozen=True)
|
|
39
40
|
class StdioMcpServer:
|
|
40
|
-
"""MCP server launched as a subprocess over stdio.
|
|
41
|
+
"""MCP server launched as a subprocess over stdio.
|
|
42
|
+
|
|
43
|
+
``tool_timeout_s`` is the maximum time one tool call may run. Providers
|
|
44
|
+
that cannot express a per-server tool timeout reject a non-``None`` value
|
|
45
|
+
rather than silently dropping it.
|
|
46
|
+
"""
|
|
41
47
|
|
|
42
48
|
name: str
|
|
43
49
|
command: str
|
|
44
50
|
args: Sequence[str] = ()
|
|
45
51
|
env: Mapping[str, str] = field(default_factory=_no_env)
|
|
52
|
+
tool_timeout_s: float | None = None
|
|
46
53
|
|
|
47
54
|
def __post_init__(self) -> None:
|
|
48
55
|
"""Reject a spec the provider could never launch.
|
|
@@ -56,6 +63,7 @@ class StdioMcpServer:
|
|
|
56
63
|
if not self.command:
|
|
57
64
|
msg = f"MCP server {self.name!r} must declare a command"
|
|
58
65
|
raise ValueError(msg)
|
|
66
|
+
_validate_tool_timeout(self.name, self.tool_timeout_s)
|
|
59
67
|
|
|
60
68
|
|
|
61
69
|
#: The two remote MCP transports the CLIs distinguish.
|
|
@@ -79,6 +87,7 @@ class HttpMcpServer:
|
|
|
79
87
|
url: str
|
|
80
88
|
headers: Mapping[str, str] = field(default_factory=_no_env)
|
|
81
89
|
transport: McpTransport = "http"
|
|
90
|
+
tool_timeout_s: float | None = None
|
|
82
91
|
|
|
83
92
|
def __post_init__(self) -> None:
|
|
84
93
|
"""Reject a spec the provider could never reach.
|
|
@@ -95,6 +104,16 @@ class HttpMcpServer:
|
|
|
95
104
|
if self.transport not in _TRANSPORTS:
|
|
96
105
|
msg = f"MCP server {self.name!r} transport must be one of {_TRANSPORTS}"
|
|
97
106
|
raise ValueError(msg)
|
|
107
|
+
_validate_tool_timeout(self.name, self.tool_timeout_s)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _validate_tool_timeout(name: str, timeout_s: float | None) -> None:
|
|
111
|
+
"""Reject timeout values no provider can apply meaningfully."""
|
|
112
|
+
if timeout_s is None:
|
|
113
|
+
return
|
|
114
|
+
if isinstance(timeout_s, bool) or not math.isfinite(timeout_s) or timeout_s <= 0:
|
|
115
|
+
msg = f"MCP server {name!r} tool_timeout_s must be a positive finite number"
|
|
116
|
+
raise ValueError(msg)
|
|
98
117
|
|
|
99
118
|
|
|
100
119
|
McpServer = StdioMcpServer | HttpMcpServer
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Declared provider capabilities.
|
|
2
|
+
|
|
3
|
+
Every optional behaviour is declared here (or on a protocol) so no caller
|
|
4
|
+
has to probe a provider object for attributes.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from enum import Enum
|
|
11
|
+
from types import MappingProxyType
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Mapping
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class McpMechanism(Enum):
|
|
19
|
+
"""How a provider is told about MCP servers."""
|
|
20
|
+
|
|
21
|
+
NONE = "none"
|
|
22
|
+
CONFIG_FILE = "config_file"
|
|
23
|
+
CLI_FLAGS = "cli_flags"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OutputSchemaStyle(Enum):
|
|
27
|
+
"""How a provider accepts a JSON Schema for structured output."""
|
|
28
|
+
|
|
29
|
+
NONE = "none"
|
|
30
|
+
INLINE_JSON = "inline_json"
|
|
31
|
+
FILE_PATH = "file_path"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SchemaDialect(Enum):
|
|
35
|
+
"""Which JSON Schema subset a provider accepts.
|
|
36
|
+
|
|
37
|
+
``STRICT`` is Codex's ``--output-schema`` subset: every object declares
|
|
38
|
+
its properties and forbids undeclared keys. ``OPEN`` also accepts a
|
|
39
|
+
schema-valued or ``true`` ``additionalProperties``.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
STRICT = "strict"
|
|
43
|
+
OPEN = "open"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _no_container_env() -> Mapping[str, str]:
|
|
47
|
+
"""Empty read-only default: a frozen spec must not carry a mutable one.
|
|
48
|
+
|
|
49
|
+
A factory rather than a shared constant because Python 3.10 and 3.11
|
|
50
|
+
reject an unhashable dataclass default, and ``mappingproxy`` is one. See
|
|
51
|
+
``agentshim.core.mcp._no_env`` for the same pattern.
|
|
52
|
+
"""
|
|
53
|
+
return MappingProxyType({})
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(frozen=True)
|
|
57
|
+
class ProviderProfile:
|
|
58
|
+
"""Everything a caller needs to know about a provider without running it.
|
|
59
|
+
|
|
60
|
+
The last four fields are additive (0.6.1+) and all default, so an
|
|
61
|
+
existing keyword-built ``ProviderProfile`` keeps working unchanged.
|
|
62
|
+
``container_env`` is environment a CLI needs to run as root in a
|
|
63
|
+
container, beyond auth (e.g. Claude Code's ``IS_SANDBOX=1``).
|
|
64
|
+
``state_root_env`` is the documented variable that relocates
|
|
65
|
+
``state_dirs[0]``, or ``None`` when the CLI names none. ``auth_files`` are
|
|
66
|
+
the home-relative paths, each inside a ``state_dirs`` entry, whose
|
|
67
|
+
contents are the minimal set to copy so the CLI is logged in elsewhere.
|
|
68
|
+
``mcp_config_file`` is the workspace-relative path a ``CONFIG_FILE``
|
|
69
|
+
provider writes its MCP config to for a turn, and ``None`` for
|
|
70
|
+
``CLI_FLAGS``/``NONE`` providers.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
name: str
|
|
74
|
+
display_name: str
|
|
75
|
+
binary: str
|
|
76
|
+
supports_resume: bool
|
|
77
|
+
supports_reasoning_effort: bool
|
|
78
|
+
mcp: McpMechanism
|
|
79
|
+
output_schema: OutputSchemaStyle
|
|
80
|
+
schema_dialect: SchemaDialect | None
|
|
81
|
+
state_dirs: tuple[str, ...]
|
|
82
|
+
darwin_state_dirs: tuple[str, ...]
|
|
83
|
+
auth_env_vars: tuple[str, ...]
|
|
84
|
+
skill_dirs: tuple[str, ...]
|
|
85
|
+
container_install: tuple[str, ...]
|
|
86
|
+
container_env: Mapping[str, str] = field(default_factory=_no_container_env)
|
|
87
|
+
state_root_env: str | None = None
|
|
88
|
+
auth_files: tuple[str, ...] = ()
|
|
89
|
+
mcp_config_file: str | None = None
|
|
@@ -11,14 +11,19 @@ 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 resume_failure_lines as _claude_resume_failure
|
|
14
15
|
from .claude import scripted_lines as _claude_scripted
|
|
15
16
|
from .codex import CodexProvider
|
|
17
|
+
from .codex import resume_failure_lines as _codex_resume_failure
|
|
16
18
|
from .codex import scripted_lines as _codex_scripted
|
|
17
19
|
from .copilot import CopilotProvider
|
|
20
|
+
from .copilot import resume_failure_lines as _copilot_resume_failure
|
|
18
21
|
from .copilot import scripted_lines as _copilot_scripted
|
|
19
22
|
from .gemini import GeminiProvider
|
|
23
|
+
from .gemini import resume_failure_lines as _gemini_resume_failure
|
|
20
24
|
from .gemini import scripted_lines as _gemini_scripted
|
|
21
25
|
from .opencode import OpencodeProvider
|
|
26
|
+
from .opencode import resume_failure_lines as _opencode_resume_failure
|
|
22
27
|
from .opencode import scripted_lines as _opencode_scripted
|
|
23
28
|
|
|
24
29
|
if TYPE_CHECKING:
|
|
@@ -49,6 +54,21 @@ class ScriptedLines(Protocol):
|
|
|
49
54
|
...
|
|
50
55
|
|
|
51
56
|
|
|
57
|
+
class ResumeFailureLines(Protocol):
|
|
58
|
+
"""Builds the stdout, stderr and exit code of a resumed turn gone bad."""
|
|
59
|
+
|
|
60
|
+
def __call__(
|
|
61
|
+
self, *, session_id: str | None = None
|
|
62
|
+
) -> tuple[Sequence[str], Sequence[str], int]:
|
|
63
|
+
"""Return ``(stdout, stderr, returncode)`` for a ``FakeRun``.
|
|
64
|
+
|
|
65
|
+
``session_id`` is folded into the scripted message for realism only;
|
|
66
|
+
what a resumed turn's ``classify_exit`` actually reports comes from
|
|
67
|
+
the turn's own argv.
|
|
68
|
+
"""
|
|
69
|
+
...
|
|
70
|
+
|
|
71
|
+
|
|
52
72
|
# To add a provider: implement providers/<name>/ (provider.py, parser.py,
|
|
53
73
|
# events.py, scripted.py) and add one entry to each dict below.
|
|
54
74
|
_FACTORIES: dict[str, Callable[[], Provider]] = {
|
|
@@ -67,6 +87,14 @@ _SCRIPTED: dict[str, ScriptedLines] = {
|
|
|
67
87
|
"opencode": _opencode_scripted,
|
|
68
88
|
}
|
|
69
89
|
|
|
90
|
+
_RESUME_FAILURES: dict[str, ResumeFailureLines] = {
|
|
91
|
+
"claude": _claude_resume_failure,
|
|
92
|
+
"codex": _codex_resume_failure,
|
|
93
|
+
"copilot": _copilot_resume_failure,
|
|
94
|
+
"gemini": _gemini_resume_failure,
|
|
95
|
+
"opencode": _opencode_resume_failure,
|
|
96
|
+
}
|
|
97
|
+
|
|
70
98
|
|
|
71
99
|
def provider_names() -> list[str]:
|
|
72
100
|
"""Return the supported provider names, sorted."""
|
|
@@ -91,14 +119,27 @@ def get_scripted_lines(name: str) -> ScriptedLines:
|
|
|
91
119
|
return scripted
|
|
92
120
|
|
|
93
121
|
|
|
122
|
+
def get_resume_failure_lines(name: str) -> ResumeFailureLines:
|
|
123
|
+
"""Return the test-double resume-failure builder for *name*."""
|
|
124
|
+
resume_failure = _RESUME_FAILURES.get(name)
|
|
125
|
+
if resume_failure is None:
|
|
126
|
+
msg = (
|
|
127
|
+
f"no resume-failure stream for provider {name!r}; available: {sorted(_RESUME_FAILURES)}"
|
|
128
|
+
)
|
|
129
|
+
raise ValueError(msg)
|
|
130
|
+
return resume_failure
|
|
131
|
+
|
|
132
|
+
|
|
94
133
|
__all__ = [
|
|
95
134
|
"ClaudeProvider",
|
|
96
135
|
"CodexProvider",
|
|
97
136
|
"CopilotProvider",
|
|
98
137
|
"GeminiProvider",
|
|
99
138
|
"OpencodeProvider",
|
|
139
|
+
"ResumeFailureLines",
|
|
100
140
|
"ScriptedLines",
|
|
101
141
|
"get_provider",
|
|
142
|
+
"get_resume_failure_lines",
|
|
102
143
|
"get_scripted_lines",
|
|
103
144
|
"provider_names",
|
|
104
145
|
]
|
|
@@ -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 scripted_lines
|
|
8
|
+
from .scripted import resume_failure_lines, scripted_lines
|
|
9
9
|
|
|
10
10
|
__all__ = [
|
|
11
11
|
"PROFILE",
|
|
@@ -15,5 +15,6 @@ __all__ = [
|
|
|
15
15
|
"build_settings",
|
|
16
16
|
"mcp_entry",
|
|
17
17
|
"resolve_sandbox",
|
|
18
|
+
"resume_failure_lines",
|
|
18
19
|
"scripted_lines",
|
|
19
20
|
]
|
|
@@ -50,6 +50,18 @@ PROFILE = ProviderProfile(
|
|
|
50
50
|
# The installer drops the binary in /root/.local/bin.
|
|
51
51
|
"ln -sf /root/.local/bin/claude /usr/local/bin/claude",
|
|
52
52
|
),
|
|
53
|
+
# Claude Code refuses --dangerously-skip-permissions as root unless this
|
|
54
|
+
# is set.
|
|
55
|
+
container_env={"IS_SANDBOX": "1"},
|
|
56
|
+
# Documented Claude Code variable that relocates ~/.claude.
|
|
57
|
+
state_root_env="CLAUDE_CONFIG_DIR",
|
|
58
|
+
auth_files=(
|
|
59
|
+
".claude/.credentials.json",
|
|
60
|
+
".claude/settings.json",
|
|
61
|
+
".claude/settings.local.json",
|
|
62
|
+
".claude.json",
|
|
63
|
+
),
|
|
64
|
+
mcp_config_file=MCP_CONFIG_FILENAME,
|
|
53
65
|
)
|
|
54
66
|
|
|
55
67
|
|
|
@@ -150,6 +162,9 @@ def _resumed_session_id(argv: Sequence[str]) -> str | None:
|
|
|
150
162
|
|
|
151
163
|
def mcp_entry(server: McpServer) -> dict[str, Any]:
|
|
152
164
|
"""Render one server the way ``.mcp.json`` describes it."""
|
|
165
|
+
if server.tool_timeout_s is not None:
|
|
166
|
+
msg = "claude cannot configure a per-server MCP tool timeout"
|
|
167
|
+
raise ProviderCapabilityError(msg)
|
|
153
168
|
if isinstance(server, HttpMcpServer):
|
|
154
169
|
# Claude validates the config strictly and needs an explicit type; it
|
|
155
170
|
# names the two remote transports exactly as the spec does.
|
|
@@ -81,6 +81,27 @@ def scripted_lines(
|
|
|
81
81
|
return lines
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
def resume_failure_lines(*, session_id: str | None = None) -> tuple[list[str], list[str], int]:
|
|
85
|
+
"""Build the stdout, stderr and exit code of a resumed turn Claude cannot continue.
|
|
86
|
+
|
|
87
|
+
``ClaudeProvider.classify_exit`` treats any nonzero exit of a resumed
|
|
88
|
+
turn as ``SessionResumeError``: ``claude --resume`` gives no
|
|
89
|
+
distinguishable exit code for a missing transcript, so a bare failure on
|
|
90
|
+
stderr is enough to trigger it. ``session_id`` is folded into the message
|
|
91
|
+
for realism only; the id ``SessionResumeError`` actually reports comes
|
|
92
|
+
from the resumed turn's own argv, not from anything scripted here.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
session_id: Conversation id to name in the scripted stderr message.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
``(stdout, stderr, returncode)`` for a ``FakeRun``.
|
|
99
|
+
"""
|
|
100
|
+
detail = f" {session_id}" if session_id else ""
|
|
101
|
+
stderr = [f"Error: no conversation found to resume{detail}\n"]
|
|
102
|
+
return [], stderr, 1
|
|
103
|
+
|
|
104
|
+
|
|
84
105
|
def _usage_payload(usage: TokenUsage | None) -> dict[str, int]:
|
|
85
106
|
if usage is None:
|
|
86
107
|
return {
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from .parser import CodexStreamParser
|
|
6
|
-
from .provider import PROFILE, CodexProvider
|
|
7
|
-
from .scripted import scripted_lines
|
|
6
|
+
from .provider import PROFILE, CodexProvider, parse_mcp_servers
|
|
7
|
+
from .scripted import resume_failure_lines, scripted_lines
|
|
8
8
|
|
|
9
9
|
__all__ = [
|
|
10
10
|
"PROFILE",
|
|
11
11
|
"CodexProvider",
|
|
12
12
|
"CodexStreamParser",
|
|
13
|
+
"parse_mcp_servers",
|
|
14
|
+
"resume_failure_lines",
|
|
13
15
|
"scripted_lines",
|
|
14
16
|
]
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
import re
|
|
6
|
+
from typing import TYPE_CHECKING, Any
|
|
6
7
|
|
|
7
8
|
from agentshim.core.errors import ProviderCapabilityError, SessionResumeError
|
|
8
9
|
from agentshim.core.mcp import FlagsInstallation, HttpMcpServer, NoopInstallation
|
|
@@ -71,6 +72,13 @@ PROFILE = ProviderProfile(
|
|
|
71
72
|
auth_env_vars=("OPENAI_API_KEY", "OPENAI_BASE_URL"),
|
|
72
73
|
skill_dirs=(".agents/skills",),
|
|
73
74
|
container_install=(_NODE_INSTALL, _CODEX_INSTALL),
|
|
75
|
+
# Documented Codex CLI variable that relocates ~/.codex (``codex --help``:
|
|
76
|
+
# "Layer $CODEX_HOME/<name>.config.toml on top of the base user config";
|
|
77
|
+
# "auth still uses CODEX_HOME").
|
|
78
|
+
state_root_env="CODEX_HOME",
|
|
79
|
+
auth_files=(".codex/auth.json", ".codex/config.toml"),
|
|
80
|
+
# CLI_FLAGS: Codex takes MCP servers as --config overrides, never a file.
|
|
81
|
+
mcp_config_file=None,
|
|
74
82
|
)
|
|
75
83
|
|
|
76
84
|
|
|
@@ -178,7 +186,8 @@ def _server_flags(server: McpServer) -> list[str]:
|
|
|
178
186
|
raise ProviderCapabilityError(_HTTP_HEADERS_UNSUPPORTED)
|
|
179
187
|
# A ``--config`` override carries the address and nothing else, so
|
|
180
188
|
# Codex works the transport out from the endpoint itself.
|
|
181
|
-
|
|
189
|
+
flags = ["--config", f"{prefix}.url={_toml_str(server.url)}"]
|
|
190
|
+
return flags + _tool_timeout_flags(prefix, server.tool_timeout_s)
|
|
182
191
|
flags = [
|
|
183
192
|
"--config",
|
|
184
193
|
f"{prefix}.command={_toml_str(server.command)}",
|
|
@@ -187,7 +196,14 @@ def _server_flags(server: McpServer) -> list[str]:
|
|
|
187
196
|
]
|
|
188
197
|
for env_key, env_value in server.env.items():
|
|
189
198
|
flags += ["--config", f"{prefix}.env.{env_key}={_toml_str(env_value)}"]
|
|
190
|
-
return flags
|
|
199
|
+
return flags + _tool_timeout_flags(prefix, server.tool_timeout_s)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _tool_timeout_flags(prefix: str, timeout_s: float | None) -> list[str]:
|
|
203
|
+
"""Render Codex's per-server tool timeout as an invocation override."""
|
|
204
|
+
if timeout_s is None:
|
|
205
|
+
return []
|
|
206
|
+
return ["--config", f"{prefix}.tool_timeout_sec={timeout_s}"]
|
|
191
207
|
|
|
192
208
|
|
|
193
209
|
def _toml_str(value: str) -> str:
|
|
@@ -199,3 +215,95 @@ def _toml_str(value: str) -> str:
|
|
|
199
215
|
def _toml_array(values: Sequence[str]) -> str:
|
|
200
216
|
"""Render a sequence of strings as a TOML inline array."""
|
|
201
217
|
return "[" + ",".join(_toml_str(value) for value in values) + "]"
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
_CONFIG_OVERRIDE_RE = re.compile(r"^mcp_servers\.([^.=]+)\.(.+)$")
|
|
221
|
+
_TOML_STRING_RE = re.compile(r'"((?:[^"\\]|\\.)*)"')
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def parse_mcp_servers(argv: Sequence[str]) -> dict[str, dict[str, Any]]:
|
|
225
|
+
"""Recover the MCP servers rendered into *argv* by ``_server_flags``.
|
|
226
|
+
|
|
227
|
+
The inverse of that renderer, kept next to it so the two cannot drift:
|
|
228
|
+
walks the ``--config mcp_servers.<key>.<field>=<value>`` overrides this
|
|
229
|
+
provider emits and rebuilds one canonical entry per server. A stdio
|
|
230
|
+
server comes back as ``command``, ``args`` and ``env``; an HTTP one as
|
|
231
|
+
``url`` and ``transport`` (always ``"http"``, since a ``--config``
|
|
232
|
+
override carries only the address and this provider cannot express any
|
|
233
|
+
other transport).
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
argv: The argv a turn actually ran, as recorded on a ``CommandRequest``.
|
|
237
|
+
|
|
238
|
+
Returns:
|
|
239
|
+
One canonical entry per server, keyed by the dotted-path name
|
|
240
|
+
``_server_flags`` gave it (``-`` already mangled to ``_``).
|
|
241
|
+
"""
|
|
242
|
+
servers: dict[str, dict[str, Any]] = {}
|
|
243
|
+
for key, field, raw_value in _mcp_config_overrides(argv):
|
|
244
|
+
_apply_mcp_override(servers.setdefault(key, {}), field, raw_value)
|
|
245
|
+
for entry in servers.values():
|
|
246
|
+
if "url" not in entry:
|
|
247
|
+
entry.setdefault("args", [])
|
|
248
|
+
entry.setdefault("env", {})
|
|
249
|
+
return servers
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _mcp_config_overrides(argv: Sequence[str]) -> list[tuple[str, str, str]]:
|
|
253
|
+
"""Pull every ``mcp_servers.<key>.<field>=<value>`` override out of *argv*."""
|
|
254
|
+
args = list(argv)
|
|
255
|
+
overrides: list[tuple[str, str, str]] = []
|
|
256
|
+
for index, arg in enumerate(args):
|
|
257
|
+
if arg != "--config" or index + 1 >= len(args):
|
|
258
|
+
continue
|
|
259
|
+
path, sep, raw_value = args[index + 1].partition("=")
|
|
260
|
+
if not sep:
|
|
261
|
+
continue
|
|
262
|
+
match = _CONFIG_OVERRIDE_RE.match(path)
|
|
263
|
+
if match is not None:
|
|
264
|
+
key, field = match.groups()
|
|
265
|
+
overrides.append((key, field, raw_value))
|
|
266
|
+
return overrides
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _apply_mcp_override(entry: dict[str, Any], field: str, raw_value: str) -> None:
|
|
270
|
+
"""Fold one dotted-path override into the entry being rebuilt for it."""
|
|
271
|
+
if field == "command":
|
|
272
|
+
entry["command"] = _parse_toml_str(raw_value)
|
|
273
|
+
elif field == "args":
|
|
274
|
+
entry["args"] = _parse_toml_array(raw_value)
|
|
275
|
+
elif field == "url":
|
|
276
|
+
entry["url"] = _parse_toml_str(raw_value)
|
|
277
|
+
entry["transport"] = "http"
|
|
278
|
+
elif field == "tool_timeout_sec":
|
|
279
|
+
entry["tool_timeout_s"] = float(raw_value)
|
|
280
|
+
elif field.startswith("env."):
|
|
281
|
+
entry.setdefault("env", {})[field.removeprefix("env.")] = _parse_toml_str(raw_value)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _parse_toml_str(literal: str) -> str:
|
|
285
|
+
"""Reverse ``_toml_str``: unescape one TOML basic string literal."""
|
|
286
|
+
match = _TOML_STRING_RE.fullmatch(literal)
|
|
287
|
+
if match is None:
|
|
288
|
+
return literal
|
|
289
|
+
return _unescape_toml(match.group(1))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _parse_toml_array(literal: str) -> list[str]:
|
|
293
|
+
"""Reverse ``_toml_array``: unescape every string in a TOML inline array."""
|
|
294
|
+
return [_unescape_toml(inner) for inner in _TOML_STRING_RE.findall(literal)]
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _unescape_toml(escaped: str) -> str:
|
|
298
|
+
"""Undo the backslash-escaping ``_toml_str`` applies to a string body."""
|
|
299
|
+
result: list[str] = []
|
|
300
|
+
index = 0
|
|
301
|
+
while index < len(escaped):
|
|
302
|
+
char = escaped[index]
|
|
303
|
+
if char == "\\" and index + 1 < len(escaped):
|
|
304
|
+
result.append(escaped[index + 1])
|
|
305
|
+
index += 2
|
|
306
|
+
else:
|
|
307
|
+
result.append(char)
|
|
308
|
+
index += 1
|
|
309
|
+
return "".join(result)
|
|
@@ -85,6 +85,25 @@ def scripted_lines(
|
|
|
85
85
|
return lines
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
def resume_failure_lines(*, session_id: str | None = None) -> tuple[list[str], list[str], int]:
|
|
89
|
+
"""Build the stdout, stderr and exit code of a resumed turn with no rollout.
|
|
90
|
+
|
|
91
|
+
``CodexProvider.classify_exit`` is the one provider that names its
|
|
92
|
+
failure explicitly: it matches "thread/resume failed" and "no rollout
|
|
93
|
+
found" on stderr, so unlike the other providers a plain nonzero exit is
|
|
94
|
+
not enough here; the message has to say so.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
session_id: Thread id to name in the scripted stderr message.
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
``(stdout, stderr, returncode)`` for a ``FakeRun``.
|
|
101
|
+
"""
|
|
102
|
+
thread = session_id or "unknown-thread"
|
|
103
|
+
stderr = [f"thread/resume failed: no rollout found for thread {thread}\n"]
|
|
104
|
+
return [], stderr, 1
|
|
105
|
+
|
|
106
|
+
|
|
88
107
|
def _usage_payload(usage: TokenUsage | None) -> dict[str, int]:
|
|
89
108
|
if usage is None:
|
|
90
109
|
return {"input_tokens": 0, "cached_input_tokens": 0, "output_tokens": 0}
|
|
@@ -3,13 +3,15 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from .parser import CopilotStreamParser
|
|
6
|
-
from .provider import PROFILE, CopilotProvider, mcp_entry
|
|
7
|
-
from .scripted import scripted_lines
|
|
6
|
+
from .provider import PROFILE, CopilotProvider, mcp_entry, parse_mcp_servers
|
|
7
|
+
from .scripted import resume_failure_lines, scripted_lines
|
|
8
8
|
|
|
9
9
|
__all__ = [
|
|
10
10
|
"PROFILE",
|
|
11
11
|
"CopilotProvider",
|
|
12
12
|
"CopilotStreamParser",
|
|
13
13
|
"mcp_entry",
|
|
14
|
+
"parse_mcp_servers",
|
|
15
|
+
"resume_failure_lines",
|
|
14
16
|
"scripted_lines",
|
|
15
17
|
]
|