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.
@@ -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 []