Schema black magic for tool families - #31
Merged
Merged
Conversation
ericeil
reviewed
Aug 12, 2026
Comment on lines
+144
to
+152
| for (k, v) in self._staged.model_fields.items(): | ||
| if not v.description: | ||
| continue | ||
| descr = v.asdict() | ||
| new_attrs = { | ||
| **descr["attributes"], | ||
| "description": v.description.format(*args, **kwargs) | ||
| } | ||
| new_fields[k] = (Annotated[(v.annotation, *descr["metadata"], Field(**new_attrs))], None) |
There was a problem hiding this comment.
So I asked my trusty LLM what the , None means in this tuple, and it was like "oh that makes all fields optional." It wrote me this test to show that it fails:
import pytest
from pydantic import Field, ValidationError
from graphcore.tools.schemas import ToolFamilyParams, WithImplementation, tool_family
class CheckSpecParams(ToolFamilyParams):
spec_noun: str
component_noun: str
@tool_family(CheckSpecParams)
class CheckSpec(WithImplementation[str]):
"""A tool to check the validity of the {spec_noun} in the context of {component_noun}"""
spec: str = Field(description="The {spec_noun} to check")
def run(self) -> str:
return self.spec
def test_with_template_keeps_required_fields_required():
"""with_template must not turn a required argument into an optional one.
The current create_model call passes None as each overridden field's
default (the Pydantic "make every field optional" pattern), so constructing
the specialized tool with no arguments succeeds and spec is None.
"""
specialized = CheckSpec.with_template(
spec_noun="CVL File",
component_noun="the smart contract",
)
assert specialized.model_fields["spec"].is_required()
with pytest.raises(ValidationError):
specialized()
assert specialized(spec="rule.cvl").spec == "rule.cvl"Suggested fix:
Suggested change
| for (k, v) in self._staged.model_fields.items(): | |
| if not v.description: | |
| continue | |
| descr = v.asdict() | |
| new_attrs = { | |
| **descr["attributes"], | |
| "description": v.description.format(*args, **kwargs) | |
| } | |
| new_fields[k] = (Annotated[(v.annotation, *descr["metadata"], Field(**new_attrs))], None) | |
| for (k, v) in self._staged.model_fields.items(): | |
| if not v.description or not _placeholders(v.description): | |
| continue | |
| descr = v.asdict() | |
| new_attrs = { | |
| **descr["attributes"], | |
| "description": v.description.format(*args, **kwargs) | |
| } | |
| if not v.is_required(): | |
| if v.default_factory is not None: | |
| new_attrs.setdefault("default_factory", v.default_factory) | |
| else: | |
| new_attrs.setdefault("default", v.default) | |
| new_fields[k] = Annotated[(v.annotation, *descr["metadata"], Field(**new_attrs))] |
There was a problem hiding this comment.
I mean, I guess you're not really intending this to be used this way. :)
ericeil
approved these changes
Aug 12, 2026
ericeil
left a comment
There was a problem hiding this comment.
Looks good, just see the comment I left in case it actually matters.
Adds a test that the templating process doesn't change the validation behavior of the schema and that the template strings are applied correctly. Tested it against the old buggy version; the test did fail. I also tried "accidentally" forgetting some validation metadata, the test also failed there.
ericeil
approved these changes
Aug 14, 2026
ericeil
left a comment
There was a problem hiding this comment.
Test and fix look good; re-approving
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.
Adds a new level of indirection for tool schemas: "families". These are tools that are generic in their behavior, but for LLM prompting purposes we want to vary the nouns in the schemas sent along. For example, a generic "edit the spec" tool might be present itself as "Edit your CVL spec file" to one agent, vs "Edit your foundry fuzz test" for another.
This is accomplished by using format strings in the Field descriptions and the doc string of any subclass with
WithAsyncDependeiciesorWith*Implementationand then annotating the class withtool_family(Schema).Schemais the typename of a subclass ofToolFamilyParams. These classes are phantom types that provide strong typing for the template parameters.The intended behavior is to declare:
and then annotate your tool with:
This transforms the tool into an object with a
with_template(kwarg_sig)method which then returns (a subclass of) your original type.kwarg_sigis the kwarg signature derived fromMyParams, equivalent toUnpack[MyParams]ifMyParamswas aTypedDict. Thetool_familyannotation will check that all format string params in your tool are defined byMyParams; the annotation will throw if this is not the case.NB: The formatting is not transitive. If you do:
you'll end up sending
My cool {thing}to the llm. If actual transitive formatting is needed, we can add that later.Here is a full example:
A typechecker rejects
MyTool.bind(...)orMyTool.as_tool(...); the onlyMyToolsupports iswith_templates. You can instantiate this tool family with:MyTool.with_template(spec_noun="CVL File", component_noun="the smart contract").bind(...).as_tool(...)