agentshim 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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
+ ]
agentshim/base.py ADDED
@@ -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,3 @@
1
+ from .agent import ClaudeCodeCodingAgent, ClaudeGenerationSession
2
+
3
+ __all__ = ["ClaudeCodeCodingAgent", "ClaudeGenerationSession"]
@@ -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
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env python3
2
+ """Claude Code ``PreToolUse`` hook that denies file reads/edits outside an allowlist.
3
+
4
+ Wired up via ``SandboxConfig.confine_native_reads_to`` — see
5
+ ``agentshim/sandbox.py``. The hook is invoked by Claude Code for each
6
+ tool call matching ``Read|Glob|Grep|Edit|Write``. It parses the JSON input
7
+ on stdin, resolves the tool's target path, and rejects the call if the path
8
+ doesn't land under one of the allowed roots passed on argv.
9
+
10
+ Usage::
11
+
12
+ confine_reads.py <allowed_root> [<allowed_root> ...]
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import sys
20
+ from typing import Any
21
+
22
+
23
+ def _candidate_path(tool_input: dict[str, Any]) -> str | None:
24
+ """Best-effort extraction of the filesystem target from a tool_input blob."""
25
+ for key in ("file_path", "path", "notebook_path"):
26
+ value = tool_input.get(key)
27
+ if isinstance(value, str) and value:
28
+ return value
29
+ return None
30
+
31
+
32
+ def _is_under(path: str, roots: list[str]) -> bool:
33
+ for root in roots:
34
+ if path == root:
35
+ return True
36
+ if path.startswith(root.rstrip(os.sep) + os.sep):
37
+ return True
38
+ return False
39
+
40
+
41
+ def main() -> int:
42
+ if len(sys.argv) < 2:
43
+ # Misconfigured hook — don't block the tool, just let it through.
44
+ return 0
45
+
46
+ roots = [os.path.realpath(p) for p in sys.argv[1:]]
47
+
48
+ try:
49
+ payload: dict[str, Any] = json.load(sys.stdin)
50
+ except json.JSONDecodeError:
51
+ return 0
52
+
53
+ tool_input: dict[str, Any] = payload.get("tool_input") or {}
54
+ candidate = _candidate_path(tool_input)
55
+ if not candidate:
56
+ # Tool call has no path argument (e.g. Glob without `path` defaults to cwd).
57
+ return 0
58
+
59
+ target = os.path.realpath(os.path.abspath(candidate))
60
+ if _is_under(target, roots):
61
+ return 0
62
+
63
+ decision = {
64
+ "hookSpecificOutput": {
65
+ "hookEventName": "PreToolUse",
66
+ "permissionDecision": "deny",
67
+ "permissionDecisionReason": (
68
+ f"Path {target!r} is outside the sandbox roots {roots}. Stay inside the working directory."
69
+ ),
70
+ }
71
+ }
72
+ json.dump(decision, sys.stdout)
73
+ return 0
74
+
75
+
76
+ if __name__ == "__main__":
77
+ sys.exit(main())
@@ -0,0 +1,21 @@
1
+ """Compatibility shim for older ``agentshim.claude_events`` imports."""
2
+
3
+ from .claude.events import (
4
+ ClaudeEvent,
5
+ MultiEvent,
6
+ ResultEvent,
7
+ SystemEvent,
8
+ TextEvent,
9
+ ToolResultEvent,
10
+ ToolUseEvent,
11
+ )
12
+
13
+ __all__ = [
14
+ "ClaudeEvent",
15
+ "MultiEvent",
16
+ "ResultEvent",
17
+ "SystemEvent",
18
+ "TextEvent",
19
+ "ToolResultEvent",
20
+ "ToolUseEvent",
21
+ ]