feat(tool): expose original callable on FunctionTool.func - #3396
Conversation
|
This PR is stale because it has been open for 10 days with no activity. |
|
I independently reviewed this against current The API shape still looks focused: a keyword-only The branch now has a mechanical conflict in Happy to independently verify the rebased head once it is pushed. |
37df832 to
5cd8670
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5cd8670d4f
ℹ️ 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".
| """ | ||
|
|
||
| def _create_function_tool(the_func: ToolFunction[...]) -> FunctionTool: | ||
| original_func = the_func |
There was a problem hiding this comment.
Preserve callable identity when deep-copying tools
For a supported callable-object tool, copy.deepcopy(tool) deep-copies this stored instance, while on_invoke_tool retains the original normalized adapter because Python functions and their closures are not deep-copied. Consequently, copied_tool.func(...) mutates or invokes the copied handler, but copied_tool.on_invoke_tool(...) still invokes the original handler; this breaks the promised stable handle on an existing clone path already exercised in tests/test_function_tool.py. Define deep-copy behavior that keeps both invocation surfaces bound to the same callable rather than storing independently copied metadata.
AGENTS.md reference: AGENTS.md:L61-L61
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Confirmed on the rebased head (5cd8670): the 11 new .func tests pass, but a stateful callable-object reproducer splits after copy.deepcopy(tool). A direct copied.func(2) records on the copied handler, while await copied.on_invoke_tool(..., "{\"value\": 3}") records on the original handler (copied=[2], original=[3]). Since .func is new in this PR and the invoker closure already preserves the original callable identity, the smallest compatibility-safe fix appears to be explicit FunctionTool.__deepcopy__ behavior that keeps .func on that same identity, plus a callable-object regression test; no migration shim or broader API change should be needed.
|
Rebased onto current One deviation from your suggestion. Locally on Python 3.11: CI has not run on the new head yet: the |
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 same callable `on_invoke_tool` dispatches to, so both surfaces stay bound to one object across `copy.copy`, `copy.deepcopy`, and `dataclasses.replace` - 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, plus `copy.deepcopy` and the shared-binding guarantee for a decorated callable instance Fixes openai#3381
5cd8670 to
bef627b
Compare
|
Good catch, adopted. Confirmed the divergence before changing anything: with a stateful callable instance, Two tests added: Full suite is green locally on 3.11: 5752 passed / 7 skipped, plus 28 passed / 5 skipped serial. CI is still waiting on workflow approval. |
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