Skip to content

feat(tool): expose original callable on FunctionTool.func#3396

Open
bugbubug wants to merge 1 commit into
openai:mainfrom
bugbubug:feat/function-tool-public-func-attribute
Open

feat(tool): expose original callable on FunctionTool.func#3396
bugbubug wants to merge 1 commit into
openai:mainfrom
bugbubug:feat/function-tool-public-func-attribute

Conversation

@bugbubug
Copy link
Copy Markdown

Summary

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.

This PR adds 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. Downstream SDKs and tests can then do tool.func(...) directly, with no closure spelunking.

Compatibility notes:

  • The new field is 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 mirrors tests/test_source_compat_constructors.py.
  • repr=False keeps the callable out of repr(tool) (consistent with the other internal-metadata fields).
  • A previous attempt at this (Store reference to original function when creating FunctionTool #2146) used functools.update_wrapper, which interacted badly with pytest fixture collection. This PR avoids update_wrapper entirely 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:

  • Bare @function_tool and parenthesised @function_tool(...) forms both wire .func to the original callable.
  • Sync, async, and ToolContext-taking callables all surface on .func.
  • tool.func(...) directly invokes the underlying function, bypassing schema and ToolContext.
  • Manual FunctionTool(...) (no decorator) leaves .func is None.
  • v0.7.0 positional 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=...) and copy.copy(tool) both preserve .func.
  • repr(tool) does not leak the callable.
  • The new .func returns the same object that today's closure-walking workaround retrieves from on_invoke_tool._invoke_tool_impl.__closure__, so existing downstream code that switches to .func gets identical behavior.

Local verification (Python 3.11.14, macOS):

$ make format-check     → 777 files already formatted
$ make lint             → All checks passed!
$ make pyright          → 0 errors, 0 warnings, 0 informations
$ make mypy             → 1 pre-existing error in tests/test_run_step_execution.py:1465
                           ("eager_task_factory"); reproduces on main without these
                           changes and is unrelated.
$ make tests-parallel   → 4577 passed, 3 skipped in 42.40s
$ uv run pytest tests/test_function_tool_func_attribute.py
                        → 11 passed in 0.09s
$ uv run pytest tests/test_function_tool.py tests/test_source_compat_constructors.py
                        → 72 passed in 0.45s  (positional-compat suite unaffected)

Issue number

Closes #3381.

Checks

  • I've added new tests (if relevant)
  • I've added/updated the relevant documentation (field docstring on FunctionTool.func)
  • I've run make lint and make format
  • I've made sure tests pass

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Provide stable public access to the underlying function on FunctionTool

2 participants