spinekit 0.2.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.
Files changed (77) hide show
  1. spine_a2a/__init__.py +17 -0
  2. spine_a2a/client.py +101 -0
  3. spine_a2a/py.typed +0 -0
  4. spine_backends/__init__.py +27 -0
  5. spine_backends/embeddings.py +68 -0
  6. spine_backends/memory.py +99 -0
  7. spine_backends/migrations.py +39 -0
  8. spine_backends/pgvector.py +140 -0
  9. spine_backends/postgres.py +85 -0
  10. spine_backends/py.typed +0 -0
  11. spine_backends/redis.py +58 -0
  12. spine_backends/sqlite.py +103 -0
  13. spine_cli/__init__.py +5 -0
  14. spine_cli/app.py +363 -0
  15. spine_cli/builder.py +86 -0
  16. spine_cli/config.py +92 -0
  17. spine_cli/plugins.py +54 -0
  18. spine_cli/py.typed +0 -0
  19. spine_cli/templates.py +122 -0
  20. spine_core/__init__.py +117 -0
  21. spine_core/agent.py +540 -0
  22. spine_core/checkpoint.py +39 -0
  23. spine_core/control.py +25 -0
  24. spine_core/errors.py +23 -0
  25. spine_core/guards.py +45 -0
  26. spine_core/interrupt.py +24 -0
  27. spine_core/memory.py +48 -0
  28. spine_core/messages.py +123 -0
  29. spine_core/middleware.py +157 -0
  30. spine_core/provider.py +103 -0
  31. spine_core/py.typed +0 -0
  32. spine_core/registry.py +76 -0
  33. spine_core/result.py +55 -0
  34. spine_core/state.py +58 -0
  35. spine_core/testing.py +87 -0
  36. spine_core/tools.py +147 -0
  37. spine_core/trace.py +59 -0
  38. spine_eval/__init__.py +42 -0
  39. spine_eval/loader.py +37 -0
  40. spine_eval/models.py +120 -0
  41. spine_eval/py.typed +0 -0
  42. spine_eval/runner.py +71 -0
  43. spine_eval/scorers.py +132 -0
  44. spine_mcp/__init__.py +17 -0
  45. spine_mcp/py.typed +0 -0
  46. spine_mcp/toolset.py +126 -0
  47. spine_middleware/__init__.py +72 -0
  48. spine_middleware/cache.py +80 -0
  49. spine_middleware/compaction.py +39 -0
  50. spine_middleware/cost.py +35 -0
  51. spine_middleware/fallback.py +30 -0
  52. spine_middleware/guardrails.py +170 -0
  53. spine_middleware/loopguard.py +43 -0
  54. spine_middleware/memory.py +66 -0
  55. spine_middleware/multitenancy.py +52 -0
  56. spine_middleware/py.typed +0 -0
  57. spine_middleware/reliability.py +120 -0
  58. spine_middleware/replay.py +63 -0
  59. spine_middleware/retry.py +43 -0
  60. spine_middleware/sandbox.py +99 -0
  61. spine_middleware/structured.py +79 -0
  62. spine_middleware/tooling.py +43 -0
  63. spine_orchestration/__init__.py +7 -0
  64. spine_orchestration/patterns.py +106 -0
  65. spine_orchestration/py.typed +0 -0
  66. spine_otel/__init__.py +15 -0
  67. spine_otel/middleware.py +150 -0
  68. spine_otel/py.typed +0 -0
  69. spine_providers/__init__.py +15 -0
  70. spine_providers/anthropic.py +258 -0
  71. spine_providers/openai.py +273 -0
  72. spine_providers/py.typed +0 -0
  73. spinekit-0.2.0.dist-info/METADATA +149 -0
  74. spinekit-0.2.0.dist-info/RECORD +77 -0
  75. spinekit-0.2.0.dist-info/WHEEL +4 -0
  76. spinekit-0.2.0.dist-info/entry_points.txt +29 -0
  77. spinekit-0.2.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,103 @@
1
+ """SQLite checkpoint backend — durable state for crash recovery and resume.
2
+
3
+ Uses the stdlib ``sqlite3`` driver offloaded to a worker thread (via anyio) so
4
+ the async kernel never blocks. WAL mode keeps reads and writes concurrent. A
5
+ monotonic ``revision`` column supports optimistic-locking checks.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import sqlite3
12
+ import time
13
+ from pathlib import Path
14
+
15
+ import anyio
16
+
17
+ from spine_backends.migrations import migrate
18
+ from spine_core.registry import register_checkpoint
19
+ from spine_core.state import State
20
+
21
+ _SCHEMA = """
22
+ CREATE TABLE IF NOT EXISTS checkpoints (
23
+ session_id TEXT PRIMARY KEY,
24
+ version INTEGER NOT NULL,
25
+ revision INTEGER NOT NULL DEFAULT 1,
26
+ data TEXT NOT NULL,
27
+ updated REAL NOT NULL
28
+ )
29
+ """
30
+
31
+
32
+ class SQLiteCheckpoint:
33
+ """Durable :class:`~spine_core.checkpoint.CheckpointStore` over SQLite."""
34
+
35
+ def __init__(self, path: str | Path = "spine.db") -> None:
36
+ self.path = str(path)
37
+ self._init()
38
+
39
+ def _connect(self) -> sqlite3.Connection:
40
+ conn = sqlite3.connect(self.path)
41
+ conn.execute("PRAGMA journal_mode=WAL")
42
+ conn.execute("PRAGMA busy_timeout=5000")
43
+ return conn
44
+
45
+ def _init(self) -> None:
46
+ with self._connect() as conn:
47
+ conn.execute(_SCHEMA)
48
+
49
+ async def put(self, state: State) -> None:
50
+ await anyio.to_thread.run_sync(self._put_sync, state)
51
+
52
+ def _put_sync(self, state: State) -> None:
53
+ with self._connect() as conn:
54
+ conn.execute(
55
+ """
56
+ INSERT INTO checkpoints (session_id, version, revision, data, updated)
57
+ VALUES (?, ?, 1, ?, ?)
58
+ ON CONFLICT(session_id) DO UPDATE SET
59
+ version = excluded.version,
60
+ revision = checkpoints.revision + 1,
61
+ data = excluded.data,
62
+ updated = excluded.updated
63
+ """,
64
+ (state.session_id, state.version, state.model_dump_json(), time.time()),
65
+ )
66
+
67
+ async def get(self, session_id: str) -> State | None:
68
+ return await anyio.to_thread.run_sync(self._get_sync, session_id)
69
+
70
+ def _get_sync(self, session_id: str) -> State | None:
71
+ with self._connect() as conn:
72
+ row = conn.execute(
73
+ "SELECT data FROM checkpoints WHERE session_id = ?", (session_id,)
74
+ ).fetchone()
75
+ if row is None:
76
+ return None
77
+ raw = migrate(json.loads(row[0]))
78
+ return State.model_validate(raw)
79
+
80
+ async def delete(self, session_id: str) -> None:
81
+ await anyio.to_thread.run_sync(self._delete_sync, session_id)
82
+
83
+ def _delete_sync(self, session_id: str) -> None:
84
+ with self._connect() as conn:
85
+ conn.execute("DELETE FROM checkpoints WHERE session_id = ?", (session_id,))
86
+
87
+ async def revision(self, session_id: str) -> int:
88
+ """Current optimistic-lock revision for a session (0 if absent)."""
89
+ return await anyio.to_thread.run_sync(self._revision_sync, session_id)
90
+
91
+ def _revision_sync(self, session_id: str) -> int:
92
+ with self._connect() as conn:
93
+ row = conn.execute(
94
+ "SELECT revision FROM checkpoints WHERE session_id = ?", (session_id,)
95
+ ).fetchone()
96
+ return int(row[0]) if row is not None else 0
97
+
98
+
99
+ def register() -> None:
100
+ register_checkpoint("sqlite", lambda path="spine.db", **_: SQLiteCheckpoint(path))
101
+
102
+
103
+ register()
spine_cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Spine CLI — scaffold, run, doctor, and inspect plugins."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.2.0" # x-release-please-version
spine_cli/app.py ADDED
@@ -0,0 +1,363 @@
1
+ """The ``spine`` CLI — Typer commands with Rich output.
2
+
3
+ Project operations are declarative and reproducible: ``init`` scaffolds, ``run``
4
+ executes an agent defined in the project, ``doctor`` validates config + plugin
5
+ compatibility, and ``plugin`` inspects installed extensions.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import sys
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import typer
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+
19
+ from spine_cli import templates
20
+ from spine_cli.config import ConfigError, find_config, load_config
21
+ from spine_cli.plugins import discover, load_all
22
+
23
+ app = typer.Typer(no_args_is_help=True, add_completion=False, help="Spine — agent runtime CLI.")
24
+ plugin_app = typer.Typer(no_args_is_help=True, help="Manage and inspect plugins.")
25
+ app.add_typer(plugin_app, name="plugin")
26
+
27
+ console = Console()
28
+
29
+
30
+ @app.command()
31
+ def version() -> None:
32
+ """Print the installed Spine version."""
33
+ from importlib.metadata import PackageNotFoundError
34
+ from importlib.metadata import version as _pkg_version
35
+
36
+ try:
37
+ ver = _pkg_version("spinekit")
38
+ except PackageNotFoundError:
39
+ from spine_core import __version__ as ver
40
+
41
+ console.print(f"spinekit [bold]{ver}[/]")
42
+
43
+
44
+ @app.command()
45
+ def init(
46
+ name: str = typer.Argument(..., help="Project directory to create."),
47
+ template: str = typer.Option("minimal", "--template", "-t", help="Project template."),
48
+ path: Path = typer.Option(Path("."), help="Where to create the project."),
49
+ ) -> None:
50
+ """Scaffold a new Spine project (like ``uv init``)."""
51
+ try:
52
+ files = templates.render(name, template)
53
+ except ValueError as exc:
54
+ console.print(f"[red]error:[/] {exc}")
55
+ raise typer.Exit(1) from exc
56
+
57
+ root = (path / name).resolve()
58
+ if root.exists() and any(root.iterdir()):
59
+ console.print(f"[red]error:[/] {root} already exists and is not empty")
60
+ raise typer.Exit(1)
61
+
62
+ for relpath, content in files.items():
63
+ dest = root / relpath
64
+ dest.parent.mkdir(parents=True, exist_ok=True)
65
+ dest.write_text(content)
66
+
67
+ console.print(f"[green]✓[/] created [bold]{name}[/] ({template}) at {root}")
68
+ console.print(f' next: [cyan]cd {name} && uv sync && uv run spine run assistant "hi"[/]')
69
+
70
+
71
+ @app.command()
72
+ def run(
73
+ target: str = typer.Argument(..., help="Agent to run: 'assistant' or 'module:attr'."),
74
+ input: str = typer.Argument(..., help="The input message for the agent."),
75
+ path: Path = typer.Option(Path("."), help="Project root."),
76
+ ) -> None:
77
+ """Run an agent defined in the project and print its answer."""
78
+ project_root = _project_root(path)
79
+ try:
80
+ agent = _load_agent(target, project_root)
81
+ except (ImportError, AttributeError) as exc:
82
+ console.print(f"[red]error:[/] could not load agent '{target}': {exc}")
83
+ raise typer.Exit(1) from exc
84
+
85
+ result = agent.run_sync(input)
86
+ if result.stopped_reason.value == "error":
87
+ console.print(f"[red]run failed:[/] {result.error}")
88
+ raise typer.Exit(1)
89
+ if result.interrupted:
90
+ console.print(f"[yellow]⏸ interrupted[/] (resume token: {result.resume_token})")
91
+ console.print(result.interrupt)
92
+ return
93
+ console.print(result.answer or "")
94
+ console.print(
95
+ f"\n[dim]stopped: {result.stopped_reason.value} · steps: {result.state.step} · "
96
+ f"${result.usage.cost_usd:.4f} · {result.usage.total_tokens} tok[/]"
97
+ )
98
+
99
+
100
+ @app.command()
101
+ def doctor(path: Path = typer.Option(Path("."), help="Project root.")) -> None:
102
+ """Validate config, environment, and plugin compatibility."""
103
+ import os
104
+
105
+ from spine_core import ProviderError, resolve_provider
106
+
107
+ rows: list[tuple[str, str, str]] = []
108
+ failed = False
109
+
110
+ config_path = find_config(path)
111
+ if config_path is None:
112
+ rows.append(("config", "[red]fail[/]", "no spine.toml found"))
113
+ _render_doctor(rows)
114
+ raise typer.Exit(1)
115
+ rows.append(("config", "[green]ok[/]", str(config_path)))
116
+
117
+ try:
118
+ config = load_config(config_path)
119
+ except ConfigError as exc:
120
+ rows.append(("config parse", "[red]fail[/]", str(exc)))
121
+ _render_doctor(rows)
122
+ raise typer.Exit(1) from exc
123
+
124
+ loaded = load_all()
125
+ rows.append(
126
+ ("plugins", "[green]ok[/]", f"{sum(p.loaded for p in loaded)}/{len(loaded)} loaded")
127
+ )
128
+ for plugin in loaded:
129
+ if plugin.error:
130
+ failed = True
131
+ rows.append((f" {plugin.name}", "[red]fail[/]", plugin.error))
132
+
133
+ try:
134
+ resolve_provider(config.default_model)
135
+ rows.append(("model", "[green]ok[/]", config.default_model))
136
+ except ProviderError as exc:
137
+ failed = True
138
+ rows.append(("model", "[red]fail[/]", str(exc)))
139
+
140
+ scheme = config.default_model.split(":", 1)[0]
141
+ if scheme == "anthropic" and not os.environ.get("ANTHROPIC_API_KEY"):
142
+ rows.append(("ANTHROPIC_API_KEY", "[yellow]warn[/]", "unset (needed to call the model)"))
143
+
144
+ from spine_core import list_checkpoints, list_middleware
145
+
146
+ known_mw = set(list_middleware())
147
+ for name in config.middleware.chain:
148
+ if name in known_mw:
149
+ rows.append((f"mw:{name}", "[green]ok[/]", "registered"))
150
+ else:
151
+ failed = True
152
+ rows.append((f"mw:{name}", "[red]fail[/]", "not registered (install its plugin)"))
153
+
154
+ backend = config.backends.checkpoint
155
+ if backend and backend not in set(list_checkpoints()):
156
+ failed = True
157
+ rows.append((f"checkpoint:{backend}", "[red]fail[/]", "backend not registered"))
158
+ elif backend:
159
+ rows.append((f"checkpoint:{backend}", "[green]ok[/]", "registered"))
160
+
161
+ _render_doctor(rows)
162
+ if failed:
163
+ raise typer.Exit(1)
164
+
165
+
166
+ @plugin_app.command("list")
167
+ def plugin_list() -> None:
168
+ """List installed Spine plugins (entry points)."""
169
+ plugins = discover()
170
+ if not plugins:
171
+ console.print("[dim]no spine.plugins entry points installed[/]")
172
+ return
173
+ table = Table("name", "target", "origin")
174
+ for plugin in plugins:
175
+ origin = "first-party" if plugin.first_party else "[yellow]third-party[/]"
176
+ table.add_row(plugin.name, plugin.value, origin)
177
+ console.print(table)
178
+
179
+
180
+ @app.command("eval")
181
+ def eval_cmd(
182
+ suite: str = typer.Argument(..., help="Path to the eval dataset (.yaml/.json)."),
183
+ path: Path = typer.Option(Path("."), help="Project root."),
184
+ scorer: str = typer.Option("contains", help="Built-in scorer: contains | exact."),
185
+ ) -> None:
186
+ """Run the eval harness for the spine.toml agent against a dataset."""
187
+ import functools
188
+
189
+ import anyio
190
+
191
+ try:
192
+ from spine_eval import Contains, ExactMatch, Scorer, evaluate, load_dataset
193
+ except ImportError as exc:
194
+ console.print("[red]error:[/] spine-eval is not installed ([cyan]uv add spine-eval[/])")
195
+ raise typer.Exit(1) from exc
196
+
197
+ config_path = find_config(path)
198
+ if config_path is None:
199
+ console.print("[red]error:[/] no spine.toml found")
200
+ raise typer.Exit(1)
201
+ config = load_config(config_path)
202
+ project_root = config_path.parent
203
+
204
+ suite_path = Path(suite)
205
+ if not suite_path.is_absolute():
206
+ suite_path = project_root / suite
207
+ if not suite_path.is_file():
208
+ console.print(f"[red]error:[/] no eval suite at {suite_path}")
209
+ raise typer.Exit(1)
210
+
211
+ from spine_cli.builder import build_agent
212
+
213
+ agent = build_agent(config, project_root)
214
+ dataset = load_dataset(suite_path)
215
+ options: dict[str, list[Scorer]] = {"contains": [Contains()], "exact": [ExactMatch()]}
216
+ scorers = options.get(scorer)
217
+ if scorers is None:
218
+ console.print(f"[red]error:[/] unknown scorer {scorer!r} (use contains|exact)")
219
+ raise typer.Exit(1)
220
+
221
+ report = anyio.run(functools.partial(evaluate, agent, dataset, scorers))
222
+
223
+ table = Table("metric", "value")
224
+ table.add_row("cases", str(report.total))
225
+ table.add_row("pass rate", f"{report.pass_rate:.0%} ({report.passed}/{report.total})")
226
+ table.add_row("error rate", f"{report.error_rate:.0%}")
227
+ table.add_row("cost (total)", f"${report.cost_total_usd:.4f}")
228
+ table.add_row("latency avg / p95", f"{report.latency_avg_s:.2f}s / {report.latency_p95_s:.2f}s")
229
+ for name, mean in report.scorer_means.items():
230
+ table.add_row(f"scorer:{name}", f"{mean:.2f}")
231
+ console.print(table)
232
+ if report.error_rate > 0 or report.pass_rate < 1.0:
233
+ raise typer.Exit(1)
234
+
235
+
236
+ @app.command()
237
+ def dev(
238
+ input: str = typer.Argument(..., help="The input message."),
239
+ path: Path = typer.Option(Path("."), help="Project root."),
240
+ ) -> None:
241
+ """Run the spine.toml agent and stream every step's trace event live."""
242
+ import anyio
243
+
244
+ from spine_cli.builder import build_agent, save_trace
245
+
246
+ config_path = find_config(path)
247
+ if config_path is None:
248
+ console.print("[red]error:[/] no spine.toml found")
249
+ raise typer.Exit(1)
250
+ config = load_config(config_path)
251
+ project_root = config_path.parent
252
+ agent = build_agent(config, project_root)
253
+
254
+ async def go() -> None:
255
+ async for event in agent.stream(input):
256
+ detail = ", ".join(f"{k}={v}" for k, v in event.data.items())
257
+ console.print(f"[dim]{event.seq:>3}[/] [cyan]{event.type}[/] {detail}")
258
+
259
+ anyio.run(go)
260
+ result = agent.last_result
261
+ if result is not None:
262
+ save_trace(project_root, result)
263
+ console.print(f"\n[bold]{result.answer or '(' + result.stopped_reason.value + ')'}[/]")
264
+
265
+
266
+ @app.command()
267
+ def chat(
268
+ input: str = typer.Argument(..., help="The input message."),
269
+ path: Path = typer.Option(Path("."), help="Project root."),
270
+ ) -> None:
271
+ """Run the agent described by spine.toml (model, guards, middleware, backend)."""
272
+ from spine_cli.builder import build_agent, save_trace
273
+
274
+ config_path = find_config(path)
275
+ if config_path is None:
276
+ console.print("[red]error:[/] no spine.toml found")
277
+ raise typer.Exit(1)
278
+ try:
279
+ config = load_config(config_path)
280
+ except ConfigError as exc:
281
+ console.print(f"[red]error:[/] {exc}")
282
+ raise typer.Exit(1) from exc
283
+
284
+ project_root = config_path.parent
285
+ agent = build_agent(config, project_root)
286
+ result = agent.run_sync(input)
287
+ trace_path = save_trace(project_root, result)
288
+
289
+ if result.stopped_reason.value == "error":
290
+ console.print(f"[red]run failed:[/] {result.error}")
291
+ raise typer.Exit(1)
292
+ console.print(result.answer or f"[yellow]stopped: {result.stopped_reason.value}[/]")
293
+ console.print(
294
+ f"\n[dim]stopped: {result.stopped_reason.value} · steps: {result.state.step} · "
295
+ f"${result.usage.cost_usd:.4f} · {result.usage.total_tokens} tok · "
296
+ f"trace: {trace_path}[/]"
297
+ )
298
+
299
+
300
+ @app.command()
301
+ def trace(
302
+ session: str = typer.Argument(None, help="Session id to inspect; omit to list recent."),
303
+ path: Path = typer.Option(Path("."), help="Project root."),
304
+ ) -> None:
305
+ """Inspect a recorded run trace (saved by `spine chat`)."""
306
+ import json
307
+
308
+ from spine_cli.builder import TRACES_DIR
309
+
310
+ project_root = _project_root(path)
311
+ traces_dir = project_root / TRACES_DIR
312
+ if not traces_dir.is_dir():
313
+ console.print("[dim]no traces recorded yet[/]")
314
+ return
315
+
316
+ if session is None:
317
+ files = sorted(traces_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
318
+ table = Table("session", "stopped", "events")
319
+ for file in files[:20]:
320
+ data = json.loads(file.read_text())
321
+ table.add_row(
322
+ file.stem, data.get("stopped_reason", "?"), str(len(data.get("events", [])))
323
+ )
324
+ console.print(table)
325
+ return
326
+
327
+ file = traces_dir / f"{session}.json"
328
+ if not file.is_file():
329
+ console.print(f"[red]error:[/] no trace for session {session!r}")
330
+ raise typer.Exit(1)
331
+ data = json.loads(file.read_text())
332
+ table = Table("seq", "step", "type", "detail")
333
+ for event in data.get("events", []):
334
+ detail = ", ".join(f"{k}={v}" for k, v in event.get("data", {}).items())
335
+ table.add_row(str(event.get("seq")), str(event.get("step")), event.get("type", ""), detail)
336
+ console.print(table)
337
+
338
+
339
+ # -- helpers ----------------------------------------------------------------
340
+
341
+
342
+ def _project_root(path: Path) -> Path:
343
+ config_path = find_config(path)
344
+ return config_path.parent if config_path is not None else path.resolve()
345
+
346
+
347
+ def _load_agent(target: str, project_root: Path) -> Any:
348
+ root = str(project_root)
349
+ if root not in sys.path:
350
+ sys.path.insert(0, root)
351
+ module_name, _, attr = target.partition(":")
352
+ if not attr:
353
+ module_name, attr = f"agents.{target}", "agent"
354
+ module = importlib.import_module(module_name)
355
+ agent = getattr(module, attr)
356
+ return agent
357
+
358
+
359
+ def _render_doctor(rows: list[tuple[str, str, str]]) -> None:
360
+ table = Table("check", "status", "detail")
361
+ for check, status, detail in rows:
362
+ table.add_row(check, status, detail)
363
+ console.print(table)
spine_cli/builder.py ADDED
@@ -0,0 +1,86 @@
1
+ """Build a live :class:`Agent` from declarative ``spine.toml`` config.
2
+
3
+ This closes the gap between the declared config (model, guards, middleware chain,
4
+ checkpoint backend) and a running agent: names in the chain are resolved through
5
+ the core registries, which installed plugins populate.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from spine_cli.config import SpineConfig
17
+ from spine_cli.plugins import load_all
18
+ from spine_core import (
19
+ Agent,
20
+ Provider,
21
+ Result,
22
+ Tool,
23
+ resolve_checkpoint,
24
+ resolve_middleware,
25
+ )
26
+
27
+ TRACES_DIR = ".spine/traces"
28
+
29
+
30
+ def _ensure_on_path(project_root: Path) -> None:
31
+ root = str(project_root)
32
+ if root not in sys.path:
33
+ sys.path.insert(0, root)
34
+
35
+
36
+ def discover_tools(project_root: Path) -> list[Tool]:
37
+ """Collect ``@tool`` instances exposed by the project's ``tools`` package."""
38
+ _ensure_on_path(project_root)
39
+ # Drop any cached "tools" so discovery always reads the current project's.
40
+ sys.modules.pop("tools", None)
41
+ try:
42
+ module = importlib.import_module("tools")
43
+ except ImportError:
44
+ return []
45
+ return [value for value in vars(module).values() if isinstance(value, Tool)]
46
+
47
+
48
+ def build_agent(
49
+ config: SpineConfig,
50
+ project_root: Path,
51
+ *,
52
+ provider: Provider | str | None = None,
53
+ ) -> Agent:
54
+ """Construct an Agent from config: model, guards, middleware, backend, tools."""
55
+ load_all() # import installed plugins so registry names resolve
56
+
57
+ middleware = [
58
+ resolve_middleware(name, **config.plugins.get(name, {})) for name in config.middleware.chain
59
+ ]
60
+ checkpoint = None
61
+ if config.backends.checkpoint:
62
+ backend = config.backends.checkpoint
63
+ checkpoint = resolve_checkpoint(backend, **config.plugins.get(backend, {}))
64
+
65
+ return Agent(
66
+ provider or config.default_model,
67
+ tools=discover_tools(project_root),
68
+ guards=config.guards,
69
+ middleware=middleware,
70
+ checkpoint=checkpoint,
71
+ system=config.system,
72
+ )
73
+
74
+
75
+ def save_trace(project_root: Path, result: Result) -> Path:
76
+ """Persist a run's trace under ``.spine/traces/<session>.json`` for `spine trace`."""
77
+ directory = project_root / TRACES_DIR
78
+ directory.mkdir(parents=True, exist_ok=True)
79
+ path = directory / f"{result.state.session_id}.json"
80
+ payload: dict[str, Any] = {
81
+ "session_id": result.state.session_id,
82
+ "stopped_reason": result.stopped_reason.value,
83
+ "events": [event.model_dump() for event in result.trace],
84
+ }
85
+ path.write_text(json.dumps(payload, default=str, indent=2))
86
+ return path
spine_cli/config.py ADDED
@@ -0,0 +1,92 @@
1
+ """``spine.toml`` loader.
2
+
3
+ An agent's full behavior is reproducible from version control: model, guards,
4
+ middleware order, and backends are declared here. Secrets are never inlined —
5
+ ``${ENV_VAR}`` references are interpolated from the environment at load time.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import tomllib
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from pydantic import BaseModel, Field
17
+
18
+ from spine_core import Guards
19
+
20
+ _ENV_REF = re.compile(r"\$\{([^}]+)\}")
21
+
22
+ CONFIG_FILENAME = "spine.toml"
23
+
24
+
25
+ class MiddlewareConfig(BaseModel):
26
+ chain: list[str] = Field(default_factory=list)
27
+
28
+
29
+ class BackendsConfig(BaseModel):
30
+ checkpoint: str | None = None
31
+ memory: str | None = None
32
+
33
+
34
+ class SpineConfig(BaseModel):
35
+ default_model: str = "anthropic:claude-sonnet-4-6"
36
+ system: str | None = None
37
+ guards: Guards = Field(default_factory=Guards)
38
+ middleware: MiddlewareConfig = Field(default_factory=MiddlewareConfig)
39
+ backends: BackendsConfig = Field(default_factory=BackendsConfig)
40
+ plugins: dict[str, dict[str, Any]] = Field(default_factory=dict)
41
+
42
+
43
+ class ConfigError(Exception):
44
+ """``spine.toml`` is missing, malformed, or references an unset env var."""
45
+
46
+
47
+ def _interpolate(value: Any) -> Any:
48
+ """Recursively replace ``${VAR}`` with the environment value."""
49
+ if isinstance(value, str):
50
+
51
+ def repl(match: re.Match[str]) -> str:
52
+ name = match.group(1)
53
+ resolved = os.environ.get(name)
54
+ if resolved is None:
55
+ raise ConfigError(
56
+ f"environment variable '{name}' referenced in spine.toml is unset"
57
+ )
58
+ return resolved
59
+
60
+ return _ENV_REF.sub(repl, value)
61
+ if isinstance(value, dict):
62
+ return {k: _interpolate(v) for k, v in value.items()}
63
+ if isinstance(value, list):
64
+ return [_interpolate(v) for v in value]
65
+ return value
66
+
67
+
68
+ def find_config(start: Path) -> Path | None:
69
+ """Search ``start`` and its parents for ``spine.toml``."""
70
+ start = start.resolve()
71
+ for directory in (start, *start.parents):
72
+ candidate = directory / CONFIG_FILENAME
73
+ if candidate.is_file():
74
+ return candidate
75
+ return None
76
+
77
+
78
+ def load_config(path: Path) -> SpineConfig:
79
+ """Load and validate ``spine.toml`` at ``path`` (a file or its directory)."""
80
+ config_path = path if path.is_file() else path / CONFIG_FILENAME
81
+ if not config_path.is_file():
82
+ raise ConfigError(f"no {CONFIG_FILENAME} found at {config_path}")
83
+ try:
84
+ raw = tomllib.loads(config_path.read_text())
85
+ except tomllib.TOMLDecodeError as exc:
86
+ raise ConfigError(f"{config_path} is not valid TOML: {exc}") from exc
87
+
88
+ section = _interpolate(raw.get("spine", {}))
89
+ try:
90
+ return SpineConfig.model_validate(section)
91
+ except Exception as exc: # pydantic ValidationError
92
+ raise ConfigError(f"invalid [spine] config in {config_path}: {exc}") from exc