spawnllm 0.5.0__tar.gz → 0.5.2__tar.gz

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.
Files changed (29) hide show
  1. {spawnllm-0.5.0 → spawnllm-0.5.2}/PKG-INFO +1 -1
  2. {spawnllm-0.5.0 → spawnllm-0.5.2}/pyproject.toml +1 -1
  3. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/__init__.py +4 -1
  4. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/backends/base.py +72 -37
  5. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/backends/claude.py +53 -7
  6. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/backends/codex.py +15 -7
  7. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/backends/gemini.py +7 -2
  8. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/backends/mlx.py +2 -2
  9. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/backends/registry.py +5 -2
  10. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/call.py +4 -5
  11. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/extract.py +4 -5
  12. spawnllm-0.5.2/spawnllm/response.py +78 -0
  13. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/run.py +3 -2
  14. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/spec.py +12 -1
  15. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/structured.py +2 -2
  16. spawnllm-0.5.0/spawnllm/response.py +0 -29
  17. {spawnllm-0.5.0 → spawnllm-0.5.2}/LICENSE +0 -0
  18. {spawnllm-0.5.0 → spawnllm-0.5.2}/README.md +0 -0
  19. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/__main__.py +0 -0
  20. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/backends/__init__.py +0 -0
  21. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/cli.py +0 -0
  22. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/mlx/__init__.py +0 -0
  23. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/mlx/codec.py +0 -0
  24. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/mlx/engine.py +0 -0
  25. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/mlx/fuse.py +0 -0
  26. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/mlx/patches.py +0 -0
  27. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/proc.py +0 -0
  28. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/py.typed +0 -0
  29. {spawnllm-0.5.0 → spawnllm-0.5.2}/spawnllm/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spawnllm
3
- Version: 0.5.0
3
+ Version: 0.5.2
4
4
  Summary: Subshell + MLX LLM-calling backends (Claude/Codex CLI, local MLX) shared across tools.
5
5
  Keywords:
6
6
  Author: Yasyf Mohamedali
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "spawnllm"
3
3
  # Inert sentinel: the real version is set from the release tag (uv version --frozen).
4
- version = "0.5.0"
4
+ version = "0.5.2"
5
5
  description = "Subshell + MLX LLM-calling backends (Claude/Codex CLI, local MLX) shared across tools."
6
6
  readme = "README.md"
7
7
  license = "MIT"
@@ -28,7 +28,7 @@ from spawnllm.backends import (
28
28
  )
29
29
  from spawnllm.call import call, call_sync
30
30
  from spawnllm.extract import extract, extract_sync
31
- from spawnllm.response import Response
31
+ from spawnllm.response import Error, Output, Response, Result
32
32
  from spawnllm.run import run, run_sync
33
33
  from spawnllm.spec import ClaudeConfig, CodexConfig, GeminiConfig, RunSpec
34
34
  from spawnllm.types import ProviderName, TModel, TSpecialty
@@ -46,13 +46,16 @@ __all__ = [
46
46
  "CliBackend",
47
47
  "CodexCliBackend",
48
48
  "CodexConfig",
49
+ "Error",
49
50
  "GeminiCliBackend",
50
51
  "GeminiConfig",
51
52
  "LlmBackend",
52
53
  "LlmBackends",
53
54
  "MlxBackend",
55
+ "Output",
54
56
  "ProviderName",
55
57
  "Response",
58
+ "Result",
56
59
  "RunSpec",
57
60
  "TModel",
58
61
  "TSpecialty",
@@ -5,13 +5,14 @@ from __future__ import annotations
5
5
  import json
6
6
  import os
7
7
  import shutil
8
+ import subprocess
8
9
  from abc import ABC, abstractmethod
9
10
  from dataclasses import dataclass
10
11
  from pathlib import Path
11
12
  from typing import TYPE_CHECKING, ClassVar
12
13
 
13
14
  from spawnllm.proc import acapture_cli, capture_cli
14
- from spawnllm.response import Response
15
+ from spawnllm.response import Error, Output, Response, Result
15
16
 
16
17
  if TYPE_CHECKING:
17
18
  from pydantic import BaseModel
@@ -66,14 +67,9 @@ class BackendUnavailable(RuntimeError):
66
67
  class BackendCallError(RuntimeError):
67
68
  """Raised by `call`/`extract` when a backend returns a provider error.
68
69
 
69
- Carries the backend's error string (a nonzero exit with stderr, or an error
70
- envelope), attached both as the message and as a note for tracebacks.
70
+ Carries the backend's error string: a nonzero exit with stderr, or an error envelope.
71
71
  """
72
72
 
73
- def __init__(self, error: str) -> None:
74
- super().__init__(error)
75
- self.add_note(error)
76
-
77
73
 
78
74
  @dataclass(frozen=True)
79
75
  class Invocation:
@@ -133,8 +129,13 @@ class LlmBackend(ABC):
133
129
  """
134
130
 
135
131
  @abstractmethod
136
- def env(self) -> dict[str, str]:
137
- """Return extra environment variables for the invocation, merged over the inherited environment."""
132
+ def env(self, spec: RunSpec) -> dict[str, str]:
133
+ """Return extra environment variables for the invocation, merged over the inherited environment.
134
+
135
+ Args:
136
+ spec: The configured run, so a backend can scope env overrides to
137
+ `spec.isolated` (e.g. a fresh config home only when isolating).
138
+ """
138
139
 
139
140
  @abstractmethod
140
141
  def is_authenticated(self, *, timeout: int) -> bool:
@@ -177,35 +178,59 @@ class LlmBackend(ABC):
177
178
  return json.dumps(model.model_json_schema())
178
179
 
179
180
  def schema_arg(self, spec: RunSpec) -> str | None:
180
- """Return the JSON-schema string for `spec`'s `response_model`, or `None` when absent."""
181
- return self.schema_for(spec.response_model) if spec.response_model is not None else None
181
+ """Return the JSON-schema string for `spec`, from a `response_model` or a raw `schema`.
182
+
183
+ A `response_model` is run through `schema_for` (the provider's
184
+ strict-schema transform); a raw `schema` passes verbatim — a dict is
185
+ `json.dumps`'d, a string is returned unchanged. Returns `None` when
186
+ neither is set.
187
+
188
+ Args:
189
+ spec: The configured run, carrying the optional `response_model` or `schema`.
190
+
191
+ Returns:
192
+ The JSON-schema string for this backend's structured-output argument, or `None`.
193
+ """
194
+ if spec.response_model is not None:
195
+ return self.schema_for(spec.response_model)
196
+ if spec.schema is not None:
197
+ return json.dumps(spec.schema) if isinstance(spec.schema, dict) else spec.schema
198
+ return None
182
199
 
183
200
  def to_response(self, raw: str, *, returncode: int, stderr: str, spec: RunSpec) -> Response:
184
- """Resolve a raw capture into a `Response`: detect failure, extract text, validate.
201
+ """Resolve a raw capture into a structured `Response`: detect failure, extract text, validate.
185
202
 
186
- A nonzero exit or an error envelope becomes `Response.error`; otherwise
187
- the text comes from `result_text` and, when `spec.response_model` is set,
188
- the validated model from `result_value`. A `pydantic.ValidationError`
189
- from a non-conforming model propagates.
203
+ `output` always carries the full raw stream. A nonzero exit, an error
204
+ envelope, or a `pydantic.ValidationError` from a non-conforming model all
205
+ route through `error` (with the underlying exception preserved in
206
+ `error.ex`) and leave `result` as `None`; a success yields `result` (text
207
+ from `result_text`, plus the validated model from `result_value` when
208
+ `spec.response_model` is set) and `error` as `None`.
190
209
 
191
210
  Args:
192
211
  raw: The raw output read wherever the provider wrote it.
193
212
  returncode: The process exit code.
194
213
  stderr: The captured stderr.
195
- spec: The configured run, carrying the optional `response_model`.
214
+ spec: The configured run, carrying the optional `response_model` or `schema`.
196
215
 
197
216
  Returns:
198
217
  The resolved `Response`.
199
218
  """
219
+ import pydantic
220
+
221
+ output = Output(raw)
200
222
  if returncode != 0:
201
- return Response(error=f"{self.provider} exited {returncode}: {stderr.strip()[-2000:]}", result=None)
223
+ msg = f"{self.provider} exited {returncode}: {stderr.strip()[-2000:]}"
224
+ return Response(spec=spec, output=output, error=Error(msg, BackendCallError(msg)))
202
225
  if (err := self.envelope_error(raw)) is not None:
203
- return Response(error=err, result=None)
226
+ return Response(spec=spec, output=output, error=Error(err, BackendCallError(err)))
204
227
  if spec.response_model is None:
205
- return Response(error=None, result=self.result_text(raw))
206
- return Response(
207
- error=None, result=self.result_text(raw), parsed=spec.response_model.model_validate(self.result_value(raw))
208
- )
228
+ return Response(spec=spec, output=output, result=Result(raw=self.result_text(raw)))
229
+ try:
230
+ parsed = spec.response_model.model_validate(self.result_value(raw))
231
+ except pydantic.ValidationError as e:
232
+ return Response(spec=spec, output=output, error=Error(str(e), e))
233
+ return Response(spec=spec, output=output, result=Result(raw=self.result_text(raw), parsed=parsed))
209
234
 
210
235
  def result_text(self, raw: str) -> str:
211
236
  """Return the final text output from a raw capture; the default is `raw` unchanged."""
@@ -261,16 +286,23 @@ class CliBackend(LlmBackend):
261
286
  """
262
287
  return Invocation(self.build_command(spec), spec.prompt)
263
288
 
289
+ def timed_out(self, spec: RunSpec) -> Response:
290
+ msg = f"{self.provider} timed out after {spec.timeout}s"
291
+ return Response(spec=spec, output=Output(""), error=Error(msg, TimeoutError(msg)))
292
+
264
293
  async def aexecute(self, spec: RunSpec) -> Response:
265
294
  inv = self.invocation(spec)
266
295
  try:
267
- rr = await acapture_cli(
268
- inv.argv,
269
- input=inv.stdin,
270
- env=os.environ | self.env() | (spec.env or {}),
271
- cwd=spec.cwd,
272
- timeout=spec.timeout,
273
- )
296
+ try:
297
+ rr = await acapture_cli(
298
+ inv.argv,
299
+ input=inv.stdin,
300
+ env=os.environ | self.env(spec) | (spec.env or {}),
301
+ cwd=spec.cwd,
302
+ timeout=spec.timeout,
303
+ )
304
+ except TimeoutError:
305
+ return self.timed_out(spec)
274
306
  raw = Path(inv.result_path).read_text() if inv.result_path else rr.stdout
275
307
  finally:
276
308
  for path in inv.cleanup_paths:
@@ -280,13 +312,16 @@ class CliBackend(LlmBackend):
280
312
  def execute(self, spec: RunSpec) -> Response:
281
313
  inv = self.invocation(spec)
282
314
  try:
283
- rr = capture_cli(
284
- inv.argv,
285
- input=inv.stdin,
286
- env=os.environ | self.env() | (spec.env or {}),
287
- cwd=spec.cwd,
288
- timeout=spec.timeout,
289
- )
315
+ try:
316
+ rr = capture_cli(
317
+ inv.argv,
318
+ input=inv.stdin,
319
+ env=os.environ | self.env(spec) | (spec.env or {}),
320
+ cwd=spec.cwd,
321
+ timeout=spec.timeout,
322
+ )
323
+ except subprocess.TimeoutExpired:
324
+ return self.timed_out(spec)
290
325
  raw = Path(inv.result_path).read_text() if inv.result_path else rr.stdout
291
326
  finally:
292
327
  for path in inv.cleanup_paths:
@@ -2,8 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import atexit
5
6
  import json
7
+ import shutil
6
8
  import subprocess
9
+ import tempfile
10
+ from pathlib import Path
7
11
  from typing import TYPE_CHECKING, ClassVar
8
12
 
9
13
  from spawnllm.backends.base import CliBackend
@@ -58,6 +62,8 @@ class ClaudeCliBackend(CliBackend):
58
62
  binary: ClassVar[str] = "claude"
59
63
  install_hint: ClassVar[str] = "curl -fsSL https://claude.ai/install.sh | bash"
60
64
 
65
+ _isolated_config_dir: str | None = None
66
+
61
67
  def build_command(self, spec: RunSpec) -> list[str]:
62
68
  """Build the `claude -p` argv for one stdin-prompted invocation.
63
69
 
@@ -84,11 +90,12 @@ class ClaudeCliBackend(CliBackend):
84
90
  "--no-session-persistence",
85
91
  "--model",
86
92
  spec.model,
93
+ *(["--setting-sources", ""] if spec.isolated else []),
94
+ *(["--strict-mcp-config"] if spec.isolated or cfg.strict_mcp else []),
87
95
  *(
88
96
  [
89
97
  *(["--permission-mode", cfg.permission_mode] if cfg.permission_mode is not None else []),
90
98
  *(["--mcp-config", cfg.mcp_config] if cfg.mcp_config is not None else []),
91
- *(["--strict-mcp-config"] if cfg.strict_mcp else []),
92
99
  *(["--disallowedTools", *cfg.disallowed_tools] if cfg.disallowed_tools else []),
93
100
  *(
94
101
  ["--append-system-prompt", cfg.append_system_prompt]
@@ -101,7 +108,7 @@ class ClaudeCliBackend(CliBackend):
101
108
  if explicit
102
109
  else ["--permission-mode", "auto", "--max-budget-usd", "1"]
103
110
  if spec.agent
104
- else ["--system-prompt", "", "--setting-sources", "", "--strict-mcp-config"]
111
+ else ["--system-prompt", ""]
105
112
  ),
106
113
  *(["--system-prompt", cfg.system_prompt] if cfg.system_prompt is not None else []),
107
114
  *(["--max-turns", str(cfg.max_turns)] if cfg.max_turns is not None else []),
@@ -150,11 +157,50 @@ class ClaudeCliBackend(CliBackend):
150
157
  return event["result"] if isinstance(event.get("result"), str) else "claude reported an error"
151
158
  return None
152
159
 
153
- def env(self) -> dict[str, str]:
154
- """Return no extra environment variables; the `claude` CLI runs with the inherited environment."""
155
- # CLAUDE_CODE_SIMPLE=1 breaks claude.ai keychain auth ("Not logged in")
156
- # on current CLIs; --setting-sources ""/--strict-mcp-config already trim startup.
157
- return {}
160
+ def env(self, spec: RunSpec) -> dict[str, str]:
161
+ """Point an isolated run at a fresh, host-free `CLAUDE_CONFIG_DIR`; otherwise add nothing.
162
+
163
+ Defense in depth behind the argv flags: a config home seeded with nothing
164
+ but the account pointer and OAuth token means plugin and
165
+ `~/.claude.json`-driven loading finds no host settings, plugins, or hooks
166
+ even if a flag is ever dropped.
167
+
168
+ Args:
169
+ spec: The configured run; `spec.isolated` gates the override.
170
+
171
+ Returns:
172
+ `{"CLAUDE_CONFIG_DIR": <isolated dir>}` for an isolated run, else `{}`.
173
+ """
174
+ if not spec.isolated:
175
+ return {}
176
+ return {"CLAUDE_CONFIG_DIR": self._isolated_dir()}
177
+
178
+ def _isolated_dir(self) -> str:
179
+ """Return the process-lifetime isolated config home, creating and seeding it once.
180
+
181
+ The home is a fresh temp dir seeded with only the two auth-bearing files:
182
+ `~/.claude.json` minus its `mcpServers` block (the active-account pointer,
183
+ with no host MCP servers leaking even absent `--strict-mcp-config`) and
184
+ `~/.claude/.credentials.json` (the claude.ai OAuth token, which lives in
185
+ the config home — relocating it without this copy logs the run out). Host
186
+ `settings.json`, plugins, and hooks are never copied. The dir is cached on
187
+ the backend and removed at interpreter exit.
188
+ """
189
+ if self._isolated_config_dir is not None:
190
+ return self._isolated_config_dir
191
+ config_dir = Path(tempfile.mkdtemp(prefix="spawnllm-claude-config-"))
192
+ home = Path.home()
193
+ account_path = home / ".claude.json"
194
+ if account_path.exists():
195
+ account = json.loads(account_path.read_text())
196
+ account.pop("mcpServers", None)
197
+ (config_dir / ".claude.json").write_text(json.dumps(account))
198
+ credentials_path = home / ".claude" / ".credentials.json"
199
+ if credentials_path.exists():
200
+ shutil.copyfile(credentials_path, config_dir / ".credentials.json")
201
+ atexit.register(shutil.rmtree, config_dir, ignore_errors=True)
202
+ self._isolated_config_dir = str(config_dir)
203
+ return self._isolated_config_dir
158
204
 
159
205
  def is_authenticated(self, *, timeout: int) -> bool:
160
206
  """Report whether `claude auth status` exits cleanly, i.e. a claude.ai login is stored.
@@ -36,9 +36,9 @@ class CodexCliBackend(CliBackend):
36
36
  """
37
37
 
38
38
  models: ClassVar[dict[TModel, str]] = {
39
- "small": "gpt-5.3-codex-spark",
40
- "medium": "gpt-5.4-mini",
41
- "large": "gpt-5.5",
39
+ "small": "gpt-5.4-mini:low",
40
+ "medium": "gpt-5.4-mini:medium",
41
+ "large": "gpt-5.5:medium",
42
42
  }
43
43
  provider: ClassVar[ProviderName] = "codex"
44
44
  binary: ClassVar[str] = "codex"
@@ -63,6 +63,7 @@ class CodexCliBackend(CliBackend):
63
63
 
64
64
  def command_for(self, spec: RunSpec, schema_path: str | None) -> list[str]:
65
65
  cfg = spec.config_for(CodexConfig) or CodexConfig()
66
+ model, _, effort = spec.model.partition(":")
66
67
  return [
67
68
  "codex",
68
69
  "exec",
@@ -70,12 +71,14 @@ class CodexCliBackend(CliBackend):
70
71
  "--sandbox",
71
72
  cfg.sandbox or "read-only",
72
73
  "--model",
73
- spec.model,
74
+ model,
75
+ *(["-c", f"model_reasoning_effort={effort}"] if effort else []),
76
+ *(["--ignore-user-config"] if spec.isolated else []),
74
77
  *(
75
78
  []
76
79
  if spec.agent
77
80
  else [
78
- *([] if cfg.enable_hooks else ["-c", "features.codex_hooks=false"]),
81
+ *([] if cfg.enable_hooks else ["-c", "features.hooks=false"]),
79
82
  *([] if cfg.enable_mcp else ["-c", "features.mcp_servers=false"]),
80
83
  ]
81
84
  ),
@@ -125,8 +128,13 @@ class CodexCliBackend(CliBackend):
125
128
 
126
129
  return json.dumps(to_strict_json_schema(model))
127
130
 
128
- def env(self) -> dict[str, str]:
129
- """Return no extra environment variables; the `codex` CLI runs with the inherited environment."""
131
+ def env(self, _spec: RunSpec) -> dict[str, str]:
132
+ """Return no extra environment variables; `--ignore-user-config` isolates config while `CODEX_HOME` keeps auth.
133
+
134
+ `codex` keeps `auth.json` in `CODEX_HOME`, so relocating it would strand a
135
+ single-use OAuth refresh token; the `--ignore-user-config` flag isolates
136
+ `config.toml` without touching auth.
137
+ """
130
138
  return {}
131
139
 
132
140
  def is_authenticated(self, *, timeout: int) -> bool:
@@ -30,8 +30,13 @@ class GeminiFamilyBackend(CliBackend, ABC):
30
30
 
31
31
  api_key_envs: ClassVar[tuple[str, ...]]
32
32
 
33
- def env(self) -> dict[str, str]:
34
- """Return no extra environment variables; Gemini-family CLIs authenticate via OAuth, not an injected key."""
33
+ def env(self, _spec: RunSpec) -> dict[str, str]:
34
+ """Return no extra environment variables.
35
+
36
+ Gemini-family CLIs read settings and OAuth from the same config home (`~/.gemini`,
37
+ `~/.gemini/antigravity-cli`), with no isolation flag and no way to relocate config
38
+ without stranding auth, so isolated runs read the real config — the no-flag exception.
39
+ """
35
40
  return {}
36
41
 
37
42
  def is_authenticated(self, *, timeout: int) -> bool:
@@ -52,8 +52,8 @@ class MlxBackend(LlmBackend):
52
52
  """Return the `structured_output` from a stream-json result event, else `raw` parsed as JSON."""
53
53
  return structured_value(raw)
54
54
 
55
- def env(self) -> dict[str, str]:
56
- """Return no extra environment variables; MLX runs in-process."""
55
+ def env(self, _spec: RunSpec) -> dict[str, str]:
56
+ """Return no extra environment variables; MLX runs in-process with nothing to isolate."""
57
57
  return {}
58
58
 
59
59
  def is_authenticated(self, *, timeout: int) -> bool:
@@ -55,7 +55,9 @@ def select_backend(*, specialty: TSpecialty | None = None, timeout: int = 10) ->
55
55
  """Return the first installed, authenticated backend in priority order.
56
56
 
57
57
  A `specialty`, when given, promotes its registered backend to the front of
58
- the chain; the chain otherwise follows `PRIORITY`. The first backend whose
58
+ the chain; the chain otherwise follows `PRIORITY`, minus `GeminiCliBackend`
59
+ (its Code Assist OAuth tier is retired, so it reports ready yet fails at call
60
+ time — reach it only via an explicit `backend=`). The first backend whose
59
61
  `check_status` reports `BackendReady` wins, short-circuiting the rest;
60
62
  backends that time out are skipped.
61
63
 
@@ -71,7 +73,8 @@ def select_backend(*, specialty: TSpecialty | None = None, timeout: int = 10) ->
71
73
  """
72
74
  preferred = [LlmBackends.LLM_BACKENDS[specialty]] if specialty else []
73
75
  seen = {type(b) for b in preferred}
74
- for backend in (*preferred, *(b for b in PRIORITY if type(b) not in seen)):
76
+ auto = (b for b in PRIORITY if type(b) not in seen and not isinstance(b, GeminiCliBackend))
77
+ for backend in (*preferred, *auto):
75
78
  try:
76
79
  if isinstance(backend.check_status(timeout=timeout), BackendReady):
77
80
  return backend
@@ -4,7 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  from typing import TYPE_CHECKING
6
6
 
7
- from spawnllm.backends.base import BackendCallError
8
7
  from spawnllm.backends.registry import select_backend
9
8
  from spawnllm.run import run, run_sync
10
9
  from spawnllm.spec import RunSpec
@@ -54,8 +53,8 @@ async def call(
54
53
  RunSpec(prompt=prompt, model=backend.models[model], agent=agent, cwd=cwd, timeout=timeout), backend=backend
55
54
  )
56
55
  if resp.error is not None:
57
- raise BackendCallError(resp.error)
58
- return resp.result
56
+ raise resp.error.ex
57
+ return resp.result.raw
59
58
 
60
59
 
61
60
  def call_sync(
@@ -96,5 +95,5 @@ def call_sync(
96
95
  RunSpec(prompt=prompt, model=backend.models[model], agent=agent, cwd=cwd, timeout=timeout), backend=backend
97
96
  )
98
97
  if resp.error is not None:
99
- raise BackendCallError(resp.error)
100
- return resp.result
98
+ raise resp.error.ex
99
+ return resp.result.raw
@@ -4,7 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  from typing import TYPE_CHECKING, cast
6
6
 
7
- from spawnllm.backends.base import BackendCallError
8
7
  from spawnllm.backends.registry import select_backend
9
8
  from spawnllm.run import run, run_sync
10
9
  from spawnllm.spec import RunSpec
@@ -66,8 +65,8 @@ async def extract[T: BaseModel](
66
65
  )
67
66
  resp = await run(spec, backend=backend)
68
67
  if resp.error is not None:
69
- raise BackendCallError(resp.error)
70
- return cast(T, resp.parsed)
68
+ raise resp.error.ex
69
+ return cast(T, resp.result.parsed)
71
70
 
72
71
 
73
72
  def extract_sync[T: BaseModel](
@@ -118,5 +117,5 @@ def extract_sync[T: BaseModel](
118
117
  )
119
118
  resp = run_sync(spec, backend=backend)
120
119
  if resp.error is not None:
121
- raise BackendCallError(resp.error)
122
- return cast(T, resp.parsed)
120
+ raise resp.error.ex
121
+ return cast(T, resp.result.parsed)
@@ -0,0 +1,78 @@
1
+ """The structured object every backend hands back: spec, raw output, result, and error."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from pydantic import BaseModel
10
+
11
+ from spawnllm.spec import RunSpec
12
+
13
+
14
+ @dataclass(frozen=True, slots=True)
15
+ class Error:
16
+ """A failed run: a human-readable message plus the underlying exception.
17
+
18
+ `ex` preserves the real exception so a caller can re-raise it unchanged —
19
+ a `BackendCallError` for a nonzero exit or error envelope, a normalized
20
+ timeout error, or a `pydantic.ValidationError` for a non-conforming model.
21
+
22
+ Example:
23
+ >>> Error(msg="codex exited 127: codex: not found", ex=RuntimeError("..."))
24
+ """
25
+
26
+ msg: str
27
+ ex: Exception
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class Result:
32
+ """A successful run: the extracted final text and the optional validated model.
33
+
34
+ `raw` is the extracted final text; `parsed` is the validated model, set only
35
+ when the `RunSpec` carried a `response_model`.
36
+
37
+ Example:
38
+ >>> Result(raw="hello", parsed=None)
39
+ """
40
+
41
+ raw: str
42
+ parsed: BaseModel | None = None
43
+
44
+
45
+ @dataclass(frozen=True, slots=True)
46
+ class Output:
47
+ """The full unparsed transport stream, present on success and failure alike.
48
+
49
+ `raw` is the complete output the provider wrote — the `claude
50
+ --output-format json` event stream, the `codex -o` file, or plain stdout —
51
+ before any extraction.
52
+
53
+ Example:
54
+ >>> Output(raw='[{"type": "system"}, {"type": "result", "result": "hi"}]')
55
+ """
56
+
57
+ raw: str
58
+
59
+
60
+ @dataclass(frozen=True, slots=True)
61
+ class Response:
62
+ """A backend's fully-resolved outcome: the spec, the raw output, and exactly one of result/error.
63
+
64
+ A backend runs the process, reads its output wherever the provider writes it,
65
+ detects failure, and validates — then hands back one `Response`. `spec` and
66
+ `output` are always present (the raw bytes live in `output.raw` even on
67
+ failure); exactly one of `result`/`error` is set. Every failure — a nonzero
68
+ exit, an error envelope, a timeout, or a validation error — routes through
69
+ `error`, never a raise from `run`.
70
+
71
+ Example:
72
+ >>> Response(spec=spec, output=Output(raw="hi"), result=Result(raw="hi"))
73
+ """
74
+
75
+ spec: RunSpec
76
+ output: Output
77
+ result: Result | None = None
78
+ error: Error | None = None
@@ -23,8 +23,9 @@ async def run(spec: RunSpec, *, backend: LlmBackend | None = None) -> Response:
23
23
  Each attempt runs through `backend.aexecute`; a transient `Response.error`
24
24
  (a 529, overloaded, rate-limit, or `5xx`) triggers a capped exponential
25
25
  backoff and another attempt, up to `spec.max_attempts`. The final `Response`
26
- — success or last transient failure — is returned without raising. A
27
- `pydantic.ValidationError` from the backend's validate propagates.
26
+ — success or last failure — is returned without raising; every operational
27
+ failure (nonzero exit, error envelope, timeout, validation) lives in
28
+ `resp.error`.
28
29
 
29
30
  Args:
30
31
  spec: The configured run to execute.
@@ -75,7 +75,12 @@ class RunSpec:
75
75
  Common fields are interpreted by every backend; `provider_configs` carries
76
76
  optional per-provider flag passthrough that only the matching backend reads.
77
77
  `model` is a literal provider model id (`opus`, `sonnet`, …) passed straight
78
- through with no tier mapping.
78
+ through with no tier mapping. `isolated` (default `True`) runs the backend
79
+ against a fresh, host-free config home so a spawned CLI ignores ambient
80
+ settings, MCP servers, and hooks. Structured output comes from either a
81
+ `response_model` (validated to a model) or a raw `schema` (a JSON-Schema dict
82
+ or pre-serialized string, passed to the provider verbatim, with nothing to
83
+ validate); setting both raises `ValueError`.
79
84
 
80
85
  Example:
81
86
  >>> RunSpec(prompt="ping", model="opus")
@@ -84,13 +89,19 @@ class RunSpec:
84
89
  prompt: str
85
90
  model: str
86
91
  response_model: type[BaseModel] | None = None
92
+ schema: dict[str, object] | str | None = None
87
93
  agent: bool = False
94
+ isolated: bool = True
88
95
  cwd: str | None = None
89
96
  env: dict[str, str] | None = None
90
97
  timeout: int = 180
91
98
  max_attempts: int = 5
92
99
  provider_configs: dict[ProviderName, ProviderConfig] = field(default_factory=dict)
93
100
 
101
+ def __post_init__(self) -> None:
102
+ if self.response_model is not None and self.schema is not None:
103
+ raise ValueError("RunSpec accepts either response_model or schema, not both")
104
+
94
105
  def config_for[T: ProviderConfig](self, kind: type[T]) -> T | None:
95
106
  """Return the first provider config that is an instance of `kind`, or None."""
96
107
  return next((c for c in self.provider_configs.values() if isinstance(c, kind)), None)
@@ -92,7 +92,7 @@ def structured_value(raw: str) -> object:
92
92
  def is_transient(resp: Response) -> bool:
93
93
  """Report whether a response failed with a retryable transient error.
94
94
 
95
- A response is transient iff it carries an `error` whose text matches the
95
+ A response is transient iff it carries an `error` whose message matches the
96
96
  `TRANSIENT` pattern (529, overloaded, rate-limit, or any `5xx`).
97
97
 
98
98
  Args:
@@ -101,7 +101,7 @@ def is_transient(resp: Response) -> bool:
101
101
  Returns:
102
102
  `True` when the response should be retried.
103
103
  """
104
- return resp.error is not None and bool(TRANSIENT.search(resp.error))
104
+ return resp.error is not None and bool(TRANSIENT.search(resp.error.msg))
105
105
 
106
106
 
107
107
  def backoff(attempt: int) -> float:
@@ -1,29 +0,0 @@
1
- """The single object every backend hands back: error, text, and validated model."""
2
-
3
- from __future__ import annotations
4
-
5
- from dataclasses import dataclass
6
- from typing import TYPE_CHECKING
7
-
8
- if TYPE_CHECKING:
9
- from pydantic import BaseModel
10
-
11
-
12
- @dataclass(frozen=True, slots=True)
13
- class Response:
14
- """A backend's fully-resolved outcome: the provider error, the text, and the model.
15
-
16
- A backend runs the process, reads its output wherever the provider writes it,
17
- detects failure, and validates — then hands back one `Response`. No
18
- subprocess plumbing escapes: `error` carries the provider failure (a nonzero
19
- exit with stderr, or an error envelope) and is `None` on success; `result`
20
- is the final text output; `parsed` is the validated model, set only when the
21
- `RunSpec` carried a `response_model`.
22
-
23
- Example:
24
- >>> Response(error=None, result="hello", parsed=None)
25
- """
26
-
27
- error: str | None
28
- result: str | None
29
- parsed: BaseModel | None = None
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes