driangle-agentrunner 0.0.1__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.
@@ -0,0 +1,35 @@
1
+ """agentrunner — Python library for programmatically invoking AI coding agents."""
2
+
3
+ from .claudecode import ClaudeRunner, ClaudeRunOptions
4
+ from .errors import (
5
+ CancelledError,
6
+ NonZeroExitError,
7
+ NoResultError,
8
+ NotFoundError,
9
+ ParseError,
10
+ RunnerError,
11
+ TimeoutError,
12
+ )
13
+ from .ollama import OllamaRunner, OllamaRunnerConfig, OllamaRunOptions
14
+ from .types import Message, Result, Runner, RunOptions, Session, Usage
15
+
16
+ __all__ = [
17
+ "CancelledError",
18
+ "ClaudeRunner",
19
+ "ClaudeRunOptions",
20
+ "Message",
21
+ "NoResultError",
22
+ "NonZeroExitError",
23
+ "NotFoundError",
24
+ "OllamaRunner",
25
+ "OllamaRunnerConfig",
26
+ "OllamaRunOptions",
27
+ "ParseError",
28
+ "Result",
29
+ "RunOptions",
30
+ "Runner",
31
+ "RunnerError",
32
+ "Session",
33
+ "TimeoutError",
34
+ "Usage",
35
+ ]
@@ -0,0 +1,11 @@
1
+ """Claude Code runner for agentrunner."""
2
+
3
+ from .options import ClaudeRunOptions
4
+ from .runner import ClaudeRunner
5
+ from .version import MIN_VERSION
6
+
7
+ __all__ = [
8
+ "ClaudeRunner",
9
+ "ClaudeRunOptions",
10
+ "MIN_VERSION",
11
+ ]
@@ -0,0 +1,51 @@
1
+ """Build CLI arguments from prompt and options."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .options import ClaudeRunOptions
6
+
7
+
8
+ def build_args(prompt: str, options: ClaudeRunOptions | None = None) -> list[str]:
9
+ """Build CLI arguments from prompt and options."""
10
+ args = ["--print", "--output-format", "stream-json", "--verbose"]
11
+
12
+ if options is None:
13
+ args.extend(["--", prompt])
14
+ return args
15
+
16
+ # Common options.
17
+ if options.model:
18
+ args.extend(["--model", options.model])
19
+ if options.system_prompt:
20
+ args.extend(["--system-prompt", options.system_prompt])
21
+ if options.append_system_prompt:
22
+ args.extend(["--append-system-prompt", options.append_system_prompt])
23
+ if options.max_turns is not None and options.max_turns > 0:
24
+ args.extend(["--max-turns", str(options.max_turns)])
25
+ if options.skip_permissions:
26
+ args.append("--dangerously-skip-permissions")
27
+
28
+ # Claude-specific options.
29
+ if options.allowed_tools:
30
+ for tool in options.allowed_tools:
31
+ args.extend(["--allowedTools", tool])
32
+ if options.disallowed_tools:
33
+ for tool in options.disallowed_tools:
34
+ args.extend(["--disallowedTools", tool])
35
+ if options.mcp_config:
36
+ args.extend(["--mcp-config", options.mcp_config])
37
+ if options.json_schema:
38
+ args.extend(["--json-schema", options.json_schema])
39
+ if options.max_budget_usd is not None and options.max_budget_usd > 0:
40
+ args.extend(["--max-budget-usd", str(options.max_budget_usd)])
41
+ if options.resume:
42
+ args.extend(["--resume", options.resume])
43
+ if options.continue_session:
44
+ args.append("--continue")
45
+ if options.session_id:
46
+ args.extend(["--session-id", options.session_id])
47
+ if options.include_partial_messages:
48
+ args.append("--include-partial-messages")
49
+
50
+ args.extend(["--", prompt])
51
+ return args
@@ -0,0 +1,59 @@
1
+ """Map Claude stream-json types to common types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from ..types import Result, Usage
8
+ from .types import StreamMessage
9
+
10
+ _TYPE_MAP = {
11
+ "system": "system",
12
+ "assistant": "assistant",
13
+ "result": "result",
14
+ }
15
+
16
+
17
+ def map_message_type(raw_type: str, raw_line: str) -> str:
18
+ """Map Claude stream-json type to common MessageType.
19
+
20
+ Uses the raw JSON line to distinguish tool_use and tool_result subtypes
21
+ from generic assistant/user messages, matching the INTERFACE.md taxonomy.
22
+ """
23
+ if raw_type in _TYPE_MAP:
24
+ return _TYPE_MAP[raw_type]
25
+
26
+ if raw_type == "user":
27
+ try:
28
+ d = json.loads(raw_line)
29
+ for block in d.get("content", []):
30
+ if isinstance(block, dict) and block.get("type") == "tool_result":
31
+ return "tool_result"
32
+ except (json.JSONDecodeError, TypeError):
33
+ pass
34
+ return "user"
35
+
36
+ if raw_type == "stream_event":
37
+ return "assistant"
38
+
39
+ return raw_type
40
+
41
+
42
+ def map_result(msg: StreamMessage, fallback_session_id: str) -> Result:
43
+ """Map a StreamMessage result to a common Result."""
44
+ usage = Usage(
45
+ input_tokens=msg.usage.input_tokens if msg.usage else 0,
46
+ output_tokens=msg.usage.output_tokens if msg.usage else 0,
47
+ cache_creation_input_tokens=msg.usage.cache_creation_input_tokens if msg.usage else 0,
48
+ cache_read_input_tokens=msg.usage.cache_read_input_tokens if msg.usage else 0,
49
+ )
50
+
51
+ return Result(
52
+ text=msg.result or "",
53
+ is_error=msg.is_error or False,
54
+ exit_code=0,
55
+ usage=usage,
56
+ cost_usd=msg.total_cost_usd or 0,
57
+ duration_ms=msg.duration_ms or 0,
58
+ session_id=msg.session_id or fallback_session_id,
59
+ )
@@ -0,0 +1,33 @@
1
+ """Configuration and option types for the Claude Code runner."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Protocol
7
+
8
+ from ..types import RunOptions
9
+
10
+
11
+ class Logger(Protocol):
12
+ """Logger interface for debug output. Opt-in, disabled by default.
13
+
14
+ Compatible with ``logging.getLogger()``.
15
+ """
16
+
17
+ def debug(self, message: str, *args: object, **kwargs: object) -> None: ...
18
+ def error(self, message: str, *args: object, **kwargs: object) -> None: ...
19
+
20
+
21
+ @dataclass
22
+ class ClaudeRunOptions(RunOptions):
23
+ """Claude Code-specific options that extend the common RunOptions."""
24
+
25
+ allowed_tools: list[str] | None = None
26
+ disallowed_tools: list[str] | None = None
27
+ mcp_config: str | None = None
28
+ json_schema: str | None = None
29
+ max_budget_usd: float | None = None
30
+ resume: str | None = None
31
+ continue_session: bool = False
32
+ session_id: str | None = None
33
+ include_partial_messages: bool = False
@@ -0,0 +1,152 @@
1
+ """Parse Claude Code CLI stream-json output lines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from .types import (
8
+ AssistantMessage,
9
+ ContentBlock,
10
+ ContentBlockInfo,
11
+ Delta,
12
+ MessageStartData,
13
+ ResultUsage,
14
+ StreamEventInner,
15
+ StreamMessage,
16
+ StreamUsage,
17
+ )
18
+
19
+
20
+ def _parse_content_block(raw: dict) -> ContentBlock:
21
+ return ContentBlock(
22
+ type=raw.get("type", ""),
23
+ text=raw.get("text"),
24
+ thinking=raw.get("thinking"),
25
+ name=raw.get("name"),
26
+ input=raw.get("input"),
27
+ content=raw.get("content"),
28
+ )
29
+
30
+
31
+ def _parse_assistant_message(raw: dict) -> AssistantMessage:
32
+ content = [_parse_content_block(b) for b in raw.get("content", [])]
33
+ return AssistantMessage(
34
+ model=raw.get("model"),
35
+ id=raw.get("id"),
36
+ content=content,
37
+ stop_reason=raw.get("stop_reason"),
38
+ )
39
+
40
+
41
+ def _parse_result_usage(raw: dict) -> ResultUsage:
42
+ return ResultUsage(
43
+ input_tokens=raw.get("input_tokens", 0),
44
+ output_tokens=raw.get("output_tokens", 0),
45
+ cache_creation_input_tokens=raw.get("cache_creation_input_tokens", 0),
46
+ cache_read_input_tokens=raw.get("cache_read_input_tokens", 0),
47
+ )
48
+
49
+
50
+ def _parse_delta(raw: dict) -> Delta:
51
+ return Delta(
52
+ type=raw.get("type"),
53
+ text=raw.get("text"),
54
+ thinking=raw.get("thinking"),
55
+ partial_json=raw.get("partial_json"),
56
+ stop_reason=raw.get("stop_reason"),
57
+ stop_sequence=raw.get("stop_sequence"),
58
+ )
59
+
60
+
61
+ def _parse_content_block_info(raw: dict) -> ContentBlockInfo:
62
+ return ContentBlockInfo(
63
+ type=raw.get("type", ""),
64
+ name=raw.get("name"),
65
+ id=raw.get("id"),
66
+ )
67
+
68
+
69
+ def _parse_stream_usage(raw: dict) -> StreamUsage:
70
+ return StreamUsage(
71
+ input_tokens=raw.get("input_tokens", 0),
72
+ output_tokens=raw.get("output_tokens", 0),
73
+ )
74
+
75
+
76
+ def _parse_message_start_data(raw: dict) -> MessageStartData:
77
+ usage = None
78
+ if "usage" in raw and isinstance(raw["usage"], dict):
79
+ usage = _parse_stream_usage(raw["usage"])
80
+ return MessageStartData(
81
+ model=raw.get("model", ""),
82
+ id=raw.get("id", ""),
83
+ usage=usage,
84
+ )
85
+
86
+
87
+ def _parse_stream_event(raw: dict) -> StreamEventInner | None:
88
+ if not isinstance(raw, dict) or not isinstance(raw.get("type"), str):
89
+ return None
90
+
91
+ event = StreamEventInner(type=raw["type"])
92
+ event.index = raw.get("index")
93
+
94
+ if "delta" in raw and isinstance(raw["delta"], dict):
95
+ event.delta = _parse_delta(raw["delta"])
96
+ if "content_block" in raw and isinstance(raw["content_block"], dict):
97
+ event.content_block = _parse_content_block_info(raw["content_block"])
98
+ if "message" in raw and isinstance(raw["message"], dict):
99
+ event.message = _parse_message_start_data(raw["message"])
100
+ if "usage" in raw and isinstance(raw["usage"], dict):
101
+ event.usage = _parse_stream_usage(raw["usage"])
102
+
103
+ return event
104
+
105
+
106
+ def parse(line: str) -> StreamMessage:
107
+ """Parse a single JSON line into a StreamMessage.
108
+
109
+ For assistant-type lines, content blocks are lifted from the nested
110
+ 'message' wrapper into StreamMessage.content for convenient access.
111
+ """
112
+ raw = json.loads(line)
113
+
114
+ if not isinstance(raw, dict) or not isinstance(raw.get("type"), str):
115
+ raise ValueError("not a valid stream message: missing type field")
116
+
117
+ msg = StreamMessage(
118
+ type=raw["type"],
119
+ subtype=raw.get("subtype"),
120
+ session_id=raw.get("session_id"),
121
+ model=raw.get("model"),
122
+ )
123
+
124
+ # Result fields.
125
+ if "result" in raw:
126
+ msg.result = raw["result"]
127
+ if "is_error" in raw:
128
+ msg.is_error = raw["is_error"]
129
+ if "total_cost_usd" in raw:
130
+ msg.total_cost_usd = raw["total_cost_usd"]
131
+ if "duration_ms" in raw:
132
+ msg.duration_ms = raw["duration_ms"]
133
+ if "duration_api_ms" in raw:
134
+ msg.duration_api_ms = raw["duration_api_ms"]
135
+ if "num_turns" in raw:
136
+ msg.num_turns = raw["num_turns"]
137
+ if "usage" in raw and isinstance(raw["usage"], dict):
138
+ msg.usage = _parse_result_usage(raw["usage"])
139
+
140
+ # Assistant message.
141
+ if "message" in raw and isinstance(raw["message"], dict):
142
+ msg.message = _parse_assistant_message(raw["message"])
143
+
144
+ # Lift content from assistant message.
145
+ if msg.type == "assistant" and msg.message and msg.message.content:
146
+ msg.content = msg.message.content
147
+
148
+ # Stream event.
149
+ if msg.type == "stream_event" and "event" in raw:
150
+ msg.event = _parse_stream_event(raw["event"])
151
+
152
+ return msg
@@ -0,0 +1,68 @@
1
+ """Process helpers: logging, error collection, spawn resolution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import shlex
7
+ from typing import Protocol
8
+
9
+ from .options import Logger
10
+
11
+
12
+ class SpawnFn(Protocol):
13
+ """Function that spawns an async subprocess.
14
+
15
+ Internal protocol used for dependency injection in tests.
16
+ """
17
+
18
+ async def __call__(
19
+ self,
20
+ program: str,
21
+ *args: str,
22
+ cwd: str | None = None,
23
+ env: dict[str, str] | None = None,
24
+ ) -> asyncio.subprocess.Process: ...
25
+
26
+
27
+ def log_cmd(
28
+ logger: Logger | None,
29
+ binary: str,
30
+ args: list[str],
31
+ cwd: str | None = None,
32
+ ) -> None:
33
+ """Log the command about to be executed."""
34
+ if not logger:
35
+ return
36
+
37
+ cmd = " ".join(shlex.quote(a) for a in [binary, *args])
38
+ logger.debug("executing CLI command", extra={"cmd": cmd, "dir": cwd or ""})
39
+
40
+
41
+ async def default_spawn(
42
+ program: str,
43
+ *args: str,
44
+ cwd: str | None = None,
45
+ env: dict[str, str] | None = None,
46
+ ) -> asyncio.subprocess.Process:
47
+ return await asyncio.create_subprocess_exec(
48
+ program,
49
+ *args,
50
+ cwd=cwd,
51
+ env=env,
52
+ stdin=asyncio.subprocess.DEVNULL,
53
+ stdout=asyncio.subprocess.PIPE,
54
+ stderr=asyncio.subprocess.PIPE,
55
+ )
56
+
57
+
58
+ def collect_error_detail(stderr: str, stdout_errors: list[str]) -> str:
59
+ """Build a human-readable error string from stderr and unparseable stdout lines."""
60
+ parts: list[str] = []
61
+ trimmed = stderr.strip()
62
+ if trimmed:
63
+ parts.append(trimmed)
64
+ if stdout_errors:
65
+ parts.append("\n".join(stdout_errors))
66
+ if not parts:
67
+ return "unknown error (no output from CLI)"
68
+ return "\n".join(parts)
@@ -0,0 +1,270 @@
1
+ """Claude Code runner implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import os
7
+ from collections.abc import AsyncIterator
8
+ from typing import Any
9
+
10
+ from ..errors import (
11
+ CancelledError,
12
+ NonZeroExitError,
13
+ NoResultError,
14
+ NotFoundError,
15
+ TimeoutError,
16
+ )
17
+ from ..types import Message, Result
18
+ from .args import build_args
19
+ from .mapping import map_message_type, map_result
20
+ from .options import ClaudeRunOptions, Logger
21
+ from .parser import parse
22
+ from .process import SpawnFn, collect_error_detail, default_spawn, log_cmd
23
+ from .types import StreamMessage
24
+ from .version import check_version
25
+
26
+
27
+ class ClaudeSession:
28
+ """Session encapsulates a running Claude Code agent process.
29
+
30
+ Supports ``async for msg in session`` to iterate messages,
31
+ and ``await session.result`` to get the final result.
32
+ """
33
+
34
+ def __init__(
35
+ self,
36
+ logger: Logger | None,
37
+ spawn_fn: SpawnFn,
38
+ binary: str,
39
+ prompt: str,
40
+ options: ClaudeRunOptions,
41
+ ) -> None:
42
+ self._logger = logger
43
+ self._spawn_fn = spawn_fn
44
+ self._binary = binary
45
+ self._prompt = prompt
46
+ self._options = options
47
+
48
+ self._loop = asyncio.get_running_loop()
49
+ self._queue: asyncio.Queue[Message | None] = asyncio.Queue()
50
+ self._result_future: asyncio.Future[Result] = self._loop.create_future()
51
+ self._process: asyncio.subprocess.Process | None = None
52
+ self._aborted = False
53
+ self._timed_out = False
54
+ self._task: asyncio.Task[None] | None = None
55
+
56
+ # Launch the background task.
57
+ self._task = asyncio.ensure_future(self._run_process())
58
+
59
+ async def _run_process(self) -> None:
60
+ args = build_args(self._prompt, self._options)
61
+ env = {**os.environ, **self._options.env} if self._options.env else None
62
+
63
+ log_cmd(self._logger, self._binary, args, self._options.working_dir)
64
+
65
+ try:
66
+ self._process = await self._spawn_fn(
67
+ self._binary,
68
+ *args,
69
+ cwd=self._options.working_dir,
70
+ env=env,
71
+ )
72
+ except FileNotFoundError:
73
+ err = NotFoundError(f"failed to start {self._binary}: command not found")
74
+ self._result_future.set_exception(err)
75
+ await self._queue.put(None)
76
+ return
77
+
78
+ if self._process.stdout is None:
79
+ err = NotFoundError(f"failed to start {self._binary}: no stdout")
80
+ self._result_future.set_exception(err)
81
+ await self._queue.put(None)
82
+ return
83
+
84
+ # Set up timeout (options.timeout is in seconds).
85
+ timeout_handle: asyncio.TimerHandle | None = None
86
+ if self._options.timeout is not None and self._options.timeout > 0:
87
+ timeout_handle = self._loop.call_later(self._options.timeout, self._on_timeout)
88
+
89
+ init_session_id = ""
90
+ result_msg: StreamMessage | None = None
91
+ stdout_errors: list[str] = []
92
+
93
+ try:
94
+ while True:
95
+ raw_line = await self._process.stdout.readline()
96
+ if not raw_line:
97
+ break
98
+
99
+ line = raw_line.decode("utf-8", errors="replace").rstrip("\n").rstrip("\r")
100
+ if not line:
101
+ continue
102
+
103
+ if self._aborted or self._timed_out:
104
+ break
105
+
106
+ try:
107
+ parsed = parse(line)
108
+ except Exception:
109
+ stdout_errors.append(line)
110
+ continue
111
+
112
+ if parsed.type == "system" and parsed.subtype == "init" and parsed.session_id:
113
+ init_session_id = parsed.session_id
114
+ if parsed.type == "result":
115
+ result_msg = parsed
116
+
117
+ msg = Message(type=map_message_type(parsed.type, line), raw=line)
118
+
119
+ await self._queue.put(msg)
120
+
121
+ # Wait for process to finish.
122
+ await self._process.wait()
123
+
124
+ if timeout_handle:
125
+ timeout_handle.cancel()
126
+
127
+ if self._timed_out:
128
+ self._result_future.set_exception(TimeoutError("execution timed out"))
129
+ return
130
+
131
+ if self._aborted:
132
+ self._result_future.set_exception(CancelledError("execution cancelled"))
133
+ return
134
+
135
+ if result_msg:
136
+ self._result_future.set_result(map_result(result_msg, init_session_id))
137
+ return
138
+
139
+ exit_code = self._process.returncode
140
+ if exit_code is not None and exit_code != 0:
141
+ stderr_bytes = await self._process.stderr.read() if self._process.stderr else b""
142
+ stderr = stderr_bytes.decode("utf-8", errors="replace")
143
+ detail = collect_error_detail(stderr, stdout_errors)
144
+ if self._logger:
145
+ self._logger.error(
146
+ "CLI command failed",
147
+ extra={
148
+ "exit_code": exit_code,
149
+ "stderr": stderr.strip(),
150
+ "stdout_errors": stdout_errors,
151
+ },
152
+ )
153
+ self._result_future.set_exception(
154
+ NonZeroExitError(exit_code, f"exit {exit_code}: {detail}")
155
+ )
156
+ return
157
+
158
+ self._result_future.set_exception(NoResultError())
159
+ finally:
160
+ await self._queue.put(None)
161
+ if self._process.returncode is None:
162
+ try:
163
+ self._process.kill()
164
+ except ProcessLookupError:
165
+ pass
166
+ if timeout_handle:
167
+ timeout_handle.cancel()
168
+
169
+ def _on_timeout(self) -> None:
170
+ self._timed_out = True
171
+ if self._process and self._process.returncode is None:
172
+ try:
173
+ self._process.kill()
174
+ except ProcessLookupError:
175
+ pass
176
+
177
+ def __aiter__(self) -> AsyncIterator[Message]:
178
+ return self._message_iter()
179
+
180
+ async def _message_iter(self) -> AsyncIterator[Message]:
181
+ while True:
182
+ msg = await self._queue.get()
183
+ if msg is None:
184
+ break
185
+ yield msg
186
+
187
+ @property
188
+ def result(self) -> asyncio.Future[Result]:
189
+ return self._result_future
190
+
191
+ def abort(self) -> None:
192
+ self._aborted = True
193
+ if self._process and self._process.returncode is None:
194
+ try:
195
+ self._process.kill()
196
+ except ProcessLookupError:
197
+ pass
198
+
199
+ def send(self, input: Any) -> None:
200
+ raise NotImplementedError("send is not yet supported")
201
+
202
+
203
+ class ClaudeRunner:
204
+ """Claude Code runner.
205
+
206
+ Construct directly with keyword arguments::
207
+
208
+ runner = ClaudeRunner(binary="/usr/local/bin/claude", logger=my_logger)
209
+
210
+ For testing, pass ``_spawn`` to inject a fake subprocess spawner::
211
+
212
+ runner = ClaudeRunner(_spawn=my_fake_spawn)
213
+ """
214
+
215
+ def __init__(
216
+ self,
217
+ *,
218
+ binary: str = "claude",
219
+ logger: Logger | None = None,
220
+ _spawn: SpawnFn | None = None,
221
+ ) -> None:
222
+ self._binary = binary
223
+ self._logger = logger
224
+ self._spawn_fn: SpawnFn = _spawn or default_spawn
225
+ self._has_custom_spawn = _spawn is not None
226
+ self._version_checked = False
227
+
228
+ async def _ensure_version(self) -> None:
229
+ """Check the CLI version once per runner instance."""
230
+ if self._version_checked or self._has_custom_spawn:
231
+ return
232
+ await check_version(self._binary)
233
+ self._version_checked = True
234
+
235
+ def start(
236
+ self,
237
+ prompt: str,
238
+ options: ClaudeRunOptions | None = None,
239
+ ) -> ClaudeSession:
240
+ return ClaudeSession(
241
+ self._logger,
242
+ self._spawn_fn,
243
+ self._binary,
244
+ prompt,
245
+ options or ClaudeRunOptions(),
246
+ )
247
+
248
+ async def run(
249
+ self,
250
+ prompt: str,
251
+ options: ClaudeRunOptions | None = None,
252
+ ) -> Result:
253
+ await self._ensure_version()
254
+ session = self.start(prompt, options)
255
+ async for _msg in session:
256
+ pass
257
+ return await session.result
258
+
259
+ async def run_stream(
260
+ self,
261
+ prompt: str,
262
+ options: ClaudeRunOptions | None = None,
263
+ ) -> ClaudeSession:
264
+ """Start a session and return it for streaming.
265
+
266
+ The returned session is async-iterable and also provides
267
+ ``session.result`` and ``session.abort()``.
268
+ """
269
+ await self._ensure_version()
270
+ return self.start(prompt, options)