Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b854499
https://github.com/OpenHands/software-agent-sdk/issues/2566
VascoSch92 Apr 13, 2026
2de4af5
address comments
VascoSch92 Apr 13, 2026
ef699bd
last small thing
VascoSch92 Apr 13, 2026
50316a4
fix(tool): reject reserved meta-field names in response_schema
luciobaiocchi Jul 23, 2026
4c2b491
Merge branch 'main' into feat/2566-structured-output
luciobaiocchi Jul 23, 2026
093af31
Update tool.py
luciobaiocchi Jul 24, 2026
d442f11
refactor(tool): use a generator in parse_last_response
luciobaiocchi Jul 24, 2026
baf3c28
fix(tool): persist structured output safely
luciobaiocchi Jul 28, 2026
90933d7
Merge upstream main into feat/2566-structured-output
luciobaiocchi Jul 28, 2026
d7c3b53
fix(sdk): preserve structured output compatibility
luciobaiocchi Jul 29, 2026
a7a662e
docs(sdk): defer structured output example
luciobaiocchi Jul 29, 2026
b4a9d09
Merge branch 'main' into feat/2566-structured-output
VascoSch92 Jul 29, 2026
a5d6e71
Merge branch 'main' into feat/2566-structured-output
VascoSch92 Jul 29, 2026
1f6de46
fix(sdk): reject unsupported response schemas
luciobaiocchi Jul 30, 2026
d4b16e4
merge: sync upstream main at v1.39.1
luciobaiocchi Jul 30, 2026
529e292
fix(sdk): preserve structured response contracts
luciobaiocchi Aug 4, 2026
78f9ecd
Merge remote-tracking branch 'origin/main' into feat/2566-structured-…
openhands-agent Aug 4, 2026
7acd49f
Cache normalized response schema to avoid recomputation per tool call
openhands-agent Aug 4, 2026
978b402
fix(sdk): cache response_schema JSON by class, not per-instance Priva…
openhands-agent Aug 4, 2026
216995f
Merge branch 'main' into feat/2566-structured-output
VascoSch92 Aug 5, 2026
a5e549d
Merge remote-tracking branch 'refs/remotes/upstream/main' into feat/2…
luciobaiocchi Aug 5, 2026
a76b2d6
fix(tool): guard the response-schema JSON cache with its own lock
luciobaiocchi Aug 6, 2026
e586b87
Merge branch 'main' into feat/2566-structured-output
VascoSch92 Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 112 additions & 0 deletions examples/01_standalone_sdk/56_structured_output.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""Structured output via ``response_schema``.

Attach a Pydantic model to *any* tool spec and the agent must populate those
fields when calling that tool. The schema is sent to the LLM as the tool's
JSON-schema parameters and validated on receipt.

Demonstrated here on two tools:
- ``TerminalTool`` (existing SDK tool) — every command must come with a
``purpose`` and ``expected_outcome``, on top of the tool's own ``command``
field. No subclassing required: the schema is merged in via the spec.
- ``FinishTool`` (built-in) — the final answer comes back as a typed object.
"""

import os
from typing import cast

from pydantic import BaseModel, Field

from openhands.sdk import LLM, Agent, Conversation
from openhands.sdk.event import ActionEvent
from openhands.sdk.tool import Tool, register_tool
from openhands.sdk.tool.builtins.finish import FinishTool
from openhands.tools.file_editor import FileEditorTool
from openhands.tools.terminal import TerminalTool


# --- Structured-output schemas ------------------------------------------------


class CommandRationale(BaseModel):
"""Forced-annotation schema attached to TerminalTool."""

purpose: str = Field(description="Why this command is being run, in one line.")
expected_outcome: str = Field(
description="What the assistant expects to observe from running it."
)


class ProjectFacts(BaseModel):
# NOTE: ``summary`` and ``security_risk`` are reserved — the SDK injects
# meta-fields with those names on every action schema, and a response
# schema declaring them is rejected with a ValueError.
description: str = Field(description="One-paragraph description of the project.")
facts: list[str] = Field(description="Three concise, distinct facts.")


# Register FinishTool so we can attach a response_schema via Tool spec.
register_tool("FinishTool", FinishTool)


# --- Agent setup --------------------------------------------------------------


llm = LLM(
model=os.getenv("LLM_MODEL", "anthropic/claude-sonnet-4-5-20250929"),
api_key=os.getenv("LLM_API_KEY"),
base_url=os.getenv("LLM_BASE_URL", None),
)

agent = Agent(
llm=llm,
tools=[
# Existing tool, augmented with a forced-annotation schema:
Tool(name=TerminalTool.name, params={"response_schema": CommandRationale}),
Tool(name=FileEditorTool.name),
Tool(name="FinishTool", params={"response_schema": ProjectFacts}),
],
# Skip the auto-injected default FinishTool so our schema-bound one is used.
include_default_tools=["ThinkTool"],
)

conversation = Conversation(agent=agent, workspace=os.getcwd())
conversation.send_message(
"Inspect the repo using terminal commands, then finish with three facts "
"about the project."
)
conversation.run()


# --- Recover typed outputs from any tool with a response_schema ---------------

events = conversation.state.events
terminal_tool = agent.tools_map[TerminalTool.name]
finish_tool = agent.tools_map["finish"]

# Every TerminalTool call now carries our annotation fields. Walk all events to
# show that the LLM populated them on every invocation.
print("\n[Terminal commands with rationale]")

for event in events:
if (
isinstance(event, ActionEvent)
and event.tool_name == TerminalTool.name
and event.action is not None
):
rationale = cast(CommandRationale, terminal_tool.parse_response(event.action))
# action.command is the tool's own field; rationale.* came from the schema.
print(f" $ {getattr(event.action, 'command', '?')}")
print(f" purpose: {rationale.purpose}")
print(f" expected_outcome: {rationale.expected_outcome}")

# And the typed final answer:
facts = cast(ProjectFacts | None, finish_tool.parse_last_response(events))
if facts:
print("\n[Finish]")
print(f" description: {facts.description}")
for fact in facts.facts:
print(f" - {fact}")

# Report cost
cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost
print(f"\nEXAMPLE_COST: {cost}")
12 changes: 11 additions & 1 deletion openhands-sdk/openhands/sdk/tool/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,17 @@ def resolve_tool(
if resolver is None:
raise KeyError(f"ToolDefinition '{tool_spec.name}' is not registered")

return resolver(tool_spec.params, conv_state)
# `response_schema` is a generic, tool-agnostic mechanism for structured
# output, so it must NOT be forwarded into the tool's own ``.create()``.
# Pop it *before* calling the resolver — otherwise factories with a fixed
# kwarg list (e.g. ``FinishTool.create`` which rejects extra params, or
# any ``def create(cls, conv_state, working_dir=None)``) will raise.
params = dict(tool_spec.params)
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]
Comment thread
luciobaiocchi marked this conversation as resolved.
Outdated
return tools


def list_registered_tools() -> list[str]:
Expand Down
10 changes: 9 additions & 1 deletion openhands-sdk/openhands/sdk/tool/spec.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from typing import Any

from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, Field, field_serializer, field_validator


class Tool(BaseModel):
Expand Down Expand Up @@ -37,3 +37,11 @@ def validate_name(cls, v: str) -> str:
def validate_params(cls, v: dict[str, Any] | None) -> dict[str, Any]:
"""Convert None params to empty dict."""
return v if v is not None else {}

@field_serializer("params")
def _ser_params(self, params: dict[str, Any]) -> dict[str, Any]:
"""Drop non-JSON-serialisable class values (e.g. ``response_schema``
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)}
Comment thread
luciobaiocchi marked this conversation as resolved.
Outdated
121 changes: 120 additions & 1 deletion openhands-sdk/openhands/sdk/tool/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
Protocol,
Self,
TypeVar,
cast,
)

from litellm import (
Expand Down Expand Up @@ -45,8 +46,14 @@
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] = {}
Comment thread
luciobaiocchi marked this conversation as resolved.
Outdated
_action_type_lock = threading.Lock()

# 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"})
Comment thread
luciobaiocchi marked this conversation as resolved.
Outdated


def _camel_to_snake(name: str) -> str:
"""Convert CamelCase to snake_case.
Expand Down Expand Up @@ -252,6 +259,14 @@ def __init_subclass__(cls, **kwargs):
default=None, repr=False, exclude=True
)

# Optional Pydantic model describing the structured payload the LLM must
# 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(
Comment thread
luciobaiocchi marked this conversation as resolved.
Outdated
default=None, repr=False, exclude=True
)

@classmethod
def is_usable(cls) -> bool:
"""Return whether the tool can be used in the current environment."""
Expand Down Expand Up @@ -318,6 +333,10 @@ def set_executor(self, executor: ToolExecutor) -> Self:
"""Create a new Tool instance with the given executor."""
return self.model_copy(update={"executor": executor})

def set_response_schema(self, response_schema: type[BaseModel] | None) -> Self:
"""Return a copy of this tool with the structured ``response_schema`` set."""
return self.model_copy(update={"response_schema": response_schema})

def as_executable(self) -> ExecutableTool:
"""Return this tool as an ExecutableTool, ensuring it has an executor.

Expand Down Expand Up @@ -356,7 +375,46 @@ def action_from_arguments(self, arguments: dict[str, Any]) -> Action:
Returns:
The action instance created from the arguments.
"""
return self.action_type.model_validate(arguments)
action_type: type[Action] = self.action_type
if self.response_schema is not None:
action_type = cast(
type[Action],
_create_action_type_with_schema(action_type, self.response_schema),
)
return action_type.model_validate(arguments)
Comment thread
luciobaiocchi marked this conversation as resolved.
Outdated

def parse_response(self, action: Action) -> BaseModel:
"""Validate ``action`` against ``response_schema`` and return the model.

The return type is declared as ``BaseModel`` because ``ToolDefinition``
is not generic over the schema; cast at the call site for type safety::

result = cast(MySchema, tool.parse_response(action))

Raises ``ValueError`` if no ``response_schema`` is configured.
"""
if self.response_schema is None:
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}
Comment thread
luciobaiocchi marked this conversation as resolved.
Outdated
return self.response_schema.model_validate(data)

def parse_last_response(self, events: "Sequence[Any]") -> BaseModel | None:
"""Find the most recent ``ActionEvent`` for this tool and parse it.

Returns ``None`` if the tool has not been called yet.
"""
from openhands.sdk.event import ActionEvent # avoid circular import
Comment thread
luciobaiocchi marked this conversation as resolved.
Outdated

for event in reversed(events):
if (
isinstance(event, ActionEvent)
and event.tool_name == self.name
and event.action is not None
):
return self.parse_response(event.action)
return None

Comment thread
luciobaiocchi marked this conversation as resolved.
Outdated
def __call__(
self, action: ActionT, conversation: "LocalConversation | None" = None
Expand Down Expand Up @@ -445,6 +503,12 @@ def _get_tool_schema(
) -> dict[str, Any]:
action_type = action_type or self.action_type

# 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(
Comment thread
luciobaiocchi marked this conversation as resolved.
Outdated
action_type, self.response_schema
)

# Apply security risk enhancement if enabled
add_security_risk_prediction = add_security_risk_prediction and (
self.annotations is None or (not self.annotations.readOnlyHint)
Expand Down Expand Up @@ -607,6 +671,61 @@ def create_action_type_with_risk(action_type: type[Schema]) -> type[Schema]:
return action_type_with_risk


def _create_action_type_with_schema(
action_type: type[Schema], response_schema: type[BaseModel]
) -> type[Schema]:
"""Return an action type extended with the fields of ``response_schema``.

Every field declared on the Pydantic ``response_schema`` becomes an extra
field on the returned action class. This is what enables an LLM to reply
with structured output: the JSON schema sent to the model includes both
the tool's own parameters and the user-defined response fields.

A field name collision between the action and the response schema is a
programming error and raises ``ValueError``. The same applies to the
meta-field names the SDK injects into every action schema *after* this
merge (``summary``, and ``security_risk`` when the risk analyzer is on):
a response-schema field with one of those names would be silently
shadowed on the way out or swallowed by the agent on the way back, so
they are rejected up front.
"""
cache_key = (action_type, response_schema)
with _action_type_lock:
cached = _action_types_with_schema.get(cache_key)
if cached is not None:
return cached

overlap = set(action_type.model_fields) & set(response_schema.model_fields)
if overlap:
raise ValueError(
f"response_schema fields {sorted(overlap)} collide with "
f"existing fields on {action_type.__name__}."
)

reserved = _RESERVED_META_FIELDS & set(response_schema.model_fields)
Comment thread
luciobaiocchi marked this conversation as resolved.
Outdated
if reserved:
raise ValueError(
f"response_schema fields {sorted(reserved)} are reserved: "
f"the SDK injects meta-fields with these names into every "
f"action schema. Rename them in your response schema."
)

attrs: dict[str, Any] = {}
annotations: dict[str, Any] = {}
for field_name, field_info in response_schema.model_fields.items():
attrs[field_name] = field_info
annotations[field_name] = field_info.annotation
attrs["__annotations__"] = annotations

new_type = type(
Comment thread
luciobaiocchi marked this conversation as resolved.
Outdated
f"{action_type.__name__}With{response_schema.__name__}",
(action_type,),
attrs,
)
_action_types_with_schema[cache_key] = new_type
return new_type


def _create_action_type_with_summary(action_type: type[Schema]) -> type[Schema]:
"""Create a new action type with summary field for LLM to predict.

Expand Down
Loading
Loading