|
1 | | -"""Tool-manifest schema (Phase 2). |
| 1 | +"""Tool-manifest schema. |
2 | 2 |
|
3 | 3 | 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. |
7 | 5 |
|
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. |
9 | 12 | """ |
10 | 13 |
|
11 | 14 | 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