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 +24 -0
- agentshim/base.py +52 -0
- agentshim/claude/__init__.py +3 -0
- agentshim/claude/agent.py +264 -0
- agentshim/claude/events.py +149 -0
- agentshim/claude/hooks/__init__.py +0 -0
- agentshim/claude/hooks/confine_reads.py +77 -0
- agentshim/claude_events.py +21 -0
- agentshim/cli_agent.py +433 -0
- agentshim/codex/__init__.py +3 -0
- agentshim/codex/agent.py +236 -0
- agentshim/codex/events.py +216 -0
- agentshim/codex_events.py +23 -0
- agentshim/events.py +24 -0
- agentshim/gemini/__init__.py +3 -0
- agentshim/gemini/agent.py +234 -0
- agentshim/gemini/events.py +78 -0
- agentshim/gemini_events.py +11 -0
- agentshim/llm_client.py +64 -0
- agentshim/mcp_config.py +25 -0
- agentshim/opencode/__init__.py +3 -0
- agentshim/opencode/agent.py +196 -0
- agentshim/opencode/events.py +90 -0
- agentshim/opencode_events.py +11 -0
- agentshim/py.typed +0 -0
- agentshim/sandbox.py +133 -0
- agentshim/subagent.py +93 -0
- agentshim/trajectory.py +168 -0
- agentshim/usage.py +63 -0
- agentshim/utils.py +120 -0
- agentshim-0.1.0.dist-info/METADATA +61 -0
- agentshim-0.1.0.dist-info/RECORD +33 -0
- agentshim-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from agentshim.trajectory import TrajectoryRecorderProtocol
|
|
7
|
+
|
|
8
|
+
from ..base import register_provider
|
|
9
|
+
from ..cli_agent import CLICodingAgent, CLIGenerationSession
|
|
10
|
+
from ..events import AgentEventHandler
|
|
11
|
+
from ..sandbox import SandboxConfig
|
|
12
|
+
from ..usage import ProviderUsage, TokenUsage
|
|
13
|
+
from .events import OpencodeEvent, StepFinishEvent, TextEvent, ToolUseEvent
|
|
14
|
+
|
|
15
|
+
OPENCODE_DEFAULT_MODEL = "google-vertex/gemini-3-pro-preview"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _to_args_dict(input_data: Any) -> dict[str, Any]:
|
|
19
|
+
"""Convert input_data to a dict[str, Any] for tool call recording."""
|
|
20
|
+
if isinstance(input_data, dict):
|
|
21
|
+
return {str(k): v for k, v in input_data.items()} # type: ignore[reportUnknownVariableType]
|
|
22
|
+
return {"input": input_data}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OpencodeGenerationSession(CLIGenerationSession):
|
|
26
|
+
def __init__(self, **kwargs: Any):
|
|
27
|
+
super().__init__(**kwargs)
|
|
28
|
+
# Accumulates usage across step_finish events.
|
|
29
|
+
self._accumulated_tokens = TokenUsage()
|
|
30
|
+
self._accumulated_cost_usd: float = 0.0
|
|
31
|
+
self._saw_cost: bool = False
|
|
32
|
+
|
|
33
|
+
def _process_stdout(self, line: str) -> None:
|
|
34
|
+
"""Process a line from stdout."""
|
|
35
|
+
if not line:
|
|
36
|
+
return
|
|
37
|
+
try:
|
|
38
|
+
data = json.loads(line)
|
|
39
|
+
if self.session_id is None:
|
|
40
|
+
sid = data.get("sessionID")
|
|
41
|
+
if isinstance(sid, str) and sid:
|
|
42
|
+
self.session_id = sid
|
|
43
|
+
event = OpencodeEvent.from_dict(data)
|
|
44
|
+
if event:
|
|
45
|
+
self._handle_event(event)
|
|
46
|
+
except json.JSONDecodeError:
|
|
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: OpencodeEvent):
|
|
54
|
+
"""Handle a single parsed Opencode event."""
|
|
55
|
+
if isinstance(event, TextEvent):
|
|
56
|
+
self.stdout_lines.append(event.text)
|
|
57
|
+
if self.event_handler:
|
|
58
|
+
self.event_handler.on_thinking(event.text)
|
|
59
|
+
|
|
60
|
+
elif isinstance(event, StepFinishEvent):
|
|
61
|
+
self._update_usage_from_step(event)
|
|
62
|
+
|
|
63
|
+
elif isinstance(event, ToolUseEvent):
|
|
64
|
+
if event.status in ("success", "error"):
|
|
65
|
+
args = _to_args_dict(event.input_data)
|
|
66
|
+
stdout = str(event.output_data) if event.output_data is not None else ""
|
|
67
|
+
|
|
68
|
+
self.recorder.add_tool_call(
|
|
69
|
+
tool=event.tool_name,
|
|
70
|
+
args=args,
|
|
71
|
+
stdout=stdout,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if self.event_handler:
|
|
75
|
+
self.event_handler.on_tool_call(event.tool_name, args)
|
|
76
|
+
self.event_handler.on_tool_result(
|
|
77
|
+
tool=event.tool_name,
|
|
78
|
+
stdout=stdout,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if not self.silent:
|
|
82
|
+
self._render_event(event)
|
|
83
|
+
|
|
84
|
+
def _update_usage_from_step(self, event: StepFinishEvent) -> None:
|
|
85
|
+
"""Fold a step_finish payload into the running usage totals."""
|
|
86
|
+
tokens: dict[str, Any] = event.tokens or {}
|
|
87
|
+
cache: dict[str, Any] = tokens.get("cache") or {}
|
|
88
|
+
cache_read = int(cache.get("read") or 0)
|
|
89
|
+
cache_write = int(cache.get("write") or 0)
|
|
90
|
+
cached = cache_read + cache_write
|
|
91
|
+
step_usage = TokenUsage(
|
|
92
|
+
input_tokens=int(tokens.get("input") or 0) + cached,
|
|
93
|
+
output_tokens=int(tokens.get("output") or 0) + int(tokens.get("reasoning") or 0),
|
|
94
|
+
cached_input_tokens=cached,
|
|
95
|
+
turns=1,
|
|
96
|
+
)
|
|
97
|
+
self._accumulated_tokens = self._accumulated_tokens + step_usage
|
|
98
|
+
if event.cost is not None:
|
|
99
|
+
self._accumulated_cost_usd += float(event.cost)
|
|
100
|
+
self._saw_cost = True
|
|
101
|
+
self.usage = ProviderUsage(
|
|
102
|
+
tokens=self._accumulated_tokens,
|
|
103
|
+
total_cost_usd=self._accumulated_cost_usd if self._saw_cost else None,
|
|
104
|
+
provider="opencode",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def _render_event(self, event: OpencodeEvent):
|
|
108
|
+
"""Render the event to stdout."""
|
|
109
|
+
if isinstance(event, TextEvent):
|
|
110
|
+
self._print_stream_content(event.text)
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
if not self._at_line_start:
|
|
114
|
+
self._log_raw("\n")
|
|
115
|
+
self._at_line_start = True
|
|
116
|
+
|
|
117
|
+
output = event.render(self.log_prefix)
|
|
118
|
+
if output:
|
|
119
|
+
self._log_raw(output + "\n")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@register_provider("opencode")
|
|
123
|
+
class OpencodeCodingAgent(CLICodingAgent):
|
|
124
|
+
"""Coding agent implementation using the Opencode CLI tool."""
|
|
125
|
+
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
model: str | None = None,
|
|
129
|
+
recorder: TrajectoryRecorderProtocol | None = None,
|
|
130
|
+
event_handler: AgentEventHandler | None = None,
|
|
131
|
+
mcp_servers: list[object] | None = None,
|
|
132
|
+
sandbox: bool | SandboxConfig = False,
|
|
133
|
+
):
|
|
134
|
+
"""Initialize the Opencode coding agent.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
model: Optional model name to use.
|
|
138
|
+
recorder: Trajectory recorder instance.
|
|
139
|
+
event_handler: Optional event handler for UI updates.
|
|
140
|
+
mcp_servers: Optional list of MCP server configurations.
|
|
141
|
+
sandbox: Not supported for Opencode; must be False.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
ValueError: If mcp_servers is non-empty (not supported).
|
|
145
|
+
NotImplementedError: If ``sandbox`` is truthy.
|
|
146
|
+
"""
|
|
147
|
+
if mcp_servers:
|
|
148
|
+
raise ValueError("OpencodeCodingAgent does not support programmatic MCP server configuration via CLI flags")
|
|
149
|
+
if sandbox:
|
|
150
|
+
raise NotImplementedError("sandbox is not supported for OpencodeCodingAgent")
|
|
151
|
+
if not model:
|
|
152
|
+
model = OPENCODE_DEFAULT_MODEL
|
|
153
|
+
super().__init__("opencode", model, recorder, event_handler)
|
|
154
|
+
|
|
155
|
+
@property
|
|
156
|
+
def _log_prefix(self) -> str:
|
|
157
|
+
"""Return the log prefix for this agent."""
|
|
158
|
+
return "[Opencode]"
|
|
159
|
+
|
|
160
|
+
def _get_command(self, prompt: str, resume_session_id: str | None = None) -> list[str]:
|
|
161
|
+
cmd = [self.binary_path, "run"]
|
|
162
|
+
|
|
163
|
+
if resume_session_id:
|
|
164
|
+
cmd.extend(["--session", resume_session_id])
|
|
165
|
+
|
|
166
|
+
cmd.append(f'"{prompt}"')
|
|
167
|
+
|
|
168
|
+
if self.model:
|
|
169
|
+
cmd.extend(["--model", self.model])
|
|
170
|
+
|
|
171
|
+
cmd.extend(["--format=json"])
|
|
172
|
+
|
|
173
|
+
return cmd
|
|
174
|
+
|
|
175
|
+
def _create_session(
|
|
176
|
+
self,
|
|
177
|
+
cmd: list[str],
|
|
178
|
+
cwd: str | None = None,
|
|
179
|
+
timeout: int = 300,
|
|
180
|
+
silent: bool = False,
|
|
181
|
+
recorder: TrajectoryRecorderProtocol | None = None,
|
|
182
|
+
on_process_started: Callable[[subprocess.Popen[str]], None] | None = None,
|
|
183
|
+
) -> OpencodeGenerationSession:
|
|
184
|
+
return OpencodeGenerationSession(
|
|
185
|
+
binary_name=self.binary_name,
|
|
186
|
+
env=self.env,
|
|
187
|
+
log_prefix=self._log_prefix,
|
|
188
|
+
cmd=cmd,
|
|
189
|
+
logger=self.logger,
|
|
190
|
+
cwd=cwd,
|
|
191
|
+
timeout=timeout,
|
|
192
|
+
silent=silent,
|
|
193
|
+
recorder=recorder,
|
|
194
|
+
event_handler=self.event_handler,
|
|
195
|
+
on_process_started=on_process_started,
|
|
196
|
+
)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from ..utils import truncate_tool_params
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class OpencodeEvent(ABC):
|
|
10
|
+
"""Base class for Opencode 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]) -> OpencodeEvent | None:
|
|
18
|
+
"""Factory method to create events from JSON data."""
|
|
19
|
+
msg_type = data.get("type")
|
|
20
|
+
part = data.get("part", {})
|
|
21
|
+
|
|
22
|
+
if msg_type == "text":
|
|
23
|
+
return TextEvent(text=part.get("text", ""))
|
|
24
|
+
if msg_type == "tool_use":
|
|
25
|
+
return ToolUseEvent(
|
|
26
|
+
tool_name=part.get("tool", "Tool"),
|
|
27
|
+
input_data=part.get("state", {}).get("input"),
|
|
28
|
+
output_data=part.get("state", {}).get("output"),
|
|
29
|
+
status=part.get("state", {}).get("status"),
|
|
30
|
+
)
|
|
31
|
+
if msg_type == "step_start":
|
|
32
|
+
return StepStartEvent()
|
|
33
|
+
if msg_type == "step_finish":
|
|
34
|
+
return StepFinishEvent(
|
|
35
|
+
reason=part.get("reason"),
|
|
36
|
+
cost=part.get("cost"),
|
|
37
|
+
tokens=part.get("tokens"),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TextEvent(OpencodeEvent):
|
|
44
|
+
def __init__(self, text: str):
|
|
45
|
+
self.text = text
|
|
46
|
+
|
|
47
|
+
def render(self, log_prefix: str) -> str | None:
|
|
48
|
+
# We will handle text printing in the agent loop to handle potential streaming
|
|
49
|
+
# or just print it as is.
|
|
50
|
+
# For now, let's return it.
|
|
51
|
+
return self.text
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class ToolUseEvent(OpencodeEvent):
|
|
55
|
+
def __init__(self, tool_name: str, input_data: Any, output_data: Any, status: str):
|
|
56
|
+
self.tool_name = tool_name
|
|
57
|
+
self.input_data = input_data
|
|
58
|
+
self.output_data = output_data
|
|
59
|
+
self.status = status
|
|
60
|
+
|
|
61
|
+
def render(self, log_prefix: str) -> str:
|
|
62
|
+
# Render tool use and result
|
|
63
|
+
truncated_input = truncate_tool_params(self.tool_name, self.input_data)
|
|
64
|
+
|
|
65
|
+
output_str = ""
|
|
66
|
+
if self.output_data:
|
|
67
|
+
# Truncate output to first 5 lines
|
|
68
|
+
out_lines = str(self.output_data).splitlines()
|
|
69
|
+
truncated_output = "\n".join(out_lines[:5] + (["..."] if len(out_lines) > 5 else []))
|
|
70
|
+
output_str = f"\n{log_prefix} \033[32m[Tool Result] {truncated_output}\033[0m"
|
|
71
|
+
|
|
72
|
+
return f"{log_prefix} \033[34m[Tool Use] {self.tool_name} {truncated_input}\033[0m{output_str}"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class StepStartEvent(OpencodeEvent):
|
|
76
|
+
def render(self, log_prefix: str) -> str | None:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class StepFinishEvent(OpencodeEvent):
|
|
81
|
+
def __init__(self, reason: str | None, cost: float | None, tokens: dict[str, Any] | None):
|
|
82
|
+
self.reason = reason
|
|
83
|
+
self.cost = cost
|
|
84
|
+
self.tokens = tokens
|
|
85
|
+
|
|
86
|
+
def render(self, log_prefix: str) -> str | None:
|
|
87
|
+
# Optional: Print cost info?
|
|
88
|
+
# For now, maybe just ignore or print verbose.
|
|
89
|
+
# Let's keep it clean.
|
|
90
|
+
return None
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Compatibility shim for older ``agentshim.opencode_events`` imports."""
|
|
2
|
+
|
|
3
|
+
from .opencode.events import OpencodeEvent, StepFinishEvent, StepStartEvent, TextEvent, ToolUseEvent
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"OpencodeEvent",
|
|
7
|
+
"StepFinishEvent",
|
|
8
|
+
"StepStartEvent",
|
|
9
|
+
"TextEvent",
|
|
10
|
+
"ToolUseEvent",
|
|
11
|
+
]
|
agentshim/py.typed
ADDED
|
File without changes
|
agentshim/sandbox.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Sandbox support for CLI coding agents.
|
|
2
|
+
|
|
3
|
+
Currently only ``ClaudeCodeCodingAgent`` supports sandboxing, via Claude
|
|
4
|
+
Code's native bubblewrap/Seatbelt sandbox (configured through its
|
|
5
|
+
``settings.json`` schema). Other providers raise ``NotImplementedError``
|
|
6
|
+
if sandboxing is requested.
|
|
7
|
+
|
|
8
|
+
See https://code.claude.com/docs/en/sandboxing for how the native sandbox
|
|
9
|
+
scopes bash subprocesses at the OS level (filesystem + network), without
|
|
10
|
+
wrapping the Claude process itself.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
_CONFINE_READS_HOOK = os.path.join(os.path.dirname(os.path.abspath(__file__)), "claude", "hooks", "confine_reads.py")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class SandboxConfig:
|
|
24
|
+
"""Claude Code sandbox configuration.
|
|
25
|
+
|
|
26
|
+
Fields mirror a subset of the ``sandbox`` key in Claude Code's
|
|
27
|
+
``settings.json``. Only the knobs we actually need are exposed; pass
|
|
28
|
+
additional keys through ``extra_settings`` if you need something not
|
|
29
|
+
modeled here.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
fail_if_unavailable: If True, Claude exits with an error when the
|
|
33
|
+
sandbox can't start (missing ``bwrap``, unsupported platform)
|
|
34
|
+
instead of silently falling back to unsandboxed execution.
|
|
35
|
+
Recommended to keep True so sandbox behavior is a hard gate.
|
|
36
|
+
auto_allow_bash: If True, sandboxed bash commands are auto-approved
|
|
37
|
+
without permission prompts (they're already constrained by the
|
|
38
|
+
sandbox).
|
|
39
|
+
allow_unsandboxed_commands: If False, disables the
|
|
40
|
+
``dangerouslyDisableSandbox`` escape hatch entirely.
|
|
41
|
+
excluded_commands: Commands that should run *outside* the sandbox
|
|
42
|
+
(e.g. ``["docker *"]``).
|
|
43
|
+
allow_write: Extra paths where sandboxed commands may write (cwd
|
|
44
|
+
is always writable by default).
|
|
45
|
+
deny_write: Paths sandboxed commands must not write to.
|
|
46
|
+
allow_read: Paths to re-allow inside ``deny_read`` regions.
|
|
47
|
+
deny_read: Paths sandboxed commands must not read.
|
|
48
|
+
allowed_domains: Outbound network domains allowed for bash
|
|
49
|
+
subprocesses (e.g. ``["github.com", "*.npmjs.org"]``).
|
|
50
|
+
Does not affect Claude's own API calls.
|
|
51
|
+
extra_settings: Raw dict merged into the ``sandbox`` settings
|
|
52
|
+
block, for fields not covered above.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
fail_if_unavailable: bool = True
|
|
56
|
+
auto_allow_bash: bool = True
|
|
57
|
+
allow_unsandboxed_commands: bool = False
|
|
58
|
+
excluded_commands: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
|
59
|
+
allow_write: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
|
60
|
+
deny_write: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
|
61
|
+
allow_read: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
|
62
|
+
deny_read: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
|
63
|
+
allowed_domains: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
|
64
|
+
extra_settings: dict[str, Any] = field(default_factory=dict) # pyright: ignore[reportUnknownVariableType]
|
|
65
|
+
|
|
66
|
+
# The OS-level sandbox only wraps bash subprocesses, not Claude's built-in
|
|
67
|
+
# Read/Glob/Grep/Edit/Write tools. When this list is non-empty we inject a
|
|
68
|
+
# PreToolUse hook that denies any tool call whose target path is outside
|
|
69
|
+
# the listed roots. Leave empty to allow Claude's native tools to read
|
|
70
|
+
# anywhere (the default; matches unsandboxed behavior).
|
|
71
|
+
confine_native_reads_to: list[str] = field(default_factory=list) # pyright: ignore[reportUnknownVariableType]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def resolve_sandbox(value: bool | SandboxConfig | None) -> SandboxConfig | None:
|
|
75
|
+
"""Normalize a user-supplied ``sandbox`` argument to a SandboxConfig or None."""
|
|
76
|
+
if value is None or value is False:
|
|
77
|
+
return None
|
|
78
|
+
if value is True:
|
|
79
|
+
return SandboxConfig()
|
|
80
|
+
if isinstance(value, SandboxConfig): # pyright: ignore[reportUnnecessaryIsInstance]
|
|
81
|
+
return value
|
|
82
|
+
raise TypeError(f"sandbox must be bool or SandboxConfig, got {type(value).__name__}")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def build_claude_sandbox_settings(config: SandboxConfig) -> dict[str, Any]:
|
|
86
|
+
"""Build the Claude Code ``settings.json`` payload that enables the sandbox."""
|
|
87
|
+
sandbox: dict[str, Any] = {
|
|
88
|
+
"enabled": True,
|
|
89
|
+
"failIfUnavailable": config.fail_if_unavailable,
|
|
90
|
+
"autoAllowBashIfSandboxed": config.auto_allow_bash,
|
|
91
|
+
"allowUnsandboxedCommands": config.allow_unsandboxed_commands,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if config.excluded_commands:
|
|
95
|
+
sandbox["excludedCommands"] = list(config.excluded_commands)
|
|
96
|
+
|
|
97
|
+
filesystem: dict[str, Any] = {}
|
|
98
|
+
if config.allow_write:
|
|
99
|
+
filesystem["allowWrite"] = list(config.allow_write)
|
|
100
|
+
if config.deny_write:
|
|
101
|
+
filesystem["denyWrite"] = list(config.deny_write)
|
|
102
|
+
if config.allow_read:
|
|
103
|
+
filesystem["allowRead"] = list(config.allow_read)
|
|
104
|
+
if config.deny_read:
|
|
105
|
+
filesystem["denyRead"] = list(config.deny_read)
|
|
106
|
+
if filesystem:
|
|
107
|
+
sandbox["filesystem"] = filesystem
|
|
108
|
+
|
|
109
|
+
if config.allowed_domains:
|
|
110
|
+
sandbox["network"] = {"allowedDomains": list(config.allowed_domains)}
|
|
111
|
+
|
|
112
|
+
sandbox.update(config.extra_settings)
|
|
113
|
+
|
|
114
|
+
settings: dict[str, Any] = {"sandbox": sandbox}
|
|
115
|
+
if config.confine_native_reads_to:
|
|
116
|
+
settings["hooks"] = _build_confine_reads_hook(config.confine_native_reads_to)
|
|
117
|
+
return settings
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _build_confine_reads_hook(roots: list[str]) -> dict[str, Any]:
|
|
121
|
+
"""Build the ``hooks`` block that denies native-tool reads outside ``roots``."""
|
|
122
|
+
resolved = [os.path.realpath(r) for r in roots]
|
|
123
|
+
# Quote each arg with double-quotes so paths with spaces survive shell parsing.
|
|
124
|
+
args = " ".join(f'"{r}"' for r in resolved)
|
|
125
|
+
command = f'"{_CONFINE_READS_HOOK}" {args}'
|
|
126
|
+
return {
|
|
127
|
+
"PreToolUse": [
|
|
128
|
+
{
|
|
129
|
+
"matcher": "Read|Glob|Grep|Edit|Write|NotebookEdit",
|
|
130
|
+
"hooks": [{"type": "command", "command": command}],
|
|
131
|
+
}
|
|
132
|
+
]
|
|
133
|
+
}
|
agentshim/subagent.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Shared subagent primitive for isolated LLM calls.
|
|
2
|
+
|
|
3
|
+
Provides ``call_subagent()`` — a single fresh litellm call with its own
|
|
4
|
+
message list. Used by RLM (isolated recursive calls) and the
|
|
5
|
+
SubagentCodingAgent (fan-out analysis calls).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import subprocess
|
|
10
|
+
import time
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import litellm
|
|
14
|
+
from loguru import logger
|
|
15
|
+
|
|
16
|
+
_NETWORK_ERROR_MARKERS = (
|
|
17
|
+
"nameresolutionerror",
|
|
18
|
+
"name or service not known",
|
|
19
|
+
"transporterror",
|
|
20
|
+
"apiconnectionerror",
|
|
21
|
+
"connectionerror",
|
|
22
|
+
"max retries exceeded",
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def litellm_call_with_retry(
|
|
27
|
+
kwargs: dict[str, Any],
|
|
28
|
+
label: str,
|
|
29
|
+
max_attempts: int = 3,
|
|
30
|
+
token_acc: dict[str, int] | None = None,
|
|
31
|
+
) -> str:
|
|
32
|
+
"""Call litellm.completion with retry on transient network errors."""
|
|
33
|
+
for attempt in range(max_attempts):
|
|
34
|
+
try:
|
|
35
|
+
response = litellm.completion(**kwargs) # type: ignore[reportUnknownMemberType]
|
|
36
|
+
if token_acc is not None:
|
|
37
|
+
usage = getattr(response, "usage", None)
|
|
38
|
+
if usage:
|
|
39
|
+
token_acc["prompt_tokens"] = token_acc.get("prompt_tokens", 0) + (
|
|
40
|
+
getattr(usage, "prompt_tokens", 0) or 0
|
|
41
|
+
)
|
|
42
|
+
token_acc["completion_tokens"] = token_acc.get("completion_tokens", 0) + (
|
|
43
|
+
getattr(usage, "completion_tokens", 0) or 0
|
|
44
|
+
)
|
|
45
|
+
token_acc["total_tokens"] = token_acc.get("total_tokens", 0) + (
|
|
46
|
+
getattr(usage, "total_tokens", 0) or 0
|
|
47
|
+
)
|
|
48
|
+
return response.choices[0].message.content or "" # type: ignore[reportAttributeAccessIssue]
|
|
49
|
+
except Exception as e:
|
|
50
|
+
if any(m in str(e).lower() for m in _NETWORK_ERROR_MARKERS) and attempt < max_attempts - 1:
|
|
51
|
+
delay = 15 * (2**attempt)
|
|
52
|
+
logger.warning(
|
|
53
|
+
f"[RLM] {label}: transient network error (attempt {attempt + 1}/{max_attempts}), "
|
|
54
|
+
f"retrying in {delay}s: {e}"
|
|
55
|
+
)
|
|
56
|
+
time.sleep(delay)
|
|
57
|
+
else:
|
|
58
|
+
raise
|
|
59
|
+
|
|
60
|
+
return "" # unreachable; satisfies type checker
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def call_subagent(
|
|
64
|
+
model: str,
|
|
65
|
+
system_prompt: str,
|
|
66
|
+
user_prompt: str,
|
|
67
|
+
location: str | None = None,
|
|
68
|
+
token_acc: dict[str, int] | None = None,
|
|
69
|
+
) -> str:
|
|
70
|
+
"""Make a completely fresh, isolated litellm call.
|
|
71
|
+
|
|
72
|
+
Builds a new ``messages`` list from scratch (system + user), so there is
|
|
73
|
+
no shared conversation history with any other call.
|
|
74
|
+
"""
|
|
75
|
+
kwargs: dict[str, Any] = {
|
|
76
|
+
"model": model,
|
|
77
|
+
"messages": [
|
|
78
|
+
{"role": "system", "content": system_prompt},
|
|
79
|
+
{"role": "user", "content": user_prompt},
|
|
80
|
+
],
|
|
81
|
+
"cache": {"no-cache": True},
|
|
82
|
+
}
|
|
83
|
+
loc = location or os.environ.get("VERTEX_LOCATION")
|
|
84
|
+
if loc:
|
|
85
|
+
kwargs["vertex_location"] = loc
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
return litellm_call_with_retry(kwargs, label="subagent call", token_acc=token_acc)
|
|
89
|
+
except KeyboardInterrupt:
|
|
90
|
+
raise
|
|
91
|
+
except (TimeoutError, ConnectionError, subprocess.SubprocessError, OSError) as e:
|
|
92
|
+
logger.error(f"[Subagent] LLM call failed: {type(e).__name__}: {e}")
|
|
93
|
+
return f"Subagent call failed: {type(e).__name__}: {e}"
|
agentshim/trajectory.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Trajectory protocol and null implementation for the agent_cli library.
|
|
2
|
+
|
|
3
|
+
This module provides the protocol interface and no-op implementation that
|
|
4
|
+
standalone library code can use without depending on app_operator.
|
|
5
|
+
|
|
6
|
+
The full TrajectoryRecorder implementation lives in app_operator/trajectory.py.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any, Protocol, TypedDict, runtime_checkable
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TokenUsage(TypedDict):
|
|
20
|
+
"""Token usage statistics from an LLM call."""
|
|
21
|
+
|
|
22
|
+
prompt_tokens: int
|
|
23
|
+
completion_tokens: int
|
|
24
|
+
total_tokens: int
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FaultInjectionMetadata(TypedDict):
|
|
28
|
+
"""Metadata about fault injection for embedding in trajectory JSON."""
|
|
29
|
+
|
|
30
|
+
enabled: bool
|
|
31
|
+
num_faults_requested: int
|
|
32
|
+
num_faults_injected: int
|
|
33
|
+
faults: list[Any]
|
|
34
|
+
failed_injections: list[Any]
|
|
35
|
+
fault_ids: list[str]
|
|
36
|
+
categories: list[str]
|
|
37
|
+
severities: list[str]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@runtime_checkable
|
|
41
|
+
class TrajectoryRecorderProtocol(Protocol):
|
|
42
|
+
"""Protocol for trajectory recorders."""
|
|
43
|
+
|
|
44
|
+
def start_phase(self, phase: Any, context: dict[str, Any] | None = None) -> None: ...
|
|
45
|
+
def end_phase(self, status: str | None = None) -> None: ...
|
|
46
|
+
def add_system_message(self, content: str) -> None: ...
|
|
47
|
+
def add_user_message(self, content: str) -> None: ...
|
|
48
|
+
|
|
49
|
+
def add_assistant_message(self, content: str, duration: float | None = None) -> None: ...
|
|
50
|
+
|
|
51
|
+
def add_tool_call(
|
|
52
|
+
self,
|
|
53
|
+
tool: str,
|
|
54
|
+
args: dict[str, Any],
|
|
55
|
+
stdout: str = "",
|
|
56
|
+
stderr: str = "",
|
|
57
|
+
exit_code: int | None = None,
|
|
58
|
+
duration: float | None = None,
|
|
59
|
+
) -> None: ...
|
|
60
|
+
def set_phase_status(self, status: str) -> None: ...
|
|
61
|
+
def set_agent_name(self, agent_name: str) -> None: ...
|
|
62
|
+
def set_prompt_version(self, version: str) -> None: ...
|
|
63
|
+
def record_fallback(self) -> None: ...
|
|
64
|
+
def record_prompt_kwargs(self, kwargs: dict[str, Any]) -> None: ...
|
|
65
|
+
def record_rendered_prompt(self, rendered_prompt: str) -> None: ...
|
|
66
|
+
def record_fault_injection(self, metadata: FaultInjectionMetadata) -> None: ...
|
|
67
|
+
def record_token_usage(self, usage: TokenUsage) -> None: ...
|
|
68
|
+
def finalize(self, status: str = "completed") -> Path: ...
|
|
69
|
+
|
|
70
|
+
def phase(self, phase: Any, context: dict[str, Any] | None = None) -> Any: ...
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class NullTrajectoryRecorder(TrajectoryRecorderProtocol):
|
|
74
|
+
"""No-op recorder that formally implements TrajectoryRecorderProtocol.
|
|
75
|
+
|
|
76
|
+
Used as a default when no real recorder is needed (e.g. in tests).
|
|
77
|
+
Every method is a no-op, so callers never need to check for None.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def start_phase(self, phase: Any, context: dict[str, Any] | None = None) -> None:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
def end_phase(self, status: str | None = None) -> None:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
def add_system_message(self, content: str) -> None:
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
def add_user_message(self, content: str) -> None:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
def add_assistant_message(self, content: str, duration: float | None = None) -> None:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
def add_tool_call(
|
|
96
|
+
self,
|
|
97
|
+
tool: str,
|
|
98
|
+
args: dict[str, Any],
|
|
99
|
+
stdout: str = "",
|
|
100
|
+
stderr: str = "",
|
|
101
|
+
exit_code: int | None = None,
|
|
102
|
+
duration: float | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
def set_phase_status(self, status: str) -> None:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
def set_agent_name(self, agent_name: str) -> None:
|
|
110
|
+
pass
|
|
111
|
+
|
|
112
|
+
def set_prompt_version(self, version: str) -> None:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
def record_fallback(self) -> None:
|
|
116
|
+
pass
|
|
117
|
+
|
|
118
|
+
def record_prompt_kwargs(self, kwargs: dict[str, Any]) -> None:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
def record_rendered_prompt(self, rendered_prompt: str) -> None:
|
|
122
|
+
pass
|
|
123
|
+
|
|
124
|
+
def record_fault_injection(self, metadata: FaultInjectionMetadata) -> None:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
def record_token_usage(self, usage: TokenUsage) -> None:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
@contextmanager
|
|
131
|
+
def phase(self, phase: Any, context: dict[str, Any] | None = None):
|
|
132
|
+
yield self
|
|
133
|
+
|
|
134
|
+
def finalize(self, status: str = "completed") -> Path:
|
|
135
|
+
return Path("/dev/null")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _call_id_provider() -> int | None:
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _run_id_provider() -> str | None:
|
|
143
|
+
return None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def register_context_providers(
|
|
147
|
+
call_id_fn: Callable[[], int | None],
|
|
148
|
+
run_id_fn: Callable[[], str | None],
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Register live context providers for trajectory correlation.
|
|
151
|
+
|
|
152
|
+
Called by app_operator.trajectory at import time to wire in the real
|
|
153
|
+
thread-local implementations. Standalone consumers get None from the
|
|
154
|
+
default def stubs, which is correct outside of app_operator.
|
|
155
|
+
"""
|
|
156
|
+
global _call_id_provider, _run_id_provider
|
|
157
|
+
_call_id_provider = call_id_fn
|
|
158
|
+
_run_id_provider = run_id_fn
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def get_current_call_id() -> int | None:
|
|
162
|
+
"""Return the current call ID, or None if no trajectory is active."""
|
|
163
|
+
return _call_id_provider()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_run_id() -> str | None:
|
|
167
|
+
"""Return the current run ID, or None if no trajectory is active."""
|
|
168
|
+
return _run_id_provider()
|