Files
owui-tools/vm/vmd/vmd.py
T
2026-09-15 15:24:03 -04:00

1585 lines
66 KiB
Python

#!/usr/bin/env python3
"""vmd - the privileged side of the VM sandbox.
WHAT IT IS FOR
Everything that must not be reachable from the agent lives on this side
of a unix socket: /dev/kvm, the frozen base images, the ZFS root, the
Gitea token, and the ability to unfreeze a disk. The agent gets a fixed
list of verbs. It cannot unfreeze a base image because there is no verb
for it and no socket it can see that has one - that is a filesystem
permission and a missing code path, not a policy check it could argue
with.
WHAT IT IS NOT
Not a shell. Every verb builds its own command from constants; the one
caller-supplied value that reaches QEMU is a key name, checked against
QEMU's own enum first. There is deliberately no verb that takes a QMP
command, a host path, or a network setting.
WHO TALKS TO IT
Only owuitools-vmagent, over /run/owuivm/agent/agent.sock, which is mode
0660 and group owuitools-agent (gid 10001 - the container's own uid,
because this host runs no userns remapping). The maintenance path that
CAN unfreeze a disk is a different unit running as a different user,
reached a different way, and is not served here at all.
"""
from __future__ import annotations
import base64
import json
import os
import socket
import socketserver
import struct
import sys
import threading
import time
import traceback
from typing import Any, Callable
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import gitea
import keymap
import project
import sessionlog
import session as session_mod
import tasks as tasks_mod
import verify as verify_mod
import browser
import websearch
from guest import GuestError, GuestTimeout
from qmp import QmpError
SOCKET_DIR = "/run/owuivm/agent"
SOCKET_PATH = os.path.join(SOCKET_DIR, "agent.sock")
SOCKET_GROUP = "owuitools-agent"
# A command's output is capped on the way out of here as well as inside
# the guest agent. The agent trims again for its own context; this bound
# is about what vmd will hold and hand over, not about readability.
MAX_OUTPUT_CHARS = 200_000
MAX_FILE_BYTES = 2_000_000
# One write is a section, not a codebase. See v_write_file for why this is
# far smaller than the file limit - it is the forcing function, not a
# resource limit, and append_file/edit_file are the way past it.
MAX_WRITE_BYTES = 16_000
class VerbError(RuntimeError):
"""An error phrased for the agent - and through it, the model - to read."""
class State:
"""One session's worth of everything. Guarded by a single lock.
There is exactly one because there is exactly one VM; the lock is
what makes two concurrent requests from the agent safe rather than a
source of half-started machines.
"""
def __init__(self) -> None:
self.lock = threading.RLock()
self.session: session_mod.Session | None = None
# False until the machine has finished booting. Every verb that
# touches the guest checks it, so "the agent has no access until
# it is up" is enforced here rather than only by which tools the
# loop happens to offer.
self.ready: bool = False
self.tasks = tasks_mod.TaskList()
self.transcript: list[dict[str, Any]] = []
self.files_created: set[str] = set()
self.question: str | None = None
self.unfreeze_request: str | None = None
self.finished: dict[str, Any] | None = None
self.owner: str = "" # the OWUI user this session is for
self.job_text: str = "" # the job, kept to label the repository
self.commits: list[str] = []
self.gitea_url: str = ""
self.releases_url: str = ""
self.release_error: str = ""
self.publish_error: str = ""
# What the session WAS, kept after it is gone. The report is
# assembled after session_close - it has to be, or the Gitea link
# the push produced would not be in it - and by then the live
# session object is None. These three fields are all the report
# needs from it.
self.was: dict[str, Any] = {}
# The durable record of this session, written as it happens.
# See sessionlog.py: everything below was already being recorded
# and thrown away at the end.
self.log: Any = None
def reset(self) -> None:
if self.log is not None:
self.log.close(outcome="reset")
self.log = None
self.session = None
self.ready = False
self.desktop_ready = False
self.tasks = tasks_mod.TaskList()
self.transcript = []
self.files_created = set()
self.question = None
self.unfreeze_request = None
self.finished = None
self.owner = ""
self.job_text = ""
self.commits = []
self.gitea_url = ""
self.releases_url = ""
self.release_error = ""
self.publish_error = ""
self.was = {}
def require_session(self) -> session_mod.Session:
if self.session is None:
raise VerbError("there is no machine running. The session has not started, or it has already ended.")
return self.session
STATE = State()
# ---------------------------------------------------------------------
# helpers
# ---------------------------------------------------------------------
def _png_size(data: bytes) -> tuple[int, int]:
"""Width and height straight out of the IHDR chunk.
Cheaper and more honest than asking QEMU: this is the size of the
image the agent is actually looking at, which is what its click
coordinates have to be scaled against.
"""
if len(data) < 24 or data[:8] != b"\x89PNG\r\n\x1a\n":
raise VerbError("the screenshot QEMU produced is not a PNG")
width, height = struct.unpack(">II", data[16:24])
return int(width), int(height)
def _guest_path(relative: str) -> str:
"""Confine a path to the project share inside the guest.
The guest's own filesystem is disposable, so this is not about
protecting the VM - it is about the agent not wandering off and
writing its work somewhere that is wiped at the end of the session
without anybody noticing.
"""
relative = (relative or "").strip()
if not relative:
raise VerbError("no path was given.")
if relative.startswith("/"):
raise VerbError(
f"'{relative}' is an absolute path. Give a path relative to the "
"project folder - that is the only place whose contents survive "
"the end of this session."
)
if ".." in relative.split("/"):
raise VerbError("a path cannot contain '..'.")
if "\x00" in relative or "\n" in relative:
raise VerbError("that path contains characters a filename cannot have.")
sess = STATE.session
if sess is not None and sess.os_name == "windows":
# virtiofs appears as a drive letter on Windows, mounted by the
# image's own VirtIO-FS service. Backslashes because the thing
# that reads this path is cmd or PowerShell.
return "Z:\\" + relative.lstrip("./").replace("/", "\\")
return "/mnt/project/" + relative.lstrip("./")
def _record(kind: str, **fields: Any) -> None:
STATE.transcript.append({"t": round(time.time(), 3), "kind": kind, **fields})
# And to disk, in order, as it happens. The in-memory copy dies with
# the session; this is the one somebody can read afterwards.
if STATE.log is not None:
STATE.log.write(kind, **fields)
def _trim(text: str) -> tuple[str, bool]:
if len(text) <= MAX_OUTPUT_CHARS:
return text, False
return text[:MAX_OUTPUT_CHARS], True
# ---------------------------------------------------------------------
# orchestration verbs - used by the agent container's harness, not
# exposed to the model as tools
# ---------------------------------------------------------------------
def v_session_open(args: dict[str, Any]) -> dict[str, Any]:
with STATE.lock:
# A SESSION RECORD CAN OUTLIVE THE MACHINE, AND IT DID.
#
# Measured: the agent container was rebuilt mid-session, so the
# loop that would have called session_close died with it. The
# unit was reaped, `systemctl is-active` said inactive - and vmd
# still held the record, so every later session was refused with
# "a session is already running" until the daemon was restarted.
# One interrupted session would have taken the subsystem out.
#
# So the question is not whether a record exists, it is whether
# the machine does. A record with no machine is cleared, said
# plainly in the log, and the caller gets their session.
if STATE.session is not None and not STATE.session.alive():
stale = STATE.session.id
# ITS WORK IS STILL ON THE ARRAY, AND NOBODY ELSE WILL PUBLISH IT.
#
# A session whose machine died before its loop could call
# session_close - the agent container rebuilt underneath it,
# measured - left a 20 KB toolkit and five test scripts
# uncommitted in `ps-troubleshoot-toolkit`, with no repository
# and no link. The report that never came would have said
# "not published"; what the user actually got was a link the
# model made up. This is the only path that sees the record
# before it is thrown away, so the backstop lives here.
# _publish never raises.
published = _publish(STATE.session)
if published.get("url"):
print(f"vmd: published the work of stale session {stale} to "
f"{published['url']}", file=sys.stderr)
try:
session_mod.destroy(STATE.session)
except Exception as exc: # noqa: BLE001
print(f"vmd: could not tidy stale session {stale}: {exc}", file=sys.stderr)
STATE.session = None
STATE.reset()
print(f"vmd: cleared stale session {stale} - its machine was already gone",
file=sys.stderr)
if STATE.session is not None:
raise VerbError("a session is already running. Close it before starting another.")
os_name = str(args.get("os", "")).strip().lower()
project_name = args.get("project") or None
if project_name:
project.validate(str(project_name))
STATE.reset()
STATE.owner = str(args.get("owner", ""))[:200]
# Kept only to label the repository. Never shown to the guest and
# never re-sent to a model.
STATE.job_text = str(args.get("task", ""))[:4000]
# wait=False by default: the caller has twenty-odd seconds of boot
# to do something useful with, and the agent spends them writing
# its task list. Nothing that touches the guest will work until
# session_ready has returned - see _require_ready.
sess = session_mod.create(os_name, project_name,
wait=bool(args.get("wait", False)))
STATE.session = sess
STATE.ready = bool(args.get("wait", False))
# THE LOG OPENS HERE, BEFORE ANYTHING IS DONE TO THE MACHINE.
#
# `chat` is carried so a chat in Open WebUI can be matched to the
# session it started - without it, the only way to tell two runs
# apart afterwards is by guessing from timestamps, which is how a
# reader ends up describing the wrong run.
STATE.log = sessionlog.open_log(
sess.id, chat=str(args.get("chat_id", ""))[:64],
os=sess.os_name, project=sess.project_name or "",
owner=STATE.owner, task=STATE.job_text[:1000])
if project_name and gitea.configured():
try:
gitea.init(sess.share or "", str(project_name))
except Exception as exc: # noqa: BLE001
_record("git_init_failed", error=str(exc))
return {
"session": sess.id,
"os": sess.os_name,
"project": sess.project_name,
"share": sess.share,
"storage": project.describe_usage(str(project_name)) if project_name else "",
}
def v_session_ready(args: dict[str, Any]) -> dict[str, Any]:
"""Wait for the machine to finish booting, and unlock the guest verbs.
Deliberately NOT something the sub-agent can call. The harness calls
it once, between the planning turn and the working one; the agent is
simply told the machine is up. An agent that could ask "is it ready
yet?" would ask, repeatedly, and each of those is a model turn spent
on nothing.
"""
with STATE.lock:
sess = STATE.require_session()
if STATE.ready:
return {"ready": True, "seconds": 0.0, "desktop": True}
timeout = float(args.get("timeout", session_mod.BOOT_TIMEOUT_S))
desktop = bool(args.get("desktop", False))
try:
result = session_mod.await_ready(sess, timeout, desktop=desktop)
except session_mod.SessionError as exc:
# A WARM START THAT FAILS BECOMES A COLD BOOT, NOT A FAILURE.
#
# Resuming a saved machine has more ways to go wrong than
# booting one: the state can be stale, the firmware can
# disagree, a device can refuse to load. None of those are
# the caller's problem and none of them should cost them a
# session - the whole point of the saved machine is that it
# is an optimisation, and an optimisation that can take the
# feature down is not one.
if not sess.warm:
raise VerbError(str(exc)) from exc
print(f"vmd: warm start failed ({exc}); booting {sess.os_name} cold",
file=sys.stderr)
_record("warm_start_failed", error=str(exc))
was_project = sess.project_name
session_mod.destroy(sess)
STATE.session = None
try:
cold = session_mod.create(sess.os_name, was_project,
wait=False, allow_warm=False)
except session_mod.SessionError as exc2:
raise VerbError(str(exc2)) from exc2
STATE.session = cold
sess = cold
try:
result = session_mod.await_ready(cold, timeout, desktop=desktop)
except session_mod.SessionError as exc2:
raise VerbError(str(exc2)) from exc2
result["note"] = ("the saved machine could not be resumed, so this one was "
"booted from cold. Nothing about the machine differs.")
STATE.ready = True
_record("ready", **result)
return {"ready": True, **result}
def v_session_close(args: dict[str, Any]) -> dict[str, Any]:
with STATE.lock:
sess = STATE.session
if sess is None:
return {"closed": False}
published = _publish(sess)
if STATE.log is not None:
STATE.log.close(
outcome=("finished" if STATE.finished else
"asked a question" if STATE.question else "stopped early"),
verified=(STATE.finished or {}).get("verified"),
commits=len(STATE.commits), url=STATE.gitea_url or "",
releases=STATE.releases_url or "")
STATE.log = None
STATE.was = {"os": sess.os_name, "project": sess.project_name,
"share": sess.share, "id": sess.id}
STATE.publish_error = str(published.get("publish_error", ""))
session_mod.destroy(sess, graceful=bool(args.get("graceful", False)))
STATE.session = None
return {"closed": True, **published}
def _publish(sess) -> dict[str, Any]:
"""Commit anything uncommitted and push, at the end of every session.
The agent is told to commit as it goes, and often will. This is the
backstop for the session that ran out of steps, hit its deadline, or
was cancelled - the work is on the array either way, and a user who
asked for a link should get one rather than a folder they have to go
and find.
Deliberately never raises. A push that fails must not turn a session
that did its work into a session that reports a failure; it becomes a
line in the report saying the files are there but unpublished.
"""
if sess.share is None or not sess.project_name:
return {}
result: dict[str, Any] = {}
try:
if gitea.has_changes(sess.share):
sha = gitea.commit(
sess.share,
"Work from a VM session (committed automatically at the end)",
author_note=STATE.owner,
)
if sha:
STATE.commits.append(sha)
_record("commit", sha=sha, message="automatic end-of-session commit")
except Exception as exc: # noqa: BLE001
_record("commit_failed", error=str(exc))
result["publish_error"] = f"the final commit failed: {exc}"
if not gitea.configured():
result["publish_error"] = ("no Gitea token is configured, so the work is on the array "
"but was not published.")
return result
# NOT `if not STATE.commits`. That asked whether THIS session
# committed, and a session that was cancelled, timed out or lost its
# daemon leaves its work committed by the NEXT session's backstop -
# or by nothing at all, in which case a previous session's commit sits
# on the array unpushed forever. Measured: primes.py was committed
# locally and the Gitea repository did not exist, because no single
# session had both committed and closed cleanly.
#
# The right question is whether there is anything to publish at all.
if not gitea.has_commits(sess.share):
return result
try:
url = gitea.push(sess.share, sess.project_name,
gitea.short_description(STATE.job_text, sess.project_name))
STATE.gitea_url = url
result["url"] = url
_record("push", url=url)
except Exception as exc: # noqa: BLE001
_record("push_failed", error=str(exc))
result["publish_error"] = f"the push to Gitea failed: {exc}"
return result
# A RELEASE, BECAUSE A LINK INTO THE REPOSITORY DOES NOT DOWNLOAD.
#
# Measured: the raw file URL is served `text/plain` with
# `Content-Disposition: inline`, so a browser shows the source rather
# than saving it. Release assets are attachments and download. It is
# also a record of one run: each session that kept anything leaves a
# numbered entry holding the files exactly as it left them.
#
# Separate from the push and separately survivable. By this point the
# work is committed and on the server; a release that fails is a
# missing convenience, and saying so is better than turning a session
# that did its job into one that reports a failure.
try:
STATE.releases_url = gitea.publish_release(
sess.share, sess.project_name, url,
gitea.short_description(STATE.job_text, sess.project_name))
result["releases_url"] = STATE.releases_url
_record("release", url=STATE.releases_url)
except Exception as exc: # noqa: BLE001
_record("release_failed", error=str(exc))
STATE.release_error = str(exc)
result["release_error"] = STATE.release_error
return result
def v_session_status(_args: dict[str, Any]) -> dict[str, Any]:
with STATE.lock:
sess = STATE.session
return {
"running": sess is not None and sess.alive(),
"session": sess.id if sess else None,
"cancelled": sess.cancelled() if sess else False,
"tasks": STATE.tasks.render(),
"planned": STATE.tasks.planned,
"ready": STATE.ready,
"status_line": STATE.tasks.status_line(),
"question": STATE.question,
"unfreeze_request": STATE.unfreeze_request,
"finished": STATE.finished,
"commands": sum(1 for e in STATE.transcript if e["kind"] == "run"),
}
def v_session_cancel(_args: dict[str, Any]) -> dict[str, Any]:
with STATE.lock:
sess = STATE.session
if sess is None:
return {"cancelled": False, "detail": "no session"}
sess.cancel()
return {"cancelled": True}
def v_answer(args: dict[str, Any]) -> dict[str, Any]:
"""The operator's reply to ask_operator or request_unfreeze."""
with STATE.lock:
STATE.question = None
STATE.unfreeze_request = None
return {"delivered": True, "answer": str(args.get("answer", ""))[:4000]}
def v_snapshot(args: dict[str, Any]) -> dict[str, Any]:
"""Save a booted machine so later sessions resume instead of booting.
Operator-facing, not agent-facing: it is not in the sub-agent's tool
list, it takes minutes, and it is the operator's decision when an
image is worth saving - which is right after they have changed one.
"""
with STATE.lock:
if STATE.session is not None and STATE.session.alive():
raise VerbError("a session is running. Close it before saving a machine.")
os_name = str(args.get("os", "")).strip().lower()
result = session_mod.snapshot(os_name, float(args.get("settle", 45)))
_record("snapshot", **result)
return result
def v_snapshot_status(_args: dict[str, Any]) -> dict[str, Any]:
"""What saved machines exist, and whether they still match their image."""
out = {}
for os_name in session_mod.SUPPORTED_OS:
warm = session_mod.warm_set(os_name)
path = os.path.join(session_mod.WARM_ROOT, os_name)
if warm:
out[os_name] = {"usable": True, "created": warm["meta"].get("created"),
"state_gb": round(os.path.getsize(warm["state"]) / 1e9, 2)}
elif os.path.isdir(path):
# Say which of the two it is. "Does not match" and "is not
# finished" call for different actions, and guessing wrong
# sends somebody to re-freeze an image that was fine.
manifest = os.path.join(path, "manifest.json")
if not os.path.isfile(manifest):
why = "it is incomplete - no manifest, so nothing can vouch for it"
else:
why = ("it does not match the image that is there now - the image has "
"been changed since it was saved. Save it again.")
out[os_name] = {"usable": False, "why": why}
else:
out[os_name] = {"usable": False, "why": "none saved"}
return out
def v_reap(_args: dict[str, Any]) -> dict[str, Any]:
return {"reaped": session_mod.reap()}
# ---------------------------------------------------------------------
# task verbs
# ---------------------------------------------------------------------
def v_plan_tasks(args: dict[str, Any]) -> dict[str, Any]:
with STATE.lock:
items = args.get("tasks") or []
if not isinstance(items, list):
raise VerbError("tasks must be a list of short descriptions.")
table = STATE.tasks.plan([str(t) for t in items])
_record("plan", count=len(STATE.tasks.tasks))
return {"tasks": table, "status_line": STATE.tasks.status_line()}
def v_update_task(args: dict[str, Any]) -> dict[str, Any]:
with STATE.lock:
table = STATE.tasks.update(
int(args.get("id", 0)), str(args.get("status", "")), str(args.get("note", "")))
_record("task", id=args.get("id"), status=args.get("status"))
return {"tasks": table, "status_line": STATE.tasks.status_line()}
def v_add_task(args: dict[str, Any]) -> dict[str, Any]:
with STATE.lock:
after = args.get("after_id")
table = STATE.tasks.add(str(args.get("text", "")),
int(after) if after is not None else None)
_record("task_added", text=str(args.get("text", ""))[:120])
return {"tasks": table, "status_line": STATE.tasks.status_line()}
def _require_ready() -> None:
"""Nothing touches the guest until it has booted.
Belt and braces with the loop, which does not even offer these tools
while the machine is coming up. If the two ever disagree, this is the
one that decides.
"""
# Order matters: "no machine at all" and "the machine is still coming
# up" are different situations and the wrong message sends the agent
# off waiting for something that is never going to arrive.
if STATE.session is None:
raise VerbError(
"there is no machine running. The session has not started, or it has already ended."
)
if not STATE.ready:
raise VerbError(
"the machine is still booting. You will be told when it is ready; "
"there is nothing to do until then."
)
def _require_plan() -> None:
"""The first move is always the task list.
Not a style preference: it is the stop condition. An agent with no
written-down standard is an agent that decides for itself when it has
done enough, which is the failure the previous attempt was retired
for. Stated as tool output because that is what this model treats as
fact.
"""
if not STATE.tasks.planned:
raise VerbError(
"write the task list first. Call plan_tasks with what you intend "
"to do, then work through it. Nothing else runs until there is a "
"list to measure the work against."
)
# ---------------------------------------------------------------------
# guest verbs
# ---------------------------------------------------------------------
# GIT IS NOT RUN IN THE MACHINE, AND THE REASON IS NOT TIDINESS.
#
# The project folder is a repository managed on the HOST, by gitea.py,
# which pins every setting that decides whether code runs (hooks, filters,
# ext:: URLs) on the command line, commits as the owning uid, and leaves
# scratch and test scripts out of what gets published. A commit made
# inside the guest has none of that.
#
# It also does not work. The guest sees `.git` owned by another uid, so
# git refuses with "dubious ownership" and exit 128 unless it is fought
# with `safe.directory` and `HOME` - and a session did exactly that for
# a dozen turns at the end of its budget, saying "let me use the
# git_commit tool" at one point and then going back to raw git anyway.
# Refused here, in tool output, because that is the channel this model
# treats as fact.
#
# Read-only subcommands are refused too, reluctantly: they hit the same
# ownership wall, so allowing them would just invite the same dance.
_GIT_WRITES = frozenset({
"add", "commit", "push", "pull", "fetch", "clone", "init", "rm", "mv",
"reset", "checkout", "switch", "restore", "merge", "rebase", "revert",
"cherry-pick", "clean", "stash", "remote", "config", "apply", "am",
})
# Where one command ends and the next begins, and the words that can sit
# in front of a command without being one.
_SEPARATORS = frozenset({"&&", "||", ";", "|", "&", "\n", "(", ")", "{", "}"})
_PREFIXES = frozenset({"sudo", "env", "nohup", "time", "command", "exec",
"then", "do", "else", "export"})
def _git_write_in(command: str) -> str | None:
"""The mutating git subcommand this shell line would run, if any.
A SIGNPOST, NOT A SANDBOX. A guest that insists can still write to
`.git` directly, and nothing here would stop it; what protects the
published history is that gitea.py does every host-side commit with
hooks and filters pinned off, whatever the tree says. This exists to
catch the agent's honest attempts and point them at the tool that
works, because those attempts cost a session most of its last hour.
`git` has to be in COMMAND position - the start of the line or just
after a separator. Without that, `echo git commit > notes.txt` reads
as a commit, and a refusal that fires on a string in an echo is worse
than no refusal at all.
"""
import shlex
try:
tokens = shlex.split(command)
except ValueError:
tokens = command.split()
index = 0
at_command = True
while index < len(tokens):
token = tokens[index]
if token in _SEPARATORS:
at_command = True
index += 1
continue
if not at_command:
index += 1
continue
if os.path.basename(token) == "git":
index += 1
# git's own options come before the subcommand, and a few take
# a value: `git -c safe.directory=... add` is the exact shape
# that showed up in the session this was written for.
while index < len(tokens) and tokens[index].startswith("-"):
takes_value = tokens[index] in ("-c", "-C", "--git-dir", "--work-tree")
index += 1
if takes_value and index < len(tokens):
index += 1
if index < len(tokens) and tokens[index] in _GIT_WRITES:
return tokens[index]
continue
# Still in front of the command: `sudo`, `env`, a flag, or the
# VAR=value that `env A=1 cmd` and a bare assignment both use.
if (token in _PREFIXES or token.startswith("-")
or ("=" in token and not token.startswith("="))):
index += 1
continue
at_command = False
index += 1
return None
def v_run(args: dict[str, Any]) -> dict[str, Any]:
with STATE.lock:
_require_plan()
_require_ready()
sess = STATE.require_session()
script = str(args.get("command", "")).strip()
if not script:
raise VerbError("no command was given.")
offending = _git_write_in(script)
if offending:
raise VerbError(
f"`git {offending}` is not run inside the machine. The project folder is a "
"repository kept on the host: the guest sees its .git owned by another user, "
"so git answers with 'dubious ownership' and exit 128 unless you fight it "
"with safe.directory and HOME. A session spent a dozen turns doing exactly "
"that and committed nothing.\n\n"
"Use `git_commit` with a message instead. It runs on the host as the owner, "
"commits the work, leaves test scripts and scratch out of what gets "
"published, tells you what it held back, and the push to Gitea happens for "
"you when the session ends."
)
timeout = max(1.0, min(float(args.get("timeout_s", 120)), 900.0))
try:
with sess.guest(timeout=30.0) as agent:
# Every command starts in the project folder - the same
# place the supervisor re-runs the verification from. They
# used to differ, and a Windows session wrote its script
# into C:\Windows\system32, checked it there perfectly
# well, and then failed verification on a file that
# existed one directory away. See verify.py.
result = agent.shell(
verify_mod.working_directory_prelude(sess) + script,
timeout_s=timeout)
except GuestTimeout as exc:
_record("run", command=script[:500], exit_code=None, timed_out=True)
raise VerbError(str(exc)) from exc
except (GuestError, OSError) as exc:
raise VerbError(f"the machine could not be reached: {exc}") from exc
out, out_cut = _trim(result["stdout"])
err, err_cut = _trim(result["stderr"])
_record("run", command=script[:500], exit_code=result["exit_code"],
seconds=result["seconds"])
return {
"exit_code": result["exit_code"],
"stdout": out, "stderr": err,
"truncated": bool(result["truncated"] or out_cut or err_cut),
"seconds": result["seconds"],
}
def _make_parent(sess, agent, path: str) -> None:
"""Create the directory a file is about to be written into.
THE PATH IS SPLIT HERE, NOT IN THE GUEST. Two goes at this failed for
the same underlying reason - the shell is not the one you think:
1. It sent `mkdir -p "$(dirname ...)"` to every machine. On Windows
that reaches PowerShell, where `mkdir` takes no `-p` and
`dirname` is not a command.
2. Rewritten in PowerShell, it used `Split-Path -Parent -LiteralPath`
- which **5.1 rejects as an ambiguous parameter set**. `$p` came
back null, `Test-Path` and `New-Item` both bound null and did
nothing, and because those are NON-TERMINATING errors PowerShell
still exited 0. Measured: `PARENT=[]`, `EXISTS_AFTER=`, exit 0,
no directory. A check on the exit code could not see it.
vmd built this path in `_guest_path`, so it knows the separator and
can take the parent itself. Nothing is parsed in the guest, and the
result is CONFIRMED rather than inferred from a status code.
"""
if sess.os_name == "windows":
parent = path.rsplit("\\", 1)[0]
if len(parent) <= 2: # "Z:" - the share root always exists
return
quoted = parent.replace("'", "''")
probe = agent.shell(
"$ErrorActionPreference = 'Stop'; "
f"New-Item -ItemType Directory -Force -Path '{quoted}' | Out-Null; "
f"if (Test-Path -LiteralPath '{quoted}') {{ Write-Output 'MADE' }}",
timeout_s=30)
else:
parent = path.rsplit("/", 1)[0]
if not parent:
return
probe = agent.shell(
f"mkdir -p {_sh(parent)} && test -d {_sh(parent)} && echo MADE", timeout_s=30)
# CONFIRMED, NOT ASSUMED. PowerShell exits 0 on a non-terminating
# error, so "it returned zero" proves nothing; the directory saying it
# exists is the only evidence worth having.
if "MADE" not in (probe.get("stdout") or ""):
detail = (probe.get("stderr") or "").strip()
raise VerbError(
f"could not create the folder {parent} for that file: "
f"{detail[:300] or 'the machine gave no reason'}")
def v_write_file(args: dict[str, Any]) -> dict[str, Any]:
with STATE.lock:
_require_plan()
_require_ready()
sess = STATE.require_session()
path = _guest_path(str(args.get("path", "")))
content = str(args.get("content", ""))
if len(content.encode()) > MAX_WRITE_BYTES:
raise VerbError(
f"that is {len(content.encode()) // 1000} KB, and one write takes at most "
f"{MAX_WRITE_BYTES // 1000} KB. This is deliberate: a file you have to "
"produce in a single call is a file you have to design completely before "
"you can write any of it, and that is where sessions disappear.\n\n"
"Write the skeleton now - the structure, with the parts named and empty - "
"then fill it in with append_file, or revise it with edit_file. Check it "
"parses between sections rather than at the end. If it is generated data "
"rather than code, produce it with a command in the machine instead."
)
try:
with sess.guest(timeout=60.0) as agent:
_make_parent(sess, agent, path)
written = agent.write_file(path, content.encode())
except (GuestError, OSError) as exc:
raise VerbError(f"could not write that file: {exc}") from exc
STATE.files_created.add(path)
_record("write_file", path=path, bytes=written)
return {"path": path, "bytes": written}
def v_read_file(args: dict[str, Any]) -> dict[str, Any]:
with STATE.lock:
_require_plan()
_require_ready()
sess = STATE.require_session()
path = _guest_path(str(args.get("path", "")))
offset = max(0, int(args.get("offset", 0)))
size = max(1, min(int(args.get("size", 40000)), 200_000))
try:
with sess.guest(timeout=60.0) as agent:
data = agent.read_file(path, max_bytes=offset + size)
except (GuestError, OSError) as exc:
raise VerbError(f"could not read that file: {exc}") from exc
window = data[offset:offset + size]
_record("read_file", path=path, bytes=len(window))
return {
"path": path,
"content": window.decode("utf-8", "replace"),
"offset": offset,
"returned": len(window),
"more": len(data) > offset + len(window),
}
def v_append_file(args: dict[str, Any]) -> dict[str, Any]:
"""Add to the end of a file, creating it if it is not there.
Building forward, one section per turn, instead of holding the whole
thing in your head until it can be emitted in one call.
"""
with STATE.lock:
_require_plan()
_require_ready()
sess = STATE.require_session()
path = _guest_path(str(args.get("path", "")))
content = str(args.get("content", ""))
if len(content.encode()) > MAX_WRITE_BYTES:
raise VerbError(
f"that is {len(content.encode()) // 1000} KB and one append takes at most "
f"{MAX_WRITE_BYTES // 1000} KB. Add it in smaller pieces."
)
try:
with sess.guest(timeout=60.0) as agent:
_make_parent(sess, agent, path)
try:
existing = agent.read_file(path, max_bytes=MAX_FILE_BYTES)
except (GuestError, OSError):
existing = b""
written = agent.write_file(path, existing + content.encode())
except (GuestError, OSError) as exc:
raise VerbError(f"could not append to that file: {exc}") from exc
STATE.files_created.add(path)
_record("append_file", path=path, bytes=len(content.encode()))
return {"path": path, "bytes": written, "added": len(content.encode())}
def v_edit_file(args: dict[str, Any]) -> dict[str, Any]:
"""Replace one exact piece of a file with another.
THE TOOL THAT WAS MISSING, AND THE REASON SESSIONS DISAPPEARED.
The agent had `write_file` and nothing else, so every change to a file
meant reproducing the whole file - and a file you must reproduce whole
is a file you must design whole before you can write any of it.
Measured: a session spent twenty-two minutes and roughly fifty
thousand tokens in a single turn, designing an entire application in
its head, still on task one of twelve, because there was no way to
emit the first part of it and come back for the rest.
It fails LOUDLY, which is the whole point. Text that is not there, or
that is there more than once, is a mistaken belief about the file, and
finding out now is far cheaper than a silent half-edit that is
discovered three sections later.
"""
with STATE.lock:
_require_plan()
_require_ready()
sess = STATE.require_session()
path = _guest_path(str(args.get("path", "")))
old = str(args.get("old", ""))
new = str(args.get("new", ""))
every = bool(args.get("replace_all", False))
if not old:
raise VerbError("'old' is required: it is the exact text to replace.")
if old == new:
raise VerbError("'old' and 'new' are identical, so that would change nothing.")
try:
with sess.guest(timeout=60.0) as agent:
raw = agent.read_file(path, max_bytes=MAX_FILE_BYTES)
except (GuestError, OSError) as exc:
raise VerbError(f"could not read that file to edit it: {exc}") from exc
text = raw.decode("utf-8", "replace")
found = text.count(old)
if found == 0:
raise VerbError(
"that text is not in the file. Read it again rather than guessing at the "
"wording - whitespace and indentation have to match exactly."
)
if found > 1 and not every:
raise VerbError(
f"that text appears {found} times, so replacing it is ambiguous. Include "
"enough of the surrounding lines to name one of them, or pass "
"replace_all if you mean all of them."
)
updated = text.replace(old, new) if every else text.replace(old, new, 1)
try:
with sess.guest(timeout=60.0) as agent:
written = agent.write_file(path, updated.encode())
except (GuestError, OSError) as exc:
raise VerbError(f"could not write that file back: {exc}") from exc
STATE.files_created.add(path)
_record("edit_file", path=path, replaced=found if every else 1)
return {"path": path, "bytes": written,
"replaced": found if every else 1,
"note": "the file was changed; check it still parses"}
def v_list_dir(args: dict[str, Any]) -> dict[str, Any]:
with STATE.lock:
_require_plan()
_require_ready()
sess = STATE.require_session()
path = _guest_path(str(args.get("path", ".")) or ".")
try:
with sess.guest(timeout=30.0) as agent:
result = agent.shell(f"ls -la --time-style=long-iso {_sh(path)}", timeout_s=30)
except (GuestError, OSError) as exc:
raise VerbError(f"could not list that directory: {exc}") from exc
_record("list_dir", path=path, exit_code=result["exit_code"])
return {"path": path, "exit_code": result["exit_code"],
"listing": _trim(result["stdout"])[0], "stderr": result["stderr"]}
def v_check_internet(_args: dict[str, Any]) -> dict[str, Any]:
"""Because `ping` is permanently broken here and looks like no internet.
net.ipv4.ping_group_range on this host is "1 0", i.e. no group may
open an unprivileged ICMP socket, so libslirp cannot forward one. The
guest's `ping` therefore fails every single time while HTTPS works
perfectly. An agent that tests connectivity the obvious way concludes
it is offline and spends its budget on the wrong problem, so the fact
is stated here, in tool output, rather than in a system prompt.
"""
with STATE.lock:
_require_ready()
sess = STATE.require_session()
try:
probe = (
"curl -sS -o /dev/null -w '%{http_code}' --max-time 15 https://1.1.1.1/ "
"&& echo && getent hosts archive.ubuntu.com | head -1"
)
if sess.os_name == "windows":
probe = (
"$r = Invoke-WebRequest -Uri 'https://1.1.1.1/' -UseBasicParsing "
"-TimeoutSec 15; Write-Output $r.StatusCode; "
"(Resolve-DnsName -Name microsoft.com -Type A -ErrorAction Stop | "
"Select-Object -First 1).IPAddress"
)
with sess.guest(timeout=40.0) as agent:
result = agent.shell(probe, timeout_s=40)
except (GuestError, OSError) as exc:
raise VerbError(f"the machine could not be reached: {exc}") from exc
reachable = result["exit_code"] == 0
_record("check_internet")
return {
"internet": reachable,
"detail": _trim(result["stdout"])[0] or result["stderr"][:400],
"note": (
"ping does not work on this machine and never will: the host "
"does not permit unprivileged ICMP sockets, so libslirp cannot "
"forward them. Use curl or getent to test connectivity. The LAN "
"and this host are deliberately unreachable; the internet is not."
),
}
def v_web_search(args: dict[str, Any]) -> dict[str, Any]:
"""The same search the main model uses, run on the host.
Not in the machine: SearXNG sits inside another container's network
namespace behind a default-deny filter, which the sandbox cannot
reach and should not be able to. Running it here also means the
agent gets the formatter, the cache and the rate limit that the api
container already has, rather than a second search client that
behaves slightly differently.
No `_require_ready()`: this needs the host, not the machine, so it
still answers while the guest is wedged - which is exactly when
looking something up is most useful.
"""
with STATE.lock:
_require_plan()
try:
text = websearch.search(
str(args.get("query", "")),
int(args.get("max_results", 6) or 6),
str(args.get("site", "") or ""),
str(args.get("time_range", "") or ""),
)
except websearch.SearchError as exc:
raise VerbError(str(exc)) from exc
_record("web_search", query=str(args.get("query", ""))[:200])
return {"results": text}
def v_read_page(args: dict[str, Any]) -> dict[str, Any]:
"""Fetch a page as text, on the host, without involving the machine.
Reading a page as text costs one call and a few hundred tokens where
reading it off the screen costs a screenshot per scroll. `open_page`
is for when the agent needs to SEE a page - a download button, a
rendered diagram; this is for when it needs to KNOW what it says.
"""
with STATE.lock:
_require_plan()
try:
text = websearch.read(
str(args.get("url", "")),
int(args.get("max_chars", 6000) or 6000),
int(args.get("offset", 0) or 0),
str(args.get("search", "") or ""),
)
except websearch.SearchError as exc:
raise VerbError(str(exc)) from exc
_record("read_page", url=str(args.get("url", ""))[:300])
return {"text": text}
def v_research(args: dict[str, Any]) -> dict[str, Any]:
"""One of the main model's lookup tools, run on the host.
Deliberately one verb with an allowlist rather than eight verbs: the
list of what a session may look up is then a single readable
thing, in one place, instead of something to be reconstructed from
the verb table.
"""
with STATE.lock:
_require_plan()
tool = str(args.get("tool", ""))
try:
text = websearch.research(tool, args.get("args") or {})
except websearch.SearchError as exc:
raise VerbError(str(exc)) from exc
_record("research", tool=tool)
return {"text": text}
def v_open_page(args: dict[str, Any]) -> dict[str, Any]:
"""Put a URL on the machine's screen in one call instead of six."""
with STATE.lock:
_require_plan()
_require_ready()
sess = STATE.require_session()
try:
name = browser.open_url(sess, str(args.get("url", "")))
except browser.BrowserError as exc:
raise VerbError(str(exc)) from exc
_record("open_page", url=str(args.get("url", ""))[:300], browser=name)
return {"browser": name, "url": browser.validate(str(args.get("url", "")))}
def _require_desktop() -> None:
"""Wait for the graphical session, the first time anybody needs it.
LAZY, BECAUSE MOST SESSIONS NEVER LOOK AT THE SCREEN.
There used to be two kinds of session and only the desktop one waited
for a desktop at handover. There is one kind now, and making every
session wait for GNOME to finish painting before it can run a shell
command would hand the cost of a feature to the jobs that do not use
it - the opposite of why the split was removed.
So the wait happens here, on the first screen verb, and once. A job
that only ever runs commands never pays it; one that takes a
screenshot pays it exactly once, at the point where it matters.
"""
if STATE.desktop_ready:
return
sess = STATE.require_session()
try:
session_mod.await_desktop(sess, timeout=300.0)
except session_mod.SessionError as exc:
raise VerbError(
f"the desktop is not up, so there is nothing to look at yet: {exc}"
) from exc
STATE.desktop_ready = True
def v_launch_app(args: dict[str, Any]) -> dict[str, Any]:
"""Start a program on the real desktop, where a screenshot can see it."""
with STATE.lock:
_require_plan()
_require_ready()
_require_desktop()
sess = STATE.require_session()
path = _guest_path(str(args.get("path", "")))
try:
note = browser.launch_app(sess, path)
except browser.BrowserError as exc:
raise VerbError(str(exc)) from exc
_record("launch_app", path=path)
return {"path": path, "status": note}
def v_reboot_vm(_args: dict[str, Any]) -> dict[str, Any]:
with STATE.lock:
_require_plan()
_require_ready()
sess = STATE.require_session()
try:
with sess.guest(timeout=15.0) as agent:
agent.shutdown("reboot")
except (GuestError, OSError):
with sess.qmp() as monitor:
monitor.reset()
_record("reboot")
time.sleep(3.0)
session_mod._wait_for_guest(sess)
return {"rebooted": True,
"note": "the disk survived the reboot, so nothing you had written is lost."}
# ---------------------------------------------------------------------
# GUI verbs
# ---------------------------------------------------------------------
def _screen_size(sess) -> tuple[int, int]:
data = _screenshot_bytes(sess)
return _png_size(data)
def _screenshot_bytes(sess) -> bytes:
path = os.path.join(sess.run_dir, "screen.png")
try:
with sess.qmp() as monitor:
monitor.screenshot(path)
except (QmpError, OSError) as exc:
raise VerbError(f"could not take a screenshot: {exc}") from exc
deadline = time.monotonic() + 5.0
while time.monotonic() < deadline:
if os.path.exists(path) and os.path.getsize(path) > 0:
break
time.sleep(0.05)
with open(path, "rb") as handle:
return handle.read()
def v_screenshot(_args: dict[str, Any]) -> dict[str, Any]:
with STATE.lock:
_require_plan()
_require_ready()
_require_desktop()
sess = STATE.require_session()
data = _screenshot_bytes(sess)
width, height = _png_size(data)
_record("screenshot", width=width, height=height, bytes=len(data))
return {"png_b64": base64.b64encode(data).decode(),
"width": width, "height": height}
def v_click(args: dict[str, Any]) -> dict[str, Any]:
return _pointer(args, "click")
def v_move(args: dict[str, Any]) -> dict[str, Any]:
return _pointer(args, "move")
def _pointer(args: dict[str, Any], action: str) -> dict[str, Any]:
with STATE.lock:
_require_plan()
_require_ready()
_require_desktop()
sess = STATE.require_session()
width, height = _screen_size(sess)
x, y = int(args.get("x", 0)), int(args.get("y", 0))
button = str(args.get("button", "left"))
try:
with sess.qmp() as monitor:
if action == "click":
monitor.click(x, y, width, height, button)
else:
monitor.move(x, y, width, height)
except QmpError as exc:
raise VerbError(str(exc)) from exc
_record(action, x=x, y=y)
return {"x": x, "y": y, "screen": [width, height]}
def v_scroll(args: dict[str, Any]) -> dict[str, Any]:
with STATE.lock:
_require_plan()
_require_ready()
_require_desktop()
sess = STATE.require_session()
width, height = _screen_size(sess)
try:
with sess.qmp() as monitor:
monitor.scroll(int(args.get("x", width // 2)), int(args.get("y", height // 2)),
width, height, int(args.get("amount", -3)))
except QmpError as exc:
raise VerbError(str(exc)) from exc
_record("scroll", x=int(args.get("x", width // 2)),
y=int(args.get("y", height // 2)), amount=int(args.get("amount", -3)))
return {"scrolled": int(args.get("amount", -3))}
def v_key(args: dict[str, Any]) -> dict[str, Any]:
with STATE.lock:
_require_plan()
_require_ready()
_require_desktop()
sess = STATE.require_session()
keys = args.get("keys") or []
if isinstance(keys, str):
keys = [k.strip() for k in keys.split("+") if k.strip()]
try:
with sess.qmp() as monitor:
monitor.key([str(k) for k in keys])
except QmpError as exc:
raise VerbError(str(exc)) from exc
_record("key", keys=keys)
return {"pressed": keys}
def v_type(args: dict[str, Any]) -> dict[str, Any]:
with STATE.lock:
_require_plan()
_require_ready()
_require_desktop()
sess = STATE.require_session()
text = str(args.get("text", ""))
if len(text) > 2000:
raise VerbError(
"that is a lot to type through a keyboard. Write it to a file "
"with write_file and open the file in the machine instead."
)
try:
chords = keymap.to_chords(text)
except keymap.KeymapError as exc:
raise VerbError(str(exc)) from exc
try:
with sess.qmp() as monitor:
for chord in chords:
monitor.key(chord, hold_ms=25)
except QmpError as exc:
raise VerbError(str(exc)) from exc
_record("type", chars=len(text))
return {"typed": len(text)}
# Twenty minutes. Long enough for a Windows feature install or a big
# apt transaction, short enough that a wedged wait cannot eat a session.
# The agent is NOT told this number up front - it is one more sentence in
# a tool description that is paid for on every turn, and it only matters
# in the one case where it is exceeded, where the tool says so.
MAX_WAIT_SECONDS = 1200.0
def v_wait(args: dict[str, Any]) -> dict[str, Any]:
"""Sleep, while staying interruptible.
A long wait still has to notice a cancellation - a session stopped by
the user should not sit here for another quarter of an hour - so this
sleeps in short steps and checks between them rather than blocking.
"""
with STATE.lock:
_require_ready()
sess = STATE.require_session()
asked = float(args.get("seconds", 1.0))
seconds = max(0.1, min(asked, MAX_WAIT_SECONDS))
waited = 0.0
while waited < seconds:
if sess.cancelled():
_record("wait", seconds=round(waited, 1), cancelled=True)
return {"waited": round(waited, 1), "cancelled": True,
"note": "the session was cancelled while waiting."}
step = min(2.0, seconds - waited)
time.sleep(step)
waited += step
_record("wait", seconds=round(waited, 1))
result: dict[str, Any] = {"waited": round(waited, 1)}
if asked > MAX_WAIT_SECONDS:
result["note"] = (
f"You asked to wait {asked:.0f}s; a single wait is capped at "
f"{MAX_WAIT_SECONDS:.0f}s ({MAX_WAIT_SECONDS / 60:.0f} minutes), so that is "
"how long this waited. Check whether the thing you are waiting for has "
"finished - if it has not, wait again rather than assuming it failed."
)
return result
# ---------------------------------------------------------------------
# publishing and finishing
# ---------------------------------------------------------------------
def v_git_commit(args: dict[str, Any]) -> dict[str, Any]:
with STATE.lock:
_require_plan()
_require_ready()
sess = STATE.require_session()
if not sess.share:
raise VerbError("this session has no project folder, so there is nothing to commit.")
message = str(args.get("message", "")).strip()
if not message:
raise VerbError("a commit needs a message saying what changed.")
# ASKED FOR BEFORE THE COMMIT, BECAUSE THE COMMIT CONSUMES IT.
# Once the staging has run, the working tree no longer shows what
# was skipped.
skipped = gitea.held_back(sess.share)
try:
sha = gitea.commit(sess.share, message, author_note=STATE.owner)
except gitea.GitError as exc:
raise VerbError(str(exc)) from exc
if sha:
STATE.commits.append(sha)
_record("commit", sha=sha, message=message[:200], held_back=len(skipped))
detail = "nothing had changed since the last commit" if not sha else ""
# SAID OUT LOUD, IN TOOL OUTPUT.
#
# A filter nobody mentions is indistinguishable from a bug: the
# agent writes test_thing.ps1, commits, and watches it not turn
# up in the release. Telling it here is also the only channel
# this stack has repeatedly measured the model to act on.
note = ""
if skipped:
shown = ", ".join(skipped[:8]) + ("..." if len(skipped) > 8 else "")
note = (
f"Not committed, and so not published: {shown}. Scratch and test "
"scripts stay on the machine's project folder where you can still "
"read and run them - the release is only what the work actually "
"produced. If one of those IS the deliverable, rename it to "
"something that is not a test name and commit again."
)
return {"sha": sha, "detail": detail, "held_back": skipped, "note": note}
def v_ask_operator(args: dict[str, Any]) -> dict[str, Any]:
with STATE.lock:
question = str(args.get("question", "")).strip()
if not question:
raise VerbError("no question was given.")
STATE.question = question[:2000]
_record("question", text=question[:500])
return {"asked": True}
def v_request_unfreeze(args: dict[str, Any]) -> dict[str, Any]:
"""The agent can ask. It cannot do, and there is no verb that does.
Unfreezing is a different unit, running as a different user, behind a
polkit check that only an active local session of the operator
satisfies. This verb records a request; a human is the only thing that
can act on it.
"""
with STATE.lock:
reason = str(args.get("reason", "")).strip()
if not reason:
raise VerbError(
"say what needs installing or changing in the base image, and why "
"it cannot be done inside this session."
)
STATE.unfreeze_request = reason[:2000]
_record("unfreeze_request", reason=reason[:500])
return {
"requested": True,
"note": (
"This is a request, not a change. The base image is unchanged and "
"will stay that way unless the operator unfreezes it themselves at "
"the machine. Anything you install in this session works for this "
"session only."
),
}
def v_finish(args: dict[str, Any]) -> dict[str, Any]:
with STATE.lock:
sess = STATE.require_session()
refusal = STATE.tasks.why_not_finished()
if refusal:
raise VerbError(refusal)
if not any(e["kind"] == "run" for e in STATE.transcript):
raise VerbError(
"no command has been run in this machine, so there is nothing to "
"report. Run the work before finishing - a summary of work that "
"was never attempted is worse than no answer."
)
summary = str(args.get("summary", "")).strip()
# `verification` IS DECLARED AS AN OBJECT AND ARRIVES AS A STRING.
#
# Often enough to matter: the schema says {command, expected} and
# the model sends the command on its own. This used to call .get on
# it and raise AttributeError out of the verb - so `finish` was
# refused with an internal error, on every attempt. Measured: a
# session did all six of its tasks, committed, passed its own
# check, and then could not close, trying seven times with varying
# arguments before marking the task blocked and giving up.
#
# The same shape of bug was fixed in the live view's renderer; this
# is the one that mattered, because the display only draws a line
# and this ends the session.
check = args.get("verification")
if isinstance(check, str):
check = {"command": check}
elif not isinstance(check, dict):
check = {}
command = str(check.get("command", "")).strip()
expected = str(check.get("expected", "")).strip()
if not command:
raise VerbError(
"finish needs a verification: a single command that demonstrates "
"the work is right, and what you expect it to print. It will be "
"run again by the supervisor, and its real exit code is what the "
"user is shown."
)
result = verify_mod.run(sess, command, expected, STATE.files_created)
STATE.finished = {
"summary": summary[:8000],
"verification": result.render(),
"verified": result.ok,
}
_record("finish", verified=result.ok)
return {"accepted": True, "verified": result.ok, "verification": result.render()}
def v_note(args: dict[str, Any]) -> dict[str, Any]:
"""What the model said this turn, put in the log beside what it did.
Harness-only, like session_ready: it is not in the sub-agent's tool
list and the model never learns it exists. The loop calls it once per
turn, so the log reads as an alternating narrative - what it was
thinking, then what it actually called - which is the difference
between a list of verbs and an account of a session.
The text is the model's VISIBLE reply, never its thinking: the
thinking is by far the largest thing it produces, it is discarded from
the context on purpose, and a log nobody can read through is the same
as no log.
"""
with STATE.lock:
if STATE.log is not None:
STATE.log.write("note", turn=args.get("turn"),
text=str(args.get("text", ""))[:sessionlog.NOTE_CHARS])
return {"noted": True}
def v_report(_args: dict[str, Any]) -> dict[str, Any]:
"""The tool result, assembled here rather than by the agent.
The agent's prose is downstream of guest output, which is downstream
of whatever a script chose to print. A summary that reaches the main
model unannotated is a channel from the guest into the model's
instructions, so the facts come from this side and the agent's words
are quoted, capped and labelled.
"""
import report
with STATE.lock:
return {"text": report.render(STATE)}
# ---------------------------------------------------------------------
# dispatch
# ---------------------------------------------------------------------
def _sh(value: str) -> str:
import shlex
return shlex.quote(value)
VERBS: dict[str, Callable[[dict[str, Any]], dict[str, Any]]] = {
# orchestration
"append_file": v_append_file, "edit_file": v_edit_file,
"launch_app": v_launch_app,
"session_open": v_session_open, "session_ready": v_session_ready,
"session_close": v_session_close,
"session_status": v_session_status, "session_cancel": v_session_cancel,
"answer": v_answer, "reap": v_reap, "report": v_report, "note": v_note,
# tasks
"plan_tasks": v_plan_tasks, "update_task": v_update_task, "add_task": v_add_task,
# guest
"run": v_run, "write_file": v_write_file, "read_file": v_read_file,
"list_dir": v_list_dir, "check_internet": v_check_internet, "reboot_vm": v_reboot_vm,
# the web, from the host - the machine cannot reach SearXNG and must not
"web_search": v_web_search, "read_page": v_read_page, "open_page": v_open_page,
"research": v_research,
# operator-facing, never offered to the agent
"snapshot": v_snapshot, "snapshot_status": v_snapshot_status,
# gui
"screenshot": v_screenshot, "click": v_click, "move": v_move, "scroll": v_scroll,
"key": v_key, "type": v_type, "wait": v_wait,
# publishing
"git_commit": v_git_commit, "ask_operator": v_ask_operator,
"request_unfreeze": v_request_unfreeze, "finish": v_finish,
}
class Handler(socketserver.StreamRequestHandler):
timeout = 1800
def handle(self) -> None:
for raw in self.rfile:
line = raw.strip()
if not line:
continue
try:
message = json.loads(line)
verb = str(message.get("verb", ""))
args = message.get("args") or {}
handler = VERBS.get(verb)
if handler is None:
raise VerbError(f"'{verb}' is not something this can do.")
reply = {"ok": True, "result": handler(args)}
except (VerbError, project.ProjectError, tasks_mod.TaskError,
session_mod.SessionError, gitea.GitError) as exc:
reply = {"ok": False, "error": str(exc)}
except Exception as exc: # noqa: BLE001
# Never let a traceback reach the agent and through it the
# model: it reads as a broken tool rather than a finding,
# which is the same reasoning as toolbase.py's catch-all.
traceback.print_exc(file=sys.stderr)
reply = {"ok": False,
"error": f"vmd hit an internal error ({type(exc).__name__}); "
"it has been logged. Do not retry the same call."}
self.wfile.write(json.dumps(reply).encode() + b"\n")
self.wfile.flush()
class Server(socketserver.ThreadingUnixStreamServer):
daemon_threads = True
allow_reuse_address = True
def main() -> int:
import grp
os.makedirs(SOCKET_DIR, mode=0o2770, exist_ok=True)
try:
os.chown(SOCKET_DIR, -1, grp.getgrnam(SOCKET_GROUP).gr_gid)
os.chmod(SOCKET_DIR, 0o2770)
except (OSError, KeyError) as exc:
print(f"vmd: could not set up {SOCKET_DIR}: {exc}", file=sys.stderr)
return 1
# Anything a previous run left behind, before anybody connects. Same
# reasoning as octave/run.sh clearing its run directory at start.
reaped = session_mod.reap(verbose=True)
if reaped:
print(f"vmd: reaped {len(reaped)} leftover(s) from a previous run", file=sys.stderr)
if os.path.exists(SOCKET_PATH):
os.unlink(SOCKET_PATH)
server = Server(SOCKET_PATH, Handler)
os.chmod(SOCKET_PATH, 0o660)
try:
os.chown(SOCKET_PATH, -1, grp.getgrnam(SOCKET_GROUP).gr_gid)
except (OSError, KeyError):
pass
print(f"vmd: listening on {SOCKET_PATH}", file=sys.stderr)
try:
server.serve_forever(poll_interval=0.5)
except KeyboardInterrupt:
pass
finally:
with STATE.lock:
if STATE.session is not None:
session_mod.destroy(STATE.session)
return 0
if __name__ == "__main__":
raise SystemExit(main())