Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2023-2026 @ CAMEL-AI.org. All Rights Reserved. =========
import ast
import json
import re
from typing import Any, Dict, List, Optional
Expand Down Expand Up @@ -41,6 +42,26 @@ class HermesFunctionFormatter(
):
r"""Hermes-style function calling format implementation with validation"""

@staticmethod
def _loads(raw: str) -> Any:
r"""Parse a Hermes tool-call/response object into a Python value.

Real Hermes output is JSON, so parse it as JSON first. Fall back to
:func:`ast.literal_eval` for legacy Python-``repr`` payloads (single
quotes, ``True``/``False``/``None``) that earlier CAMEL versions
emitted, so older serialized data keeps round-tripping.

Args:
raw (str): The raw object substring captured from the message.

Returns:
Any: The parsed object (typically a ``dict``).
"""
try:
return json.loads(raw)
except json.JSONDecodeError:
return ast.literal_eval(raw)

def extract_tool_calls(self, message: str) -> List[HermesToolCall]:
r"""Extracts all tool calls from the provided message string.

Expand All @@ -57,7 +78,7 @@ def extract_tool_calls(self, message: str) -> List[HermesToolCall]:

for match in matches:
try:
call_dict = json.loads(match.group(1).replace("'", '"'))
call_dict = self._loads(match.group(1))
tool_calls.append(HermesToolCall.model_validate(call_dict))
except Exception as e:
print(f"Warning: Failed to parse tool call: {e}")
Expand All @@ -83,8 +104,7 @@ def extract_tool_response(

if match:
try:
response_json = match.group(1)
response_dict = json.loads(response_json.replace("'", '"'))
response_dict = self._loads(match.group(1))
return HermesToolResponse.model_validate(response_dict)
except Exception as e:
print(f"Warning: Failed to parse tool response: {e}")
Expand All @@ -109,10 +129,11 @@ def format_tool_call(
format.
"""
tool_call_dict = {"name": func_name, "arguments": args}
tool_call_json = json.dumps(tool_call_dict, ensure_ascii=False)

if content:
return f"{content}\n<tool_call>\n{tool_call_dict}\n</tool_call>"
return f"<tool_call>\n{tool_call_dict}\n</tool_call>"
return f"{content}\n<tool_call>\n{tool_call_json}\n</tool_call>"
return f"<tool_call>\n{tool_call_json}\n</tool_call>"

def format_tool_response(self, func_name: str, result: Any) -> str:
r"""Formats a tool response message with the given function name and
Expand All @@ -128,4 +149,5 @@ def format_tool_response(self, func_name: str, result: Any) -> str:
format.
"""
response_dict = {"name": func_name, "content": result}
return f"<tool_response>\n{response_dict}\n</tool_response>"
response_json = json.dumps(response_dict, ensure_ascii=False)
return f"<tool_response>\n{response_json}\n</tool_response>"
56 changes: 56 additions & 0 deletions test/messages/test_func_message.py
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,62 @@ def test_convert_function_call_and_response_to_from_sharegpt_hermes(
assert function_result_message == reconverted_function_result


def test_hermes_sharegpt_roundtrip_preserves_non_string_args():
r"""Tool-call args with apostrophes, booleans, None and floats must
survive a ``to_sharegpt()`` -> ``from_sharegpt()`` round-trip.

They previously did not: the formatter serialized args with Python
``repr`` and parsed them by swapping ``'`` for ``"``, which corrupts any
string containing an apostrophe and rejects ``True``/``False``/``None``.
The tool call then failed to parse and silently degraded to a plain
``BaseMessage`` with the arguments lost.
"""
args = {
"note": "it's sunny",
"active": True,
"missing": None,
"ratio": 0.5,
"city": "London",
}
message = FunctionCallingMessage(
role_name="assistant",
role_type=RoleType.ASSISTANT,
meta_dict=None,
content="",
func_name="note_tool",
args=args,
tool_call_id=None,
)

sharegpt = message.to_sharegpt()
reconverted = BaseMessage.from_sharegpt(
sharegpt, function_format=HermesFunctionFormatter()
)

assert isinstance(reconverted, FunctionCallingMessage)
assert reconverted.func_name == "note_tool"
assert reconverted.args == args


def test_hermes_extract_tool_calls_parses_legacy_repr_payload():
r"""Legacy single-quoted Python-``repr`` ``<tool_call>`` payloads (as
emitted by earlier CAMEL versions) must still parse, so previously
serialized data keeps round-tripping."""
formatter = HermesFunctionFormatter()
legacy = (
"<tool_call>\n"
"{'name': 'note_tool', 'arguments': {'city': 'London', "
"'active': True}}\n"
"</tool_call>"
)

calls = formatter.extract_tool_calls(legacy)

assert len(calls) == 1
assert calls[0].name == "note_tool"
assert calls[0].arguments == {"city": "London", "active": True}


def test_function_func_message_to_openai_assistant_message(
function_result_message: FunctionCallingMessage,
):
Expand Down
Loading