Skip to content

Schemas

The schema a model is told is derived from the Python type the code parses. There is one declaration, not two, so the pair cannot drift.

from tesserix_adk.core import schema_for, schema_hash

schema = schema_for(Itinerary)
version = schema_hash(schema)

schema_for accepts a pydantic model, a dataclass, a TypedDict, or a callable with annotated parameters. What comes back is normalised JSON Schema (Draft 2020-12): titles dropped, keys ordered, required sorted — so two generations of one type are the same bytes and a diff of two schemas shows only what changed.

Descriptions come from the docstring

The summary line describes the type. The Google-style Args: block describes its fields, and each entry lands on the matching property:

class Waypoint(AdkModel):
    """A stop on the way.

    Args:
        city: Where the traveller stops.
        nights: How long they stay, in nights.
    """

    city: str
    nights: int = 1

Field(description=...) wins where both exist. A missing docstring, a missing Args: block, an entry naming a field that no longer exists, or a line the parser cannot read costs a description and nothing else — documentation is guidance, and a typo in it is not a reason to refuse to run.

The Args: block is never used as the type's own description: pydantic would otherwise emit the whole docstring, repeating field-by-field guidance the schema already carries.

A description has to have been written for this type. schema_for(str) is {"type": "string"} and not str's own docstring: a builtin's __doc__ is written for a Python reader, and telling the model that a string is str(object='') -> str spends tokens to confuse it.

What can be described

A pydantic model, a dataclass, a TypedDict, a callable, a bare type, and a type spelled in a way isinstance does not recognise — list[str], str | None, dict[str, int] — which is how a tool's parameters usually arrive.

annotations_of is the resolution schema_for uses on a callable, exported because a caller inspecting a signature needs the same answer: get_type_hints with include_extras=True, falling back to the __call__ of a callable object, which get_type_hints refuses outright.

Excluding a parameter

schema_for(callable, exclude=("ctx",)) describes every parameter but the named ones. It is for arguments the caller injects — a request context, a connection, a tenant — which are not the model's to choose and often cannot be rendered as JSON Schema anyway. Excluding is structural rather than cosmetic: the parameter is not described, so nothing the caller injects can be overridden by a model that guessed the name. @tool uses it for ToolContext.

Provider dialects

Providers disagree about what a schema may contain, so the dialect is a parameter rather than something each caller patches afterwards.

Dialect What it emits For
JSON_SCHEMA (default) Draft 2020-12, $defs and $ref intact Anything that accepts JSON Schema
STRICT_SUBSET additionalProperties: false on every object Structured-output modes that demand closed objects
INLINE_REFS Every $ref substituted, no $defs Providers that will not follow a reference

STRICT_SUBSET closes objects and does nothing else. Some provider guides also widen required to every field; the kit does not, because a schema that demands keys the type treats as optional refuses payloads the code would have accepted.

A dialect is anything satisfying SchemaDialect — a name, a forbidden set of schema keywords, and an adapt. Nothing about the three built-ins is privileged:

@dataclass(frozen=True)
class NoUnions:
    name: str = "no-unions"
    forbidden: frozenset[str] = frozenset({"anyOf"})

    def adapt(self, schema: dict[str, Any]) -> dict[str, Any]:
        return schema

forbidden is checked after adapt. A keyword the provider will not accept raises CapabilityError naming the dialect, the keyword and where it appeared — the schema is never rewritten into something looser that the provider would take and the code would not.

The hash

schema_hash is a SHA-256 over the canonical form, prefixed with the algorithm that produced it. Key order does not change it; anything about the shape does.

That is deliberate. A renamed field, a widened bound, a new union member and a different dialect all produce a different hash, so a cassette recorded against the old shape misses loudly instead of replaying an answer for a type that no longer exists. It is the same property the run fingerprint relies on (docs/determinism.md).

What fails, and when

Every failure below happens where the type is declared, not on the first call that sends it. A schema that accepts more than the type does is the worst outcome available: the model satisfies it, the code refuses the answer, and the run fails in production.

Situation Raised
A parameter with no annotation SchemaGenerationError naming the parameter
*args or **kwargs on a tool callable SchemaGenerationError naming it
Any in a required position, at any depth SchemaGenerationError naming the field
A type pydantic cannot render as JSON Schema SchemaGenerationError naming the field and the type
A schema past max_bytes SchemaGenerationError with both sizes
A dialect that forbids a keyword the type needs CapabilityError naming dialect and keyword
A recursive type under INLINE_REFS CapabilityError naming the type
A type nested deeper than InlineRefs(max_depth=...) CapabilityError naming the depth

Any is refused rather than emitted as {}, which accepts everything. Where a field genuinely holds arbitrary provider data, model it as a declared map — the same rule as Usage.extras in docs/models.md.

A schema over a provider's size limit is refused whole. Truncating it would describe a different type, and the model would be told a shape the code does not accept.

Recursion is fine wherever references are allowed:

schema_for(Node)                        # $defs/Node referring to itself
schema_for(Node, dialect=INLINE_REFS)   # CapabilityError: inlining a cycle does not terminate

Field changes and versions

The semver rules for changing a field are in docs/models.md. The schema hash is how those changes become visible: any of them changes it, and a changed hash invalidates cassettes and cached prompts rather than letting a stale one answer.