Skip to content

Fall back rather than fail on non-Encodable response types - #770

Open
eb8680 wants to merge 3 commits into
worktree-issue-761-recursive-schema-refsfrom
worktree-issue-763-no-encoding-schema
Open

Fall back rather than fail on non-Encodable response types#770
eb8680 wants to merge 3 commits into
worktree-issue-761-recursive-schema-refsfrom
worktree-issue-763-no-encoding-schema

Conversation

@eb8680

@eb8680 eb8680 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Resovles #763

This PR prevents the failure in #763 by falling back to a trivial validation schema. call_assistant will still fail for such return types unless tool_choice="required", but when that is set correctly Skill calls with arbitrary return types should work as expected.

@eb8680
eb8680 requested a review from jfeser September 2, 2026 13:28
@eb8680 eb8680 linked an issue Sep 2, 2026 that may be closed by this pull request

@jfeser jfeser left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per chatgpt:

After this change, tools that take unecodable parameters will appear to be callable using json. Previously, the json encoding mode checked whether parameters are serializable and disabled the tool.

eb8680 added a commit that referenced this pull request Sep 2, 2026
Review feedback on #770. The raise that `_NoEncoding` used to make was doing
double duty: besides breaking #763, it was the signal
`LexicalToolExtractor.call_assistant` probes for when deciding whether a tool
can be offered under the JSON pathway. Removing it left a tool with an
unencodable parameter advertised as callable, and every call to it fails to
decode -- `InstanceOf` rejects whatever string the model sends -- so the model
spends a turn per attempt on a tool that was previously, correctly, withheld.

The refusal belongs one layer up rather than back in `Encodable`, which must
keep producing something safe to hand to `completion`: a response type with no
decoding still makes a request worth sending, because a final-answer tool can
answer it, while a tool parameter with no decoding makes the tool useless.
`_serialize_name_and_tool` now refuses to advertise such a tool, raising the
same `PydanticSchemaGenerationError` that `_pydantic_type_operation` raises and
that the probe already catches. Both callers get their pre-#763 behavior back:
a lexically-discovered tool is skipped with a warning, an explicitly-passed one
fails the request. Only parameters are checked -- a tool that *returns* an
unencodable value is still callable, and its result reaches the model as text.

Recognizing a refusal is `_UndecodableReturn`'s job, since it is already the
type meaning "no direct reply can be decoded". It gains a `__schema_title__`
that both refusing schemas carry, so `_is_decodable` can identify one without
matching on prose that is free to change.

`_is_decodable` asks the generated schema rather than the type. An encoding
that supplies its own validation schema does not delegate inward, so a refusal
nested in its arguments is never one the model is shown: `write_and_run_body`
takes `SkillBody[[int], Interpretation]` and is asked for source, so it decodes
whatever the skill returns. Reading the type instead finds that return type and
withdraws the very tool the #763 redirect exists to reach.

`test_json_mode_skips_unadvertisable_tool` is parametrized over both ways a
parameter can fail to name something the model could send -- no schema at all,
and a schema no reply satisfies. Only the second fails without this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eb8680

eb8680 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

OK, it should be fixed

eb8680 added a commit that referenced this pull request Sep 2, 2026
The encoding registry was designed to be extensible so that a type the
library serializes badly, or not at all, can be given an encoding without
a library-level fix. That hook was only reachable as
`TypeToPydanticType.register` on the internal handler class, so the escape
hatch was effectively undiscoverable from the public interface.

`register` is now a classmethod on `Encodable`, documented with a doctest
that starts from the failure a user actually hits and ends with the type
encoding, decoding and schematizing. It takes a `TypeForm` rather than a
`type`, since the registry also accepts unions and typing special forms,
and returns a decorator that preserves the function it decorates. The
internal registrations keep calling `TypeToPydanticType.register`; the
library registering into its own handler class is the right layering.

The developer-facing pointer in `_NoEncoding`'s error message still names
the internal class, deliberately: #770 deletes that block outright, so
editing it here would buy nothing but a merge conflict.

`Encodable` stays a `TYPE_CHECKING`-only alias, so `@Encodable.register`
in typed code needs `# type: ignore[attr-defined]`, as the example shows.
That is forced, not incidental: mypy resolves a name as either a generic
alias (usable in annotations) or an object with attributes, never both.
Replacing the alias with a class of any shape -- plain, generic, Protocol,
or a `TypeForm`-typed metaclass `__getitem__` -- makes `register` check
but breaks `field: Encodable[T]`, and mypy ignores `__class_getitem__`
entirely, so no subscript signature can rescue it. Under that ignore the
call is `Any`, so the signature documents rather than checks.

Closes #769

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jfeser pushed a commit that referenced this pull request Sep 2, 2026
The encoding registry was designed to be extensible so that a type the
library serializes badly, or not at all, can be given an encoding without
a library-level fix. That hook was only reachable as
`TypeToPydanticType.register` on the internal handler class, so the escape
hatch was effectively undiscoverable from the public interface.

`register` is now a classmethod on `Encodable`, documented with a doctest
that starts from the failure a user actually hits and ends with the type
encoding, decoding and schematizing. It takes a `TypeForm` rather than a
`type`, since the registry also accepts unions and typing special forms,
and returns a decorator that preserves the function it decorates. The
internal registrations keep calling `TypeToPydanticType.register`; the
library registering into its own handler class is the right layering.

The developer-facing pointer in `_NoEncoding`'s error message still names
the internal class, deliberately: #770 deletes that block outright, so
editing it here would buy nothing but a merge conflict.

`Encodable` stays a `TYPE_CHECKING`-only alias, so `@Encodable.register`
in typed code needs `# type: ignore[attr-defined]`, as the example shows.
That is forced, not incidental: mypy resolves a name as either a generic
alias (usable in annotations) or an object with attributes, never both.
Replacing the alias with a class of any shape -- plain, generic, Protocol,
or a `TypeForm`-typed metaclass `__getitem__` -- makes `register` check
but breaks `field: Encodable[T]`, and mypy ignores `__class_getitem__`
entirely, so no subscript signature can rescue it. Under that ignore the
call is `Any`, so the signature documents rather than checks.

Closes #769

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eb8680 eb8680 added the blocked label Sep 2, 2026
@eb8680

eb8680 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Let's land #771 before this, then I'll update it to get rid of WithJsonSchema as in #771

eb8680 and others added 3 commits September 2, 2026 19:43
`Encodable[T]` carries one obligation: whatever it produces must be safe to
hand to `completion`. `_NoEncoding` broke it. For a type with no decoding it
raised `PydanticInvalidForJsonSchema` when asked for a *validation* schema,
and nothing asks for one until litellm converts the response format -- on the
request path, where the exception came back out as `APIConnectionError:
OpenAIException`. So any `Skill` returning such a type (`-> Interpretation`,
an arbitrary class, or a list/dataclass/tuple holding one) died while
assembling its first request, naming neither the skill nor the annotation,
even when the call was perfectly answerable: `write_and_run_body` advertises
cleanly for these, since a return type reaches the model only through
`_best_effort_schema`, which degrades.

Refuse the way `_UndecodableReturn` already does for a return type left
uninstantiated -- a strict-legal string schema that no reply satisfies, whose
description redirects the model to a final-answer tool. Two situations with
identical semantics no longer get opposite treatments.

That leaves `_NoEncoding` with nothing to be. A mode-conditional schema is
exactly what `WithJsonSchema(mode=...)` is, and the fallback already carried
one for the serialization side, so the class goes and a second `WithJsonSchema`
takes its place. `_UndecodableReturn` is untouched and does not merge with it:
it is a *type* substituted into an annotation and checked by identity in
`call_assistant`, standing in for a type never determined, so it maps to
`Annotated[str, ...]` with no real value behind it. This annotates a real type
that must keep serializing real values.

The `InstanceOf` already in the chain does the rejecting, preserving the
asymmetry that matters: a string from the model fails, a real Python value
still validates, so the tool this redirects to can return one.

A second symptom falls out. `_pydantic_type_tuple` builds its stand-in schema
eagerly, in validation mode, at `Encodable[...]`-construction time, so
`Encodable[tuple[int, Interpretation]]` could not previously be constructed at
all -- breaking `call_tool` for a tool returning such a tuple in the *send*
direction, where every part serializes fine. Nothing raises now, so the branch
needed no change.

`call_assistant` is unchanged; its `_UndecodableReturn`-and-no-tools check
stays the only one there.

Two existing tests pinned the raise and now pin the schema. New coverage: the
response format converts for `completion` (bare, nested in a list, inside a
tuple), a container with an unencodable element still serializes, and a Skill
returning one answers end to end through `write_and_run_body`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback on #770. The raise that `_NoEncoding` used to make was doing
double duty: besides breaking #763, it was the signal
`LexicalToolExtractor.call_assistant` probes for when deciding whether a tool
can be offered under the JSON pathway. Removing it left a tool with an
unencodable parameter advertised as callable, and every call to it fails to
decode -- `InstanceOf` rejects whatever string the model sends -- so the model
spends a turn per attempt on a tool that was previously, correctly, withheld.

The refusal belongs one layer up rather than back in `Encodable`, which must
keep producing something safe to hand to `completion`: a response type with no
decoding still makes a request worth sending, because a final-answer tool can
answer it, while a tool parameter with no decoding makes the tool useless.
`_serialize_name_and_tool` now refuses to advertise such a tool, raising the
same `PydanticSchemaGenerationError` that `_pydantic_type_operation` raises and
that the probe already catches. Both callers get their pre-#763 behavior back:
a lexically-discovered tool is skipped with a warning, an explicitly-passed one
fails the request. Only parameters are checked -- a tool that *returns* an
unencodable value is still callable, and its result reaches the model as text.

Recognizing a refusal is `_UndecodableReturn`'s job, since it is already the
type meaning "no direct reply can be decoded". It gains a `__schema_title__`
that both refusing schemas carry, so `_is_decodable` can identify one without
matching on prose that is free to change.

`_is_decodable` asks the generated schema rather than the type. An encoding
that supplies its own validation schema does not delegate inward, so a refusal
nested in its arguments is never one the model is shown: `write_and_run_body`
takes `SkillBody[[int], Interpretation]` and is asked for source, so it decodes
whatever the skill returns. Reading the type instead finds that return type and
withdraws the very tool the #763 redirect exists to reach.

`test_json_mode_skips_unadvertisable_tool` is parametrized over both ways a
parameter can fail to name something the model could send -- no schema at all,
and a schema no reply satisfies. Only the second fails without this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@eb8680
eb8680 force-pushed the worktree-issue-763-no-encoding-schema branch from 13a75ee to c863012 Compare September 2, 2026 23:48
@eb8680
eb8680 changed the base branch from master to worktree-issue-761-recursive-schema-refs September 2, 2026 23:49
@eb8680 eb8680 removed the blocked label Sep 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

call_assistant errors on _NoEncoding response types

2 participants