Tool families: a rendered family parameter keeps its identity - #35
Merged
Conversation
ericeil
force-pushed
the
eric/family-param-identity
branch
2 times, most recently
from
August 20, 2026 20:29
52ff6db to
ae60174
Compare
`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
force-pushed
the
eric/family-param-identity
branch
from
August 20, 2026 20:52
ae60174 to
4191291
Compare
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.
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.
jtoman
approved these changes
Aug 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
family_paramis what makesPortionusable as an annotation inServeDishandMeal: it is declaredCallable[[type[T]], type[T]], and the class it binds really is a subclass of the one writtenabove it. So
MealandServeDishname the same type, and the value one produces is the valuethe other stores. That is the whole promise of the decorator.
The problem
It does not hold:
family_parambindsPortionto a clone built ascreate_model(..., __base__=(t, _TemplatedTool)).Rendering
ServeDishwalks its fields, finds a family in theportionannotation, and renders ittoo -- but
with_templatebuilt the rendered class ascreate_model(__base__=cls._wrapped), on theundecorated class. The rendered
Portionand the boundPortiontherefore both descend from theclass 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_paramreturnstype[T], so to a checker the two are one type. Andunder 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_errorsstrips asinjected. 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 bycls.__module__andcls.__name__, then restores it withgetattr(import_module(module), name)(**kwargs). On failureit returns the kwargs dict.
create_modeltakes__module__from the calling frame, so both the bound clone and a renderingof it claimed
graphcore.tools.schemas. That name is not there, so a rendered value comes back asa bare dict and the next read of it fails on an attribute it no longer has:
Stamping the rendering's
__module__as the bound class would make the lookup succeed, but thelive 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 handletool_familybinds. It is never a value's type, so a renderingis the wrapped schema, exactly as before.
_FamilyParam-- whatfamily_parambinds. It renders onto itself, which is what makes arendered value an instance of the bound name.
The bound class claims
t.__module__: the decorator binds it tot's name int's module, sothat 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_toolrebuilds nested family-param fields after constructing the tool (skippingInjectedState)tool_state_update/tool_outputrebuild anything going intoCommand.updateWhat hits a checkpoint is
Portion. The rendering stays the LLM schema -- templated prose, aruntime subclass -- and is never what JsonPlus has to import.
_TemplatedToolremains the sentinelmap_typematches on, andtool_family's declared returntype 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 thebound name, a value the tool built validates against that name,
as_tool/tool_state_updaterebind that value so a
JsonPlusSerializerround trip restores aPortionrather 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__)thengetattr(__name__).