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/config.py ADDED
@@ -0,0 +1,273 @@
1
+ """User configuration: ``config.toml``, and model aliases (DESIGN.md §6).
2
+
3
+ Precedence, applied by the CLI: command-line flag > ``CALLAI_*`` environment variable > this file >
4
+ built-in default. The file is optional; a missing default file is an empty config.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import math
10
+ import os
11
+ import tomllib
12
+ from collections.abc import Mapping
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from .adapters import HARNESS_NAMES
18
+ from .errors import UsageError
19
+
20
+
21
+ class _Invalid(ValueError):
22
+ """A problem in the config file; :func:`load_config` adds the file name and raises UsageError."""
23
+
24
+
25
+ _TOP_KEYS = {"default_harness", "model", "timeout", "harnesses", "aliases"}
26
+ _HARNESS_KEYS = {"binary", "model", "extra_args"}
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class HarnessConfig:
31
+ """Settings for one harness, from ``[harnesses.<name>]``.
32
+
33
+ Attributes:
34
+ binary: Executable to run instead of the adapter's default (``~`` is expanded).
35
+ model: Default model for this harness (may be an alias).
36
+ extra_args: Arguments passed to the harness before any given after ``--``.
37
+ """
38
+
39
+ binary: str | None = None
40
+ model: str | None = None
41
+ extra_args: tuple[str, ...] = ()
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Config:
46
+ """Parsed ``config.toml``; every field is optional.
47
+
48
+ Attributes:
49
+ default_harness: Harness used when neither ``-n`` nor ``CALLAI_HARNESS_NAME`` is given.
50
+ model: Default model for every harness (typically an alias).
51
+ timeout: Default ``--timeout`` in seconds.
52
+ harnesses: Per-harness settings, by harness name.
53
+ aliases: Model alias -> {harness name -> model}.
54
+ """
55
+
56
+ default_harness: str | None = None
57
+ model: str | None = None
58
+ timeout: float | None = None
59
+ harnesses: dict[str, HarnessConfig] = field(default_factory=dict)
60
+ aliases: dict[str, dict[str, str]] = field(default_factory=dict)
61
+
62
+
63
+ def config_path(environ: Mapping[str, str], home: str | None = None) -> tuple[str, bool]:
64
+ """Where the config file is, and whether the user named it explicitly.
65
+
66
+ Args:
67
+ environ: Environment (``CALLAI_CONFIG``, ``XDG_CONFIG_HOME``).
68
+ home: Home directory; defaults to the current user's.
69
+
70
+ Returns:
71
+ ``(path, explicit)``: ``explicit`` is true when ``CALLAI_CONFIG`` set it.
72
+ """
73
+ if environ.get("CALLAI_CONFIG"):
74
+ return environ["CALLAI_CONFIG"], True
75
+ base = environ.get("XDG_CONFIG_HOME") or os.path.join(home or str(Path.home()), ".config")
76
+ return os.path.join(base, "callai", "config.toml"), False
77
+
78
+
79
+ def load_config(environ: Mapping[str, str], home: str | None = None) -> Config:
80
+ """Read and validate the config file.
81
+
82
+ Args:
83
+ environ: Environment used to find the file.
84
+ home: Home directory; defaults to the current user's.
85
+
86
+ Returns:
87
+ The config; empty if the default file doesn't exist.
88
+
89
+ Raises:
90
+ UsageError: If an explicit ``CALLAI_CONFIG`` file is missing, or the file is unreadable,
91
+ not TOML, or has unknown keys or invalid values. The message names the file.
92
+ """
93
+ path, explicit = config_path(environ, home)
94
+ if not os.path.exists(path):
95
+ if explicit:
96
+ raise UsageError(f"CALLAI_CONFIG points at {path}, which doesn't exist")
97
+ return Config()
98
+ try:
99
+ with open(path, "rb") as fh:
100
+ data = tomllib.load(fh)
101
+ except tomllib.TOMLDecodeError as exc:
102
+ raise UsageError(f"{path}: invalid TOML ({exc})") from None
103
+ except OSError as exc:
104
+ raise UsageError(f"{path}: cannot read config ({exc.strerror or exc})") from None
105
+ try:
106
+ return _parse(data)
107
+ except _Invalid as exc:
108
+ raise UsageError(f"{path}: {exc}") from None
109
+
110
+
111
+ def resolve_model(config: Config, harness_name: str, model: str | None) -> str | None:
112
+ """Turn a model alias into the harness's own model name; anything else passes through.
113
+
114
+ Args:
115
+ config: The config holding ``[aliases]``.
116
+ harness_name: Harness the model is for.
117
+ model: Model or alias, or ``None``.
118
+
119
+ Returns:
120
+ The model to pass to the harness.
121
+
122
+ Raises:
123
+ UsageError: If ``model`` is an alias with no entry for this harness.
124
+ """
125
+ if model is None or model not in config.aliases:
126
+ return model
127
+ try:
128
+ return config.aliases[model][harness_name]
129
+ except KeyError:
130
+ raise UsageError(f"model alias {model!r} has no entry for {harness_name}") from None
131
+
132
+
133
+ # --- validation ---------------------------------------------------------------
134
+
135
+
136
+ def _parse(data: dict[str, Any]) -> Config:
137
+ """Validate a decoded config file.
138
+
139
+ Args:
140
+ data: Decoded TOML.
141
+
142
+ Returns:
143
+ The config.
144
+
145
+ Raises:
146
+ _Invalid: On unknown keys or invalid values.
147
+ """
148
+ _check_keys(data, _TOP_KEYS, "")
149
+ default = data.get("default_harness")
150
+ if default is not None and default not in HARNESS_NAMES:
151
+ raise _Invalid(f"default_harness: unknown harness {default!r}; choose from {', '.join(HARNESS_NAMES)}")
152
+ model = _opt_str(data, "model", "")
153
+ timeout = data.get("timeout")
154
+ if timeout is not None and (
155
+ isinstance(timeout, bool) or not isinstance(timeout, int | float) or not math.isfinite(timeout) or timeout <= 0
156
+ ):
157
+ raise _Invalid(f"timeout must be a positive number of seconds, got {timeout!r}")
158
+ return Config(
159
+ default_harness=default,
160
+ model=model,
161
+ timeout=timeout,
162
+ harnesses=_parse_harnesses(_table(data, "harnesses")),
163
+ aliases=_parse_aliases(_table(data, "aliases")),
164
+ )
165
+
166
+
167
+ def _parse_harnesses(raw: dict[str, Any]) -> dict[str, HarnessConfig]:
168
+ """Validate ``[harnesses.<name>]`` tables.
169
+
170
+ Args:
171
+ raw: The ``harnesses`` table.
172
+
173
+ Returns:
174
+ Settings by harness name.
175
+
176
+ Raises:
177
+ _Invalid: On an unknown harness, unknown key or invalid value.
178
+ """
179
+ out = {}
180
+ for name, table in raw.items():
181
+ where = f"[harnesses.{name}]"
182
+ if name not in HARNESS_NAMES:
183
+ raise _Invalid(f"{where}: unknown harness; choose from {', '.join(HARNESS_NAMES)}")
184
+ if not isinstance(table, dict):
185
+ raise _Invalid(f"{where} must be a table")
186
+ _check_keys(table, _HARNESS_KEYS, where)
187
+ binary = _opt_str(table, "binary", where)
188
+ if binary == "":
189
+ raise _Invalid(f"{where}: binary must be a non-empty string")
190
+ extra = table.get("extra_args", [])
191
+ if not isinstance(extra, list) or not all(isinstance(a, str) for a in extra):
192
+ raise _Invalid(f"{where}: extra_args must be a list of strings")
193
+ out[name] = HarnessConfig(binary=binary, model=_opt_str(table, "model", where), extra_args=tuple(extra))
194
+ return out
195
+
196
+
197
+ def _parse_aliases(raw: dict[str, Any]) -> dict[str, dict[str, str]]:
198
+ """Validate ``[aliases]``: each alias maps harness names to model names.
199
+
200
+ Args:
201
+ raw: The ``aliases`` table.
202
+
203
+ Returns:
204
+ Alias -> {harness name -> model}.
205
+
206
+ Raises:
207
+ _Invalid: If an alias isn't a table of strings or names an unknown harness.
208
+ """
209
+ out = {}
210
+ for alias, mapping in raw.items():
211
+ if not isinstance(mapping, dict) or not all(isinstance(m, str) for m in mapping.values()):
212
+ raise _Invalid(f"[aliases] {alias} must map harness names to models, e.g. {{ codex = 'o3' }}")
213
+ for name in mapping:
214
+ if name not in HARNESS_NAMES:
215
+ raise _Invalid(f"[aliases] {alias}: unknown harness {name!r}; choose from {', '.join(HARNESS_NAMES)}")
216
+ out[alias] = dict(mapping)
217
+ return out
218
+
219
+
220
+ def _check_keys(table: dict[str, Any], allowed: set[str], where: str) -> None:
221
+ """Refuse unknown keys, so a typo doesn't silently do nothing.
222
+
223
+ Args:
224
+ table: A TOML table.
225
+ allowed: Keys it may have.
226
+ where: Table name for the message (``""`` for the top level).
227
+
228
+ Raises:
229
+ _Invalid: On the first unknown key.
230
+ """
231
+ for key in table:
232
+ if key not in allowed:
233
+ prefix = f"{where}: " if where else ""
234
+ raise _Invalid(f"{prefix}unknown key {key!r}; allowed: {', '.join(sorted(allowed))}")
235
+
236
+
237
+ def _table(data: dict[str, Any], key: str) -> dict[str, Any]:
238
+ """An optional sub-table, ``{}`` if absent.
239
+
240
+ Args:
241
+ data: The parent table.
242
+ key: Sub-table name.
243
+
244
+ Returns:
245
+ The sub-table.
246
+
247
+ Raises:
248
+ _Invalid: If the key holds something other than a table.
249
+ """
250
+ value = data.get(key, {})
251
+ if not isinstance(value, dict):
252
+ raise _Invalid(f"{key} must be a table")
253
+ return value
254
+
255
+
256
+ def _opt_str(table: dict[str, Any], key: str, where: str) -> str | None:
257
+ """An optional string value.
258
+
259
+ Args:
260
+ table: The table.
261
+ key: The key.
262
+ where: Table name for the message.
263
+
264
+ Returns:
265
+ The string, or ``None`` if absent.
266
+
267
+ Raises:
268
+ _Invalid: If the value isn't a string.
269
+ """
270
+ value = table.get(key)
271
+ if value is not None and not isinstance(value, str):
272
+ raise _Invalid(f"{where + ': ' if where else ''}{key} must be a string")
273
+ return value
callai/errors.py ADDED
@@ -0,0 +1,65 @@
1
+ """Exceptions raised by callai, each carrying the process exit code it maps to (DESIGN.md §8)."""
2
+
3
+
4
+ class CallaiError(Exception):
5
+ """Base class for errors callai reports to the user instead of a traceback."""
6
+
7
+ exit_code = 1
8
+
9
+
10
+ class UsageError(CallaiError):
11
+ """Invalid command line, configuration or request (exit code 2)."""
12
+
13
+ exit_code = 2
14
+
15
+
16
+ class HarnessNotFound(CallaiError):
17
+ """The harness executable is not on PATH (exit code 127, like a shell)."""
18
+
19
+ exit_code = 127
20
+
21
+ def __init__(self, binary: str, harness_name: str | None = None, install_url: str | None = None) -> None:
22
+ """Build the message.
23
+
24
+ Args:
25
+ binary: Executable that was looked up.
26
+ harness_name: The ``--name`` (harness) that needs it, if known.
27
+ install_url: Where to get it, if known.
28
+ """
29
+ msg = f"harness binary {binary!r} not found on PATH"
30
+ if harness_name:
31
+ msg += f" (needed for --name {harness_name})"
32
+ if install_url:
33
+ msg += f"; install it: {install_url}"
34
+ super().__init__(msg)
35
+
36
+
37
+ class NoHarnessInstalled(CallaiError):
38
+ """``-n all`` found none of the supported harnesses on PATH (exit code 127)."""
39
+
40
+ exit_code = 127
41
+
42
+
43
+ class HarnessTimeout(CallaiError):
44
+ """The harness ran longer than ``--timeout`` and was killed (exit code 124, like GNU timeout)."""
45
+
46
+ exit_code = 124
47
+
48
+ def __init__(self, binary: str, timeout: float) -> None:
49
+ """Build the message.
50
+
51
+ Args:
52
+ binary: Executable that was killed.
53
+ timeout: The limit, in seconds.
54
+ """
55
+ super().__init__(f"{binary!r} timed out after {timeout:g}s and was killed")
56
+
57
+
58
+ class Interrupted(CallaiError):
59
+ """The user interrupted the run (exit code 130, like SIGINT in a shell)."""
60
+
61
+ exit_code = 130
62
+
63
+ def __init__(self) -> None:
64
+ """Build the message."""
65
+ super().__init__("interrupted")
callai/fanout.py ADDED
@@ -0,0 +1,166 @@
1
+ """Fan-out: one prompt through several harnesses (DESIGN.md §3).
2
+
3
+ ``-n a/b/c`` runs them at the same time, ``-n a,b,c`` one after another, ``-n all`` every installed
4
+ harness at the same time. Results come back in the order given, whatever order the runs finish in.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import threading
10
+ from collections.abc import Callable, Sequence
11
+ from dataclasses import dataclass
12
+
13
+ from . import runner
14
+ from .adapters import HARNESS_NAMES, Adapter
15
+ from .api import EventHandler, run_harness
16
+ from .errors import CallaiError, Interrupted, UsageError
17
+ from .request import Request
18
+ from .result import Completed, Result
19
+
20
+ SINGLE, PARALLEL, SEQUENTIAL, ALL = "single", "parallel", "sequential", "all"
21
+
22
+
23
+ def split_harness_names(value: str) -> tuple[list[str], str]:
24
+ """Split a ``-n`` value into harness names and how to run them.
25
+
26
+ Args:
27
+ value: ``"codex"``, ``"a/b/c"`` (parallel), ``"a,b,c"`` (one after another) or ``"all"``.
28
+
29
+ Returns:
30
+ ``(names, mode)``; mode is ``SINGLE``, ``PARALLEL``, ``SEQUENTIAL`` or ``ALL``. Names aren't
31
+ checked against the registry here.
32
+
33
+ Raises:
34
+ UsageError: For mixed separators, empty or repeated names, or ``all`` inside a list.
35
+ """
36
+ if "/" in value and "," in value:
37
+ raise UsageError(f"-n {value}: use '/' (parallel) or ',' (one after another), not both")
38
+ sep = "/" if "/" in value else ","
39
+ names = [n.strip() for n in value.split(sep)]
40
+ if any(not n for n in names):
41
+ raise UsageError(f"-n {value}: empty harness name")
42
+ if len(names) == 1:
43
+ return (list(HARNESS_NAMES), ALL) if names[0] == "all" else (names, SINGLE)
44
+ if "all" in names:
45
+ raise UsageError(f"-n {value}: 'all' can't be combined with other harnesses")
46
+ for name in names:
47
+ if names.count(name) > 1:
48
+ raise UsageError(f"-n {value}: {name} is listed twice")
49
+ return names, PARALLEL if sep == "/" else SEQUENTIAL
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Job:
54
+ """One harness's part of a fan-out.
55
+
56
+ Attributes:
57
+ adapter: The harness's adapter.
58
+ req: Its request.
59
+ argv: Its command line (binary already resolved from the config).
60
+ """
61
+
62
+ adapter: Adapter
63
+ req: Request
64
+ argv: tuple[str, ...]
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class Outcome:
69
+ """How one job ended.
70
+
71
+ Attributes:
72
+ result: The normalised result (a timeout is a failed result, exit 124).
73
+ completed: The raw process outcome, or ``None`` if the run didn't finish.
74
+ """
75
+
76
+ result: Result
77
+ completed: Completed | None
78
+
79
+
80
+ def run_jobs(
81
+ jobs: Sequence[Job],
82
+ *,
83
+ concurrent: bool,
84
+ verbose: bool = False,
85
+ event_handler: Callable[[Job], EventHandler | None] = lambda job: None,
86
+ ) -> list[Outcome]:
87
+ """Run every job, at the same time or one after another; one failure doesn't stop the rest.
88
+
89
+ Args:
90
+ jobs: The jobs, in output order.
91
+ concurrent: Run them all at once (one thread each) instead of in order.
92
+ verbose: Copy each harness's stderr to ours as it arrives.
93
+ event_handler: Gives each job its ``-o stream-json`` event callback, or ``None``.
94
+
95
+ Returns:
96
+ One outcome per job, in the same order.
97
+
98
+ Raises:
99
+ Interrupted: On Ctrl-C, after every running harness was stopped.
100
+ """
101
+ if not concurrent:
102
+ return [_run_one(job, verbose, event_handler(job)) for job in jobs]
103
+ outcomes: list[Outcome | None] = [None] * len(jobs)
104
+ errors: list[BaseException] = []
105
+
106
+ def work(i: int) -> None:
107
+ """Run job ``i`` in its thread, keeping its outcome or unexpected error.
108
+
109
+ Args:
110
+ i: The job's index.
111
+ """
112
+ try:
113
+ outcomes[i] = _run_one(jobs[i], verbose, event_handler(jobs[i]))
114
+ except BaseException as exc: # noqa: BLE001 - re-raised in the main thread below
115
+ errors.append(exc)
116
+
117
+ threads = [threading.Thread(target=work, args=(i,), daemon=True) for i in range(len(jobs))]
118
+ for t in threads:
119
+ t.start()
120
+ try:
121
+ for t in threads:
122
+ while t.is_alive():
123
+ t.join(0.1) # short joins, so Ctrl-C reaches this thread
124
+ except KeyboardInterrupt:
125
+ runner.stop_all()
126
+ for t in threads:
127
+ t.join()
128
+ raise Interrupted() from None
129
+ if errors:
130
+ raise errors[0]
131
+ return [o for o in outcomes if o is not None]
132
+
133
+
134
+ def _run_one(job: Job, verbose: bool, on_event: EventHandler | None) -> Outcome:
135
+ """Run one job; a callai error (e.g. a timeout) becomes a failed result instead of stopping the others.
136
+
137
+ Args:
138
+ job: The job.
139
+ verbose: Copy the harness's stderr to ours as it arrives.
140
+ on_event: ``-o stream-json`` event callback, or ``None``.
141
+
142
+ Returns:
143
+ Its outcome.
144
+
145
+ Raises:
146
+ Interrupted: On Ctrl-C while it ran (one after another), so the rest don't start.
147
+ """
148
+ try:
149
+ completed = run_harness(job.adapter, job.req, job.argv, verbose=verbose, on_event=on_event)
150
+ except Interrupted:
151
+ raise
152
+ except CallaiError as exc:
153
+ result = Result(
154
+ harness_name=job.adapter.name,
155
+ model=job.req.model,
156
+ text=str(exc),
157
+ exit_code=exc.exit_code,
158
+ is_error=True,
159
+ argv=job.argv,
160
+ )
161
+ completed = None
162
+ else:
163
+ result = job.adapter.parse(completed, job.req)
164
+ if on_event is not None:
165
+ on_event({"type": "result", **result.to_dict()})
166
+ return Outcome(result, completed)
callai/py.typed ADDED
File without changes
callai/request.py ADDED
@@ -0,0 +1,70 @@
1
+ """The harness-independent request every adapter turns into a command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from .errors import UsageError
8
+
9
+ OUTPUT_FORMATS = ("text", "json", "stream-json", "raw")
10
+ PERMISSIONS = ("default", "read-only", "auto", "bypass")
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class Request:
15
+ """One non-interactive prompt to run through a harness.
16
+
17
+ Attributes:
18
+ harness_name: Harness name, e.g. ``"claude-code"`` (validated by the adapter registry).
19
+ prompt: Final prompt text, after stdin has been merged in.
20
+ model: Model passed to the harness verbatim, or ``None`` for its default.
21
+ output_format: How callai prints the result (not how the harness is invoked).
22
+ cwd: Working directory for the harness, or ``None`` for the current directory.
23
+ timeout: Seconds before the harness is killed, or ``None`` for no limit.
24
+ permission: Harness-neutral permission level (DESIGN.md §7).
25
+ system_prompt: Optional system prompt, where the harness supports one.
26
+ extra_args: Arguments passed through to the harness unchanged.
27
+ resume: Session id to continue (the ``session_id`` a previous run reported), or ``None``.
28
+ continue_session: Continue the harness's most recent session instead of starting a new one.
29
+ """
30
+
31
+ harness_name: str
32
+ prompt: str
33
+ model: str | None = None
34
+ output_format: str = "text"
35
+ cwd: str | None = None
36
+ timeout: float | None = None
37
+ permission: str = "default"
38
+ system_prompt: str | None = None
39
+ extra_args: tuple[str, ...] = ()
40
+ resume: str | None = None
41
+ continue_session: bool = False
42
+
43
+ def __post_init__(self) -> None:
44
+ """Validate fields and normalise ``extra_args`` to a tuple.
45
+
46
+ Raises:
47
+ UsageError: If any field has an invalid value.
48
+ """
49
+ if not isinstance(self.prompt, str):
50
+ raise UsageError("prompt must be a string")
51
+ if self.output_format not in OUTPUT_FORMATS:
52
+ raise UsageError(f"invalid output format {self.output_format!r}; choose from {', '.join(OUTPUT_FORMATS)}")
53
+ if self.permission not in PERMISSIONS:
54
+ raise UsageError(f"invalid permission {self.permission!r}; choose from {', '.join(PERMISSIONS)}")
55
+ if self.timeout is not None and (
56
+ isinstance(self.timeout, bool) or not isinstance(self.timeout, int | float) or self.timeout <= 0
57
+ ):
58
+ raise UsageError(f"timeout must be a positive number of seconds, got {self.timeout!r}")
59
+ if isinstance(self.extra_args, str) or not all(isinstance(a, str) for a in self.extra_args):
60
+ raise UsageError("extra_args must be a sequence of strings")
61
+ object.__setattr__(self, "extra_args", tuple(self.extra_args))
62
+ if self.resume is not None:
63
+ if not isinstance(self.resume, str):
64
+ raise UsageError("resume must be a session id string")
65
+ if not self.resume:
66
+ raise UsageError("--resume: empty session id")
67
+ if self.resume.startswith("-"):
68
+ raise UsageError("--resume: a session id can't start with '-' (the harness would read it as an option)")
69
+ if self.continue_session:
70
+ raise UsageError("use --resume or --continue, not both")
callai/result.py ADDED
@@ -0,0 +1,95 @@
1
+ """What a harness run produced: the raw process outcome and the normalised result."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import copy
6
+ import dataclasses
7
+ from collections.abc import Sequence
8
+ from dataclasses import dataclass, field
9
+ from typing import Any
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Completed:
14
+ """Raw outcome of running a harness process (filled in by the runner).
15
+
16
+ Attributes:
17
+ argv: The exact command line that ran.
18
+ exit_code: The harness's exit status.
19
+ stdout: Captured standard output.
20
+ stderr: Captured standard error.
21
+ duration_ms: Wall-clock run time in milliseconds, if measured.
22
+ """
23
+
24
+ argv: Sequence[str]
25
+ exit_code: int
26
+ stdout: str
27
+ stderr: str
28
+ duration_ms: int | None = None
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Result:
33
+ """Harness-independent result of one run; ``to_dict()`` is the ``-o json`` schema (DESIGN.md §5).
34
+
35
+ Attributes:
36
+ harness_name: Harness name.
37
+ text: The final assistant answer.
38
+ exit_code: The harness's exit status.
39
+ is_error: Whether the run failed, per the harness's own error signal.
40
+ model: Model the harness reports it used, else the requested one.
41
+ duration_ms: Wall-clock run time in milliseconds.
42
+ session_id: Harness session id, if reported.
43
+ usage: ``{"input_tokens": ..., "output_tokens": ...}``; input includes cached tokens.
44
+ cost_usd: Reported cost in US dollars.
45
+ argv: The command line that ran.
46
+ raw_stdout: Unparsed harness stdout (for ``-o raw``; not part of ``to_dict()``).
47
+ raw_stderr: Unparsed harness stderr (not part of ``to_dict()``).
48
+ """
49
+
50
+ harness_name: str
51
+ text: str
52
+ exit_code: int
53
+ is_error: bool
54
+ model: str | None = None
55
+ duration_ms: int | None = None
56
+ session_id: str | None = None
57
+ usage: dict[str, int] | None = None
58
+ cost_usd: float | None = None
59
+ argv: tuple[str, ...] = ()
60
+ raw_stdout: str = field(default="", repr=False)
61
+ raw_stderr: str = field(default="", repr=False)
62
+
63
+ def __post_init__(self) -> None:
64
+ """Normalise ``argv`` to a tuple."""
65
+ object.__setattr__(self, "argv", tuple(self.argv))
66
+
67
+ def to_dict(self) -> dict[str, Any]:
68
+ """Return the JSON-serialisable result, in the stable key order of DESIGN.md §5.
69
+
70
+ Returns:
71
+ A new dict; mutating it does not affect this result.
72
+ """
73
+ return {
74
+ "harness_name": self.harness_name,
75
+ "model": self.model,
76
+ "text": self.text,
77
+ "exit_code": self.exit_code,
78
+ "is_error": self.is_error,
79
+ "duration_ms": self.duration_ms,
80
+ "session_id": self.session_id,
81
+ "usage": copy.deepcopy(self.usage),
82
+ "cost_usd": self.cost_usd,
83
+ "argv": list(self.argv),
84
+ }
85
+
86
+ def with_updates(self, **changes: Any) -> Result:
87
+ """Return a copy with some fields replaced.
88
+
89
+ Args:
90
+ **changes: Field names and their new values.
91
+
92
+ Returns:
93
+ The updated copy.
94
+ """
95
+ return dataclasses.replace(self, **changes)