Files
2026-09-15 15:24:03 -04:00

909 lines
44 KiB
Python

"""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()