agentshim 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentshim-0.1.0/.github/workflows/ci.yml +18 -0
- agentshim-0.1.0/.github/workflows/publish.yml +41 -0
- agentshim-0.1.0/.gitignore +6 -0
- agentshim-0.1.0/PKG-INFO +61 -0
- agentshim-0.1.0/README.md +50 -0
- agentshim-0.1.0/agentshim/__init__.py +24 -0
- agentshim-0.1.0/agentshim/base.py +52 -0
- agentshim-0.1.0/agentshim/claude/__init__.py +3 -0
- agentshim-0.1.0/agentshim/claude/agent.py +264 -0
- agentshim-0.1.0/agentshim/claude/events.py +149 -0
- agentshim-0.1.0/agentshim/claude/hooks/__init__.py +0 -0
- agentshim-0.1.0/agentshim/claude/hooks/confine_reads.py +77 -0
- agentshim-0.1.0/agentshim/claude_events.py +21 -0
- agentshim-0.1.0/agentshim/cli_agent.py +433 -0
- agentshim-0.1.0/agentshim/codex/__init__.py +3 -0
- agentshim-0.1.0/agentshim/codex/agent.py +236 -0
- agentshim-0.1.0/agentshim/codex/events.py +216 -0
- agentshim-0.1.0/agentshim/codex_events.py +23 -0
- agentshim-0.1.0/agentshim/events.py +24 -0
- agentshim-0.1.0/agentshim/gemini/__init__.py +3 -0
- agentshim-0.1.0/agentshim/gemini/agent.py +234 -0
- agentshim-0.1.0/agentshim/gemini/events.py +78 -0
- agentshim-0.1.0/agentshim/gemini_events.py +11 -0
- agentshim-0.1.0/agentshim/llm_client.py +64 -0
- agentshim-0.1.0/agentshim/mcp_config.py +25 -0
- agentshim-0.1.0/agentshim/opencode/__init__.py +3 -0
- agentshim-0.1.0/agentshim/opencode/agent.py +196 -0
- agentshim-0.1.0/agentshim/opencode/events.py +90 -0
- agentshim-0.1.0/agentshim/opencode_events.py +11 -0
- agentshim-0.1.0/agentshim/py.typed +0 -0
- agentshim-0.1.0/agentshim/sandbox.py +133 -0
- agentshim-0.1.0/agentshim/subagent.py +93 -0
- agentshim-0.1.0/agentshim/trajectory.py +168 -0
- agentshim-0.1.0/agentshim/usage.py +63 -0
- agentshim-0.1.0/agentshim/utils.py +120 -0
- agentshim-0.1.0/pyproject.toml +44 -0
- agentshim-0.1.0/tests/unit/cli_agent/test_agent_cli_cleanup.py +558 -0
- agentshim-0.1.0/tests/unit/cli_agent/test_check_cli.py +38 -0
- agentshim-0.1.0/tests/unit/cli_agent/test_cli_prompt_passing.py +335 -0
- agentshim-0.1.0/tests/unit/llm/conftest.py +33 -0
- agentshim-0.1.0/tests/unit/llm/test_claude_stream.py +603 -0
- agentshim-0.1.0/tests/unit/llm/test_gemini_fixture.py +84 -0
- agentshim-0.1.0/tests/unit/llm/test_gemini_stream.py +186 -0
- agentshim-0.1.0/tests/unit/test_agent_cli_claude.py +252 -0
- agentshim-0.1.0/tests/unit/test_agent_cli_codex.py +69 -0
- agentshim-0.1.0/tests/unit/test_agent_cli_event_parsing.py +346 -0
- agentshim-0.1.0/tests/unit/test_agent_cli_mcp_unsupported.py +38 -0
- agentshim-0.1.0/tests/unit/test_agent_cli_resume.py +283 -0
- agentshim-0.1.0/tests/unit/test_agent_cli_sandbox.py +141 -0
- agentshim-0.1.0/tests/unit/test_cli_agent_usage.py +163 -0
- agentshim-0.1.0/tests/unit/test_coding_agent_recorder_default.py +12 -0
- agentshim-0.1.0/tests/unit/test_mcp_config.py +77 -0
- agentshim-0.1.0/uv.lock +2200 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
test:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
- uses: astral-sh/setup-uv@v6
|
|
13
|
+
- uses: actions/setup-python@v5
|
|
14
|
+
with:
|
|
15
|
+
python-version: "3.11"
|
|
16
|
+
- run: uv sync --dev
|
|
17
|
+
- run: uv run pytest
|
|
18
|
+
- run: uv run ruff check .
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
release:
|
|
6
|
+
types: [published]
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
build:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: astral-sh/setup-uv@v6
|
|
14
|
+
- uses: actions/setup-python@v5
|
|
15
|
+
with:
|
|
16
|
+
python-version: "3.11"
|
|
17
|
+
- name: Build distributions
|
|
18
|
+
run: uv build
|
|
19
|
+
- name: Upload distributions
|
|
20
|
+
uses: actions/upload-artifact@v4
|
|
21
|
+
with:
|
|
22
|
+
name: python-package-distributions
|
|
23
|
+
path: dist/
|
|
24
|
+
|
|
25
|
+
publish:
|
|
26
|
+
needs: build
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
environment:
|
|
29
|
+
name: pypi
|
|
30
|
+
url: https://pypi.org/p/agentshim
|
|
31
|
+
permissions:
|
|
32
|
+
id-token: write
|
|
33
|
+
contents: read
|
|
34
|
+
steps:
|
|
35
|
+
- name: Download distributions
|
|
36
|
+
uses: actions/download-artifact@v4
|
|
37
|
+
with:
|
|
38
|
+
name: python-package-distributions
|
|
39
|
+
path: dist/
|
|
40
|
+
- name: Publish package distributions to PyPI
|
|
41
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
agentshim-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: agentshim
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Provider-agnostic coding-agent CLI shims
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: litellm>=1.0.0
|
|
7
|
+
Requires-Dist: loguru>=0.7.2
|
|
8
|
+
Provides-Extra: test
|
|
9
|
+
Requires-Dist: pytest>=8.0.0; extra == 'test'
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# agentshim
|
|
13
|
+
|
|
14
|
+
`agentshim` is a small Python package that wraps coding-agent CLIs behind a
|
|
15
|
+
stable interface for higher-level runtimes.
|
|
16
|
+
|
|
17
|
+
It currently includes:
|
|
18
|
+
|
|
19
|
+
- a `CodingAgent` base class and provider registry
|
|
20
|
+
- CLI adapters for Claude Code, Codex, Gemini, and Opencode
|
|
21
|
+
- MCP server config dataclasses
|
|
22
|
+
- sandbox settings helpers for Claude Code
|
|
23
|
+
- a lightweight litellm client and isolated subagent helper
|
|
24
|
+
- a trajectory protocol with a no-op implementation
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install agentshim
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
For development:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv sync --dev
|
|
36
|
+
uv run pytest
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick Example
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from agentshim.claude import ClaudeCodeCodingAgent
|
|
43
|
+
|
|
44
|
+
agent = ClaudeCodeCodingAgent(model="sonnet")
|
|
45
|
+
result = agent.generate("Summarize the repository layout.", cwd=".")
|
|
46
|
+
print(result)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Publishing
|
|
50
|
+
|
|
51
|
+
Build locally with:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
uv build
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Publish with:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
uv publish
|
|
61
|
+
```
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# agentshim
|
|
2
|
+
|
|
3
|
+
`agentshim` is a small Python package that wraps coding-agent CLIs behind a
|
|
4
|
+
stable interface for higher-level runtimes.
|
|
5
|
+
|
|
6
|
+
It currently includes:
|
|
7
|
+
|
|
8
|
+
- a `CodingAgent` base class and provider registry
|
|
9
|
+
- CLI adapters for Claude Code, Codex, Gemini, and Opencode
|
|
10
|
+
- MCP server config dataclasses
|
|
11
|
+
- sandbox settings helpers for Claude Code
|
|
12
|
+
- a lightweight litellm client and isolated subagent helper
|
|
13
|
+
- a trajectory protocol with a no-op implementation
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install agentshim
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
For development:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
uv sync --dev
|
|
25
|
+
uv run pytest
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Quick Example
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
from agentshim.claude import ClaudeCodeCodingAgent
|
|
32
|
+
|
|
33
|
+
agent = ClaudeCodeCodingAgent(model="sonnet")
|
|
34
|
+
result = agent.generate("Summarize the repository layout.", cwd=".")
|
|
35
|
+
print(result)
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Publishing
|
|
39
|
+
|
|
40
|
+
Build locally with:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
uv build
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Publish with:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
uv publish
|
|
50
|
+
```
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from .base import CodingAgent
|
|
2
|
+
from .claude import ClaudeCodeCodingAgent
|
|
3
|
+
from .codex import CodexCodingAgent
|
|
4
|
+
from .gemini import GeminiCodingAgent
|
|
5
|
+
from .llm_client import LiteLLMClient
|
|
6
|
+
from .mcp_config import HttpMcpServer, McpServerConfig, StdioMcpServer
|
|
7
|
+
from .opencode import OpencodeCodingAgent
|
|
8
|
+
from .sandbox import SandboxConfig
|
|
9
|
+
from .subagent import call_subagent, litellm_call_with_retry
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"CodingAgent",
|
|
13
|
+
"CodexCodingAgent",
|
|
14
|
+
"GeminiCodingAgent",
|
|
15
|
+
"OpencodeCodingAgent",
|
|
16
|
+
"ClaudeCodeCodingAgent",
|
|
17
|
+
"HttpMcpServer",
|
|
18
|
+
"McpServerConfig",
|
|
19
|
+
"StdioMcpServer",
|
|
20
|
+
"SandboxConfig",
|
|
21
|
+
"call_subagent",
|
|
22
|
+
"litellm_call_with_retry",
|
|
23
|
+
"LiteLLMClient",
|
|
24
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from typing import Any, TypeVar
|
|
3
|
+
|
|
4
|
+
from agentshim.trajectory import NullTrajectoryRecorder, TrajectoryRecorderProtocol
|
|
5
|
+
|
|
6
|
+
_T = TypeVar("_T")
|
|
7
|
+
|
|
8
|
+
AGENT_REGISTRY: dict[str, Any] = {}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def register_provider(*names: str) -> Any:
|
|
12
|
+
"""Decorator to register a coding agent provider.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
*names: List of provider names/aliases (case-insensitive).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def decorator(cls: _T) -> _T:
|
|
19
|
+
for name in names:
|
|
20
|
+
AGENT_REGISTRY[name.lower()] = cls
|
|
21
|
+
return cls
|
|
22
|
+
|
|
23
|
+
return decorator
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CodingAgent(ABC):
|
|
27
|
+
"""Abstract base class for coding agents."""
|
|
28
|
+
|
|
29
|
+
recorder: TrajectoryRecorderProtocol = NullTrajectoryRecorder()
|
|
30
|
+
event_handler: Any | None = None
|
|
31
|
+
|
|
32
|
+
@abstractmethod
|
|
33
|
+
def generate(
|
|
34
|
+
self,
|
|
35
|
+
prompt: str,
|
|
36
|
+
cwd: str | None = None,
|
|
37
|
+
timeout: int = 300,
|
|
38
|
+
silent: bool = False,
|
|
39
|
+
) -> str:
|
|
40
|
+
"""One-shot prompt → reply. Equivalent to a fresh ``start_session``
|
|
41
|
+
followed by a single ``session.generate(prompt)``; no conversation
|
|
42
|
+
state is retained. Use ``start_session`` for multi-turn flows.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
prompt: The prompt to send to the agent.
|
|
46
|
+
cwd: Optional working directory context.
|
|
47
|
+
timeout: Timeout in seconds.
|
|
48
|
+
silent: If True, suppress stdout printing of the agent's output.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Generated text.
|
|
52
|
+
"""
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from agentshim.trajectory import TrajectoryRecorderProtocol
|
|
8
|
+
|
|
9
|
+
from ..base import register_provider
|
|
10
|
+
from ..cli_agent import CLICodingAgent, CLIGenerationSession
|
|
11
|
+
from ..events import AgentEventHandler
|
|
12
|
+
from ..mcp_config import HttpMcpServer, McpServerConfig
|
|
13
|
+
from ..sandbox import SandboxConfig, build_claude_sandbox_settings, resolve_sandbox
|
|
14
|
+
from ..usage import ProviderUsage, TokenUsage
|
|
15
|
+
from .events import (
|
|
16
|
+
ClaudeEvent,
|
|
17
|
+
MultiEvent,
|
|
18
|
+
ResultEvent,
|
|
19
|
+
SystemEvent,
|
|
20
|
+
TextEvent,
|
|
21
|
+
ToolResultEvent,
|
|
22
|
+
ToolUseEvent,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ClaudeGenerationSession(CLIGenerationSession):
|
|
27
|
+
def __init__(self, **kwargs: Any):
|
|
28
|
+
super().__init__(**kwargs)
|
|
29
|
+
# Initialize state required for stream processing
|
|
30
|
+
self.tool_map: dict[str, str] = {}
|
|
31
|
+
self.tool_start_times: dict[str, float] = {}
|
|
32
|
+
self.tool_args: dict[str, Any] = {}
|
|
33
|
+
self.final_result: str | None = None
|
|
34
|
+
|
|
35
|
+
def _process_stdout(self, line: str) -> None:
|
|
36
|
+
"""Process a line from stdout."""
|
|
37
|
+
if not line:
|
|
38
|
+
return
|
|
39
|
+
try:
|
|
40
|
+
data = json.loads(line)
|
|
41
|
+
event = ClaudeEvent.from_dict(data)
|
|
42
|
+
if event:
|
|
43
|
+
self._handle_event(event)
|
|
44
|
+
except json.JSONDecodeError:
|
|
45
|
+
# Fallback for non-JSON lines - still accumulate them
|
|
46
|
+
self.stdout_lines.append(line.rstrip())
|
|
47
|
+
if not self.silent:
|
|
48
|
+
if self._at_line_start:
|
|
49
|
+
self._log_raw(f"{self.log_prefix} ")
|
|
50
|
+
self._log_raw(line.rstrip() + "\n")
|
|
51
|
+
self._at_line_start = True
|
|
52
|
+
|
|
53
|
+
def _handle_event(self, event: ClaudeEvent):
|
|
54
|
+
"""Handle a single parsed Claude event."""
|
|
55
|
+
if isinstance(event, MultiEvent):
|
|
56
|
+
for sub_event in event.events:
|
|
57
|
+
self._handle_event(sub_event)
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
self._update_state(event)
|
|
61
|
+
|
|
62
|
+
if not self.silent:
|
|
63
|
+
self._render_event(event)
|
|
64
|
+
|
|
65
|
+
def _update_state(self, event: ClaudeEvent):
|
|
66
|
+
"""Update internal state based on the event."""
|
|
67
|
+
if isinstance(event, SystemEvent):
|
|
68
|
+
if self.session_id is None and event.session_id:
|
|
69
|
+
self.session_id = event.session_id
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
if isinstance(event, TextEvent):
|
|
73
|
+
self.stdout_lines.append(event.text)
|
|
74
|
+
if self.event_handler:
|
|
75
|
+
self.event_handler.on_thinking(event.text)
|
|
76
|
+
|
|
77
|
+
elif isinstance(event, ToolUseEvent):
|
|
78
|
+
if event.tool_id:
|
|
79
|
+
self.tool_map[event.tool_id] = event.tool_name
|
|
80
|
+
self.tool_start_times[event.tool_id] = time.time()
|
|
81
|
+
self.tool_args[event.tool_id] = event.parameters
|
|
82
|
+
if self.event_handler:
|
|
83
|
+
self.event_handler.on_tool_call(event.tool_name, event.parameters)
|
|
84
|
+
|
|
85
|
+
elif isinstance(event, ToolResultEvent):
|
|
86
|
+
if event.tool_id:
|
|
87
|
+
event.tool_name_resolved = self.tool_map.get(event.tool_id, "Tool")
|
|
88
|
+
|
|
89
|
+
start_time = self.tool_start_times.get(event.tool_id)
|
|
90
|
+
duration = time.time() - start_time if start_time else None
|
|
91
|
+
args = self.tool_args.get(event.tool_id, {})
|
|
92
|
+
|
|
93
|
+
self.recorder.add_tool_call(
|
|
94
|
+
tool=event.tool_name_resolved,
|
|
95
|
+
args=args,
|
|
96
|
+
stdout=event.output,
|
|
97
|
+
duration=duration,
|
|
98
|
+
)
|
|
99
|
+
if self.event_handler:
|
|
100
|
+
self.event_handler.on_tool_result(
|
|
101
|
+
tool=event.tool_name_resolved,
|
|
102
|
+
stdout=event.output,
|
|
103
|
+
duration=duration,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
elif isinstance(event, ResultEvent):
|
|
107
|
+
self.final_result = event.result
|
|
108
|
+
# Anthropic reports cache_creation + cache_read as disjoint
|
|
109
|
+
# from input_tokens; fold them into input_tokens to match the
|
|
110
|
+
# crucible invariant (cached ⊆ input).
|
|
111
|
+
usage = event.usage or {}
|
|
112
|
+
cached = int(usage.get("cache_creation_input_tokens") or 0) + int(usage.get("cache_read_input_tokens") or 0)
|
|
113
|
+
self.usage = ProviderUsage(
|
|
114
|
+
tokens=TokenUsage(
|
|
115
|
+
input_tokens=int(usage.get("input_tokens") or 0) + cached,
|
|
116
|
+
output_tokens=int(usage.get("output_tokens") or 0),
|
|
117
|
+
cached_input_tokens=cached,
|
|
118
|
+
turns=int(event.num_turns or 0),
|
|
119
|
+
),
|
|
120
|
+
total_cost_usd=event.total_cost_usd,
|
|
121
|
+
provider="claude",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def _render_event(self, event: ClaudeEvent):
|
|
125
|
+
"""Render the event to stdout."""
|
|
126
|
+
if isinstance(event, TextEvent):
|
|
127
|
+
self._print_stream_content(event.text)
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
if not self._at_line_start:
|
|
131
|
+
self._log_raw("\n")
|
|
132
|
+
self._at_line_start = True
|
|
133
|
+
|
|
134
|
+
output = event.render(self.log_prefix)
|
|
135
|
+
if output:
|
|
136
|
+
self._log_raw(output + "\n")
|
|
137
|
+
|
|
138
|
+
def run(self, prompt: str) -> str:
|
|
139
|
+
"""Execute the command and return the result."""
|
|
140
|
+
super().run(prompt)
|
|
141
|
+
if self.final_result:
|
|
142
|
+
return self.final_result
|
|
143
|
+
return "\n".join(self.stdout_lines)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@register_provider("claude", "claude-code", "anthropic")
|
|
147
|
+
class ClaudeCodeCodingAgent(CLICodingAgent):
|
|
148
|
+
"""Coding agent implementation using the Claude Code CLI tool."""
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
model: str | None = None,
|
|
153
|
+
recorder: TrajectoryRecorderProtocol | None = None,
|
|
154
|
+
event_handler: AgentEventHandler | None = None,
|
|
155
|
+
mcp_servers: list[McpServerConfig] | None = None,
|
|
156
|
+
sandbox: bool | SandboxConfig = False,
|
|
157
|
+
):
|
|
158
|
+
"""Initialize the Claude Code coding agent.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
model: Optional model name to use with Claude Code. If None, uses default.
|
|
162
|
+
recorder: Trajectory recorder instance.
|
|
163
|
+
event_handler: Optional event handler for UI updates.
|
|
164
|
+
mcp_servers: Optional list of MCP server configurations.
|
|
165
|
+
sandbox: If True (or a ``SandboxConfig``), enable Claude Code's
|
|
166
|
+
native sandbox (bubblewrap on Linux / Seatbelt on macOS)
|
|
167
|
+
by injecting a ``sandbox`` settings block via
|
|
168
|
+
``--settings``. Only bash subprocess commands are
|
|
169
|
+
sandboxed; the Claude process itself is not wrapped.
|
|
170
|
+
Defaults to False (no sandbox).
|
|
171
|
+
"""
|
|
172
|
+
super().__init__("claude", model, recorder, event_handler, mcp_servers)
|
|
173
|
+
self.sandbox = resolve_sandbox(sandbox)
|
|
174
|
+
if self.sandbox is not None:
|
|
175
|
+
# Without this, Claude Code cd's into a per-invocation scratch dir
|
|
176
|
+
# under <project-root>/.local_tmp/claude-$UID/cwd-* before every
|
|
177
|
+
# Bash call. That dir is outside the sandbox's allow_write set, so
|
|
178
|
+
# every sandboxed Bash invocation fails with EROFS before its
|
|
179
|
+
# command runs. Maintaining the project cwd avoids the scratch dir.
|
|
180
|
+
self.env["CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR"] = "1"
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def claude_path(self) -> str:
|
|
184
|
+
"""Return path to claude binary (for backward compatibility)."""
|
|
185
|
+
return self.binary_path
|
|
186
|
+
|
|
187
|
+
@property
|
|
188
|
+
def _log_prefix(self) -> str:
|
|
189
|
+
"""Return the log prefix for this agent."""
|
|
190
|
+
return "[Claude]"
|
|
191
|
+
|
|
192
|
+
def _build_mcp_config_json(self) -> str:
|
|
193
|
+
"""Build the JSON string for --mcp-config.
|
|
194
|
+
|
|
195
|
+
Claude Code runs the rendered config through ``--strict-mcp-config``
|
|
196
|
+
validation, which requires HTTP servers to declare ``type``
|
|
197
|
+
explicitly (``"sse"`` or ``"http"``). ``HttpMcpServer`` represents
|
|
198
|
+
the SSE transport (the field doc says HTTP/SSE; current call sites
|
|
199
|
+
use ``…/sse`` URLs), so emit ``type: "sse"``. ``headers`` is
|
|
200
|
+
included only when non-empty, mirroring the schema's optional
|
|
201
|
+
nature.
|
|
202
|
+
"""
|
|
203
|
+
servers: dict[str, dict[str, Any]] = {}
|
|
204
|
+
for server in self.mcp_servers:
|
|
205
|
+
if isinstance(server, HttpMcpServer):
|
|
206
|
+
http_entry: dict[str, Any] = {"type": "sse", "url": server.url}
|
|
207
|
+
if server.headers:
|
|
208
|
+
http_entry["headers"] = dict(server.headers)
|
|
209
|
+
servers[server.name] = http_entry
|
|
210
|
+
else:
|
|
211
|
+
entry: dict[str, Any] = {"command": server.command, "args": server.args}
|
|
212
|
+
if server.env:
|
|
213
|
+
entry["env"] = server.env
|
|
214
|
+
servers[server.name] = entry
|
|
215
|
+
return json.dumps({"mcpServers": servers})
|
|
216
|
+
|
|
217
|
+
def _get_command(self, prompt: str, resume_session_id: str | None = None) -> list[str]:
|
|
218
|
+
cmd = [
|
|
219
|
+
self.binary_path,
|
|
220
|
+
"-p", # Print mode, accepts prompt from stdin
|
|
221
|
+
"--dangerously-skip-permissions", # Auto-approval mode
|
|
222
|
+
"--output-format",
|
|
223
|
+
"stream-json",
|
|
224
|
+
"--verbose",
|
|
225
|
+
]
|
|
226
|
+
if resume_session_id:
|
|
227
|
+
cmd.extend(["--resume", resume_session_id])
|
|
228
|
+
cmd.append(prompt)
|
|
229
|
+
if self.model:
|
|
230
|
+
cmd.extend(["--model", self.model])
|
|
231
|
+
if self.mcp_servers:
|
|
232
|
+
cmd.extend(
|
|
233
|
+
[
|
|
234
|
+
"--mcp-config",
|
|
235
|
+
self._build_mcp_config_json(),
|
|
236
|
+
"--strict-mcp-config",
|
|
237
|
+
]
|
|
238
|
+
)
|
|
239
|
+
if self.sandbox is not None:
|
|
240
|
+
cmd.extend(["--settings", json.dumps(build_claude_sandbox_settings(self.sandbox))])
|
|
241
|
+
return cmd
|
|
242
|
+
|
|
243
|
+
def _create_session(
|
|
244
|
+
self,
|
|
245
|
+
cmd: list[str],
|
|
246
|
+
cwd: str | None = None,
|
|
247
|
+
timeout: int = 300,
|
|
248
|
+
silent: bool = False,
|
|
249
|
+
recorder: TrajectoryRecorderProtocol | None = None,
|
|
250
|
+
on_process_started: Callable[[subprocess.Popen[str]], None] | None = None,
|
|
251
|
+
) -> ClaudeGenerationSession:
|
|
252
|
+
return ClaudeGenerationSession(
|
|
253
|
+
binary_name=self.binary_name,
|
|
254
|
+
env=self.env,
|
|
255
|
+
log_prefix=self._log_prefix,
|
|
256
|
+
cmd=cmd,
|
|
257
|
+
logger=self.logger,
|
|
258
|
+
cwd=cwd,
|
|
259
|
+
timeout=timeout,
|
|
260
|
+
silent=silent,
|
|
261
|
+
recorder=recorder,
|
|
262
|
+
event_handler=self.event_handler,
|
|
263
|
+
on_process_started=on_process_started,
|
|
264
|
+
)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
from ..utils import truncate_content, truncate_tool_params
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ClaudeEvent(ABC):
|
|
10
|
+
"""Base class for Claude Code stream events."""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def render(self, log_prefix: str) -> str | None:
|
|
14
|
+
"""Render the event as a string for terminal output."""
|
|
15
|
+
|
|
16
|
+
@staticmethod
|
|
17
|
+
def from_dict(data: dict[str, Any]) -> ClaudeEvent | None:
|
|
18
|
+
"""Factory method to create events from JSON data."""
|
|
19
|
+
event_type = data.get("type")
|
|
20
|
+
|
|
21
|
+
if event_type == "system":
|
|
22
|
+
return SystemEvent(data)
|
|
23
|
+
if event_type == "assistant":
|
|
24
|
+
message = data.get("message", {})
|
|
25
|
+
content_blocks = message.get("content", [])
|
|
26
|
+
events: list[ClaudeEvent] = []
|
|
27
|
+
for block in content_blocks:
|
|
28
|
+
block_type = block.get("type")
|
|
29
|
+
if block_type == "text":
|
|
30
|
+
events.append(TextEvent(block.get("text", "")))
|
|
31
|
+
elif block_type == "tool_use":
|
|
32
|
+
events.append(
|
|
33
|
+
ToolUseEvent(
|
|
34
|
+
tool_name=block.get("name", "Tool"),
|
|
35
|
+
tool_id=block.get("id"),
|
|
36
|
+
parameters=block.get("input"),
|
|
37
|
+
)
|
|
38
|
+
)
|
|
39
|
+
return MultiEvent(events) if events else None
|
|
40
|
+
if event_type == "user":
|
|
41
|
+
message = data.get("message", {})
|
|
42
|
+
content_blocks = message.get("content", [])
|
|
43
|
+
for block in content_blocks:
|
|
44
|
+
if block.get("type") == "tool_result":
|
|
45
|
+
return ToolResultEvent(
|
|
46
|
+
output=block.get("content", ""),
|
|
47
|
+
tool_id=block.get("tool_use_id"),
|
|
48
|
+
)
|
|
49
|
+
return None
|
|
50
|
+
if event_type == "result":
|
|
51
|
+
return ResultEvent(
|
|
52
|
+
result=data.get("result", ""),
|
|
53
|
+
num_turns=data.get("num_turns"),
|
|
54
|
+
usage=data.get("usage"),
|
|
55
|
+
total_cost_usd=data.get("total_cost_usd"),
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class MultiEvent(ClaudeEvent):
|
|
62
|
+
"""Container for multiple events from a single message."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, events: list[ClaudeEvent]):
|
|
65
|
+
self.events = events
|
|
66
|
+
|
|
67
|
+
def render(self, log_prefix: str) -> str | None:
|
|
68
|
+
# MultiEvent doesn't render itself; events are handled individually
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SystemEvent(ClaudeEvent):
|
|
73
|
+
"""System initialization event.
|
|
74
|
+
|
|
75
|
+
Carries the provider ``session_id`` on the ``init`` subtype, used to
|
|
76
|
+
enable conversation resumption via ``claude --resume <id>``.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
def __init__(self, data: dict[str, Any]):
|
|
80
|
+
self.data = data
|
|
81
|
+
self.session_id: str | None = data.get("session_id")
|
|
82
|
+
|
|
83
|
+
def render(self, log_prefix: str) -> str | None:
|
|
84
|
+
# System events are silent
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class TextEvent(ClaudeEvent):
|
|
89
|
+
"""Assistant text content event."""
|
|
90
|
+
|
|
91
|
+
def __init__(self, text: str):
|
|
92
|
+
self.text = text
|
|
93
|
+
|
|
94
|
+
def render(self, log_prefix: str) -> str | None:
|
|
95
|
+
# Text rendering is handled specially due to streaming
|
|
96
|
+
return self.text
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class ToolUseEvent(ClaudeEvent):
|
|
100
|
+
"""Tool call event from assistant."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, tool_name: str, tool_id: str | None, parameters: Any):
|
|
103
|
+
self.tool_name = tool_name
|
|
104
|
+
self.tool_id = tool_id
|
|
105
|
+
self.parameters = parameters
|
|
106
|
+
|
|
107
|
+
def render(self, log_prefix: str) -> str:
|
|
108
|
+
truncated = truncate_tool_params(self.tool_name, self.parameters)
|
|
109
|
+
return f"{log_prefix} \033[34m[Tool Use] {self.tool_name} {truncated}\033[0m"
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class ToolResultEvent(ClaudeEvent):
|
|
113
|
+
"""Tool execution result event."""
|
|
114
|
+
|
|
115
|
+
def __init__(self, output: Any, tool_id: str | None):
|
|
116
|
+
# Convert output to string if it's not already
|
|
117
|
+
if isinstance(output, list):
|
|
118
|
+
# Handle list content (e.g., from tool_result blocks with multiple items)
|
|
119
|
+
self.output = "\n".join(str(item) for item in cast("list[Any]", output))
|
|
120
|
+
else:
|
|
121
|
+
self.output = str(output) if output else ""
|
|
122
|
+
self.tool_id = tool_id
|
|
123
|
+
self.tool_name_resolved: str = "Tool" # To be set externally
|
|
124
|
+
|
|
125
|
+
def render(self, log_prefix: str) -> str:
|
|
126
|
+
if not self.output:
|
|
127
|
+
return f"{log_prefix} \033[32m{self.tool_name_resolved} ran successfully\033[0m"
|
|
128
|
+
truncated = truncate_content(self.output)
|
|
129
|
+
return f"{log_prefix} \033[32m[Tool Result] {truncated}\033[0m"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class ResultEvent(ClaudeEvent):
|
|
133
|
+
"""Final session summary event."""
|
|
134
|
+
|
|
135
|
+
def __init__(
|
|
136
|
+
self,
|
|
137
|
+
result: str,
|
|
138
|
+
num_turns: int | None = None,
|
|
139
|
+
usage: dict[str, Any] | None = None,
|
|
140
|
+
total_cost_usd: float | None = None,
|
|
141
|
+
):
|
|
142
|
+
self.result = result
|
|
143
|
+
self.num_turns = num_turns
|
|
144
|
+
self.usage = usage
|
|
145
|
+
self.total_cost_usd = total_cost_usd
|
|
146
|
+
|
|
147
|
+
def render(self, log_prefix: str) -> str | None:
|
|
148
|
+
# Result events are silent (result is captured separately)
|
|
149
|
+
return None
|
|
File without changes
|