Compare commits
4 commits
main
...
studio-too
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0506e1e7c | ||
|
|
9dff999ed6 | ||
|
|
646b4f02d9 | ||
|
|
62827a9f3f |
2 changed files with 133 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"<tool_call>.*?</tool_call>|<function=\w+>.*?</function>",
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue