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 ADDED
@@ -0,0 +1,37 @@
1
+ """callai: run a prompt non-interactively through any coding-agent harness.
2
+
3
+ Usage mirrors ``claude -p``::
4
+
5
+ callai -n {claude-code,codex,opencode,pi,hermes,grok} -p PROMPT [-m MODEL]
6
+
7
+ From Python::
8
+
9
+ from callai import run
10
+ result = run("codex", "Fix the failing test", permission="auto")
11
+ print(result.text, result.session_id)
12
+
13
+ See ``docs/DESIGN.md`` for the CLI surface and ``conformance/`` for the executable spec.
14
+ """
15
+
16
+ from .api import run
17
+ from .cli import main
18
+ from .errors import CallaiError, HarnessNotFound, HarnessTimeout, Interrupted, UsageError
19
+ from .result import Result
20
+
21
+ __all__ = [
22
+ "CallaiError",
23
+ "HarnessNotFound",
24
+ "HarnessTimeout",
25
+ "Interrupted",
26
+ "Result",
27
+ "UsageError",
28
+ "main",
29
+ "run",
30
+ ]
31
+
32
+ from importlib import metadata
33
+
34
+ try:
35
+ __version__ = metadata.version("callai-cli") # the distribution name, not the import name
36
+ except metadata.PackageNotFoundError: # pragma: no cover - running from a source tree that was never installed
37
+ __version__ = "0.0.0+unknown"
callai/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """Entry point for ``python -m callai``: runs :func:`callai.cli.main`."""
2
+
3
+ import sys
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())
@@ -0,0 +1,82 @@
1
+ """Harness adapters and the registry that maps ``--name`` values to them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ..errors import UsageError
6
+ from .base import Adapter, strip_ansi
7
+
8
+ # Every harness callai supports, in --help order (DESIGN.md §2). Adapters register as they are built.
9
+ HARNESS_NAMES: tuple[str, ...] = ("claude-code", "codex", "opencode", "pi", "hermes", "grok")
10
+
11
+ REGISTRY: dict[str, Adapter] = {}
12
+
13
+
14
+ def register(adapter: Adapter) -> Adapter:
15
+ """Add an adapter to the registry.
16
+
17
+ Args:
18
+ adapter: The adapter instance; its ``name`` must be one of ``HARNESS_NAMES``.
19
+
20
+ Returns:
21
+ The same adapter, so this can wrap an instantiation.
22
+
23
+ Raises:
24
+ ValueError: If the name is not a supported harness or is already registered.
25
+ """
26
+ if adapter.name not in HARNESS_NAMES:
27
+ raise ValueError(f"{adapter.name!r} is not a supported harness")
28
+ if adapter.name in REGISTRY:
29
+ raise ValueError(f"{adapter.name!r} is already registered")
30
+ REGISTRY[adapter.name] = adapter
31
+ return adapter
32
+
33
+
34
+ def get_adapter(harness_name: str) -> Adapter:
35
+ """Look up the adapter for a ``--name`` value.
36
+
37
+ Args:
38
+ harness_name: Harness name from the command line, environment or config.
39
+
40
+ Returns:
41
+ The registered adapter.
42
+
43
+ Raises:
44
+ UsageError: If the harness is unknown or its adapter is not implemented yet.
45
+ """
46
+ if harness_name not in HARNESS_NAMES:
47
+ raise UsageError(f"unknown harness {harness_name!r}; choose from {', '.join(HARNESS_NAMES)}")
48
+ try:
49
+ return REGISTRY[harness_name]
50
+ except KeyError:
51
+ raise UsageError(f"harness {harness_name!r} is not implemented yet") from None
52
+
53
+
54
+ # Built-in adapters (imported last: they use register() above).
55
+ from .claude_code import ClaudeCode # noqa: E402
56
+ from .codex import Codex # noqa: E402
57
+ from .grok import Grok # noqa: E402
58
+ from .hermes import Hermes # noqa: E402
59
+ from .opencode import OpenCode # noqa: E402
60
+ from .pi import Pi # noqa: E402
61
+
62
+ register(ClaudeCode())
63
+ register(Codex())
64
+ register(OpenCode())
65
+ register(Pi())
66
+ register(Grok())
67
+ register(Hermes())
68
+
69
+ __all__ = [
70
+ "HARNESS_NAMES",
71
+ "REGISTRY",
72
+ "Adapter",
73
+ "ClaudeCode",
74
+ "Codex",
75
+ "Grok",
76
+ "Hermes",
77
+ "OpenCode",
78
+ "Pi",
79
+ "get_adapter",
80
+ "register",
81
+ "strip_ansi",
82
+ ]
@@ -0,0 +1,190 @@
1
+ """The interface every harness adapter implements (DESIGN.md §4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from abc import ABC, abstractmethod
8
+ from typing import Any
9
+
10
+ from ..request import Request
11
+ from ..result import Completed, Result
12
+
13
+ # CSI sequences (colours, cursor moves) and OSC sequences (titles, hyperlinks).
14
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
15
+
16
+
17
+ def strip_ansi(text: str) -> str:
18
+ """Remove ANSI escape sequences from terminal output.
19
+
20
+ Args:
21
+ text: Text that may contain escape sequences.
22
+
23
+ Returns:
24
+ The text without them.
25
+ """
26
+ return _ANSI_RE.sub("", text)
27
+
28
+
29
+ def jsonl_events(stdout: str) -> list[dict[str, Any]]:
30
+ """Parse JSON-lines output, skipping lines that are not JSON objects (banners, warnings).
31
+
32
+ Args:
33
+ stdout: The harness's stdout.
34
+
35
+ Returns:
36
+ The event objects, in order.
37
+ """
38
+ return [data for line in stdout.splitlines() if (data := json_line(line)) is not None]
39
+
40
+
41
+ def json_line(line: str) -> dict[str, Any] | None:
42
+ """Parse one line of JSON-lines output.
43
+
44
+ Args:
45
+ line: One stdout line (a trailing newline is fine).
46
+
47
+ Returns:
48
+ The object, or ``None`` if the line isn't a JSON object (a banner, a warning).
49
+ """
50
+ try:
51
+ data = json.loads(line)
52
+ except ValueError:
53
+ return None
54
+ return data if isinstance(data, dict) else None
55
+
56
+
57
+ def content_text(blocks: Any) -> str:
58
+ """Join the text of a content value: a string, or a list of ``{"type": "text", "text"}`` blocks.
59
+
60
+ Args:
61
+ blocks: A tool result's content.
62
+
63
+ Returns:
64
+ The text; non-text blocks are skipped.
65
+ """
66
+ if isinstance(blocks, str):
67
+ return blocks
68
+ if not isinstance(blocks, list):
69
+ return ""
70
+ return "".join(b["text"] for b in blocks if isinstance(b, dict) and isinstance(b.get("text"), str))
71
+
72
+
73
+ def tool_use(call_id: Any, name: Any, tool_input: Any) -> dict[str, Any]:
74
+ """A normalised ``tool_use`` event (DESIGN.md §5).
75
+
76
+ Args:
77
+ call_id: The harness's id for the call.
78
+ name: The harness's own tool name.
79
+ tool_input: The tool's arguments.
80
+
81
+ Returns:
82
+ The event.
83
+ """
84
+ return {"type": "tool_use", "id": call_id, "name": name, "input": tool_input}
85
+
86
+
87
+ def tool_result(call_id: Any, is_error: bool, output: str) -> dict[str, Any]:
88
+ """A normalised ``tool_result`` event (DESIGN.md §5).
89
+
90
+ Args:
91
+ call_id: The id of the ``tool_use`` it answers.
92
+ is_error: Whether the tool failed or was refused.
93
+ output: What the tool returned, as text.
94
+
95
+ Returns:
96
+ The event.
97
+ """
98
+ return {"type": "tool_result", "id": call_id, "is_error": is_error, "output": output}
99
+
100
+
101
+ class Adapter(ABC):
102
+ """Translates a :class:`Request` into one harness's command line and its output back into a :class:`Result`.
103
+
104
+ Both methods are pure (no I/O), so adapters are tested without the harness installed.
105
+
106
+ Attributes:
107
+ name: Harness name used with ``--name`` (one of ``callai.adapters.HARNESS_NAMES``).
108
+ binary: Executable looked up on PATH.
109
+ install_url: Where to point users when the binary is missing.
110
+ """
111
+
112
+ name: str
113
+ binary: str
114
+ install_url: str
115
+
116
+ @abstractmethod
117
+ def build_argv(self, req: Request) -> list[str]:
118
+ """Build the exact harness command line, with the prompt last after ``--``.
119
+
120
+ Args:
121
+ req: The request to run.
122
+
123
+ Returns:
124
+ The argv list; ``argv[0]`` is the bare binary name.
125
+ """
126
+
127
+ @abstractmethod
128
+ def parse(self, completed: Completed, req: Request) -> Result:
129
+ """Turn the harness's raw output into a normalised result.
130
+
131
+ Args:
132
+ completed: Raw process outcome.
133
+ req: The request that produced it.
134
+
135
+ Returns:
136
+ The normalised result.
137
+ """
138
+
139
+ def build_env(self, req: Request) -> dict[str, str]:
140
+ """Environment variables the harness needs for this request, set on top of callai's own.
141
+
142
+ For settings a harness takes only from its environment (hermes's extra system prompt).
143
+ Pure, like :meth:`build_argv`.
144
+
145
+ Args:
146
+ req: The request to run.
147
+
148
+ Returns:
149
+ Variables to set; empty (the default) leaves the inherited environment unchanged.
150
+ """
151
+ return {}
152
+
153
+ def stream_events(self, line: str, state: dict[str, Any]) -> list[dict[str, Any]]:
154
+ """Map one stdout line of a running harness to normalised ``-o stream-json`` events.
155
+
156
+ Called for every stdout line in order, as it arrives, with one ``state`` dict per run for
157
+ adapters that need to remember earlier lines. Returns ``text``, ``tool_use`` and
158
+ ``tool_result`` events (DESIGN.md §5); the CLI writes ``start`` and ``result`` itself.
159
+ Pure apart from ``state``. The default, for harnesses without an event stream, returns none.
160
+
161
+ Args:
162
+ line: One stdout line, newline included.
163
+ state: Scratch space shared by the calls of one run.
164
+
165
+ Returns:
166
+ The events, possibly none.
167
+ """
168
+ return []
169
+
170
+ def text_result(self, completed: Completed, req: Request) -> Result:
171
+ """Fallback parser for harnesses without structured output: cleaned stdout is the answer.
172
+
173
+ Args:
174
+ completed: Raw process outcome.
175
+ req: The request that produced it.
176
+
177
+ Returns:
178
+ A result whose text is stdout without ANSI codes or surrounding whitespace.
179
+ """
180
+ return Result(
181
+ harness_name=self.name,
182
+ model=req.model,
183
+ text=strip_ansi(completed.stdout).strip(),
184
+ exit_code=completed.exit_code,
185
+ is_error=completed.exit_code != 0,
186
+ duration_ms=completed.duration_ms,
187
+ argv=completed.argv,
188
+ raw_stdout=completed.stdout,
189
+ raw_stderr=completed.stderr,
190
+ )
@@ -0,0 +1,194 @@
1
+ """Adapter for Anthropic's Claude Code CLI (``claude -p``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from ..request import Request
9
+ from ..result import Completed, Result
10
+ from .base import Adapter, content_text, json_line, tool_result, tool_use
11
+
12
+ # callai --permission level -> claude flags (DESIGN.md §7).
13
+ _PERMISSION_FLAGS: dict[str, list[str]] = {
14
+ "default": [],
15
+ "read-only": ["--permission-mode", "plan"],
16
+ "auto": ["--permission-mode", "acceptEdits"],
17
+ "bypass": ["--dangerously-skip-permissions"],
18
+ }
19
+
20
+
21
+ class ClaudeCode(Adapter):
22
+ """``claude -p --output-format json`` — always asks for JSON so the answer is never scraped from text."""
23
+
24
+ name = "claude-code"
25
+ binary = "claude"
26
+ install_url = "https://code.claude.com/docs/en/setup"
27
+
28
+ def build_argv(self, req: Request) -> list[str]:
29
+ """Build the ``claude`` command line.
30
+
31
+ Args:
32
+ req: The request to run.
33
+
34
+ ``-o stream-json`` asks for claude's own event stream, which needs ``--verbose`` with ``-p``;
35
+ its last line is the same result object ``--output-format json`` prints.
36
+
37
+ Returns:
38
+ The argv list, prompt last after ``--``.
39
+ """
40
+ stream = req.output_format == "stream-json"
41
+ argv = [self.binary, "-p", "--output-format", *(["stream-json", "--verbose"] if stream else ["json"])]
42
+ if req.model:
43
+ argv += ["--model", req.model]
44
+ argv += _PERMISSION_FLAGS[req.permission]
45
+ if req.system_prompt is not None:
46
+ argv += ["--system-prompt", req.system_prompt]
47
+ return [*argv, *_session_flags(req), *req.extra_args, "--", req.prompt]
48
+
49
+ def parse(self, completed: Completed, req: Request) -> Result:
50
+ """Parse the JSON result object ``claude -p`` prints (alone, or as the last stream-json line).
51
+
52
+ Errors come from ``is_error`` or a non-zero exit, never ``subtype`` (claude reports
53
+ ``"subtype": "success"`` with ``"is_error": true``, e.g. for an unknown model).
54
+ Output that is not a JSON object falls back to plain text.
55
+
56
+ Args:
57
+ completed: Raw process outcome.
58
+ req: The request that produced it.
59
+
60
+ Returns:
61
+ The normalised result.
62
+ """
63
+ data = _last_json_object(completed.stdout)
64
+ if data is None:
65
+ return self.text_result(completed, req).with_updates(is_error=True)
66
+ is_error = bool(data.get("is_error")) or completed.exit_code != 0
67
+ text = data.get("result")
68
+ cost = data.get("total_cost_usd")
69
+ return Result(
70
+ harness_name=self.name,
71
+ model=req.model if is_error else (_main_model(data.get("modelUsage")) or req.model),
72
+ text=text if isinstance(text, str) else "",
73
+ exit_code=completed.exit_code,
74
+ is_error=is_error,
75
+ duration_ms=completed.duration_ms,
76
+ session_id=data.get("session_id"),
77
+ usage=_usage(data.get("usage")),
78
+ cost_usd=cost if isinstance(cost, int | float) else None,
79
+ argv=completed.argv,
80
+ raw_stdout=completed.stdout,
81
+ raw_stderr=completed.stderr,
82
+ )
83
+
84
+ def stream_events(self, line: str, state: dict[str, Any]) -> list[dict[str, Any]]:
85
+ """Map a ``--output-format stream-json`` line: assistant text and tool calls, then tool results.
86
+
87
+ claude sends whole messages (no ``--include-partial-messages``), so each text event is one
88
+ complete text block.
89
+
90
+ Args:
91
+ line: One stdout line.
92
+ state: Unused.
93
+
94
+ Returns:
95
+ The events.
96
+ """
97
+ data = json_line(line)
98
+ message = data.get("message") if data else None
99
+ if not isinstance(message, dict) or not isinstance(message.get("content"), list):
100
+ return []
101
+ events = []
102
+ for block in message["content"]:
103
+ if not isinstance(block, dict):
104
+ continue
105
+ kind = block.get("type")
106
+ if data.get("type") == "assistant" and kind == "text":
107
+ events.append({"type": "text", "text": block.get("text", "")})
108
+ elif data.get("type") == "assistant" and kind == "tool_use":
109
+ events.append(tool_use(block.get("id"), block.get("name"), block.get("input")))
110
+ elif data.get("type") == "user" and kind == "tool_result":
111
+ events.append(
112
+ tool_result(
113
+ block.get("tool_use_id"), bool(block.get("is_error")), content_text(block.get("content"))
114
+ )
115
+ )
116
+ return events
117
+
118
+
119
+ def _last_json_object(stdout: str) -> dict[str, Any] | None:
120
+ """Find the result object: the whole output, else the last line that is a JSON object.
121
+
122
+ Args:
123
+ stdout: The harness's stdout.
124
+
125
+ Returns:
126
+ The parsed object, or ``None`` if there is none.
127
+ """
128
+ for candidate in (stdout, *reversed(stdout.splitlines())):
129
+ try:
130
+ data = json.loads(candidate)
131
+ except ValueError:
132
+ continue
133
+ if isinstance(data, dict):
134
+ return data
135
+ return None
136
+
137
+
138
+ def _usage(raw: Any) -> dict[str, int] | None:
139
+ """Normalise claude's usage: input counts cache reads and writes too (conformance/README.md).
140
+
141
+ Args:
142
+ raw: claude's ``usage`` value.
143
+
144
+ Returns:
145
+ ``{"input_tokens", "output_tokens"}``, or ``None`` if absent.
146
+ """
147
+ if not isinstance(raw, dict):
148
+ return None
149
+ keys = ("input_tokens", "cache_read_input_tokens", "cache_creation_input_tokens")
150
+ return {
151
+ "input_tokens": sum(int(raw.get(k) or 0) for k in keys),
152
+ "output_tokens": int(raw.get("output_tokens") or 0),
153
+ }
154
+
155
+
156
+ def _main_model(model_usage: Any) -> str | None:
157
+ """Pick the model that did the work from ``modelUsage`` (claude also lists side-call models).
158
+
159
+ Args:
160
+ model_usage: claude's ``modelUsage`` mapping of model id to stats.
161
+
162
+ Returns:
163
+ The model with the highest cost (then output tokens), or ``None``.
164
+ """
165
+ if not isinstance(model_usage, dict) or not model_usage:
166
+ return None
167
+
168
+ def weight(item: tuple[str, Any]) -> tuple[float, float]:
169
+ """Sort key: (cost, output tokens).
170
+
171
+ Args:
172
+ item: A ``(model id, stats)`` pair.
173
+
174
+ Returns:
175
+ The key.
176
+ """
177
+ stats = item[1] if isinstance(item[1], dict) else {}
178
+ return (float(stats.get("costUSD") or 0), float(stats.get("outputTokens") or 0))
179
+
180
+ return max(model_usage.items(), key=weight)[0]
181
+
182
+
183
+ def _session_flags(req: Request) -> list[str]:
184
+ """The harness's flags for ``--resume ID`` / ``--continue`` (none for a new session).
185
+
186
+ Args:
187
+ req: The request.
188
+
189
+ Returns:
190
+ The flags.
191
+ """
192
+ if req.resume is not None:
193
+ return ["--resume", req.resume]
194
+ return ["--continue"] if req.continue_session else []
@@ -0,0 +1,155 @@
1
+ """Adapter for OpenAI's Codex CLI (``codex exec``)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Any
7
+
8
+ from ..request import Request
9
+ from ..result import Completed, Result
10
+ from .base import Adapter, json_line, jsonl_events, tool_result, tool_use
11
+
12
+ # callai --permission level -> codex flags (DESIGN.md §7; verified against codex-cli 0.166.0 --help).
13
+ _PERMISSION_FLAGS: dict[str, list[str]] = {
14
+ "default": [],
15
+ "read-only": ["--sandbox", "read-only"],
16
+ "auto": ["--sandbox", "workspace-write"],
17
+ "bypass": ["--dangerously-bypass-approvals-and-sandbox"],
18
+ }
19
+
20
+
21
+ # Item types that are not tool calls; every other item type (command_execution, file_change,
22
+ # mcp_tool_call, web_search, ...) is reported as a tool call named after its type.
23
+ _NOT_TOOLS = {"agent_message", "reasoning", "error", "todo_list"}
24
+ # Item fields that describe the call's outcome rather than its input.
25
+ _OUTCOME_KEYS = {"id", "type", "status", "aggregated_output", "exit_code", "result", "error"}
26
+
27
+
28
+ def toml_string(text: str) -> str:
29
+ """Encode text as a TOML basic string (for codex ``-c key=value`` overrides).
30
+
31
+ JSON string escapes are valid TOML, except that TOML also forbids a raw DEL character.
32
+
33
+ Args:
34
+ text: Any text.
35
+
36
+ Returns:
37
+ The quoted TOML string.
38
+ """
39
+ return json.dumps(text, ensure_ascii=False).replace("\x7f", "\\u007f")
40
+
41
+
42
+ class Codex(Adapter):
43
+ """``codex exec --json`` — parses the JSONL event stream rather than scraping text."""
44
+
45
+ name = "codex"
46
+ binary = "codex"
47
+ install_url = "https://developers.openai.com/codex/cli"
48
+
49
+ def build_argv(self, req: Request) -> list[str]:
50
+ """Build the ``codex exec`` command line.
51
+
52
+ ``--skip-git-repo-check`` is always added, so callai works in any directory like the other tools.
53
+
54
+ Args:
55
+ req: The request to run.
56
+
57
+ Returns:
58
+ The argv list, prompt last after ``--`` (so it can't be taken for a subcommand like ``review``).
59
+ """
60
+ argv = [self.binary, "exec", "--json", "--skip-git-repo-check"]
61
+ if req.model:
62
+ argv += ["--model", req.model]
63
+ argv += _PERMISSION_FLAGS[req.permission]
64
+ if req.system_prompt is not None:
65
+ argv += ["-c", f"developer_instructions={toml_string(req.system_prompt)}"]
66
+ # `resume` is a subcommand of exec that rejects --sandbox, so exec's own options (and any extra
67
+ # args) go before it; only the session id or --last follow it.
68
+ if req.resume is not None:
69
+ resume = ["resume", req.resume]
70
+ else:
71
+ resume = ["resume", "--last"] if req.continue_session else []
72
+ return [*argv, *req.extra_args, *resume, "--", req.prompt]
73
+
74
+ def parse(self, completed: Completed, req: Request) -> Result:
75
+ """Parse the JSONL events of ``codex exec --json``.
76
+
77
+ The answer is the last ``agent_message`` item. Failure comes only from ``turn.failed`` or a
78
+ non-zero exit: ``error`` events and ``item.type == "error"`` items are non-fatal warnings
79
+ (reconnects, missing model metadata). codex does not report the model it used.
80
+
81
+ Args:
82
+ completed: Raw process outcome.
83
+ req: The request that produced it.
84
+
85
+ Returns:
86
+ The normalised result.
87
+ """
88
+ events = jsonl_events(completed.stdout)
89
+ if not events:
90
+ return self.text_result(completed, req).with_updates(is_error=True)
91
+ text, session_id, usage, failure = "", None, None, None
92
+ for ev in events:
93
+ kind = ev.get("type")
94
+ if kind == "thread.started":
95
+ session_id = ev.get("thread_id")
96
+ elif kind == "item.completed":
97
+ item = ev.get("item") or {}
98
+ if item.get("type") == "agent_message" and isinstance(item.get("text"), str):
99
+ text = item["text"]
100
+ elif kind == "turn.completed" and isinstance(ev.get("usage"), dict):
101
+ usage = {
102
+ # OpenAI semantics: input_tokens already includes cached_input_tokens.
103
+ "input_tokens": int(ev["usage"].get("input_tokens") or 0),
104
+ "output_tokens": int(ev["usage"].get("output_tokens") or 0),
105
+ }
106
+ elif kind == "turn.failed":
107
+ failure = (ev.get("error") or {}).get("message") or text
108
+ return Result(
109
+ harness_name=self.name,
110
+ model=req.model,
111
+ text=failure if failure is not None else text,
112
+ exit_code=completed.exit_code,
113
+ is_error=failure is not None or completed.exit_code != 0,
114
+ duration_ms=completed.duration_ms,
115
+ session_id=session_id,
116
+ usage=usage,
117
+ argv=completed.argv,
118
+ raw_stdout=completed.stdout,
119
+ raw_stderr=completed.stderr,
120
+ )
121
+
122
+ def stream_events(self, line: str, state: dict[str, Any]) -> list[dict[str, Any]]:
123
+ """Map a ``codex exec --json`` line: ``agent_message`` items are text, other items tool calls.
124
+
125
+ A tool call is reported when its item starts (``item.started``) and its result when the item
126
+ completes. Items that only arrive completed get both then; ``state`` remembers which calls
127
+ were already reported. Verified for ``command_execution``; other item types follow the same
128
+ event schema (their fields, minus outcome fields, become the input).
129
+
130
+ Args:
131
+ line: One stdout line.
132
+ state: Remembers reported call ids (``"started"``).
133
+
134
+ Returns:
135
+ The events.
136
+ """
137
+ data = json_line(line)
138
+ item = data.get("item") if data else None
139
+ if not isinstance(item, dict):
140
+ return []
141
+ kind, item_id = item.get("type"), item.get("id")
142
+ if data.get("type") == "item.completed" and kind == "agent_message":
143
+ return [{"type": "text", "text": item.get("text", "")}]
144
+ if kind in _NOT_TOOLS or data.get("type") not in ("item.started", "item.completed"):
145
+ return []
146
+ started = state.setdefault("started", set())
147
+ events = []
148
+ if item_id not in started:
149
+ started.add(item_id)
150
+ events.append(tool_use(item_id, kind, {k: v for k, v in item.items() if k not in _OUTCOME_KEYS}))
151
+ if data["type"] == "item.completed":
152
+ failed = item.get("status") == "failed" or item.get("exit_code") not in (None, 0) or bool(item.get("error"))
153
+ output = item.get("aggregated_output")
154
+ events.append(tool_result(item_id, failed, output if isinstance(output, str) else ""))
155
+ return events