517 lines
24 KiB
Python
517 lines
24 KiB
Python
"""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)
|