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
agentshim/cli_agent.py
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import signal
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
from abc import abstractmethod
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from loguru import logger
|
|
13
|
+
|
|
14
|
+
from agentshim.trajectory import NullTrajectoryRecorder, TrajectoryRecorderProtocol
|
|
15
|
+
|
|
16
|
+
from .base import CodingAgent
|
|
17
|
+
from .events import AgentEventHandler
|
|
18
|
+
from .mcp_config import McpServerConfig
|
|
19
|
+
from .usage import ProviderUsage
|
|
20
|
+
from .utils import get_interactive_env
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class CLIGenerationSession:
|
|
24
|
+
"""Handles a single generation request lifecycle."""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
binary_name: str,
|
|
29
|
+
env: dict[str, str],
|
|
30
|
+
log_prefix: str,
|
|
31
|
+
cmd: list[str],
|
|
32
|
+
logger: Any,
|
|
33
|
+
cwd: str | None = None,
|
|
34
|
+
timeout: int = 300,
|
|
35
|
+
silent: bool = False,
|
|
36
|
+
recorder: TrajectoryRecorderProtocol | None = None,
|
|
37
|
+
event_handler: AgentEventHandler | None = None,
|
|
38
|
+
on_process_started: Callable[[subprocess.Popen[str]], None] | None = None,
|
|
39
|
+
):
|
|
40
|
+
self.binary_name = binary_name
|
|
41
|
+
self.env = env
|
|
42
|
+
self.log_prefix = log_prefix
|
|
43
|
+
self.cmd = cmd
|
|
44
|
+
self.logger = logger
|
|
45
|
+
self.cwd = cwd
|
|
46
|
+
self.timeout = timeout
|
|
47
|
+
self.silent = silent
|
|
48
|
+
self.recorder = recorder or NullTrajectoryRecorder()
|
|
49
|
+
self.event_handler = event_handler
|
|
50
|
+
self.on_process_started = on_process_started
|
|
51
|
+
|
|
52
|
+
# State initialization
|
|
53
|
+
self.stdout_lines: list[str] = []
|
|
54
|
+
self.stderr_lines: list[str] = []
|
|
55
|
+
self._at_line_start = True
|
|
56
|
+
# Providers populate this during event handling; stays at the
|
|
57
|
+
# empty default if the session crashes before any terminal event.
|
|
58
|
+
self.usage: ProviderUsage = ProviderUsage()
|
|
59
|
+
# Provider session id captured from the event stream (set by subclasses
|
|
60
|
+
# that parse JSON events). ``None`` if the underlying CLI did not emit
|
|
61
|
+
# an id during this run.
|
|
62
|
+
self.session_id: str | None = None
|
|
63
|
+
|
|
64
|
+
def _log_raw(self, message: str) -> None:
|
|
65
|
+
"""Log a raw message directly to output if not silent."""
|
|
66
|
+
if not self.silent:
|
|
67
|
+
self.logger.opt(raw=True).info(message)
|
|
68
|
+
|
|
69
|
+
def _process_stdout(self, line: str) -> None:
|
|
70
|
+
"""Process a line from stdout."""
|
|
71
|
+
line_stripped = line.rstrip("\n")
|
|
72
|
+
if not self.silent:
|
|
73
|
+
self.logger.info(line_stripped)
|
|
74
|
+
|
|
75
|
+
if self.event_handler and line_stripped:
|
|
76
|
+
self.event_handler.on_thinking(line_stripped + "\n")
|
|
77
|
+
|
|
78
|
+
self.stdout_lines.append(line)
|
|
79
|
+
|
|
80
|
+
def _print_stream_content(self, content: str):
|
|
81
|
+
"""Print streaming content with prefix handling."""
|
|
82
|
+
if not content:
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
lines = content.split("\n")
|
|
86
|
+
|
|
87
|
+
for i, line in enumerate(lines):
|
|
88
|
+
is_last = i == len(lines) - 1
|
|
89
|
+
|
|
90
|
+
if is_last:
|
|
91
|
+
if line:
|
|
92
|
+
if self._at_line_start:
|
|
93
|
+
self._log_raw(f"{self.log_prefix} ")
|
|
94
|
+
self._at_line_start = False
|
|
95
|
+
self._log_raw(line)
|
|
96
|
+
else:
|
|
97
|
+
if self._at_line_start:
|
|
98
|
+
self._log_raw(f"{self.log_prefix} ")
|
|
99
|
+
self._log_raw(line)
|
|
100
|
+
self._log_raw("\n")
|
|
101
|
+
self._at_line_start = True
|
|
102
|
+
|
|
103
|
+
def _process_stderr(self, line: str) -> None:
|
|
104
|
+
"""Process a line from stderr."""
|
|
105
|
+
line_stripped = line.rstrip("\n")
|
|
106
|
+
if not self.silent:
|
|
107
|
+
self.logger.bind(stderr=True).info(f"[STDERR] {line_stripped}")
|
|
108
|
+
self.stderr_lines.append(line)
|
|
109
|
+
|
|
110
|
+
def run(self, prompt: str) -> str:
|
|
111
|
+
"""Execute the generation process."""
|
|
112
|
+
if not self.silent:
|
|
113
|
+
self.logger.info(f"Running command: {' '.join(self.cmd)}")
|
|
114
|
+
self._log_raw("=" * 80 + "\n")
|
|
115
|
+
sys.stdout.flush()
|
|
116
|
+
|
|
117
|
+
def read_stdout(pipe: io.TextIOWrapper) -> None:
|
|
118
|
+
for line in iter(pipe.readline, ""):
|
|
119
|
+
if not line:
|
|
120
|
+
break
|
|
121
|
+
self._process_stdout(line)
|
|
122
|
+
pipe.close()
|
|
123
|
+
|
|
124
|
+
def read_stderr(pipe: io.TextIOWrapper) -> None:
|
|
125
|
+
for line in iter(pipe.readline, ""):
|
|
126
|
+
if not line:
|
|
127
|
+
break
|
|
128
|
+
self._process_stderr(line)
|
|
129
|
+
pipe.close()
|
|
130
|
+
|
|
131
|
+
process = subprocess.Popen(
|
|
132
|
+
self.cmd,
|
|
133
|
+
stdin=subprocess.PIPE,
|
|
134
|
+
stdout=subprocess.PIPE,
|
|
135
|
+
stderr=subprocess.PIPE,
|
|
136
|
+
text=True,
|
|
137
|
+
bufsize=1,
|
|
138
|
+
cwd=self.cwd,
|
|
139
|
+
env=self.env,
|
|
140
|
+
start_new_session=True,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
if self.on_process_started is not None:
|
|
144
|
+
try:
|
|
145
|
+
self.on_process_started(process)
|
|
146
|
+
except Exception as exc:
|
|
147
|
+
self.logger.warning(f"on_process_started callback raised: {exc}")
|
|
148
|
+
|
|
149
|
+
stdout_thread = threading.Thread(target=read_stdout, args=(process.stdout,))
|
|
150
|
+
stderr_thread = threading.Thread(target=read_stderr, args=(process.stderr,))
|
|
151
|
+
|
|
152
|
+
stdout_thread.daemon = True
|
|
153
|
+
stderr_thread.daemon = True
|
|
154
|
+
|
|
155
|
+
stdout_thread.start()
|
|
156
|
+
stderr_thread.start()
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
if process.stdin:
|
|
160
|
+
process.stdin.write(prompt)
|
|
161
|
+
process.stdin.close()
|
|
162
|
+
except BrokenPipeError:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
try:
|
|
166
|
+
process.wait(timeout=self.timeout)
|
|
167
|
+
except subprocess.TimeoutExpired:
|
|
168
|
+
try:
|
|
169
|
+
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
|
170
|
+
except ProcessLookupError:
|
|
171
|
+
pass
|
|
172
|
+
process.wait()
|
|
173
|
+
raise subprocess.TimeoutExpired(self.cmd, self.timeout) from None
|
|
174
|
+
finally:
|
|
175
|
+
if process.poll() is None:
|
|
176
|
+
try:
|
|
177
|
+
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
|
178
|
+
process.wait()
|
|
179
|
+
except (ProcessLookupError, OSError):
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
stdout_thread.join(timeout=1)
|
|
183
|
+
stderr_thread.join(timeout=1)
|
|
184
|
+
|
|
185
|
+
stdout_data = "".join(self.stdout_lines)
|
|
186
|
+
stderr_data = "".join(self.stderr_lines)
|
|
187
|
+
|
|
188
|
+
self._log_raw("=" * 80 + "\n")
|
|
189
|
+
|
|
190
|
+
if process.returncode != 0:
|
|
191
|
+
raise RuntimeError(f"{self.binary_name} exited with code {process.returncode}: {stderr_data}")
|
|
192
|
+
|
|
193
|
+
return stdout_data.strip()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class CLICodingAgent(CodingAgent):
|
|
197
|
+
"""Base class for CLI-based coding agents."""
|
|
198
|
+
|
|
199
|
+
CLI_CHECK_TIMEOUT_SECONDS = 15
|
|
200
|
+
|
|
201
|
+
def __init__(
|
|
202
|
+
self,
|
|
203
|
+
binary_name: str,
|
|
204
|
+
model: str | None = None,
|
|
205
|
+
recorder: TrajectoryRecorderProtocol | None = None,
|
|
206
|
+
event_handler: AgentEventHandler | None = None,
|
|
207
|
+
mcp_servers: list[McpServerConfig] | None = None,
|
|
208
|
+
):
|
|
209
|
+
"""Initialize the CLI coding agent.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
binary_name: The name of the executable to use.
|
|
213
|
+
model: Optional model name to use.
|
|
214
|
+
recorder: Trajectory recorder instance.
|
|
215
|
+
event_handler: Optional event handler for UI updates.
|
|
216
|
+
mcp_servers: Optional list of MCP server configurations.
|
|
217
|
+
|
|
218
|
+
Raises:
|
|
219
|
+
RuntimeError: If binary is not found in PATH or is not working.
|
|
220
|
+
"""
|
|
221
|
+
self.env = get_interactive_env()
|
|
222
|
+
self.binary_name = binary_name
|
|
223
|
+
self.model = model
|
|
224
|
+
self.recorder: TrajectoryRecorderProtocol = recorder or NullTrajectoryRecorder()
|
|
225
|
+
self.event_handler = event_handler
|
|
226
|
+
self.mcp_servers: list[McpServerConfig] = mcp_servers or []
|
|
227
|
+
|
|
228
|
+
# Search for binary in the captured environment's PATH
|
|
229
|
+
binary_path = shutil.which(binary_name, path=self.env.get("PATH"))
|
|
230
|
+
|
|
231
|
+
if not binary_path:
|
|
232
|
+
# Fallback to current PATH if not found in interactive env
|
|
233
|
+
binary_path = shutil.which(binary_name)
|
|
234
|
+
|
|
235
|
+
if not binary_path:
|
|
236
|
+
raise RuntimeError(
|
|
237
|
+
f"{binary_name} binary not found in PATH. Please ensure {binary_name} is installed and available."
|
|
238
|
+
)
|
|
239
|
+
self.binary_path = binary_path
|
|
240
|
+
self._check_cli()
|
|
241
|
+
self.logger = logger.bind(agent_prefix=self._log_prefix)
|
|
242
|
+
# Populated after each generate() call from the session's usage.
|
|
243
|
+
self.last_usage: ProviderUsage = ProviderUsage()
|
|
244
|
+
|
|
245
|
+
def _check_cli(self):
|
|
246
|
+
"""Check if the CLI tool is available and executable."""
|
|
247
|
+
try:
|
|
248
|
+
result = subprocess.run(
|
|
249
|
+
[self.binary_path, "--help"],
|
|
250
|
+
capture_output=True,
|
|
251
|
+
text=True,
|
|
252
|
+
check=False,
|
|
253
|
+
env=self.env,
|
|
254
|
+
stdin=subprocess.DEVNULL,
|
|
255
|
+
timeout=self.CLI_CHECK_TIMEOUT_SECONDS,
|
|
256
|
+
)
|
|
257
|
+
if result.returncode != 0:
|
|
258
|
+
raise RuntimeError(
|
|
259
|
+
f"{self.binary_name} CLI tool at '{self.binary_path}' is not working correctly. "
|
|
260
|
+
f"'{self.binary_path} --help' exited with code {result.returncode}. "
|
|
261
|
+
f"Stderr: {result.stderr}"
|
|
262
|
+
)
|
|
263
|
+
except subprocess.TimeoutExpired as e:
|
|
264
|
+
raise RuntimeError(
|
|
265
|
+
f"{self.binary_name} CLI tool at '{self.binary_path}' did not respond to "
|
|
266
|
+
f"'--help' within {self.CLI_CHECK_TIMEOUT_SECONDS}s."
|
|
267
|
+
) from e
|
|
268
|
+
except FileNotFoundError as e:
|
|
269
|
+
raise RuntimeError(
|
|
270
|
+
f"{self.binary_name} CLI tool not found at '{self.binary_path}'. "
|
|
271
|
+
f"Please ensure {self.binary_name} is installed and in your PATH."
|
|
272
|
+
) from e
|
|
273
|
+
except Exception as e:
|
|
274
|
+
raise RuntimeError(f"Failed to check {self.binary_name} CLI tool: {e}") from e
|
|
275
|
+
|
|
276
|
+
@abstractmethod
|
|
277
|
+
def _get_command(self, prompt: str, resume_session_id: str | None = None) -> list[str]:
|
|
278
|
+
"""Construct the command line arguments.
|
|
279
|
+
|
|
280
|
+
Args:
|
|
281
|
+
prompt: The prompt to send to the agent.
|
|
282
|
+
resume_session_id: If set, the provider session id to resume.
|
|
283
|
+
"""
|
|
284
|
+
|
|
285
|
+
@property
|
|
286
|
+
def _log_prefix(self) -> str:
|
|
287
|
+
"""Return the log prefix for this agent."""
|
|
288
|
+
return f"[{self.__class__.__name__}]"
|
|
289
|
+
|
|
290
|
+
def _create_session(
|
|
291
|
+
self,
|
|
292
|
+
cmd: list[str],
|
|
293
|
+
cwd: str | None = None,
|
|
294
|
+
timeout: int = 300,
|
|
295
|
+
silent: bool = False,
|
|
296
|
+
recorder: TrajectoryRecorderProtocol | None = None,
|
|
297
|
+
on_process_started: Callable[[subprocess.Popen[str]], None] | None = None,
|
|
298
|
+
) -> CLIGenerationSession:
|
|
299
|
+
"""Create a session for a single generation request.
|
|
300
|
+
|
|
301
|
+
Can be overridden by subclasses to return specialized sessions.
|
|
302
|
+
"""
|
|
303
|
+
return CLIGenerationSession(
|
|
304
|
+
binary_name=self.binary_name,
|
|
305
|
+
env=self.env,
|
|
306
|
+
log_prefix=self._log_prefix,
|
|
307
|
+
cmd=cmd,
|
|
308
|
+
logger=self.logger,
|
|
309
|
+
cwd=cwd,
|
|
310
|
+
timeout=timeout,
|
|
311
|
+
silent=silent,
|
|
312
|
+
recorder=recorder,
|
|
313
|
+
event_handler=self.event_handler,
|
|
314
|
+
on_process_started=on_process_started,
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
def start_session(
|
|
318
|
+
self,
|
|
319
|
+
cwd: str | None = None,
|
|
320
|
+
timeout: int = 300,
|
|
321
|
+
silent: bool = False,
|
|
322
|
+
) -> "CLIAgentSession":
|
|
323
|
+
"""Open a stateful conversation with the underlying CLI.
|
|
324
|
+
|
|
325
|
+
Returns a :class:`CLIAgentSession` whose ``generate(prompt)`` may be
|
|
326
|
+
called repeatedly; each call after the first automatically resumes
|
|
327
|
+
the prior conversation via the provider's native resume flag.
|
|
328
|
+
|
|
329
|
+
Args:
|
|
330
|
+
cwd: Default working directory for ``session.generate`` calls.
|
|
331
|
+
timeout: Default timeout in seconds.
|
|
332
|
+
silent: If True, suppress stdout printing of the agent's output.
|
|
333
|
+
"""
|
|
334
|
+
return CLIAgentSession(self, cwd=cwd, timeout=timeout, silent=silent)
|
|
335
|
+
|
|
336
|
+
def generate(
|
|
337
|
+
self,
|
|
338
|
+
prompt: str,
|
|
339
|
+
cwd: str | None = None,
|
|
340
|
+
timeout: int = 300,
|
|
341
|
+
silent: bool = False,
|
|
342
|
+
on_process_started: Callable[[subprocess.Popen[str]], None] | None = None,
|
|
343
|
+
) -> str:
|
|
344
|
+
"""One-shot prompt → reply. Convenience wrapper around
|
|
345
|
+
``start_session(...).generate(prompt)``; no conversation state is
|
|
346
|
+
retained across calls. Use :meth:`start_session` for multi-turn flows.
|
|
347
|
+
"""
|
|
348
|
+
return self.start_session(cwd=cwd, timeout=timeout, silent=silent).generate(
|
|
349
|
+
prompt, on_process_started=on_process_started
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
class CLIAgentSession:
|
|
354
|
+
"""Stateful, resumable conversation with a CLI agent.
|
|
355
|
+
|
|
356
|
+
Holds the provider session id captured from the first ``generate`` call
|
|
357
|
+
so subsequent calls automatically pass the right native resume flag
|
|
358
|
+
(``claude --resume``, ``codex exec resume``, ``gemini --resume``,
|
|
359
|
+
``opencode run --session``). Callers do not see provider-specific
|
|
360
|
+
plumbing.
|
|
361
|
+
|
|
362
|
+
A session is single-threaded — concurrent calls into ``generate`` on the
|
|
363
|
+
same instance are not supported.
|
|
364
|
+
"""
|
|
365
|
+
|
|
366
|
+
def __init__(
|
|
367
|
+
self,
|
|
368
|
+
agent: CLICodingAgent,
|
|
369
|
+
*,
|
|
370
|
+
cwd: str | None = None,
|
|
371
|
+
timeout: int = 300,
|
|
372
|
+
silent: bool = False,
|
|
373
|
+
):
|
|
374
|
+
self.agent = agent
|
|
375
|
+
self._cwd = cwd
|
|
376
|
+
self._timeout = timeout
|
|
377
|
+
self._silent = silent
|
|
378
|
+
# Provider session id, set after the first ``generate`` call (None
|
|
379
|
+
# if the underlying CLI did not emit one).
|
|
380
|
+
self.session_id: str | None = None
|
|
381
|
+
|
|
382
|
+
def generate(
|
|
383
|
+
self,
|
|
384
|
+
prompt: str,
|
|
385
|
+
cwd: str | None = None,
|
|
386
|
+
timeout: int | None = None,
|
|
387
|
+
silent: bool | None = None,
|
|
388
|
+
on_process_started: Callable[[subprocess.Popen[str]], None] | None = None,
|
|
389
|
+
) -> str:
|
|
390
|
+
"""Send ``prompt``, returning the assistant's text reply.
|
|
391
|
+
|
|
392
|
+
Per-call ``cwd`` / ``timeout`` / ``silent`` override the defaults
|
|
393
|
+
captured by :meth:`CLICodingAgent.start_session`. The provider's
|
|
394
|
+
native resume flag is added automatically on every call after the
|
|
395
|
+
first.
|
|
396
|
+
|
|
397
|
+
Args:
|
|
398
|
+
prompt: The prompt to send.
|
|
399
|
+
cwd: Override the session's default working directory.
|
|
400
|
+
timeout: Override the session's default timeout (seconds).
|
|
401
|
+
silent: Override the session's default silent flag.
|
|
402
|
+
on_process_started: Optional callback invoked with the spawned
|
|
403
|
+
``subprocess.Popen`` immediately after the CLI subprocess
|
|
404
|
+
starts (used by callers that need to kill it externally,
|
|
405
|
+
e.g. crucible's short-circuit).
|
|
406
|
+
"""
|
|
407
|
+
effective_cwd = cwd if cwd is not None else self._cwd
|
|
408
|
+
effective_timeout = timeout if timeout is not None else self._timeout
|
|
409
|
+
effective_silent = silent if silent is not None else self._silent
|
|
410
|
+
|
|
411
|
+
self.agent.recorder.add_user_message(prompt)
|
|
412
|
+
|
|
413
|
+
cmd = self.agent._get_command(prompt, resume_session_id=self.session_id) # pyright: ignore[reportPrivateUsage]
|
|
414
|
+
run_session = self.agent._create_session( # pyright: ignore[reportPrivateUsage]
|
|
415
|
+
cmd,
|
|
416
|
+
effective_cwd,
|
|
417
|
+
effective_timeout,
|
|
418
|
+
effective_silent,
|
|
419
|
+
recorder=self.agent.recorder,
|
|
420
|
+
on_process_started=on_process_started,
|
|
421
|
+
)
|
|
422
|
+
result = run_session.run(prompt)
|
|
423
|
+
self.agent.last_usage = getattr(run_session, "usage", ProviderUsage())
|
|
424
|
+
|
|
425
|
+
# Capture the id on first run; refresh on later runs only if the
|
|
426
|
+
# underlying CLI actually emitted one (defensive — providers always
|
|
427
|
+
# echo the same id back when resumed).
|
|
428
|
+
captured = getattr(run_session, "session_id", None)
|
|
429
|
+
if captured:
|
|
430
|
+
self.session_id = captured
|
|
431
|
+
|
|
432
|
+
self.agent.recorder.add_assistant_message(result)
|
|
433
|
+
return result
|
agentshim/codex/agent.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
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
|
|
14
|
+
from ..usage import ProviderUsage, TokenUsage
|
|
15
|
+
from .events import (
|
|
16
|
+
CodexEvent,
|
|
17
|
+
ErrorEvent,
|
|
18
|
+
TextEvent,
|
|
19
|
+
ThreadStartedEvent,
|
|
20
|
+
ToolResultEvent,
|
|
21
|
+
ToolUseEvent,
|
|
22
|
+
TurnCompletedEvent,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CodexGenerationSession(CLIGenerationSession):
|
|
27
|
+
"""Session that parses Codex ``--json`` event stream."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, **kwargs: Any):
|
|
30
|
+
super().__init__(**kwargs)
|
|
31
|
+
self.tool_map: dict[str, str] = {}
|
|
32
|
+
self.tool_start_times: dict[str, float] = {}
|
|
33
|
+
self.tool_args: dict[str, Any] = {}
|
|
34
|
+
# Codex has no single "final message" frame; track the most recent
|
|
35
|
+
# agent_message text so run() can return it as the final result.
|
|
36
|
+
self.final_result: str | None = None
|
|
37
|
+
# Accumulator for per-turn usage; finalized into self.usage at end.
|
|
38
|
+
self._accumulated_tokens = TokenUsage()
|
|
39
|
+
|
|
40
|
+
def _process_stdout(self, line: str) -> None:
|
|
41
|
+
if not line:
|
|
42
|
+
return
|
|
43
|
+
try:
|
|
44
|
+
data = json.loads(line)
|
|
45
|
+
except json.JSONDecodeError:
|
|
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
|
+
return
|
|
53
|
+
|
|
54
|
+
event = CodexEvent.from_dict(data)
|
|
55
|
+
if event is None:
|
|
56
|
+
return
|
|
57
|
+
self._handle_event(event)
|
|
58
|
+
|
|
59
|
+
def _handle_event(self, event: CodexEvent):
|
|
60
|
+
self._update_state(event)
|
|
61
|
+
if not self.silent:
|
|
62
|
+
self._render_event(event)
|
|
63
|
+
|
|
64
|
+
def _update_state(self, event: CodexEvent):
|
|
65
|
+
if isinstance(event, ThreadStartedEvent):
|
|
66
|
+
if self.session_id is None and event.thread_id:
|
|
67
|
+
self.session_id = event.thread_id
|
|
68
|
+
return
|
|
69
|
+
|
|
70
|
+
if isinstance(event, TextEvent):
|
|
71
|
+
if event.text:
|
|
72
|
+
self.stdout_lines.append(event.text)
|
|
73
|
+
self.final_result = event.text
|
|
74
|
+
if self.event_handler:
|
|
75
|
+
self.event_handler.on_thinking(event.text)
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
if isinstance(event, ToolUseEvent):
|
|
79
|
+
if event.tool_id:
|
|
80
|
+
self.tool_map[event.tool_id] = event.tool_name
|
|
81
|
+
self.tool_start_times[event.tool_id] = time.time()
|
|
82
|
+
self.tool_args[event.tool_id] = event.parameters
|
|
83
|
+
if self.event_handler:
|
|
84
|
+
self.event_handler.on_tool_call(event.tool_name, event.parameters)
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
if isinstance(event, TurnCompletedEvent):
|
|
88
|
+
self._accumulated_tokens = self._accumulated_tokens + TokenUsage(
|
|
89
|
+
input_tokens=event.input_tokens,
|
|
90
|
+
output_tokens=event.output_tokens,
|
|
91
|
+
cached_input_tokens=event.cached_input_tokens,
|
|
92
|
+
turns=1,
|
|
93
|
+
)
|
|
94
|
+
self.usage = ProviderUsage(
|
|
95
|
+
tokens=self._accumulated_tokens,
|
|
96
|
+
total_cost_usd=None,
|
|
97
|
+
provider="codex",
|
|
98
|
+
)
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
if isinstance(event, ToolResultEvent):
|
|
102
|
+
if not event.tool_id:
|
|
103
|
+
return
|
|
104
|
+
event.tool_name_resolved = self.tool_map.get(event.tool_id, "Tool")
|
|
105
|
+
|
|
106
|
+
start_time = self.tool_start_times.get(event.tool_id)
|
|
107
|
+
duration = time.time() - start_time if start_time else None
|
|
108
|
+
args = self.tool_args.get(event.tool_id, {})
|
|
109
|
+
|
|
110
|
+
self.recorder.add_tool_call(
|
|
111
|
+
tool=event.tool_name_resolved,
|
|
112
|
+
args=args,
|
|
113
|
+
stdout=event.output,
|
|
114
|
+
exit_code=event.exit_code,
|
|
115
|
+
duration=duration,
|
|
116
|
+
)
|
|
117
|
+
if self.event_handler:
|
|
118
|
+
self.event_handler.on_tool_result(
|
|
119
|
+
tool=event.tool_name_resolved,
|
|
120
|
+
stdout=event.output,
|
|
121
|
+
exit_code=event.exit_code,
|
|
122
|
+
duration=duration,
|
|
123
|
+
)
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
def _render_event(self, event: CodexEvent):
|
|
127
|
+
if isinstance(event, TextEvent):
|
|
128
|
+
if event.text:
|
|
129
|
+
self._print_stream_content(event.text)
|
|
130
|
+
return
|
|
131
|
+
|
|
132
|
+
if not self._at_line_start:
|
|
133
|
+
self._log_raw("\n")
|
|
134
|
+
self._at_line_start = True
|
|
135
|
+
|
|
136
|
+
output = event.render(self.log_prefix)
|
|
137
|
+
if output:
|
|
138
|
+
self._log_raw(output + "\n")
|
|
139
|
+
|
|
140
|
+
if isinstance(event, ErrorEvent):
|
|
141
|
+
self.stdout_lines.append(event.message)
|
|
142
|
+
|
|
143
|
+
def run(self, prompt: str) -> str:
|
|
144
|
+
super().run(prompt)
|
|
145
|
+
if self.final_result:
|
|
146
|
+
return self.final_result
|
|
147
|
+
return "\n".join(self.stdout_lines)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@register_provider("openai", "codex")
|
|
151
|
+
class CodexCodingAgent(CLICodingAgent):
|
|
152
|
+
"""Coding agent implementation using the Codex CLI tool."""
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
model: str | None = None,
|
|
157
|
+
recorder: TrajectoryRecorderProtocol | None = None,
|
|
158
|
+
event_handler: AgentEventHandler | None = None,
|
|
159
|
+
mcp_servers: list[McpServerConfig] | None = None,
|
|
160
|
+
sandbox: bool | SandboxConfig = False,
|
|
161
|
+
):
|
|
162
|
+
"""Initialize the Codex coding agent.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
model: Optional model name to use with codex. If None, uses default.
|
|
166
|
+
recorder: Trajectory recorder instance.
|
|
167
|
+
event_handler: Optional event handler for UI updates.
|
|
168
|
+
mcp_servers: Optional list of MCP server configurations.
|
|
169
|
+
sandbox: Not supported for Codex; must be False.
|
|
170
|
+
"""
|
|
171
|
+
if sandbox:
|
|
172
|
+
raise NotImplementedError("sandbox is not supported for CodexCodingAgent")
|
|
173
|
+
super().__init__("codex", model, recorder, event_handler, mcp_servers)
|
|
174
|
+
|
|
175
|
+
@property
|
|
176
|
+
def codex_path(self) -> str:
|
|
177
|
+
"""Return path to codex binary (for backward compatibility)."""
|
|
178
|
+
return self.binary_path
|
|
179
|
+
|
|
180
|
+
@property
|
|
181
|
+
def _log_prefix(self) -> str:
|
|
182
|
+
"""Return the log prefix for this agent."""
|
|
183
|
+
return "[Codex]"
|
|
184
|
+
|
|
185
|
+
def _build_mcp_args(self) -> list[str]:
|
|
186
|
+
"""Build -c flag arguments for MCP server configuration."""
|
|
187
|
+
args: list[str] = []
|
|
188
|
+
for server in self.mcp_servers:
|
|
189
|
+
prefix = f"mcp_servers.{server.name}"
|
|
190
|
+
if isinstance(server, HttpMcpServer):
|
|
191
|
+
args.extend(["-c", f'{prefix}.url="{server.url}"'])
|
|
192
|
+
else:
|
|
193
|
+
args.extend(["-c", f'{prefix}.command="{server.command}"'])
|
|
194
|
+
if server.args:
|
|
195
|
+
toml_arr = "[" + ", ".join(f'"{arg}"' for arg in server.args) + "]"
|
|
196
|
+
args.extend(["-c", f"{prefix}.args={toml_arr}"])
|
|
197
|
+
for key, value in server.env.items():
|
|
198
|
+
args.extend(["-c", f'{prefix}.env.{key}="{value}"'])
|
|
199
|
+
return args
|
|
200
|
+
|
|
201
|
+
def _get_command(self, prompt: str, resume_session_id: str | None = None) -> list[str]:
|
|
202
|
+
cmd: list[str] = [self.binary_path, "exec"]
|
|
203
|
+
if resume_session_id:
|
|
204
|
+
cmd.extend(["resume", resume_session_id])
|
|
205
|
+
cmd.extend(["--dangerously-bypass-approvals-and-sandbox", "--json"])
|
|
206
|
+
if self.model:
|
|
207
|
+
cmd.extend(["--model", self.model])
|
|
208
|
+
if self.mcp_servers:
|
|
209
|
+
cmd.extend(self._build_mcp_args())
|
|
210
|
+
# Tell Codex to read the prompt from stdin instead of expecting an
|
|
211
|
+
# inline positional prompt argument.
|
|
212
|
+
cmd.append("-")
|
|
213
|
+
return cmd
|
|
214
|
+
|
|
215
|
+
def _create_session(
|
|
216
|
+
self,
|
|
217
|
+
cmd: list[str],
|
|
218
|
+
cwd: str | None = None,
|
|
219
|
+
timeout: int = 300,
|
|
220
|
+
silent: bool = False,
|
|
221
|
+
recorder: TrajectoryRecorderProtocol | None = None,
|
|
222
|
+
on_process_started: Callable[[subprocess.Popen[str]], None] | None = None,
|
|
223
|
+
) -> CodexGenerationSession:
|
|
224
|
+
return CodexGenerationSession(
|
|
225
|
+
binary_name=self.binary_name,
|
|
226
|
+
env=self.env,
|
|
227
|
+
log_prefix=self._log_prefix,
|
|
228
|
+
cmd=cmd,
|
|
229
|
+
logger=self.logger,
|
|
230
|
+
cwd=cwd,
|
|
231
|
+
timeout=timeout,
|
|
232
|
+
silent=silent,
|
|
233
|
+
recorder=recorder,
|
|
234
|
+
event_handler=self.event_handler,
|
|
235
|
+
on_process_started=on_process_started,
|
|
236
|
+
)
|