Skip to content

fix(structured-output): keep required nested objects required - #4391

Open
BlueX888 wants to merge 1 commit into
strands-agents:mainfrom
BlueX888:fix/prep-structured-output-drops-required-nested-field
Open

BlueX888 wants to merge 1 commit into
strands-agents:mainfrom
BlueX888:fix/prep-structured-output-drops-required-nested-field

Conversation

@BlueX888

Copy link
Copy Markdown
Contributor

Description

_flatten_schema in strands-py/src/strands/tools/structured_output/structured_output_utils.py keeps the outer property's required-ness in is_required (line 44). The loop that processes the sub-properties of an expanded nested object wrote its own result into that same local (line 57):

is_required = prop_name in required_fields          # line 44 — outer property
...
for nested_prop_name, nested_prop_value in prop_value["properties"].items():
    is_required = "required" in prop_value and nested_prop_name in prop_value["required"]   # line 57

The inner assignment rebinds the outer local, so once the loop finishes is_required holds the required-ness of the nested model's last declared field. The outer check at line 71 then reads that value. When the nested model's last field is optional, the check is false and the required nested object is left out of the tool spec's required list, so the model is never told the field is required.

Reproduced on main:

class Inner(BaseModel):
    a: int
    b: str = "default"  # optional, declared last

class Outer(BaseModel):
    inner: Inner

convert_pydantic_to_tool_spec(Outer)["inputSchema"]["json"].get("required")
# main:     None       expected: ["inner"]
# reordering Inner so its last field is required flips this to ["inner"]

Changes:

  • structured_output_utils.py: bind the nested sub-property's required-ness to a separate nested_is_required local, so the outer property's is_required survives the inner loop.
  • test_structured_output.py: add test_convert_pydantic_required_nested_object_stays_required, which converts a model with a required nested object under both field orders of the nested model and asserts required == ["inner"] in both cases.

The emitted schema is unchanged except where the bug suppressed a required entry; nothing else reads the local.

Related Issues

Fixes #4379

Documentation PR

No documentation change is needed — this is an internal fix to the schema converter; the public API is unchanged.

Type of Change

Bug fix

Testing

  • New test, pytest tests/strands/tools/test_structured_output.py -k stays_required:

    • on main without the source change: 1 failed — KeyError: 'required' at the first assertion.
    • with the change: 1 passed.
  • pytest tests/strands/tools/test_structured_output.py: 14 passed.

  • pytest tests/strands/tools/: 692 passed, 31 skipped.

  • Broader run over the structured-output call sites (tests/strands/tools/, test_agent_structured_output.py, test_streaming_structured_output.py, test_event_loop_structured_output.py, test_agent_as_tool.py): 766 passed, 31 skipped. The same selection with the source change reverted and the new test deselected gives 765 passed, 31 skipped, 1 deselected — the same set, so the only behavior that changed is the one the new test covers.

  • pytest tests/strands/agent tests/strands/event_loop tests/strands/tools (ignoring test_a2a_agent.py, which needs the optional a2a extra not installed here): 1648 passed, 31 skipped.

  • ruff check: All checks passed. ruff format --check on the two changed files: already formatted.

  • mypy src/strands/tools/structured_output/structured_output_utils.py: no errors in the changed file.

  • I ran hatch run prepare (the hatch-static-analysis env build in my environment spent ~50 minutes compiling the optional native dependencies and did not finish; I ran the equivalent ruff/mypy/pytest commands above)

Checklist

  • I have read the CONTRIBUTING document
  • I have reviewed and understand every line of code in this PR, including any generated by AI tools, and I can explain why it works
  • My change is focused and reasonably small; I have split unrelated work into separate PRs
  • I have added any necessary tests that prove my fix is effective or my feature works
  • I have updated the documentation accordingly
  • I have added an appropriate example to the documentation to outline the feature, or no new docs are needed
  • My changes generate no new warnings
  • Any dependent changes have been merged and published

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@BlueX888
BlueX888 requested a review from a team as a code owner September 18, 2026 09:56
@github-actions github-actions Bot added bug Something isn't working area-structured-output Related to the structured output api python Pull requests that update python code area-tool Tool behavior/api labels Sep 18, 2026
@arielnabavian

Copy link
Copy Markdown
Contributor

@strandly-the-agent review

@strandly-the-agent strandly-the-agent left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approve. Correct, minimal fix for #4379: the inner loop was rebinding the outer is_required, so a required nested object was dropped from required whenever the nested model's last field was optional. Renaming the loop-local fixes exactly that and nothing else. No findings.

What I verified
  • Head 7a1fbc4, diff vs merge-base is 2 files / +37 −2 (the source change is the 2-line rename at structured_output_utils.py:57-58).
  • New test fails on main with the fix reverted (KeyError: 'required') and passes on the PR head.
  • pytest tests/strands/tools/structured_output tests/strands/tools/test_structured_output.py tests/strands/agent/test_agent_structured_output.py -n auto → 65 passed.
  • ruff check / ruff format --check on both changed files: clean.
  • Side-effect probe (required-nested/optional-last, nested-with-default/required-last, nullable-nested/required-last) — only the first case changes (None → ['inner']); the other two are None on both main and the PR head, so the fix doesn't flip any optional nested object to required.
  • No TS counterpart: strands-ts builds structured-output schemas from zod and has no $ref-flattening step, so no parity change needed.

Skipped the full specialist fan-out — a two-line local rename with a deterministic before/after test doesn't warrant it.

`_flatten_schema` reused the `is_required` loop variable while
processing the sub-properties of an expanded nested object, so the
outer property's required-ness was left holding the value of the last
nested sub-property. A required nested model whose last field was
optional was then dropped from the tool spec's `required` list.

Use a separate local for the nested sub-property required-ness.
@BlueX888
BlueX888 force-pushed the fix/prep-structured-output-drops-required-nested-field branch from 7a1fbc4 to 915dd1d Compare October 8, 2026 10:14

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-structured-output Related to the structured output api area-tool Tool behavior/api bug Something isn't working python Pull requests that update python code

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] required is dropped for a required nested-model field when that nested model's last-declared field is optional

3 participants