agentshim 0.6.0__tar.gz → 0.6.1__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.1}/CHANGELOG.md +53 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/PKG-INFO +1 -1
- agentshim-0.6.1/agentshim/core/profile.py +89 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/__init__.py +41 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/claude/__init__.py +2 -1
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/claude/provider.py +12 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/claude/scripted.py +21 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/codex/__init__.py +4 -2
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/codex/provider.py +99 -1
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/codex/scripted.py +19 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/copilot/__init__.py +4 -2
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/copilot/provider.py +48 -1
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/copilot/scripted.py +19 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/gemini/__init__.py +2 -1
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/gemini/provider.py +12 -2
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/gemini/scripted.py +21 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/opencode/__init__.py +2 -1
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/opencode/provider.py +12 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/opencode/scripted.py +20 -0
- agentshim-0.6.1/agentshim/testing/__init__.py +318 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/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.1}/.gitignore +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/README.md +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/__init__.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/agent.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/__init__.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/_files.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/env.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/errors.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/events.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/mcp.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/provider.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/schema.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/stream.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/turn.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/core/usage.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/execution/__init__.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/execution/executor.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/execution/host.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/execution/transform.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/claude/events.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/claude/hooks/__init__.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/claude/hooks/confine_reads.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/claude/parser.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/claude/sandbox.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/codex/events.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/codex/parser.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/copilot/events.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/copilot/parser.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/gemini/events.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/gemini/parser.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/opencode/events.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/providers/opencode/parser.py +0 -0
- {agentshim-0.6.0 → agentshim-0.6.1}/agentshim/py.typed +0 -0
|
@@ -1,5 +1,58 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.1 (2026-09-10)
|
|
4
|
+
|
|
5
|
+
Additive only: every new field defaults, so a 0.6.0 consumer's existing
|
|
6
|
+
`ProviderProfile` construction and every shipped provider keep working
|
|
7
|
+
unchanged.
|
|
8
|
+
|
|
9
|
+
`ProviderProfile` gains four fields:
|
|
10
|
+
|
|
11
|
+
- `container_env: Mapping[str, str]` (default empty): environment a CLI
|
|
12
|
+
needs to run as root in a container, beyond auth. Claude Code sets
|
|
13
|
+
`IS_SANDBOX=1`, which it requires before accepting
|
|
14
|
+
`--dangerously-skip-permissions` as root; the other four providers need
|
|
15
|
+
nothing extra.
|
|
16
|
+
- `state_root_env: str | None` (default `None`): the CLI's own documented
|
|
17
|
+
variable for relocating its primary state directory (`state_dirs[0]`).
|
|
18
|
+
`CLAUDE_CONFIG_DIR` for Claude, `CODEX_HOME` for Codex, `COPILOT_HOME` for
|
|
19
|
+
Copilot; `None` for Gemini and opencode, which document no such variable.
|
|
20
|
+
- `auth_files: tuple[str, ...]` (default empty): home-relative paths to the
|
|
21
|
+
files that hold credentials and user configuration, the minimal set to
|
|
22
|
+
copy into another environment so the CLI is already logged in. Every entry
|
|
23
|
+
lies inside a `state_dirs` entry.
|
|
24
|
+
- `mcp_config_file: str | None` (default `None`): for a `CONFIG_FILE`
|
|
25
|
+
provider, the workspace-relative path its MCP config is written to for a
|
|
26
|
+
turn (`.mcp.json`, `.gemini/settings.json`, `opencode.json`). The provider
|
|
27
|
+
module defines it as the same constant `install_mcp` writes to, so the two
|
|
28
|
+
cannot drift. `None` for `CLI_FLAGS`/`NONE` providers.
|
|
29
|
+
|
|
30
|
+
`tests/unit/providers/test_conventions.py` pins these across every provider:
|
|
31
|
+
`auth_files` entries lie inside `state_dirs`, `mcp_config_file` is set
|
|
32
|
+
exactly when `mcp` is `CONFIG_FILE` and matches where `install_mcp` actually
|
|
33
|
+
writes, `state_root_env` is uppercase when set, and `container_env` keys are
|
|
34
|
+
uppercase.
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- `agentshim.testing.scripted_resume_failure(provider, *, session_id=None)`:
|
|
39
|
+
a `FakeRun` that fails the way *provider* recognises a lost resumed
|
|
40
|
+
conversation, so a consumer can test `SessionResumeError` handling without
|
|
41
|
+
knowing any provider's wire format. Backed by a required
|
|
42
|
+
`resume_failure_lines(...)` entry in every `providers/<name>/scripted.py`,
|
|
43
|
+
found through `providers.get_resume_failure_lines(name)` the same way
|
|
44
|
+
`scripted_turn` finds `scripted_lines`, so a new provider cannot ship
|
|
45
|
+
without one.
|
|
46
|
+
- `agentshim.testing.installed_mcp_servers(provider, request, workspace)`:
|
|
47
|
+
the MCP servers one turn installed, keyed by server name, each normalized
|
|
48
|
+
to `command`/`args`/`env` (stdio) or `url`/`transport` (HTTP) regardless of
|
|
49
|
+
how the provider itself renders it. Reads the config file for a
|
|
50
|
+
CONFIG_FILE provider and parses `request.argv` for a CLI_FLAGS one, via a
|
|
51
|
+
`parse_mcp_servers` kept next to the renderer it inverts
|
|
52
|
+
(`providers/codex/provider.py`, `providers/copilot/provider.py`). Meant to
|
|
53
|
+
be called from inside a `FakeExecutor` `run` callback: a CONFIG_FILE
|
|
54
|
+
provider's config exists only for the lifetime of the turn.
|
|
55
|
+
|
|
3
56
|
## 0.6.0
|
|
4
57
|
|
|
5
58
|
A restructure, not an upgrade. 0.6 replaces the whole public API of 0.5 and
|
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -199,3 +207,93 @@ def _toml_str(value: str) -> str:
|
|
|
199
207
|
def _toml_array(values: Sequence[str]) -> str:
|
|
200
208
|
"""Render a sequence of strings as a TOML inline array."""
|
|
201
209
|
return "[" + ",".join(_toml_str(value) for value in values) + "]"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
_CONFIG_OVERRIDE_RE = re.compile(r"^mcp_servers\.([^.=]+)\.(.+)$")
|
|
213
|
+
_TOML_STRING_RE = re.compile(r'"((?:[^"\\]|\\.)*)"')
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def parse_mcp_servers(argv: Sequence[str]) -> dict[str, dict[str, Any]]:
|
|
217
|
+
"""Recover the MCP servers rendered into *argv* by ``_server_flags``.
|
|
218
|
+
|
|
219
|
+
The inverse of that renderer, kept next to it so the two cannot drift:
|
|
220
|
+
walks the ``--config mcp_servers.<key>.<field>=<value>`` overrides this
|
|
221
|
+
provider emits and rebuilds one canonical entry per server. A stdio
|
|
222
|
+
server comes back as ``command``, ``args`` and ``env``; an HTTP one as
|
|
223
|
+
``url`` and ``transport`` (always ``"http"``, since a ``--config``
|
|
224
|
+
override carries only the address and this provider cannot express any
|
|
225
|
+
other transport).
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
argv: The argv a turn actually ran, as recorded on a ``CommandRequest``.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
One canonical entry per server, keyed by the dotted-path name
|
|
232
|
+
``_server_flags`` gave it (``-`` already mangled to ``_``).
|
|
233
|
+
"""
|
|
234
|
+
servers: dict[str, dict[str, Any]] = {}
|
|
235
|
+
for key, field, raw_value in _mcp_config_overrides(argv):
|
|
236
|
+
_apply_mcp_override(servers.setdefault(key, {}), field, raw_value)
|
|
237
|
+
for entry in servers.values():
|
|
238
|
+
if "url" not in entry:
|
|
239
|
+
entry.setdefault("args", [])
|
|
240
|
+
entry.setdefault("env", {})
|
|
241
|
+
return servers
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _mcp_config_overrides(argv: Sequence[str]) -> list[tuple[str, str, str]]:
|
|
245
|
+
"""Pull every ``mcp_servers.<key>.<field>=<value>`` override out of *argv*."""
|
|
246
|
+
args = list(argv)
|
|
247
|
+
overrides: list[tuple[str, str, str]] = []
|
|
248
|
+
for index, arg in enumerate(args):
|
|
249
|
+
if arg != "--config" or index + 1 >= len(args):
|
|
250
|
+
continue
|
|
251
|
+
path, sep, raw_value = args[index + 1].partition("=")
|
|
252
|
+
if not sep:
|
|
253
|
+
continue
|
|
254
|
+
match = _CONFIG_OVERRIDE_RE.match(path)
|
|
255
|
+
if match is not None:
|
|
256
|
+
key, field = match.groups()
|
|
257
|
+
overrides.append((key, field, raw_value))
|
|
258
|
+
return overrides
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _apply_mcp_override(entry: dict[str, Any], field: str, raw_value: str) -> None:
|
|
262
|
+
"""Fold one dotted-path override into the entry being rebuilt for it."""
|
|
263
|
+
if field == "command":
|
|
264
|
+
entry["command"] = _parse_toml_str(raw_value)
|
|
265
|
+
elif field == "args":
|
|
266
|
+
entry["args"] = _parse_toml_array(raw_value)
|
|
267
|
+
elif field == "url":
|
|
268
|
+
entry["url"] = _parse_toml_str(raw_value)
|
|
269
|
+
entry["transport"] = "http"
|
|
270
|
+
elif field.startswith("env."):
|
|
271
|
+
entry.setdefault("env", {})[field.removeprefix("env.")] = _parse_toml_str(raw_value)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _parse_toml_str(literal: str) -> str:
|
|
275
|
+
"""Reverse ``_toml_str``: unescape one TOML basic string literal."""
|
|
276
|
+
match = _TOML_STRING_RE.fullmatch(literal)
|
|
277
|
+
if match is None:
|
|
278
|
+
return literal
|
|
279
|
+
return _unescape_toml(match.group(1))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _parse_toml_array(literal: str) -> list[str]:
|
|
283
|
+
"""Reverse ``_toml_array``: unescape every string in a TOML inline array."""
|
|
284
|
+
return [_unescape_toml(inner) for inner in _TOML_STRING_RE.findall(literal)]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _unescape_toml(escaped: str) -> str:
|
|
288
|
+
"""Undo the backslash-escaping ``_toml_str`` applies to a string body."""
|
|
289
|
+
result: list[str] = []
|
|
290
|
+
index = 0
|
|
291
|
+
while index < len(escaped):
|
|
292
|
+
char = escaped[index]
|
|
293
|
+
if char == "\\" and index + 1 < len(escaped):
|
|
294
|
+
result.append(escaped[index + 1])
|
|
295
|
+
index += 2
|
|
296
|
+
else:
|
|
297
|
+
result.append(char)
|
|
298
|
+
index += 1
|
|
299
|
+
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
|
]
|
|
@@ -11,7 +11,7 @@ from agentshim.core.profile import McpMechanism, OutputSchemaStyle, ProviderProf
|
|
|
11
11
|
from .parser import CopilotStreamParser
|
|
12
12
|
|
|
13
13
|
if TYPE_CHECKING:
|
|
14
|
-
from collections.abc import Callable, Sequence
|
|
14
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
17
|
from agentshim.core.errors import AgentShimError, CliExitError
|
|
@@ -47,6 +47,19 @@ PROFILE = ProviderProfile(
|
|
|
47
47
|
skill_dirs=(".github/skills",),
|
|
48
48
|
# The npm package ships the CLI; the image supplies node and npm.
|
|
49
49
|
container_install=("npm install -g @github/copilot",),
|
|
50
|
+
# ``COPILOT_HOME`` relocates ~/.copilot; documented above and by
|
|
51
|
+
# ``copilot help environment``.
|
|
52
|
+
state_root_env="COPILOT_HOME",
|
|
53
|
+
# Copilot documents no specific credential filenames. ``.copilot/`` on a
|
|
54
|
+
# logged-in install holds ``config.json`` (opaque, not plain JSON; most
|
|
55
|
+
# likely where the login state lives) and ``settings.json`` (user
|
|
56
|
+
# config, e.g. the chosen model); ``.config/github-copilot/`` on the same
|
|
57
|
+
# install held only ``versions.json``, which is not credential-bearing,
|
|
58
|
+
# so it is left out.
|
|
59
|
+
auth_files=(".copilot/config.json", ".copilot/settings.json"),
|
|
60
|
+
# CLI_FLAGS: Copilot takes MCP servers as one --additional-mcp-config
|
|
61
|
+
# flag, never a file.
|
|
62
|
+
mcp_config_file=None,
|
|
50
63
|
)
|
|
51
64
|
|
|
52
65
|
|
|
@@ -132,3 +145,37 @@ def mcp_entry(server: McpServer) -> dict[str, Any]:
|
|
|
132
145
|
if stdio.env:
|
|
133
146
|
rendered["env"] = dict(stdio.env)
|
|
134
147
|
return rendered
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def parse_mcp_servers(argv: Sequence[str]) -> dict[str, dict[str, Any]]:
|
|
151
|
+
"""Recover the MCP servers rendered into *argv* by ``install_mcp``.
|
|
152
|
+
|
|
153
|
+
The inverse of ``mcp_entry``, kept next to it so the two cannot drift:
|
|
154
|
+
reads the ``--additional-mcp-config`` flag's JSON payload back and maps
|
|
155
|
+
each entry to the canonical shape ``installed_mcp_servers`` returns.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
argv: The argv a turn actually ran, as recorded on a ``CommandRequest``.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
One canonical entry per server, keyed by server name.
|
|
162
|
+
"""
|
|
163
|
+
args = list(argv)
|
|
164
|
+
if MCP_CONFIG_FLAG not in args:
|
|
165
|
+
return {}
|
|
166
|
+
index = args.index(MCP_CONFIG_FLAG)
|
|
167
|
+
if index + 1 >= len(args):
|
|
168
|
+
return {}
|
|
169
|
+
config = json.loads(args[index + 1])
|
|
170
|
+
servers = config.get(MCP_SERVER_KEY, {})
|
|
171
|
+
return {name: _canonical_entry(raw) for name, raw in servers.items()}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _canonical_entry(raw: Mapping[str, Any]) -> dict[str, Any]:
|
|
175
|
+
if "url" in raw:
|
|
176
|
+
return {"url": raw["url"], "transport": raw.get("type", "http")}
|
|
177
|
+
return {
|
|
178
|
+
"command": raw.get("command", ""),
|
|
179
|
+
"args": list(raw.get("args", ())),
|
|
180
|
+
"env": dict(raw.get("env", {})),
|
|
181
|
+
}
|
|
@@ -104,6 +104,25 @@ def scripted_lines(
|
|
|
104
104
|
return lines
|
|
105
105
|
|
|
106
106
|
|
|
107
|
+
def resume_failure_lines(*, session_id: str | None = None) -> tuple[list[str], list[str], int]:
|
|
108
|
+
"""Build the stdout, stderr and exit code of a resumed turn Copilot cannot continue.
|
|
109
|
+
|
|
110
|
+
``CopilotProvider.classify_exit`` returns every exit unchanged: Copilot
|
|
111
|
+
gives no signal that distinguishes a lost session from any other
|
|
112
|
+
failure, so this always surfaces as a plain ``CliExitError``, never
|
|
113
|
+
``SessionResumeError``, whether the turn was resumed or not.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
session_id: Conversation id to name in the scripted stderr message.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
``(stdout, stderr, returncode)`` for a ``FakeRun``.
|
|
120
|
+
"""
|
|
121
|
+
detail = f" {session_id}" if session_id else ""
|
|
122
|
+
stderr = [f"Error: could not resume session{detail}\n"]
|
|
123
|
+
return [], stderr, 1
|
|
124
|
+
|
|
125
|
+
|
|
107
126
|
def _usage_payload(usage: TokenUsage) -> dict[str, Any]:
|
|
108
127
|
"""Undo the parser's folding to print the disjoint counts Copilot reports."""
|
|
109
128
|
cached = usage.cached_input_tokens
|
|
@@ -4,12 +4,13 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from .parser import GeminiStreamParser
|
|
6
6
|
from .provider import PROFILE, GeminiProvider, mcp_entry
|
|
7
|
-
from .scripted import scripted_lines
|
|
7
|
+
from .scripted import resume_failure_lines, scripted_lines
|
|
8
8
|
|
|
9
9
|
__all__ = [
|
|
10
10
|
"PROFILE",
|
|
11
11
|
"GeminiProvider",
|
|
12
12
|
"GeminiStreamParser",
|
|
13
13
|
"mcp_entry",
|
|
14
|
+
"resume_failure_lines",
|
|
14
15
|
"scripted_lines",
|
|
15
16
|
]
|
|
@@ -38,7 +38,7 @@ if TYPE_CHECKING:
|
|
|
38
38
|
from agentshim.core.mcp import McpServer
|
|
39
39
|
from agentshim.core.provider import ArgvContext, McpInstallation
|
|
40
40
|
|
|
41
|
-
|
|
41
|
+
MCP_CONFIG_FILENAME = ".gemini/settings.json"
|
|
42
42
|
MCP_SERVER_KEY = "mcpServers"
|
|
43
43
|
|
|
44
44
|
_NO_WORKSPACE = "gemini installs MCP servers into <cwd>/.gemini/settings.json; the turn needs a cwd"
|
|
@@ -71,6 +71,16 @@ PROFILE = ProviderProfile(
|
|
|
71
71
|
"rm -f node.tgz; }",
|
|
72
72
|
"npm install -g @google/gemini-cli",
|
|
73
73
|
),
|
|
74
|
+
# ``gemini --help`` documents no variable that relocates ~/.gemini, so
|
|
75
|
+
# this stays unset rather than guessed.
|
|
76
|
+
state_root_env=None,
|
|
77
|
+
auth_files=(
|
|
78
|
+
".gemini/oauth_creds.json",
|
|
79
|
+
".gemini/google_accounts.json",
|
|
80
|
+
".gemini/settings.json",
|
|
81
|
+
".gemini/.env",
|
|
82
|
+
),
|
|
83
|
+
mcp_config_file=MCP_CONFIG_FILENAME,
|
|
74
84
|
)
|
|
75
85
|
|
|
76
86
|
|
|
@@ -106,7 +116,7 @@ class GeminiProvider:
|
|
|
106
116
|
if workspace is None:
|
|
107
117
|
raise ProviderCapabilityError(_NO_WORKSPACE)
|
|
108
118
|
return install_config_file(
|
|
109
|
-
workspace
|
|
119
|
+
workspace / MCP_CONFIG_FILENAME,
|
|
110
120
|
server_key=MCP_SERVER_KEY,
|
|
111
121
|
servers={server.name: mcp_entry(server) for server in servers},
|
|
112
122
|
)
|
|
@@ -90,6 +90,27 @@ def scripted_lines(
|
|
|
90
90
|
return lines
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
def resume_failure_lines(*, session_id: str | None = None) -> tuple[list[str], list[str], int]:
|
|
94
|
+
"""Build the stdout, stderr and exit code of a resumed turn Gemini cannot continue.
|
|
95
|
+
|
|
96
|
+
``GeminiProvider.classify_exit`` treats any nonzero exit of a resumed
|
|
97
|
+
turn as ``SessionResumeError``: the CLI reports a refused resume the same
|
|
98
|
+
way it reports any other startup failure. ``session_id`` is folded into
|
|
99
|
+
the message for realism only; the id ``SessionResumeError`` actually
|
|
100
|
+
reports comes from the resumed turn's own argv, not from anything
|
|
101
|
+
scripted here.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
session_id: Conversation id to name in the scripted stderr message.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
``(stdout, stderr, returncode)`` for a ``FakeRun``.
|
|
108
|
+
"""
|
|
109
|
+
detail = f" {session_id}" if session_id else ""
|
|
110
|
+
stderr = [f"Error: failed to load session{detail}\n"]
|
|
111
|
+
return [], stderr, 1
|
|
112
|
+
|
|
113
|
+
|
|
93
114
|
def _stats(usage: TokenUsage | None, tool_calls: int) -> dict[str, int]:
|
|
94
115
|
"""Render token counts the way ``convertToStreamStats`` does.
|
|
95
116
|
|
|
@@ -4,12 +4,13 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from .parser import OpencodeStreamParser
|
|
6
6
|
from .provider import PROFILE, OpencodeProvider, mcp_entry
|
|
7
|
-
from .scripted import scripted_lines
|
|
7
|
+
from .scripted import resume_failure_lines, scripted_lines
|
|
8
8
|
|
|
9
9
|
__all__ = [
|
|
10
10
|
"PROFILE",
|
|
11
11
|
"OpencodeProvider",
|
|
12
12
|
"OpencodeStreamParser",
|
|
13
13
|
"mcp_entry",
|
|
14
|
+
"resume_failure_lines",
|
|
14
15
|
"scripted_lines",
|
|
15
16
|
]
|