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 @@ -1406,7 +1406,7 @@ async def call_with_tools(

if max_completion_tokens is not None:
call_params[self._max_tokens_param_name()] = max_completion_tokens
if temperature is not None:
if temperature is not None and not self._supports_reasoning_model():
# MiniMax requires temperature in (0.0, 1.0] — clamp accordingly
if self.provider == "minimax":
temperature = max(0.01, min(temperature, 1.0))
Expand Down
6 changes: 6 additions & 0 deletions hindsight-api-slim/hindsight_api/engine/reflect/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,9 @@ def _model_for(schema: dict, name: str) -> type:
response_format=DynamicModel,
scope="reflect_structured",
strict_schema=get_config().llm_strict_schema_reflect,
# Schema extraction should be deterministic. The configured reflect
# temperature applies to answer generation, not this parsing pass.
temperature=0.0,
max_completion_tokens=max_tokens,
max_retries=1,
initial_backoff=0.25,
Expand Down Expand Up @@ -687,6 +690,7 @@ async def _tracked_llm_call(prompt: str, trace_scope: str, system_prompt: str, c
{"role": "user", "content": prompt},
],
scope="reflect",
temperature=get_config().llm_temperature_reflect,
max_completion_tokens=completion_cap,
return_usage=True,
)
Expand Down Expand Up @@ -878,6 +882,7 @@ async def _forced_final_synthesis(iterations_completed: int) -> ReflectAgentResu
tools=tools,
scope="reflect_tool_call",
tool_choice=iter_tool_choice,
temperature=get_config().llm_temperature_reflect,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agent.py:874 is the only in-repo caller of call_with_tools, so this is the first time a temperature reaches that path — and OpenAICompatibleLLM.call_with_tools does not apply the reasoning-model suppression its own call() does (providers/openai_compatible_llm.py:930: if temperature is not None and not is_reasoning_model, versus :1363 where the guard is absent). OpenAIResponsesLLM has it on both paths (:456, :574).

Concrete break: provider=openai, model=gpt-5 (or o1/o3) routes to OpenAICompatibleLLM; with no config set the reflect tool loop now sends temperature=0.9, which chat/completions rejects with a 400 ('temperature' does not support 0.9 with this model), so every reflect fails after retries. Before this change no temperature was sent and it worked.

Suggested fix in the provider, to keep the two methods symmetric:

if temperature is not None and not self._supports_reasoning_model():
    ...
    call_params["temperature"] = temperature

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 529b4abb1: call_with_tools now mirrors call() and omits temperature for reasoning models. Added a regression that captures the outgoing request.

)
if incremental_caching and iter_tool_choice is LLM_TOOL_CHOICE_AUTO and rolling_cache_name is not None:
ct_kwargs["cached_prefix"] = rolling_cache_name
Expand Down Expand Up @@ -1307,6 +1312,7 @@ async def _process_done_tool(
{"role": "user", "content": rewrite_user},
],
scope="reflect",
temperature=get_config().llm_temperature_reflect,
max_completion_tokens=get_config().reflect_max_completion_tokens,
return_usage=True,
)
Expand Down
16 changes: 16 additions & 0 deletions hindsight-api-slim/tests/test_deepseek_tool_call_compat.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,22 @@ def test_deepseek_reasoning_models_still_use_reasoning_parameters():
assert llm._supports_reasoning_model() is True


@pytest.mark.asyncio
async def test_reasoning_tool_call_omits_temperature():
llm = _make_deepseek_llm("deepseek-v4-pro")

with patch.object(llm._client.chat.completions, "create", new_callable=AsyncMock) as mock_create:
mock_create.return_value = _make_tool_call_response()
await llm.call_with_tools(
messages=[{"role": "user", "content": "Search observations."}],
tools=TOOLS,
temperature=0.9,
max_retries=0,
)

assert "temperature" not in mock_create.call_args.kwargs


@pytest.mark.asyncio
async def test_deepseek_named_tool_choice_filters_tools_but_omits_tool_choice():
"""DeepSeek rejects required/named tool_choice but accepts a narrowed tools list."""
Expand Down
31 changes: 30 additions & 1 deletion hindsight-api-slim/tests/test_reflect_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,27 @@ async def test_structured_output_forwards_max_tokens(self):
call_kwargs = llm.call.await_args.kwargs
assert call_kwargs["max_completion_tokens"] == 4096

@pytest.mark.asyncio
async def test_structured_output_uses_deterministic_temperature(self, monkeypatch):
"""Structured extraction is deterministic even when reflect generation is not."""
config = MagicMock(llm_temperature_reflect=0.17, llm_strict_schema_reflect=False)
monkeypatch.setattr("hindsight_api.engine.reflect.agent.get_config", lambda: config)
llm = MagicMock()
llm.call = AsyncMock(side_effect=RuntimeError("stop after request capture"))

await _generate_structured_output(
answer="Alice prefers concise engineering updates.",
response_schema={
"type": "object",
"properties": {"summary": {"type": "string"}},
"required": ["summary"],
},
llm_config=llm,
reflect_id="test-reflect",
)

assert llm.call.await_args.kwargs["temperature"] == 0.0

@pytest.mark.asyncio
async def test_structured_output_omits_budget_when_unset(self):
"""With no max_tokens (default), the structured call forwards
Expand Down Expand Up @@ -332,7 +353,13 @@ async def test_output_language_reaches_the_done_path(self, mock_llm, mock_functi
assert "exclusively in English" in done_tool["function"]["description"]

@pytest.mark.asyncio
async def test_done_tool_answer_respects_max_tokens(self, mock_llm, mock_functions):
async def test_done_tool_answer_respects_max_tokens(self, mock_llm, mock_functions, monkeypatch):
config = MagicMock(
reflect_prompt_cache_enabled=False,
reflect_max_completion_tokens=None,
llm_temperature_reflect=0.17,
)
monkeypatch.setattr("hindsight_api.engine.reflect.agent.get_config", lambda: config)
mock_functions["search_mental_models_fn"].return_value = {
"mental_models": [{"id": "mm-1", "name": "User prefs", "content": "Fresh content.", "is_stale": False}]
}
Expand Down Expand Up @@ -368,6 +395,8 @@ async def test_done_tool_answer_respects_max_tokens(self, mock_llm, mock_functio
# so thinking models don't truncate the rewrite mid-word (#3365), while the
# target still reaches the model through the prompt.
assert mock_llm.call.await_args.kwargs["max_completion_tokens"] is None
assert mock_llm.call.await_args.kwargs["temperature"] == 0.17
assert all(call.kwargs["temperature"] == 0.17 for call in mock_llm.call_with_tools.await_args_list)
rewrite_user_msg = mock_llm.call.await_args.kwargs["messages"][1]["content"]
assert "Target budget: 8 tokens" in rewrite_user_msg
assert result.usage.total_tokens == 150
Expand Down
Loading