Initial Commit
This commit is contained in:
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
Reference in New Issue
Block a user