diff --git a/studio/backend/core/inference/llama_cpp.py b/studio/backend/core/inference/llama_cpp.py
index b53fc513de..77e19f0df9 100644
--- a/studio/backend/core/inference/llama_cpp.py
+++ b/studio/backend/core/inference/llama_cpp.py
@@ -2453,6 +2453,7 @@ class LlamaCppBackend:
auto_heal_tool_calls: bool = True,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
+ synthesise_after_tool_result: bool = False,
) -> Generator[dict, None, None]:
"""
Agentic loop: let the model call tools, execute them, and continue.
@@ -2473,6 +2474,39 @@ class LlamaCppBackend:
_accumulated_predicted_ms = 0.0
_accumulated_predicted_n = 0
+ # Flipped once a tool result has been appended to the conversation
+ # and the system message updated with a synthesise-now directive.
+ # Small models otherwise keep honouring the initial "prefer tools"
+ # nudge and loop on search forever, even when the result they need
+ # is already in context.
+ _synthesise_nudge_applied = False
+ # Phrased as a concrete action rather than an opt-out clause --
+ # the "do not call more tools" wording actively harmed small-model
+ # synthesis rate in our benchmarks.
+ _SYNTHESISE_NUDGE = (
+ " Tool results have been gathered. Now write the final answer to the"
+ " user's original question using what you have. Tool calls are no"
+ " longer needed for this turn."
+ )
+
+ def _apply_synthesise_nudge() -> None:
+ nonlocal _synthesise_nudge_applied
+ if _synthesise_nudge_applied:
+ return
+ for _msg in conversation:
+ if _msg.get("role") == "system":
+ _content = _msg.get("content") or ""
+ if _SYNTHESISE_NUDGE.strip() not in _content:
+ _msg["content"] = _content.rstrip() + _SYNTHESISE_NUDGE
+ _synthesise_nudge_applied = True
+ return
+ # No system message yet: insert one
+ conversation.insert(
+ 0,
+ {"role": "system", "content": _SYNTHESISE_NUDGE.lstrip()},
+ )
+ _synthesise_nudge_applied = True
+
def _strip_tool_markup(text: str, *, final: bool = False) -> str:
if not auto_heal_tool_calls:
return text
@@ -3008,6 +3042,7 @@ class LlamaCppBackend:
assistant_msg["tool_calls"] = tool_calls
conversation.append(assistant_msg)
+ _any_tool_succeeded = False
for tc in tool_calls or []:
func = tc.get("function", {})
tool_name = func.get("name", "")
@@ -3113,6 +3148,8 @@ class LlamaCppBackend:
_error_prefixes
)
_tool_call_history.append((_tc_key, _is_error))
+ if not _is_error:
+ _any_tool_succeeded = True
# Strip image sentinel before feeding result to the LLM
# (the full result with sentinel is still yielded via
# tool_end so the frontend can extract image paths).
@@ -3135,6 +3172,15 @@ class LlamaCppBackend:
tool_msg["tool_call_id"] = tool_call_id
conversation.append(tool_msg)
+ # First successful tool result of the loop: tell the model
+ # to synthesise an answer rather than continue searching.
+ # Only enabled for small models (via synthesise_after_tool_result)
+ # since large models handle multi-step tool use well.
+ # Skip if every tool call in this batch errored -- let the
+ # model retry or try a different approach instead.
+ if synthesise_after_tool_result and _any_tool_succeeded:
+ _apply_synthesise_nudge()
+
# Clear tool status badge before next generation iteration
yield {"type": "status", "text": ""}
# Continue the loop to let model respond with context
diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py
index 4246f0056b..3db56b9c6b 100644
--- a/studio/backend/routes/inference.py
+++ b/studio/backend/routes/inference.py
@@ -127,6 +127,68 @@ _TOOL_ACTION_NUDGE = (
" Do NOT output code blocks -- use the python tool instead."
)
+# Softer variant for small models (<9B). The aggressive ALWAYS-CALL-TOOLS
+# phrasing above causes small models to pick web_search on every factual
+# question even when the answer sits in their training data, and to keep
+# calling search after each result instead of synthesising. See
+# tests/test_tool_loop_with_nudge.py for the measured behaviour.
+_TOOL_ACTION_NUDGE_SMALL = (
+ " Call tools only when you need current information or a specific"
+ " calculation. For questions within your knowledge, answer directly."
+ " Issue one tool call at a time rather than queuing several at once."
+)
+
+# Appended whenever the current conversation already contains a tool
+# result, to counteract the "prefer tools" nudge and push the model
+# toward synthesising a final answer from what it has. Matters most for
+# small models where the initial "prefer tools" directive is still
+# dominating on the second and subsequent turns.
+#
+# Phrasing matters a lot here -- "do not call more tools" as an opt-out
+# clause is actually worse than no nudge (measured 33% vs 57% synthesis
+# rate on Qwen3.5-4B UD-Q4_K_XL). Reframing as a concrete action ("write
+# the final answer to the user's original question using what you have")
+# is what moves the needle: the same bench run jumps to 90% synthesis.
+_TOOL_SYNTHESISE_NUDGE = (
+ " Tool results have been gathered. Now write the final answer to the"
+ " user's original question using what you have. Tool calls are no"
+ " longer needed for this turn."
+)
+
+
+_TOOL_ERROR_PREFIXES = (
+ "Error",
+ "Search failed",
+ "Execution error",
+ "Blocked:",
+ "Exit code",
+ "Failed to fetch",
+ "Failed to resolve",
+ "No query provided",
+)
+
+
+def _has_successful_tool_result_in_current_turn(messages: list[dict]) -> bool:
+ """Check if the current turn has at least one successful tool result.
+
+ Scans backwards from the end of the message list. Only returns True
+ when a non-error tool result appears after the last user message.
+ Error-only tool results (timeouts, failed fetches, etc.) return False
+ so the model can retry rather than being forced to synthesise.
+ """
+ for msg in reversed(messages):
+ role = msg.get("role")
+ if role == "tool":
+ content = (msg.get("content") or "").lstrip()
+ if not content.startswith(_TOOL_ERROR_PREFIXES):
+ return True
+ # Error tool result -- keep scanning for a successful one
+ continue
+ if role == "user":
+ return False
+ return False
+
+
# Regex for stripping leaked tool-call XML from assistant messages/stream
_TOOL_XML_RE = _re.compile(
r".*?|.*?",
@@ -1242,7 +1304,18 @@ async def openai_chat_completions(
_nudge = ""
if _nudge:
- _nudge += _TOOL_ACTION_NUDGE
+ _nudge += (
+ _TOOL_ACTION_NUDGE_SMALL if _is_small_model else _TOOL_ACTION_NUDGE
+ )
+ # Small models loop on tool calls instead of answering.
+ # If the current turn already has a successful tool
+ # result, nudge the model to synthesise a final answer.
+ # Large models handle multi-step tool use well, so this
+ # is gated to small models only.
+ if _is_small_model and _has_successful_tool_result_in_current_turn(
+ chat_messages
+ ):
+ _nudge += _TOOL_SYNTHESISE_NUDGE
# Append nudge to system prompt (preserve user's prompt)
if system_prompt:
system_prompt = system_prompt.rstrip() + "\n\n" + _nudge
@@ -1284,6 +1357,7 @@ async def openai_chat_completions(
if payload.tool_call_timeout is not None
else 300,
session_id = payload.session_id,
+ synthesise_after_tool_result = _is_small_model,
)
_tool_sentinel = object()
@@ -2468,7 +2542,17 @@ async def anthropic_messages(
_nudge = ""
if _nudge:
- _nudge += _TOOL_ACTION_NUDGE
+ _nudge += (
+ _TOOL_ACTION_NUDGE_SMALL if _is_small_model else _TOOL_ACTION_NUDGE
+ )
+ # Only nudge small models to synthesise -- see comment in
+ # /chat/completions for rationale. (The OpenAI-compat schema
+ # does not yet accept role="tool", so this branch is currently
+ # unreachable; kept for when the schema is extended.)
+ if _is_small_model and _has_successful_tool_result_in_current_turn(
+ openai_messages
+ ):
+ _nudge += _TOOL_SYNTHESISE_NUDGE
# Inject into system prompt
if openai_messages and openai_messages[0].get("role") == "system":
openai_messages[0]["content"] = (
@@ -2499,6 +2583,7 @@ async def anthropic_messages(
auto_heal_tool_calls = True,
tool_call_timeout = 300,
session_id = payload.session_id,
+ synthesise_after_tool_result = _is_small_model,
)
if payload.stream: