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.
- agentrunner/__init__.py +35 -0
- agentrunner/claudecode/__init__.py +11 -0
- agentrunner/claudecode/args.py +51 -0
- agentrunner/claudecode/mapping.py +59 -0
- agentrunner/claudecode/options.py +33 -0
- agentrunner/claudecode/parser.py +152 -0
- agentrunner/claudecode/process.py +68 -0
- agentrunner/claudecode/runner.py +270 -0
- agentrunner/claudecode/types.py +131 -0
- agentrunner/claudecode/version.py +47 -0
- agentrunner/errors.py +36 -0
- agentrunner/ollama/__init__.py +14 -0
- agentrunner/ollama/accessors.py +22 -0
- agentrunner/ollama/options.py +41 -0
- agentrunner/ollama/runner.py +327 -0
- agentrunner/ollama/types.py +87 -0
- agentrunner/types.py +188 -0
- driangle_agentrunner-0.0.1.dist-info/METADATA +219 -0
- driangle_agentrunner-0.0.1.dist-info/RECORD +20 -0
- driangle_agentrunner-0.0.1.dist-info/WHEEL +4 -0
agentrunner/__init__.py
ADDED
|
@@ -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,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)
|