Skip to content

fix(tools): materialize generator transform_fn results in TransformedTool.run - #5672

Merged
zzstoatzz merged 5 commits into
PrefectHQ:mainfrom
BlueX888:fix/prep-transformed-tool-generator-data-loss
Oct 20, 2026
Merged

zzstoatzz merged 5 commits into
PrefectHQ:mainfrom
BlueX888:fix/prep-transformed-tool-generator-data-loss

Conversation

@BlueX888

@BlueX888 BlueX888 commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Generator transform functions could lose yielded values or fail structured output, and transforms with injected dependencies could run a blocking generator on the event loop. Wrap the body before dependency injection and reuse the ordinary tool materialization path, keeping synchronous iteration in the worker and asynchronous iteration inside the invocation. Body validation failures remain tool execution errors.

from collections.abc import Iterator

from fastmcp import FastMCP
from fastmcp.tools import Tool

server = FastMCP("Generators")

async def original(value: int) -> list[int]:
    return [value]

def expand(value: int) -> Iterator[int]:
    yield value
    yield value + 1

base_tool = Tool.from_function(original)
transformed = Tool.from_tool(base_tool, transform_fn=expand)
server.add_tool(transformed)

Closes #5668

Incorporates the before-injection wrapper and generator coverage from #5670.

Co-authored-by: Nandana Dileep [email protected]

…Tool.run

A generator transform_fn was never consumed by TransformedTool.run's
direct dispatch branch, which only handled coroutine and plain-sync
callables: a sync generator's yielded values were silently lost (empty
content and structured output), and an async generator function, not
being a coroutine function, was dispatched via the sync threadpool path
and serialized as its repr, hard-failing the structured-content check.

Consume generators inside the invocation, mirroring FunctionTool's
materialization and without_injected_parameters' wrapper, so yielded
values become the result while _current_tool is still set for
forward()/forward_raw().
@github-actions

This comment has been minimized.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-12T02:22:01.827895Z 8bea70f PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@marvin-context-protocol marvin-context-protocol Bot added the bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality. label Oct 12, 2026

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8bea70ff76

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@BrickerP BrickerP left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review: TransformedTool.run generator materialization (#5668)

Bug / intent

TransformedTool.run already routes through without_injected_parameters(self.fn), but that helper returns the raw callable when there is nothing to inject (dependencies.py early if not exclude: return fn). The direct dispatch branch then only handled coroutine vs plain-sync (is_coroutine_function → await, else call_sync_fn_in_threadpool), and handed the raw return to convert_result. A sync generator therefore reached serialization unconsumed → empty content / structured_content={'result': []}; an async generator function is not a coroutine function, so it took the sync path and the async-gen object was stringified as its repr, failing the structured-content check. That matches #5668 and the ctx-injected vs no-inject asymmetry.

Fix soundness

The insertion in tool_transform.py (after await / threadpool return, still inside the _current_tool try, before InputRequired* / ToolResult / convert_result) is the right place:

if inspect.isasyncgen(result):
    result = [item async for item in result]
if inspect.isgenerator(result):
    result = list(result)

That is the same contract as FunctionTool._materialize_generator (function_tool.py) and the consume block inside the injected-params wrapper — including two separate ifs rather than elif, which is what _materialize_generator uses. Keeping materialization under _current_tool so a generator body that calls forward() / forward_raw() still sees the ContextVar set is an important detail the comment calls out correctly.

The regression tests (test_transform_fn_sync_generator_yields_values / test_transform_fn_async_generator_yields_values) are stronger than a single combined smoke: they exercise both shapes via Client.call_tool and assert both data and structured_content. That covers the silent-empty and hard-fail modes from the issue.

Relationship to #5670

#5670 patches the same TransformedTool.run site for the same #5668 root cause (I left a COMMENTED review there earlier). This PR is effectively the companion/duplicate path with clearer _current_tool rationale and more thorough tests. Maintainers will want to land one and close the other (or fold) to avoid conflicting diffs on the same hunk.

Residual gaps / suggestions

  1. Sync generator vs threadpool. Non-async transform_fn still constructs the generator inside call_sync_fn_in_threadpool, then list(result) runs on the event loop. FunctionTool deliberately materializes sync generators inside _wrap_body_errors(..., materialize_generators=True) so body iteration stays under the same invocation / worker-thread boundary. Not a correctness bug for the empty-result / repr failure this PR fixes, but worth aligning if a blocking sync generator transform should not stall the loop (Codex flagged the same placement concern).
  2. DRY. This is a third copy of the four-line materialize (alongside FunctionTool._materialize_generator and without_injected_parameters). Reusing the static helper (or a shared util) would keep empty-gen / exception-wrapping edge cases in one place.
  3. Tests (optional follow-ups). Empty generator → []; a ctx-injected transform that already worked via the deps wrapper (guards against a future double-path regression); yielding ToolResult / InputRequiredResult from a generator is exotic and non-blocking.

Verdict: Sound fix, mirrors the intended FunctionTool / deps pattern, and the tests are the right smoke for #5668. Leaving COMMENTED rather than approve only for the threadpool-parity residual, the DRY nit, and the overlap with #5670 — happy to approve once those are acknowledged or one of the two PRs is chosen as the landing vehicle.

…thread

TransformedTool.run materialized sync generator transform_fn results on
the event loop after the threadpool call returned, so a generator body
with blocking work stalled every concurrent request for the full
iteration. Consume sync generators inside the same threadpool call that
created them, matching FunctionTool's _wrap_body_errors(...,
materialize_generators=True) placement.

Extract _materialize_sync_generator next to FunctionTool's other
materialization helpers and use it from _wrap_body_errors and
FunctionTool._materialize_generator as well, so the transformed-tool
path reuses the shared helper instead of adding a third hand-rolled
copy. Async generators still materialize on the event loop, inside this
invocation, because their bodies may await between items.

Add a regression test that runs a time.sleep-ing sync generator
transform_fn under a heartbeat task and asserts the loop keeps servicing
concurrent work while the body blocks.
@BlueX888

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — all three points are addressed in 67defbf.

Threadpool parity: sync generators are now consumed inside the same call_sync_fn_in_threadpool call that created them (_wrap_materialize_generators, same placement as _wrap_body_errors(..., materialize_generators=True)). Measured with a blocking time.sleep-ing body: event-loop heartbeat max gap drops from 531ms to ~5ms — matching plain FunctionTool. A regression test (test_transform_fn_blocking_sync_generator_does_not_stall_event_loop) asserts this.

DRY: both call sites now reuse FunctionTool._materialize_generator / the shared _materialize_sync_generator helper; no third copy.

Overlap with #5670: leaving that call to maintainers. Happy to fold either way — our two tests are available for @nandanadileep to cherry-pick if #5670 is chosen as the landing vehicle.

@zzstoatzz
zzstoatzz merged commit b918dd9 into PrefectHQ:main Oct 20, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working. Reports of errors, unexpected behavior, or broken functionality.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generator transform_fn in Tool.from_tool: sync generator silently returns empty result, async generator fails structured-content validation

3 participants