feat(tool): expose original callable on FunctionTool.func#3396
Open
bugbubug wants to merge 1 commit into
Open
Conversation
When `@function_tool` wraps a function, the resulting `FunctionTool` dataclass captures the original callable only in the closure of `_on_invoke_tool_impl` (free variable `the_func`, reachable today via `tool.on_invoke_tool._invoke_tool_impl.__closure__`). There is no public attribute and no callable forward, so downstream code that wants to introspect, ship, or directly invoke the user's tool body has to walk a private closure that silently breaks any time the internal indirection changes — and the v0.16 `_FailureHandlingFunctionToolInvoker` wrapper already added one extra hop. Add a public `FunctionTool.func` attribute that holds the original callable when the tool is constructed through `@function_tool`, and is `None` when `FunctionTool` is built manually with a custom `on_invoke_tool`. The field is `kw_only=True` to preserve the v0.7.0 positional constructor contract documented in AGENTS.md, and `repr=False` to keep the callable out of the default `repr`. A previous attempt at this (openai#2146) used `functools.update_wrapper`, which interacted badly with pytest fixture collection; this PR avoids `update_wrapper` entirely and just threads the callable through `_build_wrapped_function_tool`. - `FunctionTool` gains `func: ToolFunction[...] | None` - `_build_wrapped_function_tool` accepts an optional `func` parameter - `_create_function_tool` passes the wrapped callable as `func=the_func` - New `tests/test_function_tool_func_attribute.py` covers the bare and parenthesised decorator forms, async / context callables, direct invocation through `.func`, manual `FunctionTool(...)` defaults, the v0.7.0 positional constructor (still binds correctly with `.func is None`), `dataclasses.replace`, `copy.copy`, and equivalence with the closure-walking workaround Fixes openai#3381
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
When
@function_toolwraps a function, the resultingFunctionTooldataclass captures the original callable only in the closure of_on_invoke_tool_impl(free variablethe_func, reachable today viatool.on_invoke_tool._invoke_tool_impl.__closure__). There is no public attribute and no callable forward, so downstream code that wants to introspect, ship, or directly invoke the user's tool body has to walk a private closure that silently breaks any time the internal indirection changes — and the v0.16_FailureHandlingFunctionToolInvokerwrapper already added one extra hop.This PR adds a public
FunctionTool.funcattribute that holds the original callable when the tool is constructed through@function_tool, and isNonewhenFunctionToolis built manually with a customon_invoke_tool. Downstream SDKs and tests can then dotool.func(...)directly, with no closure spelunking.Compatibility notes:
kw_only=True, so the v0.7.0 positional constructor contract (FunctionTool("name", "desc", schema, on_invoke, …)) keeps working — covered by an explicit test that mirrorstests/test_source_compat_constructors.py.repr=Falsekeeps the callable out ofrepr(tool)(consistent with the other internal-metadata fields).FunctionTool#2146) usedfunctools.update_wrapper, which interacted badly with pytest fixture collection. This PR avoidsupdate_wrapperentirely and just threads the original callable through_build_wrapped_function_tool, so that failure mode does not recur.Test plan
New test file
tests/test_function_tool_func_attribute.py(11 tests, all passing) covers:@function_tooland parenthesised@function_tool(...)forms both wire.functo the original callable.ToolContext-taking callables all surface on.func.tool.func(...)directly invokes the underlying function, bypassing schema andToolContext.FunctionTool(...)(no decorator) leaves.func is None.FunctionTool("name", "desc", schema, on_invoke, True, True, None, None)still binds correctly and yields.func is None— guards the AGENTS.md "Public API Positional Compatibility" contract.dataclasses.replace(tool, name=...)andcopy.copy(tool)both preserve.func.repr(tool)does not leak the callable..funcreturns the same object that today's closure-walking workaround retrieves fromon_invoke_tool._invoke_tool_impl.__closure__, so existing downstream code that switches to.funcgets identical behavior.Local verification (Python 3.11.14, macOS):
Issue number
Closes #3381.
Checks
FunctionTool.func)make lintandmake format