Skip to content

Tool families: a rendered family parameter keeps its identity - #35

Merged
ericeil merged 5 commits into
masterfrom
eric/family-param-identity
Aug 21, 2026
Merged

Tool families: a rendered family parameter keeps its identity#35
ericeil merged 5 commits into
masterfrom
eric/family-param-identity

Conversation

@ericeil

@ericeil ericeil commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Take a tool whose argument is a nested schema, where both the tool's prose and the nested
schema's prose speak the family's noun:

class RecipeParams(ToolFamilyParams):
    dish: str

@family_param(RecipeParams)
class Portion(BaseModel):
    """A portion of the {dish}"""
    grams: int = Field(description="How many grams of the {dish} to serve")

class ServeDish(WithImplementation):
    """Serve the {dish}"""
    portion: Portion = Field(description="The portion of {dish} to plate")

class Meal(BaseModel):
    """Wherever the value the tool built is kept afterwards -- graph state, most of all."""
    portions: list[Portion]

family_param is what makes Portion usable as an annotation in ServeDish and Meal: it is declared
Callable[[type[T]], type[T]], and the class it binds really is a subclass of the one written
above it. So Meal and ServeDish name the same type, and the value one produces is the value
the other stores. That is the whole promise of the decorator.

The problem

It does not hold:

serve = tool_family(RecipeParams)(ServeDish).with_template(dish="risotto")
plated = serve.model_validate({"portion": {"grams": 200}})

isinstance(plated.portion, Portion)     # False
Meal(portions=[plated.portion])         # ValidationError

family_param binds Portion to a clone built as create_model(..., __base__=(t, _TemplatedTool)).
Rendering ServeDish walks its fields, finds a family in the portion annotation, and renders it
too -- but with_template built the rendered class as create_model(__base__=cls._wrapped), on the
undecorated class. The rendered Portion and the bound Portion therefore both descend from the
class in the source and neither descends from the other. They are siblings.

Everything the model drives arrives as the rendered class, so nothing annotated with the bound name
ever accepts a value a templated tool built.

Nothing flags it. family_param returns type[T], so to a checker the two are one type. And
under langgraph the runtime failure is invisible in its own way: a tool taking injected state fails
validation with the error under loc=('state', ...), which _filter_validation_errors strips as
injected. The model is handed an empty error string, and retries against it until the session dies.

The same promise, broken a second way

A checkpoint serializer (JsonPlusSerializer) names a pydantic value by cls.__module__ and
cls.__name__, then restores it with getattr(import_module(module), name)(**kwargs). On failure
it returns the kwargs dict.

create_model takes __module__ from the calling frame, so both the bound clone and a rendering
of it claimed graphcore.tools.schemas. That name is not there, so a rendered value comes back as
a bare dict and the next read of it fails on an attribute it no longer has:

serde = JsonPlusSerializer()
serde.loads_typed(serde.dumps_typed([plated.portion]))    # [{'grams': 200}] -- a dict

Stamping the rendering's __module__ as the bound class would make the lookup succeed, but the
live object would still be a different class. LangChain validates against the rendered schema and
then calls the tool with those instances: a second construction typed as the rendering rejects a
bound value. So the rendering has to remain a runtime subclass during validation; what has to
change is what is written into graph state.

The fix

What a rendering derives from is the only thing that separates the two kinds of family, so they
become two types rather than one with a branch inside with_template:

  • _ToolFamily -- the handle tool_family binds. It is never a value's type, so a rendering
    is the wrapped schema, exactly as before.
  • _FamilyParam -- what family_param binds. It renders onto itself, which is what makes a
    rendered value an instance of the bound name.

The bound class claims t.__module__: the decorator binds it to t's name in t's module, so
that is where it lives and where a serializer can import it.

Rendered family-param values are then rebound to that class at the boundaries where they become
values rather than schema:

  • as_tool rebuilds nested family-param fields after constructing the tool (skipping InjectedState)
  • tool_state_update / tool_output rebuild anything going into Command.update

What hits a checkpoint is Portion. The rendering stays the LLM schema -- templated prose, a
runtime subclass -- and is never what JsonPlus has to import.

_TemplatedTool remains the sentinel map_type matches on, and tool_family's declared return
type is unchanged, so no consumer signature moves.

Tests

In tests/test_tool_families.py, over exactly the example above: a rendering is a subtype of the
bound name, a value the tool built validates against that name, as_tool / tool_state_update
rebind that value so a JsonPlusSerializer round trip restores a Portion rather than a dict,
renderings from different template args stay unrelated, a family param is directly renderable, and
the bound class is recoverable by import(__module__) then getattr(__name__).

@ericeil
ericeil force-pushed the eric/family-param-identity branch 2 times, most recently from 52ff6db to ae60174 Compare August 20, 2026 20:29
`family_param` promises a decorator that leaves the name usable as an annotation --
it returns `type[T]`, and the class it binds is a subclass of the decorated one. Two
things then broke that promise at runtime while no checker could see it.

`with_template` rendered onto `_wrapped`, so the class it produced was a sibling of
the class the decorator bound, not a subtype of it. Annotate anything with that name
-- graph state above all -- and every value a templated tool builds fails validation
against it. Statically the two are one type, so nothing flags it; in langgraph the
failure then lands under `loc=('state', ...)`, which is stripped as injected, leaving
the model an empty error string it retries against forever.

Neither the bound class nor a rendering of it admitted where it lived, either:
`create_model` takes `__module__` from the calling frame, so both claimed this module.
Restoring a value by importing its class -- what a checkpoint serializer does -- looked
for it here, did not find it, and handed back a bare dict.

A rendering's identity is the whole of what separates the two kinds of family, so they
are now two types rather than one with a branch. A `_ToolFamily` handle is never a
value's type: a rendering of it is the wrapped schema and stays where it is built,
which no name resolves to, since the bound name holds the fieldless handle. A
`_FamilyParam` renders onto itself and claims its own module, so a rendered value both
validates as the bound class and comes back from a checkpoint as one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
@ericeil
ericeil force-pushed the eric/family-param-identity branch from ae60174 to 4191291 Compare August 20, 2026 20:52
Stamping a rendering's __module__ as the bound class made JsonPlus look
the bound name up, but the live object was still a different class.
LangChain validates against the rendered schema and then calls the tool
with those instances, so a second construction rejects a bound value
typed as the rendering.

as_tool, tool_state_update, and tool_output now rebuild rendered
family-param instances as the decorator-bound class. What hits a
checkpoint is importable; the rendering stays the LLM schema.
Pyright types Command.update as Any | None; assert it is present
before indexing the rebound portions.
@ericeil
ericeil marked this pull request as ready for review August 20, 2026 21:18
@ericeil
ericeil requested a review from jtoman August 20, 2026 21:18
create_model was taking __module__ from this file, so JsonPlus dumped a
rendering as graphcore.tools.schemas.Portion, failed the lookup, and
restored a dict. Rebind at as_tool / Command.update papered over that.

A rendering now claims _render_onto()'s module, which for a family param
is the class the decorator bound. Restore constructs that class. The
live object can stay a subclass used as the LLM schema.
@ericeil
ericeil merged commit 54a6852 into master Aug 21, 2026
2 checks passed
@ericeil
ericeil deleted the eric/family-param-identity branch August 21, 2026 22:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants