callai-cli 0.9.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.
- callai/__init__.py +37 -0
- callai/__main__.py +8 -0
- callai/adapters/__init__.py +82 -0
- callai/adapters/base.py +190 -0
- callai/adapters/claude_code.py +194 -0
- callai/adapters/codex.py +155 -0
- callai/adapters/grok.py +207 -0
- callai/adapters/hermes.py +112 -0
- callai/adapters/opencode.py +186 -0
- callai/adapters/pi.py +179 -0
- callai/api.py +134 -0
- callai/cli.py +674 -0
- callai/config.py +273 -0
- callai/errors.py +65 -0
- callai/fanout.py +166 -0
- callai/py.typed +0 -0
- callai/request.py +70 -0
- callai/result.py +95 -0
- callai/runner.py +233 -0
- callai_cli-0.9.0.dist-info/METADATA +218 -0
- callai_cli-0.9.0.dist-info/RECORD +25 -0
- callai_cli-0.9.0.dist-info/WHEEL +5 -0
- callai_cli-0.9.0.dist-info/entry_points.txt +2 -0
- callai_cli-0.9.0.dist-info/licenses/LICENSE +201 -0
- callai_cli-0.9.0.dist-info/top_level.txt +1 -0
callai/adapters/grok.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Adapter for xAI's Grok Build CLI (``grok --output-format json --single=PROMPT``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..errors import UsageError
|
|
9
|
+
from ..request import Request
|
|
10
|
+
from ..result import Completed, Result
|
|
11
|
+
from .base import Adapter, content_text, json_line, jsonl_events, strip_ansi, tool_result, tool_use
|
|
12
|
+
|
|
13
|
+
# Verified against grok 1.0.70 with scripted tool calls from the stub (write, edit, shell). A headless
|
|
14
|
+
# run refuses (and cancels the turn on) any call that needs approval, so "default" already blocks
|
|
15
|
+
# writes and shell. read-only also removes every tool but grok's read tools; --tools keeps
|
|
16
|
+
# search_tool/use_tool (the gateway to MCP and deferred tools), so those are removed explicitly.
|
|
17
|
+
# auto uses allow rules: grok accepts --permission-mode acceptEdits but still refused a headless write,
|
|
18
|
+
# and --permission-mode auto lets a model-based safety check approve calls (shell included).
|
|
19
|
+
_PERMISSION_FLAGS: dict[str, list[str]] = {
|
|
20
|
+
"default": [],
|
|
21
|
+
"read-only": ["--tools", "read_file,grep,list_dir", "--disallowed-tools", "search_tool,use_tool"],
|
|
22
|
+
"auto": ["--allow", "Write", "--allow", "Edit"], # writes and edits yes; shell etc. refused
|
|
23
|
+
"bypass": ["--permission-mode", "bypassPermissions"],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Grok(Adapter):
|
|
28
|
+
"""``grok --output-format json`` — parses the single JSON result object."""
|
|
29
|
+
|
|
30
|
+
name = "grok"
|
|
31
|
+
binary = "grok"
|
|
32
|
+
install_url = "https://x.ai/cli"
|
|
33
|
+
|
|
34
|
+
def build_argv(self, req: Request) -> list[str]:
|
|
35
|
+
"""Build the ``grok`` command line.
|
|
36
|
+
|
|
37
|
+
grok's ``-p``/``--single`` has no ``--`` escape (``-p --help`` is a parser error), so the prompt
|
|
38
|
+
goes last as one ``--single=PROMPT`` element, which takes any text as the value.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
req: The request to run.
|
|
42
|
+
|
|
43
|
+
``-o stream-json`` uses ``--output-format streaming-json`` (JSON lines; see :meth:`parse`).
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The argv list, prompt last.
|
|
47
|
+
|
|
48
|
+
Raises:
|
|
49
|
+
UsageError: If the prompt starts with ``/``, which grok runs as a slash command.
|
|
50
|
+
"""
|
|
51
|
+
if req.prompt.lstrip().startswith("/"):
|
|
52
|
+
raise UsageError(
|
|
53
|
+
"grok runs a prompt starting with '/' as a slash command (e.g. /compact), even after "
|
|
54
|
+
"leading spaces, and has no escape; start the prompt with other text"
|
|
55
|
+
)
|
|
56
|
+
fmt = "streaming-json" if req.output_format == "stream-json" else "json"
|
|
57
|
+
argv = [self.binary, "--output-format", fmt]
|
|
58
|
+
if req.model:
|
|
59
|
+
argv += ["--model", req.model]
|
|
60
|
+
argv += _PERMISSION_FLAGS[req.permission]
|
|
61
|
+
if req.system_prompt is not None:
|
|
62
|
+
argv += ["--system-prompt-override", req.system_prompt]
|
|
63
|
+
return [*argv, *_session_flags(req), *req.extra_args, f"--single={req.prompt}"]
|
|
64
|
+
|
|
65
|
+
def parse(self, completed: Completed, req: Request) -> Result:
|
|
66
|
+
"""Parse the JSON object printed by ``grok --output-format json`` (or rebuilt from ``streaming-json``).
|
|
67
|
+
|
|
68
|
+
Success is ``{"text", "stopReason": "end_turn", "sessionId", "usage", "modelUsage", ...}``.
|
|
69
|
+
Failure is ``{"type": "error", "message"}`` (also on stderr, exit 1), or a result whose
|
|
70
|
+
``stopReason`` isn't ``end_turn``: ``cancelled`` when grok refused a tool call that needs
|
|
71
|
+
approval (exit **0**), ``max_turn_requests`` for ``--max-turns`` (exit 1). Those carry no
|
|
72
|
+
text; the reason is on stderr.
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
completed: Raw process outcome.
|
|
76
|
+
req: The request that produced it.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
The normalised result.
|
|
80
|
+
"""
|
|
81
|
+
obj = _result_object(completed.stdout)
|
|
82
|
+
is_error_obj = isinstance(obj, dict) and obj.get("type") == "error"
|
|
83
|
+
text = obj.get("message" if is_error_obj else "text") if isinstance(obj, dict) else None
|
|
84
|
+
if not isinstance(text, str): # no JSON result (e.g. a crash): the reason is on stderr
|
|
85
|
+
text = strip_ansi(completed.stderr).strip() or strip_ansi(completed.stdout).strip()
|
|
86
|
+
return self.text_result(completed, req).with_updates(text=text, is_error=True)
|
|
87
|
+
stop = obj.get("stopReason")
|
|
88
|
+
failed = is_error_obj or completed.exit_code != 0 or stop not in (None, "end_turn")
|
|
89
|
+
if failed and not text:
|
|
90
|
+
text = strip_ansi(completed.stderr).strip() or str(stop or "")
|
|
91
|
+
cost = obj.get("total_cost_usd")
|
|
92
|
+
return Result(
|
|
93
|
+
harness_name=self.name,
|
|
94
|
+
model=_model(obj.get("modelUsage")) or req.model,
|
|
95
|
+
text=text,
|
|
96
|
+
exit_code=completed.exit_code,
|
|
97
|
+
is_error=failed,
|
|
98
|
+
duration_ms=completed.duration_ms,
|
|
99
|
+
session_id=obj.get("sessionId"),
|
|
100
|
+
usage=_usage(obj.get("usage")),
|
|
101
|
+
cost_usd=cost if isinstance(cost, int | float) else None,
|
|
102
|
+
argv=completed.argv,
|
|
103
|
+
raw_stdout=completed.stdout,
|
|
104
|
+
raw_stderr=completed.stderr,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def stream_events(self, line: str, state: dict[str, Any]) -> list[dict[str, Any]]:
|
|
108
|
+
"""Map a ``grok --output-format streaming-json`` line: text pieces and tool calls.
|
|
109
|
+
|
|
110
|
+
A call is reported from ``tool_call`` and its result from the ``tool_call_update`` whose
|
|
111
|
+
status is ``completed`` or ``failed`` (a refused call fails with "User cancelled ...").
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
line: One stdout line.
|
|
115
|
+
state: Unused.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
The events.
|
|
119
|
+
"""
|
|
120
|
+
data = json_line(line)
|
|
121
|
+
kind = data.get("type") if data else None
|
|
122
|
+
if kind == "text":
|
|
123
|
+
return [{"type": "text", "text": data.get("data", "")}]
|
|
124
|
+
if kind == "tool_call":
|
|
125
|
+
return [tool_use(data.get("toolCallId"), data.get("toolName"), data.get("rawInput"))]
|
|
126
|
+
if kind == "tool_call_update" and data.get("status") in ("completed", "failed"):
|
|
127
|
+
blocks = data.get("content") if isinstance(data.get("content"), list) else []
|
|
128
|
+
output = "".join(content_text([b.get("content")]) for b in blocks if isinstance(b, dict))
|
|
129
|
+
return [tool_result(data.get("toolCallId"), data["status"] == "failed", output)]
|
|
130
|
+
return []
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _result_object(stdout: str) -> dict[str, Any] | None:
|
|
134
|
+
"""The run's result object, from ``--output-format json`` or ``streaming-json`` output.
|
|
135
|
+
|
|
136
|
+
json mode prints one object. streaming-json prints JSON lines: ``text`` pieces, then an ``end``
|
|
137
|
+
object that is the json-mode object without ``text``, or the same ``{"type": "error"}`` object.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
stdout: grok's stdout.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
The object (for a stream, ``end`` with the joined text added), or ``None`` if there is none.
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
whole = json.loads(stdout)
|
|
147
|
+
except ValueError:
|
|
148
|
+
whole = None
|
|
149
|
+
if isinstance(whole, dict) and whole.get("type") != "end":
|
|
150
|
+
return whole
|
|
151
|
+
events = jsonl_events(stdout)
|
|
152
|
+
last = next((e for e in reversed(events) if e.get("type") in ("end", "error")), None)
|
|
153
|
+
if last is None or last["type"] == "error":
|
|
154
|
+
return last
|
|
155
|
+
text = "".join(e["data"] for e in events if e.get("type") == "text" and isinstance(e.get("data"), str))
|
|
156
|
+
return {**last, "text": text}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _usage(raw: Any) -> dict[str, int] | None:
|
|
160
|
+
"""Normalise grok's usage; its ``input_tokens`` excludes cache reads and writes, so add them back.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
raw: The result's ``usage`` object.
|
|
164
|
+
|
|
165
|
+
Returns:
|
|
166
|
+
``{"input_tokens", "output_tokens"}`` or ``None``.
|
|
167
|
+
"""
|
|
168
|
+
if not isinstance(raw, dict):
|
|
169
|
+
return None
|
|
170
|
+
keys = ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens")
|
|
171
|
+
return {
|
|
172
|
+
"input_tokens": sum(int(raw.get(k) or 0) for k in keys),
|
|
173
|
+
"output_tokens": int(raw.get("output_tokens") or 0),
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _model(model_usage: Any) -> str | None:
|
|
178
|
+
"""The model that did most of the work, by tokens, from ``modelUsage``.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
model_usage: The result's ``modelUsage`` object (model id → counts).
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
The model id, or ``None`` if none is reported.
|
|
185
|
+
"""
|
|
186
|
+
if not isinstance(model_usage, dict):
|
|
187
|
+
return None
|
|
188
|
+
sizes = {
|
|
189
|
+
m: int(u.get("inputTokens") or 0) + int(u.get("outputTokens") or 0)
|
|
190
|
+
for m, u in model_usage.items()
|
|
191
|
+
if isinstance(u, dict)
|
|
192
|
+
}
|
|
193
|
+
return max(sizes, key=sizes.__getitem__) if sizes else None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _session_flags(req: Request) -> list[str]:
|
|
197
|
+
"""The harness's flags for ``--resume ID`` / ``--continue`` (none for a new session).
|
|
198
|
+
|
|
199
|
+
Args:
|
|
200
|
+
req: The request.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
The flags.
|
|
204
|
+
"""
|
|
205
|
+
if req.resume is not None:
|
|
206
|
+
return [f"--resume={req.resume}"]
|
|
207
|
+
return ["--continue"] if req.continue_session else []
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Adapter for Nous Research's Hermes Agent (``hermes chat -Q --query=PROMPT``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
from ..errors import UsageError
|
|
8
|
+
from ..request import Request
|
|
9
|
+
from ..result import Completed, Result
|
|
10
|
+
from .base import Adapter, strip_ansi
|
|
11
|
+
|
|
12
|
+
# Verified against hermes-agent 0.19.0 with scripted tool calls from the stub. hermes runs file writes
|
|
13
|
+
# and ordinary shell commands without asking; only "dangerous" commands need approval, which a
|
|
14
|
+
# non-interactive run denies on timeout. -t takes toolset names, and no toolset is read-only.
|
|
15
|
+
_PERMISSION_FLAGS: dict[str, list[str]] = {
|
|
16
|
+
"default": [],
|
|
17
|
+
"auto": ["-t", "file"], # read_file, write_file, patch, search_files: edits yes, shell no
|
|
18
|
+
"bypass": ["--yolo"],
|
|
19
|
+
}
|
|
20
|
+
_SESSION_RE = re.compile(r"^session_id: (\S+)$", re.MULTILINE)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Hermes(Adapter):
|
|
24
|
+
"""``hermes chat -Q`` — the answer is stdout minus hermes's display lines."""
|
|
25
|
+
|
|
26
|
+
name = "hermes"
|
|
27
|
+
binary = "hermes"
|
|
28
|
+
install_url = "https://hermes-agent.nousresearch.com/docs/"
|
|
29
|
+
|
|
30
|
+
def build_argv(self, req: Request) -> list[str]:
|
|
31
|
+
"""Build the ``hermes`` command line.
|
|
32
|
+
|
|
33
|
+
Not ``hermes -z``: one-shot mode auto-approves every tool call. ``--source tool`` keeps callai
|
|
34
|
+
runs out of the user's session list. The prompt goes last as one ``--query=PROMPT`` element,
|
|
35
|
+
so a prompt starting with ``-`` can't be read as an option.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
req: The request to run.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The argv list, prompt last.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
UsageError: For ``--permission read-only`` (no read-only toolset) or ``--continue`` (hermes
|
|
45
|
+
would continue the user's interactive session, not callai's last run).
|
|
46
|
+
"""
|
|
47
|
+
if req.permission == "read-only":
|
|
48
|
+
raise UsageError(
|
|
49
|
+
"--permission read-only: hermes has no read-only toolset (its `file` toolset can write); "
|
|
50
|
+
"use the default or --permission auto"
|
|
51
|
+
)
|
|
52
|
+
if req.continue_session:
|
|
53
|
+
raise UsageError(
|
|
54
|
+
"--continue: hermes only continues its most recent interactive session, not callai's runs; "
|
|
55
|
+
"use --resume with the session_id callai reported"
|
|
56
|
+
)
|
|
57
|
+
argv = [self.binary, "chat", "-Q", "--source", "tool"]
|
|
58
|
+
if req.model:
|
|
59
|
+
argv += ["-m", req.model]
|
|
60
|
+
argv += _PERMISSION_FLAGS[req.permission]
|
|
61
|
+
return [*argv, *_session_flags(req), *req.extra_args, f"--query={req.prompt}"]
|
|
62
|
+
|
|
63
|
+
def build_env(self, req: Request) -> dict[str, str]:
|
|
64
|
+
"""Pass ``--system-prompt`` as ``HERMES_EPHEMERAL_SYSTEM_PROMPT``.
|
|
65
|
+
|
|
66
|
+
hermes has no flag for it; it reads this variable (before ``agent.system_prompt`` in its
|
|
67
|
+
config) and never saves it to the session history.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
req: The request to run.
|
|
71
|
+
|
|
72
|
+
Returns:
|
|
73
|
+
The variable when a system prompt is given, else nothing (an inherited one is kept).
|
|
74
|
+
"""
|
|
75
|
+
return {} if req.system_prompt is None else {"HERMES_EPHEMERAL_SYSTEM_PROMPT": req.system_prompt}
|
|
76
|
+
|
|
77
|
+
def parse(self, completed: Completed, req: Request) -> Result:
|
|
78
|
+
r"""Parse ``hermes chat -Q`` output.
|
|
79
|
+
|
|
80
|
+
hermes prints the answer with a plain ``print`` (``\n`` line ends) after the run. Its display
|
|
81
|
+
lines (tool diff previews, warnings, "denying command") go through prompt_toolkit and end in
|
|
82
|
+
``\r\n``, so those are dropped. The session id is on stderr. Failure is a non-zero exit, or
|
|
83
|
+
no answer at all, whose reason ("Error: ...") is then on stderr.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
completed: Raw process outcome.
|
|
87
|
+
req: The request that produced it.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
The normalised result.
|
|
91
|
+
"""
|
|
92
|
+
answer = "".join(line for line in completed.stdout.splitlines(keepends=True) if not line.endswith("\r\n"))
|
|
93
|
+
text = strip_ansi(answer).strip()
|
|
94
|
+
failed = completed.exit_code != 0 or not text
|
|
95
|
+
if not text:
|
|
96
|
+
text = strip_ansi(_SESSION_RE.sub("", completed.stderr)).strip()
|
|
97
|
+
match = _SESSION_RE.search(completed.stderr)
|
|
98
|
+
return self.text_result(completed, req).with_updates(
|
|
99
|
+
text=text, is_error=failed, session_id=match.group(1) if match else None
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _session_flags(req: Request) -> list[str]:
|
|
104
|
+
"""The harness's flag for ``--resume ID`` (``--continue`` is refused in ``build_argv``).
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
req: The request.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
The flags.
|
|
111
|
+
"""
|
|
112
|
+
return ["--resume", req.resume] if req.resume is not None else []
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Adapter for the opencode CLI (``opencode run``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..errors import UsageError
|
|
9
|
+
from ..request import Request
|
|
10
|
+
from ..result import Completed, Result
|
|
11
|
+
from .base import Adapter, json_line, jsonl_events, tool_result, tool_use
|
|
12
|
+
|
|
13
|
+
# Verified against opencode 1.19.5. Levels opencode cannot honour fail closed (DESIGN.md §7).
|
|
14
|
+
_PERMISSION_FLAGS: dict[str, list[str]] = {"default": [], "bypass": ["--auto"]}
|
|
15
|
+
_UNSUPPORTED_PERMISSION = {
|
|
16
|
+
"read-only": "opencode has no read-only mode (its plan agent still allows shell commands); "
|
|
17
|
+
"pass `-- --agent plan` yourself if that is acceptable",
|
|
18
|
+
"auto": "opencode has no 'auto-approve edits only' mode (--auto approves everything); "
|
|
19
|
+
"use --permission bypass or the default",
|
|
20
|
+
}
|
|
21
|
+
# `--print-logs --log-level ERROR` puts the real failure reason on stderr, e.g.
|
|
22
|
+
# level=ERROR ... error="ProviderModelNotFoundError: Model not found: ..." cause="..."
|
|
23
|
+
_ERROR_LOG_RE = re.compile(r'level=ERROR\b.*?\berror="((?:[^"\\]|\\.)*)"')
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class OpenCode(Adapter):
|
|
27
|
+
"""``opencode run --format json`` — parses the JSONL event stream."""
|
|
28
|
+
|
|
29
|
+
name = "opencode"
|
|
30
|
+
binary = "opencode"
|
|
31
|
+
install_url = "https://opencode.ai/docs/"
|
|
32
|
+
|
|
33
|
+
def build_argv(self, req: Request) -> list[str]:
|
|
34
|
+
"""Build the ``opencode run`` command line.
|
|
35
|
+
|
|
36
|
+
opencode wraps every message argument that contains a space in literal quotes before sending
|
|
37
|
+
it to the model, so the prompt is passed split on single spaces; opencode joins the pieces
|
|
38
|
+
with single spaces, which rebuilds the prompt exactly (all of it after ``--``).
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
req: The request to run.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The argv list.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
UsageError: For permission levels or a system prompt opencode cannot honour.
|
|
48
|
+
"""
|
|
49
|
+
if req.permission in _UNSUPPORTED_PERMISSION:
|
|
50
|
+
raise UsageError(f"--permission {req.permission}: {_UNSUPPORTED_PERMISSION[req.permission]}")
|
|
51
|
+
if req.system_prompt is not None:
|
|
52
|
+
raise UsageError("opencode run has no system prompt option; drop --system-prompt")
|
|
53
|
+
argv = [self.binary, "run", "--format", "json", "--print-logs", "--log-level", "ERROR"]
|
|
54
|
+
if req.model:
|
|
55
|
+
argv += ["--model", req.model]
|
|
56
|
+
argv += _PERMISSION_FLAGS[req.permission]
|
|
57
|
+
return [*argv, *_session_flags(req), *req.extra_args, "--", *req.prompt.split(" ")]
|
|
58
|
+
|
|
59
|
+
def parse(self, completed: Completed, req: Request) -> Result:
|
|
60
|
+
"""Parse the JSONL events of ``opencode run --format json``.
|
|
61
|
+
|
|
62
|
+
The answer is the text of the last step (earlier steps precede tool calls); tokens and cost
|
|
63
|
+
are summed over all steps. An ``error`` event or a non-zero exit is a failure, reported with
|
|
64
|
+
the ERROR log line from stderr when there is one (the event itself is often generic).
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
completed: Raw process outcome.
|
|
68
|
+
req: The request that produced it.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
The normalised result.
|
|
72
|
+
"""
|
|
73
|
+
events = jsonl_events(completed.stdout)
|
|
74
|
+
if not events:
|
|
75
|
+
return self.text_result(completed, req).with_updates(is_error=True)
|
|
76
|
+
texts: list[str] = []
|
|
77
|
+
session_id = None
|
|
78
|
+
usage: dict[str, int] | None = None
|
|
79
|
+
cost: float | None = None
|
|
80
|
+
failure: str | None = None
|
|
81
|
+
for ev in events:
|
|
82
|
+
session_id = session_id or ev.get("sessionID")
|
|
83
|
+
part = ev.get("part") or {}
|
|
84
|
+
kind = ev.get("type")
|
|
85
|
+
if kind == "step_start":
|
|
86
|
+
texts = []
|
|
87
|
+
elif kind == "text" and isinstance(part.get("text"), str):
|
|
88
|
+
texts.append(part["text"])
|
|
89
|
+
elif kind == "step_finish":
|
|
90
|
+
usage, cost = _add_step(usage, cost, part)
|
|
91
|
+
elif kind == "error":
|
|
92
|
+
err = ev.get("error") or {}
|
|
93
|
+
failure = (err.get("data") or {}).get("message") or err.get("name") or "opencode error"
|
|
94
|
+
log = _ERROR_LOG_RE.search(completed.stderr)
|
|
95
|
+
if failure is not None and log:
|
|
96
|
+
failure = log.group(1).replace('\\"', '"')
|
|
97
|
+
text = "\n".join(texts)
|
|
98
|
+
return Result(
|
|
99
|
+
harness_name=self.name,
|
|
100
|
+
model=req.model,
|
|
101
|
+
text=failure if failure is not None else text,
|
|
102
|
+
exit_code=completed.exit_code,
|
|
103
|
+
is_error=failure is not None or completed.exit_code != 0,
|
|
104
|
+
duration_ms=completed.duration_ms,
|
|
105
|
+
session_id=session_id,
|
|
106
|
+
usage=usage,
|
|
107
|
+
cost_usd=cost,
|
|
108
|
+
argv=completed.argv,
|
|
109
|
+
raw_stdout=completed.stdout,
|
|
110
|
+
raw_stderr=completed.stderr,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
def stream_events(self, line: str, state: dict[str, Any]) -> list[dict[str, Any]]:
|
|
114
|
+
"""Map an ``opencode run --format json`` line: ``text`` parts, and ``tool_use`` parts.
|
|
115
|
+
|
|
116
|
+
opencode 1.19.5 prints a tool part once the call has finished, with its input and output
|
|
117
|
+
together, so both events come from one line. A part seen earlier (still running) isn't
|
|
118
|
+
reported twice; ``state`` remembers it.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
line: One stdout line.
|
|
122
|
+
state: Remembers reported call ids (``"started"``).
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The events.
|
|
126
|
+
"""
|
|
127
|
+
data = json_line(line)
|
|
128
|
+
part = data.get("part") if data else None
|
|
129
|
+
if not isinstance(part, dict):
|
|
130
|
+
return []
|
|
131
|
+
if data.get("type") == "text":
|
|
132
|
+
return [{"type": "text", "text": part.get("text", "")}]
|
|
133
|
+
call = part.get("state")
|
|
134
|
+
if data.get("type") != "tool_use" or not isinstance(call, dict):
|
|
135
|
+
return []
|
|
136
|
+
call_id = part.get("callID")
|
|
137
|
+
started = state.setdefault("started", set())
|
|
138
|
+
events = []
|
|
139
|
+
if call_id not in started:
|
|
140
|
+
started.add(call_id)
|
|
141
|
+
events.append(tool_use(call_id, part.get("tool"), call.get("input")))
|
|
142
|
+
if call.get("status") in ("completed", "error"):
|
|
143
|
+
failed = call["status"] == "error"
|
|
144
|
+
output = call.get("error" if failed else "output")
|
|
145
|
+
events.append(tool_result(call_id, failed, output if isinstance(output, str) else ""))
|
|
146
|
+
return events
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _add_step(
|
|
150
|
+
usage: dict[str, int] | None, cost: float | None, part: dict[str, Any]
|
|
151
|
+
) -> tuple[dict[str, int] | None, float | None]:
|
|
152
|
+
"""Add one ``step_finish`` part's tokens and cost to the running totals.
|
|
153
|
+
|
|
154
|
+
opencode's ``input`` excludes cached tokens, so cache reads and writes are added back in.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
usage: Totals so far, or ``None``.
|
|
158
|
+
cost: Cost so far, or ``None``.
|
|
159
|
+
part: The ``step_finish`` event's part.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
The updated ``(usage, cost)``.
|
|
163
|
+
"""
|
|
164
|
+
tokens = part.get("tokens")
|
|
165
|
+
if isinstance(tokens, dict):
|
|
166
|
+
cache = tokens.get("cache") or {}
|
|
167
|
+
usage = usage or {"input_tokens": 0, "output_tokens": 0}
|
|
168
|
+
usage["input_tokens"] += sum(int(v or 0) for v in (tokens.get("input"), cache.get("read"), cache.get("write")))
|
|
169
|
+
usage["output_tokens"] += int(tokens.get("output") or 0)
|
|
170
|
+
if isinstance(part.get("cost"), int | float):
|
|
171
|
+
cost = (cost or 0) + part["cost"]
|
|
172
|
+
return usage, cost
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _session_flags(req: Request) -> list[str]:
|
|
176
|
+
"""The harness's flags for ``--resume ID`` / ``--continue`` (none for a new session).
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
req: The request.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
The flags.
|
|
183
|
+
"""
|
|
184
|
+
if req.resume is not None:
|
|
185
|
+
return ["--session", req.resume]
|
|
186
|
+
return ["--continue"] if req.continue_session else []
|
callai/adapters/pi.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Adapter for the pi coding agent (``pi --mode json``)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from ..errors import UsageError
|
|
8
|
+
from ..request import Request
|
|
9
|
+
from ..result import Completed, Result
|
|
10
|
+
from .base import Adapter, content_text, json_line, jsonl_events, strip_ansi, tool_result, tool_use
|
|
11
|
+
|
|
12
|
+
# pi has no approval prompts, only tool allowlists (verified against pi 1.4.0 and the stub's request
|
|
13
|
+
# log). --tools keeps MCP tools, so restricted levels also pass --no-mcp.
|
|
14
|
+
_READ_TOOLS = "read,grep,find,ls"
|
|
15
|
+
_PERMISSION_FLAGS: dict[str, list[str]] = {
|
|
16
|
+
"default": [],
|
|
17
|
+
"read-only": ["--tools", _READ_TOOLS, "--no-mcp"],
|
|
18
|
+
"auto": ["--tools", f"{_READ_TOOLS},edit,write", "--no-mcp"], # edits yes, shell no
|
|
19
|
+
"bypass": [], # pi's default already runs every tool without asking
|
|
20
|
+
}
|
|
21
|
+
_FAILED_STOPS = {"error", "aborted", "length"}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Pi(Adapter):
|
|
25
|
+
"""``pi --mode json`` — parses the JSONL session events."""
|
|
26
|
+
|
|
27
|
+
name = "pi"
|
|
28
|
+
binary = "pi"
|
|
29
|
+
install_url = "https://pi.dev/docs/latest/quickstart"
|
|
30
|
+
|
|
31
|
+
def build_argv(self, req: Request) -> list[str]:
|
|
32
|
+
"""Build the ``pi`` command line.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
req: The request to run.
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
The argv list, prompt last after ``--``.
|
|
39
|
+
|
|
40
|
+
Raises:
|
|
41
|
+
UsageError: If the prompt starts with ``@``, which pi always reads as a file to attach.
|
|
42
|
+
"""
|
|
43
|
+
if req.prompt.startswith("@"):
|
|
44
|
+
raise UsageError(
|
|
45
|
+
"pi reads a prompt starting with '@' as a file to attach, and has no escape; "
|
|
46
|
+
"start the prompt with other text (e.g. a space)"
|
|
47
|
+
)
|
|
48
|
+
argv = [self.binary, "--mode", "json"]
|
|
49
|
+
if req.model:
|
|
50
|
+
argv += ["--model", req.model]
|
|
51
|
+
argv += _PERMISSION_FLAGS[req.permission]
|
|
52
|
+
if req.system_prompt is not None:
|
|
53
|
+
argv += ["--system-prompt", req.system_prompt]
|
|
54
|
+
return [*argv, *_session_flags(req), *req.extra_args, "--", req.prompt]
|
|
55
|
+
|
|
56
|
+
def parse(self, completed: Completed, req: Request) -> Result:
|
|
57
|
+
"""Parse the JSONL events of ``pi --mode json``.
|
|
58
|
+
|
|
59
|
+
The answer is the text of the last assistant ``message_end``. pi exits 0 even when the model
|
|
60
|
+
call fails, so failure comes from that message's ``stopReason`` (error, aborted or length),
|
|
61
|
+
a missing assistant message, or a non-zero exit. Usage and cost are summed over all turns.
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
completed: Raw process outcome.
|
|
65
|
+
req: The request that produced it.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
The normalised result.
|
|
69
|
+
"""
|
|
70
|
+
events = jsonl_events(completed.stdout)
|
|
71
|
+
if not events: # e.g. a model pi doesn't know: the reason is only on stderr
|
|
72
|
+
text = strip_ansi(completed.stderr).strip() or strip_ansi(completed.stdout).strip()
|
|
73
|
+
return self.text_result(completed, req).with_updates(text=text, is_error=True)
|
|
74
|
+
session_id = None
|
|
75
|
+
last: dict[str, Any] | None = None
|
|
76
|
+
usage: dict[str, int] | None = None
|
|
77
|
+
cost: float | None = None
|
|
78
|
+
for ev in events:
|
|
79
|
+
if ev.get("type") == "session":
|
|
80
|
+
session_id = ev.get("id")
|
|
81
|
+
message = ev.get("message") or {}
|
|
82
|
+
if ev.get("type") == "message_end" and message.get("role") == "assistant":
|
|
83
|
+
last = message
|
|
84
|
+
usage, cost = _add_usage(usage, cost, message.get("usage"))
|
|
85
|
+
text, failed, model = "", last is None, req.model
|
|
86
|
+
if last is not None:
|
|
87
|
+
text = "\n".join(
|
|
88
|
+
b["text"]
|
|
89
|
+
for b in last.get("content") or []
|
|
90
|
+
if b.get("type") == "text" and isinstance(b.get("text"), str)
|
|
91
|
+
)
|
|
92
|
+
stop = last.get("stopReason")
|
|
93
|
+
if stop in _FAILED_STOPS:
|
|
94
|
+
failed = True
|
|
95
|
+
if stop != "length":
|
|
96
|
+
text = last.get("errorMessage") or stop
|
|
97
|
+
if last.get("provider") and last.get("model"):
|
|
98
|
+
model = f"{last['provider']}/{last['model']}"
|
|
99
|
+
return Result(
|
|
100
|
+
harness_name=self.name,
|
|
101
|
+
model=model,
|
|
102
|
+
text=text,
|
|
103
|
+
exit_code=completed.exit_code,
|
|
104
|
+
is_error=failed or completed.exit_code != 0,
|
|
105
|
+
duration_ms=completed.duration_ms,
|
|
106
|
+
session_id=session_id,
|
|
107
|
+
usage=usage,
|
|
108
|
+
cost_usd=cost,
|
|
109
|
+
argv=completed.argv,
|
|
110
|
+
raw_stdout=completed.stdout,
|
|
111
|
+
raw_stderr=completed.stderr,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def stream_events(self, line: str, state: dict[str, Any]) -> list[dict[str, Any]]:
|
|
115
|
+
"""Map a ``pi --mode json`` line: text deltas, and tool executions.
|
|
116
|
+
|
|
117
|
+
Text comes from ``text_delta`` updates (pieces of a message as the model writes them), not
|
|
118
|
+
from the whole messages pi repeats in ``message_end``/``turn_end``, so nothing is reported twice.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
line: One stdout line.
|
|
122
|
+
state: Unused.
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
The events.
|
|
126
|
+
"""
|
|
127
|
+
data = json_line(line)
|
|
128
|
+
kind = data.get("type") if data else None
|
|
129
|
+
if kind == "message_update":
|
|
130
|
+
update = data.get("assistantMessageEvent")
|
|
131
|
+
if isinstance(update, dict) and update.get("type") == "text_delta":
|
|
132
|
+
return [{"type": "text", "text": update.get("delta", "")}]
|
|
133
|
+
elif kind == "tool_execution_start":
|
|
134
|
+
return [tool_use(data.get("toolCallId"), data.get("toolName"), data.get("args"))]
|
|
135
|
+
elif kind == "tool_execution_end":
|
|
136
|
+
result = data.get("result")
|
|
137
|
+
output = content_text(result.get("content")) if isinstance(result, dict) else ""
|
|
138
|
+
return [tool_result(data.get("toolCallId"), bool(data.get("isError")), output)]
|
|
139
|
+
return []
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _add_usage(
|
|
143
|
+
usage: dict[str, int] | None, cost: float | None, raw: Any
|
|
144
|
+
) -> tuple[dict[str, int] | None, float | None]:
|
|
145
|
+
"""Add one assistant message's usage to the running totals.
|
|
146
|
+
|
|
147
|
+
pi's ``input`` excludes cached tokens, so ``cacheRead`` and ``cacheWrite`` are added back in.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
usage: Totals so far, or ``None``.
|
|
151
|
+
cost: Cost so far, or ``None``.
|
|
152
|
+
raw: The message's ``usage`` object.
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
The updated ``(usage, cost)``.
|
|
156
|
+
"""
|
|
157
|
+
if not isinstance(raw, dict):
|
|
158
|
+
return usage, cost
|
|
159
|
+
usage = usage or {"input_tokens": 0, "output_tokens": 0}
|
|
160
|
+
usage["input_tokens"] += sum(int(raw.get(k) or 0) for k in ("input", "cacheRead", "cacheWrite"))
|
|
161
|
+
usage["output_tokens"] += int(raw.get("output") or 0)
|
|
162
|
+
total = (raw.get("cost") or {}).get("total")
|
|
163
|
+
if isinstance(total, int | float):
|
|
164
|
+
cost = (cost or 0) + total
|
|
165
|
+
return usage, cost
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _session_flags(req: Request) -> list[str]:
|
|
169
|
+
"""The harness's flags for ``--resume ID`` / ``--continue`` (none for a new session).
|
|
170
|
+
|
|
171
|
+
Args:
|
|
172
|
+
req: The request.
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
The flags.
|
|
176
|
+
"""
|
|
177
|
+
if req.resume is not None:
|
|
178
|
+
return ["--session", req.resume]
|
|
179
|
+
return ["--continue"] if req.continue_session else []
|