Skip to content
Closed
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
28 changes: 25 additions & 3 deletions hindsight-api-slim/hindsight_api/engine/providers/gemini_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,12 +83,17 @@ def _convert_messages_to_gemini(msg_list: list[dict[str, Any]]) -> _GeminiConver
"""
system_instruction: str | None = None
gemini_contents: list[genai_types.Content] = []
pending_tool_names_by_call_id: dict[str, str] = {}
i = 0
while i < len(msg_list):
msg = msg_list[i]
role = msg.get("role", "user")
content = msg.get("content", "")

if role != "tool" and pending_tool_names_by_call_id:
missing_ids = ", ".join(sorted(pending_tool_names_by_call_id))
raise ValueError(f"Gemini assistant tool calls require results before the next message: {missing_ids}")

if role == "system":
system_instruction = (system_instruction + "\n\n" + content) if system_instruction else content
i += 1
Expand All @@ -97,15 +102,22 @@ def _convert_messages_to_gemini(msg_list: list[dict[str, Any]]) -> _GeminiConver
while i < len(msg_list) and msg_list[i].get("role") == "tool":
tool_msg = msg_list[i]
tool_content = tool_msg.get("content", "")
tool_call_id = tool_msg["tool_call_id"]
tool_name = pending_tool_names_by_call_id.pop(tool_call_id, None)
if tool_name is None:
raise ValueError(f"Gemini tool result references unknown tool_call_id {tool_call_id!r}")
parts.append(
genai_types.Part(
function_response=genai_types.FunctionResponse(
name=tool_msg.get("name", ""),
name=tool_name,
response={"result": tool_content},
)
)
)
i += 1
if pending_tool_names_by_call_id:
missing_ids = ", ".join(sorted(pending_tool_names_by_call_id))
raise ValueError(f"Gemini assistant tool calls are missing results: {missing_ids}")
gemini_contents.append(genai_types.Content(role="user", parts=parts))
elif role == "assistant":
tool_calls_in_msg = msg.get("tool_calls", [])
Expand All @@ -114,8 +126,14 @@ def _convert_messages_to_gemini(msg_list: list[dict[str, Any]]) -> _GeminiConver
if content:
parts.append(genai_types.Part(text=content))
for tc in tool_calls_in_msg:
fn = tc.get("function", {})
fn_name = fn.get("name", "")
tool_call_id = tc["id"]
fn = tc["function"]
fn_name = fn["name"]
if tool_call_id in pending_tool_names_by_call_id:
raise ValueError(
f"Gemini assistant tool call id {tool_call_id!r} must be unique within its turn"
)
pending_tool_names_by_call_id[tool_call_id] = fn_name
fn_args_str = fn.get("arguments", "{}")
fn_args = parse_llm_json(fn_args_str)
thought_signature = tc.get("thought_signature")
Expand All @@ -132,6 +150,10 @@ def _convert_messages_to_gemini(msg_list: list[dict[str, Any]]) -> _GeminiConver
gemini_contents.append(genai_types.Content(role="user", parts=[genai_types.Part(text=content)]))
i += 1

if pending_tool_names_by_call_id:
missing_ids = ", ".join(sorted(pending_tool_names_by_call_id))
raise ValueError(f"Gemini assistant tool calls are missing results: {missing_ids}")

return _GeminiConversation(system_instruction=system_instruction, contents=gemini_contents)


Expand Down
3 changes: 0 additions & 3 deletions hindsight-api-slim/hindsight_api/engine/reflect/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1174,7 +1174,6 @@ def _log_completion(answer: str, iterations: int, forced: bool = False):
{
"role": "tool",
"tool_call_id": done_call.id,
"name": done_call.name, # Required by Gemini
"content": json.dumps(
{
"error": "You must search for information first. Use search_mental_models(), search_observations(), or recall() before providing your final answer."
Expand Down Expand Up @@ -1240,7 +1239,6 @@ def _log_completion(answer: str, iterations: int, forced: bool = False):
{
"role": "tool",
"tool_call_id": tc.id,
"name": tc.name,
"content": json.dumps(
{
"error": f"Tool '{_normalize_tool_name(tc.name)}' is not available. Use only the tools provided to you."
Expand Down Expand Up @@ -1344,7 +1342,6 @@ def _log_completion(answer: str, iterations: int, forced: bool = False):
{
"role": "tool",
"tool_call_id": tc.id,
"name": tc.name, # Required by Gemini
"content": json.dumps(output, default=str, ensure_ascii=False),
}
)
Expand Down
7 changes: 6 additions & 1 deletion hindsight-api-slim/tests/test_deepseek_tool_call_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM


TOOLS = [
{
"type": "function",
Expand Down Expand Up @@ -174,6 +173,12 @@ async def test_deepseek_tool_history_gets_empty_reasoning_content_fallback():

sent_messages = mock_create.call_args.kwargs["messages"]
assert sent_messages[1]["reasoning_content"] == ""
assert sent_messages[2] == {
"role": "tool",
"tool_call_id": "call_deepseek_123",
"content": "{}",
}
assert "name" not in sent_messages[2]
assert "reasoning_content" not in messages[1]


Expand Down
91 changes: 90 additions & 1 deletion hindsight-api-slim/tests/test_gemini_safety_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,31 @@ async def test_call_with_tools_applies_safety_settings():
]

await provider.call_with_tools(
messages=[{"role": "user", "content": "hi"}],
messages=[
{"role": "user", "content": "hi"},
{
"role": "assistant",
"tool_calls": [
{
"id": "call_test_tool",
"type": "function",
"function": {"name": "test_tool", "arguments": "{}"},
}
],
},
{"role": "tool", "tool_call_id": "call_test_tool", "content": '{"ok": true}'},
{
"role": "assistant",
"tool_calls": [
{
"id": "call_test_tool",
"type": "function",
"function": {"name": "test_tool", "arguments": "{}"},
}
],
},
{"role": "tool", "tool_call_id": "call_test_tool", "content": '{"ok": true}'},
],
tools=tools,
scope="test",
)
Expand All @@ -218,6 +242,71 @@ async def test_call_with_tools_applies_safety_settings():
s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings
]
assert "HARM_CATEGORY_HARASSMENT" in categories
contents = call_args.kwargs["contents"]
assert contents[-1].parts[0].function_response.name == "test_tool"

with pytest.raises(ValueError, match="missing results: call_test_tool"):
await provider.call_with_tools(
messages=[
{"role": "user", "content": "hi"},
{
"role": "assistant",
"tool_calls": [
{
"id": "call_test_tool",
"type": "function",
"function": {"name": "test_tool", "arguments": "{}"},
}
],
},
],
tools=tools,
scope="test",
)

with pytest.raises(ValueError, match="unknown tool_call_id 'call_unknown'"):
await provider.call_with_tools(
messages=[
{"role": "user", "content": "hi"},
{
"role": "assistant",
"tool_calls": [
{
"id": "call_test_tool",
"type": "function",
"function": {"name": "test_tool", "arguments": "{}"},
}
],
},
{"role": "tool", "tool_call_id": "call_unknown", "content": '{"ok": true}'},
],
tools=tools,
scope="test",
)

with pytest.raises(ValueError, match="must be unique within its turn"):
await provider.call_with_tools(
messages=[
{"role": "user", "content": "hi"},
{
"role": "assistant",
"tool_calls": [
{
"id": "call_test_tool",
"type": "function",
"function": {"name": "test_tool", "arguments": "{}"},
},
{
"id": "call_test_tool",
"type": "function",
"function": {"name": "test_tool", "arguments": "{}"},
},
],
},
],
tools=tools,
scope="test",
)


# ─── with_config() override ───────────────────────────────────────────────────
Expand Down
5 changes: 4 additions & 1 deletion hindsight-api-slim/tests/test_reflect_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,10 @@ async def test_fresh_mental_model_releases_forced_retrieval(self, mock_llm, mock
first_choice = mock_llm.call_with_tools.await_args_list[0].kwargs["tool_choice"]
assert first_choice == {"type": "function", "function": {"name": "search_mental_models"}}
assert mock_llm.call_with_tools.await_args_list[1].kwargs["tool_choice"] == "auto"
tool_result = mock_llm.call_with_tools.await_args_list[1].kwargs["messages"][-1]
assert tool_result["role"] == "tool"
assert tool_result["tool_call_id"] == "1"
assert "name" not in tool_result

@pytest.mark.asyncio
async def test_done_tool_answer_respects_max_tokens(self, mock_llm, mock_functions):
Expand Down Expand Up @@ -985,7 +989,6 @@ def test_count_messages_tokens_with_tool_result(self):
{
"role": "tool",
"tool_call_id": "x",
"name": "recall",
"content": '{"memories": ['
+ ", ".join(
[
Expand Down