Repository navigation
fix(tools): materialize generator transform_fn results in TransformedTool.run - #5672
Conversation
…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().
This comment has been minimized.
This comment has been minimized.
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 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
left a comment
There was a problem hiding this comment.
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
- Sync generator vs threadpool. Non-async
transform_fnstill constructs the generator insidecall_sync_fn_in_threadpool, thenlist(result)runs on the event loop.FunctionTooldeliberately 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). - DRY. This is a third copy of the four-line materialize (alongside
FunctionTool._materialize_generatorandwithout_injected_parameters). Reusing the static helper (or a shared util) would keep empty-gen / exception-wrapping edge cases in one place. - Tests (optional follow-ups). Empty generator →
[]; actx-injected transform that already worked via the deps wrapper (guards against a future double-path regression); yieldingToolResult/InputRequiredResultfrom 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.
|
Thanks for the thorough review — all three points are addressed in 67defbf. Threadpool parity: sync generators are now consumed inside the same DRY: both call sites now reuse 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. |
Co-authored-by: Nandana Dileep <[email protected]>
Co-authored-by: Nandana Dileep <[email protected]>
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.
Closes #5668
Incorporates the before-injection wrapper and generator coverage from #5670.
Co-authored-by: Nandana Dileep [email protected]