Skip to content

Provide stable public access to the underlying function on FunctionTool #3381

@pipercyterski

Description

@pipercyterski

Summary

When @function_tool is applied to a function, the resulting FunctionTool
dataclass captures the original callable only in the closure of
_on_invoke_tool_impl (free var the_func, reachable today via
function_tool.on_invoke_tool._invoke_tool_impl.__closure__). There is no
public attribute (func, fn, __wrapped__) and no __call__ forward.

PR #2146 was opened in December 2025 to add a .func attribute and was
closed unmerged in February 2026 over an unrelated pytest fixture-collection
regression. The underlying ergonomic gap remains, and from 0.16 onward
the _FailureHandlingFunctionToolInvoker indirection made the
closure-walking workaround more expensive (one more attribute hop).

Why this matters

Anything outside the Agents runtime that wants to introspect, ship, or
call the user's tool body has no stable hook:

  • Code-shipping SDKs that bundle the user's tool source and re-run
    the bottom function in a sandbox. The standard unwrap chain
    (__wrapped__ / func / fn) misses every @function_tool-decorated
    symbol, so customers get an AttributeError at build time (or a
    non-callable wrapper at runtime) on the very first example any
    tutorial shows.
  • Direct testing of the user's tool body without spinning up an
    Agent / ToolContext / JSON encode-decode cycle (Make FuncTool and @function_tool decorated function callable #708).
  • Migrating across agent frameworks — the original callable is the
    cleanest portability surface.

Proposed fix

Either of these is sufficient; both are compatible with the existing
dataclass shape.

Option 1 — public attribute (what #2146 was driving at):

```python
@DataClass
class FunctionTool:
...
_func: ToolFunction[...] | None = field(default=None, kw_only=True, repr=False)

@property
def func(self) -> ToolFunction[...] | None:
    return self._func

```

with `_create_function_tool` passing `_func=the_func` when constructing
the dataclass. No `functools.update_wrapper`, which dodges the pytest
collection issue that closed #2146.

Option 2 — callable forward (ergonomic ceiling, complementary to 1):

```python
def call(self, *args, **kwargs):
if self._func is None:
raise RuntimeError("FunctionTool has no underlying function")
return self._func(*args, **kwargs)
```

Option 1 unblocks SDKs and tests immediately; Option 2 makes
`my_tool(...)` work in notebooks and unit tests too.

Workaround in the wild

Pending an upstream fix, downstream SDKs walk
`on_invoke_tool._invoke_tool_impl.closure` for the free var named
`the_func`. That's private-implementation-detail spelunking — every
release risks a silent break. A public attribute lets us delete the
workaround entirely.

Happy to PR whichever shape a maintainer prefers.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions