Skip to content

Commit d64f52f

Browse files
update tool decorator (#1691)
Co-authored-by: Wendong-Fan <133094783+Wendong-Fan@users.noreply.github.com>
1 parent cdfb128 commit d64f52f

4 files changed

Lines changed: 342 additions & 0 deletions

File tree

camel/toolkits/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
get_openai_function_schema,
1818
get_openai_tool_schema,
1919
generate_docstring,
20+
tool,
2021
)
2122
from .open_api_specs.security_config import openapi_security_config
2223

@@ -106,6 +107,7 @@
106107
'BaseToolkit',
107108
'manual_timeout',
108109
'FunctionTool',
110+
'tool',
109111
'get_openai_function_schema',
110112
'get_openai_tool_schema',
111113
"generate_docstring",

camel/toolkits/function_tool.py

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -962,3 +962,89 @@ def parameters(self, value: Dict[str, Any]) -> None:
962962
except SchemaError as e:
963963
raise e
964964
self.openai_tool_schema["function"]["parameters"]["properties"] = value
965+
966+
967+
def tool(
968+
func: Optional[Callable] = None,
969+
*,
970+
openai_tool_schema: Optional[Dict[str, Any]] = None,
971+
synthesize_schema: bool = False,
972+
synthesize_schema_model: Optional[BaseModelBackend] = None,
973+
synthesize_schema_max_retries: int = 2,
974+
synthesize_output: bool = False,
975+
synthesize_output_model: Optional[BaseModelBackend] = None,
976+
synthesize_output_format: Optional[Type[BaseModel]] = None,
977+
):
978+
r"""A decorator that converts a Python function into a FunctionTool
979+
instance.
980+
981+
This decorator can be used with or without parentheses:
982+
- @tool - without parentheses, uses default settings
983+
- @tool() - with parentheses, uses default settings
984+
- @tool(synthesize_output=True) - with custom settings
985+
986+
Args:
987+
func (Optional[Callable], optional): The function to be decorated.
988+
This is automatically passed when using @tool without parentheses.
989+
(default: :obj:`None`)
990+
openai_tool_schema (Optional[Dict[str, Any]], optional): A
991+
user-defined OpenAI tool schema to override the default result.
992+
(default: :obj:`None`)
993+
synthesize_schema (bool, optional): Whether to enable schema synthesis.
994+
(default: :obj:`False`)
995+
synthesize_schema_model (Optional[BaseModelBackend], optional):
996+
Model to use for schema synthesis. (default: :obj:`None`)
997+
synthesize_schema_max_retries (int, optional): Maximum number of
998+
retries for schema synthesis. (default: :obj:`2`)
999+
synthesize_output (bool, optional): Whether to enable output synthesis.
1000+
(default: :obj:`False`)
1001+
synthesize_output_model (Optional[BaseModelBackend], optional):
1002+
Model to use for output synthesis. (default: :obj:`None`)
1003+
synthesize_output_format (Optional[Type[BaseModel]], optional):
1004+
Format for synthesized output. (default: :obj:`None`)
1005+
1006+
Returns:
1007+
Callable[[Callable], FunctionTool]: A decorator function that converts
1008+
the decorated function into a FunctionTool instance.
1009+
1010+
Example:
1011+
Using @tool without parentheses::
1012+
1013+
@tool
1014+
def add(a: int, b: int = 0) -> int:
1015+
'''Add two numbers.'''
1016+
return a + b
1017+
1018+
Using @tool() with parentheses::
1019+
1020+
@tool()
1021+
def multiply(a: int, b: int) -> int:
1022+
'''Multiply two numbers.'''
1023+
return a * b
1024+
"""
1025+
1026+
def decorator(f: Callable) -> FunctionTool:
1027+
r"""The actual decorator function.
1028+
1029+
Args:
1030+
f (Callable): The function to be converted into a FunctionTool.
1031+
1032+
Returns:
1033+
FunctionTool: The function wrapped as a FunctionTool instance.
1034+
"""
1035+
return FunctionTool(
1036+
func=f,
1037+
openai_tool_schema=openai_tool_schema,
1038+
synthesize_schema=synthesize_schema,
1039+
synthesize_schema_model=synthesize_schema_model,
1040+
synthesize_schema_max_retries=synthesize_schema_max_retries,
1041+
synthesize_output=synthesize_output,
1042+
synthesize_output_model=synthesize_output_model,
1043+
synthesize_output_format=synthesize_output_format,
1044+
)
1045+
1046+
# Support both @tool and @tool() usage patterns
1047+
if func is not None:
1048+
return decorator(func)
1049+
else:
1050+
return decorator
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# ========= Copyright 2023-2026 @ CAMEL-AI.org. All Rights Reserved. =========
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
# ========= Copyright 2023-2026 @ CAMEL-AI.org. All Rights Reserved. =========
14+
from typing import Any, Dict
15+
16+
from camel.agents import ChatAgent
17+
from camel.toolkits import tool
18+
19+
20+
@tool()
21+
def calculate_bmi(
22+
weight: float,
23+
height: float,
24+
) -> Dict[str, Any]:
25+
r"""Calculate BMI (Body Mass Index) and provide health status.
26+
27+
Args:
28+
weight (float): Weight in kilograms.
29+
height (float): Height in meters.
30+
31+
Returns:
32+
Dict[str, Any]: Dictionary containing BMI value and health status.
33+
"""
34+
bmi = weight / (height * height)
35+
36+
if bmi < 18.5:
37+
status = "Underweight"
38+
elif 18.5 <= bmi < 24.9:
39+
status = "Normal weight"
40+
elif 24.9 <= bmi < 29.9:
41+
status = "Overweight"
42+
else:
43+
status = "Obese"
44+
45+
return {"bmi": round(bmi, 2), "status": status}
46+
47+
48+
def main():
49+
system_message = """You are a health assistant that helps calculate BMI.
50+
When users mention their height and weight, use the calculate_bmi tool to:
51+
1. Calculate their BMI
52+
2. Determine their health status
53+
3. Provide the results in a clear format
54+
55+
Do NOT perform the calculation yourself. Always use the tool."""
56+
57+
agent = ChatAgent(
58+
system_message,
59+
tools=[calculate_bmi],
60+
)
61+
62+
messages = [
63+
"I am 70 kg and 1.75 meters tall. What's my BMI?",
64+
"If someone is 80 kg and 1.8 meters, are they overweight?",
65+
"My friend weighs 65 kg with height 1.7 meters, check their BMI.",
66+
]
67+
68+
for msg in messages:
69+
print("\nUser:", msg)
70+
response = agent.step(msg)
71+
print("Assistant:", response.msgs[0].content)
72+
if "tool_calls" in response.info:
73+
print("\nTool Calls:")
74+
for call in response.info["tool_calls"]:
75+
print(call)
76+
77+
78+
if __name__ == "__main__":
79+
main()
80+
81+
'''
82+
===============================================================================
83+
User: I am 70 kg and 1.75 meters tall. What's my BMI?
84+
Assistant: Your BMI is 22.86, which falls within the "Normal weight" category.
85+
86+
Tool Calls:
87+
Tool Execution: calculate_bmi
88+
Args: {'weight': 70, 'height': 1.75}
89+
Result: {'bmi': 22.86, 'status': 'Normal weight'}
90+
91+
92+
User: If someone is 80 kg and 1.8 meters, are they overweight?
93+
Assistant: For someone who is 80 kg and 1.8 meters tall, their BMI is 24.69,
94+
which is classified as "Normal weight." Therefore, they are not
95+
considered overweight.
96+
97+
Tool Calls:
98+
Tool Execution: calculate_bmi
99+
Args: {'weight': 80, 'height': 1.8}
100+
Result: {'bmi': 24.69, 'status': 'Normal weight'}
101+
102+
103+
User: My friend weighs 65 kg with height 1.7 meters, check their BMI.
104+
Assistant: Your friend's BMI is 22.49, which is categorized as "Normal weight."
105+
106+
Tool Calls:
107+
Tool Execution: calculate_bmi
108+
Args: {'weight': 65, 'height': 1.7}
109+
Result: {'bmi': 22.49, 'status': 'Normal weight'}
110+
===============================================================================
111+
'''
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# ========= Copyright 2023-2026 @ CAMEL-AI.org. All Rights Reserved. =========
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
# ========= Copyright 2023-2026 @ CAMEL-AI.org. All Rights Reserved. =========
14+
import pytest
15+
16+
from camel.toolkits import FunctionTool, tool
17+
18+
19+
@tool()
20+
def add(
21+
a: int,
22+
b: int = 0,
23+
) -> int:
24+
r"""Add two numbers and return their sum.
25+
26+
Args:
27+
a (int): The first number to add.
28+
b (int, optional): The second number to add.
29+
(default: :obj:`0`)
30+
31+
Returns:
32+
int: The sum of the two numbers.
33+
"""
34+
return a + b
35+
36+
37+
@tool(synthesize_output=True)
38+
def format_result(
39+
result: int,
40+
) -> str:
41+
r"""Format the calculation result.
42+
43+
Args:
44+
result (int): The number to format.
45+
46+
Returns:
47+
str: A formatted string.
48+
"""
49+
return f"Result: {result}"
50+
51+
52+
def test_basic_tool_decorator():
53+
r"""Test basic functionality of the tool decorator."""
54+
assert isinstance(add, FunctionTool)
55+
56+
assert add(1, 2) == 3
57+
assert add(5) == 5
58+
59+
60+
def test_tool_schema_generation():
61+
r"""Test schema generation of the decorated function."""
62+
schema = add.get_openai_tool_schema()
63+
64+
assert schema["type"] == "function"
65+
assert "function" in schema
66+
67+
func_schema = schema["function"]
68+
assert func_schema["name"] == "add"
69+
assert "description" in func_schema
70+
assert "parameters" in func_schema
71+
72+
params = func_schema["parameters"]
73+
assert "properties" in params
74+
assert "a" in params["properties"]
75+
assert "b" in params["properties"]
76+
assert params["properties"]["a"]["type"] == "integer"
77+
assert "description" in params["properties"]["a"]
78+
79+
80+
def test_tool_with_synthesis():
81+
r"""Test the tool decorator with output synthesis enabled."""
82+
assert format_result.synthesize_output is True
83+
84+
result = format_result(42)
85+
assert isinstance(result, str)
86+
assert "42" in result
87+
88+
89+
def test_tool_without_parentheses():
90+
r"""Test the tool decorator without parentheses (@tool instead of
91+
@tool()).
92+
"""
93+
94+
@tool
95+
def subtract(a: int, b: int = 0) -> int:
96+
r"""Subtract two numbers.
97+
98+
Args:
99+
a (int): The first number.
100+
b (int, optional): The number to subtract. (default: :obj:`0`)
101+
102+
Returns:
103+
int: The difference.
104+
"""
105+
return a - b
106+
107+
assert isinstance(subtract, FunctionTool)
108+
assert subtract(5, 3) == 2
109+
assert subtract(10) == 10
110+
111+
schema = subtract.get_openai_tool_schema()
112+
assert schema["function"]["name"] == "subtract"
113+
114+
115+
def test_custom_schema():
116+
r"""Test the tool decorator with custom schema."""
117+
custom_schema = {
118+
"type": "function",
119+
"function": {
120+
"name": "custom_add",
121+
"description": "Custom add function",
122+
"parameters": {
123+
"type": "object",
124+
"properties": {
125+
"a": {"type": "integer", "description": "First number"},
126+
"b": {"type": "integer", "description": "Second number"},
127+
},
128+
},
129+
},
130+
}
131+
132+
@tool(openai_tool_schema=custom_schema)
133+
def custom_add(a: int, b: int = 0) -> int:
134+
r"""Custom add function."""
135+
return a + b
136+
137+
schema = custom_add.get_openai_tool_schema()
138+
assert schema["function"]["name"] == "custom_add"
139+
assert schema["function"]["description"] == "Custom add function"
140+
141+
142+
if __name__ == "__main__":
143+
pytest.main([__file__])

0 commit comments

Comments
 (0)