Skip to content

feat: add structured task outcomes to preset finish tool - #334

Merged
malhotra5 merged 16 commits into
mainfrom
add-task-outcome-structured-output
Aug 24, 2026
Merged

feat: add structured task outcomes to preset finish tool#334
malhotra5 merged 16 commits into
mainfrom
add-task-outcome-structured-output

Conversation

@malhotra5

Copy link
Copy Markdown
Member

Summary

  • Add TaskOutcome/TaskOutcomeBlocker Pydantic response models to prompt and plugin preset SDK templates.
  • Configure preset agents to use an explicit FinishTool with response_schema=TaskOutcome.
  • Use outcome_summary as the schema field alias for the model's summary attribute because summary is reserved by the SDK structured-output tool schema.
  • Add preset tests that assert both generated templates include the structured finish-tool wiring.

Testing

  • uv run pytest tests/test_preset_router.py -q -k 'PresetFileSyntax or GenerateTarball or GeneratePluginTarball'
  • uv run pre-commit run --files openhands/automation/presets/prompt/sdk_main.py openhands/automation/presets/plugin/sdk_main.py tests/test_preset_router.py --show-diff-on-failure

Note: I also attempted uv run pytest tests/ -q --ignore=tests/integration; it failed during setup because Docker/testcontainers is unavailable in this environment (DockerException: Error while fetching server API version).

This PR was created by an AI agent (OpenHands) on behalf of the user.

@malhotra5 can click here to continue refining the PR

Co-authored-by: openhands <openhands@all-hands.dev>
@github-actions github-actions Bot added the type: feat A new feature label Aug 12, 2026
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Coverage

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

Copy link
Copy Markdown
Contributor

🔍 Review in progress…

We are performing the review through OpenHands Cloud Automation. You can log in and view the conversation here.

1 similar comment

Copy link
Copy Markdown
Contributor

🔍 Review in progress…

We are performing the review through OpenHands Cloud Automation. You can log in and view the conversation here.

all-hands-bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Review complete.

This review was performed through OpenHands Cloud Automation. You can log in and view the conversation here.

@all-hands-bot all-hands-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Taste rating: 🟢 Good taste — Approving.

The change is small, focused, and well-tested. Two-layer design (in-sandbox finish-tool wiring + server-side outcome extraction) is the right shape; each piece is independently testable.

What I verified locally:

  • Pinned SDK commit d99c1efd796d1188bbed7f4fec1f495d17505a85 resolves to a working install of openhands.tools.preset.TaskOutcome (subdir openhands-tools, same VCS pin as openhands-sdk). First-party package → 7-day supply-chain rule does not apply.
  • Full test suite: 132 passed. The one error in tests/test_router.py is DockerException from a missing Docker daemon in this environment (unrelated to this PR).
  • Migration 016 is minimal (one nullable JSON column, no backfill, no FK, no data loss risk on downgrade).
  • Both presets/{prompt,plugin}/sdk_main.py apply the same finish_tool_response_schema=TaskOutcome wiring — the duplicated change is consistent across files.
  • setup.sh parses the new /sdk-version JSON shape identically in all three locations (scripts/test_tarball/, presets/prompt/, presets/plugin/).

Minor suggestions (non-blocking):

[IMPROVEMENT OPPORTUNITIES]

  • [openhands/automation/utils/version.py] Dead code: _sdk_direct_url_install_spec() exists only to be tested — production code now uses _direct_url_install_spec(distribution, SDK_PACKAGE_NAME) directly. The one-line wrapper has a single caller (tests/test_version.py) and could be inlined into the test or dropped.
  • [openhands/automation/utils/version.py, get_tools_install_spec] String surgery: the tools install spec is built by substring-replacing openhands-sdkopenhands-tools and subdirectory=openhands-sdksubdirectory=openhands-tools on the SDK install spec. Today this is safe because the SDK pin guarantees both subdirs live at the same commit, but it's an implicit coupling to the subdirectory naming convention. A cleaner shape: parse the SDK's direct_url.json, swap the package name and subdirectory field, and re-serialize — same logic, no string surgery, no silent breakage if the subdir name ever changes.
  • [openhands/automation/utils/conversation_outcome.py] Uncovered branch: fetch_latest_finish_tool_response_for_run (the wrapper that catches all exceptions and handles local-vs-remote backend) is only exercised in tests/test_router.py via monkeypatch. Neither the backend.is_local_mode true branch nor the remote httpx branch inside this wrapper has a direct test. The inner fetch_latest_finish_tool_response IS covered with httpx.MockTransport (good), so the bug surface is small, but a 5-line direct test of the wrapper would close the gap.
  • [openhands/automation/presets/{prompt,plugin}/sdk_main.py] Duplication: The 4-line get_default_agent(...) change is duplicated across two files. Not avoidable given the preset structure, but worth noting that any future change to the finish-tool wiring must touch both — a comment pointing to its sibling would help reviewers of future PRs.

[TESTING GAPS]

  • Same point as the third item above — a direct unit test for fetch_latest_finish_tool_response_for_run (with both is_local_mode=True and the remote-httpx path) would make the outcome pipeline provably correct end-to-end without depending on the integration test in test_router.py.

[RISK ASSESSMENT]

  • ⚠️ Risk Assessment: 🟢 LOW
    • Breaking change: None — only additive schema (run_metadata nullable JSON column, new field on response, new utility, new key in /sdk-version response). Existing API consumers see the same shape plus one extra optional key.
    • Security: None — run_metadata is server-internal storage; the finish-tool response is parsed from a sandbox we already trust to run arbitrary code.
    • Supply chain: SDK pinned to a commit, but it's a first-party OpenHands package — the 7-day freshness rule does not apply per the reviewer guidelines, and the commit is referenced as required by this PR.
    • Failure mode: If outcome extraction fails, the wrapper silently returns None and the run still completes normally — graceful degradation, no user-visible regression.

VERDICT:Worth merging. Solid PR; minor cleanup suggestions above are optional follow-ups.

KEY INSIGHT: Server-side outcome extraction is intentionally a best-effort post-step (try/except → None) rather than a hard dependency on the finish-tool schema — this is the right call, because it keeps run completion decoupled from agent schema evolution.


This review was generated by an AI agent (OpenHands) on behalf of the user through OpenHands Automation. View conversation

Comment thread openhands/automation/utils/version.py Outdated
Comment thread openhands/automation/utils/version.py Outdated
Comment thread openhands/automation/utils/conversation_outcome.py
Comment thread openhands/automation/presets/plugin/sdk_main.py
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
@all-hands-bot

Copy link
Copy Markdown
Contributor

👋 This PR needs a couple of things fixed before OpenHands can review it:

  • the PR description's HUMAN: section needs at least 20 characters describing what you tested, not just the template placeholder

Push an update once this is addressed and this check re-runs automatically.

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

@malhotra5
malhotra5 merged commit 40e171c into main Aug 24, 2026
8 checks passed
VascoSch92 added a commit that referenced this pull request Aug 24, 2026
#334 landed revision 017 on main after this branch was cut, so both files
declared revision 017 revising 016. Alembic refused to run at all — two
heads, and "Revision 017 is present more than once". Chain this one after
it instead.
VascoSch92 added a commit that referenced this pull request Aug 24, 2026
…or and verifier registry (#378)

* feat: replace the parse-only source registry with a provider descriptor and verifier registry

A provider was described in three places: BUILTIN_SOURCES mapped a slug to a
secret extractor, the event_schemas _PARSERS registry mapped the same slug to
a parser, and RESERVED_SOURCES listed the same slugs by hand. Adding one meant
editing all three.

Verification was not a registry at all: verify_signature() was hard-wired to
hex HMAC-SHA256 over the raw body, and a custom webhook could configure only
the header name. Standard Webhooks and Slack could not be onboarded.

Add openhands/automation/providers.py holding both registries. Provider carries
the parser, verifier name, signature header, secret accessor, capabilities, and
the subject/handshake hooks reserved for later phases. VERIFIERS resolves a
scheme by name: hmac_sha256_hex (unchanged), standard_webhooks (from PR #247)
and slack_v0, the last two rejecting deliveries outside a 5-minute window.

event_router resolves a verifier instead of calling verify_signature directly,
parse_event reads the descriptor, and RESERVED_SOURCES is derived. Add
custom_webhooks.signature_scheme as a nullable column; NULL reads as
hmac_sha256_hex, the behaviour the row was created with.

slack is deliberately not registered as a provider: the OSS VM delivers to that
source through a CustomWebhook row, and reserving the name would break it.
Slack becomes a provider in #360.

Also fix two order-dependent tests in test_router.py that asserted on a
browser-supplied telemetry distinct id without forcing local mode; they passed
only while an earlier test file left a local-mode config in the cache.

* refactor: address review comments on the provider registry

Delete the `# ====` banner comments, in providers.py, event_schemas and
utils/webhook.py (where the banner predated this branch) and in the tests.

Hoist every constant in providers.py to the top of the file and type them
Final[str]/Final[int].

Drop `from __future__ import annotations`. It was carrying nothing: the two
TYPE_CHECKING names are reached through string forward references inside
`Callable[...]` type aliases, which are runtime expressions the future import
does not touch. Import, pyright and the suite are unaffected without it.

Correct the lazy-import comment in `parse_event()`. The import is genuinely
required, but the cycle it breaks is not the one the comment named: moving it
to module level fails on providers.py's own `from ...event_schemas import
WebhookEvent`, not on the parsers the submodules define.

Drop the issue and PR numbers from code comments and test docstrings.

Trim the docstrings throughout to a single line where the signature already
says the rest, including the Args/Returns block `verify_signature()` carried
over from utils/webhook.py, and cut the migration docstring down to the
one-line summary the other migrations use.

No behaviour change: 1422 passed, pre-commit clean.

* fix: refuse non-ASCII signature headers and hoist both registries

Move VERIFIERS to the top of providers.py alongside PROVIDERS. Neither can
hold its entries there -- the values are instances of classes defined further
down, and a module-level annotation is evaluated at runtime -- so both are
declared empty next to the type contracts and filled at the bottom, which is
what PROVIDERS already did.

Fix a crash on a non-ASCII signature header. `hmac.compare_digest` raises
TypeError rather than returning False when either str argument is non-ASCII,
and header bytes reach an app latin-1 decoded, so a signature header of b"\xff"
took the request out through an unhandled exception -- a 500 where a 401
belongs. Reachable on every scheme: it was already true of `verify_signature`
before this branch, and the two new verifiers inherited it. All three now
compare bytes.

Correct three comments and a migration docstring that claimed rows predating
the column read as NULL. They do not: the ALTER TABLE carries a server_default,
so existing rows are backfilled to "hmac_sha256_hex". NULL is reachable, but
through a PATCH that clears the field, so the fallback stays and the reason it
exists is now stated accurately. The test that covered this is renamed to match
what it actually sets up.

New tests: a non-ASCII signature is refused by each verifier and returns 401
end to end; PATCHing signature_scheme persists and changes which signature the
delivery path accepts, which nothing covered; and standard_webhooks_key pins
that a literal secret which happens to parse as base64 is still decoded.

1428 passed, pre-commit clean.

* refactor: drop dead code and the duplicated freshness check

Remove the module logger and its import: nothing in providers.py ever logged.

`except (binascii.Error, ValueError)` around b64decode is one clause written
twice -- binascii.Error subclasses ValueError -- so catch ValueError and drop
the binascii import.

`except (TypeError, ValueError)` around int(timestamp) had a dead arm. The
guard above it rules out None, and int() of a str raises only ValueError.

Both timestamped verifiers carried the same six lines of parse-and-compare.
They now share `_within_tolerance()`, which also names the concept the two
schemes have in common.

`get_header` tried an exact hit, then a lowercased hit, then a full scan. The
scan already subsumes the lowercased hit, so that middle branch was three lines
buying nothing.

1428 passed, pre-commit clean.

* fix: renumber the signature_scheme migration to 018

#334 landed revision 017 on main after this branch was cut, so both files
declared revision 017 revising 016. Alembic refused to run at all — two
heads, and "Revision 017 is present more than once". Chain this one after
it instead.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants