mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-07-10 05:14:48 +08:00
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:
123
test/unit_test/rag/llm/test_tool_decorator.py
Normal file
123
test/unit_test/rag/llm/test_tool_decorator.py
Normal 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))
|
||||
Reference in New Issue
Block a user