Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
109 changes: 97 additions & 12 deletions studio/backend/core/inference/llama_cpp.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import atexit
import contextlib
import json
import re
import struct
import structlog
from loggers import get_logger
Expand Down Expand Up @@ -2120,7 +2121,7 @@ def generate_chat_completion_with_tools(
stop: Optional[list[str]] = None,
cancel_event: Optional[threading.Event] = None,
enable_thinking: Optional[bool] = None,
max_tool_iterations: int = 10,
max_tool_iterations: int = 25,
auto_heal_tool_calls: bool = True,
tool_call_timeout: int = 300,
session_id: Optional[str] = None,
Expand Down Expand Up @@ -2172,6 +2173,33 @@ def _strip_tool_markup(text: str, *, final: bool = False) -> str:
)
_MAX_BUFFER_CHARS = 32

# ── Duplicate tool-call detection ────────────────────────
# Track recent (tool_name, arguments) hashes to detect loops
# where the model repeats the exact same call. Retries after
# a transient failure are allowed (only block when the previous
# identical call succeeded).
import hashlib as _hl

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.

medium

Importing hashlib inside the function on every call is inefficient. This import should be moved to the top of the file to follow standard Python practices and improve performance.

References
  1. Imports should be at the top of the file, after any module comments and docstrings, and before module globals and constants. (link)


_tool_call_history: list[tuple[str, bool]] = [] # (key, failed)

def _tool_call_key(name: str, args: dict) -> str:
raw = json.dumps({"t": name, "a": args}, sort_keys = True)
return _hl.md5(raw.encode()).hexdigest()

def _is_duplicate_call(name: str, args: dict) -> bool:
"""Block if the immediately previous call was identical and succeeded."""
if not _tool_call_history:
return False
key = _tool_call_key(name, args)
last_key, last_failed = _tool_call_history[-1]
return last_key == key and not last_failed
Comment on lines +2188 to +2194

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.

medium

The current duplicate call detection only checks the immediately preceding call. Models can sometimes enter cycles involving multiple different tool calls (e.g., A -> B -> A -> B). It would be more robust to check if the current call has appeared anywhere in the history of the current generation turn.

Suggested change
def _is_duplicate_call(name: str, args: dict) -> bool:
"""Block if the immediately previous call was identical and succeeded."""
if not _tool_call_history:
return False
key = _tool_call_key(name, args)
last_key, last_failed = _tool_call_history[-1]
return last_key == key and not last_failed
def _is_duplicate_call(name: str, args: dict) -> bool:
"""Block if this exact call has already succeeded in the current turn."""
if not _tool_call_history:
return False
key = _tool_call_key(name, args)
return any(h_key == key and not h_failed for h_key, h_failed in _tool_call_history)


def _record_tool_call(name: str, args: dict, failed: bool) -> None:
key = _tool_call_key(name, args)
_tool_call_history.append((key, failed))

_hit_tool_cap = False

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.

medium

The variable _hit_tool_cap is initialized but never used within the function. It should be removed to maintain code cleanliness.


for iteration in range(max_tool_iterations):
if cancel_event is not None and cancel_event.is_set():
return
Expand Down Expand Up @@ -2568,6 +2596,14 @@ def _strip_tool_markup(text: str, *, final: bool = False) -> str:
# Merge accumulated metrics from prior tool
# iterations so they are not silently dropped.
yield {"type": "status", "text": ""}
if content_accum:
# Strip leaked tool-call XML before yielding
content_accum = re.sub(
r"<tool_call>.*?</tool_call>",
"",
content_accum,
flags = re.DOTALL,
).strip()

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.

medium

This manual regex substitution is redundant and less comprehensive than the _strip_tool_markup helper function defined earlier (line 2160). Using the helper ensures that both \u003ctool_call\u003e and \u003cfunction=...\u003e tags are handled consistently, including cases where tags might be unclosed at the end of the stream. Additionally, _strip_tool_markup respects the auto_heal_tool_calls setting.

                            content_accum = _strip_tool_markup(content_accum, final = True)

if content_accum:
yield {"type": "content", "text": content_accum}
_fu = _iter_usage or {}
Expand Down Expand Up @@ -2661,16 +2697,28 @@ def _strip_tool_markup(text: str, *, final: bool = False) -> str:
"arguments": arguments,
}

_effective_timeout = (
None if tool_call_timeout >= 9999 else tool_call_timeout
)
result = execute_tool(
tool_name,
arguments,
cancel_event = cancel_event,
timeout = _effective_timeout,
session_id = session_id,
)
# ── Duplicate call detection ──────────────
if _is_duplicate_call(tool_name, arguments):
result = (
"You already made this exact call. "
"Do not repeat the same tool call. "
"Try a different approach: fetch a URL "
"from previous results, use Python to "
"process data you already have, or "
"provide your final answer now."
)
_record_tool_call(tool_name, arguments, failed = False)

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.

medium

This call to _record_tool_call is redundant because the tool call is recorded again at the end of the loop (line 2738) regardless of whether it was a duplicate or a fresh execution. Removing this redundant call keeps the history clean and avoids double-counting attempts.

else:
_effective_timeout = (
None if tool_call_timeout >= 9999 else tool_call_timeout
)
result = execute_tool(
tool_name,
arguments,
cancel_event = cancel_event,
timeout = _effective_timeout,
session_id = session_id,
)

yield {
"type": "tool_end",
Expand All @@ -2679,10 +2727,31 @@ def _strip_tool_markup(text: str, *, final: bool = False) -> str:
"result": result,
}

# Nudge model to try a different approach on errors
_error_prefixes = (
"Error",
"Search failed",
"Execution error",
"Blocked",
"No ",
"Exit code",
"Failed to fetch",
)
_is_error = isinstance(result, str) and result.lstrip().startswith(
_error_prefixes
)
_record_tool_call(tool_name, arguments, failed = _is_error)
_result_content = result
if _is_error:
_result_content = (
result + "\n\nThe tool call encountered an issue. "
"Please try a different approach or rephrase your request."
)

tool_msg = {
"role": "tool",
"name": tool_name,
"content": result,
"content": _result_content,
}
tool_call_id = tc.get("id")
if tool_call_id:
Expand All @@ -2699,6 +2768,22 @@ def _strip_tool_markup(text: str, *, final: bool = False) -> str:
return
raise

# ── Tool iteration cap reached -- synthesize final answer ──
# The model used all iterations without producing a final text
# response. Inject a nudge so the final streaming pass produces
# a useful answer instead of continuing to request tools.
if max_tool_iterations > 0:
conversation.append(
{
"role": "user",
"content": (
"You have used all available tool calls. Based on "
"everything you have found so far, provide your final "
"answer now. Do not call any more tools."
),
}
)

# Clear status
yield {"type": "status", "text": ""}

Expand Down
172 changes: 164 additions & 8 deletions studio/backend/core/inference/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,16 +57,23 @@ def _get_workdir(session_id: str | None = None) -> str:
"type": "function",
"function": {
"name": "web_search",
"description": "Search the web for current information, recent events, or facts you are uncertain about.",
"description": (
"Search the web and fetch page content. Returns snippets for all results. "
"Use the url parameter to fetch full page text from a specific URL."
),
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query",
}
},
"url": {
"type": "string",
"description": "A URL to fetch full page content from (instead of searching). Use this to read a page found in search results.",
},
},
"required": ["query"],
"required": [],
},
},
}
Expand Down Expand Up @@ -131,7 +138,11 @@ def execute_tool(
)
effective_timeout = _EXEC_TIMEOUT if timeout is _TIMEOUT_UNSET else timeout
if name == "web_search":
return _web_search(arguments.get("query", ""), timeout = effective_timeout)
return _web_search(
arguments.get("query", ""),
url = arguments.get("url"),
timeout = effective_timeout,
)
if name == "python":
return _python_exec(
arguments.get("code", ""), cancel_event, effective_timeout, session_id
Expand All @@ -143,9 +154,148 @@ def execute_tool(
return f"Unknown tool: {name}"


def _web_search(query: str, max_results: int = 5, timeout: int = _EXEC_TIMEOUT) -> str:
"""Search the web using DuckDuckGo and return formatted results."""
if not query.strip():
_MAX_PAGE_CHARS = 16000 # limit fetched page text
_MAX_FETCH_BYTES = _MAX_PAGE_CHARS * 4 + 1 # cap raw download size


def _is_public_host(hostname: str, port: int) -> tuple[bool, str]:
"""Resolve *hostname* and reject private/loopback/link-local addresses."""
import ipaddress
import socket

try:
infos = socket.getaddrinfo(hostname, port, type = socket.SOCK_STREAM)
except OSError as e:
return False, f"Failed to resolve host: {e}"

for *_, sockaddr in infos:
ip = ipaddress.ip_address(sockaddr[0])
if (
ip.is_private
or ip.is_loopback
or ip.is_link_local
or ip.is_multicast
or ip.is_reserved
or ip.is_unspecified
):
return False, f"Blocked: refusing to fetch non-public address {ip}."
return True, ""


def _fetch_page_text(

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.

medium

The _fetch_page_text function has a default timeout of 30 seconds. However, when called from _web_search, the timeout is explicitly set to min(timeout, 60). This means the effective timeout for _fetch_page_text will always be capped at 60 seconds, and its own default of 30 seconds will only apply if it's called directly, not through _web_search.

To improve clarity and avoid potential confusion, consider making the default timeout for _fetch_page_text consistent with its intended maximum usage (e.g., 60 seconds if that's the hard limit for web fetches) or add a constant for this maximum timeout.

url: str, max_chars: int = _MAX_PAGE_CHARS, timeout: int = 30
) -> str:
"""Fetch a URL and return plain text content (HTML tags stripped).

Blocks private/loopback/link-local targets (SSRF protection) and caps
the download size to avoid unbounded memory usage.
"""
import re as _re
from urllib.parse import urlparse

parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return f"Blocked: only http/https URLs are allowed (got {parsed.scheme!r})."
Comment on lines +207 to +209

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Block internal hosts in URL fetcher

_fetch_page_text only validates the URL scheme, so web_search can fetch http(s) targets on loopback, link-local, or private networks (for example 127.0.0.1, localhost, 169.254.169.254, RFC1918 ranges). Because tool arguments come from model output, this introduces an SSRF path that can exfiltrate internal metadata/services in production deployments; host/IP allowlisting or private-range blocking is needed in addition to scheme checks.

Useful? React with 👍 / 👎.

if not parsed.hostname:
return "Blocked: URL is missing a hostname."

ok, reason = _is_public_host(
parsed.hostname,
parsed.port or (443 if parsed.scheme == "https" else 80),
)
if not ok:
return reason

try:
import urllib.request
from urllib.parse import urljoin

# Disable auto-redirect so we can validate each hop for SSRF
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
Comment on lines +227 to +228

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Handle HTTP redirects without triggering urllib errors

Returning None from _NoRedirect.redirect_request causes urllib to treat 3xx responses as HTTPError via the default error handler, so _fetch_page_text never reaches the if resp.status in (301, ...) redirect branch. In practice, URLs that rely on normal redirects (for example httphttps or canonical-path redirects) will fail with Failed to fetch URL instead of being followed through the intended validated-hop loop.

Useful? React with 👍 / 👎.


opener = urllib.request.build_opener(_NoRedirect)
max_bytes = max_chars * 4 + 1
current_url = url

for _hop in range(5):
req = urllib.request.Request(
current_url,
headers = {"User-Agent": "UnslothStudio/1.0"},
)
resp = opener.open(req, timeout = timeout)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Prevent DNS rebinding between host check and fetch

The private-range check is done on a preflight DNS lookup, but the subsequent opener.open(req, ...) call performs a fresh resolution when connecting. That leaves a DNS-rebinding window where an attacker-controlled hostname can resolve to a public IP during validation and then to a private/loopback IP during the actual request, bypassing the SSRF protection added here.

Useful? React with 👍 / 👎.

if resp.status in (301, 302, 303, 307, 308):
location = resp.headers.get("Location")
if not location:
return "Failed to fetch URL: redirect missing Location header."
current_url = urljoin(current_url, location)
rp = urlparse(current_url)
if rp.scheme not in ("http", "https") or not rp.hostname:
return "Blocked: redirect target is not a valid http/https URL."
ok2, reason2 = _is_public_host(
rp.hostname,
rp.port or (443 if rp.scheme == "https" else 80),
)
if not ok2:
return reason2
continue
# Success -- read capped body
raw_bytes = resp.read(max_bytes)
break
else:
return "Failed to fetch URL: too many redirects."

raw_html = raw_bytes.decode("utf-8", errors = "replace")

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.

medium

Hardcoding utf-8 for decoding the HTML body may lead to corrupted text for pages using other encodings (e.g., ISO-8859-1). It is better to use the charset provided in the response headers.

Suggested change
raw_html = raw_bytes.decode("utf-8", errors = "replace")
charset = resp.info().get_content_charset() or "utf-8"
raw_html = raw_bytes.decode(charset, errors = "replace")

except Exception as e:
return f"Failed to fetch URL: {e}"

# Convert HTML to text -- prefer html2text for clean markdown output
try:
import html2text as _h2t

converter = _h2t.HTML2Text()
converter.ignore_links = False
converter.ignore_images = True
converter.body_width = 0 # no wrapping
text = converter.handle(raw_html).strip()
except ImportError:
# Fallback: regex-based stripping
text = _re.sub(
r"<script[^>]*>.*?</script[^>]*>",
"",
raw_html,
flags = _re.DOTALL | _re.IGNORECASE,
)
text = _re.sub(
r"<style[^>]*>.*?</style[^>]*>", "", text, flags = _re.DOTALL | _re.IGNORECASE
)
text = _re.sub(r"<[^>]+>", " ", text)
text = _re.sub(r"\s+", " ", text).strip()
Comment on lines +296 to +306

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.

medium

Using regular expressions to strip HTML is fragile and can be easily bypassed or produce poor results with complex or malformed HTML. While this is a fallback, consider making html2text a required dependency or using a more robust library like beautifulsoup4 if available in the environment.


if not text:
return "(page returned no readable text)"
if len(text) > max_chars:
text = text[:max_chars] + f"\n\n... (truncated, {len(text)} chars total)"
return text


def _web_search(
query: str,
max_results: int = 5,
timeout: int = _EXEC_TIMEOUT,
url: str | None = None,
) -> str:
"""Search the web using DuckDuckGo and return formatted results.

If ``url`` is provided, fetches that page directly instead of searching.
"""
# Direct URL fetch mode
if url and url.strip():
fetch_timeout = 60 if timeout is None else min(timeout, 60)
return _fetch_page_text(url.strip(), timeout = fetch_timeout)

if not query or not query.strip():
return "No query provided."
try:
from ddgs import DDGS
Expand All @@ -160,7 +310,13 @@ def _web_search(query: str, max_results: int = 5, timeout: int = _EXEC_TIMEOUT)
f"URL: {r.get('href', '')}\n"
f"Snippet: {r.get('body', '')}"
)
return "\n\n---\n\n".join(parts)
text = "\n\n---\n\n".join(parts)
text += (
"\n\n---\n\nIMPORTANT: These are only short snippets. "
"To get the full page content, call web_search with "
'the url parameter (e.g. {"url": "<URL>"}).'
)
return text
except Exception as e:
return f"Search failed: {e}"

Expand Down
2 changes: 1 addition & 1 deletion studio/backend/models/inference.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,7 @@ class ChatCompletionRequest(BaseModel):
description = "[x-unsloth] Auto-detect and fix malformed tool calls from model output.",
)
max_tool_calls_per_message: Optional[int] = Field(
10,
25,
ge = 0,
description = "[x-unsloth] Maximum number of tool call iterations per message (0 = disabled, 9999 = unlimited).",
)
Expand Down
Loading
Loading