Skip to content

Feat: structured output - #4207

Merged
VascoSch92 merged 23 commits into
OpenHands:mainfrom
luciobaiocchi:feat/2566-structured-output
Aug 6, 2026
Merged

Feat: structured output#4207
VascoSch92 merged 23 commits into
OpenHands:mainfrom
luciobaiocchi:feat/2566-structured-output

Conversation

@luciobaiocchi

@luciobaiocchi luciobaiocchi commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

HUMAN:

Implemented a general version of #4116 after @VascoSch92 suggested it. Credit also to his original design idea #2808 remained unfinished.


AGENT:

Why

#2566 asks for first-class structured output. Today, getting reliably
formatted responses out of an agent means manual prompting plus brittle
post-processing. @VascoSch92 implemented the mechanism in #2808 — reviewed
favorably, never landed for lack of time — and offered the final pass to me.
This PR completes that work: his three commits rebased onto current main
(authorship preserved), plus a fix for the known meta-field collision issue.

Summary

  • Attach a Pydantic model to any tool spec, no subclassing:
    Tool(name="FinishTool", params={"response_schema": ProjectFacts}).
    The model's fields are merged into the action schema sent to the LLM
    (_create_action_type_with_schema, cached per (action_type, schema) pair),
    validated on receipt in action_from_arguments, and recoverable typed via
    parse_response() / parse_last_response().
  • The registry pops response_schema from spec params before calling the
    tool's create() (factories with fixed signatures, e.g. FinishTool.create,
    never see it) and applies it via set_response_schema() — a model_copy,
    consistent with set_executor().
  • response_schema is runtime-only (SkipJsonSchema, exclude=True): it never
    crosses the serialization boundary, so persisted events/specs are unchanged
    and older readers are unaffected. Tool.params drops class values on dump.
  • New in this pass: response-schema fields named summary or
    security_risk are rejected with an explicit ValueError. The SDK injects
    meta-fields with those names into every action schema after the merge: a
    user field would be silently absorbed as the event summary (summary) or
    redefined and swallowed by the risk-analyzer flow (security_risk).
  • Zero cost when unused: every hook is behind if response_schema is None.

Issue Number

Closes #2566. Supersedes / completes #2808.

How to Test

uv run pytest tests/sdk/tool/test_response_schema.py -v   # 19 passed
uv run pytest tests/sdk/tool tests/cross -q               # 562 passed, 1 skipped

19 tests cover: schema extension, payload validation (accept/reject), nested
Pydantic models, class-creation caching, spec serialization dropping class
values, parse_last_response across multiple tools, executor unchanged,
tool-without-schema unchanged, action/schema field collision, and the two
reserved meta-field names. Full tests/sdk/tool + tests/cross suites pass
(verified on two machines); tests/sdk/agent passes except one failure
(test_acp_agent.py::…::test_gemini_046_uses_set_session_model) reproduced on
unmodified main, i.e. pre-existing. pre-commit hook set (ruff, pyright,
pycodestyle, import rules, tool registration) clean on all changed files.

See OpenHands/docs#668 for the runnable end-to-end example.

Video/Screenshots

Both directions of the mechanism:

Schema attached to TerminalTool — the model must justify every command:

[Terminal commands with rationale]
  $ ls -F
    purpose:          List the contents of the current directory to understand the repository structure.
    expected_outcome: A list of files and directories in the current working directory.

Schema attached to FinishTool — the final answer comes back as a typed
object via parse_last_response() (Pydantic-validated, no text parsing):

[Finish]
  description: The OpenHands Software Agent SDK is a set of Python and REST APIs for building agents that work with code, supporting various tasks from simple maintenance to complex refactoring, and offering flexible deployment options for workspaces.
  - The OpenHands Software Agent SDK provides Python and REST APIs for building code-working agents.
  - It supports diverse tasks, from simple README generation to complex refactoring and dependency updates.
  - Agents can operate in local or ephemeral workspaces (Docker/Kubernetes) via the Agent Server.

EXAMPLE_COST: 0.01012643

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

  • Commits 1–3 are @VascoSch92's original feat(tool): structured output via response_schema on any tool #2808 work, cherry-picked with
    authorship preserved; they applied onto current main without conflicts.
    Commit 4 adds the reserved-name guard, renumbers the example to 56_
    (48 was taken), and updates its NOTE.
  • Eval impact: this touches action_from_arguments / to_mcp_tool, i.e.
    the path of every tool call. The no-schema fast path is covered by
    test_finish_tool_without_schema_is_unchanged; happy to have the eval suite
    run before merge if maintainers want the extra confidence — guidance on how
    to trigger it welcome.

Documentation

Companion docs PR: OpenHands/docs#668.

Review update (d7c3b53)

Structured output is now runtime-only on actions and is reconstructed from the existing persisted tool_call.arguments field, keeping the event wire shape readable by older SDKs. Response schema merging also preserves validation constraints and rejects schemas without named properties.

Review update (529e292)

Preserved additionalProperties: false in the advertised schema and made parse_last_response() side-effect free. Validation: 214 SDK tool tests and pre-commit passed.

VascoSch92 and others added 5 commits July 23, 2026 19:48
Port follow-ups on top of OpenHands#2808:
- reject response_schema fields named 'summary' or 'security_risk': the
  SDK injects meta-fields with those names into every action schema after
  the response-schema merge, so a user field would be silently shadowed on
  the way out (risk) or double as the event summary on the way back
- renumber the example to 56_ (48_ was taken by conversation_fork)
- update the example NOTE: the reserved names are now enforced, not a
  convention
Comment thread openhands-sdk/openhands/sdk/tool/tool.py Outdated
Comment thread openhands-sdk/openhands/sdk/tool/tool.py Outdated
Co-authored-by: Vasco Schiavo <115561717+VascoSch92@users.noreply.github.com>
@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor
  ✅ **PR Artifacts Cleaned Up**

  The `.pr/` directory is no longer present.

Address review: collapse the reverse-scan loop into next() over a
generator expression.

Co-authored-by: Vasco Schiavo <115561717+VascoSch92@users.noreply.github.com>

@VascoSch92 VascoSch92 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Moreover, could you make the comments and docstring minimal and not verbose. Thanks

Comment thread openhands-sdk/openhands/sdk/tool/tool.py Outdated
Comment thread openhands-sdk/openhands/sdk/tool/tool.py Outdated
Comment thread openhands-sdk/openhands/sdk/tool/spec.py Outdated
Comment thread openhands-sdk/openhands/sdk/tool/registry.py Outdated
Comment thread openhands-sdk/openhands/sdk/tool/tool.py Outdated
Comment thread openhands-sdk/openhands/sdk/tool/tool.py Outdated
Comment thread openhands-sdk/openhands/sdk/tool/tool.py Outdated
Comment thread openhands-sdk/openhands/sdk/tool/tool.py Outdated
Comment thread openhands-sdk/openhands/sdk/tool/tool.py Outdated
Comment thread openhands-sdk/openhands/sdk/tool/tool.py Outdated
luciobaiocchi and others added 2 commits July 28, 2026 20:39
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
@VascoSch92
VascoSch92 requested a review from all-hands-bot July 29, 2026 05:07
@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Requested reviewer: @all-hands-bot
Review request event: 28638655394 at 2026-07-29T05:07:32Z
Head commit: 90933d7c1e92059ba6ba0f6d5b9a1abd07bed523
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/bf459c08-842d-4ca3-b5b2-eaf6d42c7c42

This comment was posted by an AI agent (OpenHands).

@all-hands-bot all-hands-bot 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.

🔴 Needs improvement

[CRITICAL ISSUES]

  • The new persisted Action.structured_output field is not readable by older SDKs. I reproduced a structured FinishAction event from this head and loaded it with openhands-sdk==1.36.0; Event.model_validate_json() fails with action.structured_output Extra inputs are not permitted. This contradicts the PR's compatibility claim and creates a version-skew/resume break for older readers.
  • The LLM-facing response schema drops valid constraints before advertising it, so outputs can satisfy the advertised contract and still fail runtime validation. See the inline finding.

[TESTING / VALIDATION GAPS]

  • check-examples currently fails because examples/01_standalone_sdk/56_structured_output.py is undocumented. The SDK package guidance requires a corresponding docs PR and cross-reference; none is linked here.
  • This changes core tool-calling and action-event behavior, but there is no completed eval-monitor run with human confirmation. Per repository policy, a human maintainer should decide after lightweight evals. The focused structured-output/adjacent suite passed locally (58 tests); the current sdk-tests failure is an MCP stdio Connection closed failure rather than a structured-output assertion.

[RISK ASSESSMENT]

  • [Overall PR] ⚠️ Risk Assessment: 🟡 MEDIUM. The design addresses a real need and the revised static action kind fixes the earlier persistence blocker, but this still changes the serialized event contract and every structured tool-call validation path. The compatibility and schema-contract defects should be fixed before merge, followed by lightweight eval validation.

VERDICT:
Needs rework: Fix event compatibility and preserve the advertised response-schema contract; add/link the required docs update.

KEY INSIGHT:
Structured output must preserve one identical contract across LLM schema generation, runtime validation, persistence, and version-skewed readers.

Improve this review? If any feedback above seems incorrect or irrelevant to this repository, you can teach the reviewer to do better:

  1. Add a .agents/skills/custom-codereview-guide.md file to your branch (or edit it if one already exists) with the /codereview trigger and the context the reviewer is missing (e.g., "Security concerns about X do not apply here because Y"). See the customization docs for the required frontmatter format.
  2. Re-request a review - the reviewer reads guidelines from the PR branch, so your changes take effect immediately.
  3. When your PR is merged, the guideline file goes through normal code review by repository maintainers.

Resolve with AI? Install the iterate skill in your agent and run /iterate to automatically drive this PR through CI, review, and QA until it's merge-ready.

Was this review helpful? React with 👍 or 👎 to give feedback.

This review was created by an AI agent (OpenHands) on behalf of the repository reviewer.

Comment thread openhands-sdk/openhands/sdk/tool/schema.py Outdated
Comment thread openhands-sdk/openhands/sdk/tool/tool.py
Co-authored-by: openhands <openhands@all-hands.dev>
@luciobaiocchi

Copy link
Copy Markdown
Contributor Author

Implemented the latest review fixes in d7c3b53 and resolved both threads.

Validation:

  • focused structured-output suite: 27 passed
  • tool + cross suites: 581 passed, 1 skipped
  • pre-commit: passed
  • cross-version check: a newly serialized structured action loads with openhands-sdk==1.36.0

Companion docs: OpenHands/docs#668. The optional check-examples job cannot see a fork branch (it only checks OpenHands/docs/) and will remain red until that docs PR is merged or the branch is created upstream; this account cannot push to OpenHands/docs. The sdk-tests CI failure is unrelated timing flakiness in tests/sdk/io/test_filestore_cache.py::test_cache_performance_repeated_reads (5627 other tests passed). I do not have repository permission to rerun that job or apply the review-this label. A human maintainer still needs to run/confirm the requested lightweight eval.

Co-authored-by: openhands <openhands@all-hands.dev>
@luciobaiocchi

Copy link
Copy Markdown
Contributor Author

Current status on a7a662e: all CI checks pass, including sdk-tests, tools-tests, Windows tests, all three agent-server binary builds, coverage, pre-commit, and the review-thread gate. The runnable example was removed from the SDK diff to avoid the fork-only docs branch limitation; the companion documentation remains in OpenHands/docs#668. Ready for human review and the requested eval confirmation.

Copy link
Copy Markdown
Member

Conflicts resolved and merged main into the branch (78f9ecd).

The only conflicts were in dependency files:

  • openhands-sdk/pyproject.toml — kept the new jsonschema>=4.23.0 requirement alongside main's bump to joserfc>=1.6.8.
  • uv.lock — mirrored the same in requires-dist; uv lock --check passes (resolved locked versions: jsonschema 4.23.0, joserfc 1.6.8).

No source-code conflicts — the structured-output changes merged cleanly.

Validation:

  • tests/sdk/tool/test_response_schema.py: 32 passed
  • tests/sdk/tool: 214 passed
  • import sanity check: jsonschema 4.23.0, joserfc 1.6.8, ToolDefinition.set_response_schema intact

The PR now reports mergeable: true.

This comment was posted by an AI agent (OpenHands) on behalf of the user.

@openhands-ai

openhands-ai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Done. I've resolved the merge conflicts on PR #4207.

Summary

The branch feat/2566-structured-output was out of date with main and had conflicts. I merged the latest main into it and resolved the conflicts.

What conflicted: Only two dependency files — no source-code conflicts. The structured-output implementation merged cleanly.

  • openhands-sdk/pyproject.toml — the PR added jsonschema>=4.23.0 while main bumped joserfc from >=1.0.0 to >=1.6.8. I kept both: the new jsonschema dependency plus main's newer joserfc pin.
  • uv.lock — applied the same resolution in requires-dist. uv lock --check confirms the lockfile is consistent (resolved to jsonschema 4.23.0, joserfc 1.6.8).

Validation:

  • tests/sdk/tool/test_response_schema.py: 32 passed
  • tests/sdk/tool: 214 passed
  • Import sanity check confirmed jsonschema 4.23.0, joserfc 1.6.8, and ToolDefinition.set_response_schema intact

(The broader tests/cross suite couldn't be run to completion here because some of those tests spawn/kill tmux, which repeatedly tore down the agent's own shared tmux session — a sandbox infrastructure conflict, not a merge problem. The affected files weren't touched by the merge.)

Result: Merge commit 78f9ecd pushed to luciobaiocchi/software-agent-sdk:feat/2566-structured-output. The PR now reports mergeable: true (previously dirty/conflicting). I posted a summary comment: #4207 (comment)

The remaining "blocked" state just reflects pending required reviews/checks, not conflicts.

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Head commit: 78f9ecdfa74052b29b7dcc2cd8b9d7d03a9aea73
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/2c49ec6f-eecf-4c6a-841d-17dd59e7eac4

This comment was posted by an AI agent (OpenHands).

@all-hands-bot all-hands-bot 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.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Review: Feat: structured output (head 78f9ecd)

I reviewed the latest head against the findings from the three prior reviews (two all-hands-bot, one enyst). All previously identified blocking issues are resolved on this head:

  • Persistence compatibility: _structured_output is a PrivateAttr on Action that never crosses the serialization boundary. I confirmed via test_action_event_roundtrips_with_static_kind that serialized events contain neither structured_output nor a synthetic action subclass, and that Event.model_validate_json restores a clean FinishAction with structured_output is None.
  • additionalProperties: false propagation: _merge_response_schema now copies additionalProperties: false from the response schema into the merged LLM-facing schema. Verified with test_response_schema_preserves_additional_properties_false.
  • parse_last_response side-effect-free: it calls event.action.model_copy() before setting _structured_output, so the caller's event is not mutated. The round-trip test asserts restored.action.structured_output is None both before and after the call.
  • Unsupported top-level schema keywords: _response_tool_schema uses an explicit allowlist (_SUPPORTED_RESPONSE_SCHEMA_KEYS) and rejects anything outside it (e.g. dependentRequired), so the advertised and validated schema contracts cannot diverge via unsupported object-level semantics.
  • Dynamic fields: additionalProperties values other than None/False are rejected, and schemas without named properties are rejected.
  • Reserved meta-field names (kind, security_risk, structured_output, summary) are rejected at set_response_schema time using the expanded property set.
  • Field collision is checked in both set_response_schema and _merge_response_schema.

I verified the alias edge case directly: a Pydantic model with Field(alias="myFoo") advertises the alias in the LLM-facing schema, _split_response_arguments validates the alias keys (default model_validate), dumps to field names via model_dump(mode="json"), and parse_response correctly re-validates with by_name=True to match the field-name keys. The by_name=True is essential here — without it, parse_response would fail on aliased models because model_validate defaults to expecting alias keys.

All 214 tests/sdk/tool/ tests pass, including the 32 in test_response_schema.py.

No blocking correctness or security defects found

Remaining items (non-blocking)

  1. Missing integration-test label. This PR modifies agent dispatch (_get_action_event now runs fix_malformed_tool_arguments against tool.response_schema before tool execution) and tool schema generation — the path of every tool call. Per AGENTS.md TESTING guidance, changes to tool descriptions or agent decision logic should add the integration-test label so benchmark impact is verified. This has been flagged in all three prior reviews and remains unaddressed. Please add the label before merge.

  2. Stale PR description. The "How to Test" and "Video/Screenshots" sections reference examples/01_standalone_sdk/56_structured_output.py, but that file does not exist in the diff (deferred to the companion docs PR). Update the PR description to avoid pointing reviewers at a non-existent file.

  3. _response_schema_json is recomputed on every tool call (tool.py:518). For a Pydantic model, this calls model_json_schema() on each invocation of _split_response_arguments, which is not free. The no-schema fast path is unaffected, but for tools with a response_schema this runs on every action. Consider caching the normalized schema on the tool instance (e.g. a one-time normalization in set_response_schema).

Risk assessment

MEDIUM. The design is sound and all prior blocking issues are resolved. Risk remains medium because this touches the path of every tool call (action_from_arguments / _get_tool_schema / to_mcp_tool), and the repository-mandated benchmark signal (integration-test) has not been recorded.

Verdict: No blocking correctness or security defects. Recommend adding the integration-test label and running the benchmark gate before merge.

Comment thread openhands-sdk/openhands/sdk/tool/tool.py
Comment thread openhands-sdk/openhands/sdk/agent/agent.py
_response_schema_json() was called on every action_from_arguments()
invocation, regenerating the Pydantic JSON schema each time. The no-schema
fast path was unaffected, but tools with a response_schema paid the cost on
every call. Now the normalized schema is computed once in set_response_schema
and stored in a PrivateAttr, with a fallback for older instances.

Co-authored-by: openhands <openhands@all-hands.dev>
…teAttr

The previous per-instance PrivateAttr cache went stale after model_copy:
- set_response_schema(None) left a non-empty cache on the no-schema copy
- model_copy(update={"response_schema": X}) bypassed set_response_schema
  and reused the OLD schema, silently routing fields to the wrong model

Move the cache to a module-level dict keyed by the immutable Pydantic class
(matching the existing _action_types_with_* pattern) inside _response_schema_json.
Pydantic model_json_schema() now runs at most once per class with zero
staleness risk; dict schemas keep the cheap deepcopy path. Drop the
PrivateAttr field entirely, so the cache can never diverge from
response_schema.

Co-authored-by: openhands <openhands@all-hands.dev>
@OpenHands OpenHands deleted a comment from all-hands-bot Aug 5, 2026
@OpenHands OpenHands deleted a comment from all-hands-bot Aug 5, 2026
@VascoSch92 VascoSch92 added the integration-test Runs the integration tests and comments the results label Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Hi! I started running the integration tests on your PR. You will receive a comment with the results shortly.

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🚦 CI is currently failing on this PR's latest commit.

Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request @all-hands-bot as a reviewer to have it reviewed regardless of CI status.)

This is an automated check - no AI was used to generate this comment.

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Head commit: 216995ffa44092cf9ee3fc0ffd3d9d78241e1224
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/5c2027f3-df46-4b10-9617-e57d22ac0453

This comment was posted by an AI agent (OpenHands).

@all-hands-bot all-hands-bot 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.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Summary

This PR implements first-class structured output by attaching a Pydantic model (or JSON Schema dict) to any tool spec via response_schema. The model's fields are merged into the LLM-facing action schema, validated on receipt in action_from_arguments, and recoverable as a typed object through parse_response() / parse_last_response(). The design is clean and aligns well with the SDK's stateless-Pydantic-model principle: response_schema and structured_output are both runtime-only (SkipJsonSchema, exclude=True / PrivateAttr), so persisted events and tool specs are unchanged and older readers are unaffected. The zero-cost-when-unused guards (if self.response_schema is None) keep the hot path untouched, and parse_last_response() reconstructs structured output from the already-persisted tool_call.arguments rather than relying on a new persisted field, which is the right call for backward compatibility.

I verified the core paths by exercising them directly: the $ref expansion handles circular references, the reserved-field check correctly catches aliased collisions (e.g. Field(alias="summary") is rejected because the advertised schema property key is summary), fix_malformed_tool_arguments works correctly with both Pydantic-model and dict-schema response schemas, and optional response fields omitted by the LLM are filled with defaults. The by_name=True in parse_response is safe because it accepts both field-name and alias keys.

Risk Assessment: Low-Medium

The PR touches action_from_arguments / to_mcp_tool / _get_tool_schema, i.e. the path of every tool call, so the integration-test label is appropriate. The no-schema fast path is guarded and covered by test_finish_tool_without_schema_is_unchanged. The new jsonschema dependency is a well-known, stable package.

Actionable Finding: Merge drift reverts unrelated work

The diff between the PR head and the current main tip (06a7d72) includes changes that are not part of this feature and appear to be artifacts of the branch not being rebased onto the latest main. Inline comments could not be attached because the affected files are deletions/reversions, so the findings are listed here:

  1. Version downgrades in all four pyproject.toml files (openhands-sdk, openhands-tools, openhands-workspace, openhands-agent-server) from 1.40.1 to 1.40.0. This would regress the published version if merged as-is.
  2. Deletion of openhands-agent-server/openhands/agent_server/canvas_extensions/ (manifest module + __init__, ~178 lines) and the corresponding tests under tests/agent_server/canvas_extensions/. These were added in #4361 ("Canvas Extensions manifest and containment [1/4]").
  3. Reversion of the accumulated-LLM-cost completion callback (#4311): BaseWorkspace.register_cost, accumulated_cost, _send_completion_callback, LocalWorkspace.__exit__ / RemoteWorkspace.__exit__ overrides, and the associated tests under tests/sdk/workspace/, tests/conversation/, and tests/workspace/ are removed.

These reverts are almost certainly unintentional. Before merging, rebase the branch onto the current main tip so only the structured-output changes remain in the diff. The structured-output implementation itself looks correct and complete.

@all-hands-bot

Copy link
Copy Markdown
Collaborator

🤖 OpenHands is reviewing this PR.

Head commit: a5e549d97e3eb57c2d803269da303c870fbbe094
View the conversation: https://oss-agent-canvas.ngrok.dev/conversations/a68e10f0-fa5d-4144-b4d9-a6d00288b637

This comment was posted by an AI agent (OpenHands).

@all-hands-bot all-hands-bot 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.

This review was created by an AI agent (OpenHands) on behalf of the repository maintainers.

Summary

Reviewed the structured-output feature: a Pydantic model or JSON Schema dict attached to a tool via params={"response_schema": ...}. The schema fields are merged into the LLM-facing action schema, split out on receipt into Action._structured_output (a PrivateAttr), validated, and recovered typed via parse_response() / parse_last_response().

Overall this is a clean, well-scoped implementation. I verified the test suite (34 tests pass), pre-commit (ruff/pyright/pycodestyle/import-rules/tool-registration all clean on changed files), and several edge cases by hand (aliased fields, nested models, dict-schema required-field enforcement, $ref expansion, round-trip through persisted specs). The design choices are sound:

  • Zero cost when unused — every new hook is behind if response_schema is None, and _merge_response_schema / to_mcp_tool early-return unchanged on the no-schema path, so the hot path for plain tools is unaffected.
  • Persistence/wire shape unchangedresponse_schema is SkipJsonSchema+exclude=True on ToolDefinition, and _structured_output is a PrivateAttr, so persisted events/specs are byte-for-byte unchanged and older SDKs keep reading them. parse_last_response reconstructs structured output from the already-persisted tool_call.arguments, which I confirmed still carries the response fields (it is serialized before _extract_security_risk/_extract_summary pop meta fields).
  • Reserved-field guard is thorough — rejecting kind, security_risk, structured_output, and summary prevents the silent absorption the PR calls out. The action-field collision check and the additionalProperties/named-properties/whitelist validation on dict schemas are appropriately defensive.

Risk assessment: Low

The change is opt-in and backward-compatible. The residual risk is test coverage on the composed agent path (see below) and the eval impact the author already flagged — the integration-test label is appropriate.

Findings

Inline

  • tool.py: the per-class JSON-schema cache is unguarded while its sibling caches use _action_type_lock, and the comment's "at most once per class" guarantee isn't actually enforced under concurrency. Low severity (GIL-safe + idempotent in CPython), but the comment/implementation mismatch is worth a one-line fix.

In the body (not tied to a single changed line)

  1. No integration test for the agent _get_action_event path. The new conditional second fix_malformed_tool_arguments(arguments, tool.response_schema) call in agent.py — and its interaction with _extract_security_risk/_extract_summary popping security_risk/summary before action_from_arguments splits the response fields — is on the hot path of every tool call but is only covered indirectly. The unit tests exercise action_from_arguments and fix_malformed_tool_arguments in isolation. A behavior-style test that drives a response_schema-equipped tool through a real Agent.step (and asserts structured_output survives on the emitted ActionEvent) would lock in the composition and guard the reconstruction contract. This is the most material gap.

  2. ClientTool._get_tool_schema merges the response schema twice. super()._get_tool_schema(...) (line 285) already calls self._merge_response_schema(...) internally, then line 301 calls self._merge_response_schema(merged) again. Not a correctness bug (idempotent), just redundant work on the schema-build path. Worth collapsing to a single merge.

  3. PR description inaccuracy (non-blocking). The description references _create_action_type_with_schema "cached per (action_type, schema) pair," but no such function exists — the implementation merges at the JSON-schema level via _merge_response_schema / _response_tool_schema. The code is correct; only the description is stale. Flagging so reviewers aren't confused when cross-referencing.

Verification notes

  • tests/sdk/tool/test_response_schema.py: 34 passed.
  • tests/sdk/tool: all pass.
  • tests/cross failures observed (test_remote_conversation_live_server.py, test_event_loss_repro.py) are environmental — they fail on agent-server config/env parsing (IndexError in env_parser.py) and live-server startup, unrelated to this PR's changes.
  • Pre-commit clean on all 8 changed SDK files.

No blocking issues. The inline cache note and the agent-path test gap are the two items I'd suggest addressing before merge; the rest are optional.

Comment thread openhands-sdk/openhands/sdk/tool/tool.py
Address review: the per-class cache was read and written unguarded while
its sibling action-type caches use _action_type_lock, so the promised
'at most once per class' build was not actually enforced. Build under a
dedicated lock (the two never nest, so schema building stays off the
action-type critical section) and hand back a private copy so callers
cannot mutate the cached entry. Adds a concurrency test.
@all-hands-bot

Copy link
Copy Markdown
Collaborator

🚦 CI is currently failing on this PR's latest commit.

Please fix the failing checks before OpenHands reviews it - this is re-checked automatically once you push a new commit. (A maintainer can also request @all-hands-bot as a reviewer to have it reviewed regardless of CI status.)

This is an automated check - no AI was used to generate this comment.

@luciobaiocchi

Copy link
Copy Markdown
Contributor Author

@VascoSch92 can you take a look ?

@VascoSch92 VascoSch92 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

@VascoSch92

Copy link
Copy Markdown
Member

I'm looking inside the tests but it seems an infrastructure problem

@VascoSch92
VascoSch92 merged commit da03ee4 into OpenHands:main Aug 6, 2026
45 of 50 checks passed
@VascoSch92

Copy link
Copy Markdown
Member

thanks

@luciobaiocchi

Copy link
Copy Markdown
Contributor Author

It was a pleasure working with you on this. I already have a few new ideas 😉. Thanks again!

VascoSch92 pushed a commit to OpenHands/docs that referenced this pull request Aug 10, 2026
* docs(sdk): add structured output guide

Documents the response_schema mechanism landed in
OpenHands/software-agent-sdk#4207: attaching a Pydantic model or JSON
Schema to any tool spec, reading typed results via parse_response /
parse_last_response, the raw JSON Schema form, and the constraints
(reserved field names, one tool per spec, round-trip to dict).

* docs(sdk): address review on the structured output guide

- fix the persistence claim: structured_output is a PrivateAttr excluded
  from event serialization, so it is None after a reload; parse_last_response
  re-reads the tool call and does survive
- note that a schema field clashing with the tool's own field also raises
  at resolution time, not just the reserved meta names
- follow the other guides: add a Ready-to-run Example block backed by
  examples/01_standalone_sdk/56_structured_output.py
- trim the prose throughout (124 -> 50 lines)

* docs(sdk): note that response_schema fields are scoped to their tool

Observed while running the example: the model may attempt to send the
schema fields when calling other tools, which are rejected as unexpected
arguments before the agent retries.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

integration-test Runs the integration tests and comments the results

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Structured Output

5 participants