Skip to content

Feat: structured output#4207

Open
luciobaiocchi wants to merge 7 commits into
OpenHands:mainfrom
luciobaiocchi:feat/2566-structured-output
Open

Feat: structured output#4207
luciobaiocchi wants to merge 7 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.

End-to-end with a real LLM (gemini/gemini-2.5-flash via litellm):

uv run python examples/01_standalone_sdk/56_structured_output.py

Video/Screenshots

Both directions of the mechanism, from the run above:

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.

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

Copy link
Copy Markdown
Contributor

📁 PR Artifacts Notice

This PR contains a .pr/ directory with temporary PR-specific documents. Because this is a fork PR, the directory will be automatically removed from main immediately after merge.

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

type[Action],
_create_action_type_with_schema(action_type, self.response_schema),
)
return action_type.model_validate(arguments)

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.

This is the blocker for me. Returning an instance of the generated class puts the dynamic name into kind, and kind is exactly what the event log round-trips on.

Real conversation with persistence_dir set, one FinishTool call carrying a response_schema:

events/event-00000-….json
  "action": {"message":"done","success":true,"summary_text":"s",
             "kind":"FinishActionWithTaskResult"}
base_state.json
  "tools": [{"name":"FinishTool","params":{}}]

Reopening it (event_store.py:163,176 read through Event.model_validate_json):

ValidationError: 1 validation error for Event
action  Value error, Unknown kind 'FinishActionWithTaskResult' for
openhands.sdk.tool.schema.Action; Expected one of: ['MCPToolAction',
'FinishAction', 'InvokeSkillAction', 'SwitchLLMAction', 'ThinkAction',
'VisionInspectAction']

It can't self-heal either — the spec that would rebuild the class was stripped on dump (see my note on spec.py). I also tried resuming with the identical schema re-declared in code, on the theory that it was just an ordering problem. Still fails: the class is only built lazily by _get_tool_schema/action_from_arguments, which runs after the event log is read.

Worth calling out that this is what separates the schema classes from _create_action_type_with_summary / create_action_type_with_risk on main — those are only ever used to render JSON schema (called solely from _get_tool_schema, tool.py:517/520), never instantiated, so they never reach kind. This PR is the first place a dynamic Action subclass gets stored.

I don't have a clean fix to hand. Validating against the augmented type but constructing the base type would keep kind stable, except Action is extra="forbid" so the extra fields have nowhere to go and parse_response loses its source. Might be worth putting the structured payload in a declared field on the action instead of merging fields onto the class — that keeps the wire format stable and static.

annotations[field_name] = field_info.annotation
attrs["__annotations__"] = annotations

new_type = type(

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.

The generated name is derived from the user's class name, which makes collisions reachable — and _get_checked_concrete_subclasses (utils/models.py:152-158) treats a duplicate name as a hard error for the entire Action union.

Two modules each defining a Result schema is enough:

attach Result(alpha) to FinishTool -> ok
attach Result(beta)  to FinishTool -> ok

Event.model_validate({..., "action": {"message":"hi","kind":"FinishAction"}})
ValidationError: Duplicate class definition for openhands.sdk.tool.schema.Action:
  abc.FinishActionWithResult : abc.FinishActionWithResult

Note what breaks there: a plain, pre-existing FinishAction event that deserialized fine seconds earlier. Once poisoned it stays poisoned for the life of the process, and it takes every Action payload with it, not just the schema-bearing tool's.

main's naming (f"{action_type.__name__}WithSummary") derives only from the action type, so it can't collide. This is new.

Minor, but related: type() leaves __module__ as abc, which is where that abc.FinishActionWithResult comes from. Passing __module__ explicitly would at least make the error readable.

Pydantic classes) so the spec can be persisted as part of conversation
state. These runtime values are reapplied by the registry on resolve.
"""
return {k: v for k, v in params.items() if not isinstance(v, type)}

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.

This drops the class in python mode as well as JSON, so anything that round-trips the Agent loses structured output silently:

agent.tools persisted   : [{'name':'FinishTool','params':{}}]
after round-trip params : {}
resolved response_schema: None
tool schema properties  : ['message','summary']

Practical effect: remote / agent-server conversations never get structured output at all (the Agent is serialized to the server), and resumed local conversations quietly lose it.

The docstring says these values "are reapplied by the registry on resolve", but the registry can only reapply what's still in the in-memory spec — after a dump/load there's nothing left to reapply. That's also what turns the kind problem into an unrecoverable one rather than something that heals on reload.

At an absolute minimum I'd log a warning when a class-valued param gets dropped, so it isn't invisible.

response_schema = params.pop("response_schema", None)
tools = resolver(params, conv_state)
if response_schema is not None:
tools = [t.set_response_schema(response_schema) for t in tools]

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.

This applies the schema to every tool the resolver returns, which gets surprising with toolsets:

resolve_tool(Tool(name="browser_tool_set", params={"response_schema": Anno}))
tools resolved: 14
carrying response_schema: 14

So one response_schema on browser_tool_set forces those fields onto all 14 browser tools. Same shape for task_tool_set and workflow_tool_set. I doubt that's intended — either reject it when the resolver returns more than one tool, or document it very loudly.


# Merge the structured response schema (if any) into the action schema
if self.response_schema is not None:
action_type = _create_action_type_with_schema(

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.

Merging here means only subclasses that don't override _get_tool_schema pick it up. Two in-tree ones do override it, and they fail in opposite directions.

ClientTool (client_tool.py:294-299) lifts only security_risk/summary out of the super() schema, so the response fields are dropped — but it doesn't override action_from_arguments, so validation still demands them:

baseline advertised (no schema): ['q','summary']
with response_schema           : ['q','summary']   <- unchanged
action_from_arguments({"q":"hi"})
  ValidationError: success  Field required

The model is never told about the field it's required to send, so every call to that tool fails.

MCPToolDefinition (mcp/tool.py:358 and :273) overrides both, so a response_schema is a silent no-op there and parse_response later gives AttributeError: 'MCPToolAction' object has no attribute 'success'.

Unrelated to those two but same area: to_mcp_tool (tool.py:481) builds inputSchema straight off self.action_type and never routes through here, so it advertises the un-augmented schema. Only tests reach that path today — both in-tree callers pass an explicit input_schema — so it's latent rather than broken. But the PR description says this touches to_mcp_tool, and as far as I can tell it doesn't.

# return when invoking this tool. When set, the model's fields are merged
# into the action schema sent to the LLM, and ``action_from_arguments``
# validates the call against that augmented schema. Runtime-only.
response_schema: SkipJsonSchema[type[BaseModel] | None] = Field(

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.

Another consequence of hanging the fields off the action type: Agent._get_action_event repairs malformed LLM output against tool.action_type (agent.py:1210), which doesn't know about them. So the GLM-4.6 / minimax "list arrived as a JSON string" fixups skip response-schema fields entirely:

{"message":"done","files_changed":'["a.py","b.py"]'}
after fix_malformed_tool_arguments: files_changed still the raw string
action_from_arguments -> ValidationError: Input should be a valid list

Passing the augmented type there is enough to fix it:

repaired against augmented type: {'files_changed': ['a.py','b.py']}

f"existing fields on {action_type.__name__}."
)

reserved = _RESERVED_META_FIELDS & set(response_schema.model_fields)

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.

The guard itself is a good addition, but it only fires from _get_tool_schema(), so the ValueError lands in the middle of the agent's LLM step rather than where the mistake was made:

resolve_tool + set_response_schema -> succeeds, no error
tool.response_schema = <class 'Reserved'>
...later, on the first LLM call:
ValueError: response_schema fields ['summary'] are reserved: ...

Any reason it can't move into set_response_schema? Then a bad schema blows up at config time, where the user can actually see it.

# Meta-field names injected into every action schema after the
# response-schema merge (see ``_create_action_type_with_summary`` and
# ``create_action_type_with_risk``); rejected in user response schemas.
_RESERVED_META_FIELDS = frozenset({"summary", "security_risk"})

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.

kind belongs in this set too. It's the discriminator injected by DiscriminatedUnionMixin — same category as the other two — and a schema declaring it skips the nice error entirely:

UserWarning: Field name "kind" in "FinishActionWithKindSchema" shadows an attribute in parent "FinishAction"
TypeError: Field 'kind' of class 'FinishActionWithKindSchema' overrides symbol of same
  name in a parent class. This override with a computed_field is incompatible.

raise ValueError(f"Tool '{self.name}' has no response_schema configured.")
# Pick only the schema's own fields so meta-fields (kind, summary, ...)
# don't leak in and we don't have to chase them.
data = {k: getattr(action, k) for k in self.response_schema.model_fields}

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.

Bare getattr gives a fairly opaque error when the action doesn't carry the fields:

tool.parse_response(FinishAction(message="hi"))
AttributeError: 'FinishAction' object has no attribute 'foo'

Not hypothetical — that's precisely the post-resume state, where the tool has its schema back but the stored actions don't have the fields. A sentinel check with a real message would be kinder.

ObservationT = TypeVar("ObservationT", bound=Observation)
_action_types_with_risk: dict[type, type] = {}
_action_types_with_summary: dict[type, type] = {}
_action_types_with_schema: dict[tuple[type, type], type] = {}

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.

This never evicts, and the keys hold strong refs to both the action type and the user's schema class, so generated classes live for the life of the process (51 entries after a 50-schema loop). Every type() call also triggers __init_subclass__ -> _bump_subclass_generation(), invalidating the global subclass caches.

Probably fine in practice, since schemas are normally declared once at module scope. But it is a real leak for anything constructing schemas dynamically, and worth at least a comment saying the cache is intentionally permanent.

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]: Structured Output

2 participants