From 5ddb7047181f60a62ef955d63cb2a1d31f3b45ff Mon Sep 17 00:00:00 2001 From: Eli Date: Wed, 2 Sep 2026 09:23:57 -0400 Subject: [PATCH 1/3] Refuse undecodable response types with a schema, not an exception (#763) `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) --- .../handlers/llm/harness/serialization.py | 43 +++++++------- ...test_handlers_llm_harness_serialization.py | 57 ++++++++++++++---- tests/test_handlers_llm_harness_toolcall.py | 59 +++++++++++++++++++ 3 files changed, 126 insertions(+), 33 deletions(-) diff --git a/effectful/handlers/llm/harness/serialization.py b/effectful/handlers/llm/harness/serialization.py index c78c97bfc..1b01cec9e 100644 --- a/effectful/handlers/llm/harness/serialization.py +++ b/effectful/handlers/llm/harness/serialization.py @@ -434,23 +434,6 @@ def _pydantic_type_str[T](ty: type[T]) -> type[T]: return ty -@dataclasses.dataclass(frozen=True) -class _NoEncoding: - """Refuses a *validation* JSON schema for a type that has no encoding.""" - - ty: typing_extensions.TypeForm - - def __get_pydantic_json_schema__(self, schema, handler): - if handler.mode == "validation": - raise pydantic.errors.PydanticInvalidForJsonSchema( - f"`{inspect.formatannotation(self.ty)}` has no `Encodable` encoding, so a " - f"value of it can be sent to the model but not decoded from the " - f"model's output. Register one with `TypeToPydanticType.register` " - f"if the model needs to produce these." - ) - return handler(schema) - - def _serialize_unencodable(value: typing.Any) -> str: """Render a value whose type has no encoding, as text. @@ -470,21 +453,39 @@ def _serialize_unencodable(value: typing.Any) -> str: @TypeToPydanticType.register(object) def _pydantic_type_base(ty: typing.Any) -> typing.Any: - """Pydantic's own handling, or a serialize-only encoding if it has none.""" + """Pydantic's own handling, or a serialize-only encoding if it has none. + + A serialize-only value reaches the model as text and nothing decodes back to + one, so the two directions get different schemas: the validation one asks for + a string no reply can satisfy, the way `_UndecodableReturn` does for a return + type left uninstantiated. Register a real encoding with + `TypeToPydanticType.register` if the model needs to produce these. + """ try: pydantic.TypeAdapter(ty) return ty except pydantic.errors.PydanticSchemaGenerationError: + name = inspect.formatannotation(ty) return typing.Annotated[ ty, pydantic.InstanceOf, pydantic.PlainSerializer( _serialize_unencodable, - return_type=typing.Annotated[ - str, pydantic.Field(description=inspect.formatannotation(ty)) + return_type=typing.Annotated[str, pydantic.Field(description=name)], + ), + pydantic.BeforeValidator( + lambda value: value, + json_schema_input_type=typing.Annotated[ + str, + pydantic.Field( + description=( + f"No decoding exists for `{name}`, so a direct reply of " + f"it cannot be decoded. Do not answer directly: call a " + f"tool that produces a final answer instead." + ) + ), ], ), - _NoEncoding(ty), ] diff --git a/tests/test_handlers_llm_harness_serialization.py b/tests/test_handlers_llm_harness_serialization.py index 4297bddfa..91c9f4d62 100644 --- a/tests/test_handlers_llm_harness_serialization.py +++ b/tests/test_handlers_llm_harness_serialization.py @@ -38,6 +38,7 @@ _TYPE_CHECK_ANCHOR_KEY, CONTENT_BLOCK_TYPES, DecodedToolCall, + _BoxedResponse, _NameAndTool, to_content_blocks, ) @@ -701,8 +702,8 @@ def test_metadata_does_not_make_an_unencodable_type_encodable(): """Orthogonality in the other direction: metadata is not a way in. A type the registry cannot encode is equally undecodable annotated, and - fails the same way -- so attaching a contract never turns a clear schema - error into something subtler. + refuses the same way -- so attaching a contract never turns a refusal into + a schema that promises the type. Stated on the *validation* schema rather than on building the adapter, since an unencodable type is still serializable (see @@ -715,10 +716,9 @@ class Widget: marker = pydantic.AfterValidator(lambda v: v) for ty in (Widget, Annotated[Widget, marker]): - with pytest.raises( - pydantic.errors.PydanticInvalidForJsonSchema, match="no `Encodable`" - ): - pydantic.TypeAdapter(Encodable[ty]).json_schema() + schema = pydantic.TypeAdapter(Encodable[ty]).json_schema() + assert schema["type"] == "string" + assert "No decoding exists" in schema["description"] # ============================================================================ @@ -1128,18 +1128,51 @@ def test_unencodable_type_serializes_but_does_not_decode(): degradation -- worst case the model reads a `repr` -- and it happens on paths that never asked the model for anything: a tool result, a value spliced into a prompt, a trace. Decoding is not, because nothing rebuilds - an arbitrary object from that text, so the validation schema refuses - instead of promising a string. + an arbitrary object from that text, so the validation schema asks for a + string that nothing satisfies rather than one that would decode. """ adapter = pydantic.TypeAdapter(Encodable[_WidgetWithRepr]) assert ( adapter.dump_python(_WidgetWithRepr(1), mode="json") == "_WidgetWithRepr(n=1)" ) assert adapter.json_schema(mode="serialization")["type"] == "string" - with pytest.raises( - pydantic.errors.PydanticInvalidForJsonSchema, match="_WidgetWithRepr" - ): - adapter.json_schema() + + validation = adapter.json_schema() + assert validation["type"] == "string" + assert "_WidgetWithRepr" in validation["description"] + with pytest.raises(pydantic.ValidationError): + adapter.validate_python("_WidgetWithRepr(n=1)") + + +@pytest.mark.parametrize( + "ty", + [_WidgetWithRepr, list[_WidgetWithRepr], tuple[int, _WidgetWithRepr]], + ids=["bare", "nested", "in-tuple"], +) +def test_unencodable_response_format_reaches_the_provider(ty): + """Refusing with a schema rather than an exception is what keeps a response + format buildable, which is the obligation `Encodable` carries: a `Skill` + returning such a type answers by calling a final-answer tool, and never gets + to if assembling the request cannot be done at all. + + However deep the unencodable type sits, the refusal is emitted at that leaf + and names it, leaving every other part its real schema. + """ + box = pydantic.create_model( + "BoxedResponse", value=Encodable[ty], __base__=_BoxedResponse + ) + schema = litellm.utils.type_to_response_format_param(box) + assert "_WidgetWithRepr" in json.dumps(schema) + + +def test_unencodable_element_does_not_block_encoding_its_container(): + """A container is sendable when its parts are. One part having no decoding + does not change that: the refusal is that element's, in one direction.""" + adapter = pydantic.TypeAdapter(Encodable[tuple[int, _WidgetWithRepr]]) + assert adapter.dump_python((7, _WidgetWithRepr(1)), mode="json", context={}) == { + "item_0": 7, + "item_1": "_WidgetWithRepr(n=1)", + } def test_unencodable_value_rendering_is_stable_across_runs(): diff --git a/tests/test_handlers_llm_harness_toolcall.py b/tests/test_handlers_llm_harness_toolcall.py index dbbbdf191..2796c6cd2 100644 --- a/tests/test_handlers_llm_harness_toolcall.py +++ b/tests/test_handlers_llm_harness_toolcall.py @@ -1163,6 +1163,65 @@ def test_generic_skill_without_binding_redirects_to_code_mode(generic_mod): assert result == [generic_mod.Item(text="be kind")] +# ============================================================================ +# Return types with no `Encodable` decoding at all +# ============================================================================ + +_UNENCODABLE_SKILL_SRC = ''' +from effectful.handlers.llm import Skill + + +class Widget: + """A type the encoding registry knows nothing about.""" + + def __init__(self, n: int) -> None: + self.n = n + + def __repr__(self) -> str: + return f"Widget({self.n})" + + +@Skill.define +def make_widget(n: int) -> Widget: + """Build a widget holding {n}.""" +''' + + +@pytest.fixture +def widget_mod(tmp_path, request): + modname = f"_widget_fixture_{request.node.name}".replace("[", "_").replace("]", "") + mod = _import_fixture(tmp_path, _UNENCODABLE_SKILL_SRC, modname) + yield mod + sys.modules.pop(modname, None) + + +def test_unencodable_return_redirects_to_code_mode(widget_mod): + # A return type with no decoding is answerable by the route an uninstantiated + # one takes: the response format asks for a string nothing satisfies, so the + # plausible-looking reply below is refused and the feedback steers the model + # to `write_and_run_body`, whose result is the answer. + from effectful.handlers.llm.harness.durability.retrying import TenacityRetryer + from effectful.handlers.llm.harness.synthesis.body import FinalBodySynthesizer + + result = _run_generic( + widget_mod, + lambda: widget_mod.make_widget(3), + [ + make_text_response(json.dumps({"value": "Widget(3)"})), + make_tool_call_response( + "write_and_run_body", + json.dumps( + {"implementation": "def make_widget(n):\n return Widget(n)\n"} + ), + ), + ], + FinalBodySynthesizer(), + TenacityRetryer(), + ) + assert isinstance(result, widget_mod.Widget) + assert result.n == 3 + + # ============================================================================ # Live model # ============================================================================ From 9816c22dff9433ab6749d01981e13b89f867a303 Mon Sep 17 00:00:00 2001 From: Eli Date: Wed, 2 Sep 2026 10:49:14 -0400 Subject: [PATCH 2/3] Don't advertise tools whose parameters cannot be decoded 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) --- .../handlers/llm/harness/serialization.py | 55 ++++++++++++++++++- ...test_handlers_llm_harness_serialization.py | 51 +++++++++++++++++ tests/test_handlers_llm_harness_toolcall.py | 19 +++++-- 3 files changed, 118 insertions(+), 7 deletions(-) diff --git a/effectful/handlers/llm/harness/serialization.py b/effectful/handlers/llm/harness/serialization.py index 1b01cec9e..8aa7a2faf 100644 --- a/effectful/handlers/llm/harness/serialization.py +++ b/effectful/handlers/llm/harness/serialization.py @@ -478,11 +478,12 @@ def _pydantic_type_base(ty: typing.Any) -> typing.Any: json_schema_input_type=typing.Annotated[ str, pydantic.Field( + title=_UndecodableReturn.__schema_title__, description=( f"No decoding exists for `{name}`, so a direct reply of " f"it cannot be decoded. Do not answer directly: call a " f"tool that produces a final answer instead." - ) + ), ), ], ), @@ -500,6 +501,31 @@ def _best_effort_schema( return {"description": inspect.formatannotation(annotation)} +def _is_decodable(annotation: typing_extensions.TypeForm) -> bool: + """Whether the model can be asked to produce a value of ``annotation``. + + False when its validation schema refuses anywhere within, and when it has no + such schema at all (`Operation`, `Term`). + + The question is put to the generated schema rather than to the type, because + an encoding that supplies its own validation schema does not delegate inward: + a synthesized `Callable` is asked for as source, so it decodes whatever its + return type is. + """ + + def refuses(node: typing.Any) -> bool: + if isinstance(node, dict): + return node.get("title") == _UndecodableReturn.__schema_title__ or any( + refuses(v) for v in node.values() + ) + return isinstance(node, list) and any(refuses(v) for v in node) + + try: + return not refuses(pydantic.TypeAdapter(Encodable[annotation]).json_schema()) # type: ignore + except Exception: + return False + + @TypeToPydanticType.register(type) @TypeToPydanticType.register(abc.ABCMeta) @TypeToPydanticType.register(GenericAlias) @@ -530,6 +556,11 @@ class _UndecodableReturn: reply cannot be decoded soundly. Do not answer directly: call a tool that produces a final answer instead.""" + # The `title` every refusing validation schema carries, so `_is_decodable` can + # recognize one without reading its prose. Deliberately not an identifier, like + # the context keys at the top of this module. + __schema_title__: typing.ClassVar[typing.Literal["$UNDECODABLE"]] = "$UNDECODABLE" + def _fail_validation(value: typing.Any) -> typing.Any: raise ValueError(inspect.getdoc(_UndecodableReturn)) @@ -540,7 +571,10 @@ def _pydantic_type_undecodable_return(ty: type[_UndecodableReturn]) -> typing.An return typing.Annotated[ str, pydantic.PlainValidator(_fail_validation, json_schema_input_type=str), - pydantic.Field(description=inspect.getdoc(_UndecodableReturn)), + pydantic.Field( + title=_UndecodableReturn.__schema_title__, + description=inspect.getdoc(_UndecodableReturn), + ), ] @@ -815,10 +849,25 @@ def _tool_description(tool: Tool, *, param_schemas: bool = False) -> str: def _serialize_name_and_tool(value: _NameAndTool) -> ChatCompletionToolParam: + """Encode ``value`` as the JSON advertisement of the tool it names. + + A tool with a parameter the model cannot produce is refused rather than + advertised: it would be offered as callable and then fail to decode every + call, spending a turn each time. Its return type is not in question -- an + unencodable result reaches the model as text -- and the expression pathway + can still call it, with real Python values. + """ name, tool = value + params = inspect.signature(tool).parameters + for param_name, param in params.items(): + if not _is_decodable(param.annotation): + raise pydantic.errors.PydanticSchemaGenerationError( + f"`{name}` cannot be advertised as JSON: no value of parameter " + f"`{param_name}` could be decoded from the model's output" + ) fields: dict[str, typing.Any] = { param_name: TypeToPydanticType().evaluate(param.annotation) - for param_name, param in inspect.signature(tool).parameters.items() + for param_name, param in params.items() } sig_model = pydantic.create_model( "Params", diff --git a/tests/test_handlers_llm_harness_serialization.py b/tests/test_handlers_llm_harness_serialization.py index 91c9f4d62..940bd5f82 100644 --- a/tests/test_handlers_llm_harness_serialization.py +++ b/tests/test_handlers_llm_harness_serialization.py @@ -39,7 +39,9 @@ CONTENT_BLOCK_TYPES, DecodedToolCall, _BoxedResponse, + _is_decodable, _NameAndTool, + _UndecodableReturn, to_content_blocks, ) from effectful.handlers.llm.harness.validation.ty import TyTypeChecker @@ -1165,6 +1167,55 @@ def test_unencodable_response_format_reaches_the_provider(ty): assert "_WidgetWithRepr" in json.dumps(schema) +@pytest.mark.parametrize("ty", [_UndecodableReturn, _WidgetWithRepr], ids=str) +def test_refusals_announce_themselves_on_the_wire(ty): + """Both refusing schemas carry the title `_is_decodable` recognizes them by + -- the one for a return type never instantiated, and the one for a type with + no encoding -- and strict-mode post-processing leaves it alone.""" + box = pydantic.create_model( + "BoxedResponse", value=Encodable[ty], __base__=_BoxedResponse + ) + schema = litellm.utils.type_to_response_format_param(box) + assert _UndecodableReturn.__schema_title__ in json.dumps(schema) + + +@pytest.mark.parametrize( + "ty,expected", + [ + (int, True), + (Image.Image, True), + (dict[str, int], True), + (_WidgetWithRepr, False), + (list[_WidgetWithRepr], False), + (tuple[int, _WidgetWithRepr], False), + (Operation, False), + ], + ids=str, +) +def test_is_decodable(ty, expected): + """Whether the model can be *asked* for a value, as opposed to shown one. + + False covers both ways a type can fail to name something the model could + send: a schema that refuses however deeply it sits, and no schema at all. + """ + assert _is_decodable(ty) is expected + + +def test_is_decodable_looks_past_an_encoding_that_replaces_its_arguments(): + """A refusal only counts where the model would actually meet it. + + `SkillBody` is asked for as source and decoded by compiling it, so its own + schema stands in for its arguments' -- which is what lets a Skill returning + an undecodable type still be answered by synthesizing one, the whole point + of refusing a direct reply. Reading the type rather than the schema it + generates gets this backwards and withdraws the tool that was the way out. + """ + from effectful.handlers.llm.harness.synthesis.body import SkillBody + + assert not _is_decodable(_WidgetWithRepr) + assert _is_decodable(SkillBody[[int], _WidgetWithRepr]) + + def test_unencodable_element_does_not_block_encoding_its_container(): """A container is sendable when its parts are. One part having no decoding does not change that: the refusal is that element's, in one direction.""" diff --git a/tests/test_handlers_llm_harness_toolcall.py b/tests/test_handlers_llm_harness_toolcall.py index 2796c6cd2..ea9dc3957 100644 --- a/tests/test_handlers_llm_harness_toolcall.py +++ b/tests/test_handlers_llm_harness_toolcall.py @@ -96,7 +96,7 @@ def dbl(x: int) -> int: from dataclasses import dataclass from effectful.handlers.llm import Agent, Skill, Tool -from effectful.ops.types import NotHandled, Operation +from effectful.ops.types import Interpretation, NotHandled, Operation calls = [] @@ -126,6 +126,12 @@ def op_tool(op: Operation) -> str: return op.__name__ +@Tool.define +def interp_tool(i: Interpretation) -> str: + """A tool whose parameter type advertises but cannot be decoded.""" + return str(len(i)) + + @Tool.define def vsum(*xs: int) -> int: """Sum any number of integers.""" @@ -600,7 +606,8 @@ def test_505_generic_tool_in_scope_does_not_break_unrelated_skill(poly_mod): assert result == "just text" -def test_json_mode_skips_unadvertisable_tool(poly_mod, caplog): +@pytest.mark.parametrize("skipped", ["op_tool", "interp_tool"]) +def test_json_mode_skips_unadvertisable_tool(poly_mod, caplog, skipped): # Under the JSON pathway a tool whose advertisement cannot be encoded is # skipped (with a warning) instead of breaking every request it is merely # in scope for. The skip happens at encoding time, in `call_assistant`'s @@ -610,6 +617,10 @@ def test_json_mode_skips_unadvertisable_tool(poly_mod, caplog): # *generic* tool still advertises there, but degraded to untyped `{}` # parameter schemas -- the #489 decode ambiguity the expression pathway # exists to fix.) + # + # Two ways a parameter can fail to describe a value the model could send: + # `op_tool`'s has no schema at all, and `interp_tool`'s has one that no + # reply satisfies. Both make the tool uncallable, so both are skipped. advertised: list[list] = [] class _SpecCapture(ObjectInterpretation): @@ -630,9 +641,9 @@ def _c(self, *args, **kwargs): assert poly_mod.grow([1, 2, 3]) == "just text" names = {spec["function"]["name"] for specs in advertised for spec in specs} assert "other_tool" in names and "extend_sequence" in names - assert "op_tool" not in names + assert skipped not in names assert any( - "op_tool" in record.message and record.levelname == "WARNING" + skipped in record.message and record.levelname == "WARNING" for record in caplog.records ) From c8630120d14edad0ea75fddef502c633a32336c9 Mon Sep 17 00:00:00 2001 From: Eli Date: Wed, 2 Sep 2026 10:53:53 -0400 Subject: [PATCH 3/3] Trim docstrings and comments in serialization.py Co-Authored-By: Claude Opus 5 (1M context) --- .../handlers/llm/harness/serialization.py | 31 ++----------------- 1 file changed, 2 insertions(+), 29 deletions(-) diff --git a/effectful/handlers/llm/harness/serialization.py b/effectful/handlers/llm/harness/serialization.py index 8aa7a2faf..3168f7bc4 100644 --- a/effectful/handlers/llm/harness/serialization.py +++ b/effectful/handlers/llm/harness/serialization.py @@ -453,14 +453,7 @@ def _serialize_unencodable(value: typing.Any) -> str: @TypeToPydanticType.register(object) def _pydantic_type_base(ty: typing.Any) -> typing.Any: - """Pydantic's own handling, or a serialize-only encoding if it has none. - - A serialize-only value reaches the model as text and nothing decodes back to - one, so the two directions get different schemas: the validation one asks for - a string no reply can satisfy, the way `_UndecodableReturn` does for a return - type left uninstantiated. Register a real encoding with - `TypeToPydanticType.register` if the model needs to produce these. - """ + """Pydantic's own handling, or a serialize-only encoding if it has none.""" try: pydantic.TypeAdapter(ty) return ty @@ -502,16 +495,7 @@ def _best_effort_schema( def _is_decodable(annotation: typing_extensions.TypeForm) -> bool: - """Whether the model can be asked to produce a value of ``annotation``. - - False when its validation schema refuses anywhere within, and when it has no - such schema at all (`Operation`, `Term`). - - The question is put to the generated schema rather than to the type, because - an encoding that supplies its own validation schema does not delegate inward: - a synthesized `Callable` is asked for as source, so it decodes whatever its - return type is. - """ + """Whether the model can be asked to produce a value of ``annotation``.""" def refuses(node: typing.Any) -> bool: if isinstance(node, dict): @@ -556,9 +540,6 @@ class _UndecodableReturn: reply cannot be decoded soundly. Do not answer directly: call a tool that produces a final answer instead.""" - # The `title` every refusing validation schema carries, so `_is_decodable` can - # recognize one without reading its prose. Deliberately not an identifier, like - # the context keys at the top of this module. __schema_title__: typing.ClassVar[typing.Literal["$UNDECODABLE"]] = "$UNDECODABLE" @@ -849,14 +830,6 @@ def _tool_description(tool: Tool, *, param_schemas: bool = False) -> str: def _serialize_name_and_tool(value: _NameAndTool) -> ChatCompletionToolParam: - """Encode ``value`` as the JSON advertisement of the tool it names. - - A tool with a parameter the model cannot produce is refused rather than - advertised: it would be offered as callable and then fail to decode every - call, spending a turn each time. Its return type is not in question -- an - unencodable result reaches the model as text -- and the expression pathway - can still call it, with real Python values. - """ name, tool = value params = inspect.signature(tool).parameters for param_name, param in params.items():