Skip to content

Comments

Enforce async-only function registration in FunctionRegistry#373

Merged
aliev merged 1 commit intomainfrom
enforce-async-register-function
Feb 25, 2026
Merged

Enforce async-only function registration in FunctionRegistry#373
aliev merged 1 commit intomainfrom
enforce-async-register-function

Conversation

@aliev
Copy link
Member

@aliev aliev commented Feb 25, 2026

Motivation

register_function previously accepted both sync and async callables. This led to hidden complexity: _run_one_tool had to inspect every function at call time (iscoroutinefunction), branch into await or asyncio.to_thread, and maintain a _maybe_await helper for the fallback path. All of this existed solely to paper over sync functions being registered in an async-first framework.

By requiring async functions at registration time we:

  • Fail fast — a ValueError at registration is easier to debug than unexpected blocking at runtime.
  • Simplify internals_invoke, _run_one_tool, and call_function no longer need sync/async branching; _maybe_await is removed entirely.
  • Make blocking explicit — if a user needs to call blocking code (HTTP client, DB driver, file I/O) they wrap it in asyncio.to_thread themselves inside their async function, which makes the cost visible at the call site.

Migration

# Before
@llm.register_function(description="Get weather")
def get_weather(location: str) -> str:
    return requests.get(f"https://weather.api/{location}").text

# After — non-blocking
@llm.register_function(description="Get weather")
async def get_weather(location: str) -> str:
    return (await asyncio.to_thread(requests.get, f"https://weather.api/{location}")).text

Changes

  • FunctionRegistry.register — raises ValueError for non-coroutine callables.
  • FunctionRegistry.call_function — now async, awaits the registered function directly.
  • LLM.call_function — now async.
  • LLM._run_one_tool_invoke simplified to a plain await fn(**args); removed iscoroutinefunction check, asyncio.to_thread branch, and _maybe_await.
  • Tests & examples — all registered functions converted to async def; sync lambdas in mock_tools replaced with async functions.

Summary by CodeRabbit

Release Notes

  • Breaking Changes

    • Function registration now requires asynchronous functions; attempting to register synchronous functions will raise an error.
    • Function calling is now asynchronous and must be awaited.
  • Tests

    • Updated all test utilities and examples to demonstrate asynchronous function usage patterns.

register_function now raises ValueError when given a synchronous
function. call_function and _run_one_tool are simplified since all
registered callables are guaranteed to be coroutines.
@coderabbitai
Copy link

coderabbitai bot commented Feb 25, 2026

📝 Walkthrough

Walkthrough

The pull request migrates the function calling system from synchronous to asynchronous execution. Core registry and LLM classes now enforce async function registration and provide async call methods. Test files and examples throughout the codebase are updated to register and invoke async functions with await syntax.

Changes

Cohort / File(s) Summary
Core Framework Async Migration
agents-core/vision_agents/core/llm/function_registry.py, agents-core/vision_agents/core/llm/llm.py
call_function() methods converted to async in both classes. Decorator enforces async-only function registration via ValueError. Removed _maybe_await helper and simplified tool invocation to always await registered functions.
Mock Utilities & Documentation
agents-core/vision_agents/testing/_mock_tools.py
Updated docstring example to demonstrate async function mocking with async def syntax.
AWS Plugin Examples & Tests
plugins/aws/example/aws_realtime_function_calling_example.py, plugins/aws/tests/test_aws_realtime.py
Registered functions (calculate, get_test_data) converted from sync to async definitions in two test classes.
Gemini Plugin Tests
plugins/gemini/tests/test_realtime_function_calling.py
Five test helper functions (get_weather, unreliable_function, get_time, get_status, test_func) converted to async definitions.
OpenRouter Plugin
plugins/openrouter/example/openrouter_example.py, plugins/openrouter/tests/test_openrouter_llm.py
Example functions (get_weather, calculate_sum) and test probe tool converted to async. Minor text corrections in tool rules documentation.
xAI Plugin Tests
plugins/xai/tests/test_xai_realtime.py, plugins/xai/tests/test_xai_tools.py
Registered get_weather() function converted to async in both test files.
Core Test Suite
tests/test_function_calling.py, tests/test_openai_function_calling_integration.py, tests/test_testing/test_mock_tools.py
All test methods converted to async. Test functions registered for mocking updated to async. New test added to validate sync function registration raises ValueError. Function registry and LLM calls updated to use await syntax throughout.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

Bell jars sealed with async/await—
The old sync dance lies suffocated, pale.
Each test rewritten in coroutine's thread,
Functions yearn and never truly dead,
Suspended, waiting, neither here nor there. ⏳

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 59.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: enforcing async-only function registration in FunctionRegistry, which is the core objective of the PR.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch enforce-async-register-function

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@aliev aliev marked this pull request as ready for review February 25, 2026 12:16
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
agents-core/vision_agents/testing/_mock_tools.py (2)

27-66: ⚠️ Potential issue | 🟠 Major

mock_tools silently fails when given a synchronous callable.

mock_tools assigns mock_fn directly to func_def.function (Line 60) without any async check. Every execution path — _run_one_tool (await fn(**args)) and FunctionRegistry.call_function (await func_def.function(...)) — unconditionally awaits the stored callable. Passing a sync callable produces a TypeError (e.g., "object str can't be used in 'await' expression") that is caught by _run_one_tool's broad except and silently returned as {"error": ...}, making test assertions fail in a confusing way.

mock_functions is safe because it wraps everything in AsyncMock(side_effect=...). mock_tools has no equivalent guard.

🛡️ Proposed fix — mirror the registry's own enforcement
+import inspect
 from collections.abc import Callable, Generator
+from collections.abc import Coroutine
 ...

 `@contextmanager`
 def mock_tools(
     llm: LLM,
-    mocks: dict[str, Callable[..., Any]],
+    mocks: dict[str, Callable[..., Coroutine[Any, Any, Any]]],
 ) -> Generator[None, None, None]:
     ...
     for tool_name in mocks:
         if registry._functions.get(tool_name) is None:
             raise KeyError(f"Tool '{tool_name}' is not registered on this LLM")
+
+    for tool_name, mock_fn in mocks.items():
+        if not inspect.iscoroutinefunction(mock_fn):
+            raise ValueError(
+                f"Mock for '{tool_name}' must be an async function"
+            )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@agents-core/vision_agents/testing/_mock_tools.py` around lines 27 - 66,
mock_tools assigns the provided mock_fn directly to func_def.function (in
mock_tools) without ensuring it's async, so callers like _run_one_tool and
FunctionRegistry.call_function that use await will raise a TypeError for
synchronous callables; fix by wrapping non-async mocks in an async wrapper (or
convert them to AsyncMock) before assignment so func_def.function is always
awaitable—detect with asyncio.iscoroutinefunction or inspect.iscoroutinefunction
and if false, replace mock_fn with an async wrapper that calls the sync mock (or
use AsyncMock(side_effect=mock_fn)) before setting
registry._functions[tool_name].function, mirroring the registry's enforcement
used by mock_functions.

27-66: ⚠️ Potential issue | 🟠 Major

mock_tools silently breaks when given a synchronous callable.

mock_tools assigns the raw callable directly to func_def.function (line 60). Every downstream execution path (_run_one_tool, FunctionRegistry.call_function) now unconditionally awaits that callable. Passing a sync function produces a TypeError at runtime (e.g., "object str can't be used in 'await' expression"), which is silently swallowed by _run_one_tool's broad except clause and surfaces as an unexpected {"error": ...} result in tests.

mock_functions is safe because it wraps with AsyncMock(side_effect=...), which handles sync callables; mock_tools has no such guard.

Consider adding a validation check (mirroring the registry's own enforcement) or updating the type hint to communicate the constraint clearly:

🛡️ Proposed fix
+import inspect
+from collections.abc import Coroutine
 from collections.abc import Callable, Generator
...

 `@contextmanager`
 def mock_tools(
     llm: LLM,
-    mocks: dict[str, Callable[..., Any]],
+    mocks: dict[str, Callable[..., Coroutine[Any, Any, Any]]],
 ) -> Generator[None, None, None]:
     ...
     for tool_name in mocks:
         if registry._functions.get(tool_name) is None:
             raise KeyError(f"Tool '{tool_name}' is not registered on this LLM")

+    for tool_name, mock_fn in mocks.items():
+        if not inspect.iscoroutinefunction(mock_fn):
+            raise ValueError(
+                f"Mock for '{tool_name}' must be an async function"
+            )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@agents-core/vision_agents/testing/_mock_tools.py` around lines 27 - 66,
mock_tools assigns raw callables to func_def.function but downstream code always
awaits them, so passing a synchronous function causes a TypeError; fix by
detecting non-coroutine mocks in mock_tools (use inspect.iscoroutinefunction or
asyncio.iscoroutinefunction) and either wrap sync callables with
AsyncMock(side_effect=mock_fn) before assigning to func_def.function or raise a
clear TypeError; update the behavior in mock_tools where it assigns to
registry._functions[tool_name].function (and adjust the typing/comments if you
prefer the stricter error approach) so tests no longer get swallowed await
errors.
tests/test_testing/test_mock_tools.py (1)

25-45: ⚠️ Potential issue | 🟠 Major

Test design still depends on mocking despite no-mock test policy.

These updated tests continue to validate behavior via mocked callables/AsyncMock, which violates the repository’s testing rule and should be replaced with concrete pytest fixtures/fakes.

As per coding guidelines, "Never mock in tests; use pytest for testing".

Also applies to: 127-188

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_testing/test_mock_tools.py` around lines 25 - 45, The test
test_mock_tools_swaps_and_restores uses a mocked callable (mock_fn/AsyncMock)
via mock_tools which violates the no-mock rule; replace the mock with a concrete
fake implementation and/or pytest fixture: register a real async function (e.g.,
a simple async def replacement_tool(x: int) -> int that returns x * 100) and use
that with mock_tools(llm, {"my_tool": replacement_tool}); ensure you still
capture original_fn from llm.function_registry._functions["my_tool"].function,
assert the active function is the replacement function, call the function via
llm.function_registry.call_function("my_tool", {"x": 5}) to verify behavior, and
after the context verify restoration to original_fn—apply the same replacement
approach to the other tests noted (lines ~127-188) that currently use AsyncMock
or other mocks.
tests/test_function_calling.py (1)

177-370: ⚠️ Potential issue | 🟠 Major

Replace Mock/patch-based tests with pytest-native non-mocking fixtures.

These changed integration-style tests still rely on unittest.mock (@patch, Mock), which conflicts with the repository test policy and should be refactored to non-mocking test doubles/fixtures.

As per coding guidelines, "Never mock in tests; use pytest for testing".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_function_calling.py` around lines 177 - 370, The tests
(test_openai_function_calling_response, test_openai_conversational_response,
test_claude_function_calling_response, test_claude_conversational_response,
test_gemini_function_calling_response) currently use unittest.mock patching and
Mock objects; refactor them to use pytest-native fixtures/monkeypatch instead:
remove `@patch` decorators and Mock usage, add pytest fixtures (e.g.,
openai_client_fixture, anthropic_client_fixture, gemini_client_fixture) that
create lightweight fake client objects implementing the minimal methods used by
OpenAILLM.responses.create, ClaudeLLM.messages.create, and
GeminiLLM.aio.chats.create/send_message_stream (including returning an async
iterator for Gemini), and inject those fixtures into tests via function
arguments or monkeypatch the module attributes (e.g., set
vision_agents.plugins.openai.openai_llm.AsyncOpenAI to the fixture or patch the
client attribute on the LLM instance) so behavior is deterministic without
unittest.mock.
🧹 Nitpick comments (2)
agents-core/vision_agents/core/llm/llm.py (2)

247-249: _run_one_tool bypasses required-parameter validation.

get_callable returns the raw function without the required-parameter check that FunctionRegistry.call_function performs for introspected FunctionDefinition entries. When a required parameter is absent, Python raises a generic TypeError from the await fn(**args) call rather than the descriptive message produced by the registry's own guard. The error is swallowed by the broad except and returned as {"error": ...}, so this is not a crash — but the inconsistency between _run_one_tool and the public LLM.call_function is confusing.

Swapping to self.function_registry.call_function unifies both paths and makes the behavior predictable:

♻️ Proposed fix
         async def _invoke():
-            fn = self.function_registry.get_callable(tc["name"])
-            return await fn(**args)
+            return await self.function_registry.call_function(tc["name"], args)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@agents-core/vision_agents/core/llm/llm.py` around lines 247 - 249,
_run_one_tool currently uses self.function_registry.get_callable(tc["name"]) and
awaits the raw fn(**args), which bypasses the FunctionRegistry parameter
validation provided by FunctionRegistry.call_function and leads to
non-descriptive TypeError on missing required params; change _run_one_tool to
call and await self.function_registry.call_function(tc["name"], args) (or the
exact call_function signature used elsewhere) instead of get_callable to ensure
FunctionDefinition validation runs and errors match LLM.call_function behavior,
preserving the existing exception handling and return shape.

247-249: _run_one_tool bypasses required-parameter validation.

Using get_callable and then await fn(**args) directly skips the required-parameter guard in FunctionRegistry.call_function, which iterates func_def.parameters and raises a descriptive TypeError for missing required args. When a required parameter is absent here, Python raises a generic TypeError that is swallowed by the outer except and returned as {"error": ...}. The public LLM.call_function path correctly delegates to FunctionRegistry.call_function and gets the validation. The two paths are now inconsistent.

♻️ Proposed fix — unify through the validated path
         async def _invoke():
-            fn = self.function_registry.get_callable(tc["name"])
-            return await fn(**args)
+            return await self.function_registry.call_function(tc["name"], args)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@agents-core/vision_agents/core/llm/llm.py` around lines 247 - 249, The helper
_run_one_tool currently fetches a raw callable via
function_registry.get_callable and invokes it directly, bypassing
required-parameter validation; change it to delegate to
FunctionRegistry.call_function (e.g., call self.function_registry.call_function
with the function name tc["name"] and the args dict) so the registry's parameter
checks run, preserving the existing async/await and outer error handling; ensure
you stop using get_callable in _run_one_tool and pass the same args through the
validated call path used by LLM.call_function.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@agents-core/vision_agents/testing/_mock_tools.py`:
- Around line 27-66: mock_tools assigns the provided mock_fn directly to
func_def.function (in mock_tools) without ensuring it's async, so callers like
_run_one_tool and FunctionRegistry.call_function that use await will raise a
TypeError for synchronous callables; fix by wrapping non-async mocks in an async
wrapper (or convert them to AsyncMock) before assignment so func_def.function is
always awaitable—detect with asyncio.iscoroutinefunction or
inspect.iscoroutinefunction and if false, replace mock_fn with an async wrapper
that calls the sync mock (or use AsyncMock(side_effect=mock_fn)) before setting
registry._functions[tool_name].function, mirroring the registry's enforcement
used by mock_functions.
- Around line 27-66: mock_tools assigns raw callables to func_def.function but
downstream code always awaits them, so passing a synchronous function causes a
TypeError; fix by detecting non-coroutine mocks in mock_tools (use
inspect.iscoroutinefunction or asyncio.iscoroutinefunction) and either wrap sync
callables with AsyncMock(side_effect=mock_fn) before assigning to
func_def.function or raise a clear TypeError; update the behavior in mock_tools
where it assigns to registry._functions[tool_name].function (and adjust the
typing/comments if you prefer the stricter error approach) so tests no longer
get swallowed await errors.

In `@tests/test_function_calling.py`:
- Around line 177-370: The tests (test_openai_function_calling_response,
test_openai_conversational_response, test_claude_function_calling_response,
test_claude_conversational_response, test_gemini_function_calling_response)
currently use unittest.mock patching and Mock objects; refactor them to use
pytest-native fixtures/monkeypatch instead: remove `@patch` decorators and Mock
usage, add pytest fixtures (e.g., openai_client_fixture,
anthropic_client_fixture, gemini_client_fixture) that create lightweight fake
client objects implementing the minimal methods used by
OpenAILLM.responses.create, ClaudeLLM.messages.create, and
GeminiLLM.aio.chats.create/send_message_stream (including returning an async
iterator for Gemini), and inject those fixtures into tests via function
arguments or monkeypatch the module attributes (e.g., set
vision_agents.plugins.openai.openai_llm.AsyncOpenAI to the fixture or patch the
client attribute on the LLM instance) so behavior is deterministic without
unittest.mock.

In `@tests/test_testing/test_mock_tools.py`:
- Around line 25-45: The test test_mock_tools_swaps_and_restores uses a mocked
callable (mock_fn/AsyncMock) via mock_tools which violates the no-mock rule;
replace the mock with a concrete fake implementation and/or pytest fixture:
register a real async function (e.g., a simple async def replacement_tool(x:
int) -> int that returns x * 100) and use that with mock_tools(llm, {"my_tool":
replacement_tool}); ensure you still capture original_fn from
llm.function_registry._functions["my_tool"].function, assert the active function
is the replacement function, call the function via
llm.function_registry.call_function("my_tool", {"x": 5}) to verify behavior, and
after the context verify restoration to original_fn—apply the same replacement
approach to the other tests noted (lines ~127-188) that currently use AsyncMock
or other mocks.

---

Nitpick comments:
In `@agents-core/vision_agents/core/llm/llm.py`:
- Around line 247-249: _run_one_tool currently uses
self.function_registry.get_callable(tc["name"]) and awaits the raw fn(**args),
which bypasses the FunctionRegistry parameter validation provided by
FunctionRegistry.call_function and leads to non-descriptive TypeError on missing
required params; change _run_one_tool to call and await
self.function_registry.call_function(tc["name"], args) (or the exact
call_function signature used elsewhere) instead of get_callable to ensure
FunctionDefinition validation runs and errors match LLM.call_function behavior,
preserving the existing exception handling and return shape.
- Around line 247-249: The helper _run_one_tool currently fetches a raw callable
via function_registry.get_callable and invokes it directly, bypassing
required-parameter validation; change it to delegate to
FunctionRegistry.call_function (e.g., call self.function_registry.call_function
with the function name tc["name"] and the args dict) so the registry's parameter
checks run, preserving the existing async/await and outer error handling; ensure
you stop using get_callable in _run_one_tool and pass the same args through the
validated call path used by LLM.call_function.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 94c8a33 and a325f38.

📒 Files selected for processing (13)
  • agents-core/vision_agents/core/llm/function_registry.py
  • agents-core/vision_agents/core/llm/llm.py
  • agents-core/vision_agents/testing/_mock_tools.py
  • plugins/aws/example/aws_realtime_function_calling_example.py
  • plugins/aws/tests/test_aws_realtime.py
  • plugins/gemini/tests/test_realtime_function_calling.py
  • plugins/openrouter/example/openrouter_example.py
  • plugins/openrouter/tests/test_openrouter_llm.py
  • plugins/xai/tests/test_xai_realtime.py
  • plugins/xai/tests/test_xai_tools.py
  • tests/test_function_calling.py
  • tests/test_openai_function_calling_integration.py
  • tests/test_testing/test_mock_tools.py

@aliev aliev merged commit f38a5b5 into main Feb 25, 2026
10 checks passed
@aliev aliev deleted the enforce-async-register-function branch February 25, 2026 15:00
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.

2 participants