Skip to content
Open
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
24 changes: 20 additions & 4 deletions miles/rollout/generate_utils/tool_call_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from miles.utils.types import Sample

_DUMMY_USER = {"role": "user", "content": "dummy"}
_INVALID_TOOL_ARGUMENTS_MESSAGE = "Error: Tool arguments must be a valid JSON object."


def create_tool_call_parser(tool_specs, tool_call_parser):
Expand All @@ -40,21 +41,36 @@ async def _execute_tool_call(
) -> dict[str, Any]:
if isinstance(call, ChatCompletionMessageToolCall):
name = call.function.name
params = json.loads(call.function.arguments) if call.function.arguments else {}
raw_arguments = call.function.arguments
tool_call_id = call.id
elif isinstance(call, ToolCallItem):
name = call.name
params = json.loads(call.parameters) if call.parameters else {}
raw_arguments = call.parameters
tool_call_id = f"call_{uuid.uuid4().hex[:24]}"
else:
raise TypeError(f"Unsupported tool call type: {type(call)}")

result = await execute_one(name, params)
assert isinstance(result, str)
params = _parse_tool_arguments(raw_arguments)
if params is None:
result = _INVALID_TOOL_ARGUMENTS_MESSAGE
else:
result = await execute_one(name, params)
assert isinstance(result, str)

return {"role": "tool", "tool_call_id": tool_call_id, "content": result, "name": name}


def _parse_tool_arguments(raw_arguments: str | None) -> dict[str, Any] | None:
"""Return executor kwargs, or None when recognized arguments are invalid."""
if not raw_arguments:
return {}
try:
params = json.loads(raw_arguments)
except json.JSONDecodeError:
return None
return params if isinstance(params, dict) else None


def update_sample_with_tool_responses(sample: Sample, tool_messages: list[dict[str, Any]], tokenizer):
next_obs_tokens_ids: list[int] = tokenize_tool_responses(tool_messages, tokenizer=tokenizer)
sample.response += tokenizer.decode(next_obs_tokens_ids)
Expand Down
64 changes: 63 additions & 1 deletion tests/fast/rollout/generate_hub/test_tool_call_utils.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
from unittest.mock import AsyncMock

import pytest
from openai.types.chat import ChatCompletionMessageToolCall
from sglang.srt.function_call.core_types import ToolCallItem

from miles.rollout.generate_utils.tool_call_utils import _DUMMY_USER, _build_dummy_assistant, tokenize_tool_responses
from miles.rollout.generate_utils.tool_call_utils import (
_DUMMY_USER,
_build_dummy_assistant,
_execute_tool_call,
tokenize_tool_responses,
)
from miles.utils.processing_utils import load_tokenizer

TOOL_CALL_TEST_MODELS = [
Expand Down Expand Up @@ -59,6 +68,59 @@
]


def _make_tool_call(call_type: str, arguments: str) -> ToolCallItem | ChatCompletionMessageToolCall:
if call_type == "sglang":
return ToolCallItem(tool_index=0, name="bash", parameters=arguments)
return ChatCompletionMessageToolCall(
id="call_test",
type="function",
function={"name": "bash", "arguments": arguments},
)


class TestExecuteToolCall:
@pytest.mark.asyncio
@pytest.mark.parametrize("call_type", ["sglang", "openai"])
@pytest.mark.parametrize(
("arguments", "expected_params"),
[
('{"cmd": "pytest"}', {"cmd": "pytest"}),
("", {}),
],
)
async def test_valid_object_arguments_reach_executor(self, call_type, arguments, expected_params):
execute_one = AsyncMock(return_value="ok")

message = await _execute_tool_call(_make_tool_call(call_type, arguments), execute_one)

execute_one.assert_awaited_once_with("bash", expected_params)
assert message["role"] == "tool"
assert message["content"] == "ok"
assert message["name"] == "bash"
assert message["tool_call_id"]

@pytest.mark.asyncio
@pytest.mark.parametrize("call_type", ["sglang", "openai"])
@pytest.mark.parametrize(
"arguments",
[
pytest.param('{"cmd": "pytest"', id="malformed"),
pytest.param("[1, 2]", id="list"),
pytest.param("42", id="number"),
],
)
async def test_invalid_arguments_return_tool_error_without_execution(self, call_type, arguments):
execute_one = AsyncMock(return_value="must not run")

message = await _execute_tool_call(_make_tool_call(call_type, arguments), execute_one)

execute_one.assert_not_awaited()
assert message["role"] == "tool"
assert message["content"] == "Error: Tool arguments must be a valid JSON object."
assert message["name"] == "bash"
assert message["tool_call_id"]


class TestTokenizeToolResponses:
@pytest.mark.parametrize("model_name", ["Qwen/Qwen3-0.6B"])
def test_snapshot(self, model_name):
Expand Down
Loading