Skip to content

Python: Preserve structured output on streaming harness tool-approval path (#7418) - #8548

Merged
Eduard van Valkenburg (eavanvalkenburg) merged 8 commits into
microsoft:mainfrom
Shivani767:fix/7418-harness-streaming-structured-value-v2
Sep 23, 2026
Merged

Eduard van Valkenburg (eavanvalkenburg) merged 8 commits into
microsoft:mainfrom
Shivani767:fix/7418-harness-streaming-structured-value-v2

Conversation

@Shivani767

@Shivani767 Shivani . (Shivani767) commented Sep 19, 2026 •

Copy link
Copy Markdown
Contributor

Motivation & Context

When a harness agent uses structured output (response_format) together with tool approval, the streaming path lost the parsed value. ToolApprovalMiddleware._process_stream returned ResponseStream(..., finalizer=AgentResponse.from_updates) without forwarding output_format_type. The non-streaming path forwarded it, so AgentResponse.value was populated only when streaming was on (#7418).

This is the successor to #7800, which was closed while I consolidated on a single open PR; westey (@westey-m) then correctly noted that #7798 did not cover this change, so the fix was left without an active PR. This PR carries it as a fresh, non-draft PR. This supersedes #7800 — please close #7800 in favour of this one.

Description & Review Guide

What are the major changes?

  • Added _structured_response_format(context): resolves the effective response format, preferring run options["response_format"] and falling back to agent.default_options.
  • The streaming finalizer now forwards that format to AgentResponse.from_updates, so the streaming path parses value the same way the non-streaming path does.
  • The finalizer rebuilds the response from the streamed updates (preserving the middleware's approval / user-input handling), then forwards the terminal inner response's parsed structured value through AgentResponse.from_updates(..., value=...) — the public value argument on from_updates, not a private-field mutation (Python: [Bug]: Harness Agent drops structured value on the streaming path with tool approval #7418). Without this, an auto-approved preamble that from_updates coalesces into the JSON message masks the parsed value.

What is the impact of these changes?

  • AgentResponse.value is now populated for streaming runs that use structured output plus tool approval, including the auto-approved-tool and coalesced-preamble cases.
  • No change to non-streaming behaviour.
  • The finalizer resolves final.value (which also completes the terminal response's lazy parse) and passes the result to from_updates(value=...). If the terminal parse fails, the value is left unset so the outer response still raises lazily on value access, matching the error timing of a middleware-free streaming run.

What do you want reviewers to focus on?

  • The combination of "rebuild from updates, then forward the parsed value" versus simply returning the inner response. Returning the inner response directly was the earlier attempt, but current main relies on the finalizer rebuilding from updates so buffered approval requests are re-emitted (_tool_approval.py:526), and that broke test_tool_approval_middleware_auto_approves_with_host_pause_and_cached_safe_call[streaming].
  • The finalizer now uses the public AgentResponse.from_updates(..., value=...) contract rather than touching the private _value / _value_parsed fields, so the lazy-parse case no longer reparses preamble + JSON as one invalid string.

Related Issue

Fixes #7418

Supersedes #7800 (closed, no replacement PR). #7798 addressed a separate issue and did not include this fix. No other open PR addresses #7418.

Validation

Branch is up to date with current main (6362719, merge of main into the branch on 2026-09-21).

uv run pytest packages/core/tests/core/test_harness_tool_approval.py -q
71 passed

uv run pytest packages/core/tests/core/ -q -k 'approval or streaming'
all passed (EXIT=0)

New regression tests: test_streaming_tool_approval_preserves_structured_value, test_streaming_auto_approved_tool_preserves_structured_value, test_streaming_auto_approved_tool_preserves_value_when_preamble_text_coalesces, test_non_streaming_tool_approval_preserves_structured_value, test_streaming_tool_approval_defers_structured_parse_error_to_value_access.

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change. If it is a breaking change, add the breaking change label (or add "[BREAKING]" to the title prefix, before or after any language prefix) — a workflow keeps the label and title prefix in sync automatically.

/cc westey (@westey-m) — you flagged that #7800's structured-output fix was not covered by #7798; this is that fix, rebased and green.

… path

Forward response_format through ToolApprovalMiddleware's stream finalizer
so AgentResponse.value is parsed the same way as the non-streaming path.
Return the last inner AgentResponse from the streaming tool-approval
finalizer so coalesced preamble text cannot clobber response.value.

Copilot AI 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.

Copilot review overview

🟡 Changes recommended

Lazy parsing prevents the terminal structured value from being copied in the preamble case.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 1 High severity

Open (1)
What changed in this PR

Fixes structured-output preservation in streaming tool-approval middleware.

Changes:

  • Propagates the effective response format into stream finalization.
  • Attempts to retain parsed terminal values.
  • Adds structured-output regression tests.
File Description
_tool_approval.py Adds response-format resolution and value carry-over.
test_harness_tool_approval.py Adds streaming and non-streaming regression coverage.

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

@moonbox3

Copy link
Copy Markdown
Contributor

Please also fix the CI/CD failures.

@Shivani767

Copy link
Copy Markdown
Contributor Author

Sure, I will update with 1-2 hour time period

…before checking lazy parse state'

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Follow-up to microsoft#8548 review feedback:

- Forward the terminal inner response's structured value through the public
  AgentResponse.from_updates(value=...) contract instead of mutating the
  private _value/_value_parsed fields. Those fields were only copied when the
  inner response had already parsed its value, so the normal lazy-parse case
  still reparsed the coalesced preamble and JSON as one invalid string.
- Resolve final.value eagerly (which completes the lazy parse) and suppress
  the expected parse failures (ValueError/TypeError) so a parse error still
  surfaces lazily on value access, matching a middleware-free streaming run.
- Cast default_options to Mapping[str, Any] in _structured_response_format so
  strict pyright no longer reports reportUnknownMemberType and
  reportUnknownVariableType (Package Checks).
- Keep the parametrized regression test's option dicts out of Agent's generic
  client/options inference so mypy, pyrefly, ty, zuban, and pyright all pass
  (Test Typing Checks).
@Shivani767

Shivani . (Shivani767) commented Sep 21, 2026 •

Copy link
Copy Markdown
Contributor Author

Evan Mattson (@moonbox3) the CI/CD failures are fixed and verified locally on Python 3.11.

  • Package Checks: pyright on packages/core reports 0 errors. The committed code failed here with reportUnknownMemberType and reportUnknownVariableType at _tool_approval.py:94, because default_options.get(...) was partially unknown. That lookup now goes through an explicit cast("Mapping[str, Any]", ...).
  • Test Typing Checks: mypy, pyrefly, ty, zuban and pyright all pass over the packages/core tests. The failure was in the parametrized regression test, where the option dicts clashed with Agent's generic OptionsCoT inference against the fixture's MockBaseChatClient[ChatOptions[None]].
  • Tests: pytest packages/core/tests/core/test_harness_tool_approval.py passes 71 tests, and -k 'approval or streaming' over packages/core/tests/core also passes.
  • ruff check and ruff format --check are clean.

The finalizer also now follows Eduard van Valkenburg (@eavanvalkenburg)'s suggestion and passes the parsed value through the public AgentResponse.from_updates(..., value=...) contract, instead of mutating the private _value and _value_parsed fields.

Update: I merged main into the branch to bring it up to date. The head is now 6362719 and the PR's net diff is unchanged (_harness/_tool_approval.py plus the harness tool-approval tests). Re-verified locally after the merge: pyright 0 errors, all five test type checkers pass, and the tool-approval test file passes 71 tests.

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.

Shivani . (@Shivani767) Thanks for updating the implementation. The PR body still describes copying _value / _value_parsed and asks reviewers to evaluate that private-field approach, but the current head uses the public AgentResponse.from_updates(..., value=...) contract. Could you update the Description & Review Guide to describe the implementation that is actually in the diff?

@Shivani767

Copy link
Copy Markdown
Contributor Author

Eduard van Valkenburg (@eavanvalkenburg) done — thanks for catching that. The Description & Review Guide now describes the implementation that is actually in the diff: the finalizer rebuilds the outer response from the streamed updates and forwards the terminal inner response's parsed value through the public AgentResponse.from_updates(..., value=...) contract, with no mention of the old _value / _value_parsed copying. The reviewer-focus bullet now reads as confirming the public-contract approach rather than asking reviewers to evaluate the private-field mutation. Also updated the Validation section to the current head (6362719) and current test counts (71 passed), including the deferred-parse-error regression test.

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.

Shivani . (@Shivani767) Reviewed the current head; no blocking findings.

Merged via the queue into microsoft:main with commit 9225e8d Sep 23, 2026
42 checks passed

This branch was previously deployed

1 inactive deployment
github-app-auth — cc9b8104 Deployed Sep 22, 2026 by Shivani767 via add_label #23537
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Bug]: Harness Agent drops structured value on the streaming path with tool approval

4 participants