Fix native tool-calling follow-up round on Gemini and Ollama (#867)
The agent's multi-round (tool-result) follow-up request was rejected with
HTTP 400 on two providers, so tools ran but the agent never produced an answer:
- OpenAI-compatible streaming (Gemini 3) dropped the per-call thought_signature
and collided parallel tool calls, which arrive with index=None: they all
landed in slot 0, overwriting the first call's name and corrupting its
arguments by concatenation, so the follow-up request 400'd. Capture and replay
each call's extra_content (thought_signature), and give every parallel call
its own accumulator slot (allocated above the max key, so sparse or mixed
indices can't collide).
- Native Ollama /api/chat expects object tool-call arguments, but Odysseus
carries them as a JSON string, which Ollama rejected ("Value looks like
object, but can't find closing '}' symbol"). Convert them to objects in the
Ollama payload builder.
Both compose with the no-prose null-content sanitize fix from #862.
Tested: python -m pytest tests/test_llm_core_streaming.py
tests/test_llm_core_ollama.py tests/test_agent_loop.py (53 pass), and
python -m py_compile src/llm_core.py src/agent_loop.py.
This commit is contained in:
@@ -301,3 +301,39 @@ class TestAppendToolResultsNativeContent:
|
||||
assert messages[0]["content"] == "thinking..."
|
||||
assert messages[1]["role"] == "user"
|
||||
assert "tool output" in messages[1]["content"]
|
||||
|
||||
|
||||
class TestAppendToolResultsThoughtSignature:
|
||||
"""Gemini 3 returns an opaque thought_signature (in extra_content) with each
|
||||
function call and rejects the follow-up turn with HTTP 400 unless it is
|
||||
echoed back on the assistant tool_call. _append_tool_results must replay it
|
||||
when present, and omit the field entirely otherwise (other providers never
|
||||
send it)."""
|
||||
|
||||
def test_extra_content_is_replayed_when_present(self):
|
||||
native = [{
|
||||
"id": "call_g",
|
||||
"name": "app_api",
|
||||
"arguments": '{"action": "get_memory"}',
|
||||
"extra_content": {"google": {"thought_signature": "EuIDCt8DAQ=="}},
|
||||
}]
|
||||
messages = []
|
||||
_append_tool_results(
|
||||
messages, "", native, [{}], ["mem"],
|
||||
used_native=True, round_num=1,
|
||||
)
|
||||
tc = messages[0]["tool_calls"][0]
|
||||
assert tc["extra_content"] == {"google": {"thought_signature": "EuIDCt8DAQ=="}}
|
||||
# function payload is still well-formed alongside it
|
||||
assert tc["function"]["name"] == "app_api"
|
||||
assert tc["id"] == "call_g"
|
||||
|
||||
def test_no_extra_content_key_when_absent(self):
|
||||
native = [{"id": "call_o", "name": "app_api", "arguments": "{}"}]
|
||||
messages = []
|
||||
_append_tool_results(
|
||||
messages, "", native, [{}], ["r"],
|
||||
used_native=True, round_num=1,
|
||||
)
|
||||
# No empty/None extra_content leaks onto non-Gemini tool calls.
|
||||
assert "extra_content" not in messages[0]["tool_calls"][0]
|
||||
|
||||
Reference in New Issue
Block a user