Feat: @tool decorator for chat-model tool registration (#15047)

## Summary

- Adds a lightweight `@tool` decorator and `FunctionToolSession` adapter
in `rag/llm/tool_decorator.py` that let callers register plain Python
functions as LLM tools without hand-writing OpenAI function schemas or
building an MCP-style session.
- Refactors `Base.bind_tools` and `LiteLLMBase.bind_tools` in
`rag/llm/chat_model.py` to accept either the new decorator form
`bind_tools(tools=[fn1, fn2])` or the existing `(toolcall_session,
tools_schemas)` form, so existing agent/dialog call-sites in
`agent/component/agent_with_tools.py`, `api/db/services/llm_service.py`,
and `api/db/services/dialog_service.py` are unaffected.
- Adds 8 unit tests in `test/unit_test/rag/llm/test_tool_decorator.py`
covering schema shape, required/optional inference, sync + async
dispatch, and bad-input rejection.

## Usage

```python
from rag.llm.tool_decorator import tool

@tool
def get_weather(city: str) -> str:
    """Get current weather for a city.

    :param city: City name to look up.
    """
    return f"{city}: 21 C, partly cloudy"

chat_mdl.bind_tools(tools=[get_weather])
ans, tk = await chat_mdl.async_chat_with_tools(system, history)
```

The decorator introspects `inspect.signature` + type hints + the
docstring (`:param name:` style) and attaches an OpenAI-format
`openai_schema` to the callable. `FunctionToolSession` duck-types the
existing `ToolCallSession` protocol, dispatching async callables
directly and sync ones through `thread_pool_exec` so the event loop is
never blocked.

## Design notes

- `tool_decorator.py` deliberately does **not** live inside
`rag/llm/__init__.py` to avoid forcing every consumer through the heavy
provider auto-discovery loop and to sidestep a circular import
(`__init__.py` imports `chat_model`, which would otherwise need symbols
from `__init__.py`).
- `FunctionToolSession` is duck-typed against
`common.mcp_tool_call_conn.ToolCallSession` rather than explicitly
inheriting from it, so importing the decorator doesn't pull the MCP
client SDK into the import graph.
- Docstring parsing is intentionally minimal (`:param name:` only) to
keep this dependency-free; Google/NumPy styles can be added later via
`docstring_parser` if needed.

## Test plan

- [x] `python -m pytest test/unit_test/rag/llm/test_tool_decorator.py
-v` — 8 passed
- [x] `python -m pytest test/unit_test/rag/llm/
--ignore=test/unit_test/rag/llm/test_perplexity_embed.py` — 11 passed
(the ignored test has a pre-existing `numpy` import that's unrelated)
- [ ] Reviewer: smoke-test the new path end-to-end with a live model via
`chat_mdl.bind_tools(tools=[my_fn])` to confirm the OpenAI-format
schemas pass through unchanged

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kevin Hu
2026-05-21 14:07:00 +08:00
committed by Jin Hai
parent a6186244ee
commit e7544562cc
3 changed files with 391 additions and 2 deletions

View File

@@ -0,0 +1,123 @@
#
# Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
"""Smoke tests for the ``@tool`` decorator and ``FunctionToolSession`` adapter.
Covers the contract that :meth:`rag.llm.chat_model.Base.bind_tools` relies
on: each ``@tool`` callable carries a well-formed OpenAI function schema,
required vs. optional params are derived from defaults, and the session
dispatches both sync and async callables by name.
"""
import asyncio
import pytest
from rag.llm.tool_decorator import FunctionToolSession, is_tool, tool
@tool
def get_weather(city: str) -> str:
"""Get current weather for a city.
:param city: City name to look up.
"""
return f"{city}: 21 C, partly cloudy"
@tool
def add(a: int, b: int = 1) -> int:
"""Return the sum of two numbers.
:param a: first addend
:param b: second addend (defaults to 1)
"""
return a + b
@tool
async def echo_async(text: str) -> str:
"""Return the input untouched."""
return text
def test_tool_marks_callable_and_attaches_schema():
assert is_tool(get_weather)
schema = get_weather.openai_schema
assert schema["type"] == "function"
fn = schema["function"]
assert fn["name"] == "get_weather"
assert "current weather" in fn["description"].lower()
params = fn["parameters"]
assert params["type"] == "object"
assert params["properties"]["city"]["type"] == "string"
assert params["properties"]["city"]["description"] == "City name to look up."
assert params["required"] == ["city"]
def test_required_vs_optional_from_defaults():
params = add.openai_schema["function"]["parameters"]
assert params["properties"]["a"]["type"] == "integer"
assert params["properties"]["b"]["type"] == "integer"
assert params["required"] == ["a"]
def test_session_dispatches_sync_tool():
session = FunctionToolSession([get_weather, add])
result = session.tool_call("get_weather", {"city": "Beijing"})
assert "Beijing" in result
assert session.tool_call("add", {"a": 2, "b": 3}) == 5
def test_session_dispatches_async_tool():
session = FunctionToolSession([echo_async])
result = asyncio.run(session.tool_call_async("echo_async", {"text": "hi"}))
assert result == "hi"
def test_session_rejects_unknown_name():
session = FunctionToolSession([get_weather])
with pytest.raises(KeyError):
asyncio.run(session.tool_call_async("nope", {}))
def test_session_rejects_non_mapping_arguments():
session = FunctionToolSession([get_weather])
with pytest.raises(TypeError):
asyncio.run(session.tool_call_async("get_weather", ["Beijing"]))
def test_session_rejects_non_tool_callable():
def plain(x): return x
with pytest.raises(TypeError):
FunctionToolSession([plain])
def test_schemas_passthrough_for_bind_tools():
session = FunctionToolSession([get_weather, add])
schemas = session.schemas
assert [s["function"]["name"] for s in schemas] == ["get_weather", "add"]
def test_async_tool_timeout_raises():
@tool
async def slow() -> str:
"""Sleep longer than the timeout."""
await asyncio.sleep(0.5)
return "done"
session = FunctionToolSession([slow])
with pytest.raises(asyncio.TimeoutError):
asyncio.run(session.tool_call_async("slow", {}, request_timeout=0.05))