Skip to content

Commit 513e67d

Browse files
fix(ollama): generate unique toolUseId instead of reusing tool name (#2053)
1 parent 2eaff9c commit 513e67d

2 files changed

Lines changed: 89 additions & 22 deletions

File tree

src/strands/models/ollama.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import json
77
import logging
8+
import uuid
89
from collections.abc import AsyncGenerator
910
from typing import Any, TypeVar, cast
1011

@@ -124,7 +125,7 @@ def _format_request_message_contents(self, role: str, content: ContentBlock) ->
124125
"tool_calls": [
125126
{
126127
"function": {
127-
"name": content["toolUse"]["toolUseId"],
128+
"name": content["toolUse"]["name"],
128129
"arguments": content["toolUse"]["input"],
129130
}
130131
}
@@ -246,7 +247,8 @@ def format_chunk(self, event: dict[str, Any]) -> StreamEvent:
246247
return {"contentBlockStart": {"start": {}}}
247248

248249
tool_name = event["data"].function.name
249-
return {"contentBlockStart": {"start": {"toolUse": {"name": tool_name, "toolUseId": tool_name}}}}
250+
tool_use_id = f"tooluse_{uuid.uuid4().hex[:24]}"
251+
return {"contentBlockStart": {"start": {"toolUse": {"name": tool_name, "toolUseId": tool_use_id}}}}
250252

251253
case "content_delta":
252254
if event["data_type"] == "text":

tests/strands/models/test_ollama.py

Lines changed: 85 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import json
22
import logging
3+
import re
34
import unittest.mock
45

56
import pydantic
@@ -127,7 +128,12 @@ def test_format_request_with_image(model, model_id):
127128

128129
def test_format_request_with_tool_use(model, model_id):
129130
messages = [
130-
{"role": "assistant", "content": [{"toolUse": {"toolUseId": "calculator", "input": '{"expression": "2+2"}'}}]}
131+
{
132+
"role": "assistant",
133+
"content": [
134+
{"toolUse": {"toolUseId": "tool-use-id-123", "name": "calculator", "input": '{"expression": "2+2"}'}}
135+
],
136+
}
131137
]
132138

133139
tru_request = model.format_request(messages)
@@ -321,9 +327,11 @@ def test_format_chunk_content_start_tool(model):
321327
event = {"chunk_type": "content_start", "data_type": "tool", "data": mock_function}
322328

323329
tru_chunk = model.format_chunk(event)
324-
exp_chunk = {"contentBlockStart": {"start": {"toolUse": {"name": "calculator", "toolUseId": "calculator"}}}}
330+
tool_use = tru_chunk["contentBlockStart"]["start"]["toolUse"]
325331

326-
assert tru_chunk == exp_chunk
332+
assert tool_use["name"] == "calculator"
333+
assert tool_use["toolUseId"] != "calculator"
334+
assert len(tool_use["toolUseId"]) > 0
327335

328336

329337
def test_format_chunk_content_delta_text(model):
@@ -499,24 +507,27 @@ async def test_stream_with_tool_calls(ollama_client, model, agenerator, alist):
499507
response = model.stream(messages)
500508

501509
tru_events = await alist(response)
502-
exp_events = [
503-
{"messageStart": {"role": "assistant"}},
504-
{"contentBlockStart": {"start": {}}},
505-
{"contentBlockStart": {"start": {"toolUse": {"name": "calculator", "toolUseId": "calculator"}}}},
506-
{"contentBlockDelta": {"delta": {"toolUse": {"input": '{"expression": "2+2"}'}}}},
507-
{"contentBlockStop": {}},
508-
{"contentBlockDelta": {"delta": {"text": "I'll calculate that for you"}}},
509-
{"contentBlockStop": {}},
510-
{"messageStop": {"stopReason": "tool_use"}},
511-
{
512-
"metadata": {
513-
"usage": {"inputTokens": 8, "outputTokens": 15, "totalTokens": 23},
514-
"metrics": {"latencyMs": 2.0},
515-
}
516-
},
517-
]
518510

519-
assert tru_events == exp_events
511+
# Verify the tool use event has a unique ID (not equal to the tool name)
512+
tool_start_event = tru_events[2]
513+
tool_use = tool_start_event["contentBlockStart"]["start"]["toolUse"]
514+
assert tool_use["name"] == "calculator"
515+
assert tool_use["toolUseId"] != "calculator"
516+
517+
# Verify all other events
518+
assert tru_events[0] == {"messageStart": {"role": "assistant"}}
519+
assert tru_events[1] == {"contentBlockStart": {"start": {}}}
520+
assert tru_events[3] == {"contentBlockDelta": {"delta": {"toolUse": {"input": '{"expression": "2+2"}'}}}}
521+
assert tru_events[4] == {"contentBlockStop": {}}
522+
assert tru_events[5] == {"contentBlockDelta": {"delta": {"text": "I'll calculate that for you"}}}
523+
assert tru_events[6] == {"contentBlockStop": {}}
524+
assert tru_events[7] == {"messageStop": {"stopReason": "tool_use"}}
525+
assert tru_events[8] == {
526+
"metadata": {
527+
"usage": {"inputTokens": 8, "outputTokens": 15, "totalTokens": 23},
528+
"metrics": {"latencyMs": 2.0},
529+
}
530+
}
520531
expected_request = {
521532
"model": "m1",
522533
"messages": [{"role": "user", "content": "Calculate 2+2"}],
@@ -625,3 +636,57 @@ def test_format_request_filters_location_source_document(model, caplog):
625636
user_message = formatted_messages[0]
626637
assert user_message["content"] == "analyze this document"
627638
assert "Location sources are not supported by Ollama" in caplog.text
639+
640+
641+
def test_tool_use_id_is_unique_and_not_tool_name(model):
642+
"""Test that toolUseId is a unique UUID, not the tool name."""
643+
mock_function = unittest.mock.Mock()
644+
mock_function.function.name = "calculator"
645+
646+
event = {"chunk_type": "content_start", "data_type": "tool", "data": mock_function}
647+
648+
chunk1 = model.format_chunk(event)
649+
chunk2 = model.format_chunk(event)
650+
651+
tool_use1 = chunk1["contentBlockStart"]["start"]["toolUse"]
652+
tool_use2 = chunk2["contentBlockStart"]["start"]["toolUse"]
653+
654+
# toolUseId should not equal the tool name
655+
assert tool_use1["toolUseId"] != "calculator"
656+
assert tool_use2["toolUseId"] != "calculator"
657+
658+
# toolUseId should be unique across calls
659+
assert tool_use1["toolUseId"] != tool_use2["toolUseId"]
660+
661+
# toolUseId should follow the tooluse_<24-hex> convention used by other providers
662+
assert re.fullmatch(r"tooluse_[0-9a-f]{24}", tool_use1["toolUseId"])
663+
assert re.fullmatch(r"tooluse_[0-9a-f]{24}", tool_use2["toolUseId"])
664+
665+
# name should still be correct
666+
assert tool_use1["name"] == "calculator"
667+
assert tool_use2["name"] == "calculator"
668+
669+
670+
def test_format_request_uses_tool_name_not_tool_use_id(model, model_id):
671+
"""Test that format_request uses the 'name' field, not 'toolUseId', for the function name."""
672+
messages = [
673+
{
674+
"role": "assistant",
675+
"content": [
676+
{
677+
"toolUse": {
678+
"toolUseId": "unique-id-abc-123",
679+
"name": "calculator",
680+
"input": '{"expression": "1+1"}',
681+
}
682+
}
683+
],
684+
}
685+
]
686+
687+
request = model.format_request(messages)
688+
tool_call = request["messages"][0]["tool_calls"][0]
689+
690+
# The function name in the request must come from "name", not "toolUseId"
691+
assert tool_call["function"]["name"] == "calculator"
692+
assert tool_call["function"]["name"] != "unique-id-abc-123"

0 commit comments

Comments
 (0)