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

@github-actions

Copy link
Copy Markdown
Contributor

This PR is stale because it has been open for 10 days with no activity.

@github-actions github-actions Bot added the stale label Jul 24, 2026
@tylergibbs1

Copy link
Copy Markdown

I independently reviewed this against current main and the latest release (v0.18.3). This remains the canonical implementation for #3381; the later #3637 and #3692 attempts were closed in favor of this PR.

The API shape still looks focused: a keyword-only .func field preserves the released positional FunctionTool(...) constructor, exposes the exact original callable, and avoids the functools.update_wrapper / pytest collection regression from #2146. The existing head CI is green across Python 3.10–3.14 and Windows.

The branch now has a mechanical conflict in src/agents/tool.py because main added custom_data_extractor, allowed_callers, output_json_schema, and _output_type_adapter. On rebase, please keep all of those fields/forwarding paths, place func at the end of the public keyword-only fields (before _output_type_adapter), forward it through _build_wrapped_function_tool, and pass func=the_func from _create_function_tool. The builder still has one production call site, and dataclasses.replace/__copy__ preserve init fields, so no broader clone-path change appears necessary.

Happy to independently verify the rebased head once it is pushed.

@github-actions github-actions Bot removed the stale label Jul 26, 2026
@bugbubug
bugbubug force-pushed the feat/function-tool-public-func-attribute branch from 37df832 to 5cd8670 Compare July 26, 2026 10:06

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

💡 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".

Comment thread src/agents/tool.py Outdated
"""

def _create_function_tool(the_func: ToolFunction[...]) -> FunctionTool:
original_func = the_func

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge 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 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

@bugbubug

Copy link
Copy Markdown
Author

Rebased onto current main (head is now 5cd8670d). The conflict in src/agents/tool.py was mechanical, as you described: custom_data_extractor, allowed_callers, output_json_schema, and _output_type_adapter are all kept, func sits at the end of the public keyword-only fields just before _output_type_adapter, and it is forwarded through _build_wrapped_function_tool. The diff against main is 13 lines in tool.py plus the test file.

One deviation from your suggestion. main now runs _normalize_function_tool_callable at the top of _create_function_tool, which rebinds the_func to an internal adapter when the decorated object is a callable instance (plain functions and classes are returned unchanged). Passing func=the_func after that point would expose the adapter rather than what the caller actually passed, which contradicts both the field docstring and the ask in #3381. So the callable is captured before normalization and forwarded as func=original_func. For every input other than a callable instance the two are the same object, so this changes nothing for the common path.

Locally on Python 3.11: ruff format --check and ruff check clean, mypy clean apart from a pre-existing eager_task_factory attribute error in tests/test_run_step_execution.py that only shows up below 3.12, and the full suite passes at 5750 passed / 7 skipped plus 28 passed / 5 skipped serial. The 11 tests in tests/test_function_tool_func_attribute.py still pass unchanged.

CI has not run on the new head yet: the Tests workflow is sitting in action_required and needs a maintainer to approve it. There is no test yet covering .func for the callable-instance path; happy to add one if that behaviour is worth locking down.

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
@bugbubug
bugbubug force-pushed the feat/function-tool-public-func-attribute branch from 5cd8670 to bef627b Compare July 26, 2026 10:52
@bugbubug

Copy link
Copy Markdown
Author

Good catch, adopted. .func now stores exactly the callable on_invoke_tool dispatches to, and the field docstring states that as the contract rather than promising the pre-normalization object.

Confirmed the divergence before changing anything: with a stateful callable instance, copy.deepcopy(tool) produced a clone whose .func and on_invoke_tool each hit a different instance (one call through each surface left both at calls == 1). Storing the dispatched callable removes it by construction, since functions are atomic for deepcopy and there is one object to keep in sync.

Two tests added: .func identity through deepcopy for a plain function, and a callable instance where one call through each surface must land on the same object (calls == 2). The second one fails against the previous implementation, so it does constrain the behaviour rather than passing by accident.

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.

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

3 participants