Initial Commit
This commit is contained in:
@@ -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"]
|
||||
@@ -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()
|
||||
@@ -0,0 +1,3 @@
|
||||
"""OpenAPI tool suite for the Open WebUI assistant."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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."
|
||||
),
|
||||
)
|
||||
@@ -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
|
||||
@@ -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__}
|
||||
@@ -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)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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
|
||||
@@ -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()
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user