Skip to content
Open
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 @@ -1489,14 +1489,29 @@ async def call_with_tools(
request_tool_choice: str | None
if tool_choice.mode is LLMToolChoiceMode.NAMED:
forced_name = tool_choice.selected_function_name
filtered = [tool for tool in tools if tool.get("function", {}).get("name") == forced_name]
if len(filtered) != 1:
matching = [tool for tool in tools if tool.get("function", {}).get("name") == forced_name]
if len(matching) != 1:
raise ValueError(
f"Named tool_choice must reference exactly one declared tool; "
f"found {len(filtered)} definitions for {forced_name!r}"
f"found {len(matching)} definitions for {forced_name!r}"
)
tools = filtered
request_tool_choice = LLMToolChoiceMode.REQUIRED.value
# Providers that silently drop tool_choice="required" (ollama/lmstudio)
# used to narrow tools[] to the forced function so the call stayed
# practically forced under auto. That mutates the tools schema every
# forced turn and forces hybrid/Ollama models to full-reprefill
# (#3865). Keep tools byte-identical and encode the force as a
# conversation suffix instead.
if self._drops_tool_choice_required():
messages = list(messages) + [
{
"role": "user",
"content": f"You must call `{forced_name}` now.",
}
]
request_tool_choice = None
else:
tools = matching
request_tool_choice = LLMToolChoiceMode.REQUIRED.value
elif tool_choice.mode is LLMToolChoiceMode.AUTO:
request_tool_choice = None
else:
Expand All @@ -1521,9 +1536,9 @@ async def call_with_tools(
# LM Studio and Ollama silently drop tool_choice="required", returning an
# empty tool_calls array instead of forcing a call (#1563/#1179).
# Downgrade to auto (None) so the model still gets to call a tool. Named
# tool_choice dicts were already normalized to "required" + a single
# filtered tool above, so the call stays practically forced even under
# auto. Generic OpenAI-compatible endpoints retain the canonical
# tool_choice on these providers already kept the full tools list and
# appended a force suffix above (#3865); required-only calls just omit
# the field. Generic OpenAI-compatible endpoints retain the canonical
# ``required`` contract regardless of whether they use a custom base URL.
if request_tool_choice == LLMToolChoiceMode.REQUIRED.value and self._drops_tool_choice_required():
request_tool_choice = None
Expand Down
80 changes: 56 additions & 24 deletions hindsight-api-slim/hindsight_api/engine/reflect/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

from ...cancellation import OperationCancelledError
from ...config import get_config
from ..llm_interface import LLM_TOOL_CHOICE_AUTO, LLMToolChoice
from ..llm_interface import LLM_TOOL_CHOICE_AUTO, LLM_TOOL_CHOICE_NONE, LLMToolChoice
from ..llm_trace import LLMQueueWait, reset_queue_wait_sink, set_queue_wait_sink
from ..llm_transport import describe_llm_error
from .models import DirectiveInfo, LLMCall, ReflectAgentResult, StructuredOutputResult, TokenUsageSummary, ToolCall
Expand All @@ -25,9 +25,9 @@
_extract_directive_rules,
build_agent_user_prompt,
build_chunk_claims_prompt,
build_final_prompt,
build_final_system_prompt,
build_reduce_prompt,
build_stable_synthesis_nudge,
build_system_prompt_for_tools,
split_context_history,
)
Expand Down Expand Up @@ -768,36 +768,68 @@ async def _tracked_llm_call(
return response.strip()

async def _forced_final_synthesis(iterations_completed: int) -> ReflectAgentResult:
"""Answer without tools from the accumulated tool results.

When the accumulated results fit the prompt budget this is one LLM call,
exactly as before. When they exceed it, they are SPLIT — not truncated:
each budget-sized chunk is compressed in parallel into dated, cited
claims, and one reduce call synthesizes the answer from every chunk's
claims. The old behavior dropped any over-budget block whole (plus all
older ones), which produced confident "no information" answers carrying
hundreds of citations the synthesis model never saw (#3122).
"""Answer from the accumulated tool results.

When the accumulated results fit the prompt budget this is one LLM call
that *extends* the existing agent conversation: same system prompt, same
``tools`` array, plus a user nudge to produce the final answer with no
further tool calls (#3865). Swapping the system prompt / dropping tools
used to diverge tools-first chat templates at token 3 and force a full
prefill on hybrid/Ollama models.

When results exceed the budget they are SPLIT — not truncated: each
budget-sized chunk is compressed in parallel into dated, cited claims,
and one reduce call synthesizes the answer from every chunk's claims.
The old behavior dropped any over-budget block whole (plus all older
ones), which produced confident "no information" answers carrying
hundreds of citations the synthesis model never saw (#3122). Split
synthesis still uses dedicated prompts because the conversation no
longer fits as a prefix extension.
"""
nonlocal total_input_tokens, total_output_tokens, total_cached_tokens, total_thoughts_tokens
final_system = build_final_system_prompt(bank_profile.get("mission"), llm_output_language, directives)
chunks = split_context_history(context_history, max_context_tokens)
# Every call below uses the transport-level cap, never the caller's
# max_tokens: that is a visible-length target carried as a prompt
# directive (#3365), and capping the transport with it would truncate
# thinking models mid-word — or, on the map calls, starve the evidence
# extraction.
if len(chunks) <= 1:
prompt = build_final_prompt(
query,
context_history,
bank_profile,
context,
max_context_tokens=max_context_tokens,
max_tokens=max_tokens,
llm_output_language=llm_output_language,
# Prefix-stable synthesis: keep system + tools, append a nudge.
synth_messages = list(messages) + [
{
"role": "user",
"content": build_stable_synthesis_nudge(
max_tokens=max_tokens,
llm_output_language=llm_output_language,
),
}
]
llm_start = time.time()
result = await llm_config.call_with_tools(
messages=synth_messages,
tools=tools,
scope="reflect",
tool_choice=LLM_TOOL_CHOICE_NONE,
temperature=get_config().llm_temperature_reflect,
max_completion_tokens=synthesis_max_completion_tokens,
)
answer = await _tracked_llm_call(prompt, "final", final_system, synthesis_max_completion_tokens)
llm_duration = int((time.time() - llm_start) * 1000)
total_input_tokens += result.input_tokens
total_output_tokens += result.output_tokens
total_cached_tokens += getattr(result, "cached_tokens", 0) or 0
total_thoughts_tokens += getattr(result, "thoughts_tokens", 0) or 0
llm_trace.append(
{
"scope": "final",
"duration_ms": llm_duration,
"input_tokens": result.input_tokens,
"output_tokens": result.output_tokens,
}
)
answer = (result.content or "").strip()
else:
final_system = build_final_system_prompt(bank_profile.get("mission"), llm_output_language, directives)

log = logger.warning if len(chunks) > _SPLIT_SYNTHESIS_WARN_CHUNKS else logger.info
log(
f"[REFLECT {reflect_id}] Retrieved data exceeds the context budget; "
Expand Down Expand Up @@ -828,7 +860,7 @@ async def _forced_final_synthesis(iterations_completed: int) -> ReflectAgentResu
answer = await _tracked_llm_call(prompt, "final", final_system, synthesis_max_completion_tokens)

if not (answer or "").strip():
# Tools were disabled for this call and the model still returned nothing.
# The synthesis call returned no text.
# There is no answer to hand back, so the run failed -- see #2959 for why
# a placeholder here is worse than an exception.
raise ReflectNoAnswerError(
Expand Down Expand Up @@ -875,7 +907,7 @@ async def _forced_final_synthesis(iterations_completed: int) -> ReflectAgentResu
is_last = iteration == max_iterations - 1

if is_last:
# Force text response on last iteration - no tools
# Force final synthesis on last iteration (prefix-stable when in budget)
return await _forced_final_synthesis(iteration + 1)

# Proactive context-window guard: if accumulated messages would exceed the
Expand Down Expand Up @@ -1029,7 +1061,7 @@ async def _forced_final_synthesis(iterations_completed: int) -> ReflectAgentResu
f"produced no usable tool call (the transport may not support function calling)." + detail
)
# Model tool-called earlier and is now stopping: fall through to a clean
# forced final synthesis (tools disabled, prose expected).
# forced final synthesis (prefix-stable when in budget; prose expected).
return await _forced_final_synthesis(iteration + 1)

# The model produced at least one tool call reflect could parse: it can
Expand Down
23 changes: 23 additions & 0 deletions hindsight-api-slim/hindsight_api/engine/reflect/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -706,6 +706,29 @@ def _length_directive(max_tokens: int | None) -> str | None:
)


def build_stable_synthesis_nudge(
max_tokens: int | None = None,
llm_output_language: str | None = None,
) -> str:
"""User-message suffix for prefix-stable final synthesis (#3865).

Keeps the agent system prompt and ``tools`` array unchanged so synthesis is
a pure extension of the conversation. Hybrid / Ollama templates that render
tools before system text can then resume from a prior checkpoint instead of
full-reprefilling when the old path swapped the system prompt and dropped tools.
"""
parts = [
"Produce the final answer now. Do not make any further tool calls.",
"",
"## Instructions",
_FINAL_INSTRUCTIONS,
]
length_directive = _length_directive(max_tokens)
if length_directive is not None:
parts.append(length_directive)
return "\n".join(parts) + output_language_directive(llm_output_language)


def build_final_prompt(
query: str,
context_history: list[dict],
Expand Down
51 changes: 27 additions & 24 deletions hindsight-api-slim/tests/test_lmstudio_tool_choice.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
LM Studio (and Ollama) reject this format with HTTP 400:
"Tool choice of type 'function' is not supported. Use 'auto', 'none', or 'required'."

The fix should convert named tool_choice to "required" and filter the tools list
to only the requested tool for providers that don't support named tool_choice.
For providers that drop tool_choice="required" (lmstudio/ollama), keep the full
tools list byte-identical and append a user-message suffix forcing the named
tool (#3865). Other providers still convert named tool_choice to "required" + a
filtered tools list.
"""

import json
Expand Down Expand Up @@ -157,13 +159,17 @@ async def test_lmstudio_named_tool_choice_no_longer_causes_400(self):
assert result.tool_calls[0].name == "search_mental_models"

sent_kwargs = mock_create.call_args.kwargs
# The named dict is normalized to "required" + a single filtered tool,
# then "required" is downgraded to auto (omitted) because LM Studio
# silently drops it (#1563/#1179/#1877). The single filtered tool keeps
# the call forced in practice. See test_tool_choice_required_downgrade.py.
# "required" is omitted because LM Studio silently drops it
# (#1563/#1179/#1877). Tools stay full-schema; a user suffix forces the
# named tool (#3865). See test_tool_choice_required_downgrade.py.
assert "tool_choice" not in sent_kwargs
assert len(sent_kwargs["tools"]) == 1
assert sent_kwargs["tools"][0]["function"]["name"] == "search_mental_models"
assert [t["function"]["name"] for t in sent_kwargs["tools"]] == [
"search_mental_models",
"search_observations",
"recall",
"done",
]
assert sent_kwargs["messages"][-1]["content"] == "You must call `search_mental_models` now."

@pytest.mark.asyncio
@pytest.mark.parametrize(
Expand Down Expand Up @@ -228,11 +234,9 @@ class TestExpectedFixBehavior:
{"type": "function", "function": {"name": "search_mental_models"}}

The fix should:
1. Convert tool_choice to "required"
2. Filter tools to only the requested tool

These tests currently FAIL (because the fix is not yet implemented).
After the fix is applied, they should PASS.
1. Omit tool_choice (these servers drop "required")
2. Keep the full tools list
3. Append a user suffix forcing the named tool
"""

@pytest.mark.asyncio
Expand Down Expand Up @@ -260,21 +264,21 @@ async def test_fix_converts_named_tool_choice_and_downgrades(self):
assert result.tool_calls[0].name == "search_mental_models"

sent_kwargs = mock_create.call_args.kwargs
# Fix: dict was normalized then "required" downgraded to auto (omitted)
# Fix: "required" omitted; tools stay full; suffix forces the named tool
assert "tool_choice" not in sent_kwargs
# Fix: tools filtered to just the requested one (keeps the call forced)
assert len(sent_kwargs["tools"]) == 1
assert sent_kwargs["tools"][0]["function"]["name"] == "search_mental_models"
assert len(sent_kwargs["tools"]) == len(REFLECT_TOOLS)
assert sent_kwargs["messages"][-1]["content"] == "You must call `search_mental_models` now."

@pytest.mark.asyncio
@pytest.mark.parametrize(
"forced_tool_name",
["search_mental_models", "search_observations", "recall"],
)
async def test_fix_filters_tools_to_requested_tool(self, forced_tool_name: str):
async def test_fix_keeps_full_tools_and_appends_force_suffix(self, forced_tool_name: str):
"""
After fix: tools list is filtered to only the forced tool so the model
can only call that one tool (equivalent to the named tool_choice behavior).
After fix (#3865): tools list stays the full reflect schema and a user
suffix asks for the forced tool (equivalent to named tool_choice without
mutating tools[]).
"""
llm = _make_lmstudio_llm()
named_tool_choice = LLMToolChoice.named(forced_tool_name)
Expand All @@ -291,11 +295,10 @@ async def test_fix_filters_tools_to_requested_tool(self, forced_tool_name: str):
)

sent_kwargs = mock_create.call_args.kwargs
# "required" is downgraded to auto (omitted) for lmstudio; the single
# filtered tool keeps the call forced.
# "required" is omitted for lmstudio; tools stay full; suffix forces.
assert "tool_choice" not in sent_kwargs
assert len(sent_kwargs["tools"]) == 1
assert sent_kwargs["tools"][0]["function"]["name"] == forced_tool_name
assert len(sent_kwargs["tools"]) == len(REFLECT_TOOLS)
assert sent_kwargs["messages"][-1]["content"] == f"You must call `{forced_tool_name}` now."

@pytest.mark.asyncio
async def test_fix_also_applies_to_openai_provider(self):
Expand Down
Loading
Loading