Skip to content

Commit 9ed313d

Browse files
committed
feat: pydantic v2 manifest schema
ToolDefinition (frozen, extra="forbid") declares a tool's name, optional description, non-empty effects tuple, optional permitted_postures, an operator-confirmation flag, and free-form metadata. Effects and postures are canonicalised on construction — deduplicated and sorted by enum declaration order — so JSON round-trip is byte-stable across runs and platforms. Manifest holds the dict[str, ToolDefinition] with a validator that enforces name == key. parse_manifest() wraps pydantic.ValidationError as ManifestError so callers catch a single typed exception rooted at SpineLiteError. Accepts dicts, JSON strings, or JSON bytes. Manifest, ToolDefinition, parse_manifest added to __all__. Tests cover canonicalisation, frozen-model immutability, schema rejection (unknown effects, unknown postures, extra fields, name/key mismatch, empty effects, empty postures), JSON round-trip, and byte-stable serialisation.
1 parent 600d870 commit 9ed313d

3 files changed

Lines changed: 461 additions & 5 deletions

File tree

src/spine_lite/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
PostureError,
1717
SpineLiteError,
1818
)
19+
from spine_lite.manifest import Manifest, ToolDefinition, parse_manifest
1920
from spine_lite.posture import Posture
2021

2122
__version__ = "0.1.0a0"
@@ -25,12 +26,15 @@
2526
"ClassificationError",
2627
"Effect",
2728
"HookError",
29+
"Manifest",
2830
"ManifestError",
2931
"Posture",
3032
"PostureError",
3133
"SpineLiteError",
34+
"ToolDefinition",
3235
"__version__",
3336
"most_restrictive",
37+
"parse_manifest",
3438
]
3539

3640
logging.getLogger(__name__).addHandler(logging.NullHandler())

src/spine_lite/manifest.py

Lines changed: 207 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,213 @@
1-
"""Tool-manifest schema (Phase 2).
1+
"""Tool-manifest schema.
22
33
Pydantic v2 models for tool definitions, declared effects, and posture
4-
constraints land here. The schema must round-trip the TypeScript reference
5-
fixtures byte-for-byte after JSON normalisation. See
6-
``docs/porting-notes.md`` for the source-of-truth schema.
4+
constraints. Pure module: validation only, no I/O.
75
8-
Pure module: validation only, no I/O.
6+
Manifests round-trip authored fixtures byte-for-byte. The two
7+
order-sensitive fields — :attr:`ToolDefinition.effects` and
8+
:attr:`ToolDefinition.permitted_postures` — are canonicalised on
9+
construction (deduplicated and sorted by enum-declaration order) so JSON
10+
serialisation is stable across runs and platforms regardless of the
11+
order the author wrote them in.
912
"""
1013

1114
from __future__ import annotations
15+
16+
from typing import Any, ClassVar, Final
17+
18+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator
19+
20+
from spine_lite.effects import PRECEDENCE, Effect
21+
from spine_lite.exceptions import ManifestError
22+
from spine_lite.posture import Posture
23+
24+
_EFFECT_ORDER: Final[dict[Effect, int]] = {e: i for i, e in enumerate(PRECEDENCE)}
25+
_POSTURE_ORDER: Final[dict[Posture, int]] = {p: i for i, p in enumerate(Posture)}
26+
27+
28+
def _canonical_effects(effects: tuple[Effect, ...]) -> tuple[Effect, ...]:
29+
"""Deduplicate and sort effects by ``PRECEDENCE`` order."""
30+
seen: set[Effect] = set()
31+
canonical: list[Effect] = []
32+
for effect in sorted(effects, key=_EFFECT_ORDER.__getitem__):
33+
if effect not in seen:
34+
seen.add(effect)
35+
canonical.append(effect)
36+
return tuple(canonical)
37+
38+
39+
def _canonical_postures(postures: tuple[Posture, ...]) -> tuple[Posture, ...]:
40+
"""Deduplicate and sort postures by enum declaration order."""
41+
seen: set[Posture] = set()
42+
canonical: list[Posture] = []
43+
for posture in sorted(postures, key=_POSTURE_ORDER.__getitem__):
44+
if posture not in seen:
45+
seen.add(posture)
46+
canonical.append(posture)
47+
return tuple(canonical)
48+
49+
50+
class ToolDefinition(BaseModel):
51+
"""Declares a single tool's effects and posture constraints.
52+
53+
Attributes:
54+
name: Tool identifier as the LLM sees it. Must match the key under
55+
which this definition is registered in a :class:`Manifest`.
56+
description: Optional human-readable description.
57+
effects: Effect classes this tool's invocations can produce. Must
58+
be non-empty. Stored canonically: deduplicated and sorted by
59+
``PRECEDENCE`` order.
60+
permitted_postures: Postures under which this tool may be invoked.
61+
``None`` means no posture constraint (the tool runs under any
62+
posture). When set, must be non-empty. Stored canonically:
63+
deduplicated and sorted by :class:`Posture` declaration order.
64+
require_confirmation: If true, even an otherwise-allowed call must
65+
be confirmed by the operator before execution. Phase 3
66+
classifier honours this; Phase 2 just stores it.
67+
metadata: Free-form additional metadata. Manifest authors may
68+
carry arbitrary keys here; spine-lite ignores them but
69+
preserves them for round-trip serialisation.
70+
71+
Examples:
72+
>>> definition = ToolDefinition(
73+
... name="read_file",
74+
... effects=(Effect.READ,),
75+
... )
76+
>>> definition.effects
77+
(<Effect.READ: 'read'>,)
78+
"""
79+
80+
model_config: ClassVar[ConfigDict] = ConfigDict(
81+
frozen=True,
82+
extra="forbid",
83+
validate_default=True,
84+
str_strip_whitespace=True,
85+
)
86+
87+
name: str = Field(min_length=1)
88+
description: str | None = None
89+
effects: tuple[Effect, ...] = Field(min_length=1)
90+
permitted_postures: tuple[Posture, ...] | None = None
91+
require_confirmation: bool = False
92+
metadata: dict[str, Any] = Field(default_factory=dict)
93+
94+
@field_validator("effects", mode="after")
95+
@classmethod
96+
def _canonicalise_effects(
97+
cls,
98+
value: tuple[Effect, ...],
99+
) -> tuple[Effect, ...]:
100+
return _canonical_effects(value)
101+
102+
@field_validator("permitted_postures", mode="after")
103+
@classmethod
104+
def _canonicalise_postures(
105+
cls,
106+
value: tuple[Posture, ...] | None,
107+
) -> tuple[Posture, ...] | None:
108+
if value is None:
109+
return None
110+
canonical = _canonical_postures(value)
111+
if not canonical:
112+
raise ValueError(
113+
"permitted_postures must be non-empty when set; "
114+
"use null/None to indicate no constraint",
115+
)
116+
return canonical
117+
118+
119+
class Manifest(BaseModel):
120+
"""A collection of tool definitions keyed by tool name.
121+
122+
A manifest is the policy document for a runtime configuration. Every
123+
tool the LLM can call must appear here; calls to undeclared tools
124+
fail closed in the classifier.
125+
126+
Attributes:
127+
tools: Mapping from tool name to its :class:`ToolDefinition`.
128+
Each definition's ``name`` field must match its key in this
129+
mapping. Empty manifests are permitted (zero tools declared).
130+
131+
Examples:
132+
>>> manifest = Manifest(tools={
133+
... "read_file": ToolDefinition(name="read_file", effects=(Effect.READ,)),
134+
... })
135+
>>> manifest.get("read_file").effects
136+
(<Effect.READ: 'read'>,)
137+
"""
138+
139+
model_config: ClassVar[ConfigDict] = ConfigDict(
140+
frozen=True,
141+
extra="forbid",
142+
validate_default=True,
143+
)
144+
145+
tools: dict[str, ToolDefinition] = Field(default_factory=dict)
146+
147+
@field_validator("tools", mode="after")
148+
@classmethod
149+
def _names_match_keys(
150+
cls,
151+
tools: dict[str, ToolDefinition],
152+
) -> dict[str, ToolDefinition]:
153+
for key, tool in tools.items():
154+
if tool.name != key:
155+
raise ValueError(
156+
f"tool name mismatch: key {key!r} does not match definition name {tool.name!r}",
157+
)
158+
return tools
159+
160+
def get(self, name: str) -> ToolDefinition:
161+
"""Return the definition for ``name``.
162+
163+
Args:
164+
name: Tool name to look up.
165+
166+
Returns:
167+
The matching :class:`ToolDefinition`.
168+
169+
Raises:
170+
ManifestError: If no tool with that name is declared.
171+
"""
172+
try:
173+
return self.tools[name]
174+
except KeyError as exc:
175+
raise ManifestError(
176+
f"tool {name!r} not declared in manifest",
177+
) from exc
178+
179+
180+
def parse_manifest(data: Any) -> Manifest:
181+
"""Validate ``data`` as a :class:`Manifest`.
182+
183+
Wraps pydantic's :class:`pydantic.ValidationError` as
184+
:class:`ManifestError` so callers can catch a single typed exception
185+
rooted at :class:`SpineLiteError`.
186+
187+
Args:
188+
data: A Python mapping (dict), a JSON string, or JSON bytes.
189+
Strings and bytes are parsed via
190+
:meth:`pydantic.BaseModel.model_validate_json`; everything
191+
else through :meth:`pydantic.BaseModel.model_validate`.
192+
193+
Returns:
194+
A validated, immutable :class:`Manifest`.
195+
196+
Raises:
197+
ManifestError: If validation fails for any reason. The original
198+
:class:`pydantic.ValidationError` is attached as ``__cause__``.
199+
200+
Examples:
201+
>>> parse_manifest({
202+
... "tools": {
203+
... "read_file": {"name": "read_file", "effects": ["read"]},
204+
... },
205+
... }).get("read_file").effects
206+
(<Effect.READ: 'read'>,)
207+
"""
208+
try:
209+
if isinstance(data, (str, bytes)):
210+
return Manifest.model_validate_json(data)
211+
return Manifest.model_validate(data)
212+
except ValidationError as exc:
213+
raise ManifestError(f"manifest validation failed: {exc}") from exc

0 commit comments

Comments
 (0)