Schemas¶
Each tool has one JSON schema, built by pydantic. At registration it is checked against a portable subset: the features every supported provider accepts. Each renderer then rewrites a copy for its provider. The full flow is on Schema pipeline.
The portable subset¶
| Allowed | Rejected |
|---|---|
str, int, float, bool |
dict[str, T] (open maps) |
Literal / Enum of strings |
non-string enum values |
list[T] |
tuples; a list with no item type |
nested BaseModel |
recursive models |
T | None (also type: ["X", "null"]) |
unions of several types (int | str), oneOf, discriminated unions |
Field(description=, ge=, le=) |
pattern, string min_length/max_length, uniqueItems, min/max properties |
format: date-time |
any other format |
single-item allOf |
allOf with several items |
Any (untyped value); a type list with no non-null type |
The root must be an object. A failure raises SchemaNotPortable at registration. Its
problems list names each path, e.g. $.counts: open map (dict) is not portable.
Need something outside the subset?
Pass portable=False to skip the check. Then test that tool on each provider you
use: a provider may reject the schema at request time.
What each provider gets¶
| Rewrite | OpenAI strict | OpenAI strict=False |
Gemini | Anthropic |
|---|---|---|---|---|
$ref inlined, title dropped, const → enum |
yes | yes | yes | yes |
| every field required, optional made nullable | yes | no | no | no |
additionalProperties: false on every object |
yes | as given | as given | as given |
default |
removed; written into the description | kept | removed; written into the description | kept |
| keywords the provider does not accept | — | — | dropped | — |
Gemini keeps only: type, description, properties, required, enum, format,
minimum, maximum, items, minItems, maxItems, anyOf, additionalProperties,
nullable.
One tool, three schemas¶
The get_weather tool below renders like this (output of examples/providers.py):
import asyncio
import json
from typing import Any, Literal
from agent_adapter import Registry
registry = Registry()
@registry.tool
def get_weather(city: str, unit: Literal["c", "f"] = "c") -> str:
"""Current weather for a city.
Args:
city: City name, e.g. Paris.
unit: Temperature unit.
"""
return f"{city}: 31 degrees {unit.upper()}"
Tools from a raw JSON schema¶
Tools imported from MCP, LangChain or OpenAPI arrive with a JSON schema, not a Python
signature. spec_from_schema keeps that schema as input_schema (and the result schema
as output_schema), so json_schema() returns it unchanged.
SCHEMA = {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search text."},
"max-results": {"type": "integer", "default": 5},
},
"required": ["query"],
}
async def remote_search(**arguments: Any) -> dict[str, Any]:
"""Stands in for an MCP or HTTP call; gets the schema's own property names."""
return arguments
search = spec_from_schema(
"remote_search", "Search the remote index.", SCHEMA, remote_search, effects="read_only"
)
registry.add(search)
executor = Executor(registry)
print(await executor.run("remote_search", {"query": "returns"})) # unset optionals are not sent
print(await executor.run("remote_search", {"query": "returns", "max-results": 2}))
func gets the arguments the model actually set, named as the schema's properties
(max-results included). Unset optional arguments are not sent, so the remote side
applies its own defaults; a null is dropped unless the property schema allows null.
Arguments are validated by a loose model (schema.models.model_from_json_schema):
| Property schema | Field type |
|---|---|
string with string enum |
Literal[...] |
string / integer / number / boolean |
str / int / float / bool |
type: [X, "null"], or anyOf X and null, X primitive |
X \| None |
array / object |
list[Any] / dict[str, Any] |
anything else (oneOf, allOf, $ref, no type) |
Any |
Required properties are required; unknown properties are rejected unless the root sets
additionalProperties. Names that are not valid field names (max-results, _id,
schema) get a sanitised field name with the original as alias; both validate.
portable= |
Effect |
|---|---|
None (default) |
check_portable decides; problems are logged at DEBUG |
True |
registry.add raises SchemaNotPortable if the schema is not portable |
False |
no check |
An imported tool whose schema is not portable renders with strict: false on OpenAI and
in tool_def(), whatever strict you ask for. That is the only strict downgrade.
Validation is always ours¶
Incoming arguments are validated with the tool's own pydantic model, never by trusting the provider. A keyword dropped for a provider never weakens checking.
Return schemas¶
spec.result_schema() describes the return annotation. It is None when there is no
annotation, the annotation is -> None, a result= transform is set, or pydantic cannot
describe the type (a warning is logged). It never blocks registration.