Skip to content

fix(smolagents): restore nullable propagation and take the type from anyOf - #90

Open
Hrafz wants to merge 1 commit into
grll:mainfrom
Hrafz:fix/smolagents-required-and-anyof-type
Open

fix(smolagents): restore nullable propagation and take the type from anyOf#90
Hrafz wants to merge 1 commit into
grll:mainfrom
Hrafz:fix/smolagents-required-and-anyof-type

Conversation

@Hrafz

@Hrafz Hrafz commented Aug 10, 2026

Copy link
Copy Markdown

fix(smolagents): restore nullable propagation and take the type from anyOf

Problem

SmolAgentsAdapter.adapt() copies input_schema["properties"] into the smolagents tool
and never reads inputSchema["required"], so the schema the model sees lists every MCP
parameter as mandatory — including the ones the server declared optional. It also
force-writes "type": "string" onto any property with no top-level type, so an
anyOf-typed parameter such as int | None is advertised as a string and a
Literal[...] | None loses its enum.

The required half is a regression, not a gap. _generate_tool_inputs used to set
inputs[k]["nullable"] = "True", added in #11 ("Optional argument support for smolagents",
merged 2025-03-05) after you wrote on #10:

The fact that we should support nullable with mcpadapt sounds reasonable

Commit 900880c1 (#23, add-google-genai-support, merged 2025-03-29) replaced that helper
with a direct inputs=input_schema["properties"] copy, and the nullable handling went
with it. test_optional_sync kept passing because it asserts on call results, and
forward(*args, **kwargs) still works — so nothing caught it.

Against the repo's own optional-parameter fixture server:

--- echo_tool_optional (text: str | None = None) ---
raw inputSchema:                                       no "required" key
tool.inputs today:             {"text": {..., "type": "string"}}      # no "nullable"
get_tool_json_schema(tool)["parameters"]["required"]:   ["text"]

--- three(req: str, opt_default: str = "empty", opt_none: str | None = None) ---
adapted tool                          -> required = ["req", "opt_default", "opt_none"]
native @tool with the same signature  -> required = ["req"]
validate_tool_arguments(adapted, {"req": "a"})  -> ValueError: Argument opt_default is required
validate_tool_arguments(native,  {"req": "a"})  -> accepted

Scope, measured

  • Direct calls are unaffected. three(req="a") returns normally today and
    test_optional_sync passes on unmodified main. What is wrong is the schema advertised
    to the model and ToolCallingAgent's argument validation.
  • to_code_prompt() is byte-identical between main and this branch for the required
    aspect, so CodeAgent — which every example in the docs uses — is not affected. The
    impact is on tool-calling models.
  • default and anyOf do survive per-property today; what is dropped is the top-level
    required array. The resulting schema is self-contradictory rather than
    optionality-free: it carries "default": null on a property it simultaneously lists as
    required.
  • The fix reproduces the framework's own convention rather than inventing one: a native
    smolagents @tool with a defaulted argument also emits
    {"type": "string", "nullable": true} and also accepts None for it.

This also fixes #68

#68 reports Literal[...] | None reaching smolagents with its type unresolved. On current
main the raw inputSchema for such a parameter is:

"stage": {
  "anyOf": [{"enum": ["development", "production", "retired"], "type": "string"},
            {"type": "null"}],
  "default": null,
  "title": "Stage"
}

There is no top-level type, so the current code stamps "string" and the enum is left
buried in anyOf where smolagents does not look. Taking the type — and the enum — from
the first non-null anyOf branch resolves that case. There is a test for exactly the shape
in the issue.

Changes

  • Compute the required set from the resolved inputSchema and mark every property not in
    it with "nullable": True.
  • When a property has no top-level type, take it from the first non-null anyOf branch
    instead of defaulting to "string", carrying that branch's enum when present.
  • Three tests asserting on tool.inputs directly rather than on call results: one against
    the repo's own optional-parameter fixture server, one for int | None, one for
    Literal[...] | None. The first two fail on current main.

src/mcpadapt/smolagents_adapter.py +21/−3, tests/test_smolagents_adapter.py +80.

Known incompleteness

For text: str | None with required: ["text"] — required and nullable — this branch
leaves nullable unset where native smolagents marks it nullable: true. main behaves
the same way, so it is not a regression, but this is not full native parity and I would
rather say so than have you find it. That case looks like what #56 is aimed at, in
utils/modeling.py; this PR does not touch that file and the two should not conflict.

Related open PRs, checked for overlap

#56 (utils/modeling.py, required-but-nullable) and #86/#83 (no-input schemas, #78) touch
adjacent behaviour but different code paths; #85 is $ref resolution. None of them
restores nullable propagation or resolves anyOf types in smolagents_adapter.py.

…anyOf

SmolAgentsAdapter.adapt() copies inputSchema["properties"] into the
smolagents tool without reading inputSchema["required"], so the schema the
model sees marks every MCP parameter mandatory, including ones the server
declared optional. ToolCallingAgent then refuses a call that omits a
parameter the server said was optional.

This is a regression. _generate_tool_inputs set inputs[k]["nullable"] in grll#11;
900880c (grll#23) replaced it with a direct properties copy and the nullable
handling went with it. test_optional_sync kept passing because it asserts on
call results, and direct calls still work -- only the advertised schema is
wrong.

Compute the required set from the resolved inputSchema and mark every
property outside it nullable. Where a property has no top-level "type",
take it from the first non-null anyOf branch instead of defaulting to
"string", carrying that branch's enum. That also covers grll#68: a
Literal[...] | None parameter currently arrives as a bare "string" with its
enum buried in anyOf.

Three tests assert on tool.inputs directly rather than on call results.
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.

Feature Request: Support parsing typing in anyOf for smolagents tools

1 participant