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