agentshim 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. {agentshim-0.5.0 → agentshim-0.6.0}/.gitignore +1 -0
  2. agentshim-0.6.0/CHANGELOG.md +351 -0
  3. agentshim-0.6.0/PKG-INFO +237 -0
  4. agentshim-0.6.0/README.md +228 -0
  5. agentshim-0.6.0/agentshim/__init__.py +167 -0
  6. agentshim-0.6.0/agentshim/agent.py +424 -0
  7. agentshim-0.6.0/agentshim/core/__init__.py +118 -0
  8. agentshim-0.6.0/agentshim/core/_files.py +44 -0
  9. agentshim-0.6.0/agentshim/core/env.py +84 -0
  10. agentshim-0.6.0/agentshim/core/errors.py +137 -0
  11. agentshim-0.6.0/agentshim/core/events.py +357 -0
  12. agentshim-0.6.0/agentshim/core/mcp.py +348 -0
  13. agentshim-0.6.0/agentshim/core/profile.py +57 -0
  14. agentshim-0.6.0/agentshim/core/provider.py +142 -0
  15. agentshim-0.6.0/agentshim/core/schema.py +281 -0
  16. agentshim-0.6.0/agentshim/core/stream.py +67 -0
  17. agentshim-0.6.0/agentshim/core/turn.py +71 -0
  18. agentshim-0.6.0/agentshim/core/usage.py +78 -0
  19. agentshim-0.6.0/agentshim/execution/__init__.py +28 -0
  20. agentshim-0.6.0/agentshim/execution/executor.py +131 -0
  21. agentshim-0.6.0/agentshim/execution/host.py +295 -0
  22. agentshim-0.6.0/agentshim/execution/transform.py +75 -0
  23. agentshim-0.6.0/agentshim/providers/__init__.py +104 -0
  24. agentshim-0.6.0/agentshim/providers/claude/__init__.py +19 -0
  25. agentshim-0.6.0/agentshim/providers/claude/events.py +230 -0
  26. agentshim-0.6.0/agentshim/providers/claude/hooks/__init__.py +1 -0
  27. {agentshim-0.5.0/agentshim → agentshim-0.6.0/agentshim/providers}/claude/hooks/confine_reads.py +17 -5
  28. agentshim-0.6.0/agentshim/providers/claude/parser.py +208 -0
  29. agentshim-0.6.0/agentshim/providers/claude/provider.py +164 -0
  30. agentshim-0.6.0/agentshim/providers/claude/sandbox.py +151 -0
  31. agentshim-0.6.0/agentshim/providers/claude/scripted.py +105 -0
  32. agentshim-0.6.0/agentshim/providers/codex/__init__.py +14 -0
  33. agentshim-0.6.0/agentshim/providers/codex/events.py +230 -0
  34. agentshim-0.6.0/agentshim/providers/codex/parser.py +232 -0
  35. agentshim-0.6.0/agentshim/providers/codex/provider.py +201 -0
  36. agentshim-0.6.0/agentshim/providers/codex/scripted.py +101 -0
  37. agentshim-0.6.0/agentshim/providers/copilot/__init__.py +15 -0
  38. agentshim-0.6.0/agentshim/providers/copilot/events.py +309 -0
  39. agentshim-0.6.0/agentshim/providers/copilot/parser.py +264 -0
  40. agentshim-0.6.0/agentshim/providers/copilot/provider.py +134 -0
  41. agentshim-0.6.0/agentshim/providers/copilot/scripted.py +123 -0
  42. agentshim-0.6.0/agentshim/providers/gemini/__init__.py +15 -0
  43. agentshim-0.6.0/agentshim/providers/gemini/events.py +160 -0
  44. agentshim-0.6.0/agentshim/providers/gemini/parser.py +191 -0
  45. agentshim-0.6.0/agentshim/providers/gemini/provider.py +159 -0
  46. agentshim-0.6.0/agentshim/providers/gemini/scripted.py +121 -0
  47. agentshim-0.6.0/agentshim/providers/opencode/__init__.py +15 -0
  48. agentshim-0.6.0/agentshim/providers/opencode/events.py +185 -0
  49. agentshim-0.6.0/agentshim/providers/opencode/parser.py +206 -0
  50. agentshim-0.6.0/agentshim/providers/opencode/provider.py +165 -0
  51. agentshim-0.6.0/agentshim/providers/opencode/scripted.py +134 -0
  52. agentshim-0.6.0/agentshim/testing/__init__.py +174 -0
  53. agentshim-0.6.0/pyproject.toml +88 -0
  54. agentshim-0.5.0/.github/workflows/ci.yml +0 -41
  55. agentshim-0.5.0/.github/workflows/publish.yml +0 -41
  56. agentshim-0.5.0/PKG-INFO +0 -360
  57. agentshim-0.5.0/README.md +0 -349
  58. agentshim-0.5.0/agentshim/__init__.py +0 -46
  59. agentshim-0.5.0/agentshim/base.py +0 -315
  60. agentshim-0.5.0/agentshim/claude/__init__.py +0 -3
  61. agentshim-0.5.0/agentshim/claude/agent.py +0 -242
  62. agentshim-0.5.0/agentshim/claude/events.py +0 -125
  63. agentshim-0.5.0/agentshim/claude/hooks/__init__.py +0 -0
  64. agentshim-0.5.0/agentshim/claude_events.py +0 -21
  65. agentshim-0.5.0/agentshim/cli_agent.py +0 -324
  66. agentshim-0.5.0/agentshim/codex/__init__.py +0 -3
  67. agentshim-0.5.0/agentshim/codex/agent.py +0 -265
  68. agentshim-0.5.0/agentshim/codex/events.py +0 -206
  69. agentshim-0.5.0/agentshim/codex_events.py +0 -23
  70. agentshim-0.5.0/agentshim/copilot/__init__.py +0 -3
  71. agentshim-0.5.0/agentshim/copilot/agent.py +0 -263
  72. agentshim-0.5.0/agentshim/copilot/events.py +0 -243
  73. agentshim-0.5.0/agentshim/copilot_events.py +0 -29
  74. agentshim-0.5.0/agentshim/events.py +0 -263
  75. agentshim-0.5.0/agentshim/executor.py +0 -319
  76. agentshim-0.5.0/agentshim/gemini/__init__.py +0 -3
  77. agentshim-0.5.0/agentshim/gemini/agent.py +0 -161
  78. agentshim-0.5.0/agentshim/gemini/events.py +0 -53
  79. agentshim-0.5.0/agentshim/gemini_events.py +0 -11
  80. agentshim-0.5.0/agentshim/llm_client.py +0 -53
  81. agentshim-0.5.0/agentshim/mcp_config.py +0 -25
  82. agentshim-0.5.0/agentshim/opencode/__init__.py +0 -3
  83. agentshim-0.5.0/agentshim/opencode/agent.py +0 -180
  84. agentshim-0.5.0/agentshim/opencode/events.py +0 -57
  85. agentshim-0.5.0/agentshim/opencode_events.py +0 -11
  86. agentshim-0.5.0/agentshim/sandbox.py +0 -133
  87. agentshim-0.5.0/agentshim/subagent.py +0 -93
  88. agentshim-0.5.0/agentshim/usage.py +0 -63
  89. agentshim-0.5.0/agentshim/utils.py +0 -120
  90. agentshim-0.5.0/pyproject.toml +0 -44
  91. agentshim-0.5.0/tests/fixtures/copilot/session_turn_1.jsonl +0 -11
  92. agentshim-0.5.0/tests/fixtures/copilot/session_turn_2_resumed.jsonl +0 -11
  93. agentshim-0.5.0/tests/fixtures/copilot/streaming_dedup.jsonl +0 -5
  94. agentshim-0.5.0/tests/fixtures/copilot/tool_and_usage.jsonl +0 -6
  95. agentshim-0.5.0/tests/unit/cli_agent/test_agent_cli_cleanup.py +0 -557
  96. agentshim-0.5.0/tests/unit/cli_agent/test_check_cli.py +0 -38
  97. agentshim-0.5.0/tests/unit/cli_agent/test_cli_prompt_passing.py +0 -360
  98. agentshim-0.5.0/tests/unit/cli_agent/test_command_executor.py +0 -245
  99. agentshim-0.5.0/tests/unit/llm/conftest.py +0 -33
  100. agentshim-0.5.0/tests/unit/llm/test_claude_stream.py +0 -601
  101. agentshim-0.5.0/tests/unit/llm/test_gemini_fixture.py +0 -84
  102. agentshim-0.5.0/tests/unit/llm/test_gemini_stream.py +0 -186
  103. agentshim-0.5.0/tests/unit/test_agent_cli_claude.py +0 -314
  104. agentshim-0.5.0/tests/unit/test_agent_cli_codex.py +0 -243
  105. agentshim-0.5.0/tests/unit/test_agent_cli_copilot.py +0 -198
  106. agentshim-0.5.0/tests/unit/test_agent_cli_copilot_fixtures.py +0 -99
  107. agentshim-0.5.0/tests/unit/test_agent_cli_event_parsing.py +0 -421
  108. agentshim-0.5.0/tests/unit/test_agent_cli_mcp_unsupported.py +0 -38
  109. agentshim-0.5.0/tests/unit/test_agent_cli_resume.py +0 -336
  110. agentshim-0.5.0/tests/unit/test_agent_cli_sandbox.py +0 -141
  111. agentshim-0.5.0/tests/unit/test_cli_agent_usage.py +0 -237
  112. agentshim-0.5.0/tests/unit/test_coding_agent_facade.py +0 -227
  113. agentshim-0.5.0/tests/unit/test_event_handlers.py +0 -83
  114. agentshim-0.5.0/tests/unit/test_mcp_config.py +0 -77
  115. agentshim-0.5.0/uv.lock +0 -2200
  116. {agentshim-0.5.0 → agentshim-0.6.0}/agentshim/py.typed +0 -0
@@ -4,3 +4,4 @@ __pycache__/
4
4
  .ruff_cache/
5
5
  dist/
6
6
  build/
7
+ site/
@@ -0,0 +1,351 @@
1
+ # Changelog
2
+
3
+ ## 0.6.0
4
+
5
+ A restructure, not an upgrade. 0.6 replaces the whole public API of 0.5 and
6
+ ships **no compatibility layer**: a 0.5 consumer will not import, let alone
7
+ run, until it is migrated. The sections below list every removed and renamed
8
+ name so a migration can be done mechanically.
9
+
10
+ ### Restructure
11
+
12
+ The package is now four layers that import strictly inward:
13
+ `agentshim.testing` -> `agentshim/agent.py` -> `agentshim/providers/` ->
14
+ (`agentshim/core/`, `agentshim/execution/`). `agentshim/agent.py` is the
15
+ composition layer, and the only thing that turns a provider name into a
16
+ provider. `import-linter` enforces the ordering as a build gate
17
+ (`scripts/check_imports.sh`).
18
+
19
+ - `agentshim/core/` holds the provider-agnostic types: `TurnRequest`,
20
+ `TurnResult`, `OutputSchema`, the event dataclasses, `TokenUsage`,
21
+ `ProviderUsage`, the error hierarchy, `ProviderProfile`, the `Provider`
22
+ and `StreamParser` protocols, `ParsedTurn`, MCP specs, schema dialect
23
+ checks, and `interactive_env()`.
24
+ - `agentshim/execution/` holds the process transport: `CommandExecutor`,
25
+ `HostCommandExecutor`, `TransformingExecutor`.
26
+ - `agentshim/providers/<name>/` holds one CLI each, all with the same
27
+ layout: `provider.py`, `parser.py`, `events.py`, `scripted.py`, and an
28
+ `__init__.py` exporting `<Name>Provider`, `PROFILE`, `scripted_lines`.
29
+ All five ship: claude, codex, copilot, gemini, opencode. `fold_usage` is
30
+ not part of that contract: every package has one and no two take the same
31
+ arguments, so it stays private to its own `parser.py`.
32
+ - `agentshim/testing/` is new and part of the public API.
33
+
34
+ `ProviderProfile` declares every optional behaviour, so no caller probes a
35
+ provider object: resume, reasoning effort, MCP mechanism, output-schema
36
+ style and dialect, state dirs, auth env vars, skill dirs, container install.
37
+
38
+ ### Breaking changes from 0.5
39
+
40
+ **Removed from the public API.**
41
+
42
+ | 0.5 | Replacement |
43
+ | --- | --- |
44
+ | `CodingAgent` | `CliAgent` |
45
+ | `BaseCodingAgent` | the `Provider` protocol, in `agentshim/core/provider.py` |
46
+ | `BaseAgentSession` | `AgentSession` |
47
+ | `ClaudeCodeCodingAgent` | `CliAgent("claude")`, or `agentshim.providers.claude.ClaudeProvider` |
48
+ | `CodexCodingAgent` | `CliAgent("codex")` |
49
+ | `CopilotCodingAgent` | `CliAgent("copilot")` |
50
+ | `GeminiCodingAgent` | `CliAgent("gemini")` |
51
+ | `OpencodeCodingAgent` | `CliAgent("opencode")` |
52
+ | `get_provider_class(name)` | `get_provider(name)`, which returns an instance |
53
+ | `list_providers()` | `provider_names()` |
54
+ | `register_provider(...)` | none: pass a `Provider` instance to `CliAgent` |
55
+ | `McpServerConfig` | `McpServer` |
56
+ | `SandboxConfig` (top level) | `agentshim.providers.claude.SandboxConfig` |
57
+
58
+ Whole modules are gone: `agentshim.base`, `agentshim.cli_agent`,
59
+ `agentshim.events`, `agentshim.executor`, `agentshim.mcp_config`,
60
+ `agentshim.sandbox`, `agentshim.usage`, `agentshim.utils`,
61
+ `agentshim.subagent`, `agentshim.llm_client`, and the five
62
+ `agentshim.<name>_events` compatibility shims. `subagent.py` and
63
+ `llm_client.py` made direct LLM API calls, which is not what a CLI shim is
64
+ for; the display helpers in `utils.py` are now private to
65
+ `ConsoleEventHandler`.
66
+
67
+ **Renamed.**
68
+
69
+ | 0.5 | 0.6 |
70
+ | --- | --- |
71
+ | `session.generate(prompt) -> str` | `session.turn(TurnRequest \| str) -> TurnResult` |
72
+ | `get_interactive_env()` | `interactive_env(*, refresh=False)`, now cached per process |
73
+ | `build_claude_sandbox_settings()` | `build_settings()` |
74
+ | `<Provider>CodingAgent` | `<Provider>Provider` |
75
+
76
+ **Event handling is typed.** 0.5 dispatched seven optional callbacks
77
+ (`on_run_start`, `on_run_end`, `on_thinking`, `on_tool_call`,
78
+ `on_tool_result`, `on_usage`, `on_stderr`), three of which were not even on
79
+ the protocol and were reached through `getattr`. 0.6 has one method,
80
+ `on_event(event)`, taking a frozen dataclass from the `AgentEvent` union:
81
+ `RunStarted`, `RunFinished`, `SessionStarted`, `AssistantText`, `Reasoning`,
82
+ `ToolCall`, `ToolResult`, `UsageReport`, `Lifecycle`, `Stderr`, `RawOutput`,
83
+ `ProviderError`. `on_thinking` covered assistant text and reasoning
84
+ together; those are now separate events. `SessionStarted`, `Lifecycle`,
85
+ `RawOutput` and `ProviderError` have no 0.5 counterpart.
86
+
87
+ `event_handler=` and `event_handlers=` are now combined rather than
88
+ mutually exclusive; passing both no longer raises.
89
+
90
+ **Errors are typed.** 0.5 raised bare `RuntimeError` for every CLI failure.
91
+ 0.6 raises only `AgentShimError` subclasses, and nothing else escapes
92
+ `turn()`:
93
+
94
+ ```
95
+ AgentShimError
96
+ CliNotFoundError binary not on PATH
97
+ CliCheckError binary found but the health check failed
98
+ CliExitError nonzero exit: argv, returncode, stdout, stderr
99
+ SessionResumeError the conversation is gone: session_id
100
+ CliTimeoutError argv, timeout
101
+ ProviderCapabilityError the provider cannot do what the request asked
102
+ SchemaDialectError problems: list[str]
103
+ McpConfigError config file unreadable or not an object
104
+ ```
105
+
106
+ `SessionResumeError` is new; there was no 0.5 name for it.
107
+
108
+ **The prompt is no longer in argv.** 0.5 wrote the prompt to stdin *and*
109
+ appended it to argv on three providers: claude as a bare positional, copilot
110
+ as `-p <prompt>`, opencode as a positional wrapped in literal double quotes
111
+ (`f'"{prompt}"'`, which reached the CLI with the quotes in the string,
112
+ since argv is not shell-parsed). 0.6 delivers the prompt on stdin only, on
113
+ every provider, so an agent's own `pkill -f` cannot match the CLI by prompt
114
+ text and the prompt does not appear in the process table. `ArgvContext` has
115
+ no prompt field.
116
+
117
+ **The opencode default model is gone.** 0.5 substituted
118
+ `google-vertex/gemini-3-pro-preview` whenever no model was given. 0.6 omits
119
+ `--model` entirely, so opencode uses the model from the user's own config.
120
+ Callers who relied on the implicit default must pass `model=` explicitly.
121
+
122
+ **Dependencies removed.** 0.5 declared `litellm>=1.0.0` and `loguru>=0.7.2`,
123
+ and used pydantic transitively through litellm for the MCP server specs.
124
+ 0.6 declares `dependencies = []`. litellm went with `subagent.py` and
125
+ `llm_client.py`; the MCP specs are frozen dataclasses; and logging is a
126
+ `Callable[[str], None]` the caller supplies, with `ConsoleEventHandler`
127
+ writing to a `TextIO` given at construction instead of to a loguru logger.
128
+ `bind_event_handler_context` and `default_event_handler` are gone with it.
129
+
130
+ **Usage gained two fields.** `TokenUsage` adds `cache_write_input_tokens`
131
+ and `reasoning_output_tokens`, so `to_dict()` now emits six keys rather than
132
+ four. `ProviderUsage` adds `raw`, which holds the CLI's own usage mapping
133
+ for diagnostics and is deliberately excluded from `to_dict()`. Every
134
+ provider now normalizes to the invariant `cached_input_tokens <=
135
+ input_tokens`.
136
+
137
+ **Structured output is a first-class request.** `TurnRequest.output_schema`
138
+ takes an `OutputSchema`, `ProviderProfile.output_schema` and
139
+ `schema_dialect` declare what a provider accepts, and a schema the provider
140
+ cannot express raises `SchemaDialectError` before the process starts.
141
+
142
+ **MCP servers are per-turn, and Claude installs them differently.** 0.5 took
143
+ `mcp_servers` on the agent constructor and, for Claude, rendered them into
144
+ `--mcp-config <json> --strict-mcp-config`. 0.6 takes them on
145
+ `TurnRequest.mcp_servers`, and `ProviderProfile.mcp` declares the mechanism:
146
+ Claude, Gemini and opencode merge into a workspace config file
147
+ (`.mcp.json`, `.gemini/settings.json`, `opencode.json`), keeping the
148
+ original bytes and restoring them when the turn ends, including when it
149
+ raised; Codex and Copilot render flags. A config-file provider therefore
150
+ needs a `cwd`, and raises `ProviderCapabilityError` without one.
151
+
152
+ ### Fixed
153
+
154
+ - **Codex tool results.** A failed tool was reported on `ToolResult.stdout`
155
+ with an empty `stderr`, so a renderer showed a failure as success.
156
+ Failures now go on `stderr` with a nonzero `exit_code`, which is the rule
157
+ on all five providers.
158
+ - **Non-object JSON lines.** A stdout line that was valid JSON but not an
159
+ object (a bare `42`, a top-level array) is no longer treated as a frame.
160
+ `parse_json_object` returns `None` for blank lines, invalid JSON and
161
+ non-objects alike, and the parser emits `RawOutput` so the line stays
162
+ observable instead of crashing the turn or being dropped.
163
+ - **`ConsoleEventHandler` painted failed tool results green.** It chose the
164
+ colour from whether there was any output rather than from `exit_code`, so a
165
+ failure read as a success. Failures are red now, and a failed result with no
166
+ output says so instead of "ran successfully".
167
+ - **`AgentSession.forget()` could race a running turn.** It cleared
168
+ `session_id` without the session lock, so an id the in-flight turn was about
169
+ to write survived the forget, or an id nobody else had was dropped. It now
170
+ follows `adopt`'s rule, refusing under the lock while a turn is in flight,
171
+ and returns `bool` to say which happened.
172
+ - **`interactive_env()` truncated multi-line variables.** The probe parsed
173
+ `env` output by splitting on newlines, so a value containing one (a key, an
174
+ exported shell function) was cut short and its remaining lines became junk
175
+ keys. The probe asks for `env -0` and splits on NUL, falling back to plain
176
+ `env` where `-0` is not supported.
177
+ - **Claude's read-confinement hook quoted paths by hand.** The command was
178
+ assembled by wrapping each part in literal double quotes, which leaves `$`,
179
+ backticks and `"` inside a path live for the shell Claude runs it in. It uses
180
+ `shlex.join` now.
181
+ - **Claude tool results rendered as Python dict reprs.** A `tool_result`
182
+ whose content is a list of blocks, which is how Claude sends anything but
183
+ plain text, was flattened with `str()`, so `ToolResult.stdout` carried
184
+ `{'type': 'text', 'text': 'hi'}` instead of `hi`. Text blocks now
185
+ contribute their text and every other block is serialized as JSON.
186
+ - **`HttpMcpServer` meant a different transport on every provider.** The same
187
+ spec became SSE on claude and copilot but streamable HTTP on gemini, codex
188
+ and opencode, so a server reachable on one provider silently failed on
189
+ another. `HttpMcpServer` now takes
190
+ `transport: Literal["http", "sse"] = "http"`, and each provider renders it
191
+ the way its CLI names it (`{"type": ...}` on claude and copilot,
192
+ `httpUrl`/`url` on gemini). opencode's single `remote` type and Codex's
193
+ `--config` URL override cannot express the choice, so both work it out from
194
+ the endpoint; `docs/mcp.md` has the table. The default changes claude and
195
+ copilot from SSE to streamable HTTP.
196
+ - **Three non-`AgentShimError` exceptions escaped `turn()`**, against the
197
+ documented contract that catching `AgentShimError` is enough. Installing MCP
198
+ servers into a read-only workspace raised `PermissionError` from the config
199
+ write, and now raises `McpConfigError`. A schema carrying a `NaN` or an
200
+ infinity raised `ValueError` from `materialize`, and now raises
201
+ `ProviderCapabilityError`. `TransformingExecutor.check_binary` raised
202
+ `FileNotFoundError` when the transform's own prefix binary (`docker`, a
203
+ sandbox wrapper) was missing, and now raises `CliCheckError`.
204
+ `tests/unit/test_turn_contract.py` pins all three.
205
+ - **Atomic writes replaced symlinks.** `atomic_write` renamed its temporary
206
+ file onto the path it was given, so a `.mcp.json` (or `.gemini/settings.json`,
207
+ or `opencode.json`) that was a symlink into a dotfiles checkout became a
208
+ regular file, and the file it pointed at went stale. Symlinks are now
209
+ followed to the file they name. `install_config_file` resolves the same way,
210
+ so the backup, the merged write and the restore all address one inode, and
211
+ the installation reports the resolved path.
212
+ - **`$schema` and `$id` were rejected everywhere, and unfixable.** Both
213
+ dialects reported them as unsupported keywords, so a schema straight out of
214
+ a generator failed before the process started, and `normalize()` could not
215
+ repair it because it did not strip them. `dialect_problems` now reports them
216
+ under `STRICT` only, which is where they are genuinely refused (Codex's
217
+ `--output-schema` subset); `OPEN` ignores them the way the CLI does. And
218
+ `normalize()` drops the document metadata, `$schema`, `$id`, `title`,
219
+ `description` and `examples`, so a normalized schema is accepted in either
220
+ dialect. A property named `title` is untouched: `properties` is traversed as
221
+ a map of subschemas, not as keywords.
222
+ - **A cancel before the CLI spawned was dropped.** `cancel()` looked only at
223
+ the process handle, which the executor publishes after it has started the
224
+ process. A cancel arriving while the turn was installing MCP servers,
225
+ building argv or spawning therefore did nothing at all. The request is now
226
+ recorded under the session lock and paid out the moment the handle appears.
227
+ It is only ever recorded while a turn is in flight and is cleared when that
228
+ turn ends, so cancelling an idle session still leaves the next turn alone.
229
+ - **A timed-out turn emitted no `RunFinished` and never finished its
230
+ parser.** An `AgentShimError` from the executor skipped the run's closing
231
+ path entirely, so a handler saw a run that started and never ended, and
232
+ whatever the parser had read, the session id included, was thrown away. The
233
+ run now closes the same way it does on a clean exit: `RunFinished(None)`,
234
+ `parser.finish()`, adopt the session id, re-raise. `CliTimeoutError` gained
235
+ `partial: ParsedTurn | None` carrying that reading.
236
+ - **A turn that named its conversation and then failed was unresumable.** The
237
+ nonzero-exit check raised before `parsed.session_id` was adopted, so the id
238
+ the provider had already printed was lost. Adoption now happens first, and
239
+ is skipped only when `classify_exit` reported `SessionResumeError`, which is
240
+ the provider saying the conversation is gone.
241
+ - **MCP restore could destroy the turn.** `ConfigFileInstallation.restore()`
242
+ marked itself done before doing any work and raised `McpConfigError` when
243
+ the agent had left the config file non-JSON or non-object. Raised from the
244
+ session's `finally`, that discarded a successful `TurnResult` or masked the
245
+ real `CliExitError`, and left agentshim's injected entries in the file
246
+ forever. `restore()` now never raises: anything the precise unmerge cannot
247
+ handle falls back to writing the file's original bytes verbatim (removing
248
+ the file when there were none), and it reports what it did as a note the
249
+ session logs through the agent's `log`. `McpInstallation.restore()`
250
+ therefore returns `str | None` rather than `None`.
251
+ - **Undecodable CLI output.** The child was read in text mode with the
252
+ platform default codec and no error handler, and the reader thread
253
+ swallowed the resulting `UnicodeDecodeError`. One byte that is not valid
254
+ UTF-8 therefore discarded the rest of the turn's output and returned exit 0
255
+ with an empty transcript, or, with a large output and no timeout, hung the
256
+ turn forever because nobody was draining the child's pipe. The streams are
257
+ now decoded as UTF-8 with `errors="replace"`, the reader closes its end of
258
+ the pipe when it stops for any reason, and a reader that did fail raises
259
+ `CliExitError` instead of presenting a truncated stream as a clean EOF.
260
+ - **Stdin deadlock.** 0.5 wrote the whole prompt to the child's stdin on the
261
+ calling thread. A prompt larger than the pipe buffer, sent to a CLI that
262
+ was not reading stdin, blocked the turn forever. `HostCommandExecutor`
263
+ now writes stdin from a helper thread and closes it in `finally`.
264
+ - **Callback threading.** 0.5 called the sink directly from two reader
265
+ threads, so an event handler ran concurrently on both and had to do its
266
+ own locking. The executor now drains a queue on the thread that called
267
+ `turn()`, so every callback is serialized there. This is a documented
268
+ contract, not an implementation detail.
269
+ - **Claude read-confinement hook.** The hook was registered as
270
+ `"<path>/confine_reads.py" <roots>`, which needs the file to be
271
+ executable and its shebang to name a usable interpreter. It now runs
272
+ through `sys.executable`, so it uses the interpreter agentshim is running
273
+ under.
274
+ - **opencode tool status.** Tool parts were reported only when their status
275
+ was `"success"` or `"error"`. opencode's terminal status is `"completed"`,
276
+ so every successful tool call was silently dropped. All three terminal
277
+ states are now paired into `ToolCall` and `ToolResult`, with the part's own
278
+ `time` block supplying the duration.
279
+ - **Error frames.** Codex `turn.failed` and top-level `error` frames,
280
+ Gemini `error` frames, opencode `error` frames and Copilot
281
+ `session.error` frames now become `ProviderError` events and set
282
+ `ParsedTurn.error`, instead of being ignored or folded into the turn
283
+ text. Codex stderr lines are `Stderr` events rather than prose in the
284
+ result.
285
+
286
+ ### Added
287
+
288
+ - `agentshim.testing`: `FakeExecutor`, `FakeRun`, `FakeCommandHandle`,
289
+ `RecordingEventHandler` and `scripted_turn(provider, ...)`. There was no
290
+ 0.5 equivalent; a consumer had to write its own `CommandExecutor` fake and
291
+ hand-roll each provider's JSON. `scripted_turn` emits the provider's real
292
+ stream format, produced by `providers/<name>/scripted.py` and round-tripped
293
+ through that provider's real parser, so a consumer's tests never encode a
294
+ provider's wire format.
295
+ - `TransformingExecutor`, for running the CLI under an OS sandbox or inside
296
+ a container by rewriting argv. `HostCommandExecutor.check_binary` now goes
297
+ through `run`, so the health check is transformed too and a
298
+ container-executed CLI can actually be probed; the probe is a normal
299
+ command with a pipe for stdin, so it can never inherit a TTY.
300
+ - `AgentSession.adopt()`, `forget()`, and a thread-safe `cancel()` that
301
+ terminates the process group and kills it after a grace period.
302
+ - Gemini and opencode classify any nonzero exit of a resumed turn as
303
+ `SessionResumeError`, the rule Claude already followed: neither CLI can
304
+ tell a lost session apart from another failure, and a caller holding a
305
+ checkpoint would otherwise offer the same dead id forever. Codex keeps
306
+ matching its explicit "no rollout found" message.
307
+ - The Claude parser only reports `structured_output` for a turn that asked
308
+ for a schema, so an unrequested field can never replace the prose answer.
309
+ - `TurnRequest.mcp_workspace`: the host directory that receives config-file
310
+ MCP installs when the turn has no host `cwd`, which is the container case
311
+ (the CLI runs at the container path while the config file belongs on the
312
+ bind-mounted host workspace). Defaults to `cwd`.
313
+ - `ClaudeProvider`, `CodexProvider`, `CopilotProvider`, `GeminiProvider`,
314
+ `OpencodeProvider` and `SandboxConfig` are exported from `agentshim`
315
+ directly. The docs already told callers to construct them, so they were
316
+ reaching into `agentshim.providers.<name>` for something the public API
317
+ should have carried.
318
+ - `scripts/check_imports.sh` and the `[tool.importlinter]` contract.
319
+ - `__version__` on the package.
320
+
321
+ ### Known limitations
322
+
323
+ - **Copilot CLI reports no token usage.** Verified on Copilot CLI 1.0.83: a
324
+ run prints no `assistant.usage` frame and no per-message `outputTokens`,
325
+ so `TurnResult.usage.tokens` is all zeros for this provider. The
326
+ `session.usage_checkpoint` frame it does print carries
327
+ `totalPremiumRequests` and `totalNanoAiu`, which are billing units rather
328
+ than tokens, plus prompt-cache diagnostics (`prompt_tokens`,
329
+ `frontier_tokens`, `tool_tokens`, per-segment `tokens`) that describe how
330
+ the prompt was assembled and not what the turn was charged. Folding those
331
+ into `input_tokens` would report a number that is not the turn's usage, so
332
+ the parser leaves the frame alone.
333
+ `tests/fixtures/copilot/usage_checkpoint_1_0_83.jsonl` is a recording of
334
+ such a run and pins the behaviour. Copilot also reports no cost.
335
+ - **Codex and Gemini report no cost.** `TurnResult.cost_usd` is `None` on
336
+ both; only Claude Code and opencode report one.
337
+ - **Only Claude Code and Codex accept a native output schema**, and only
338
+ they accept a reasoning effort; asking Gemini, opencode or Copilot for
339
+ either raises `ProviderCapabilityError`. Copilot has an `--effort` flag,
340
+ but its levels are model-specific and unvalidated, so the provider does
341
+ not expose it rather than mistranslating a portable one.
342
+ - **Codex cannot carry HTTP headers on an MCP server.** It configures
343
+ servers through `--config` overrides, which have nowhere to put them, so
344
+ an `HttpMcpServer` with headers raises `ProviderCapabilityError` rather
345
+ than having them dropped silently. The other four pass headers through.
346
+ - **Resume diagnosis is provider-dependent.** Only Codex reports a lost
347
+ conversation distinguishably, on stderr. Claude maps any nonzero exit on a
348
+ resumed turn to `SessionResumeError`, because `claude --resume` gives no
349
+ distinguishable exit code and a failed resumed turn is unusable either
350
+ way. Gemini, opencode and Copilot give no signal at all, so a failed
351
+ resumed turn raises a plain `CliExitError` there.
@@ -0,0 +1,237 @@
1
+ Metadata-Version: 2.5
2
+ Name: agentshim
3
+ Version: 0.6.0
4
+ Summary: Provider-agnostic coding-agent CLI shims
5
+ Requires-Python: >=3.10
6
+ Provides-Extra: test
7
+ Requires-Dist: pytest>=8.0.0; extra == 'test'
8
+ Description-Content-Type: text/markdown
9
+
10
+ # agentshim
11
+
12
+ `agentshim` runs coding-agent CLIs (Claude Code, Codex, Gemini CLI, opencode,
13
+ Copilot CLI) as subprocesses and turns their output into typed events and a
14
+ typed turn result.
15
+
16
+ It owns everything that answers "how do I run provider X and understand what
17
+ it printed". It does not own application policy: which provider to use, when
18
+ to retire a conversation, how to sandbox the host, or how to render events.
19
+
20
+ No required runtime dependencies. Python 3.10+.
21
+
22
+ ## What it includes
23
+
24
+ - `CliAgent` / `AgentSession`: one turn at a time, resumable, cancellable
25
+ - typed events delivered on the calling thread
26
+ - normalized token accounting where `cached_input_tokens <= input_tokens` on
27
+ every provider
28
+ - declared capabilities on `ProviderProfile`, so no caller probes a provider
29
+ - injectable `CommandExecutor`s for running the CLI in a container or over a
30
+ remote shell
31
+ - MCP server installation and restoration per turn
32
+ - native structured output with per-provider schema dialect checks
33
+ - `agentshim.testing`: doubles that emit each provider's real stream format
34
+
35
+ 0.6 ships the core, the execution layer, and all five providers: Claude
36
+ Code, Codex, Gemini CLI, opencode, and Copilot CLI. It is not
37
+ source-compatible with 0.5; see [`CHANGELOG.md`](CHANGELOG.md) for what
38
+ changed and how to migrate.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ uv add agentshim
44
+ ```
45
+
46
+ agentshim does not bundle the agent CLIs. Install and authenticate the one
47
+ you want (`claude`, `codex`, `gemini`, `opencode`, or `copilot`) yourself.
48
+
49
+ ## One turn
50
+
51
+ ```python
52
+ from agentshim import CliAgent
53
+
54
+ agent = CliAgent("claude", model="sonnet")
55
+ result = agent.run("Write a short summary of this codebase.", cwd=".")
56
+
57
+ print(result.text)
58
+ print(result.usage.tokens.input_tokens, result.cost_usd, result.duration_ms)
59
+ ```
60
+
61
+ `model` is an opaque provider-specific string, passed through to the CLI
62
+ unchanged; `None` leaves the CLI's own default.
63
+
64
+ Binary lookup and the CLI health check run once, in the constructor, so a
65
+ broken install fails immediately rather than halfway through a turn.
66
+
67
+ ## A conversation
68
+
69
+ ```python
70
+ session = agent.start_session(cwd=".", timeout=600)
71
+
72
+ session.turn("What does this project do?")
73
+ second = session.turn("Which files should I read first?")
74
+
75
+ assert second.resumed
76
+ print(session.session_id)
77
+ ```
78
+
79
+ `adopt(session_id)` continues a conversation you checkpointed earlier and
80
+ returns `False` if the provider cannot resume or a turn is in flight;
81
+ `forget()` starts fresh on the next turn, and likewise returns `False` while
82
+ a turn is in flight. `cancel()` is thread-safe: it terminates the process
83
+ group, then kills it after a grace period, and works even before the CLI has
84
+ been spawned.
85
+
86
+ ## Per-turn options
87
+
88
+ ```python
89
+ from pathlib import Path
90
+ from agentshim import OutputSchema, StdioMcpServer, TurnRequest
91
+
92
+ schema = {
93
+ "type": "object",
94
+ "properties": {"summary": {"type": "string"}},
95
+ "required": ["summary"],
96
+ "additionalProperties": False,
97
+ }
98
+
99
+ result = session.turn(
100
+ TurnRequest(
101
+ prompt="Summarize the failing test.",
102
+ cwd="/workspace",
103
+ timeout=300, # None means no limit
104
+ reasoning_effort="high",
105
+ output_schema=OutputSchema(schema=schema, host_dir=Path("/tmp/schemas")),
106
+ mcp_servers=[StdioMcpServer(name="issues", command="python", args=["-m", "board.mcp"])],
107
+ env={"CI": "1"},
108
+ extra_args=("--append-system-prompt", "Be terse."),
109
+ )
110
+ )
111
+
112
+ print(result.structured_output)
113
+ ```
114
+
115
+ MCP servers are installed before the turn and restored after it, including
116
+ when the turn fails. Asking for something the provider cannot do raises
117
+ `ProviderCapabilityError` before the process starts.
118
+
119
+ ## Events
120
+
121
+ ```python
122
+ from agentshim import AssistantText, CliAgent, EventHandlerBase, ToolCall
123
+
124
+ class Watcher(EventHandlerBase):
125
+ def on_event(self, event):
126
+ if isinstance(event, ToolCall):
127
+ print("tool:", event.tool)
128
+ elif isinstance(event, AssistantText):
129
+ print(event.text, end="")
130
+
131
+ agent = CliAgent("claude", event_handler=Watcher())
132
+ ```
133
+
134
+ `on_event` always runs on the thread that called `turn()`. The executor reads
135
+ the CLI's pipes on helper threads but drains them on the calling thread, so a
136
+ handler needs no locking of its own.
137
+
138
+ `ConsoleEventHandler` renders to any text stream; `CompositeEventHandler` fans
139
+ out; `NullEventHandler` drops everything.
140
+
141
+ ## Executors
142
+
143
+ Implement `CommandExecutor` to run a provider CLI somewhere other than the
144
+ local host. `TransformingExecutor` covers the common case of rewriting argv,
145
+ and applies to the health check too.
146
+
147
+ ```python
148
+ from dataclasses import replace
149
+ from agentshim import CliAgent, CommandRequest, HostCommandExecutor, TransformingExecutor
150
+
151
+ def in_container(request: CommandRequest) -> CommandRequest:
152
+ return replace(request, argv=["docker", "exec", "-i", "workspace", *request.argv])
153
+
154
+ agent = CliAgent("claude", executor=TransformingExecutor(HostCommandExecutor(), in_container))
155
+ ```
156
+
157
+ ## Testing against agentshim
158
+
159
+ `agentshim.testing` emits each provider's real stream format, so your tests
160
+ never encode a provider's JSON shape.
161
+
162
+ ```python
163
+ from agentshim import AssistantText, CliAgent
164
+ from agentshim.testing import FakeExecutor, RecordingEventHandler, scripted_turn
165
+
166
+ events = RecordingEventHandler()
167
+ agent = CliAgent(
168
+ "claude",
169
+ executor=FakeExecutor(scripted_turn("claude", text="pong", session_id="s1")),
170
+ event_handler=events,
171
+ )
172
+
173
+ result = agent.run("ping")
174
+ assert result.text == "pong"
175
+ assert result.session_id == "s1"
176
+ assert any(isinstance(event, AssistantText) for event in events.events)
177
+ ```
178
+
179
+ Assert on `TurnResult` and the typed events, never on internal attributes.
180
+
181
+ ## Errors
182
+
183
+ Everything that escapes `turn()` is an `AgentShimError`:
184
+
185
+ ```
186
+ AgentShimError
187
+ CliNotFoundError binary not on PATH
188
+ CliCheckError binary found but the health check failed
189
+ CliExitError nonzero exit: argv, returncode, stdout, stderr
190
+ SessionResumeError the conversation is gone: session_id
191
+ CliTimeoutError argv, timeout
192
+ ProviderCapabilityError the provider cannot do what the request asked
193
+ SchemaDialectError problems: list[str]
194
+ McpConfigError config file unreadable or not an object
195
+ ```
196
+
197
+ ## Adding a provider
198
+
199
+ See [`docs/extending.md`](docs/extending.md) and
200
+ [`docs/architecture.md`](docs/architecture.md). A provider package holds only
201
+ argv construction, stream parsing, and provider-specific options; everything
202
+ shared already lives in `agentshim/core/`.
203
+
204
+ ## Development
205
+
206
+ ```bash
207
+ uv sync --dev
208
+ uv run pytest
209
+ ./scripts/format_code.sh --check
210
+ ./scripts/check_errors.sh
211
+ ./scripts/type_check.sh
212
+ ./scripts/check_imports.sh
213
+ ```
214
+
215
+ End-to-end tests under `tests/e2e/` run the real CLIs. They are skipped
216
+ unless `AGENTSHIM_E2E=1` and the binary is on PATH, so CI never runs them.
217
+
218
+ ```bash
219
+ AGENTSHIM_E2E=1 uv run pytest tests/e2e -q
220
+ ```
221
+
222
+ Gemini needs a model the account is entitled to, and opencode takes one when
223
+ the model in your own opencode config is not the one to test:
224
+
225
+ ```bash
226
+ AGENTSHIM_E2E=1 AGENTSHIM_E2E_GEMINI_MODEL=gemini-2.5-flash \
227
+ uv run pytest tests/e2e/test_gemini_e2e.py -q
228
+ ```
229
+
230
+ See [`docs/development.md`](docs/development.md) for the full gate list.
231
+
232
+ ```bash
233
+ uv build # package
234
+ uv publish # release
235
+
236
+ uv run --group docs mkdocs build --strict
237
+ ```