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.
- spine_a2a/__init__.py +17 -0
- spine_a2a/client.py +101 -0
- spine_a2a/py.typed +0 -0
- spine_backends/__init__.py +27 -0
- spine_backends/embeddings.py +68 -0
- spine_backends/memory.py +99 -0
- spine_backends/migrations.py +39 -0
- spine_backends/pgvector.py +140 -0
- spine_backends/postgres.py +85 -0
- spine_backends/py.typed +0 -0
- spine_backends/redis.py +58 -0
- spine_backends/sqlite.py +103 -0
- spine_cli/__init__.py +5 -0
- spine_cli/app.py +363 -0
- spine_cli/builder.py +86 -0
- spine_cli/config.py +92 -0
- spine_cli/plugins.py +54 -0
- spine_cli/py.typed +0 -0
- spine_cli/templates.py +122 -0
- spine_core/__init__.py +117 -0
- spine_core/agent.py +540 -0
- spine_core/checkpoint.py +39 -0
- spine_core/control.py +25 -0
- spine_core/errors.py +23 -0
- spine_core/guards.py +45 -0
- spine_core/interrupt.py +24 -0
- spine_core/memory.py +48 -0
- spine_core/messages.py +123 -0
- spine_core/middleware.py +157 -0
- spine_core/provider.py +103 -0
- spine_core/py.typed +0 -0
- spine_core/registry.py +76 -0
- spine_core/result.py +55 -0
- spine_core/state.py +58 -0
- spine_core/testing.py +87 -0
- spine_core/tools.py +147 -0
- spine_core/trace.py +59 -0
- spine_eval/__init__.py +42 -0
- spine_eval/loader.py +37 -0
- spine_eval/models.py +120 -0
- spine_eval/py.typed +0 -0
- spine_eval/runner.py +71 -0
- spine_eval/scorers.py +132 -0
- spine_mcp/__init__.py +17 -0
- spine_mcp/py.typed +0 -0
- spine_mcp/toolset.py +126 -0
- spine_middleware/__init__.py +72 -0
- spine_middleware/cache.py +80 -0
- spine_middleware/compaction.py +39 -0
- spine_middleware/cost.py +35 -0
- spine_middleware/fallback.py +30 -0
- spine_middleware/guardrails.py +170 -0
- spine_middleware/loopguard.py +43 -0
- spine_middleware/memory.py +66 -0
- spine_middleware/multitenancy.py +52 -0
- spine_middleware/py.typed +0 -0
- spine_middleware/reliability.py +120 -0
- spine_middleware/replay.py +63 -0
- spine_middleware/retry.py +43 -0
- spine_middleware/sandbox.py +99 -0
- spine_middleware/structured.py +79 -0
- spine_middleware/tooling.py +43 -0
- spine_orchestration/__init__.py +7 -0
- spine_orchestration/patterns.py +106 -0
- spine_orchestration/py.typed +0 -0
- spine_otel/__init__.py +15 -0
- spine_otel/middleware.py +150 -0
- spine_otel/py.typed +0 -0
- spine_providers/__init__.py +15 -0
- spine_providers/anthropic.py +258 -0
- spine_providers/openai.py +273 -0
- spine_providers/py.typed +0 -0
- spinekit-0.2.0.dist-info/METADATA +149 -0
- spinekit-0.2.0.dist-info/RECORD +77 -0
- spinekit-0.2.0.dist-info/WHEEL +4 -0
- spinekit-0.2.0.dist-info/entry_points.txt +29 -0
- spinekit-0.2.0.dist-info/licenses/LICENSE +21 -0
spine_backends/sqlite.py
ADDED
|
@@ -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
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
|