909 lines
44 KiB
Python
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()
|