Initial Commit

This commit is contained in:
2026-09-15 15:24:03 -04:00
commit 4dc7086e7e
605 changed files with 101380 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN adduser --system --no-create-home --uid 10001 --group owuitools
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Offline reference data, baked at build time so these lookups have no network
# dependency and no rate limit at all.
#
# manuf - Wireshark's OUI database. Preferred over IEEE's oui.csv because it
# already merges the MA-L/MA-M/MA-S registries with explicit prefix
# lengths (00:00:0C/24). Loading only the 24-bit registry silently
# mis-attributes every address in a 28- or 36-bit block to whoever
# owns the enclosing /24.
# ports - IANA service-name/port registry.
#
# Fetched here rather than at runtime because the running container has no
# route to either host. A failed download is not fatal: network_reference
# reports the dataset as unavailable and tells the model not to guess, which
# is better than refusing to start over a reference file.
COPY fetch_data.py .
RUN python fetch_data.py && rm fetch_data.py
COPY owuitools ./owuitools
USER owuitools
EXPOSE 8000
CMD ["uvicorn", "owuitools.main:app", "--host", "0.0.0.0", "--port", "8000", "--no-server-header"]
+24
View File
@@ -0,0 +1,24 @@
"""Download the offline reference datasets at image build time."""
import pathlib
import urllib.request
SOURCES = {
"manuf": "https://www.wireshark.org/download/automated/data/manuf",
"iana-ports.csv": "https://www.iana.org/assignments/service-names-port-numbers/service-names-port-numbers.csv",
}
target = pathlib.Path("owuitools/data")
target.mkdir(parents=True, exist_ok=True)
for name, url in SOURCES.items():
path = target / name
try:
request = urllib.request.Request(url, headers={"User-Agent": "owuitools-build/1.0"})
with urllib.request.urlopen(request, timeout=60) as response:
path.write_bytes(response.read())
print(f" fetched {name}: {path.stat().st_size:,} bytes")
except Exception as exc:
print(f" WARNING: could not fetch {name} ({exc}); "
"network_reference will report it unavailable")
path.touch()
+3
View File
@@ -0,0 +1,3 @@
"""OpenAPI tool suite for the Open WebUI assistant."""
__version__ = "1.0.0"
+122
View File
@@ -0,0 +1,122 @@
"""Response size control.
Open WebUI does not truncate tool results anywhere - I checked the whole
path through `process_tool_result`. Whatever a tool returns is serialised
into the conversation verbatim, and with the tool loop allowed up to 256
iterations, one chatty tool can eat a 150k window on its own.
So every response goes through `cap()` on the way out, and truncation is
always *reported* rather than silent: the model is told what it lost and
exactly how to ask for the rest. A model that knows it saw 6000 of 42000
characters behaves very differently from one that thinks it read the page.
"""
from __future__ import annotations
from .settings import settings
# Roughly 3.2 characters per token on Qwen3's tokeniser for dense English
# prose; only used to give the model a sense of cost in continuation hints.
CHARS_PER_TOKEN = 3.2
# The trailing citation line, and the marker `cap()` uses to protect it.
#
# Every other rule in this suite is carried in tool output because the model
# treats tool output as fact; the one rule that never landed - "end a
# tool-assisted answer with its URLs" - lived only in the system prompt, and
# across two graded rounds it fired on 1 of 25 opportunities. The single
# answer that did cite lifted `search_papers`' ready-made `CITE AS:` lines
# verbatim. So the fix is not another instruction: it is to hand over the
# line already formatted the way the answer should carry it.
#
# It goes at the *end*, because that is where the model is composing from
# when it writes its closing lines - the `# Source:` header at the top of a
# fetched page has been available all along and is read as document identity,
# not as something to repeat. Which makes it the one thing in the response
# that must survive trimming, hence the special case in `cap()` below.
SOURCES_SEPARATOR = "\n\n---\n"
SOURCES_PREFIX = "Sources: "
def _detach_sources(text: str) -> tuple[str, str]:
"""Split off a trailing one-line sources block, if there is one."""
marker = SOURCES_SEPARATOR + SOURCES_PREFIX
idx = text.rfind(marker)
if idx == -1:
return text, ""
block = text[idx:]
# One line only. Anything else is a coincidence in the document body, and
# moving a chunk of someone's prose to the end would be worse than trimming.
if "\n" in block[len(SOURCES_SEPARATOR):]:
return text, ""
return text[:idx], block
def cap(text: str, limit: int | None = None, *, note: str | None = None) -> str:
"""Trim `text` to `limit` characters, saying so if anything was cut."""
# A tool result that IS a data: URI is not text and must not be trimmed.
# Open WebUI detects it, replaces the textual result with a one-line
# summary, and passes the image to the model as a vision input - so the
# model sees the picture rather than 300 KB of base64. Cutting it here
# would only produce a corrupt image.
if text.startswith("data:image/"):
return text
limit = min(limit or settings.max_response_chars, settings.max_response_chars)
if len(text) <= limit:
return text
# Trimming from the end would remove the citation line, which is the one
# part of the response that exists to be copied out. Detach it, trim the
# body against a reduced budget, and put it back.
body, sources = _detach_sources(text)
budget = limit - len(sources)
# Cut on a line boundary where one is close by, so the model never sees
# a half-written table row or a truncated URL it might then "repair".
cut = body.rfind("\n", int(budget * 0.85), budget)
if cut == -1:
cut = budget
trimmed = body[:cut].rstrip()
tail = note or "This response was too long and has been trimmed."
return (f"{trimmed}\n\n[TRUNCATED after {cut:,} of {len(body):,} characters. {tail}]"
+ sources)
def paginate(text: str, offset: int, size: int, *, how_to_continue: str) -> str:
"""Return one window of a long document with an explicit continuation hint.
`how_to_continue` is phrased as an instruction the model can act on
directly, because a hint it cannot act on is just wasted tokens.
"""
total = len(text)
offset = max(0, min(offset, total))
end = min(offset + size, total)
window = text[offset:end]
header = f"[characters {offset:,}-{end:,} of {total:,}]"
if end >= total:
footer = "\n\n[END OF DOCUMENT]" if offset else ""
return f"{header}\n{window}{footer}" if offset else window
remaining = total - end
approx_tokens = int(remaining / CHARS_PER_TOKEN)
# This used to end "Only do so if this section did not answer the question."
# That reads as permission to stop, and the model took it: asked for the
# scorers in a football final it read 4,000 characters of a 118,000-character
# article, found the headline result in the lead, stopped - and then supplied
# the scorers, the minutes and the shoot-out misses from memory, wrongly. A
# lead section answers the headline question and almost never carries the
# itemised detail, so the default advice has to point the other way.
return (
f"{header}\n{window}\n\n"
f"[MORE AVAILABLE: {remaining:,} characters (~{approx_tokens:,} tokens) remain. "
f"{how_to_continue.format(offset=end)} "
f"Keep reading if the answer may be further in - what you have not read, you do not "
f"know, and a lead or summary section rarely carries names, dates, figures or tables. "
f"To reach one specific thing, jump to it with search='<term>' instead of paging.]"
)
def bullet_list(items: list[str], *, empty: str) -> str:
return "\n".join(items) if items else empty
+85
View File
@@ -0,0 +1,85 @@
"""Response cache.
Not a performance nicety. Stack Exchange allows 300 calls/day/IP without a
key and GitHub 60/hour, and those budgets are shared by every user of the
assistant at once. Without caching, two people comparing package versions
can spend the day's quota before lunch and every later answer degrades to
"I couldn't check" - or worse, to the model guessing.
Fails open: if valkey is unreachable the tools still work, they are just
uncached. A dead cache should never take the assistant's tools with it.
"""
from __future__ import annotations
import hashlib
import logging
import redis.asyncio as redis
from .settings import settings
log = logging.getLogger(__name__)
_pool: redis.Redis | None = None
# Chosen per-source from how fast the underlying thing actually changes.
TTL = {
"search": 900, # 15m - fresh enough for "latest", cheap to redo
"page": 3600, # 1h - article text rarely changes within a session
"wikipedia": 21600, # 6h
"stackexchange": 21600, # 6h - 300/day keyless makes this the tightest budget
"rfc": 604800, # 7d - a published RFC is immutable
"man": 604800, # 7d
"cve": 10800, # 3h - CVSS scores do get revised
"package": 3600, # 1h - the whole point is catching a new release
"rdap": 86400, # 24h
"dns": 300, # 5m - short on purpose; people debug live changes
"tls": 1800, # 30m
"papers": 86400, # 24h
"datasheet": 604800, # 7d
}
def _key(namespace: str, *parts: str) -> str:
digest = hashlib.sha256("\x1f".join(parts).encode()).hexdigest()[:32]
return f"owuitools:{namespace}:{digest}"
async def get_redis() -> redis.Redis | None:
global _pool
if _pool is None:
try:
_pool = redis.from_url(settings.valkey_url, decode_responses=True, socket_timeout=2)
await _pool.ping()
except Exception as exc:
log.warning("cache unavailable, continuing without it: %s", exc)
_pool = None
return _pool
async def close_redis() -> None:
global _pool
if _pool is not None:
await _pool.aclose()
_pool = None
async def cached(namespace: str, *parts: str) -> str | None:
client = await get_redis()
if client is None:
return None
try:
return await client.get(_key(namespace, *parts))
except Exception:
return None
async def store(namespace: str, value: str, *parts: str) -> None:
client = await get_redis()
if client is None:
return
try:
await client.setex(_key(namespace, *parts), TTL.get(namespace, 3600), value)
except Exception as exc:
log.debug("cache write failed for %s: %s", namespace, exc)
+320
View File
@@ -0,0 +1,320 @@
"""The one way out of this container.
Every outbound request in the suite goes through here, which means every
outbound request goes through squid. There is no fallback path and there
should never be one - the isolation story depends on this process having
no other route.
"""
from __future__ import annotations
import logging
import re
import socket
import ssl
import httpx
from .settings import settings
log = logging.getLogger(__name__)
_client: httpx.AsyncClient | None = None
_local_client: httpx.AsyncClient | None = None
# Generous enough for a slow origin, short enough that a wedged fetch does
# not sit past Open WebUI's own tool timeout and lose the whole turn.
DEFAULT_TIMEOUT = httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0)
# Hard byte ceiling on any download. A datasheet PDF is legitimately a few
# MB; nothing this assistant needs is 200MB, and streaming one into memory
# in a 1GB container is how you get an OOM kill instead of an answer.
MAX_DOWNLOAD_BYTES = 12 * 1024 * 1024
# A vendor reference manual is legitimately larger than that: ST's RM0008 is
# 13MB, and the 12MB ceiling was silently truncating it. The truncated bytes
# then failed PDF extraction, and the model was told the document "may be a
# scanned image, a video, or a page that renders entirely in JavaScript" -
# false in every particular. It went to forum posts instead and cited them as
# the manual. 40MB of PDF text extraction fits the container; the real fix is
# that going over now says so.
MAX_PDF_BYTES = 40 * 1024 * 1024
_PROXY_STATUS = re.compile(r"\b([45]\d\d)\b")
def get_client() -> httpx.AsyncClient:
global _client
if _client is None:
_client = httpx.AsyncClient(
proxy=settings.egress_proxy,
timeout=DEFAULT_TIMEOUT,
follow_redirects=True,
max_redirects=5,
headers={
"User-Agent": settings.user_agent,
"Accept-Encoding": "gzip, deflate",
},
limits=httpx.Limits(max_connections=24, max_keepalive_connections=8),
)
return _client
def get_local_client() -> httpx.AsyncClient:
"""Client for services inside our own namespace - searxng, and nothing else.
These must NOT go through squid. squid's job is to deny private
destinations, and searxng on loopback is exactly that: routing its
requests through the proxy got them refused with a 403, which surfaced
to the model as "this address is on a private network".
Deliberately a separate client rather than a per-request override.
httpx has no supported way to bypass a client's proxy for one call
(`extensions={"proxy": None}` is silently ignored), so a single client
would send every internal request through the proxy again the moment
someone forgot.
"""
global _local_client
if _local_client is None:
_local_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0),
follow_redirects=False,
headers={"User-Agent": "owuitools-internal/1.0"},
)
return _local_client
async def close_client() -> None:
global _client, _local_client
if _client is not None:
await _client.aclose()
_client = None
if _local_client is not None:
await _local_client.aclose()
_local_client = None
class FetchError(RuntimeError):
"""Outbound failure, phrased for the model rather than for a log."""
def _squid_error(exc: Exception) -> str | None:
"""The X-Squid-Error token, when squid answered instead of the origin.
Squid stamps its own error pages with this header. Matching on the status
code alone - which this used to do - reported an origin refusing a bot as
an infrastructure block, and the model then told the user the source was
unreachable and stopped trying. That is worse than the bare 403, because
it sounds authoritative and it is wrong.
"""
response = getattr(exc, "response", None)
if response is None:
return None
for key, value in response.headers.items():
if key.lower() == "x-squid-error":
parts = value.split()
return parts[0] if parts else value
return None
def _proxy_status(exc: Exception) -> str | None:
"""The status squid gave a CONNECT, dug out of the exception text.
httpx raises ProxyError for any non-200 CONNECT and puts the status line in
the message. That status is the only thing separating "this name does not
resolve" (503) from "policy refused this address" (403), and the two need
opposite advice. Collapsing them cost a graded run: the model invented
three plausible API hostnames, none of which existed, and was told each
time that the address was on a private network - so it never suspected the
hostname, and reported to the user that public services were blocked here.
"""
if not isinstance(exc, httpx.ProxyError):
return None
found = _PROXY_STATUS.search(str(exc))
return found.group(1) if found else None
def _host_of(target: str) -> str:
try:
return httpx.URL(target).host or target
except Exception:
return target
def explain(exc: Exception, target: str) -> FetchError:
"""Turn a transport exception into something the model can act on.
Error bodies from a tool server are fed straight back to the model, so
they are part of the prompt whether you treat them that way or not. What
the message blames matters as much as the fact that it failed: name the
wrong cause and the model adopts the wrong remedy, confidently.
"""
token = (_squid_error(exc) or "").upper()
status = _proxy_status(exc)
if token or status:
code = status or str(getattr(getattr(exc, "response", None), "status_code", ""))
host = _host_of(target)
if token == "ERR_ACCESS_DENIED" or code == "403":
return FetchError(
f"Access to {target} was refused by the egress proxy: the name resolves to a "
"private, loopback or otherwise reserved address, or the port is not 80 or 443. "
"This assistant reaches only the public internet. Tell the user you have no "
"access to that host instead of guessing its contents."
)
if token == "ERR_DNS_FAIL" or code == "503":
return FetchError(
f"{host} does not resolve - there is no such host in DNS. The hostname is wrong, "
"or it was guessed. Do not try variants of it, and do not report this as the host "
"being blocked: search for the real address of the service you want, or tell the "
"user that host does not exist."
)
if token in {"ERR_CONNECT_FAIL", "ERR_READ_TIMEOUT", "ERR_ZERO_SIZE_OBJECT"} \
or code in {"502", "504"}:
return FetchError(
f"{host} resolves, but the connection failed - the server is down, refusing "
"connections, or timing out. This is not a restriction on this assistant. Try "
"again later, or use a different source."
)
return FetchError(
f"{target} could not be reached through the egress proxy ({token or code}). Either the "
"name does not resolve or the host is not accepting connections; rewording the URL "
"will not change that."
)
if isinstance(exc, httpx.ConnectTimeout | httpx.ReadTimeout):
return FetchError(
f"{target} did not send data in time. For a large PDF from a slow server one retry is "
"reasonable; a third is not. If it fails again, look for a smaller document - a single "
"chapter, an application note, or an HTML version - rather than retrying this one."
)
if isinstance(exc, httpx.TooManyRedirects):
return FetchError(f"{target} redirected too many times; the page may be behind a login.")
if isinstance(exc, httpx.HTTPStatusError):
code = exc.response.status_code
if code == 401:
return FetchError(f"{target} returned 401 - the content is behind a login. Find another source.")
if code == 403:
return FetchError(
f"{target} returned 403. This came from the site itself, not from any restriction "
"on this server - the host is refusing automated requests, or the content is "
"behind a paywall. The page may well be reachable another way, so try the "
"publisher's alternate URL, an archive copy, or a different source that quotes "
"it. Do not report this as the assistant being unable to reach the internet."
)
if code == 404:
return FetchError(f"{target} does not exist (404). Check the URL or search for the correct one.")
if code == 429:
return FetchError(f"{target} is rate-limiting this server (429). Use a different source rather than retrying.")
return FetchError(f"{target} returned HTTP {code}.")
return FetchError(f"Could not reach {target}: {type(exc).__name__}.")
async def get_bytes(url: str, **kwargs) -> tuple[bytes, str, str]:
"""Fetch a URL, returning (body, content_type, final_url).
Streams so an oversized response is abandoned mid-flight rather than
buffered and then rejected.
"""
client = get_client()
async with client.stream("GET", url, **kwargs) as response:
response.raise_for_status()
content_type = response.headers.get("content-type", "").split(";")[0].strip().lower()
# PDFs get the larger allowance. A reference manual genuinely is tens
# of megabytes, and the alternative to reading it is the model quoting
# a forum post as though it were the manual.
is_pdf = "pdf" in content_type or url.lower().split("?")[0].endswith(".pdf")
limit = MAX_PDF_BYTES if is_pdf else MAX_DOWNLOAD_BYTES
chunks: list[bytes] = []
total = 0
async for chunk in response.aiter_bytes():
total += len(chunk)
if total > limit:
# This used to `break` and hand back the partial bytes. They
# then failed to parse, and the parse failure was reported
# with three suggested causes that were all untrue. Refusing
# out loud is worse for one fetch and far better for the
# answer.
log.warning("aborting oversized download from %s at %d bytes", url, total)
raise FetchError(
f"{url} is larger than the {limit // (1024 * 1024)} MB this assistant can "
f"download (stopped after {total // (1024 * 1024)} MB). None of it was read, "
"so nothing in it is available - do not describe its contents from memory or "
"from someone's summary of it. If you need one section of a large manual, "
"look for that section as its own document: search for its title, or for an "
"application note covering it, or ask the user to paste the page."
)
chunks.append(chunk)
return b"".join(chunks), content_type, str(response.url)
async def get_json(url: str, **kwargs):
client = get_client()
response = await client.get(url, **kwargs)
response.raise_for_status()
return response.json()
def tls_socket(host: str, port: int = 443, *, verify: bool, timeout: float = 12.0) -> tuple[ssl.SSLSocket, socket.socket]:
"""Open a TLS connection through squid's CONNECT tunnel.
httpx will not surface a peer certificate chain, so inspect_tls needs a
real socket. Doing the CONNECT by hand keeps that traffic on the same
single egress path as everything else rather than opening a second one.
Caller closes both returned sockets.
"""
proxy = httpx.URL(settings.egress_proxy)
raw = socket.create_connection((proxy.host, proxy.port or 3128), timeout=timeout)
try:
raw.sendall(f"CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n\r\n".encode())
# Read just the tunnel response header, not a byte of the TLS record
# that follows it.
header = b""
while b"\r\n\r\n" not in header:
chunk = raw.recv(1)
if not chunk:
raise FetchError(f"The egress proxy closed the connection while opening a tunnel to {host}.")
header += chunk
if len(header) > 8192:
raise FetchError("The egress proxy returned an oversized response.")
status_line = header.split(b"\r\n", 1)[0].decode("latin-1")
if " 200 " not in status_line:
# Same discrimination as explain(): squid says 503 when it could
# not resolve the name and 403 when policy refused the address,
# and telling a model its guessed hostname is "on a private
# network" stops it ever fixing the name.
found = _PROXY_STATUS.search(status_line)
code = found.group(1) if found else ""
if code == "503":
raise FetchError(
f"{host} does not resolve - there is no such host in DNS. The hostname is "
"wrong, or it was guessed; it is not being blocked."
)
if code in {"502", "504"}:
raise FetchError(
f"{host} resolves, but the connection to port {port} failed - the server is "
"down, refusing connections, or timing out."
)
raise FetchError(
f"The egress proxy refused a connection to {host}:{port} ({status_line.strip()}). "
"That address is private, reserved, or on a port this assistant cannot reach."
)
if verify:
context = ssl.create_default_context()
else:
# Second pass: we still want to *describe* an expired or
# self-signed certificate rather than refuse to look at it.
context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
context.check_hostname = False
context.verify_mode = ssl.CERT_NONE
wrapped = context.wrap_socket(raw, server_hostname=host)
return wrapped, raw
except Exception:
raw.close()
raise
+225
View File
@@ -0,0 +1,225 @@
"""Per-user budgets for the tools that reach out and touch a named host.
dns_lookup, inspect_tls, http_headers and lookup_ip_or_asn all make this
server connect to a host of the user's choosing. squid already guarantees
that host is on the public internet, so this is not what keeps the operator's
network safe - it is what stops one of the five guest accounts from
turning a shared assistant into someone else's traffic source.
Keyed on X-OpenWebUI-User-Id, which arrives because the open-webui
container runs with ENABLE_FORWARD_USER_INFO_HEADERS=True.
"""
from __future__ import annotations
import logging
import time
from fastapi import Header, HTTPException, status
from .cache import get_redis
from .settings import settings
log = logging.getLogger(__name__)
# Distinct pages per conversation before the tool starts saying so in its own
# output. A first, gentler note lands earlier: by five separate routes to one
# fact, the useful decision is usually "answer with what you have", and saying
# it once at five is cheaper than saying it loudly at nine.
FETCH_SOFT_LIMIT = 8
FETCH_FIRST_NOTE = 5
class Identity:
__slots__ = ("user_id", "email", "role", "chat_id")
def __init__(self, user_id: str | None, email: str | None, role: str | None, chat_id: str | None):
self.user_id = user_id or "anonymous"
self.email = email
self.role = role
self.chat_id = chat_id
def __str__(self) -> str:
return f"{self.email or self.user_id}({self.role or 'unknown'})"
async def identity(
x_openwebui_user_id: str | None = Header(default=None, include_in_schema=False),
x_openwebui_user_email: str | None = Header(default=None, include_in_schema=False),
x_openwebui_user_role: str | None = Header(default=None, include_in_schema=False),
x_openwebui_chat_id: str | None = Header(default=None, include_in_schema=False),
) -> Identity:
return Identity(x_openwebui_user_id, x_openwebui_user_email, x_openwebui_user_role, x_openwebui_chat_id)
def _decode(value) -> str:
return value.decode() if isinstance(value, bytes) else str(value)
async def note_fetch(chat_id: str | None, url: str | None = None) -> str:
"""Count distinct pages opened in one conversation and say so as they add up.
A system-prompt rule telling the model to stop after ~six fetches did not
hold: asked for a specific number out of a paywalled paper, it made 33 tool
calls across three "let me try another route" rounds and never produced an
answer at all. The instruction loses to an explicit "give me the numbers".
A count in the tool's own output does not lose, because the model reads it
as a fact about the world rather than as advice. Keyed on the chat id that
Open WebUI forwards, so it is per-conversation and resets on its own.
What changed since: it counted *calls*, so paging through one long article
with `offset` spent the whole budget on a single document and hit STOP
FETCHING on page nine of one Wikipedia page - the precise opposite of the
instruction that page needed, which was to keep reading. Counting distinct
URLs instead means continuing inside a document you already opened is free,
and only genuinely new routes to the answer are charged. Cache hits stop
costing anything too, which they never should have.
"""
if not chat_id:
return ""
client = await get_redis()
if client is None:
return ""
key = f"owuitools:fetchurls:{chat_id}"
try:
# No URL means the caller cannot tell pages apart; fall back to the old
# per-call behaviour rather than losing the budget entirely.
added = await client.sadd(key, url or f"call:{await client.incr(key + ':seq')}")
if not added:
return ""
n = await client.scard(key)
if n == 1:
await client.expire(key, 1800)
except Exception:
return ""
if n == FETCH_FIRST_NOTE:
return (f"[Page {n} of this conversation. If the specific fact is still not in front "
"of you after the next one, plan to answer with what you have and name what "
"is missing. Reading further into a page you have already opened does not "
"count towards this.]")
if n == FETCH_SOFT_LIMIT:
return (f"[You have now opened {n} different pages in this conversation. If the "
"specific fact you are chasing is still not in front of you, it is probably "
"not openly available. Consider answering with what you have and naming what "
"is missing.]")
if n > FETCH_SOFT_LIMIT:
return (f"[STOP FETCHING. This is the {n}th different page in this conversation, past "
"the point where more retrieval helps. The fact you are looking for is not "
"openly available. Answer now with what you established, say plainly which "
"part you could not verify and why, and let the user decide. Do not keep "
"trying new routes to the same source. Continuing inside a page you already "
"opened, with offset= or search=, does not count and is still fine.]")
return ""
async def recall_failure(chat_id: str | None, url: str) -> str | None:
"""Why this URL already failed in this conversation, if it did.
Retrying a URL that has already 403'd or timed out produces the identical
error, costs a call, and - measured - is what a stuck model does instead of
answering: one graded run retried a 504'ing mirror and a timed-out vendor
URL after both had already failed, and stopped only when the budget forced
it. The tool can simply decline, which is both cheaper and a clearer signal
than a second identical failure.
"""
if not chat_id or not url:
return None
client = await get_redis()
if client is None:
return None
try:
reason = await client.hget(f"owuitools:fetchfail:{chat_id}", url)
except Exception:
return None
return _decode(reason) if reason is not None else None
async def note_failure(chat_id: str | None, url: str, reason: str) -> None:
"""Remember that this URL failed, so the next attempt can be declined."""
if not chat_id or not url:
return
client = await get_redis()
if client is None:
return
key = f"owuitools:fetchfail:{chat_id}"
try:
await client.hset(key, url, " ".join(str(reason).split())[:200])
await client.expire(key, 1800)
except Exception:
pass
async def note_success(chat_id: str | None, url: str) -> None:
"""Remember that this URL was read, so search results can say so."""
if not chat_id or not url:
return
client = await get_redis()
if client is None:
return
key = f"owuitools:fetchok:{chat_id}"
try:
await client.sadd(key, url)
await client.expire(key, 1800)
except Exception:
pass
async def fetch_history(chat_id: str | None) -> tuple[set[str], dict[str, str]]:
"""URLs already read, and URLs that already failed, in this conversation.
`web_search` annotates its results with these. A result the model has
already read is not a new lead, and one that already failed is not worth a
second call - but from inside a search result they look identical to fresh
ones, and the model re-fetched both in graded runs.
"""
if not chat_id:
return set(), {}
client = await get_redis()
if client is None:
return set(), {}
try:
read = {_decode(u) for u in await client.smembers(f"owuitools:fetchok:{chat_id}")}
failed = {_decode(k): _decode(v)
for k, v in (await client.hgetall(f"owuitools:fetchfail:{chat_id}")).items()}
except Exception:
return set(), {}
return read, failed
async def spend_probe(who: Identity, tool: str) -> None:
"""Consume one unit of the caller's hourly probe budget.
Fails open on cache trouble. A rate limiter that takes the tools down
when valkey hiccups causes more damage than the abuse it prevents.
"""
limit = settings.probe_rate_per_hour
if limit <= 0:
return
client = await get_redis()
if client is None:
return
window = int(time.time()) // 3600
key = f"owuitools:probe:{who.user_id}:{window}"
try:
used = await client.incr(key)
if used == 1:
await client.expire(key, 3700)
except Exception:
return
if used > limit:
log.warning("probe budget exhausted for %s on %s (%d/%d)", who, tool, used, limit)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=(
f"This user has used all {limit} network probes allowed this hour. "
"Do not retry. Tell the user the probe limit was reached and answer "
"from what you already know, or say you cannot verify it."
),
)
+44
View File
@@ -0,0 +1,44 @@
"""Logging that goes to a file as well as to stdout.
WHY BOTH
stdout is what `docker compose logs` reads and what Docker's own log
driver captures, so it has to stay. The file is so that every component
in this stack has ONE findable place - ./logs, bind-mounted at /logs -
rather than leaving somebody to work out which compose invocation shows
the thing they are looking for.
Falling back to stdout alone is deliberate. A container run without the
mount, or with the directory not writable, should keep working and say
why once; refusing to start because a log file could not be opened would
be the tail wagging the dog.
"""
from __future__ import annotations
import logging
import os
import sys
FORMAT = "%(asctime)s %(levelname)-7s %(name)s | %(message)s"
def configure(component: str, level: str = "INFO") -> logging.Logger:
"""stdout always; ./logs/<component>.log when the directory is there."""
handlers: list[logging.Handler] = [logging.StreamHandler(sys.stdout)]
problem = ""
log_dir = os.environ.get("OWUITOOLS_LOG_DIR", "/logs")
if log_dir:
try:
os.makedirs(log_dir, exist_ok=True)
handlers.append(logging.FileHandler(
os.path.join(log_dir, f"{component}.log"), encoding="utf-8"))
except OSError as exc:
problem = f"{log_dir} is not writable ({exc}); logging to stdout only"
logging.basicConfig(level=getattr(logging, level.upper(), logging.INFO),
format=FORMAT, handlers=handlers, force=True)
logger = logging.getLogger(component)
if problem:
logger.warning(problem)
return logger
+179
View File
@@ -0,0 +1,179 @@
"""owuitools - OpenAPI tool server for Open WebUI.
Registered in Open WebUI as a single admin/global OpenAPI connection.
Everything the model can see is defined by the routers below.
Two constraints from Open WebUI 0.11.3's converter shape this whole API,
both verified against its source rather than its docs:
* The tool name the model sees is the operation's `operationId`, and an
operation without one is silently dropped from the spec. Every route
therefore sets `operation_id` explicitly.
* What the model reads as the tool's description is the operation's
`description`, which FastAPI takes from the docstring. Query parameters
get flattened to `{type, description}` - enums become prose, defaults
vanish - while a request body is `$ref`-resolved with enums, defaults
and nesting intact. So every tool is POST-with-a-body, and no tool
mixes body and query parameters (the body receives *all* parameters,
which would then fail validation).
"""
from __future__ import annotations
import logging
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from . import __version__, logging_setup
from .cache import close_redis, get_redis
from .httpc import close_client, get_client
from .routers import ee, matlab, network, reference, vm, web
from .settings import settings
from .toolconfig import disabled as disabled_tools
log = logging_setup.configure("api", settings.log_level)
@asynccontextmanager
async def lifespan(app: FastAPI):
get_client()
await get_redis()
log.info("owuitools %s ready - egress via %s", __version__, settings.egress_proxy)
yield
await close_client()
await close_redis()
app = FastAPI(
title="owuitools",
version=__version__,
description=(
"Research, reference and engineering tools for the local assistant. "
"Web search and page reading, IT and networking reference lookups, and "
"deterministic electrical-engineering calculations."
),
lifespan=lifespan,
docs_url="/docs",
openapi_url="/openapi.json",
)
# vm.router is registered only when the sandbox volume is mounted. The
# schema for a tool is paid on every turn of every conversation, so a
# tool that cannot work should not be described to the model at all -
# and on a host without the VM stack installed, this one cannot.
_routers = [web.router, reference.router, network.router, ee.router, matlab.router]
if os.path.isdir(os.environ.get("OWUITOOLS_VM_JOBS_DIR", "/vmjobs")):
_routers.append(vm.router)
# Not a tool: the live-progress feed, read by an Open WebUI filter.
# It carries no operationId and is out of the schema, so the model is
# never told it exists.
_routers.append(vm.progress_router)
# Whatever configs/tools.toml switched off stops existing - not just in
# the spec the model reads, but as a route at all. See toolconfig.py for
# why this is the seam and why a missing config leaves everything on.
#
# It has to happen HERE, on the routers, rather than on app.routes after
# the fact. FastAPI 0.141 does not flatten an included router any more:
# include_router() appends one opaque _IncludedRouter to app.routes, so
# app.routes holds no APIRoute and no operation_id at all, and filtering
# it looks like it worked while removing nothing.
#
# Routes with no operation_id are never candidates: /health and the vm
# progress feed are deliberately out of the schema and are not tools.
_implemented = {
operation_id for router in _routers for route in router.routes
if (operation_id := getattr(route, "operation_id", None))
}
_switched_off = disabled_tools(_implemented)
for router in _routers:
if _switched_off:
router.routes[:] = [
route for route in router.routes
if getattr(route, "operation_id", None) not in _switched_off
]
app.include_router(router)
if _switched_off:
log.info("%d of %d tools removed by config; %d offered: %s",
len(_switched_off), len(_implemented),
len(_implemented) - len(_switched_off),
", ".join(sorted(_implemented - _switched_off)))
@app.exception_handler(RequestValidationError)
async def _validation_as_prose(request: Request, exc: RequestValidationError) -> JSONResponse:
"""Say what was wrong with the call in words, with a 200, like every other failure.
FastAPI's default is a 422 with a JSON body of pydantic error objects.
Open WebUI does not treat a non-2xx tool response as a result; it hands
the model `{"error": "HTTP error 422: {...}"}`, which two graded rounds
show being read as the tool being broken rather than the argument being
out of range. The same information as a sentence, with the same status
as a CANNOT DO THAT, is acted on: the model fixes the one parameter.
"""
problems = []
for err in exc.errors():
loc = [str(part) for part in err.get("loc", ()) if part not in ("body",)]
field = ".".join(loc) or "the request"
sent = err.get("input")
shown = repr(sent) if not isinstance(sent, (dict, list)) else "the value given"
problems.append(f"{field}: {err.get('msg', 'invalid')} (you sent {shown})")
text = ("CANNOT DO THAT: the call was rejected before it ran - "
+ "; ".join(problems[:4])
+ ". Fix that parameter and call again; nothing else about the request needs to change.")
return JSONResponse(status_code=200, content=text)
def _lean(node) -> None:
"""Strip schema noise the model would pay for and cannot use.
Open WebUI copies each request-body property into the tool definition
verbatim, and Ollama then keeps only type, anyOf, items, enum, properties,
required and description. So `title` never reaches the model but bloats
the spec, while the `anyOf: [T, null]` pydantic emits for every optional
field *does* reach it - three tokens of type information rendered as a
dozen. Collapse it to the real type; the server accepts null regardless.
"""
if isinstance(node, dict):
node.pop("title", None)
any_of = node.get("anyOf")
if isinstance(any_of, list):
real = [alt for alt in any_of if alt != {"type": "null"}]
if len(real) == 1 and len(real) < len(any_of):
del node["anyOf"]
for key, value in real[0].items():
node.setdefault(key, value)
if "default" in node and node["default"] is None:
del node["default"]
for value in list(node.values()):
_lean(value)
elif isinstance(node, list):
for value in node:
_lean(value)
_generate_openapi = app.openapi
def _lean_openapi() -> dict:
if not app.openapi_schema:
schema = _generate_openapi()
_lean(schema.get("components", {}).get("schemas", {}))
_lean(schema.get("paths", {}))
app.openapi_schema = schema
return app.openapi_schema
app.openapi = _lean_openapi
@app.get("/health", include_in_schema=False)
async def health() -> dict:
"""Unauthenticated so Docker's healthcheck can use it. Reveals nothing."""
return {"status": "ok", "version": __version__}
+548
View File
@@ -0,0 +1,548 @@
"""MATLAB-vs-Octave compatibility checking.
We execute in GNU Octave and the user runs the result in real MATLAB, so
there are three failure classes and only one of them is the model's fault:
1. Octave ACCEPTS it, MATLAB REJECTS it. The worst case - we would tell
the user "it works" and it would not.
2. MATLAB has it, this sandbox does not (toolbox functions, display,
network). The model must be told NOT to rewrite correct code.
3. Both run, results differ. Rarely detectable statically; we flag the
few constructs worth flagging and are honest about the rest.
Every rule runs against source with string BODIES and comment BODIES
blanked out, markers kept. An adversarial review of the first version
found three high-severity bugs in this file alone, all of them in the
tokeniser rather than in the rules, so read the notes on strip_literals
before touching it.
The one exception is a rule marked `raw`, which sees the source
unstripped. That exists because the worst trap we know of lives *inside*
a string body: `regexp(s, '\b40\b', 'match')` returns nothing at all, so
a stripped-source rule could never see it and the model gets a wrong
count instead of an error.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
# After one of these keywords, a quote opens a string - it cannot be a
# transpose, because there is no value to transpose. Without this,
# `case '--'` mis-tokenised and the `--` inside the string was reported
# as an Octave-only increment operator in perfectly good MATLAB.
_KEYWORDS_BEFORE_STRING = frozenset({
"case", "otherwise", "return", "if", "elseif", "else", "while", "switch",
"for", "parfor", "do", "until", "function", "global", "persistent", "try",
"catch", "end", "break", "continue", "and", "or", "not", "spmd",
"classdef", "properties", "methods", "events", "arguments", "disp", "error",
"warning", "fprintf", "sprintf", "printf", "assert", "validateattributes",
})
_TRANSPOSE_AFTER = frozenset(")]}._'")
def strip_literals(code: str) -> str:
"""Blank string and comment BODIES, keeping their markers and length.
Offsets and line breaks are preserved so a match position still maps
to the right line of the original source.
Three things here are load-bearing, each because getting them wrong
produced a real, verified defect:
* **The comment marker survives.** Blanking from the `%`/`#` itself
made the `hash-comment` and `block-comment-hash` rules unable to
match anything, ever - two declared high-severity rules were dead
code, and `#` comments are the most common Octave-ism there is.
* **State resets at a newline, but not across a `...` continuation.**
Carrying the previous line's last character over meant a quote at
the start of a line was read as a transpose, so the string body
leaked into the stripped text and every rule scanned English prose.
That produced false "NOT VALID MATLAB" verdicts on ordinary code
like `plot(x, y, ...` / `'--r', 'LineWidth', 2);`. A continuation
is the one case where state must carry, because the logical line
genuinely continues.
* **`%{ ... %}` blocks are handled as blocks.** Treating the opener
as a line comment left the prose body to be linted as code.
"""
out = list(code)
n = len(code)
i = 0
prev_char = "" # last significant char on this logical line
word = "" # identifier immediately before the cursor
def blank(a: int, b: int) -> None:
for k in range(a, min(b, n)):
if out[k] != "\n":
out[k] = " "
def line_end(pos: int) -> int:
j = code.find("\n", pos)
return n if j == -1 else j
while i < n:
c = code[i]
if c == "\n":
prev_char = ""
word = ""
i += 1
continue
# Line continuation. Everything after `...` is a comment, and the
# logical line continues - so prev_char/word deliberately persist.
if c == "." and code.startswith("...", i):
j = line_end(i)
blank(i, j)
i = j + 1 if j < n else n
continue
if c in "%#":
marker = code[i:i + 2]
stripped_line = code[code.rfind("\n", 0, i) + 1:line_end(i)].strip()
# A block comment opener is the only thing on its line.
if stripped_line in ("%{", "#{"):
closer = "%}" if marker[0] == "%" else "#}"
j = i + 2
while j < n:
ls = j
le = line_end(j)
if code[ls:le].strip() in ("%}", "#}"):
blank(i + 2, ls) # body only
i = le
break
j = le + 1
else:
blank(i + 2, n)
i = n
continue
keep = 2 if (i + 1 < n and code[i + 1] in "{}") else 1
j = line_end(i)
blank(i + keep, j)
i = j # let the newline branch reset state
continue
if c == '"':
j = i + 1
while j < n and code[j] != "\n":
if code[j] == '"':
if j + 1 < n and code[j + 1] == '"':
j += 2
continue
break
j += 1
blank(i + 1, j)
i = min(j + 1, n)
prev_char = '"'
word = ""
continue
if c == "'":
is_transpose = (
(prev_char.isalnum() or prev_char in _TRANSPOSE_AFTER)
and word not in _KEYWORDS_BEFORE_STRING
)
if is_transpose:
prev_char = "'"
word = ""
i += 1
continue
j = i + 1
while j < n and code[j] != "\n":
if code[j] == "'":
if j + 1 < n and code[j + 1] == "'":
j += 2
continue
break
j += 1
blank(i + 1, j)
i = min(j + 1, n)
prev_char = "'"
word = ""
continue
if c.isalnum() or c == "_":
word += c
prev_char = c
elif c.isspace():
pass # whitespace keeps `word` (for `case 'x'`)
else:
word = ""
prev_char = c
i += 1
return "".join(out)
@dataclass(frozen=True)
class Rule:
id: str
pattern: re.Pattern
severity: str # "high" | "medium" | "low"
kind: str # "octave_only" | "sandbox" | "semantic"
message: str
# When set, a finding is dropped if the captured name is assigned
# anywhere in the script - a user variable called `input` or `stderr`
# is not a call to the builtin, and firing on it teaches the model
# that this whole section cries wolf.
name_group: int = 0
# Match against the ORIGINAL source instead of the literal-stripped
# copy. Only for rules whose whole subject is the content of a string
# - a regexp pattern - which is blank by the time the other rules
# run. The cost is that comments are visible too, so a raw rule can
# fire on a line of prose; keep raw rules narrow enough that this is
# rare and harmless.
raw: bool = False
def _r(pat: str) -> re.Pattern:
return re.compile(pat, re.MULTILINE)
OCTAVE_ONLY: tuple[Rule, ...] = (
Rule("hash-comment", _r(r"(?:^|[ \t;,])#(?![{}])"), "high", "octave_only",
"`#` starts a comment in Octave and is a syntax error in MATLAB. Use `%`."),
Rule("block-comment-hash", _r(r"^[ \t]*#[{}][ \t]*$"), "high", "octave_only",
"`#{ ... #}` block comments are Octave-only. MATLAB uses `%{ ... %}`."),
Rule("bang-ne", _r(r"!="), "high", "octave_only",
"`!=` is Octave-only. MATLAB spells not-equal `~=`."),
# NOT anchored at statement start: `!cmd` there is MATLAB's own shell
# escape and is valid MATLAB, so flagging it as Octave-only would be
# wrong. shell-escape covers that case separately.
Rule("bang-not", _r(r"(?<![=~<>!\w])(?<!^)![ \t]*[A-Za-z_(\[\d~]"), "high", "octave_only",
"`!` as logical NOT is Octave-only. MATLAB uses `~`."),
Rule("end-keywords", _r(r"(?<![.\w])end(if|while|for|function|switch|_try_catch|parfor)\b"),
"high", "octave_only",
"`endif`/`endwhile`/`endfor`/`endfunction`/`endswitch` are Octave-only. MATLAB closes "
"every block with a bare `end`."),
Rule("unwind-protect", _r(r"(?<![.\w])unwind_protect\b"), "high", "octave_only",
"`unwind_protect` is Octave-only. MATLAB has no direct equivalent; use try/catch, or "
"`onCleanup` for guaranteed cleanup."),
Rule("do-until", _r(r"^[ \t]*do[ \t]*$|^[ \t]*until[ \t]*\(|^[ \t]*until[ \t]+(?![=\s])"),
"high", "octave_only",
"`do ... until` is Octave-only. MATLAB has no do-while; use "
"`while true ... if cond, break; end, end`."),
Rule("aug-assign", _r(r"[A-Za-z_0-9)\]}][ \t]*(\+=|-=|\*=|/=|\^=)"), "high", "octave_only",
"`+=`, `-=`, `*=`, `/=` are Octave-only. MATLAB needs the full form, e.g. `x = x + 1`."),
Rule("incr-decr", _r(r"(?<![+\-])(\+\+|--)(?![+\-])"), "high", "octave_only",
"`++` and `--` are Octave-only. MATLAB has no increment operator; write `x = x + 1`."),
Rule("power-star-star", _r(r"\*\*"), "high", "octave_only",
"`**` is Octave-only. MATLAB's power operators are `^` and `.^`."),
Rule("printf", _r(r"(?<![.\w])printf[ \t]*\("), "high", "octave_only",
"`printf` is Octave-only. MATLAB uses `fprintf`.", 0),
Rule("octave-io", _r(r"(?<![.\w])(puts|fputs|fdisp|fflush)[ \t]*\("), "high", "octave_only",
"`puts`/`fputs`/`fdisp`/`fflush` are Octave-only. MATLAB uses `fprintf` and `disp`.", 1),
Rule("octave-streams", _r(r"(?<![.\w'\"])(stdout|stderr)(?![\w'\"])"), "medium", "octave_only",
"`stdout`/`stderr` are Octave-only names. MATLAB uses the file identifiers 1 and 2, "
"e.g. `fprintf(2, ...)` to write to stderr.", 1),
# `)(` with no space between: `f(x)(2)`. Requiring no space keeps this
# off `[f(1) (2)]`, which is a two-element matrix, not chained indexing.
Rule("chained-index", _r(r"\)\("), "medium", "octave_only",
"Indexing a call's result directly, `f(x)(2)`, is Octave-only. MATLAB needs a "
"temporary: `t = f(x); t(2)`."),
)
SANDBOX: tuple[Rule, ...] = (
Rule("interactive-input",
_r(r"(?<![.\w])(input|keyboard|menu|inputdlg|questdlg|uiwait|uigetfile|ginput)[ \t]*\("),
"high", "sandbox",
"This waits for a human. The sandbox runs non-interactively, so it will hang until the "
"timeout. Hard-code the value or pass it as a variable.", 1),
Rule("bare-pause", _r(r"(?<![.\w])pause[ \t]*(\(\s*\))?[ \t]*(;|$)"), "medium", "sandbox",
"`pause` with no argument waits for a keypress and will hang the run. Give it a "
"duration or remove it."),
Rule("network",
_r(r"(?<![.\w])(webread|webwrite|websave|urlread|urlwrite|ftp|sendmail|weboptions)[ \t]*\("),
"high", "sandbox",
"The sandbox has no network at all, so this cannot run here. The code may still be "
"correct for the user's machine - say so rather than rewriting it.", 1),
Rule("shell-escape", _r(r"(?<![.\w])(system|unix|dos|winqueryreg)[ \t]*\(|^[ \t]*![A-Za-z_/.]"),
"medium", "sandbox",
"Shell access is not available in the sandbox. Remove it or note that it is untested "
"here."),
Rule("exit-quit", _r(r"^[ \t]*(exit|quit)[ \t]*(\(|;|$)"), "medium", "sandbox",
"`exit`/`quit` terminates the interpreter before results are captured. Remove it."),
Rule("arguments-block", _r(r"^[ \t]*arguments[ \t]*(\(|$)"), "high", "sandbox",
"An `arguments` validation block is valid MATLAB (R2019b+) but this sandbox's Octave "
"cannot parse it, so the run fails with a syntax error. That is a sandbox limit, not a "
"mistake: keep it for the user if they are on R2019b+, or use "
"`inputParser`/`validateattributes` to get something runnable here."),
)
SEMANTIC: tuple[Rule, ...] = (
Rule("double-quoted", _r(r'"'), "low", "semantic",
'In MATLAB R2017a+ "..." is a *string* object; in Octave it is a char array, and some '
"functions branch on that type. Use '...' for char unless you deliberately want a "
"string."),
Rule("unique-multi", _r(r"\[[^\]\n]*,[^\]\n]*\][ \t]*=[ \t]*unique[ \t]*\("), "medium", "semantic",
"The index outputs of `unique` have differed between MATLAB and Octave over first/last "
"occurrence. Verify the indices, or pass an explicit option."),
# Matches the surviving quote character, since bodies are blanked.
Rule("rand-seed", _r(r"(?<![.\w])(rand|randn)[ \t]*\([ \t]*['\"]"), "low", "semantic",
"Legacy generator seeding does not reproduce the same stream across MATLAB and Octave. "
"Use `rng(n)` in MATLAB if reproducibility matters."),
# Raw, because the pattern argument is a string body and every other
# rule sees it blanked. This one is here because it cost a whole task:
# a word-boundary count of "40" in a 100-word text returned 0, which
# is a plausible number, so nothing looked wrong until the model
# happened to re-check it with a plain pattern.
Rule("regexp-backspace",
_r(r"(?<![.\w])regexp(?:i|rep)?[ \t]*\([^\n]*?['\"][^'\"\n]*\\b"),
"high", "semantic",
"`\\b` in a regexp pattern is the BACKSPACE character, not a word boundary - in MATLAB "
"as well as in Octave, because it is the regexp engine that reads the escape. The "
"pattern then matches nothing and you get a wrong COUNT rather than an error. Use `\\<` "
"and `\\>` for word start and end, or a lookaround. Measured here: "
"`regexp('a b', '\\bb', 'match')` returns 0 matches.",
0, raw=True),
# The weight flag is documented backwards more often than it is used
# wrongly: a graded deliverable computed std(X,0,1), called it the
# population standard deviation in the docstring, the code comment and
# the usage notes, and told the user to swap to std(X,1,1) for N-1.
# Every clause inverted. Both dialects agree on the flag; say it once.
Rule("std-weight-flag", _r(r"(?<![.\w])(?:std|var)[ \t]*\([^()\n]*?,[ \t]*[01][ \t]*[,)]"),
"low", "semantic",
"In `std(x, w, dim)` / `var(x, w, dim)` the weight flag w=0 normalises by N-1 (the "
"sample statistic, the default) and w=1 by N (the population statistic) - in MATLAB "
"and Octave alike. If you document which one the code uses, check it against this "
"line: the flag is the thing that gets written backwards."),
Rule("idivide-negative", _r(r"(?<![.\w])idivide[ \t]*\("), "medium", "semantic",
"`idivide` with negative operands returned -4 for -7/2 in this sandbox, where MATLAB's "
"default 'fix' rounding gives -3. For scratch arithmetic prefer `floor`, `fix` or "
"`round` on doubles, and say which rounding you used."),
)
ALL_RULES: tuple[Rule, ...] = OCTAVE_ONLY + SANDBOX + SEMANTIC
@dataclass(frozen=True)
class Finding:
rule: Rule
line: int
text: str
_ASSIGNED = re.compile(r"^[ \t]*(?:\[[^\]]*\][ \t]*=[^=]|([A-Za-z_]\w*)[ \t]*(?:\([^)]*\))?[ \t]*=(?!=))",
re.MULTILINE)
def _assigned_names(stripped: str) -> set[str]:
"""Identifiers the script assigns to, so builtin-name rules can skip them."""
names = set()
for m in _ASSIGNED.finditer(stripped):
if m.group(1):
names.add(m.group(1))
for m in re.finditer(r"^[ \t]*function\b[^\n]*?(?:=\s*)?([A-Za-z_]\w*)\s*\(", stripped, re.MULTILINE):
names.add(m.group(1))
return names
def lint(code: str) -> list[Finding]:
"""Run every rule against literal-stripped source, report original lines.
A rule marked `raw` is matched against the original instead. Line
numbers come out the same either way: strip_literals blanks in place,
so both strings have identical length and identical newline offsets.
"""
stripped = strip_literals(code)
lines = code.split("\n")
assigned = _assigned_names(stripped)
seen: set[tuple[str, int]] = set()
out: list[Finding] = []
for rule in ALL_RULES:
target = code if rule.raw else stripped
for m in rule.pattern.finditer(target):
if rule.name_group:
try:
name = m.group(rule.name_group)
except IndexError: # pragma: no cover
name = None
if name and name in assigned:
continue
line_no = target.count("\n", 0, m.start()) + 1
key = (rule.id, line_no)
if key in seen:
continue
seen.add(key)
text = lines[line_no - 1].strip() if line_no <= len(lines) else ""
out.append(Finding(rule, line_no, text[:120]))
out.sort(key=lambda f: (f.line, f.rule.id))
return out
def is_function_file(code: str) -> str | None:
"""Return the function name if the source is a function-definition file.
A file whose first statement is `function` defines a function rather
than running anything, in both MATLAB and Octave. Executing it looks
like a bizarre "'a' undefined" error at the first use of a parameter,
which sends the model hunting for a bug that is not there.
"""
stripped = strip_literals(code)
for raw in stripped.split("\n"):
line = raw.strip()
if not line or line.startswith("%") or line.startswith("#"):
continue
m = re.match(r"^function\b(?:[^=\n]*=)?\s*([A-Za-z_]\w*)", line)
return m.group(1) if m else None
return None
# --------------------------------------------------------------------------
# Script-local functions
#
# MATLAB (R2016b+) lets a script define its functions at the END and call
# them from anywhere above; Octave defines a function when execution
# reaches it, so the same script dies on "'foo' undefined". It is the one
# discrepancy that turns correct, ordinary MATLAB into a failed call - it
# cost m07 a whole tool call in the v3 round - and the model cannot fix it
# without writing something that is no longer the code the user asked for.
#
# So the tail is lifted out and written as separate .m files beside the
# script, which is where Octave will find them and how MATLAB behaves. The
# lines they came from are replaced with blank lines rather than deleted:
# an error two thirds of the way down still reports the line number the
# model wrote, and a diff of the file still lines up.
#
# Everything here bails rather than guesses. A file it does not fully
# understand is passed through untouched and fails the way it did before,
# which is a bad outcome; mangling a file that was about to work is a
# worse one.
# --------------------------------------------------------------------------
_MAX_LOCAL_FUNCTIONS = 40
_MAX_NAME = 63
# Statement-level block openers and closers. Matched at a statement
# boundary only - start of line, or after a `,`/`;` - so `x(end)` is not a
# block end and `numel(c)` is not a `for`.
_OPENERS = ("if", "for", "parfor", "while", "switch", "try", "do",
"function", "classdef", "unwind_protect", "spmd")
_CLOSERS = ("end", "endif", "endfor", "endparfor", "endwhile", "endswitch",
"end_try_catch", "endfunction", "end_unwind_protect", "until",
"endclassdef", "endspmd")
_BLOCK_TOKEN = re.compile(
r"(?:^|[;,])[ \t]*(" + "|".join(_OPENERS + _CLOSERS) + r")\b")
_FUNCTION_LINE = re.compile(r"^[ \t]*function\b(.*)$")
@dataclass(frozen=True)
class LocalFunction:
"""One function lifted out of a script."""
name: str
line: int # 1-based line it started on, so its file can be padded
text: str # the block, verbatim
@dataclass(frozen=True)
class LocalFunctions:
"""The result of lifting a script's trailing function definitions out."""
script: str # same line count as the original
functions: tuple[LocalFunction, ...]
bailed: str | None = None # why nothing was lifted, for diagnosis
def _function_name(signature: str) -> str | None:
"""`[a, b] = foo(x)` -> foo. Returns None for anything unexpected."""
# The signature arrives with string and comment bodies already blanked,
# which also blanks a `...` continuation: a signature split across lines
# leaves its first line readable, and one that continues before naming
# the function (`function y = ...`) leaves nothing to read and is
# refused by the name pattern below.
sig = signature.split("%")[0].split("#")[0].strip()
head = sig.split("(", 1)[0]
if "=" in head:
head = head.split("=", 1)[1]
name = head.strip()
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_]{0,%d}" % (_MAX_NAME - 1), name):
return None
if name in _KEYWORDS_BEFORE_STRING or name in ("main", "varargin", "varargout"):
return None
return name
def split_local_functions(code: str) -> LocalFunctions:
"""Lift a script's trailing `function` blocks into separate files.
Returns the script with those lines blanked and the functions in the
order they were written. `bailed` is set, and nothing is lifted, when
the file is not a plain script with a clean run of function
definitions at the end of it.
"""
stripped = strip_literals(code)
raw_lines = code.split("\n")
lines = stripped.split("\n")
stack: list[str] = []
first_fn: int | None = None
starts: list[int] = []
for idx, line in enumerate(lines):
body = line.split("%")[0].split("#")[0]
depth_at_start = len(stack)
top_level_function = False
for match in _BLOCK_TOKEN.finditer(body):
token = match.group(1)
if token in _OPENERS:
if token == "function":
# Top level means: nothing open, or only the previous
# unterminated function - Octave and MATLAB both allow
# a function file whose functions have no `end`.
if not stack or stack == ["function"]:
top_level_function = True
if stack == ["function"]:
stack.pop()
elif first_fn is not None:
return LocalFunctions(code, (), "a nested function")
if token == "classdef":
return LocalFunctions(code, (), "a classdef file")
stack.append(token)
else:
if not stack:
return LocalFunctions(code, (), "an unbalanced end")
stack.pop()
if top_level_function:
if first_fn is None:
first_fn = idx
starts.append(idx)
elif first_fn is not None and depth_at_start == 0 and body.strip():
# A statement at the top level after the definitions started -
# not a line of a function body, which sits at depth 1. MATLAB
# does not allow this either, so leave the file alone and let
# the run report whatever it reports.
return LocalFunctions(code, (), "code after the function definitions")
if first_fn is None:
return LocalFunctions(code, (), None)
if first_fn == 0 or not any(l.split("%")[0].split("#")[0].strip()
for l in lines[:first_fn]):
# Nothing above the first function: this is a function file, which
# is handled - and explained to the model - elsewhere.
return LocalFunctions(code, (), "a function file")
if len(starts) > _MAX_LOCAL_FUNCTIONS:
return LocalFunctions(code, (), f"more than {_MAX_LOCAL_FUNCTIONS} local functions")
bounds = list(zip(starts, starts[1:] + [len(lines)]))
functions: list[LocalFunction] = []
seen: set[str] = set()
for start, stop in bounds:
signature = _FUNCTION_LINE.match(lines[start])
name = _function_name(signature.group(1)) if signature else None
if name is None:
return LocalFunctions(code, (), "a function signature it could not read")
if name in seen:
return LocalFunctions(code, (), f"two functions named {name}")
seen.add(name)
functions.append(LocalFunction(
name, start + 1, "\n".join(raw_lines[start:stop]).rstrip() + "\n"))
script = raw_lines[:first_fn] + [""] * (len(raw_lines) - first_fn)
return LocalFunctions("\n".join(script), tuple(functions), None)
View File
File diff suppressed because it is too large Load Diff
+908
View File
@@ -0,0 +1,908 @@
"""Run MATLAB code and report what actually happened.
The point of this tool is the write -> run -> read the error -> revise
loop. One-shotting MATLAB is not the goal and is not expected; getting a
real error with a real line number is, because that is what lets the
model fix its own code instead of guessing.
Execution is GNU Octave in a container with no network stack. Octave is
not MATLAB, and pretending otherwise would be the worst possible
outcome - we would report success on code the user cannot run. So every
result carries a compatibility verdict alongside the output, and a run
that succeeds in Octave but uses Octave-only syntax is reported as a
FAILURE of the MATLAB check.
It is also the only general calculator in this suite, and it was going
unused as one. In a graded run the model counted the letters in a word,
compounded (1+0.07/12)^360 and worked out a frequency ratio entirely in
its head while this tool sat idle - right all three times, by a method
that cannot be relied on. Hence `purpose`: a throwaway calculation gets
none of the MATLAB-portability lecture, which is about code someone else
will run, and a missing function gets told its Octave spelling instead
of being told to keep it. Three of four calls on one writing task were
burned learning those spellings the hard way.
"""
from __future__ import annotations
import asyncio
import logging
import os
import re
import secrets
import shutil
import uuid
from typing import Literal
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field, field_validator
from ..matlab_lint import (Finding, is_function_file, lint, split_local_functions,
strip_literals)
from ..security import require_auth
from ..toolbase import ToolModel, tool
log = logging.getLogger(__name__)
router = APIRouter(prefix="/tools", tags=["matlab"], dependencies=[Depends(require_auth)])
JOBS_DIR = os.environ.get("OWUITOOLS_OCTAVE_JOBS_DIR", "/jobs")
POLL_INTERVAL = 0.15
# The runner is serial, so a job can sit queued behind another for the
# full length of that one. A fixed 20 s grace made the api give up on
# jobs that were running perfectly well whenever two users overlapped.
GRACE_SECONDS = 90
STDOUT_BUDGET = 6000
# Functions that exist in MATLAB (usually via a toolbox) and are absent
# here. Used only to classify an "undefined" error, so that the model is
# told its code may be fine rather than being sent to rewrite it.
# Functions that are real MATLAB but genuinely absent from THIS sandbox.
#
# Measured, not recalled. Every name below was tested with exist() inside
# the running container with all four forge packages loaded, because the
# guesses were wrong in both directions: fitlm, imread, pca, kmeans and
# webread are all present via octave-forge, while contains, string and
# datetime - everyday modern MATLAB - are not. A hard-coded list from
# memory would have told the model to rewrite working code and to trust
# code that cannot run.
#
# Re-measure with tests/matlab_availability.m after changing the image.
# Five names that USED to be here - contains, strlength, matches,
# histcounts, discretize - are gone because octave/shim now implements
# them. The shims are the only reason those work; see octave/check-shims.sh,
# which deletes any of them that a future Octave provides itself.
_MATLAB_ONLY = {
# Statistics and Machine Learning Toolbox
"fitglm": "Statistics and Machine Learning Toolbox",
"fitcsvm": "Statistics and Machine Learning Toolbox",
"fitctree": "Statistics and Machine Learning Toolbox",
"fitrtree": "Statistics and Machine Learning Toolbox",
"ridge": "Statistics and Machine Learning Toolbox",
"lasso": "Statistics and Machine Learning Toolbox",
# Core MATLAB, newer than this Octave
"string": "core MATLAB (string type, R2016b+)",
"pad": "core MATLAB (R2016b+)",
"insertAfter": "core MATLAB (R2016b+)",
"join": "core MATLAB (R2013a+)",
"split": "core MATLAB (R2016b+)",
"table": "core MATLAB (table type, R2013b+)",
"readtable": "core MATLAB (R2013b+)",
"writetable": "core MATLAB (R2013b+)",
"timetable": "core MATLAB (R2016b+)",
"categorical": "core MATLAB (R2013b+)",
"dictionary": "core MATLAB (R2022b+)",
"datetime": "core MATLAB (R2014b+)",
"duration": "core MATLAB (R2014b+)",
"calendarDuration": "core MATLAB (R2014b+)",
"normalize": "core MATLAB (R2018a+)",
"rmoutliers": "core MATLAB (R2018b+)",
"groupsummary": "core MATLAB (R2018a+)",
"pagemtimes": "core MATLAB (R2020b+)",
"tensorprod": "core MATLAB (R2022a+)",
"memoize": "core MATLAB (R2017a+)",
"websave": "core MATLAB - and the sandbox has no network in any case",
# Signal Processing Toolbox
"bandpass": "Signal Processing Toolbox",
"bandstop": "Signal Processing Toolbox",
"lowpass": "Signal Processing Toolbox",
"highpass": "Signal Processing Toolbox",
"pspectrum": "Signal Processing Toolbox",
"designfilt": "Signal Processing Toolbox",
"spectrogram": "Signal Processing Toolbox",
# Image Processing Toolbox
"imresize": "Image Processing Toolbox",
"bwlabel": "Image Processing Toolbox",
"regionprops": "Image Processing Toolbox",
"edge": "Image Processing Toolbox",
"imfilter": "Image Processing Toolbox",
# Symbolic Math Toolbox
"syms": "Symbolic Math Toolbox",
"vpa": "Symbolic Math Toolbox",
"solve": "Symbolic Math Toolbox",
"simplify": "Symbolic Math Toolbox",
"laplace": "Symbolic Math Toolbox",
"ilaplace": "Symbolic Math Toolbox",
# Control System Toolbox gaps in octave-control
"feedback": "Control System Toolbox",
"c2d": "Control System Toolbox",
# Curve Fitting / Deep Learning / Simulink / Parallel
"fit": "Curve Fitting Toolbox",
"cftool": "Curve Fitting Toolbox",
"trainNetwork": "Deep Learning Toolbox",
"dlarray": "Deep Learning Toolbox",
"sim": "Simulink",
"gpuArray": "Parallel Computing Toolbox",
"parfeval": "Parallel Computing Toolbox",
}
# What to write instead, when the code is a scratch calculation and
# nobody is going to run it in MATLAB.
#
# "Do NOT rewrite it" is the right advice for code being handed to a
# user and the wrong advice for code the model is using to check itself:
# on one writing task it stopped a run dead at `contains`, and the model
# spent two further calls rediscovering `strfind` and `strsplit`. Only
# names with a real one-line equivalent are listed; the next table says
# so plainly where there is none, because the model will write whatever
# we name here and pay another call to find out it does not exist.
_OCTAVE_EQUIVALENT = {
"count": "`numel(strfind(s, t))`",
"split": "`strsplit(s, delim)`, or `strsplit(s)` to split on whitespace",
"join": "`strjoin(c, delim)`",
"replace": "`strrep(s, old, new)`",
"string": "plain single-quoted char arrays - `'abc'` - and the `str*` functions "
"(`strcmp`, `strrep`, `strsplit`, `strtrim`) that work on them",
"extractBetween": "`regexp(s, 'a(.*?)b', 'tokens')`, which gives a cell of cells",
"datetime": "`datenum` for the arithmetic and `datestr` for printing it",
"duration": "plain numbers in the unit you choose, converted with `datenum` differences",
"dictionary": "`containers.Map(keys, values)`, or a struct with dynamic field names",
"normalize": "the arithmetic itself, e.g. `(x - mean(x)) ./ std(x)`",
"rmoutliers": "the test itself, e.g. `x(abs(x - median(x)) < 3 * std(x))`",
}
_NO_OCTAVE_EQUIVALENT = {
"table": "keep the columns as separate vectors, or as fields of one struct, and index "
"them with the same logical mask",
"timetable": "keep the timestamps in one vector and the values in another, and index both "
"with the same mask",
"categorical": "work with the labels as a cell array of strings and compare them with "
"`strcmp`",
"syms": "there is no symbolic algebra here - do it numerically (`roots`, `fzero`, "
"`fminsearch`, `polyfit`) or by hand, and say which you did",
"solve": "there is no symbolic solver here - use `roots` for a polynomial, `fzero` for a "
"scalar equation, or `\\` for a linear system",
"vpa": "Octave's doubles are all you have; print more digits with `fprintf('%.15g', x)` "
"rather than asking for arbitrary precision",
}
# Names a script must not give its own functions.
#
# From m04, v4: the answer defined `function normalize(X)`. That is a
# MATLAB built-in (R2018a+) and the delivered file therefore shadows it in
# the user's MATLAB - and nothing here noticed, because `normalize` does
# not exist in this Octave at all. A name that is free in the sandbox and
# taken in MATLAB is invisible to every other check we run.
#
# Two sources, both of them names MATLAB really has: the toolbox and
# modern-core table above, and the core functions below - each one
# verified present in this image, and every one of them a plausible thing
# to call your own function. The list is deliberately partial; a missing
# name costs a note, never a wrong one.
_CORE_MATLAB_NAMES = frozenset({
"abs", "accumarray", "angle", "area", "axis", "balance", "cat", "ceil",
# Implemented by octave/shim rather than by Octave, and just as much
# MATLAB's to shadow.
"contains", "discretize", "histcounts", "matches", "maxk", "mink",
"rmmissing", "strlength",
"circshift", "class", "conv", "cross", "cumsum", "deconv", "det",
"detrend", "diff", "disp", "display", "dot", "double", "eig", "fft",
"fieldnames", "filter", "find", "fix", "flip", "floor", "gradient",
"grid", "hist", "histc", "horzcat", "imag", "input", "integral",
"interp1", "intersect", "inv", "isfield", "legend", "length", "line",
"load", "lu", "magic", "max", "mean", "median", "min", "mod", "mode",
"norm", "plot", "polyfit", "polyval", "power", "print", "prod", "qr",
"range", "rank", "real", "rem", "rescale", "rot90", "round", "run",
"save", "setdiff", "sign", "single", "size", "sort", "std", "struct",
"svd", "text", "title", "trace", "trapz", "union", "unique", "var",
})
_SHADOWS_MATLAB = frozenset(_MATLAB_ONLY) | _CORE_MATLAB_NAMES
class HelperFile(BaseModel):
name: str = Field(
description="File name with the .m extension, matching the function inside, e.g. "
"'solveHeat.m'.",
)
code: str = Field(description="Full contents of the file, normally one function definition.")
class MatlabRequest(ToolModel):
# Nullable on purpose: ToolModel coerces "" to None across this whole
# suite because models do send empty strings, and a required str then
# fails validation with a 422 the model cannot read. Better to accept
# it and answer in words it understands.
code: str | None = Field(
description="The script to run: top-level statements that do something, printing what you "
"want to check with disp or fprintf. Local functions may go at the END of the "
"script and be called from above it, as in MATLAB. A script that ONLY defines "
"a function runs nothing - put those in helper_files.",
)
purpose: Literal["deliverable", "scratch"] = Field(
default="deliverable",
description="'deliverable' (default) when the user will run the code in MATLAB. 'scratch' "
"when you are working something out for yourself: the MATLAB-compatibility "
"verdict is skipped and a missing function comes back with its Octave "
"equivalent.",
)
helper_files: list[HelperFile] | None = Field(
default=None,
description="Extra .m files placed beside the script, one function each.",
)
return_figure: str | None = Field(
default=None,
description="Name of an image the script saves in this same call, e.g. 'plot.png' via "
"print('plot.png','-dpng'). On success the tool returns that image instead of "
"the text output, for YOU to check axes, scaling and that the curve is not "
"empty; the user does not see it, so describe what the code plots. Verify "
"printed numbers with an ordinary run first - nothing survives between calls.",
)
timeout_seconds: int = Field(
default=25, ge=1, le=60,
description="Wall-clock limit in seconds, default 25, max 60. Raise it only for genuinely "
"heavy numerical work; hitting the limit usually means a stuck loop.",
)
# ToolModel coerces "" to None on every field, because models send
# empty strings for parameters they are not using as often as they
# omit them. On a Literal that coercion is a 422 the model cannot
# read - it would lose the whole call over a parameter it did not
# want - so a blank or missing purpose lands back on the default.
@field_validator("purpose", mode="before")
@classmethod
def _purpose_default(cls, value):
v = value.strip().lower() if isinstance(value, str) else value
return v if v in ("deliverable", "scratch") else "deliverable"
def _fmt_findings(findings: list[Finding], kinds: set[str]) -> list[str]:
"""One entry per rule, not per line.
A script with twelve double-quoted strings printed the same sentence
twelve times, a screenful in which any other note was invisible. A rule
that fires more than once is reported once, with the line numbers.
"""
out: list[str] = []
seen: dict[str, list[Finding]] = {}
for f in findings:
if f.rule.kind not in kinds:
continue
seen.setdefault(f.rule.id, []).append(f)
for hits in seen.values():
first = hits[0]
if len(hits) == 1:
out.append(f"- line {first.line}: {first.rule.message}")
else:
shown = ", ".join(str(h.line) for h in hits[:8])
more = f" and {len(hits) - 8} more" if len(hits) > 8 else ""
out.append(f"- lines {shown}{more} ({len(hits)} places): {first.rule.message}")
if first.text:
out.append(f" {first.text}")
return out
def _clip(text: str, budget: int) -> str:
"""Trim from the MIDDLE, keeping both ends.
The global response cap trims from the end, so a script that printed a
lot used to push the MATLAB-compatibility verdict - the one thing this
tool exists to deliver - off the bottom of the result. Both ends of a
long output are also more useful than its first half: the beginning
shows it started correctly, the end shows where it got to.
"""
if len(text) <= budget:
return text
half = budget // 2
cut = len(text) - budget
return (text[:half] + f"\n\n[... {cut:,} characters of output omitted from the middle ...]\n\n"
+ text[-half:])
# (pattern, raw, advice). `raw` means match the source as written rather
# than the literal-stripped copy: the subject of the second rule IS a
# string, `'all'`, and the stripped copy has blanked it - the same trap
# the linter documents for its own raw rule.
_MATLAB_CALL_FORMS = (
(re.compile(r"\bround[ \t]*\([^()]*,"), False,
"`round(x, n)` - rounding to n digits - is MATLAB R2014b and later. This Octave's "
"`round` takes one argument. For a run here, `round(x * 10^n) / 10^n`."),
(re.compile(r"\b(?:sum|prod|mean|median|max|min|any|all)[ \t]*\([^;\n]*['\"]all['\"]"), True,
"The `'all'` option - reducing over every element at once - is MATLAB R2018b and later. "
"This Octave does not take it. For a run here, reduce the flattened array instead: "
"`sum(A(:))`, `max(A(:))`."),
)
def _classify_error(message: str, identifier: str, purpose: str = "deliverable",
code: str = "", lift_bailed: str | None = None) -> str | None:
"""Say WHY a run failed, when the raw message would mislead.
An "undefined function" for a real MATLAB toolbox function is not a
bug in the model's code, and the default reading of that error - go
and fix it - produces worse code than it started with.
Unless the code is scratch. Then the model is using Octave as a
verifier, "do not rewrite it" forbids the one move that would let it
finish, and the useful answer is the Octave spelling.
"""
m = re.search(r"'([A-Za-z_]\w*)' undefined", message)
name = m.group(1) if m else None
if name and purpose == "scratch" and name in _OCTAVE_EQUIVALENT:
return (
f"**`{name}`** is not available in this sandbox. This is a scratch calculation, so "
f"use the Octave equivalent: {_OCTAVE_EQUIVALENT[name]}. Rewriting it is the right "
f"move here - this code is not going to anyone."
)
if name and purpose == "scratch" and name in _NO_OCTAVE_EQUIVALENT:
return (
f"**`{name}`** is not available in this sandbox and there is no short Octave "
f"equivalent. This is a scratch calculation, so {_NO_OCTAVE_EQUIVALENT[name]}."
)
if name and name in _MATLAB_ONLY:
return (
f"**`{name}` is real MATLAB - it comes from {_MATLAB_ONLY[name]} - but it is not "
f"available in this sandbox.** Your code may be correct as written. Do NOT rewrite "
f"it to avoid `{name}` unless the user wants code that runs without that toolbox. "
f"Say that you could not execute this part here, and check the rest of the logic "
f"another way."
)
if name and re.search(rf"^[ \t]*function\b[^\n]*\b{re.escape(name)}[ \t]*\(", code, re.M):
# The function IS in the script - below the line that calls it.
# MATLAB wants local functions at the end of a script file; Octave
# runs a script top to bottom and only knows a function once its
# definition has executed. The raw message says "undefined", which
# reads as a typo, and the model's fix for a typo is to rewrite.
# Normally this never fires: a script's trailing functions are
# lifted into files of their own before the run, which is what
# MATLAB's hoisting amounts to. Reaching here means the lift
# refused the file, and the reason is worth naming - it is the
# thing to fix, and it is not the call site the error points at.
why = f" The definitions were not lifted out of the script because it has {lift_bailed}." \
if lift_bailed else ""
return (
f"`{name}` is defined in this script, but below the line that calls it, and this "
"sandbox (Octave) knows a function only once its definition has been reached." + why +
" MATLAB requires local functions at the END of a script and hoists them, so the "
"ordering you wrote is the correct MATLAB one - keep it in anything you hand over. "
f"To get a run here, move the definition above the first call or pass it in "
f"helper_files as `{name}.m`."
)
if name:
return (
f"`{name}` is not defined. Either it is a typo, or it is a function you meant to "
f"supply - if it is yours, pass it in helper_files as `{name}.m`."
)
# Octave's wording for a misspelled option, and the misspelling it
# nearly always means. Worth naming because the raw message says only
# that an option was not recognised, not which one or what the right
# one is, and a call died on `'token'` for want of one letter.
# A MATLAB call form this Octave does not accept. The message Octave
# gives - "Invalid call to round", "unrecognized type argument" - names
# neither the argument at fault nor the fact that the code is correct
# MATLAB, and the model's reading of "invalid" is that it wrote a bug.
stripped_code = strip_literals(code)
for pattern, raw, advice in _MATLAB_CALL_FORMS:
if pattern.search(code if raw else stripped_code):
keep = ("This is a scratch calculation, so rewriting it is the right move." if
purpose == "scratch" else
"Keep the MATLAB form in what you hand over - it is valid there - and say "
"that this sandbox could not run that line as written.")
return f"{advice} {keep}"
if "regexp: unrecognized option" in message:
return (
"`regexp`'s options are 'match', 'tokens', 'names', 'start', 'end', 'split' and "
"'once' - the plural 'tokens', not 'token'. MATLAB spells them the same way, so "
"this is a typo rather than a dialect difference."
)
if identifier == "Octave:nonconformant-args":
return ("A dimension mismatch. The shapes are in the message above - check whether you "
"wanted element-wise (`.*`, `./`) rather than matrix (`*`, `/`) operations, or "
"a transpose.")
if "parse error" in message or "syntax error" in message:
# The offending line is quoted in the message after ">>>". The
# commonest way to break an fprintf string in either dialect is a
# backslash-escaped apostrophe, which C taught everyone and MATLAB
# does not have; the raw message just says "syntax error", the
# model blamed Octave, and a call was lost to the wrong diagnosis.
quoted = re.search(r">>>\s*(.*)", message)
line = quoted.group(1) if quoted else ""
if re.search(r"'[^'\n]*\\'", line) or re.search(r"'[^'\n]*\\'", code):
return (
"The line quoted above has `\\'` inside a single-quoted string. Backslash is not "
"an escape character in MATLAB or Octave string literals: an apostrophe inside "
"a single-quoted string is written as two quotes (`'today''s dollars'`), and "
"`\\n` only becomes a newline because fprintf interprets it, not the parser. "
"Replace each `\\'` with `''` and the script will parse in both dialects."
)
return ("A syntax error, so nothing ran at all. The caret in the message points at where "
"the parser gave up; the real mistake is often on the line above.")
if identifier == "Octave:index-out-of-bounds":
return "An index ran past the end of an array. MATLAB indexes from 1, not 0."
return None
def _parse_result(raw: str) -> dict:
head, _, rest = raw.partition("__OWUITOOLS_ARTEFACTS__")
arte, _, rest2 = rest.partition("__OWUITOOLS_STDOUT__")
stdout, _, stderr = rest2.partition("__OWUITOOLS_STDERR__")
stderr, _, figure = stderr.partition("__OWUITOOLS_FIGURE__")
meta = {}
for line in head.strip().split("\n"):
if "=" in line:
k, _, v = line.partition("=")
meta[k.strip()] = v.strip()
return {
"meta": meta,
"artefacts": [a.strip() for a in arte.strip().split("\n") if a.strip()],
"stdout": stdout.strip("\n"),
"stderr": stderr.strip("\n"),
"figure": figure.strip(),
}
_INTERNAL_FRAME = re.compile(r"^\s*(__driver|run|source)\s+at\s+line\s+\d+.*$\n?", re.MULTILINE)
def _clean_stderr(text: str) -> str:
"""Drop stack frames from our own wrapper out of warning text.
Octave appends "called from / __driver at line 27 / run at line 93" to
every warning raised inside the sourced script. Those two frames are an
artefact of how we invoke it, they appear on every single warning, and
they invite the model to reason about a driver it cannot see.
"""
text = _INTERNAL_FRAME.sub("", text)
text = re.sub(r"^warning: called from\s*$\n?", "", text, flags=re.MULTILINE)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
# Octave 7.3 prints "of file" without the space, so the pattern allows
# either. The construct comes first, the line number second, the file
# last; all three are wanted.
# The construct is length-bounded rather than a bare `.+?`: stderr is
# attacker-influenced text up to 200 kB, and a lazy quantifier with no
# ceiling is a backtracking bill waiting to be presented.
_PARSER_EXT = re.compile(
r"^warning: Octave language extension used: (.{1,200}?) near line (\d{1,9}) "
r"of ?file ([^\n]{0,300})$",
re.MULTILINE)
def _parser_notes(stderr: str, already_flagged: set[int]) -> tuple[list[str], str]:
"""Turn Octave's own parse warnings into MATLAB-compatibility notes.
The driver turns on `Octave:language-extension`, so Octave reports the
Octave-only syntax it sees in the file it is about to run. This is a
second opinion on the same question our regex linter answers, from the
one parser that cannot be wrong about its own dialect - it catches
`x .*= c` and a bare newline inside parentheses, neither of which has a
rule - and it comes with a line number.
Warnings from Octave's own library are dropped in run.sh by path, so
what arrives here is the user's code. A line the linter already
reported is dropped too: the same defect said twice reads as two.
Returns the notes and the stderr with those lines removed, so a
compatibility finding is not also printed as a raw warning.
"""
notes: list[str] = []
seen: set[tuple[str, int]] = set()
for match in _PARSER_EXT.finditer(stderr or ""):
construct = " ".join(match.group(1).split())[:80]
try:
line = int(match.group(2))
except ValueError: # pragma: no cover - the pattern requires digits
continue
source = os.path.basename(match.group(3).strip()) or "main.m"
if source == "main.m" and line in already_flagged:
continue
if (source, line) in seen:
continue
seen.add((source, line))
where = "" if source == "main.m" else f" of {source}"
notes.append(
f"- line {line}{where}: Octave's parser read `{construct}` - an Octave extension, "
"not MATLAB syntax")
cleaned = _PARSER_EXT.sub("", stderr or "")
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
return notes[:12], cleaned
def _extract_error(stderr: str, local_names: frozenset[str] = frozenset()) -> tuple[str, str, list[str], str]:
"""Pull the structured error the driver emitted out of stderr.
Returns (identifier, message, user stack frames, leftover stderr).
Frames inside our own driver are dropped: they are an artefact of how
we invoke the script and mean nothing to the model. `message:` is last
in the block and takes everything after it, because an error text can
itself contain a line beginning "at:".
"""
marker = "__OWUITOOLS_ERR__"
if marker not in stderr:
return "", "", [], _clean_stderr(stderr)
before, _, block = stderr.partition(marker)
identifier = ""
frames: list[str] = []
message = ""
lines = block.strip().split("\n")
for idx, line in enumerate(lines):
if line.startswith("identifier:"):
identifier = line[len("identifier:"):].strip()
elif line.startswith("at:"):
body = line[len("at:"):]
fn, _, ln = body.rpartition(":")
if fn in ("__driver", "run", "source"):
continue
if fn == "main":
frames.append(f"main.m line {ln}")
elif fn in local_names:
# A local function lifted out of the script. Its file is
# padded so the line number is the one in the model's own
# source, and saying "main.m" keeps it that way - it never
# wrote a foo.m and should not go looking for one.
frames.append(f"main.m line {ln} (in {fn})")
else:
frames.append(f"{fn}.m line {ln}")
elif line.startswith("message:"):
message = "\n".join([line[len("message:"):]] + lines[idx + 1:]).strip()
break
# The sandbox path is an implementation detail; inviting the model to
# reason about /jobs/run/<uuid>/work only wastes its context.
message = re.sub(r"/jobs/run/[^/]+/work/", "", message)
return identifier, message, frames, _clean_stderr(before)
async def _submit(code: str, helpers: list[HelperFile], timeout: int,
figure: str | None,
locals_: tuple[tuple[str, str], ...] = ()) -> tuple[dict | None, str]:
job_id = uuid.uuid4().hex
nonce = secrets.token_hex(16)
job_dir = os.path.join(JOBS_DIR, "in", job_id)
out_path = os.path.join(JOBS_DIR, "out", f"{job_id}.out")
def _write() -> None:
os.makedirs(job_dir, exist_ok=True)
with open(os.path.join(job_dir, "main.m"), "w", encoding="utf-8") as fh:
fh.write(code)
for h in helpers:
name = os.path.basename((h.name or "").strip())
if not re.fullmatch(r"[A-Za-z]\w*\.m", name) or name == "main.m":
continue
with open(os.path.join(job_dir, name), "w", encoding="utf-8") as fh:
fh.write(h.code or "")
# Functions lifted out of the script. The name came from a parser
# that only accepts an identifier, and it is checked again here
# against the same pattern the helper files use: this is the point
# where a name becomes a path, and one check at the boundary is
# worth more than any number upstream.
for name, text in locals_:
filename = os.path.basename(f"{name}.m")
if not re.fullmatch(r"[A-Za-z]\w*\.m", filename) or filename == "main.m":
continue
path = os.path.join(job_dir, filename)
if os.path.exists(path): # a helper of the same name wins
continue
with open(path, "w", encoding="utf-8") as fh:
fh.write(text)
with open(os.path.join(job_dir, "meta"), "w", encoding="utf-8") as fh:
fh.write(f"TIMEOUT={timeout}\nNONCE={nonce}\n")
if figure:
fh.write(f"FIGURE={figure}\n")
# Written last: its existence is what tells the runner the
# directory is complete, so the two never race.
open(os.path.join(JOBS_DIR, "in", f"{job_id}.ready"), "w").close()
await asyncio.to_thread(_write)
deadline = timeout + GRACE_SECONDS
waited = 0.0
while waited < deadline:
if await asyncio.to_thread(os.path.exists, out_path):
raw = await asyncio.to_thread(
lambda: open(out_path, encoding="utf-8", errors="replace").read())
await asyncio.to_thread(lambda: os.remove(out_path))
return _parse_result(raw), nonce
await asyncio.sleep(POLL_INTERVAL)
waited += POLL_INTERVAL
await asyncio.to_thread(lambda: shutil.rmtree(job_dir, ignore_errors=True))
return None, nonce
@router.post("/matlab_run", operation_id="matlab_run", response_model=str,
summary="Run MATLAB code, or any calculation, and report output, errors and "
"MATLAB compatibility")
@tool
async def matlab_run(body: MatlabRequest) -> str:
"""Execute MATLAB code and return its output, or the real error with a line number.
Use it for any MATLAB you write for someone - never hand over code you have not run.
Run it, read the error, fix it, run again; several iterations is normal. It is also
your calculator and string scratchpad whether or not anyone asked for MATLAB: counting
letters or words, compounding, unit conversions, dates, probabilities, sorting,
anything with an exponent - five lines with fprintf give a number you can quote. Send
those with purpose='scratch'.
Nothing is shown unless the script prints it (disp, fprintf, size, class). Reusable
functions go in helper_files, one per file named after the function; a script that
only defines a function runs nothing. Execution is GNU Octave, so the result also says
whether the code is valid MATLAB - a run can succeed and still fail that check, and
then the code is not finished. The sandbox has no network, display or MATLAB toolboxes;
a function missing for that reason is reported explicitly - do not rewrite working
code to avoid it.
"""
code = body.code or ""
if not code.strip():
return "CANNOT DO THAT: `code` is empty. Provide the MATLAB script you want to run."
helpers = body.helper_files or []
rejected = [
(h.name or "(unnamed)") for h in helpers
if not re.fullmatch(r"[A-Za-z]\w*\.m", os.path.basename((h.name or "").strip()))
or os.path.basename((h.name or "").strip()) == "main.m"
]
findings = lint(code)
for h in helpers:
findings.extend(lint(h.code or ""))
# Scratch code is a throwaway nobody will run in MATLAB, so its
# Octave-only constructs are not a defect and reporting them as "THIS
# IS NOT VALID MATLAB" only buries the number that was asked for.
# SANDBOX findings stay - those mean it cannot run at all - and so do
# SEMANTIC ones, which mean the number may be wrong, and that matters
# most exactly when the number IS the answer.
scratch = body.purpose == "scratch"
blockers = [] if scratch else _fmt_findings(findings, {"octave_only"})
sandbox_notes = _fmt_findings(findings, {"sandbox"})
semantic_notes = _fmt_findings(findings, {"semantic"})
fn = is_function_file(code)
if fn:
return "\n".join([
"# Nothing ran - `code` is a function definition, not a script",
"",
f"Your `code` begins with `function {fn}(...)`, which DEFINES a function rather than "
"running anything. This is true in MATLAB too, so it is not a sandbox quirk.",
"",
"Move it into `helper_files` as:",
f' {{"name": "{fn}.m", "code": "function ..."}}',
f"and make `code` a script that calls it, e.g. `disp({fn}(...))` with real arguments "
"so you can see whether it works.",
])
# MATLAB (R2016b+) hoists a script's local functions; Octave defines
# them when execution reaches the definition, so an ordinary MATLAB
# script that calls its helpers above where they are written dies on
# "'foo' undefined" - correct code, refused. The definitions are
# lifted into files beside the script, which is where Octave looks and
# what MATLAB behaves like. Anything the parser is not sure of is left
# exactly as written; see matlab_lint.split_local_functions.
lifted = split_local_functions(code)
helper_names = {os.path.basename((h.name or "").strip())[:-2] for h in helpers}
locals_ = tuple((f.name, "\n" * (f.line - 1) + f.text)
for f in lifted.functions if f.name not in helper_names)
local_names = frozenset(name for name, _ in locals_)
shadowed = sorted(n for n in local_names | helper_names if n in _SHADOWS_MATLAB)
want_figure = (body.return_figure or "").strip()
if want_figure and not re.fullmatch(r"[A-Za-z0-9_.-]{1,64}", os.path.basename(want_figure)):
want_figure = ""
result, nonce = await _submit(lifted.script, helpers, body.timeout_seconds,
os.path.basename(want_figure) if want_figure else None,
locals_)
if result is None:
return ("FAILED: no result came back within "
f"{body.timeout_seconds + GRACE_SECONDS}s. The sandbox runs one job at a time, so "
"it may be busy with someone else's, or it may be down. Nothing was executed - "
"tell the user you could not run the code rather than implying it works, and try "
"once more before giving up.")
meta = result["meta"]
if meta.get("NONCE") != nonce:
log.error("matlab_run nonce mismatch: expected %s got %s", nonce, meta.get("NONCE"))
return ("FAILED: the sandbox returned a result that failed its integrity check, so it "
"has been discarded. Do not report anything about this run. Try again.")
rc = meta.get("RC", "")
duration_raw = meta.get("DURATION_MS", "")
duration_ms = int(duration_raw) if duration_raw.isdigit() else None
limit_ms = int(meta.get("TIMEOUT", "0") or 0) * 1000
artefacts = result["artefacts"]
identifier, message, frames, other_stderr = _extract_error(result["stderr"], local_names)
# Octave's parser had its own say while reading the script. Scratch
# code is not going to MATLAB, so it is not told - the same policy the
# linter's octave_only findings follow.
parser_notes, other_stderr = _parser_notes(
other_stderr, {f.line for f in findings if f.rule.kind == "octave_only"})
if parser_notes and not scratch:
blockers = blockers + parser_notes
if not rc:
return ("FAILED: the sandbox returned a result this tool could not read. Nothing "
"reliable is known about the run. Try again rather than reporting a result.")
# 124 is GNU timeout; 137 is SIGKILL, which `timeout -s KILL` also
# produces - so the exit code alone cannot tell a timeout from an OOM
# kill. Elapsed time can: a job killed well short of its limit ran out
# of memory, and telling the model to go and hunt for an infinite loop
# would send it after the wrong bug entirely.
hit_limit = duration_ms is not None and limit_ms and duration_ms >= limit_ms - 700
timed_out = rc == "124" or (rc == "137" and hit_limit)
oom_killed = rc == "137" and not hit_limit
ok = rc == "0"
# Returning the image ALONE is the only shape Open WebUI acts on: it
# detects a tool result that IS a data: URI and hands the image to the
# model as a vision input, replacing the text with a one-line summary.
# Mixed with text it would just be a base64 blob in the prompt.
#
# Measured, because the code that decides this is easy to misread: on
# this version's native tool-calling path a data: URI goes to the MODEL,
# not to the user - middleware.py routes it to output_parts as an
# input_image and only non-data URLs reach display_files. Verified by
# asking the model to describe a plot it had not been told about; it
# correctly reported a green filled circle on a white background with no
# edge. So this is self-verification, not user-facing display.
#
# Only on a clean run: an error the model needs to read must never be
# swallowed by a picture.
if want_figure and ok and not blockers and result.get("figure"):
b64 = "".join(result["figure"].split())
ext = os.path.splitext(want_figure)[1].lower()
mime = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".gif": "image/gif"}.get(ext, "image/png")
if b64:
return f"data:{mime};base64,{b64}"
lines: list[str] = []
# A scratch run was never checked against MATLAB, so it must not be
# headed "MATLAB run OK" - that is the claim this whole tool exists
# not to make.
label = "Scratch" if scratch else "MATLAB"
# The compatibility verdict goes FIRST on every path. The global
# response cap trims from the end, and this is the part of the result
# that must survive.
if ok and blockers:
lines += [
"# Ran in the sandbox, but THIS IS NOT VALID MATLAB",
"",
"It executed here because Octave accepts these; MATLAB will reject them and the "
"user's run will fail. Fix them and run again before handing this over.",
"",
*blockers,
"",
]
elif ok:
lines.append(
f"# {label} run OK ({duration_ms / 1000:.2f}s)" if duration_ms is not None
else f"# {label} run OK")
elif timed_out:
lines += [
f"# Timed out after {meta.get('TIMEOUT', '?')}s",
"",
"The script was killed at the limit. A timeout is almost always an infinite loop or "
"a wait for input, not slowness - check loop exit conditions before raising "
"timeout_seconds. Any output below is what it had produced by then.",
"",
]
elif oom_killed:
lines += [
"# Killed - out of memory",
"",
f"The script was killed after {duration_ms}ms, well short of its "
f"{meta.get('TIMEOUT', '?')}s limit, so this is the 1 GB memory cap rather than a "
"timeout. Something allocated far more than intended - check array sizes and any "
"array that grows inside a loop.",
"",
]
else:
lines += [f"# {label} run FAILED", ""]
if blockers:
lines += ["## It is also not valid MATLAB", *blockers, ""]
if message:
lines.append("## Error")
lines.append(message)
if frames:
lines += ["", "at " + " -> ".join(frames)]
if identifier:
lines.append(f"(identifier: {identifier})")
hint = _classify_error(message, identifier, body.purpose, body.code or "",
lifted.bailed)
if hint:
lines += ["", hint]
elif other_stderr:
lines += ["## Error", _clip(other_stderr, 2000)]
else:
lines.append(f"The script exited with status {rc} and produced no error message.")
lines.append("")
# Same ordering discipline as the verdict above: whatever must survive
# `cap()` goes near the top. In scratch mode the MATLAB check is gone
# and this is what is left - a warning that the printed number may be
# a plausible wrong one, which is worse than an error and must not be
# trimmed off the bottom behind a long output.
if scratch and semantic_notes:
lines += ["## It ran, but the NUMBERS may be wrong", *semantic_notes, ""]
# A name the script gives one of its own functions that MATLAB already
# uses. Not an error here - it is a file the user is going to keep.
# Scratch code is nobody's deliverable, so it is not told.
if shadowed and not scratch:
lines += [
"## Shadows a MATLAB function",
f"This code defines {', '.join('`' + n + '`' for n in shadowed)}, and MATLAB has a "
"function of that name. Inside this file yours wins, silently, for every call - "
"including calls made by code that meant the built-in. Some of these do not exist "
"in this sandbox at all, so the run could not have caught it. Rename, or say in the "
"answer that the built-in is deliberately being replaced.",
"",
]
if result["stdout"]:
lines += ["## Output" if ok else "## Output before it stopped", "```",
_clip(result["stdout"], STDOUT_BUDGET), "```", ""]
elif ok:
lines += ["The script ran and printed nothing. If you expected a result, add a `disp` "
"or `fprintf` - an assignment ending in a semicolon produces no output.", ""]
# Warnings used to be dropped entirely unless the run also produced a
# structured error, so a successful run that warned about singular
# matrices or implicit broadcasting said nothing about it.
if other_stderr and (message or ok or timed_out or oom_killed):
lines += ["## Warnings", "```", _clip(other_stderr, 1500), "```", ""]
if want_figure and ok and not result.get("figure"):
lines += [f"## Figure `{want_figure}` was not produced",
"The run succeeded but that file does not exist, so there was nothing to show "
"the user. Make the script save it explicitly - "
f"`print('{want_figure}', '-dpng')` - and check the name matches exactly.", ""]
if artefacts:
images = [a for a in artefacts
if os.path.splitext(a)[1].lower() in (".png", ".jpg", ".jpeg", ".gif")]
lines += [f"## Files written: {', '.join(artefacts)}", ""]
if images and not want_figure:
lines += [f"To check `{images[0]}` rendered correctly, call this tool again with "
f"return_figure=\"{images[0]}\" and the image comes back for you to look "
"at. The user does not see it either way, so describe what the code plots "
"rather than the picture.", ""]
else:
lines += ["The user does not see these files. Describe what the code plots, not what "
"the picture looks like.", ""]
if rejected:
lines += ["## Helper files ignored",
f"These were not written and were not on the path: {', '.join(rejected)}. A "
"helper file needs a name like `myFunc.m` - a bare identifier plus `.m`, no "
"directories, and not `main.m`. Any error above about them being undefined is "
"caused by this, not by your code.", ""]
if sandbox_notes:
lines += ["## Cannot work in this sandbox", *sandbox_notes, ""]
if semantic_notes and not scratch:
lines += ["## Runs in both, but may behave differently", *semantic_notes, ""]
return "\n".join(lines).rstrip()
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+516
View File
@@ -0,0 +1,516 @@
"""vm_agent - hand a task to a sub-agent with a real machine in front of it.
WHAT THE MAIN MODEL SEES
One tool, and deliberately one. Everything about driving a machine - the
screenshots, the key codes, the task bookkeeping, the transcript - lives
with the sub-agent and never enters this model's context. What arrives
here is a short report. Change 53 measured the existing 21 tool schemas
at ~6,900 tokens, paid on every turn of every conversation, so a second
tool would have to earn its place and none does.
WHY THIS ROUTER BLOCKS FOR SO LONG
A session is minutes to hours: a slow model with thinking on, driving a
real machine. Three separate clocks had to be raised for that to be
possible at all (see README), and the reason for preferring one long call
over polling is measured rather than aesthetic - Ollama runs with
OLLAMA_NUM_PARALLEL=1, so every return to the main model finds its KV
cache evicted and pays a cold prefill of its whole context. One long
block costs that once; ten polls cost it ten times.
While it blocks, the user is not staring at nothing: the sub-agent posts
status and screenshots straight into the chat message, over a channel
that never touches this model's context.
DISPATCH IS BY FILE, NOT BY HTTP
The api container lives behind netguard's default-deny filter, whose
whole allowlist is squid and valkey. Reaching the agent over HTTP would
mean editing that file. The Octave sandbox already solved this with a
shared volume, a .ready marker written last, and a per-job nonce, so the
same protocol is used here rather than a new hole.
"""
from __future__ import annotations
import asyncio
import contextlib
import json
import os
import re
import secrets
import time
import shutil
import uuid
from fastapi import APIRouter, Depends, Header, Request
from pydantic import Field
from ..budget import cap
from ..limits import Identity, identity
from ..security import require_auth
from ..settings import settings
from ..toolbase import ToolModel, tool
from ..vm_preflight import question, should_ask
router = APIRouter(prefix="/tools", tags=["vm"], dependencies=[Depends(require_auth)])
# Deliberately a SECOND router, on its own prefix and out of the schema.
#
# The live-progress feed is read by a filter running inside Open WebUI,
# not by the model. Anything under /tools with an operationId becomes a
# tool the model can call, and a tool whose whole job is "show me the
# progress of the thing you are already waiting for" is a tool it would
# call in a loop. Keeping it off /tools and out of the spec means it
# cannot: the model is never told it exists.
#
# Same bearer token as everything else - it is reachable only from
# openwebui_default, and the filter holds a tool-server key, not an
# Open WebUI admin key. That distinction is the point of this endpoint
# existing at all; see vmagent/app/events.py.
progress_router = APIRouter(prefix="/vm", tags=["vm"],
dependencies=[Depends(require_auth)],
include_in_schema=False)
@progress_router.get("/progress/{chat_id}")
async def vm_progress(chat_id: str) -> dict:
"""What the running session is doing, for the Open WebUI filter.
Returns `{}` when there is nothing - no session, a chat that never
started one, or a file not yet written. The caller polls, so "nothing
yet" and "nothing ever" are the same answer and neither is an error.
"""
if not re.fullmatch(r"[0-9a-fA-F-]{36}", chat_id or ""):
return {}
path = os.path.join(JOBS_DIR, "progress", f"{chat_id}.json")
try:
raw = await asyncio.to_thread(
lambda: open(path, encoding="utf-8", errors="replace").read())
return json.loads(raw)
except (OSError, ValueError):
return {}
JOBS_DIR = os.environ.get("OWUITOOLS_VM_JOBS_DIR", "/vmjobs")
CONFIRM_DIR = os.path.join(JOBS_DIR, "confirm")
# How long to wait for somebody to be watching before giving up on asking
# them. If the progress filter is not installed there is nobody to show
# the question to, and a tool that hangs for ten minutes in that case is
# worse than one that simply runs.
CONFIRM_PICKUP_SECONDS = 20.0
# And how long to wait for the person, once we know they were asked.
CONFIRM_ANSWER_SECONDS = 900.0
POLL_INTERVAL = 1.0
# Just under the gateway's own ceiling for this route, so the app is what
# answers rather than nginx - the same discipline the PDF extractor
# follows against the 240 s default.
WAIT_SECONDS = int(os.environ.get("OWUITOOLS_VM_WAIT_SECONDS", "7000"))
PROJECT_RE = re.compile(r"^[a-z0-9][a-z0-9-]{0,29}[a-z0-9]$")
class VmAgentRequest(ToolModel):
task: str | None = Field(
default=None,
description="What the agent should do, in plain prose: the user's request restated, "
"plus any constraints and what 'working' means. NOT an implementation - "
"do not write the code here, the agent writes it. A few hundred words "
"at most.",
)
os: str | None = Field(
default=None,
description="Which machine: 'ubuntu' for shell and Python, 'windows' for PowerShell, "
"batch and Python. Ask the user if the task does not say.",
)
project: str | None = Field(
default=None,
description="Folder and repository name for the work that is kept: lowercase letters, "
"digits and hyphens. Reuse the same name to continue earlier work.",
)
session_id: str | None = Field(
default=None,
description="Only when continuing a session that asked a question. Pass it back with "
"the user's reply in 'answer'.",
)
answer: str | None = Field(
default=None, description="The user's reply to a question the agent asked.",
)
cancel: bool = Field(
default=False, description="Stop a running session. Needs session_id.",
)
@progress_router.get("/confirm/{chat_id}", include_in_schema=False)
async def vm_confirm_pending(chat_id: str) -> dict:
"""The question waiting to be put to the person in this chat, if any.
Reading it counts as picking it up: the tool is sitting in a short
wait to find out whether anybody is listening, and this is how it
finds out. If nothing ever reads this, the job simply runs - a
confirmation nobody can see must not be able to stop work.
"""
if not re.fullmatch(r"[0-9a-fA-F-]{36}", chat_id or ""):
return {}
path = os.path.join(CONFIRM_DIR, f"{chat_id}.json")
try:
raw = await asyncio.to_thread(
lambda: open(path, encoding="utf-8", errors="replace").read())
pending = json.loads(raw)
except (OSError, ValueError):
return {}
if not pending.get("seen"):
pending["seen"] = True
with contextlib.suppress(OSError):
await asyncio.to_thread(_write_json, path, pending)
return pending
@progress_router.post("/confirm/{chat_id}", include_in_schema=False)
async def vm_confirm_answer(chat_id: str, answer: dict) -> dict:
"""What the person decided. One of: run, change, cancel."""
if not re.fullmatch(r"[0-9a-fA-F-]{36}", chat_id or ""):
return {"ok": False}
decision = str(answer.get("decision") or "").strip().lower()
if decision not in ("run", "change", "cancel"):
return {"ok": False}
payload = {"id": str(answer.get("id") or "")[:64], "decision": decision,
"text": str(answer.get("text") or "")[:2000]}
with contextlib.suppress(OSError):
await asyncio.to_thread(
_write_json, os.path.join(CONFIRM_DIR, f"{chat_id}.answer.json"), payload)
return {"ok": True}
def _write_json(path: str, payload: dict) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as handle:
json.dump(payload, handle)
os.replace(tmp, path)
async def _ask_before_starting(task: str, os_name: str, interface: str,
project: str | None, chat_id: str | None) -> tuple[str, str]:
"""Put the job to the person before a machine is started for it.
WHY THIS IS HERE AND NOT IN THE MODEL'S HANDS.
Starting a machine takes minutes and destroys itself afterwards, so a
job sent with the wrong wording costs the whole session. The person
who asked is the one who can tell at a glance whether the wording is
right - so they are shown it, verbatim, before anything starts.
It is done here, inside the tool, for the same reason: the model is
simply blocked in its call while the question is answered, and does
not have to be trusted to ask, to wait, or to report the answer
faithfully. It cannot skip this the way it could skip an instruction.
Returns (decision, text). Decisions: run, change, cancel.
"""
if not chat_id or not re.fullmatch(r"[0-9a-fA-F-]{36}", chat_id):
return ("run", "") # nobody to ask - see the pickup note
request_id = uuid.uuid4().hex
path = os.path.join(CONFIRM_DIR, f"{chat_id}.json")
answer_path = os.path.join(CONFIRM_DIR, f"{chat_id}.answer.json")
with contextlib.suppress(OSError):
await asyncio.to_thread(lambda: os.path.exists(answer_path) and os.remove(answer_path))
machine = "Windows" if os_name == "windows" else "Ubuntu"
try:
await asyncio.to_thread(_write_json, path, {
"id": request_id, "task": task, "machine": machine, "os": os_name,
"interface": interface, "project": project or "",
"asked_at": time.time(), "seen": False,
})
except OSError:
return ("run", "")
try:
deadline = time.monotonic() + CONFIRM_PICKUP_SECONDS
picked_up = False
while True:
await asyncio.sleep(0.5)
try:
raw = await asyncio.to_thread(
lambda: open(answer_path, encoding="utf-8", errors="replace").read())
reply = json.loads(raw)
if reply.get("id") == request_id:
return (str(reply.get("decision") or "run"), str(reply.get("text") or ""))
except (OSError, ValueError):
pass
if not picked_up:
with contextlib.suppress(OSError, ValueError):
current = json.loads(await asyncio.to_thread(
lambda: open(path, encoding="utf-8", errors="replace").read()))
if current.get("seen"):
picked_up = True
deadline = time.monotonic() + CONFIRM_ANSWER_SECONDS
if time.monotonic() > deadline:
# Nobody was watching, or nobody answered. Either way the
# work is what was asked for; run it.
return ("run", "")
finally:
for leftover in (path, answer_path):
with contextlib.suppress(OSError):
await asyncio.to_thread(lambda p=leftover: os.path.exists(p) and os.remove(p))
@router.post("/vm_agent", operation_id="vm_agent", response_model=str,
summary="Test scripts on a throwaway Ubuntu or Windows machine")
@tool
async def vm_agent(body: VmAgentRequest, request: Request,
who: Identity = Depends(identity),
x_openwebui_message_id: str | None = Header(default=None,
include_in_schema=False)) -> str:
"""Hand a job to a helper that has a real, disposable Ubuntu or Windows machine.
USE IT when the answer has to be true of a real machine rather than of the documentation.
The user asking you to prove it - "make sure it works", "test it properly", "check it for
real", "I don't trust the docs" - is the clearest signal, but so is any question about
behaviour in a case the documentation does not spell out. If an honest answer would have to
say "the documentation implies" or "it should", that is the answer you cannot give: run it
and report what happened.
HAND THE WORK OVER BEFORE YOU DO IT, NOT AFTER. When the user asks for something that
has to be built and run - a script, a tool, a service - call this FIRST, as the opening
move of your reply. Do not design it, do not write it, do not work out the approach: the
agent does all of that on the machine, where it can run what it writes.
The `task` is the user's request in your own words, plus anything they said that
constrains it and a sentence on what "working" means. A few hundred words. If you find
yourself putting code in it, stop - you are doing the agent's job, and you will run out
of room before the call is even finished. Measured: one attempt spent its entire output
budget writing a PowerShell GUI into the task and the tool call was never emitted at
all, so nothing ran and the user got a promise instead of a program.
DO NOT USE IT when a document already answers the question and nobody has asked for proof.
"What is X", "how is X configured", "which version ships X" are reading questions and take
seconds; a machine takes minutes. `read_man_page`, `web_search` and `fetch_page` are for
"what does X do" - this one is for "does X do that here".
The helper has a command line AND a desktop. It decides which it needs - most jobs are
shell work and never touch the screen, and one that has to see a window or click something
can. You do not choose, and nothing is lost by asking for something visual.
**Ask the user which machine when the task does not say.** Python runs on both, `.sh` only
on Ubuntu, `.ps1`/`.bat` only on Windows. Guessing costs the whole session.
Write the job as instructions, not as paths: the helper is told where its project folder is
on the machine it gets, and saying `/mnt/project` at it on Windows only confuses matters.
The machine reaches the internet but nothing on this network, and is destroyed afterwards -
only the named project folder survives, published to Gitea. Before it starts, the user is
shown the job you wrote and can change or stop it. One report comes back at the end.
WHEN THE RESULT CARRIES A DOWNLOAD LINK, GIVE THE LINK AND DO NOT REPRINT THE FILE. Each
session publishes what it built as a release - test scripts and scratch are never
published - and the result gives you that page: it lists the files and carries a zip
that saves to disk, which a link into the repository does not. Pasting the file back is
a wall of code the user must scroll past to reach the one line they wanted. Say what it
does and what the test showed, in a few sentences, and link it. Quote at most a few
lines, and only to make a specific point about them.
GIVE ONLY THE LINK THE RESULT GIVES YOU. If there is none, say the work was not published
and why. Never build a URL from the project name: that has sent a user to a different
project's release.
"""
if not os.path.isdir(JOBS_DIR):
return ("FAILED: the VM sandbox is not available on this server. Nothing was run. "
"Tell the user rather than retrying.")
if body.cancel:
return await _cancel(body.session_id)
# THE SCHEMA NO LONGER DEMANDS A NEW SESSION'S FIELDS FOR EVERY CALL.
#
# `task`, `os` and `interface` were required outright, so a cancel -
# and an answer to a question the agent had asked - was rejected with
# "task: Field required" before it reached any of this, even though
# the tool's own instructions say to call back with the session_id.
# They are optional in the schema now and required HERE, where a new
# session actually needs them, with a refusal that says which is
# missing. Found by using it.
missing = [name for name, value in (("task", body.task), ("os", body.os))
if not (value or "").strip()]
if missing:
return (f"CANNOT DO THAT: starting a session needs {', '.join(missing)}. "
"Give the job and the machine ('ubuntu' or 'windows'), then call again.")
os_name = (body.os or "").strip().lower()
if os_name not in ("ubuntu", "windows"):
return ("CANNOT DO THAT: 'os' must be 'ubuntu' or 'windows'. Ubuntu runs shell and "
"Python; Windows runs PowerShell, batch and Python. If the task could run on "
"either, ask the user which they want before calling again.")
# ONE KIND OF SESSION. The helper always has a desktop; whether it
# looks at it is its own business, and it was never a decision the
# calling model got right. It chose a command line for "build me a
# GUI" twice, with the rule in the docstring and in the system prompt
# both times, and the refusal that finally fixed it was a whole
# mechanism to protect a choice that did not need to exist.
interface = "gui"
project = (body.project or "").strip() or None
if project and not PROJECT_RE.fullmatch(project):
return (f"CANNOT DO THAT: '{project}' is not a usable project name. Use 2-31 "
"characters, lowercase letters, digits and hyphens, starting and ending with "
"a letter or digit. The name becomes a folder and a Gitea repository.")
task = (body.task or "").strip()
if len(task) < 10:
return ("CANNOT DO THAT: say what the agent should do. It cannot see this "
"conversation - the task text is everything it knows.")
# The one pre-flight refusal, and it exists because the cost of being
# wrong is a whole destroyed session rather than a retry. See
# vm_preflight.py for why it lives in tool output and not the prompt.
if not body.session_id and should_ask(task, os_name):
return question(os_name)
# THE PERSON SEES THE JOB BEFORE A MACHINE IS STARTED FOR IT.
#
# They can let it run, change the wording, or drop it. The model is
# blocked here while they decide and is told the outcome as an
# ordinary tool result - it has no part in asking and cannot skip it.
decision, note = await _ask_before_starting(
task, os_name, interface, project, getattr(who, "chat_id", None))
if decision == "cancel":
return ("STOPPED BY THE USER. No machine was started and nothing ran. "
"Reply with one short sentence acknowledging that, and nothing else - "
"do not summarise, suggest, or offer to continue unless they ask.")
if decision == "change":
return ("NOT YET - the user read the job and wants it changed before it runs.\n\n"
f"What they said: {note or '(they did not say what to change)'}\n\n"
"Rewrite the task with that taken into account and call vm_agent again. "
"Do not ask them to repeat themselves, and do not run the old wording.")
return await _submit(task, os_name, interface, project, body, who,
x_openwebui_message_id, request)
async def _submit(task: str, os_name: str, interface: str, project: str | None,
body: VmAgentRequest, who: Identity,
message_id: str | None, request: Request) -> str:
job_id = uuid.uuid4().hex
nonce = secrets.token_hex(16)
job_dir = os.path.join(JOBS_DIR, "in", job_id)
run_dir = os.path.join(JOBS_DIR, "run", job_id)
out_path = os.path.join(JOBS_DIR, "out", f"{job_id}.out")
payload = {
"task": task, "os": os_name, "interface": interface, "project": project,
"chat_id": who.chat_id or "", "message_id": message_id or "",
"owner": who.email or who.user_id or "",
"answer": (body.answer or "")[:4000],
}
def write() -> None:
# THE PREVIOUS RUN'S LEFTOVERS GO FIRST, AND THIS IS NOT TIDYING.
#
# The live-progress file is keyed by CHAT, not by run, so a second
# vm_agent call in the same conversation inherits the first one's
# final state - `done: true`. Measured: the watcher for a 45-minute
# run started, polled once, read the previous run's "done", said
# "The machine has been destroyed" and exited FOUR SECONDS IN. The
# run then worked for three quarters of an hour with nobody
# watching, and the person reasonably concluded the tool had given
# up. Deleting it here is authoritative because it happens before
# the job is queued, so there is no window for the watcher to read
# a stale file; the filter also ignores anything older than itself,
# because one guard on a race is not a guard.
for stale in (
os.path.join(JOBS_DIR, "progress", f"{payload['chat_id']}.json"),
os.path.join(CONFIRM_DIR, f"{payload['chat_id']}.answer.json"),
):
if payload["chat_id"]:
with contextlib.suppress(OSError):
os.unlink(stale)
os.makedirs(job_dir, exist_ok=True)
with open(os.path.join(job_dir, "request.json"), "w", encoding="utf-8") as handle:
json.dump(payload, handle)
with open(os.path.join(job_dir, "meta"), "w", encoding="utf-8") as handle:
handle.write(f"NONCE={nonce}\n")
# Written last: its existence is what tells the runner the
# directory is complete, so the two never race.
open(os.path.join(JOBS_DIR, "in", f"{job_id}.ready"), "w").close()
await asyncio.to_thread(write)
waited = 0.0
while waited < WAIT_SECONDS:
if await asyncio.to_thread(os.path.exists, out_path):
raw = await asyncio.to_thread(
lambda: open(out_path, encoding="utf-8", errors="replace").read())
await asyncio.to_thread(lambda: os.remove(out_path))
return _render(raw, nonce, job_id)
# The user pressed stop, or closed the tab. Without this the
# machine keeps running and the next caller waits behind it - and
# "cancel recorded, nothing killed" is precisely the bug the
# previous sub-agent stack shipped.
if await request.is_disconnected():
await asyncio.to_thread(_touch_cancel, run_dir)
return ("The VM session was cancelled because this request was abandoned. "
"The machine has been stopped.")
await asyncio.sleep(POLL_INTERVAL)
waited += POLL_INTERVAL
await asyncio.to_thread(_touch_cancel, run_dir)
return (f"FAILED: the VM session was still running after {WAIT_SECONDS // 60} minutes and "
"has been stopped. Anything it committed to the project is kept. Tell the user "
"what was attempted rather than starting again automatically.")
def _touch_cancel(run_dir: str) -> None:
try:
os.makedirs(run_dir, exist_ok=True)
open(os.path.join(run_dir, "CANCEL"), "w").close()
except OSError:
pass
async def _cancel(session_id: str | None) -> str:
if not session_id or not re.fullmatch(r"[0-9a-f]{32}", session_id):
return ("CANNOT DO THAT: cancelling needs the session_id from the report that asked "
"you to call back. If there is no session running, there is nothing to stop.")
await asyncio.to_thread(_touch_cancel, os.path.join(JOBS_DIR, "run", session_id))
return "The VM session has been told to stop. Its machine is being destroyed."
def _render(raw: str, nonce: str, job_id: str) -> str:
try:
result = json.loads(raw)
except ValueError:
return "FAILED: the VM agent returned something unreadable. Nothing is known about the run."
# Same check the Octave runner's nonce exists for: a result that is
# not stamped with the nonce this request generated did not come from
# this request.
if result.get("nonce") != nonce:
return ("FAILED: the VM agent's result did not match this request and has been "
"discarded. Nothing is known about the run.")
report = str(result.get("report") or "").strip()
state = str(result.get("state") or "done")
# A tool result that IS a data: URI is handed to the model as a vision
# input by Open WebUI, and budget.cap() passes it through untouched.
# A guest that printed one as its only output would be choosing a
# picture for this model to look at, so it can never be the whole
# answer here. vmd scrubs these too; this is the boundary check.
if report.startswith("data:"):
report = "[the agent's report was discarded: it was a bare data: URI]"
if state == "question":
report += f"\n\n_Session `{job_id}` is waiting. Call `vm_agent` again with " \
f"`session_id` and the user's `answer`._"
elif state == "unfreeze":
report += f"\n\n_Session `{job_id}` is waiting on the operator._"
steps, seconds = result.get("steps", 0), result.get("seconds", 0)
report += f"\n\n_{steps} agent steps, {seconds:.0f}s._"
return cap(report)
File diff suppressed because it is too large Load Diff
+155
View File
@@ -0,0 +1,155 @@
"""Request authentication and URL sanity checks.
Read the docstring in configs/services/egress.squid.conf before changing
anything here.
The checks in this module are a *usability* layer, not the security
boundary. They exist to turn "the model asked for something impossible"
into a fast, readable error the model can correct from, instead of a
30-second timeout. The actual boundary is that this process has no route
to anything except squid, and squid denies private destinations after
resolving them itself.
That distinction matters: because every request is proxied, this process
never opens the socket, so it cannot pin a connection to a
previously-validated IP. Anything that tried to enforce security here
would be enforcing it in the wrong place.
"""
from __future__ import annotations
import ipaddress
import re
from urllib.parse import urlparse, urlsplit, urlunsplit
from fastapi import Header, HTTPException, status
from .settings import settings
ALLOWED_SCHEMES = {"http", "https"}
ALLOWED_PORTS = {80, 443, None}
# Names that never point anywhere legitimate from a container with no
# route to the local network. Blocked here purely so the failure is
# instant and self-explanatory.
_BLOCKED_SUFFIXES = (
".local", ".localdomain", ".internal", ".intranet", ".lan",
".home", ".corp", ".private", ".onion",
)
_BLOCKED_NAMES = {
"localhost", "metadata", "metadata.google.internal",
"instance-data", "host.docker.internal",
}
class UnsafeURL(ValueError):
"""Raised with a message written to be read by the model."""
def require_auth(authorization: str | None = Header(default=None, include_in_schema=False)) -> None:
"""Bearer check for every tool endpoint.
Open WebUI sends `Authorization: Bearer <key>` from the connection's
stored key. /health is deliberately left open so Docker can probe it.
"""
expected = f"Bearer {settings.auth_token}"
if not authorization or authorization != expected:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid or missing bearer token for the owuitools server.",
)
def validate_url(raw: str, *, allow_http: bool = True) -> str:
"""Normalise a model-supplied URL, or explain why it cannot be used."""
if not raw or not raw.strip():
raise UnsafeURL("No URL was provided.")
url = raw.strip()
# Models routinely hand over bare hostnames.
if "://" not in url:
url = f"https://{url}"
try:
parts = urlsplit(url)
except ValueError as exc:
raise UnsafeURL(f"That is not a parseable URL: {exc}") from None
scheme = parts.scheme.lower()
if scheme not in ALLOWED_SCHEMES:
raise UnsafeURL(
f"Only http and https URLs can be fetched, not '{scheme}:'. "
"There is no way to read local files, FTP, or gopher with this tool."
)
if scheme == "http" and not allow_http:
raise UnsafeURL("This tool requires an https URL.")
host = (parts.hostname or "").lower().rstrip(".")
if not host:
raise UnsafeURL(f"No hostname found in '{raw}'.")
try:
port = parts.port
except ValueError:
raise UnsafeURL(f"Invalid port in '{raw}'.") from None
if port not in ALLOWED_PORTS:
raise UnsafeURL(
f"Port {port} cannot be reached. Only standard web ports 80 and 443 are available."
)
_reject_private_literal(host)
_reject_internal_name(host)
# Strip fragments; they are never sent and only confuse cache keys.
return urlunsplit((scheme, parts.netloc, parts.path or "/", parts.query, ""))
def _reject_private_literal(host: str) -> None:
literal = host[1:-1] if host.startswith("[") and host.endswith("]") else host
try:
ip = ipaddress.ip_address(literal)
except ValueError:
return # not an IP literal; squid will resolve and judge the name
if not ip.is_global or ip.is_multicast:
raise UnsafeURL(
f"{ip} is a private, loopback, link-local or otherwise non-public address. "
"This tool can only reach the public internet. If the user asked about a "
"device on their own network, say that you have no access to it rather than "
"guessing what it might return."
)
def _reject_internal_name(host: str) -> None:
if host in _BLOCKED_NAMES or host.endswith(_BLOCKED_SUFFIXES):
raise UnsafeURL(
f"'{host}' is a private/internal hostname that this tool cannot reach. "
"Only public internet addresses are available."
)
# A single-label name ("router", "nas") can only resolve inside a local
# network, so it is never a valid target from here.
if "." not in host:
raise UnsafeURL(
f"'{host}' is not a public domain name. Provide a full domain such as example.com."
)
_HOSTNAME_RE = re.compile(r"^(?=.{1,253}$)(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))+$")
def validate_hostname(raw: str) -> str:
"""Validate a bare hostname for the probe tools (DNS, TLS, RDAP)."""
if not raw or not raw.strip():
raise UnsafeURL("No hostname was provided.")
host = raw.strip().lower().rstrip(".")
# Tolerate a pasted URL where a hostname was asked for.
if "://" in host:
host = (urlparse(host).hostname or "").lower().rstrip(".")
_reject_private_literal(host)
_reject_internal_name(host)
if not _HOSTNAME_RE.match(host):
raise UnsafeURL(f"'{raw}' is not a valid public hostname.")
return host
+57
View File
@@ -0,0 +1,57 @@
"""Runtime configuration, all from the environment."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
def _int(name: str, default: int) -> int:
try:
return int(os.getenv(name, "") or default)
except ValueError:
return default
def _opt(name: str) -> str | None:
# Compose passes unset optionals through as empty strings rather than
# omitting them, so "" has to mean "absent" here.
return (os.getenv(name) or "").strip() or None
@dataclass(frozen=True)
class Settings:
auth_token: str = field(default_factory=lambda: os.environ["OWUITOOLS_AUTH_TOKEN"])
egress_proxy: str = field(default_factory=lambda: os.getenv("OWUITOOLS_EGRESS_PROXY", "http://owuitools-egress:3128"))
searxng_url: str = field(default_factory=lambda: os.getenv("OWUITOOLS_SEARXNG_URL", "http://owuitools-searxng:8080").rstrip("/"))
valkey_url: str = field(default_factory=lambda: os.getenv("OWUITOOLS_VALKEY_URL", "redis://owuitools-valkey:6379/0"))
contact: str = field(default_factory=lambda: os.getenv("OWUITOOLS_CONTACT", "owuitools@localhost"))
# Optional. Goes in the User-Agent so a site that wants to know who is
# fetching can look you up. Leave it unset if this is not reachable
# from outside, which is the normal case.
public_url: str | None = field(default_factory=lambda: _opt("OWUITOOLS_PUBLIC_URL"))
max_response_chars: int = field(default_factory=lambda: _int("OWUITOOLS_MAX_RESPONSE_CHARS", 12000))
probe_rate_per_hour: int = field(default_factory=lambda: _int("OWUITOOLS_PROBE_RATE_PER_HOUR", 30))
log_level: str = field(default_factory=lambda: os.getenv("OWUITOOLS_LOG_LEVEL", "INFO").upper())
github_token: str | None = field(default_factory=lambda: _opt("OWUITOOLS_GITHUB_TOKEN"))
stackexchange_key: str | None = field(default_factory=lambda: _opt("OWUITOOLS_STACKEXCHANGE_KEY"))
nvd_key: str | None = field(default_factory=lambda: _opt("OWUITOOLS_NVD_KEY"))
@property
def user_agent(self) -> str:
# Wikimedia hard-blocks requests with no User-Agent (verified: 403)
# and its policy explicitly disallows generic ones like "curl/8.5.0",
# which pass today and may not tomorrow. OpenAlex and Crossref read
# the same string to decide whether you get the polite pool.
#
# The URL is included only if there is one to give. It used to be
# one deployment's address, hardcoded - which every other
# deployment would then have been announcing as its own to every
# site it fetched from.
site = f"+{self.public_url}; " if self.public_url else ""
return f"owuitools/1.0 ({site}{self.contact})"
settings = Settings()
+291
View File
@@ -0,0 +1,291 @@
"""Jump to a region inside a long document instead of reading from the top.
Split out of `routers/reference.py`, where `read_man_page` and `lookup_rfc`
already had this, so `fetch_page` and `wikipedia_lookup` can use it too. Those
two had no way in at all: the only way to reach page 200 of a reference manual
or the results section of a Wikipedia article was to page through with
`offset`, six thousand characters at a time, and the model reliably gave up
and answered from what it had read - or from memory.
The one behavioural addition over the original is table-of-contents
avoidance. In an RFC or a vendor manual, a section number or a heading matches
the contents listing first, which is the single place the term is guaranteed
to appear and guaranteed to be useless. Two of four `lookup_rfc` calls in a
graded run were spent that way, each returning five thousand characters of dot
leaders. A contents line is skipped only when a body match exists further on:
if every match looks like a contents entry, the first one is still returned,
because a wrong region beats reporting a term absent when it is plainly there.
Two later additions, both from the second graded round, where `search=` twice
decided the fate of a source the model had correctly chosen:
* **A hit now names the section it landed in.** Asked for "Constitution" inside
the Canberra article, the jump matched "1 Constitution Avenue, Civic" - a
street - and returned a window about self-government, bushfires and climate.
The model read it as having checked the constitutional basis for the capital,
stopped hedging, and published a section number it had not verified and which
was wrong. The tool was honest about *where* it jumped; it said nothing about
*what* it jumped into, and a character offset does not tell you that you are
reading the wrong subject. The enclosing heading does.
* **A miss now shows the opening of the document.** Asked for "list of senses"
in the Sense article, the tool returned only "that term does not occur" - and
the model abandoned 54,000 characters of exactly the right article, answered
from memory, and got one entry wrong that the article would have corrected.
One search string missing is not the same finding as the document being
useless, and returning nothing made the two indistinguishable.
"""
from __future__ import annotations
import re
# "2.3.4 Size limits . . . . . . . . 10" / "B.4 Cipher Suites ....... 155"
_TOC_LEADERS = re.compile(r"(?:\.\s*){3,}\d+\s*$")
# The leaderless form, where the page number is just pushed to the right
# margin. Guarded by line length below - body prose that ends in a number is
# usually longer than a contents entry.
_TOC_COLUMNS = re.compile(r"\S[ \t]{3,}\d{1,4}[ \t]*$")
# Headings, in the four shapes this suite's documents actually use: Wikipedia
# plaintext extracts ("== History =="), markdown ("## Options"), numbered
# specification sections ("7.3.2 Clock configuration register") and the
# flush-left capitals of an RFC or man page ("SEE ALSO").
_HEADINGS = (
re.compile(r"^\s*(={2,})\s*(?P<h>.+?)\s*\1\s*$"),
re.compile(r"^\s{0,3}#{1,6}\s+(?P<h>\S.{0,78}?)\s*$"),
re.compile(r"^(?P<h>\d+(?:\.\d+){0,4}\.?[ \t]+[A-Za-z]\S.{0,70}?)\s*$"),
re.compile(r"^(?P<h>[A-Z][A-Z0-9 ,'/&()+-]{2,58}[A-Z0-9)])[ \t]*$"),
# The contents listing of an RFC or a manual, so a jump that lands in
# it is told so rather than told nothing.
re.compile(r"^\s*(?P<h>(?:Table of )?Contents)\s*:?\s*$", re.IGNORECASE),
)
_FLUSH_CAPS = _HEADINGS[3]
# A numbered contents entry: "15.5.19. 418 (Unused)", "7.3.2 Clock configuration".
_NUMBERED_ENTRY = re.compile(r"^\s*(?:\d+(?:\.\d+)*\.?|[A-Z](?:\.\d+)+\.?)[ \t]+\S")
def _recurs_later(text: str, line: str, after: int) -> bool:
"""Whether the same line (whitespace-insensitive) appears again after `after`."""
words = line.split()
if len(words) < 2:
return False
pattern = r"^[ \t]*" + r"\s+".join(re.escape(w) for w in words) + r"[ \t]*$"
return re.search(pattern, text[after:], re.MULTILINE) is not None
def _is_running_line(text: str, line: str) -> bool:
"""A line that repeats every page - "RM0008", a document title - is a header, not a heading."""
bare = line.strip()
if len(bare) < 2:
return True
return text.count("\n" + line.rstrip() + "\n") >= 4
def enclosing_heading(text: str, pos: int) -> str | None:
"""The nearest heading above `pos`, or None.
Bounded to the preceding 20,000 characters: past that the answer stops
being useful anyway, and an unbounded backward scan on a 500 KB RFC is
paid for on every jump.
"""
head = text[max(0, pos - 20_000):pos]
deferred = None
for line in reversed(head.splitlines()):
if not line.strip() or _is_toc_line(line):
continue
for pattern in _HEADINGS:
if match := pattern.match(line):
found = re.sub(r"\s+", " ", match.group("h")).strip(" =#.")
if not 2 <= len(found) <= 80:
continue
# Two things the flush-caps shape matched on a reference
# manual's PDF text and reported as sections: the running
# page header ("RM0008", on every page) and a register
# bit-field label ("XTPRE"). Neither is a heading. A line
# that repeats throughout the document is a header; a
# single capitalised word is kept only as a last resort,
# when no structured heading sits above it in the window
# - which in a man page (NAME, OPTIONS) is the normal case
# and in a numbered manual never is.
if pattern is _FLUSH_CAPS:
if _is_running_line(text, line):
break
if " " not in found:
if not found.isalpha():
break
deferred = deferred or found
break
elif _is_running_line(text, line):
break
return found
return deferred
_XREF_CUE = re.compile(
r"(?:refer(?:s|red)?\s+to|see(?:\s+also)?|described\s+in|defined\s+in|details?\s+in|"
r"listed\s+in|shown\s+in|given\s+in|specified\s+in)\s+(?:section|chapter|table|figure|clause)?"
r"\s*[\dA-Z]*(?:\.\d+)*\.?\s*[:\-]?\s*$",
re.IGNORECASE,
)
def _is_cross_reference(text: str, pos: int) -> bool:
"""Whether the match at `pos` is preceded on its line by a "refer to Section" cue."""
start = text.rfind("\n", 0, pos) + 1
return _XREF_CUE.search(text[start:pos]) is not None
def _loose_words(needle: str) -> str:
"""The needle as a pattern that tolerates whitespace inside its words."""
words = []
for word in needle.split():
words.append(r"\s*".join(re.escape(ch) for ch in word))
return r"\s+".join(words)
def _line_around(text: str, pos: int) -> str:
start = text.rfind("\n", 0, pos) + 1
end = text.find("\n", pos)
return text[start : end if end != -1 else len(text)]
def _is_toc_line(line: str) -> bool:
if _TOC_LEADERS.search(line):
return True
return bool(_TOC_COLUMNS.search(line)) and len(line.strip()) < 90
def find_in_document(
text: str,
needle: str,
*,
window: int = 3800,
start_at: int = 0,
continue_hint: str = "call again with offset= to read more",
) -> str | None:
"""Return the region of `text` around `needle`, or None if it is not there.
Four patterns are tried in order, because the same term means different
things depending on where it sits: an indented line is an option entry or
a numbered section, a flush-left line is a heading, and a bare substring is
a mention in prose. The last pattern allows any whitespace where the needle
has a space, because a multi-word phrase wraps across lines in a rendered
man page or RFC - searching ip-route for "sending a packet along this path"
reported it absent while the page reads "...along this\\n path."
"""
needle = (needle or "").strip()
if not needle:
return None
escaped = re.escape(needle)
patterns = (
# A heading-shaped line: optional section number, then the needle
# with whitespace tolerated inside words. PDF text layers split
# words at column boundaries - "Clock configurati on register" -
# and an exact search for the heading then lands on every
# cross-reference to it ("refer to Section 4.3.2: Clock
# configuration register") and never on the heading itself.
(rf"^[ \t]*(?:\d+(?:\.\d+)*\.?[ \t]+)?{_loose_words(needle)}[ \t]*:?[ \t]*$",
re.MULTILINE | re.IGNORECASE),
(rf"^[ \t]+{escaped}\b", re.MULTILINE), # " -Z user"
(rf"^[ \t]*{escaped}\b", re.MULTILINE), # "EXAMPLES"
(escaped, re.IGNORECASE), # anywhere in prose
(r"\s+".join(re.escape(w) for w in needle.split()), re.IGNORECASE),
)
first_any = None
chosen = None
skipped = 0
def contents_entry(m) -> bool:
line = _line_around(text, m.start())
if _is_toc_line(line):
return True
# The leaderless, page-numberless form used by every RFC published
# in the v3 text format since 2019: " 15.5.19. 418 (Unused)".
# It carries no marker at all; what identifies it is that the same
# line appears again later, as the heading it points to. Checked
# only in the opening quarter of the document, where a contents
# listing lives, so the scan stays cheap on a 500 KB specification.
return (m.start() < len(text) // 4
and _NUMBERED_ENTRY.match(line) is not None
and _recurs_later(text, line, m.end()))
for pattern, flags in patterns:
matches = [m for m in re.finditer(pattern, text, flags) if m.start() >= start_at]
if not matches:
continue
if first_any is None:
first_any = matches[0]
body = [m for m in matches[:40] if not contents_entry(m)] + matches[40:]
if body:
chosen = body[0]
skipped = sum(1 for m in matches if m.start() < chosen.start())
break
if chosen is None:
chosen = first_any
if chosen is None:
return None
start = max(0, chosen.start() - 200)
end = min(len(text), chosen.start() + window)
excerpt = text[start:end]
note = ""
if skipped:
note = (f"; skipped {skipped} table-of-contents "
f"{'entry' if skipped == 1 else 'entries'}")
if _is_cross_reference(text, chosen.start()):
note += ("; this line is a cross-reference to the section, not the section itself - "
"the heading may be split across words in this document's text layer; try "
"the section number, or a distinctive term from its first paragraph")
# Naming the section is the difference between "the string is here" and
# "the subject is here". A match on a word used in another sense lands
# under a heading that says so.
if section := enclosing_heading(text, chosen.start()):
note += f"; this is inside the section '{section}'"
header = (f"[Jumped to '{needle}' at character {chosen.start():,} "
f"of {len(text):,}{note}]")
prefix = "…\n" if start > 0 else ""
suffix = ""
if end < len(text):
suffix = (f"\n…\n\n[Showing the region around '{needle}'. The full document is "
f"{len(text):,} characters - {continue_hint}.]")
return f"{header}\n\n{prefix}{excerpt}{suffix}"
def search_window(
text: str,
needle: str,
*,
window: int = 3800,
start_at: int = 0,
continue_hint: str = "call again with offset= to read more",
miss_note: str,
) -> str:
"""`find_in_document`, but a miss returns the opening of the document too.
A caller that gets back only "that term is not here" has spent a call and
holds nothing, and the observed response to holding nothing is to stop
using the source rather than to try another term. The document is already
fetched and already in memory; showing its first window costs one round of
tokens and keeps the call from being a dead end.
"""
found = find_in_document(text, needle, window=window, start_at=start_at,
continue_hint=continue_hint)
if found is not None:
return found
size = max(500, min(window, len(text)))
lead = text[:size]
tail = ""
if size < len(text):
tail = (f"\n…\n\n[This is the opening only - {len(text) - size:,} characters follow. "
f"To read on, {continue_hint}.]")
return (f"{miss_note}\n\n[Showing the opening of the document instead, so the call is not "
f"wasted. The document itself is fine; only the search term missed.]\n\n{lead}{tail}")
+233
View File
@@ -0,0 +1,233 @@
"""Shared plumbing for tool endpoints.
One decision worth stating: recoverable failures come back as HTTP 200
with an explanatory body, not as a 4xx.
Open WebUI turns any status >= 400 into the string
`{"error": "HTTP error 502: ..."}` and marks the call failed in the UI. For
a genuine fault that is right. But "that page is behind a login" and "the
search returned nothing" are not faults, they are *findings* - and they are
exactly the findings that should stop the model inventing an answer. Handed
back as prose in a successful call, they read as information the model can
act on. Handed back as an error, they read as something to retry.
401 and 429 stay as real status codes; those are about the caller, not the
question.
"""
from __future__ import annotations
import functools
import logging
import httpx
from fastapi import HTTPException
from .budget import SOURCES_PREFIX, SOURCES_SEPARATOR, cap
from .httpc import FetchError, explain
from .security import UnsafeURL
log = logging.getLogger(__name__)
def tool(func):
"""Wrap a tool endpoint: normalise failures, enforce the size cap."""
@functools.wraps(func)
async def wrapper(*args, **kwargs):
try:
result = await func(*args, **kwargs)
except UnsafeURL as exc:
return f"CANNOT DO THAT: {exc}"
except FetchError as exc:
return f"FAILED: {exc}"
except httpx.HTTPError as exc:
return f"FAILED: {explain(exc, 'the requested source')}"
except HTTPException as exc:
# The rate limiter raises these carrying a message written for the
# model to act on. Letting them reach the generic handler below
# replaced "you have used all 30 network probes this hour" with
# "internal error" - which reads like our bug, tells the user
# nothing, and invites exactly the retry the message forbids.
return f"FAILED: {exc.detail}"
except Exception:
# Never leak a traceback into the model's context - it is a lot
# of tokens describing our bug, which the model cannot fix and
# may try to explain to the user.
log.exception("unhandled error in %s", func.__name__)
return (
"FAILED: This tool hit an internal error. Do not retry it with the same "
"input. Use a different tool, or tell the user you could not look this up."
)
return cap(result) if isinstance(result, str) else result
return wrapper
NO_RESULTS = (
"NO RESULTS. Nothing matched. Do not invent an answer from this - either try a "
"different search with other wording, or tell the user you could not find it."
)
def cite_line(first_author: str | None,
title: str,
venue: str | None = None,
year: str | int | None = None,
doi: str | None = None,
*,
et_al: bool = False) -> str:
"""One citation line the model can lift verbatim.
Lifted out of `search_papers`, where it was introduced after the model
wrote "the TIME2 trial (NEJM 2022, n=148)" for a study actually titled
"Calorie Restriction with or without Time-Restricted Eating in Weight
Loss" with 139 participants - correct finding, invented name and count.
Identifiers it can copy are the cheapest fix for that.
It is shared with `fetch_page` because the same failure recurred one layer
down: the model fetched a real paper, transcribed every number from it
correctly, and then attributed it to the wrong authors in the wrong
journal, because the fetched text began at the abstract and carried no
bibliographic data at all. Having the numbers but not the citation is
exactly the state in which a plausible one gets invented.
"""
surname = ""
if first_author and first_author.strip():
name = first_author.strip()
# Two conventions arrive here. Citation meta tags and most registries
# write "Surname, Given"; page bylines and OpenAlex write "Given
# Surname". Taking the last token of the first form yields the given
# name, which is how arXiv's "Vaswani, Ashish" came out as
# 'Ashish et al.' - a citation that looks authoritative and is wrong
# in the one field anyone would use to look the paper up.
if _is_organisation(name):
# "Cleveland Clinic" is not a Dr Clinic. An organisational
# author is cited whole.
surname = name
else:
surname = name.split(",")[0].strip() if "," in name else name.split()[-1]
head = f'{surname}{" et al." if et_al else ""}, "{title}"' if surname else f'"{title}"'
if venue:
head += f", {venue}"
if year:
head += f" ({year})"
if doi:
d = doi.strip()
# OpenAlex hands over a full https://doi.org/... URL; a citation_doi
# meta tag hands over the bare 10.x form. Both should read as a DOI.
if d.startswith("10."):
d = f"doi:{d}"
head += f". {d}"
return head
_ORG_WORDS = frozenset("""
clinic hospital institute institutes university college school foundation association society
agency administration organization organisation department ministry council centre center
committee commission authority bureau office group inc ltd llc plc corporation company co
laboratory laboratories labs lab staff team editors editorial board service services network
press news journal magazine times post health medicine medical trust federation union
consortium collaboration project program programme initiative alliance partnership
""".split())
# A site's own name as author: "ElectricalLicenseRenewal com" is a domain with
# the dot lost, not a Dr com.
_TLD_WORDS = frozenset("com org net edu gov io co uk us de fr eu info biz".split())
# What a byline carries after the person: "Kelly Johnson-Arbor; MD Medical
# Toxicologist" split on the semicolon into two authors, and the second one
# made the citation read "Johnson-Arbor et al." for a single-author page.
_CREDENTIALS = frozenset(
"md phd do rn np pa mph msc ms ma mba dds dmd pharmd dvm frcp frcs faan facp facs "
"bsc bs ba llb jd edd dphil mrcp cpa pe rd ld ccc-slp".split()
)
def _is_organisation(name: str) -> bool:
"""A multi-word author with no comma whose last word is an institutional noun."""
if "," in name:
return False
words = [w.strip(".").lower() for w in name.split()]
if len(words) < 2:
return False
return (words[-1] in _ORG_WORDS or words[-1] in _TLD_WORDS or words[0] in {"the"}
or any(w in {"of", "for", "&"} for w in words[1:-1]))
def clean_authors(names) -> list[str]:
"""Drop the entries in an author list that are not people or organisations.
Bylines and metadata split into a list on ";" and "," carry credentials
("MD Medical Toxicologist"), degrees ("PhD") and role words as separate
items. Each becomes a phantom co-author, and one phantom co-author is
enough to turn a single-author citation into "et al.".
"""
kept: list[str] = []
for raw in names or []:
name = " ".join(str(raw or "").split()).strip(" ;,")
if not name:
continue
tokens = [t.strip(".,").lower() for t in name.split()]
if tokens and tokens[0] in _CREDENTIALS:
continue
if all(t in _CREDENTIALS for t in tokens):
continue
# A trailing degree on a real name is fine to strip: "J. Smith, MD"
# arrives as two items and the "MD" is dropped above; "J. Smith MD"
# arrives as one and loses the suffix here.
while len(tokens) > 1 and tokens[-1] in _CREDENTIALS:
tokens.pop()
name = " ".join(name.split()[:-1]).rstrip(",")
kept.append(name)
return kept
def sources_line(*entries: str) -> str:
"""The trailing line a tool-assisted answer should end with, ready to copy.
Two graded rounds established that the model will not construct a citation
from material it has to assemble - a `# Source:` header field at the top of
a fetched page, a `Homepage:` row among five others - but will lift one that
is already written in the shape the answer wants. Of forty answers, exactly
one carried a URL, and it was the one whose tool had printed `CITE AS:`
lines. This is that observation applied to every tool that returns a single
identifiable document.
Deliberately not accompanied by an instruction to use it. The same rounds
showed tool output that *reports* being acted on and tool output that
*instructs* being weighed and often declined, so the line is offered as a
fact about the document rather than as a request.
`cap()` protects it from truncation, which is why it must stay one line.
"""
kept = [" ".join(e.split()) for e in entries if e and e.strip()]
if not kept:
return ""
return SOURCES_SEPARATOR + SOURCES_PREFIX + " · ".join(dict.fromkeys(kept))
from pydantic import BaseModel, ConfigDict, field_validator # noqa: E402
class ToolModel(BaseModel):
"""Base for every tool's request body.
Models hand over `""` for optional parameters they have decided not to
use, roughly as often as they omit them. Open WebUI strips empty
*query* parameters for exactly this reason but does no such thing for
body fields, so the coercion has to happen here or half the optional
parameters in this suite would arrive as empty strings and fail
validation.
"""
model_config = ConfigDict(extra="ignore", str_strip_whitespace=True)
@field_validator("*", mode="before")
@classmethod
def _blank_to_none(cls, value):
if isinstance(value, str) and not value.strip():
return None
return value
+79
View File
@@ -0,0 +1,79 @@
"""Which tools exist, read from configs/tools.toml.
WHY THIS IS A REMOVAL AND NOT A FLAG
Open WebUI has a Function Name Filter List on the tool connection, and it
was the only way to turn a tool off before this file. It hides a tool
from one model. The schema is still fetched, the endpoint still answers,
and the setting lives in Open WebUI's database rather than anywhere you
can read in the repo - so "which tools does this deployment offer" had no
answer on disk at all.
This drops the route instead. An operation that is not registered is not
in the spec, so the model is never told it exists and no context is spent
describing it; and the path 404s, so a stale cached spec somewhere else
cannot call it either.
THE SEAM IS main.py, NOT toolbase.tool
`tool()` wraps the handler, which runs long after FastAPI has registered
the route and built its description. A check there could refuse a call
but could never stop the model being told about the tool - which is the
expensive half.
FAIL OPEN, DELIBERATELY
A missing or unparseable file leaves every tool on. This is config that
removes capability, so the failure mode of losing it should be the
system it replaces, not a server with no tools. Both cases log a line
saying what happened; silence here would look identical to "everything is
switched on", which is exactly the confusion to avoid.
"""
from __future__ import annotations
import logging
import os
import tomllib
log = logging.getLogger(__name__)
CONFIG_PATH = os.environ.get("OWUITOOLS_TOOLS_CONFIG", "/configs/tools.toml")
def load(path: str | None = None) -> dict[str, bool]:
"""The file's contents as {tool_name: enabled}, or {} if unreadable."""
path = path or CONFIG_PATH
try:
with open(path, "rb") as handle:
table = tomllib.load(handle).get("tools", {})
except FileNotFoundError:
log.info("no tool config at %s - every tool stays enabled", path)
return {}
except (OSError, tomllib.TOMLDecodeError) as exc:
log.warning("tool config at %s could not be read (%s) - every tool stays enabled",
path, exc)
return {}
return {str(name): bool(value) for name, value in table.items()}
def disabled(known: set[str], path: str | None = None) -> set[str]:
"""Which of `known` this deployment has switched off.
`known` is what the server actually implements. Anything named in the
file that is not in it is reported and ignored - a typo would
otherwise be indistinguishable from a tool that is switched off, and
the whole point of the file is to be able to read what is on.
A known tool the file does not mention stays ON, so a config written
against an older version never silently removes a new tool.
"""
config = load(path)
unknown = sorted(set(config) - known)
if unknown:
log.warning("tool config names %d tool(s) this server does not have: %s",
len(unknown), ", ".join(unknown))
off = {name for name, on in config.items() if not on and name in known}
if off:
log.info("tools switched off in config: %s", ", ".join(sorted(off)))
return off
+118
View File
@@ -0,0 +1,118 @@
"""Engineering value parsing and formatting.
Nobody writes 0.0000047 for a capacitor, they write 4u7 or 4.7uF, and a
model asked for a float will happily send the string "10k". Rejecting that
wastes a whole tool-call round trip to re-ask for the same number in a
worse format, so parse what engineers actually write.
"""
from __future__ import annotations
import math
import re
SI_PREFIXES = {
"q": 1e-30, "r": 1e-27, "y": 1e-24, "z": 1e-21, "a": 1e-18, "f": 1e-15,
"p": 1e-12, "n": 1e-9, "u": 1e-6, "µ": 1e-6, "μ": 1e-6, "m": 1e-3,
"": 1.0,
"k": 1e3, "K": 1e3, "M": 1e6, "G": 1e9, "T": 1e12, "P": 1e15, "E": 1e18,
}
# Unit suffixes stripped before prefix parsing. Order matters: longest first,
# so "farad" is consumed before "f" is mistaken for femto.
_UNIT_WORDS = (
"ohms", "ohm", "Ω", "farad", "farads", "henry", "henries", "hertz",
"volts", "volt", "amps", "amp", "amperes", "ampere", "watts", "watt",
"seconds", "second", "meters", "metres", "meter", "metre",
"F", "H", "Hz", "V", "A", "W", "s",
)
# Note the absence of a bare "m". In an electronics context milli beats
# metres by a wide margin - "2.2m" is 2.2 milli, not 2.2 metres - and
# treating it as a unit made every millivolt and milliamp parse 1000x high.
# The spelled-out forms above still work for length.
_VALUE_RE = re.compile(r"^([+-]?\d*\.?\d+(?:[eE][+-]?\d+)?)\s*([a-zA-Zµμ]*)$")
# "4k7" / "4u7" / "1R5" - the prefix stands in for the decimal point.
_INFIX_RE = re.compile(r"^([+-]?\d+)\s*([RrkKMmuµμnpGT])\s*(\d+)$")
class UnitError(ValueError):
"""Raised with a message intended for the model."""
def parse(value, name: str, *, unit: str = "") -> float:
"""Parse an engineering quantity into a plain float in base units."""
if value is None:
raise UnitError(f"{name} is required for this calculation but was not provided.")
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip().replace(",", "")
if not text:
raise UnitError(f"{name} is required for this calculation but was empty.")
if infix := _INFIX_RE.match(text):
whole, prefix, frac = infix.groups()
multiplier = 1.0 if prefix in ("R", "r") else SI_PREFIXES.get(prefix, 1.0)
return float(f"{whole}.{frac}") * multiplier
for word in _UNIT_WORDS:
if len(text) > len(word) and text.endswith(word):
candidate = text[: -len(word)].strip()
if _VALUE_RE.match(candidate):
text = candidate
break
match = _VALUE_RE.match(text)
if not match:
raise UnitError(
f"Could not read '{value}' as a value for {name}. Write it as a plain number "
f"(4700), with an SI prefix (4.7k), or in the shorthand engineers use (4k7)."
)
number, suffix = match.groups()
if suffix and suffix not in SI_PREFIXES:
# A trailing unit letter that survived stripping, e.g. "10kΩ" -> "k".
suffix = suffix[0]
if suffix and suffix not in SI_PREFIXES:
raise UnitError(f"'{suffix}' in '{value}' is not an SI prefix I recognise for {name}.")
return float(number) * SI_PREFIXES.get(suffix, 1.0)
def eng(value: float, unit: str = "", *, digits: int = 4) -> str:
"""Format a number in engineering notation, the way a datasheet would."""
if value == 0:
return f"0 {unit}".strip()
if math.isnan(value) or math.isinf(value):
return f"{value} {unit}".strip()
magnitude = math.floor(math.log10(abs(value)) / 3) * 3
magnitude = max(-30, min(magnitude, 18))
scaled = value / (10.0 ** magnitude)
symbol = {
-30: "q", -27: "r", -24: "y", -21: "z", -18: "a", -15: "f", -12: "p",
-9: "n", -6: "µ", -3: "m", 0: "", 3: "k", 6: "M", 9: "G", 12: "T",
15: "P", 18: "E",
}[magnitude]
formatted = f"{scaled:.{digits}g}"
return f"{formatted} {symbol}{unit}".strip()
def require(values: dict, *names: str) -> list[float]:
"""Pull required parameters, naming everything missing at once.
Reporting all of them together matters: the error text goes straight back
to the model, and one round trip beats three.
"""
missing = [n for n in names if values.get(n) is None]
if missing:
raise UnitError(
f"This calculation needs {', '.join(missing)}, which "
f"{'was' if len(missing) == 1 else 'were'} not provided. "
f"Ask the user for {'it' if len(missing) == 1 else 'them'} if you do not know."
)
return [parse(values[n], n) for n in names]
+88
View File
@@ -0,0 +1,88 @@
"""The one question vm_agent stops to ask, and how it decides to ask it.
WHY THIS IS A SEPARATE MODULE
Two reasons, and the second is the real one.
It is dependency-free, so it can be unit-tested on the host without the
container - the same arrangement as matlab_lint.py, and for the same
reason: the interesting half of this rule cannot be tested over HTTP. A
task that is let through starts a real VM session, which is minutes long
by design, and the smoke suite's per-case timeout is 90 seconds.
And it puts the rule somewhere findable. It is the only pre-flight
refusal in the suite that is about the CALLER's judgement rather than
about a malformed argument, so it will be argued with, and the argument
should happen against a file that explains itself.
WHY THE RULE EXISTS
A Python script runs on both machines. A `.sh` runs only on Ubuntu; a
`.ps1` or `.bat` only on Windows. The model has to choose, and it has no
way to know unless the user said - but the machine is destroyed at the
end of the session, so guessing wrong does not cost a retry, it costs the
whole session.
The system prompt asks the model to check first. That is not enough on
its own: this file's oldest and most-repeated finding is that the model
treats tool output as fact and system-prompt text as advice, and a prompt
rule loses to an explicit user demand. So the tool refuses, in its own
output, and hands back the exact question to relay.
WHY IT HAS TO BE NARROW
Firing when the caller has already said which machine would be worse than
not firing at all. It would teach the model that the way past this tool
is to add a magic word, rather than to ask - and a model that has learned
to appease a check has stopped reading it.
"""
from __future__ import annotations
import re
# Things that run on both machines. Word-bounded throughout: `\bgo\b`
# rather than `go`, or every mention of "gopher" and "going" trips it.
CROSS_PLATFORM = re.compile(
r"\bpython3?\b|\bpy\b|\.py\b|\bpip3?\b|\bnode\b|\bnpm\b|\.js\b"
r"|\bjava\b|\bgo\b|\bgolang\b|\brust\b|\bcargo\b|\bdotnet\b|\bruby\b|\bperl\b",
re.IGNORECASE,
)
# Deliberately generous. The point is to catch a silent guess, not to
# interrogate a caller who has already been clear - so a mention of the
# shell, the file extension or the package manager counts as having said.
_SAYS_UBUNTU = (
"ubuntu", "linux", "debian", "bash", "sh script", ".sh", "shell script",
"apt ", "apt-get", "apt install", "/usr/", "/etc/", "/home/", "systemd",
)
_SAYS_WINDOWS = (
"windows", "powershell", "pwsh", ".ps1", "batch", ".bat", ".cmd",
"cmd.exe", "winget", "c:\\", "registry", ".exe",
)
def names_its_machine(task: str, os_name: str) -> bool:
"""Has the caller already said which machine, in the task itself?"""
lowered = (task or "").lower()
words = _SAYS_WINDOWS if os_name == "windows" else _SAYS_UBUNTU
return any(word in lowered for word in words)
def should_ask(task: str, os_name: str) -> bool:
"""True when the tool should stop and have the user asked."""
return bool(CROSS_PLATFORM.search(task or "")) and not names_its_machine(task, os_name)
def question(os_name: str) -> str:
"""The refusal, written for the model to relay rather than to obey."""
other = "Windows" if os_name == "ubuntu" else "Ubuntu"
return (
f"CANNOT DO THAT YET: this task mentions something that runs on both machines, "
f"and you chose {os_name}. Ask the user which they want - {other} is just as "
"likely and there is nothing in the task text to tell you.\n\n"
"Put it to them in one round, then call again with their answer. If they do not "
f"mind, say which you picked and call again with the task naming {os_name} "
"explicitly. Guessing costs the whole session: the machine is destroyed at the "
"end of it, so there is nothing to correct afterwards."
)
+28
View File
@@ -0,0 +1,28 @@
# Pinned deliberately: this container is the assistant's whole view of the
# outside world, and a surprise minor bump in an extraction library changes
# what the model reads.
fastapi==0.141.1
uvicorn[standard]==0.52.4
pydantic==2.13.5
httpx[http2]==0.28.1
redis==8.1.0
# TLS certificate parsing for inspect_tls.
cryptography==50.0.1
# HTML -> markdown. Top scorer in the Bevendorff et al. content-extraction
# benchmark, ~15MB installed, and it hands back metadata (title, date,
# sitename) in the same call, which is what the citation lines are built from.
trafilatura==2.2.0
# PDF text. pypdf is BSD-3, pure Python, zero runtime deps, and handles the
# digitally-generated datasheets that make up most of what gets fetched.
# pdfminer.six (MIT) is the fallback for multi-column pages where pypdf
# interleaves columns - the normal shape of a parameter table.
#
# PyMuPDF is deliberately absent. It is faster, but it is AGPL-3.0, and its
# section 13 network clause is a live problem here specifically: this serves
# an assistant that five other people reach over a network.
pypdf==6.18.0
pdfminer.six==20260107