diff --git a/hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py b/hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py index a85fcce45f..51ab687493 100644 --- a/hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py +++ b/hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py @@ -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)) diff --git a/hindsight-api-slim/hindsight_api/engine/reflect/agent.py b/hindsight-api-slim/hindsight_api/engine/reflect/agent.py index 1af4c612fa..b42f2936c1 100644 --- a/hindsight-api-slim/hindsight_api/engine/reflect/agent.py +++ b/hindsight-api-slim/hindsight_api/engine/reflect/agent.py @@ -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, @@ -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, ) @@ -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, ) 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 @@ -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, ) diff --git a/hindsight-api-slim/tests/test_deepseek_tool_call_compat.py b/hindsight-api-slim/tests/test_deepseek_tool_call_compat.py index 5bc8ba15d7..0c8298536e 100644 --- a/hindsight-api-slim/tests/test_deepseek_tool_call_compat.py +++ b/hindsight-api-slim/tests/test_deepseek_tool_call_compat.py @@ -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.""" diff --git a/hindsight-api-slim/tests/test_reflect_agent.py b/hindsight-api-slim/tests/test_reflect_agent.py index 0b79c77f8a..a9dbb29871 100644 --- a/hindsight-api-slim/tests/test_reflect_agent.py +++ b/hindsight-api-slim/tests/test_reflect_agent.py @@ -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 @@ -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}] } @@ -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