From 91f34f7fdde9f1dc632bbcaeaa967a756848a177 Mon Sep 17 00:00:00 2001 From: Lucio Baiocchi Date: Thu, 6 Aug 2026 16:34:38 +0000 Subject: [PATCH 1/3] 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.json | 1 + sdk/guides/structured-output.mdx | 124 +++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 sdk/guides/structured-output.mdx diff --git a/docs.json b/docs.json index 8f29093a8..c80f5c3c6 100644 --- a/docs.json +++ b/docs.json @@ -306,6 +306,7 @@ "pages": [ "sdk/guides/hello-world", "sdk/guides/custom-tools", + "sdk/guides/structured-output", "sdk/guides/mcp", "sdk/guides/skill", "sdk/guides/plugins", diff --git a/sdk/guides/structured-output.mdx b/sdk/guides/structured-output.mdx new file mode 100644 index 000000000..8fdb78589 --- /dev/null +++ b/sdk/guides/structured-output.mdx @@ -0,0 +1,124 @@ +--- +title: Structured Output +description: Attach a schema to any tool so the LLM must return typed, validated fields alongside the tool's own arguments — no prompt engineering or output parsing. +--- + +Agents normally return free text, so getting machine-readable results means prompting for a format and then parsing whatever comes back. Structured output removes that step: attach a schema to a tool and the SDK merges your fields into the schema the LLM sees, validates the reply, and hands you back a typed object. + +## Basic usage + +Pass a Pydantic model as the `response_schema` parameter of a tool spec. Its fields are added to that tool's parameters, so the model must populate them whenever it calls the tool: + +```python +from pydantic import BaseModel, Field + +from openhands.sdk import LLM, Agent, Conversation, Tool +from openhands.sdk.tool.builtins.finish import FinishTool +from openhands.sdk.tool import register_tool + + +class ProjectFacts(BaseModel): + description: str = Field(description="One-paragraph description of the project.") + facts: list[str] = Field(description="Three concise, distinct facts.") + + +register_tool("FinishTool", FinishTool) + +agent = Agent( + llm=llm, + tools=[Tool(name="FinishTool", params={"response_schema": ProjectFacts})], + # Skip the auto-injected FinishTool so the schema-bound one is used. + include_default_tools=["ThinkTool"], +) +``` + +No subclassing is required, and the tool's own arguments are untouched — `FinishTool` still takes its `message`, now alongside `description` and `facts`. + +## Reading typed results + +Resolved tools are available on `agent.tools_map`. Use `parse_last_response()` for the most recent call, or `parse_response()` for a specific action: + +```python +from typing import cast + +conversation = Conversation(agent=agent, workspace=os.getcwd()) +conversation.send_message("Inspect the repo, then finish with three facts about it.") +conversation.run() + +finish_tool = agent.tools_map["finish"] +facts = cast(ProjectFacts | None, finish_tool.parse_last_response(conversation.state.events)) + +if facts: + print(facts.description) + for fact in facts.facts: + print(f"- {fact}") +``` + +`parse_last_response()` returns `None` when the tool has not been called yet. To read every call instead of just the last one, walk the events and parse each action: + +```python +from openhands.sdk.event import ActionEvent + +for event in conversation.state.events: + if isinstance(event, ActionEvent) and event.tool_name == "finish" and event.action: + result = cast(ProjectFacts, finish_tool.parse_response(event.action)) +``` + +The values also live on the action itself as `action.structured_output` (a plain dict), which is what gets persisted with the event. + +## Annotating any tool + +Structured output is not limited to `FinishTool` — attach a schema to any tool to force per-call annotations. This makes every terminal command carry a justification: + +```python +class CommandRationale(BaseModel): + purpose: str = Field(description="Why this command is being run, in one line.") + expected_outcome: str = Field(description="What the assistant expects to observe.") + + +agent = Agent( + llm=llm, + tools=[Tool(name=TerminalTool.name, params={"response_schema": CommandRationale})], +) +``` + +It works the same way for [custom tools](/sdk/guides/custom-tools), client-defined tools, and [MCP](/sdk/guides/mcp) tools. + +## Using raw JSON Schema + +A JSON Schema dict works anywhere a Pydantic model does. In that case `parse_response()` validates against the schema and returns the validated dict rather than a model instance: + +```python +schema = { + "type": "object", + "properties": { + "severity": {"type": "string", "enum": ["low", "high"]}, + "summary_text": {"type": "string"}, + }, + "required": ["severity", "summary_text"], +} + +agent = Agent(llm=llm, tools=[Tool(name="FinishTool", params={"response_schema": schema})]) +``` + +The schema must describe a JSON object with named properties; anything else is rejected when the tool is resolved. + + +Pydantic schemas are serialized as JSON Schema when a conversation is persisted or sent to a remote agent server. After such a round-trip the tool holds the dict form, so `parse_response()` returns a validated dict instead of a model instance. Cast accordingly if you resume a conversation and then read results. + + +## Constraints + + +**Reserved field names.** A response schema may not declare `kind`, `security_risk`, `structured_output`, or `summary`. The SDK injects those onto every action, so a schema using them is rejected with a `ValueError` when the tool is resolved — at configuration time, not mid-run. + + +**One tool per spec.** A `response_schema` applies to exactly one tool. Attaching it to a spec that resolves to a tool set (which returns several tools) raises: + +``` +ValueError: response_schema requires a spec that resolves to exactly one tool +``` + +Attach the schema to the individual tool you want annotated instead. + +**Validation is strict.** If the model omits a schema field or sends the wrong type, the call fails validation like any other malformed tool call, and the agent is asked to correct it. From 73215ba85be553d86065aebbac016853bb3eb04b Mon Sep 17 00:00:00 2001 From: Lucio Baiocchi Date: Fri, 7 Aug 2026 11:55:52 +0000 Subject: [PATCH 2/3] 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) --- sdk/guides/structured-output.mdx | 102 +++++-------------------------- 1 file changed, 14 insertions(+), 88 deletions(-) diff --git a/sdk/guides/structured-output.mdx b/sdk/guides/structured-output.mdx index 8fdb78589..fb022e450 100644 --- a/sdk/guides/structured-output.mdx +++ b/sdk/guides/structured-output.mdx @@ -1,124 +1,50 @@ --- title: Structured Output -description: Attach a schema to any tool so the LLM must return typed, validated fields alongside the tool's own arguments — no prompt engineering or output parsing. +description: Attach a schema to any tool so the LLM returns typed, validated fields alongside the tool's own arguments. --- -Agents normally return free text, so getting machine-readable results means prompting for a format and then parsing whatever comes back. Structured output removes that step: attach a schema to a tool and the SDK merges your fields into the schema the LLM sees, validates the reply, and hands you back a typed object. +import RunExampleCode from "/sdk/shared-snippets/how-to-run-example.mdx"; -## Basic usage - -Pass a Pydantic model as the `response_schema` parameter of a tool spec. Its fields are added to that tool's parameters, so the model must populate them whenever it calls the tool: +Pass a Pydantic model (or a JSON Schema dict) as a tool's `response_schema`. Its fields are merged into the schema the LLM sees, so the model must populate them when it calls that tool, and the reply is validated on receipt — no prompting for a format, no output parsing. ```python -from pydantic import BaseModel, Field - -from openhands.sdk import LLM, Agent, Conversation, Tool -from openhands.sdk.tool.builtins.finish import FinishTool -from openhands.sdk.tool import register_tool - - class ProjectFacts(BaseModel): description: str = Field(description="One-paragraph description of the project.") facts: list[str] = Field(description="Three concise, distinct facts.") -register_tool("FinishTool", FinishTool) - agent = Agent( llm=llm, tools=[Tool(name="FinishTool", params={"response_schema": ProjectFacts})], - # Skip the auto-injected FinishTool so the schema-bound one is used. - include_default_tools=["ThinkTool"], ) ``` -No subclassing is required, and the tool's own arguments are untouched — `FinishTool` still takes its `message`, now alongside `description` and `facts`. +The tool keeps its own arguments — `FinishTool` still takes `message`, now alongside `description` and `facts`. This works on any tool, including [custom](/sdk/guides/custom-tools) and [MCP](/sdk/guides/mcp) tools. -## Reading typed results +## Reading results -Resolved tools are available on `agent.tools_map`. Use `parse_last_response()` for the most recent call, or `parse_response()` for a specific action: +Resolved tools live on `agent.tools_map`. Use `parse_last_response()` for the most recent call, or `parse_response(action)` for a specific one: ```python -from typing import cast - -conversation = Conversation(agent=agent, workspace=os.getcwd()) -conversation.send_message("Inspect the repo, then finish with three facts about it.") -conversation.run() - finish_tool = agent.tools_map["finish"] facts = cast(ProjectFacts | None, finish_tool.parse_last_response(conversation.state.events)) - -if facts: - print(facts.description) - for fact in facts.facts: - print(f"- {fact}") -``` - -`parse_last_response()` returns `None` when the tool has not been called yet. To read every call instead of just the last one, walk the events and parse each action: - -```python -from openhands.sdk.event import ActionEvent - -for event in conversation.state.events: - if isinstance(event, ActionEvent) and event.tool_name == "finish" and event.action: - result = cast(ProjectFacts, finish_tool.parse_response(event.action)) -``` - -The values also live on the action itself as `action.structured_output` (a plain dict), which is what gets persisted with the event. - -## Annotating any tool - -Structured output is not limited to `FinishTool` — attach a schema to any tool to force per-call annotations. This makes every terminal command carry a justification: - -```python -class CommandRationale(BaseModel): - purpose: str = Field(description="Why this command is being run, in one line.") - expected_outcome: str = Field(description="What the assistant expects to observe.") - - -agent = Agent( - llm=llm, - tools=[Tool(name=TerminalTool.name, params={"response_schema": CommandRationale})], -) ``` -It works the same way for [custom tools](/sdk/guides/custom-tools), client-defined tools, and [MCP](/sdk/guides/mcp) tools. - -## Using raw JSON Schema - -A JSON Schema dict works anywhere a Pydantic model does. In that case `parse_response()` validates against the schema and returns the validated dict rather than a model instance: - -```python -schema = { - "type": "object", - "properties": { - "severity": {"type": "string", "enum": ["low", "high"]}, - "summary_text": {"type": "string"}, - }, - "required": ["severity", "summary_text"], -} - -agent = Agent(llm=llm, tools=[Tool(name="FinishTool", params={"response_schema": schema})]) -``` - -The schema must describe a JSON object with named properties; anything else is rejected when the tool is resolved. +`parse_last_response()` returns `None` if the tool has not been called. With a JSON Schema dict instead of a model, both methods return a validated `dict`. -Pydantic schemas are serialized as JSON Schema when a conversation is persisted or sent to a remote agent server. After such a round-trip the tool holds the dict form, so `parse_response()` returns a validated dict instead of a model instance. Cast accordingly if you resume a conversation and then read results. +`parse_last_response()` re-reads the tool call, so it works after a conversation is persisted and reloaded. `action.structured_output` is in-memory only — it is not serialized with the event and comes back `None` after a round-trip, so prefer the parse methods. ## Constraints - -**Reserved field names.** A response schema may not declare `kind`, `security_risk`, `structured_output`, or `summary`. The SDK injects those onto every action, so a schema using them is rejected with a `ValueError` when the tool is resolved — at configuration time, not mid-run. - +- **Reserved names.** A schema may not declare `kind`, `security_risk`, `structured_output`, or `summary`, nor reuse one of the tool's own field names (e.g. `message` on `FinishTool`). Both raise a `ValueError` when the tool is resolved. +- **One tool per spec.** A spec that resolves to a tool set is rejected; attach the schema to the individual tool instead. -**One tool per spec.** A `response_schema` applies to exactly one tool. Attaching it to a spec that resolves to a tool set (which returns several tools) raises: +## Ready-to-run Example +```python icon="python" expandable examples/01_standalone_sdk/56_structured_output.py +# content is auto-synced ``` -ValueError: response_schema requires a spec that resolves to exactly one tool -``` - -Attach the schema to the individual tool you want annotated instead. -**Validation is strict.** If the model omits a schema field or sends the wrong type, the call fails validation like any other malformed tool call, and the agent is asked to correct it. + From cc63c3b35a9e497c275493d274dcc3ba9821150d Mon Sep 17 00:00:00 2001 From: Lucio Baiocchi Date: Mon, 10 Aug 2026 09:05:53 +0000 Subject: [PATCH 3/3] 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. --- sdk/guides/structured-output.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/sdk/guides/structured-output.mdx b/sdk/guides/structured-output.mdx index fb022e450..73daff372 100644 --- a/sdk/guides/structured-output.mdx +++ b/sdk/guides/structured-output.mdx @@ -40,6 +40,7 @@ facts = cast(ProjectFacts | None, finish_tool.parse_last_response(conversation.s - **Reserved names.** A schema may not declare `kind`, `security_risk`, `structured_output`, or `summary`, nor reuse one of the tool's own field names (e.g. `message` on `FinishTool`). Both raise a `ValueError` when the tool is resolved. - **One tool per spec.** A spec that resolves to a tool set is rejected; attach the schema to the individual tool instead. +- **Scoped to its tool.** A model may try to send the schema fields when calling *other* tools; those calls are rejected as unexpected arguments and the agent retries. ## Ready-to-run Example