Initial Commit
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
"""Keeps the sub-agent's context inside a budget, cheaply first.
|
||||
|
||||
WHAT THIS IS NOT
|
||||
|
||||
The archived sub-agent stack did not compact. At a fraction of num_ctx it
|
||||
forced the agent to produce a final answer, which is why its long jobs
|
||||
ended abruptly and mid-thought. Compaction here is a genuine behavioural
|
||||
change, not a port of that.
|
||||
|
||||
THE ORDER MATTERS, AND IT IS CHEAPEST-FIRST
|
||||
|
||||
1. Cap every tool result on the way IN. A single `ls -R /` or a failing
|
||||
build's output can be megabytes, and once it is in the transcript it
|
||||
is paid for on every subsequent turn, forever.
|
||||
2. Drop old tool RESULTS, oldest first, leaving a one-line stub saying
|
||||
what was dropped. The agent almost never needs the full output of a
|
||||
command it ran twenty steps ago, but it very often needs to remember
|
||||
that it ran it and what happened.
|
||||
3. Only then summarise, with one model call.
|
||||
|
||||
Steps 1 and 2 are free. Step 3 costs a call AND invalidates the prefix
|
||||
cache, which on a single-slot Ollama means the next turn re-prefills
|
||||
everything - so it is deliberately the last resort rather than the
|
||||
mechanism.
|
||||
|
||||
THE IMAGE RULE IS THE BIGGEST SAVING ONCE THE SCREEN IS USED
|
||||
|
||||
This model has vision, so a screenshot is a real image in the context,
|
||||
not a filename. Keeping the last few costs more than everything else put
|
||||
together. Exactly one is kept - the newest - and older ones become the
|
||||
text the agent itself wrote about them, which is the part it was using
|
||||
anyway.
|
||||
|
||||
WHAT IS NEVER TOUCHED
|
||||
|
||||
The system prompt, the first user message, and the task list. The first
|
||||
two are the prefix the cache is built on; the third is the standard the
|
||||
session is measured against, and a standard that can be summarised away
|
||||
is not one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
# Qwen3's tokeniser on dense English prose, the same figure budget.py
|
||||
# uses in the api container. It is an estimate and is used as one: the
|
||||
# thresholds below have slack for it being wrong by a fifth.
|
||||
CHARS_PER_TOKEN = 3.2
|
||||
|
||||
# A single tool result, on the way in.
|
||||
MAX_RESULT_CHARS = 4000
|
||||
|
||||
# Working-set target. Well under the 163840 the model is loaded with:
|
||||
# the aim is a fast prefill on every step, not to fill the window. A
|
||||
# 40k-token prompt re-prefilled on a handover costs perhaps forty
|
||||
# seconds; a 160k one costs minutes.
|
||||
TARGET_TOKENS = 24_000
|
||||
CEILING_TOKENS = 40_000
|
||||
|
||||
# Recent tool results always survive step 2 verbatim.
|
||||
KEEP_VERBATIM = 8
|
||||
|
||||
|
||||
def estimate_tokens(messages: list[dict[str, Any]]) -> int:
|
||||
total = 0
|
||||
for message in messages:
|
||||
for key in ("content", "thinking"):
|
||||
value = message.get(key)
|
||||
if isinstance(value, str):
|
||||
total += int(len(value) / CHARS_PER_TOKEN)
|
||||
if message.get("tool_calls"):
|
||||
total += int(len(json.dumps(message["tool_calls"])) / CHARS_PER_TOKEN)
|
||||
# Open WebUI's own estimator charges 1000 tokens for an image
|
||||
# part and 4 per message; both are borrowed here rather than
|
||||
# invented, so the two layers at least disagree consistently.
|
||||
total += 1000 * len(message.get("images") or [])
|
||||
total += 4
|
||||
return total
|
||||
|
||||
|
||||
def cap_result(text: str, limit: int = MAX_RESULT_CHARS) -> str:
|
||||
"""Trim one tool result, and SAY that it was trimmed.
|
||||
|
||||
Silent truncation is the failure mode worth avoiding: an agent that
|
||||
thinks it read all of a file behaves very differently from one that
|
||||
knows it read the first 4000 characters of it. Same reasoning as
|
||||
budget.cap() in the api container.
|
||||
"""
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
kept = text[:limit].rsplit("\n", 1)[0] or text[:limit]
|
||||
return (
|
||||
f"{kept}\n\n[TRUNCATED after {len(kept):,} of {len(text):,} characters. "
|
||||
"Re-run with a narrower command - grep, tail, or a specific path - if you "
|
||||
"need the rest; do not assume what was cut.]"
|
||||
)
|
||||
|
||||
|
||||
def _stub_for(message: dict[str, Any]) -> str:
|
||||
label = message.get("_label") or "a tool result"
|
||||
size = len(message.get("content") or "")
|
||||
return f"[dropped to save context: {label}, {size:,} characters]"
|
||||
|
||||
|
||||
def compact(messages: list[dict[str, Any]], protect: int,
|
||||
summarise) -> tuple[list[dict[str, Any]], list[str]]:
|
||||
"""Bring `messages` under budget. Returns the new list and what was done.
|
||||
|
||||
`protect` is how many leading messages are untouchable - the system
|
||||
prompt and the opening task. `summarise` is a callable taking a list
|
||||
of messages and returning prose; it is only invoked if steps 1 and 2
|
||||
were not enough, and it is the only thing here that costs a model call.
|
||||
"""
|
||||
notes: list[str] = []
|
||||
if estimate_tokens(messages) <= TARGET_TOKENS:
|
||||
return messages, notes
|
||||
|
||||
working = list(messages)
|
||||
|
||||
# --- images: keep exactly the newest -------------------------------
|
||||
image_indices = [i for i, m in enumerate(working) if m.get("images")]
|
||||
for index in image_indices[:-1]:
|
||||
message = dict(working[index])
|
||||
count = len(message.get("images") or [])
|
||||
message.pop("images", None)
|
||||
message["content"] = (
|
||||
(message.get("content") or "").strip()
|
||||
+ f"\n[screenshot dropped to save context - {count} image(s). "
|
||||
"Take another with screenshot() if you need to look again.]"
|
||||
).strip()
|
||||
working[index] = message
|
||||
if len(image_indices) > 1:
|
||||
notes.append(f"dropped {len(image_indices) - 1} older screenshot(s)")
|
||||
if estimate_tokens(working) <= TARGET_TOKENS:
|
||||
return working, notes
|
||||
|
||||
# --- old tool results: stub them, oldest first ----------------------
|
||||
tool_indices = [i for i, m in enumerate(working)
|
||||
if m.get("role") == "tool" and i >= protect]
|
||||
droppable = tool_indices[:-KEEP_VERBATIM] if len(tool_indices) > KEEP_VERBATIM else []
|
||||
dropped = 0
|
||||
for index in droppable:
|
||||
if estimate_tokens(working) <= TARGET_TOKENS:
|
||||
break
|
||||
if len(working[index].get("content") or "") < 200:
|
||||
continue # not worth the churn
|
||||
message = dict(working[index])
|
||||
message["content"] = _stub_for(message)
|
||||
working[index] = message
|
||||
dropped += 1
|
||||
if dropped:
|
||||
notes.append(f"summarised {dropped} old tool result(s) to one line each")
|
||||
if estimate_tokens(working) <= CEILING_TOKENS:
|
||||
return working, notes
|
||||
|
||||
# --- last resort: one model call ------------------------------------
|
||||
tail_start = max(protect, len(working) - (KEEP_VERBATIM * 2))
|
||||
head, tail = working[protect:tail_start], working[tail_start:]
|
||||
if not head:
|
||||
return working, notes
|
||||
prose = summarise(head)
|
||||
rebuilt = working[:protect] + [{
|
||||
"role": "user",
|
||||
"content": (
|
||||
"PROGRESS SO FAR (an automatic summary of earlier steps in this "
|
||||
"same session - it replaces them, and they are gone):\n\n" + prose
|
||||
),
|
||||
}] + tail
|
||||
notes.append(f"summarised {len(head)} earlier messages into one block")
|
||||
|
||||
# Even after a summary the tail can be over budget - a handful of
|
||||
# very large results is enough. Keep stubbing from the oldest, always
|
||||
# leaving the last two intact, because those are what the agent is
|
||||
# actually working from right now. Without this the ceiling is a
|
||||
# suggestion rather than a ceiling, which is how a session ends with
|
||||
# a context error instead of an answer.
|
||||
tail_tools = [i for i, m in enumerate(rebuilt)
|
||||
if m.get("role") == "tool" and i >= protect + 1]
|
||||
extra = 0
|
||||
for index in tail_tools[:-2]:
|
||||
if estimate_tokens(rebuilt) <= CEILING_TOKENS:
|
||||
break
|
||||
if len(rebuilt[index].get("content") or "") < 200:
|
||||
continue
|
||||
message = dict(rebuilt[index])
|
||||
message["content"] = _stub_for(message)
|
||||
rebuilt[index] = message
|
||||
extra += 1
|
||||
if extra:
|
||||
notes.append(f"stubbed {extra} more result(s) to stay under the ceiling")
|
||||
return rebuilt, notes
|
||||
|
||||
|
||||
SUMMARY_PROMPT = """\
|
||||
Summarise the work so far in this VM session, for your own use in the steps ahead.
|
||||
|
||||
Write it as notes to yourself, not as a report to anyone else. Include, in this order:
|
||||
|
||||
1. What has actually been done, and what the machine said when it was done.
|
||||
2. What was tried and did NOT work, with the error - so it is not tried again.
|
||||
3. The current state of the machine: what is installed, what files exist, what is running.
|
||||
4. Decisions taken and why, so they are not relitigated.
|
||||
|
||||
Be specific about names, paths, versions and exit codes; those are the details that get \
|
||||
lost and then invented. Leave out everything you would not need again. No preamble.
|
||||
"""
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Live progress, written to a file for the Open WebUI filter to collect.
|
||||
|
||||
WHY A FILE AND NOT A POST TO OPEN WEBUI
|
||||
|
||||
The first version of this posted straight to
|
||||
`/api/v1/chats/{id}/messages/{id}/event`, which works and needs an Open
|
||||
WebUI **admin** API key - because anyone may invoke vm_agent, the chat
|
||||
belongs to whoever is talking, and that endpoint refuses a key whose user
|
||||
neither owns the chat nor is an admin. An admin key living in a container
|
||||
is an agent that could read every conversation on the instance. It was
|
||||
kept out of the agent itself (nginx held it), but it still existed.
|
||||
|
||||
So it does not exist any more. The agent writes progress to the job
|
||||
volume it already shares with the api container, and a filter INSIDE Open
|
||||
WebUI reads it and emits the events itself - where `__event_emitter__` is
|
||||
already bound to the requesting user's own session and no credential is
|
||||
involved at any point.
|
||||
|
||||
The trade is that progress is polled rather than pushed, and the cost of
|
||||
that is a 2-second poll inside Open WebUI's event loop for the length of
|
||||
a session. That is the whole of it.
|
||||
|
||||
WHAT REACHES THE MODEL: NOTHING
|
||||
|
||||
Open WebUI stores a message's status history and its `embeds` as SIBLING
|
||||
fields, and rebuilds the payload sent to the model from an allowlist -
|
||||
role, thinking, content, tool_calls, tool_call_id, images. Neither is on
|
||||
it. So a screenshot posted this way is seen by the person watching and
|
||||
never by the model that called the tool. That is why this exists at all
|
||||
rather than returning images from the tool: a tool result that IS a data:
|
||||
URI goes to the MODEL as a vision input, which is the opposite of what is
|
||||
wanted here.
|
||||
|
||||
FAILURE IS ALWAYS SILENT
|
||||
|
||||
A progress update that cannot be written must never break a session. The
|
||||
user loses the live view of a job that is otherwise running perfectly,
|
||||
which is a bad trade to make the other way round.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
log = logging.getLogger("vmagent.events")
|
||||
|
||||
JOBS = os.environ.get("OWUITOOLS_VM_JOBS_DIR", "/vmjobs")
|
||||
PROGRESS_DIR = os.path.join(JOBS, "progress")
|
||||
|
||||
# Chat ids are Open WebUI UUIDs and become a filename here, so they are
|
||||
# checked rather than trusted - the same discipline as everywhere else
|
||||
# in this suite that turns a name into a path.
|
||||
CHAT_ID_RE = re.compile(r"^[0-9a-fA-F-]{36}$")
|
||||
|
||||
# Twenty lines, fixed. Enough to see a command and what it said, short
|
||||
# enough that the view does not become the conversation - and a constant,
|
||||
# so the box does not jump about as the session runs.
|
||||
TERMINAL_LINES = 20
|
||||
# The live line above the task list, and how often it may be written.
|
||||
# Short enough that the tail of it is what a one-line box actually shows:
|
||||
# the text is already only the newest characters, and anything past the
|
||||
# width of the box is clipped, so a longer cap only lags further behind.
|
||||
SPEECH_CHARS = 160
|
||||
SPEECH_SECONDS = 1.0
|
||||
|
||||
# Every screenshot write is ~80 KB on a 16 MB tmpfs and every read is a
|
||||
# poll from Open WebUI. Once every few seconds is plenty for watching a
|
||||
# window open; the agent takes them far more often than that.
|
||||
MIN_SCREENSHOT_INTERVAL = 3.0
|
||||
|
||||
JPEG_QUALITY = 55
|
||||
MAX_SHOT_BYTES = 400_000
|
||||
|
||||
|
||||
class Events:
|
||||
def __init__(self, chat_id: str | None, message_id: str | None,
|
||||
interface: str = "cli") -> None:
|
||||
self.chat_id = (chat_id or "").strip()
|
||||
self.enabled = bool(CHAT_ID_RE.fullmatch(self.chat_id))
|
||||
self.path = os.path.join(PROGRESS_DIR, f"{self.chat_id}.json") if self.enabled else ""
|
||||
self._seq = 0
|
||||
self._shot_seq = 0
|
||||
self._shot_b64 = ""
|
||||
self._shot_caption = ""
|
||||
self._last_shot = 0.0
|
||||
self._status = ""
|
||||
self._done = False
|
||||
# THE TWO VIEWS, AND WHY A SESSION ONLY EVER GETS ONE.
|
||||
#
|
||||
# A CLI session has nothing to photograph, and a GUI session's
|
||||
# screen already shows whatever terminal it opened. Sending both
|
||||
# would put a picture of a desktop next to a transcript of
|
||||
# commands that are not on it.
|
||||
# Kept only so the progress file still says what kind of session
|
||||
# this was; there is one kind now, and nothing branches on it.
|
||||
self.interface = interface if interface in ("cli", "gui") else "gui"
|
||||
# ONE HEIGHT. There used to be two, because a desktop session
|
||||
# showed a screen above the feed and the feed was halved to make
|
||||
# room. The screen now appears only once the agent has actually
|
||||
# taken a picture, so the feed is not competing with something
|
||||
# that may never arrive.
|
||||
self._terminal: deque[str] = deque(maxlen=TERMINAL_LINES)
|
||||
self._terminal_seq = 0
|
||||
self._tasks = ""
|
||||
# What the model is saying, right now, in one line. See speech().
|
||||
self._speech = ""
|
||||
self._speech_at = 0.0
|
||||
if not self.enabled:
|
||||
log.info("live progress disabled: no usable chat id was forwarded")
|
||||
return
|
||||
try:
|
||||
os.makedirs(PROGRESS_DIR, mode=0o700, exist_ok=True)
|
||||
except OSError as exc:
|
||||
log.warning("cannot create %s: %s", PROGRESS_DIR, exc)
|
||||
self.enabled = False
|
||||
|
||||
# -- writing -------------------------------------------------------
|
||||
def _flush(self) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
self._seq += 1
|
||||
payload = {
|
||||
"seq": self._seq,
|
||||
"status": self._status,
|
||||
"tasks": self._tasks,
|
||||
"speech": self._speech,
|
||||
"interface": self.interface,
|
||||
"terminal": list(self._terminal),
|
||||
"terminal_seq": self._terminal_seq,
|
||||
"done": self._done,
|
||||
"shot_seq": self._shot_seq,
|
||||
"shot": self._shot_b64,
|
||||
"caption": self._shot_caption,
|
||||
"updated": round(time.time(), 2),
|
||||
}
|
||||
try:
|
||||
# Written whole and renamed into place: the reader is a
|
||||
# separate process polling this path, and a half-written JSON
|
||||
# file is the one thing that would make it throw rather than
|
||||
# simply see nothing new.
|
||||
tmp = self.path + ".tmp"
|
||||
with open(tmp, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle)
|
||||
os.replace(tmp, self.path)
|
||||
except OSError as exc:
|
||||
log.warning("could not write progress: %s", exc)
|
||||
|
||||
# -- text ----------------------------------------------------------
|
||||
def status(self, description: str, done: bool = False, force: bool = False) -> None:
|
||||
if not force and description == self._status and done == self._done:
|
||||
return
|
||||
self._status = description
|
||||
self._done = done
|
||||
self._flush()
|
||||
|
||||
def tasks(self, rendered: str) -> None:
|
||||
"""The list the user watches the session against.
|
||||
|
||||
Above everything else in the view on purpose: a status line says
|
||||
what is happening this second, and the list says whether that
|
||||
amounts to progress.
|
||||
"""
|
||||
rendered = (rendered or "").strip()
|
||||
if rendered and rendered != self._tasks:
|
||||
self._tasks = rendered[:2000]
|
||||
self._flush()
|
||||
|
||||
def speech(self, text: str) -> None:
|
||||
"""The one line above the task list: what the model is saying now.
|
||||
|
||||
Rewritten rather than appended, and flushed at most once a second.
|
||||
The tokens arrive far faster than anybody reads and faster than
|
||||
the watcher polls, so writing the file per token would be work
|
||||
nobody sees. The tail is kept rather than the head - the end of
|
||||
the sentence is the part that is still moving.
|
||||
"""
|
||||
if not self.enabled:
|
||||
return
|
||||
line = " ".join(str(text or "").split())[-SPEECH_CHARS:]
|
||||
now = time.time()
|
||||
if line == self._speech or now - self._speech_at < SPEECH_SECONDS:
|
||||
return
|
||||
self._speech = line
|
||||
self._speech_at = now
|
||||
self._flush()
|
||||
|
||||
def terminal(self, *lines: str) -> None:
|
||||
"""Append to the rolling view of what the agent is doing.
|
||||
|
||||
Capped at the deque's own maxlen, which is a `deque` rather
|
||||
than a trim-on-read so the cost is bounded at the point where the
|
||||
lines arrive - a command that prints a megabyte does not get to
|
||||
put a megabyte through this file on its way to being cut down.
|
||||
"""
|
||||
if not self.enabled:
|
||||
return
|
||||
added = False
|
||||
for line in lines:
|
||||
for part in str(line).replace("\r", "").split("\n"):
|
||||
self._terminal.append(part[:200])
|
||||
added = True
|
||||
if added:
|
||||
self._terminal_seq += 1
|
||||
self._flush()
|
||||
|
||||
def heartbeat(self) -> None:
|
||||
"""Re-stamp the file so the watcher can tell alive from wedged.
|
||||
|
||||
A thinking model on a long context is quiet for minutes at a
|
||||
time, and "no new status" and "the agent has died" look identical
|
||||
without this.
|
||||
"""
|
||||
self._flush()
|
||||
|
||||
# -- pictures ------------------------------------------------------
|
||||
def screenshot(self, png_bytes: bytes, caption: str = "") -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
now = time.monotonic()
|
||||
if now - self._last_shot < MIN_SCREENSHOT_INTERVAL:
|
||||
return
|
||||
self._last_shot = now
|
||||
encoded = _to_jpeg_b64(png_bytes)
|
||||
if not encoded:
|
||||
return
|
||||
self._shot_b64 = encoded
|
||||
self._shot_caption = (caption or "")[:200]
|
||||
self._shot_seq += 1
|
||||
self._flush()
|
||||
|
||||
def finish(self) -> None:
|
||||
"""Mark the session over and drop the picture.
|
||||
|
||||
The status line stays - the watcher shows it as the final state -
|
||||
but the screenshot goes, so a stale frame of a machine that no
|
||||
longer exists is not left on screen.
|
||||
"""
|
||||
self._shot_b64 = ""
|
||||
self._shot_seq += 1
|
||||
# The terminal stays. It is a record of what was done, and the
|
||||
# last thing on it is usually the reason the session ended.
|
||||
self._done = True
|
||||
self._flush()
|
||||
|
||||
def clear_embeds(self) -> None:
|
||||
self.finish()
|
||||
|
||||
|
||||
def _to_jpeg_b64(png_bytes: bytes) -> str:
|
||||
"""PNG in, JPEG out, because size is the constraint here.
|
||||
|
||||
Every write lands on a 16 MB tmpfs and every read crosses into Open
|
||||
WebUI. A desktop screenshot as PNG is a few hundred KB before base64
|
||||
adds a third; JPEG at modest quality is an order of magnitude smaller
|
||||
and entirely good enough for a human watching a window open.
|
||||
|
||||
Pillow is optional: without it a small enough PNG is passed through
|
||||
and a large one is skipped. A missing dependency should cost the live
|
||||
view, not the session.
|
||||
"""
|
||||
try:
|
||||
from PIL import Image # noqa: PLC0415
|
||||
except ImportError:
|
||||
if len(png_bytes) <= MAX_SHOT_BYTES:
|
||||
return "data:image/png;base64," + base64.b64encode(png_bytes).decode()
|
||||
return ""
|
||||
try:
|
||||
image = Image.open(io.BytesIO(png_bytes)).convert("RGB")
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="JPEG", quality=JPEG_QUALITY, optimize=True)
|
||||
data = buffer.getvalue()
|
||||
if len(data) > MAX_SHOT_BYTES:
|
||||
image.thumbnail((960, 960))
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="JPEG", quality=45, optimize=True)
|
||||
data = buffer.getvalue()
|
||||
return "data:image/jpeg;base64," + base64.b64encode(data).decode()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("could not encode a screenshot: %s", exc)
|
||||
return ""
|
||||
@@ -0,0 +1,704 @@
|
||||
"""The sub-agent's turn loop.
|
||||
|
||||
WHAT MAKES THIS DIFFERENT FROM THE ATTEMPT THAT WAS RETIRED
|
||||
|
||||
The archived sub-agent orchestrator was torn down for accuracy, and its
|
||||
post-mortem names two mechanisms. Both are answered here, and neither is
|
||||
answered in the prompt - they are answered by things the model cannot
|
||||
talk its way past:
|
||||
|
||||
* "hallucinated library APIs and wrote self-confirming tests" ->
|
||||
finish() is refused until every task is closed, and the verification
|
||||
command it supplies is RE-RUN by vmd afterwards. The exit code the
|
||||
user sees comes from that second run.
|
||||
* "agents allowed to shortcut their tools guessed instead" ->
|
||||
plan_tasks must come first and finish() is refused if nothing was
|
||||
ever run. There is no path from "start" to "done" that does not go
|
||||
through the machine.
|
||||
|
||||
It also compacts rather than cutting the session short. The old loop
|
||||
forced a final answer at a fraction of num_ctx, which is why long jobs
|
||||
ended mid-thought.
|
||||
|
||||
EVERY EXIT PRODUCES A RESULT
|
||||
|
||||
A step cap, a deadline, a cancellation, an unreachable model, a wedged
|
||||
machine - each of them ends with something for the user to read. A
|
||||
session that returns nothing is the worst outcome available, because the
|
||||
work happened and the account of it was thrown away.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import compact
|
||||
import events as events_mod
|
||||
import ollama
|
||||
import tools as tools_mod
|
||||
from client import Vmd, VmdError, VmdUnavailable
|
||||
|
||||
log = logging.getLogger("vmagent.loop")
|
||||
|
||||
# ONE BUDGET, AND IT IS THE GENEROUS ONE.
|
||||
#
|
||||
# Seeing the result of an action on screen is its own turn - look, wait,
|
||||
# click, look again - where a command returns its result in the same one.
|
||||
# A session that built a working 259-line application in nine steps then
|
||||
# used its remaining twenty looking at the screen and stopped one
|
||||
# screenshot short of proving it worked. Every session can now reach for
|
||||
# the screen, so every session gets the budget that allows for it.
|
||||
MAX_STEPS = int(os.environ.get("OWUITOOLS_VM_MAX_STEPS", "110"))
|
||||
SESSION_SECONDS = float(os.environ.get("OWUITOOLS_VM_SESSION_SECONDS", "7200"))
|
||||
# How many turns in a row the model may answer with prose and no tool
|
||||
# call before it is finished for it. Three is enough to recover from one
|
||||
# confused turn and not enough to spend a session talking.
|
||||
MAX_STALLS = 3
|
||||
# Same command, same error, this many times -> say so, in tool output.
|
||||
REPEAT_LIMIT = 3
|
||||
# How many steps may pass without the task list moving before the agent is
|
||||
# reminded that somebody is reading it. See the note at the use site.
|
||||
TASK_DRIFT_LIMIT = int(os.environ.get("OWUITOOLS_VM_TASK_DRIFT", "12"))
|
||||
TASK_VERBS = frozenset({"plan_tasks", "update_task", "add_task"})
|
||||
|
||||
|
||||
@dataclass
|
||||
class Job:
|
||||
task: str
|
||||
os_name: str
|
||||
interface: str
|
||||
project: str | None
|
||||
chat_id: str = ""
|
||||
message_id: str = ""
|
||||
owner: str = ""
|
||||
answer: str = ""
|
||||
deadline: float = field(default_factory=lambda: time.monotonic() + SESSION_SECONDS)
|
||||
|
||||
|
||||
class Outcome:
|
||||
"""What the loop hands back. Always populated, whatever happened."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.report: str = ""
|
||||
self.state: str = "done" # done | question | unfreeze | cancelled | failed
|
||||
self.steps: int = 0
|
||||
self.notes: list[str] = []
|
||||
|
||||
|
||||
def run(job: Job) -> Outcome:
|
||||
outcome = Outcome()
|
||||
ui = events_mod.Events(job.chat_id, job.message_id, job.interface)
|
||||
ui.status("Starting a machine…", force=True)
|
||||
|
||||
try:
|
||||
vmd = Vmd()
|
||||
vmd.connect()
|
||||
except VmdUnavailable as exc:
|
||||
outcome.state = "failed"
|
||||
outcome.report = (
|
||||
f"FAILED: the VM service is not available ({exc}). No machine was started "
|
||||
"and nothing was run. This is a fault on this host, not a problem with the "
|
||||
"request."
|
||||
)
|
||||
ui.status("Could not start a machine", done=True, force=True)
|
||||
return outcome
|
||||
|
||||
try:
|
||||
# Does not wait for the boot: the planning turn below happens
|
||||
# while the machine comes up, which also gets the model loaded and
|
||||
# its prompt prefilled before there is anything to do with it.
|
||||
# The job text goes with it so vmd can label the repository from
|
||||
# the work rather than from a model's prose. vmd derives the one
|
||||
# line it uses; nothing here decides the wording.
|
||||
opened = vmd.call("session_open", os=job.os_name, project=job.project,
|
||||
owner=job.owner, task=job.task, wait=False,
|
||||
chat_id=job.chat_id)
|
||||
except (VmdError, VmdUnavailable) as exc:
|
||||
outcome.state = "failed"
|
||||
outcome.report = f"FAILED: could not start the machine. {exc}"
|
||||
ui.status("Could not start a machine", done=True, force=True)
|
||||
vmd.close()
|
||||
return outcome
|
||||
|
||||
ui.status(f"Booting a {job.os_name} machine - planning while it starts", force=True)
|
||||
|
||||
try:
|
||||
_converse(vmd, job, ui, outcome, opened)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("the agent loop failed")
|
||||
outcome.state = "failed"
|
||||
outcome.notes.append(f"the agent loop stopped unexpectedly: {type(exc).__name__}: {exc}")
|
||||
finally:
|
||||
# ORDER MATTERS. session_close is what commits anything left
|
||||
# uncommitted and pushes to Gitea, so the report has to be built
|
||||
# after it or the link is never in it. The machine is destroyed
|
||||
# inside the same call, which is fine: the report is assembled
|
||||
# from vmd's own record of the session, not from the machine.
|
||||
try:
|
||||
vmd.call("session_close")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.warning("session_close failed: %s", exc)
|
||||
try:
|
||||
outcome.report = vmd.call("report")["text"]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
outcome.report = outcome.report or f"FAILED: could not assemble a report ({exc})."
|
||||
ui.clear_embeds()
|
||||
ui.status("Done" if outcome.state == "done" else f"Stopped: {outcome.state}",
|
||||
done=True, force=True)
|
||||
vmd.close()
|
||||
|
||||
if outcome.notes:
|
||||
outcome.report += "\n\n" + "\n".join(f"_{n}_" for n in outcome.notes)
|
||||
return outcome
|
||||
|
||||
|
||||
def _opening_message(job: Job, opened: dict[str, Any]) -> str:
|
||||
machine = "Ubuntu" if job.os_name == "ubuntu" else "Windows"
|
||||
lines = [f"Task: {job.task}", ""]
|
||||
lines.append(
|
||||
f"A {machine} machine is booting for you right now. It is not ready yet, and you "
|
||||
"have no way to reach it until it is."
|
||||
)
|
||||
lines.append(
|
||||
"Write your task list while it comes up. That is the whole of this turn: call "
|
||||
"plan_tasks and stop. You will be told the moment the machine is ready, and you "
|
||||
"can start then - nothing is lost by planning first, and the machine would "
|
||||
"otherwise be booting while you sat idle."
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"You get its command line and its desktop. Most work is the command line; the "
|
||||
"screen is there when something has to be seen or clicked."
|
||||
)
|
||||
if opened.get("share"):
|
||||
lines.append(
|
||||
f"The project folder is /mnt/project (project '{job.project}'). It is the only "
|
||||
"thing that survives this session."
|
||||
)
|
||||
if opened.get("storage"):
|
||||
lines.append(opened["storage"])
|
||||
else:
|
||||
lines.append("There is no project folder, so nothing will be kept when this ends.")
|
||||
if job.answer:
|
||||
lines += ["", f"The user answered your question: {job.answer}"]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _converse(vmd: Vmd, job: Job, ui: events_mod.Events, outcome: Outcome,
|
||||
opened: dict[str, Any]) -> None:
|
||||
system_prompt = _load_prompt(job.os_name)
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": _opening_message(job, opened)},
|
||||
]
|
||||
PROTECTED = 2 # never compacted: the prompt and the task
|
||||
|
||||
# Phase one: the machine is booting and only the planning tools are
|
||||
# offered. Phase two starts when the agent has a list and the machine
|
||||
# answers - see _hand_over_the_machine.
|
||||
booting = True
|
||||
schemas = tools_mod.schemas(job.os_name, booting=True)
|
||||
|
||||
stalls = 0
|
||||
since_task = 0
|
||||
repeats: dict[str, int] = {}
|
||||
|
||||
for step in range(MAX_STEPS):
|
||||
outcome.steps = step + 1
|
||||
|
||||
# --- the things that end a session, checked every turn ---------
|
||||
if _cancelled(vmd):
|
||||
outcome.state = "cancelled"
|
||||
outcome.notes.append("the session was cancelled.")
|
||||
return
|
||||
if time.monotonic() > job.deadline:
|
||||
outcome.state = "failed"
|
||||
outcome.notes.append(
|
||||
f"the session hit its time limit after {step} steps and was stopped. "
|
||||
"What had been committed is kept."
|
||||
)
|
||||
return
|
||||
|
||||
# --- keep the context affordable --------------------------------
|
||||
before = compact.estimate_tokens(messages)
|
||||
if before > compact.TARGET_TOKENS:
|
||||
messages, notes = compact.compact(messages, PROTECTED, _summariser())
|
||||
if notes:
|
||||
log.info("compacted %d -> %d tokens: %s",
|
||||
before, compact.estimate_tokens(messages), "; ".join(notes))
|
||||
|
||||
ui.heartbeat()
|
||||
try:
|
||||
# The only streaming call. The compaction summariser below is
|
||||
# left alone: nobody is watching that, and it is the one call
|
||||
# whose output is machinery rather than the agent talking.
|
||||
reply = ollama.chat(messages, tools=schemas, on_text=ui.speech)
|
||||
except ollama.OllamaError as exc:
|
||||
outcome.state = "failed"
|
||||
outcome.notes.append(f"the model became unreachable: {exc}")
|
||||
return
|
||||
|
||||
assistant: dict[str, Any] = {"role": "assistant", "content": reply["content"]}
|
||||
if reply["tool_calls"]:
|
||||
assistant["tool_calls"] = reply["tool_calls"]
|
||||
# INTO THE SESSION LOG, BESIDE WHAT IT THEN DID.
|
||||
#
|
||||
# vmd records every verb it serves, which is what the agent DID;
|
||||
# this is what it said it was doing. Interleaved they read as an
|
||||
# account of the session rather than a list of calls. Suppressed
|
||||
# rather than checked: a log is never worth a step.
|
||||
if reply["content"].strip():
|
||||
with contextlib.suppress(Exception):
|
||||
vmd.call("note", turn=step + 1, text=reply["content"])
|
||||
# The thinking is deliberately NOT kept. It is the largest thing
|
||||
# the model produces, it is not needed to continue, and carrying
|
||||
# it forward would fill the window with reasoning about steps
|
||||
# that are already done.
|
||||
messages.append(assistant)
|
||||
|
||||
if not reply["tool_calls"]:
|
||||
stalls += 1
|
||||
if stalls >= MAX_STALLS:
|
||||
outcome.state = "failed"
|
||||
outcome.notes.append(
|
||||
"the agent stopped calling tools without finishing. What it had done "
|
||||
"up to that point is below."
|
||||
)
|
||||
return
|
||||
messages.append({"role": "user", "content": _nudge(vmd)})
|
||||
continue
|
||||
stalls = 0
|
||||
|
||||
for call in reply["tool_calls"]:
|
||||
function = call.get("function") or {}
|
||||
name = str(function.get("name", ""))
|
||||
arguments = function.get("arguments") or {}
|
||||
if isinstance(arguments, str):
|
||||
try:
|
||||
arguments = json.loads(arguments)
|
||||
except ValueError:
|
||||
arguments = {}
|
||||
|
||||
ui.status(_status_for(vmd, name, arguments), force=(name in ("run", "finish")))
|
||||
_show(ui, _terminal_for, name, arguments)
|
||||
text, image = tools_mod.call(vmd, name, arguments)
|
||||
|
||||
# A model that runs the same failing command over and over is
|
||||
# not going to stop being told so in the system prompt; it is
|
||||
# told here, where it reads instructions as fact.
|
||||
if name == "run":
|
||||
key = f"{arguments.get('command','')}|{text[:200]}"
|
||||
repeats[key] = repeats.get(key, 0) + 1
|
||||
if repeats[key] >= REPEAT_LIMIT:
|
||||
text += (
|
||||
f"\n\n[You have now run this command {repeats[key]} times and got the "
|
||||
"same result. It will not change. Either try something different, or "
|
||||
"mark the task blocked with this as the reason.]"
|
||||
)
|
||||
|
||||
# THE LIST IS THE ONLY THING THE WATCHER CAN READ.
|
||||
#
|
||||
# An agent that plans twelve tasks and then does the work in one
|
||||
# long stretch without touching the list leaves the person
|
||||
# watching on "1/12" for twenty minutes - measured - while the
|
||||
# file grows and the status line says nothing. The work was
|
||||
# fine; the report of it was a flat line.
|
||||
#
|
||||
# Said here rather than in the prompt, because the prompt
|
||||
# already asks for it and asking has not worked. It is a nudge,
|
||||
# not a refusal: being mid-task is legitimate, and a tool that
|
||||
# blocked on it would be wrong far more often than right.
|
||||
if name in TASK_VERBS:
|
||||
since_task = 0
|
||||
elif (since_task := since_task + 1) >= TASK_DRIFT_LIMIT:
|
||||
since_task = 0
|
||||
text += (
|
||||
f"\n\n[{TASK_DRIFT_LIMIT} steps have passed without your task list "
|
||||
"moving. Whoever asked for this is watching that list and it still says "
|
||||
"you are where you started. Mark what you have finished, mark what you "
|
||||
"are on now, and add anything you have taken on that was not in the "
|
||||
"plan.]"
|
||||
)
|
||||
|
||||
_show(ui, _terminal_result, name, text)
|
||||
|
||||
message: dict[str, Any] = {"role": "tool", "content": compact.cap_result(text),
|
||||
"_label": name}
|
||||
if image is not None:
|
||||
message["images"] = [_b64(image)]
|
||||
ui.screenshot(image, caption=_status_for(vmd, name, arguments))
|
||||
messages.append(message)
|
||||
|
||||
if name == "finish" and not text.startswith(("REFUSED", "FAILED")):
|
||||
outcome.state = "done"
|
||||
return
|
||||
|
||||
# --- hand the machine over, once there is a plan to work to -----
|
||||
if booting and _status(vmd).get("planned"):
|
||||
handover = _hand_over_the_machine(vmd, job, ui, outcome)
|
||||
if handover is None:
|
||||
return # the machine never came up
|
||||
messages.append(handover)
|
||||
booting = False
|
||||
schemas = tools_mod.schemas(job.os_name, booting=False)
|
||||
|
||||
# --- a pending question outranks continuing ---------------------
|
||||
state = _status(vmd)
|
||||
if state.get("question"):
|
||||
outcome.state = "question"
|
||||
return
|
||||
if state.get("unfreeze_request"):
|
||||
outcome.state = "unfreeze"
|
||||
return
|
||||
if state.get("status_line"):
|
||||
ui.status(state["status_line"])
|
||||
ui.tasks(state.get("tasks", ""))
|
||||
|
||||
outcome.state = "failed"
|
||||
outcome.notes.append(
|
||||
f"the agent used all {MAX_STEPS} of its steps without finishing. What it did is below."
|
||||
)
|
||||
|
||||
|
||||
def _hand_over_the_machine(vmd: Vmd, job: Job, ui: events_mod.Events,
|
||||
outcome: Outcome) -> dict[str, Any] | None:
|
||||
"""Wait for the boot to finish, then tell the agent it may start.
|
||||
|
||||
By the time this runs the agent has already spent a turn planning, so
|
||||
most or all of the boot has happened underneath it. What is left is
|
||||
usually a few seconds. The screenshot is taken here rather than left
|
||||
for the agent to ask for: a GUI session's first act is always to look
|
||||
at the screen, and doing it now saves a whole model turn.
|
||||
"""
|
||||
ui.status("Waiting for the machine to finish booting", force=True)
|
||||
try:
|
||||
# NOT desktop=True. The graphical session is waited for lazily, on
|
||||
# the first screen verb, so a job that never looks at the screen
|
||||
# never pays for GNOME finishing its paint. See vmd._require_desktop.
|
||||
ready = vmd.call("session_ready", timeout=300)
|
||||
except (VmdError, VmdUnavailable) as exc:
|
||||
outcome.state = "failed"
|
||||
outcome.notes.append(f"the machine never finished booting: {exc}")
|
||||
return None
|
||||
|
||||
machine = "Ubuntu" if job.os_name == "ubuntu" else "Windows"
|
||||
took = ready.get("seconds", 0)
|
||||
lines = [
|
||||
f"The {machine} machine is up and you can reach it now"
|
||||
+ (f" (it took {took:.0f}s, most of which was while you were planning)."
|
||||
if took else "."),
|
||||
]
|
||||
# What is actually in the machine, read from the machine. Cheaper than
|
||||
# the three turns an agent otherwise spends finding out whether python
|
||||
# is there, what the browser is called, and where the project folder
|
||||
# is - and it cannot drift from the image the way a prompt would.
|
||||
facts = (ready.get("facts") or "").strip()
|
||||
if facts:
|
||||
lines.append("What is in it:\n" + facts)
|
||||
lines.append("Work through your task list. Mark each one in_progress as you start it.")
|
||||
|
||||
# NO SCREENSHOT IS TAKEN HERE ANY MORE.
|
||||
#
|
||||
# Handing one over unasked put a picture of an idle desktop in front of
|
||||
# the reader before the session had done anything, and opened the
|
||||
# preview pane for every job including the ones that never look at a
|
||||
# screen. The operator's words: pop up the preview only when the agent
|
||||
# takes a screenshot. So the first picture in the chat is now one the
|
||||
# agent chose to take, which also makes it worth looking at.
|
||||
message: dict[str, Any] = {"role": "user", "content": "\n".join(lines)}
|
||||
return message
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# helpers
|
||||
# ---------------------------------------------------------------------
|
||||
def _load_prompt(os_name: str) -> str:
|
||||
"""The prompt for THIS machine, not for machines in general.
|
||||
|
||||
TWO MACHINES, TWO PROMPTS. Ubuntu or Windows, and the differences are
|
||||
not decoration: the project folder is `/mnt/project` or `Z:\\`, and the
|
||||
shell is bash or PowerShell 5.1 - which has no `&&`.
|
||||
|
||||
Sending one prompt for both made the agent reason about which half
|
||||
applied to it. Measured: a Windows session wrote itself the task
|
||||
"check /mnt/project accessibility", because the prompt said that was
|
||||
where work survives and nothing told it otherwise. It cost a step and
|
||||
would have cost the file if the path had been writable.
|
||||
|
||||
The screen section is the same for both, because every session has one.
|
||||
|
||||
WHERE THESE COME FROM, AND WHY THE FIRST ONE IS NOT OPTIONAL.
|
||||
|
||||
They are bind-mounted from configs/agent rather than baked into the
|
||||
image, so rewording one is a restart instead of a rebuild. That is
|
||||
worth having and it also introduces a way for them to be MISSING that
|
||||
did not exist before - a mount that was never declared, or a path
|
||||
typed wrong - where previously only a broken build could do it.
|
||||
|
||||
So the base prompt is required and a missing one raises. An agent
|
||||
running with no prompt at all still calls tools and still reports
|
||||
success, which is this subsystem's characteristic failure: it looks
|
||||
like it worked. Refusing to start is the cheaper end of that trade.
|
||||
|
||||
The per-machine and screen fragments keep the old policy and are
|
||||
skipped with a warning, because a thinner prompt genuinely does still
|
||||
run a session.
|
||||
"""
|
||||
# Same resolution as tools.py: the env var in the container, where
|
||||
# configs/agent is bind-mounted at /configs/agent; otherwise the
|
||||
# tree's own copy, so the tests and anyone reading the repo need no
|
||||
# environment set up.
|
||||
here = (os.environ.get("OWUITOOLS_AGENT_CONFIG_DIR")
|
||||
or os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||
"..", "..", "configs", "agent"))
|
||||
try:
|
||||
with open(os.path.join(here, "prompt.md"), encoding="utf-8") as handle:
|
||||
parts = [handle.read().rstrip()]
|
||||
except OSError as exc:
|
||||
raise RuntimeError(
|
||||
f"the agent's base prompt is not readable at {here}/prompt.md ({exc}). "
|
||||
"configs/agent is mounted there by docker-compose.yml; without it the agent "
|
||||
"would run with no instructions and still report success."
|
||||
) from exc
|
||||
for name in (f"prompt.{os_name}.md", "prompt.screen.md"):
|
||||
try:
|
||||
with open(os.path.join(here, name), encoding="utf-8") as handle:
|
||||
parts.append(handle.read().rstrip())
|
||||
except OSError:
|
||||
log.warning("prompt fragment %s is missing; continuing without it", name)
|
||||
return "\n\n".join(parts) + "\n"
|
||||
|
||||
|
||||
def _summariser():
|
||||
def summarise(head: list[dict[str, Any]]) -> str:
|
||||
try:
|
||||
reply = ollama.chat(
|
||||
[{"role": "system", "content": compact.SUMMARY_PROMPT},
|
||||
{"role": "user", "content": _transcribe(head)}],
|
||||
tools=None, think=False,
|
||||
)
|
||||
return reply["content"].strip() or "(the summary came back empty)"
|
||||
except ollama.OllamaError as exc:
|
||||
# Degrade to a mechanical list rather than losing the session.
|
||||
# Open WebUI's own compactor does the same when its summary
|
||||
# call fails, for the same reason.
|
||||
log.warning("summary call failed, falling back to a list: %s", exc)
|
||||
return _transcribe(head)[:4000]
|
||||
return summarise
|
||||
|
||||
|
||||
def _transcribe(messages: list[dict[str, Any]]) -> str:
|
||||
lines = []
|
||||
for message in messages:
|
||||
role = message.get("role", "?")
|
||||
label = message.get("_label")
|
||||
content = (message.get("content") or "")[:1500]
|
||||
lines.append(f"[{role}{'/' + label if label else ''}] {content}")
|
||||
return "\n\n".join(lines)
|
||||
|
||||
|
||||
def _b64(data: bytes) -> str:
|
||||
import base64
|
||||
return base64.b64encode(data).decode()
|
||||
|
||||
|
||||
def _cancelled(vmd: Vmd) -> bool:
|
||||
return bool(_status(vmd).get("cancelled"))
|
||||
|
||||
|
||||
def _status(vmd: Vmd) -> dict[str, Any]:
|
||||
try:
|
||||
return vmd.call("session_status")
|
||||
except (VmdError, VmdUnavailable):
|
||||
return {}
|
||||
|
||||
|
||||
def _nudge(vmd: Vmd) -> str:
|
||||
state = _status(vmd)
|
||||
return (
|
||||
"You answered without calling a tool, and the session is not finished.\n\n"
|
||||
f"{state.get('tasks', '')}\n\n"
|
||||
"Call a tool to make progress, or call finish if the work is done - and if it is "
|
||||
"not done and cannot be, mark the remaining tasks blocked with the reason and "
|
||||
"finish anyway. Do not reply with prose again."
|
||||
)
|
||||
|
||||
|
||||
def _show(ui: Any, render: Any, name: str, payload: Any) -> None:
|
||||
"""Put a line on the watcher's screen, and never fail doing it.
|
||||
|
||||
This is a picture of the session for somebody watching a chat. It has
|
||||
no bearing on whether the work succeeds, so it does not get a vote.
|
||||
|
||||
Measured, and the reason this exists: `finish` arrived with its
|
||||
`verification` as a string where the schema says an object, the
|
||||
renderer called `.get` on it, and the AttributeError propagated out of
|
||||
the display path and killed the agent loop - discarding a session that
|
||||
had already completed all six of its tasks and verified them. One
|
||||
malformed argument from the model must cost a line of output, not the
|
||||
work.
|
||||
"""
|
||||
try:
|
||||
ui.terminal(*render(name, payload))
|
||||
except Exception: # noqa: BLE001 - a view may never break a session
|
||||
log.debug("could not render %s for the live view", name, exc_info=True)
|
||||
|
||||
|
||||
def _terminal_for(name: str, arguments: Any) -> list[str]:
|
||||
"""The command line, as a person would have typed it.
|
||||
|
||||
A prompt and a command, not a description of one: the point of this
|
||||
view is that somebody watching can see the session the way they would
|
||||
see their own terminal, and recognise a mistake in it.
|
||||
|
||||
NOTHING IN HERE MAY RAISE. These arguments come from the model, and a
|
||||
model that has been asked for an object will sometimes send a string.
|
||||
Measured, and the reason this note exists: `finish` was called with
|
||||
`verification` as a string, `.get` was called on it, and the
|
||||
AttributeError came out of the display code and killed the agent loop -
|
||||
throwing away a session that had finished all six of its tasks. A view
|
||||
of the work must never be able to destroy the work, so every read here
|
||||
is defensive and the whole call is wrapped by `_safe_terminal` below.
|
||||
"""
|
||||
if not isinstance(arguments, dict):
|
||||
return []
|
||||
match name:
|
||||
case "run":
|
||||
return [f"$ {str(arguments.get('command', ''))[:400]}"]
|
||||
case "write_file":
|
||||
body = str(arguments.get("content", ""))
|
||||
return [f"$ cat > {arguments.get('path', '')} ({len(body)} bytes)"]
|
||||
case "append_file":
|
||||
body = str(arguments.get("content", ""))
|
||||
return [f"$ cat >> {arguments.get('path', '')} ({len(body)} bytes)"]
|
||||
case "edit_file":
|
||||
old_text = " ".join(str(arguments.get("old", "")).split())[:60]
|
||||
return [f"$ edit {arguments.get('path', '')} ({old_text!r})"]
|
||||
case "read_file":
|
||||
return [f"$ cat {arguments.get('path', '')}"]
|
||||
case "list_dir":
|
||||
return [f"$ ls -l {arguments.get('path', '.')}"]
|
||||
case "git_commit":
|
||||
return [f"$ git commit -m {str(arguments.get('message', ''))[:100]!r}"]
|
||||
case "check_internet":
|
||||
return ["$ curl -sS -o /dev/null -w '%{http_code}' https://1.1.1.1/"]
|
||||
case "reboot_vm":
|
||||
return ["$ systemctl reboot"]
|
||||
case "web_search":
|
||||
return [f"# searching the web: {str(arguments.get('query', ''))[:120]}"]
|
||||
case "read_page" | "open_page":
|
||||
return [f"# {name}: {str(arguments.get('url', ''))[:150]}"]
|
||||
case "wait":
|
||||
return [f"$ sleep {arguments.get('seconds', '')}"]
|
||||
# The desktop verbs. Without these a GUI session's command feed is
|
||||
# a box of blank rows: every tool it uses is one of these, and
|
||||
# none of them used to render.
|
||||
case "launch_app":
|
||||
return [f"$ start {arguments.get('path', '')} (on the desktop)"]
|
||||
case "screenshot":
|
||||
return ["# looking at the screen"]
|
||||
case "click":
|
||||
return [f"# click ({arguments.get('x', '?')}, {arguments.get('y', '?')})"
|
||||
+ (f" {arguments['button']}" if arguments.get("button") else "")]
|
||||
case "move":
|
||||
return [f"# move to ({arguments.get('x', '?')}, {arguments.get('y', '?')})"]
|
||||
case "key":
|
||||
return [f"# key {str(arguments.get('name') or arguments.get('key', ''))[:40]}"]
|
||||
case "type":
|
||||
return [f"# type {str(arguments.get('text', ''))[:120]!r}"]
|
||||
case "scroll":
|
||||
return [f"# scroll {arguments.get('amount', '')} at "
|
||||
f"({arguments.get('x', '?')}, {arguments.get('y', '?')})"]
|
||||
case "finish":
|
||||
# The schema says an object with a `command`; a string is what
|
||||
# arrives often enough to matter, and either is worth showing.
|
||||
check = arguments.get("verification")
|
||||
if isinstance(check, dict):
|
||||
check = check.get("command", "")
|
||||
return [f"$ {str(check or '')[:300]}"] if check else []
|
||||
case _:
|
||||
return []
|
||||
|
||||
|
||||
def _terminal_result(name: str, text: str) -> list[str]:
|
||||
"""What the machine said back, trimmed to what a screen would show.
|
||||
|
||||
Capped here rather than in events.py so a command that prints a
|
||||
megabyte does not put a megabyte through the progress file on its way
|
||||
to being cut down to twenty lines.
|
||||
"""
|
||||
# Nothing whose "result" is bookkeeping rather than output. write_file
|
||||
# returns {"path": ..., "bytes": ...}, which is right for the model and
|
||||
# is a JSON object sitting in the middle of what is supposed to look
|
||||
# like a terminal - and the command line above it already says the
|
||||
# path and the size.
|
||||
if name in ("plan_tasks", "update_task", "add_task", "screenshot",
|
||||
"click", "move", "key", "type", "scroll", "write_file",
|
||||
"open_page", "wait"):
|
||||
return []
|
||||
|
||||
lines = [line for line in (text or "").splitlines() if line.strip()]
|
||||
|
||||
# `run` comes back as "exit code: 0 (0.4s) / stdout: / ...", which is
|
||||
# the shape the MODEL needs and not the shape a terminal has. The
|
||||
# person watching is looking at something that should read like their
|
||||
# own screen, so the framing is unwrapped: output as output, and the
|
||||
# exit code only when it is news.
|
||||
if name == "run":
|
||||
code, out = None, []
|
||||
section = None
|
||||
for line in lines:
|
||||
if line.startswith("exit code:"):
|
||||
code = line.split(":", 1)[1].strip().split()[0]
|
||||
elif line in ("stdout:", "stderr:"):
|
||||
section = line[:-1]
|
||||
elif line.startswith("[output was truncated"):
|
||||
out.append(line)
|
||||
else:
|
||||
out.append(line if section != "stderr" else line)
|
||||
if code not in (None, "0"):
|
||||
out.append(f"[exit {code}]")
|
||||
lines = out
|
||||
|
||||
if len(lines) > 12:
|
||||
lines = lines[:12] + [f"... ({len(lines) - 12} more lines)"]
|
||||
return lines
|
||||
|
||||
|
||||
def _status_for(vmd: Vmd, name: str, arguments: dict[str, Any]) -> str:
|
||||
"""The one line the watching user sees."""
|
||||
line = _status(vmd).get("status_line", "")
|
||||
prefix = f"{line} - " if line else ""
|
||||
match name:
|
||||
case "run":
|
||||
return prefix + f"running `{str(arguments.get('command', ''))[:70]}`"
|
||||
case "write_file":
|
||||
return prefix + f"writing {arguments.get('path', '')}"
|
||||
case "read_file" | "list_dir":
|
||||
return prefix + f"reading {arguments.get('path', '')}"
|
||||
case "screenshot":
|
||||
return prefix + "looking at the screen"
|
||||
case "click" | "move":
|
||||
return prefix + f"clicking ({arguments.get('x')}, {arguments.get('y')})"
|
||||
case "type":
|
||||
return prefix + "typing"
|
||||
case "key":
|
||||
return prefix + f"pressing {arguments.get('keys', '')}"
|
||||
case "git_commit":
|
||||
return prefix + "committing"
|
||||
case "check_internet":
|
||||
return prefix + "checking the network"
|
||||
case "reboot_vm":
|
||||
return prefix + "rebooting the machine"
|
||||
case "finish":
|
||||
return prefix + "verifying the work"
|
||||
case "plan_tasks":
|
||||
return "writing the task list"
|
||||
case _:
|
||||
return prefix + name
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Ollama client for the sub-agent, over the one path vmgw exposes.
|
||||
|
||||
WHY THE NATIVE /api/chat AND NOT THE OPENAI SHIM
|
||||
|
||||
The native endpoint returns `thinking` as its own field rather than
|
||||
folded into the content, which matters here for two reasons: this model
|
||||
runs with thinking on and its reasoning is long, and the reasoning must
|
||||
be kept OUT of the transcript that gets summarised during compaction. A
|
||||
shim that concatenates them makes both impossible.
|
||||
|
||||
THE NUMBERS THAT MATTER, AND WHY
|
||||
|
||||
num_ctx MUST be the resident value (163840). Ollama allocates the KV
|
||||
cache per loaded model; asking for a different size makes it RELOAD -
|
||||
eighteen gigabytes across the PCIe bus - in the middle of a session.
|
||||
|
||||
keep_alive must match what Open WebUI sends, or this request quietly
|
||||
resets the model's idle timer to something shorter and the main
|
||||
assistant pays for a reload on its next message.
|
||||
|
||||
The sampling options are sent explicitly rather than inherited. The
|
||||
archived sub-agent stack was bitten by exactly this: the Modelfile
|
||||
carried presence_penalty 1.5, nothing overrode it, and Ollama merges
|
||||
request options OVER Modelfile params - so anything left unsent keeps
|
||||
whatever the model row happens to hold that week.
|
||||
|
||||
CONTENTION, STATED PLAINLY
|
||||
|
||||
OLLAMA_NUM_PARALLEL is 1 and there is one KV slot. Inside this loop that
|
||||
is cheap: the agent's turns are consecutive, so the prefix cache means
|
||||
each step prefills only its new tokens. The expensive event is the
|
||||
handover back to the main model, which finds its cache gone. That is why
|
||||
the session blocks for as long as it does instead of polling - one long
|
||||
call costs one cold prefill, ten short ones cost ten.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
BASE_URL = os.environ.get("OWUITOOLS_VMGW_URL", "http://owuitools-vmgw:8000").rstrip("/")
|
||||
MODEL = os.environ.get("OWUITOOLS_AGENT_MODEL", "qwen3.8:latest")
|
||||
NUM_CTX = int(os.environ.get("OWUITOOLS_AGENT_NUM_CTX", "163840"))
|
||||
def _keep_alive(raw: str) -> object:
|
||||
"""Ollama takes a NUMBER of seconds or a duration STRING, not both.
|
||||
|
||||
`-1` as a number means "keep it loaded indefinitely". The same value
|
||||
as the string "-1" is parsed as a duration and rejected:
|
||||
`time: missing unit in duration "-1"`. Measured - the first live
|
||||
session died on it at step one, having started a VM and done nothing.
|
||||
So anything that looks like a plain number is sent as one, and
|
||||
anything else ("30m", "1h") is passed through as the string it is.
|
||||
"""
|
||||
text = (raw or "").strip()
|
||||
try:
|
||||
return int(text)
|
||||
except ValueError:
|
||||
return text or "5m"
|
||||
|
||||
|
||||
KEEP_ALIVE = _keep_alive(os.environ.get("OWUITOOLS_AGENT_KEEP_ALIVE", "-1"))
|
||||
REQUEST_TIMEOUT = float(os.environ.get("OWUITOOLS_AGENT_REQUEST_TIMEOUT", "1800"))
|
||||
|
||||
# Qwen3's published thinking-mode profile, sent every time so that
|
||||
# nothing is inherited. See the module docstring.
|
||||
OPTIONS: dict[str, Any] = {
|
||||
"temperature": float(os.environ.get("OWUITOOLS_AGENT_TEMPERATURE", "0.6")),
|
||||
"top_p": float(os.environ.get("OWUITOOLS_AGENT_TOP_P", "0.95")),
|
||||
"top_k": int(os.environ.get("OWUITOOLS_AGENT_TOP_K", "20")),
|
||||
"min_p": 0.0,
|
||||
# ZERO, AND NOT BECAUSE THE MAIN MODEL USES ONE.
|
||||
#
|
||||
# These options were copied from the main assistant's row, where
|
||||
# presence_penalty 1.0 is right: it writes prose, and a penalty on
|
||||
# tokens already used discourages repetitive phrasing.
|
||||
#
|
||||
# This agent writes CODE, where the same property is a defect. A
|
||||
# 25 KB script says `$form`, `$output`, `Add-Type` and its own
|
||||
# function names over and over - that repetition IS the program - and
|
||||
# a penalty on it is steady pressure to reach for a different token
|
||||
# than the one that is correct. Measured across two failing sessions,
|
||||
# both stalled on exactly that shape of bug: "a scope issue with how
|
||||
# I'm accessing the log", and "it's using `$a[0]` when `$a` was never
|
||||
# actually defined".
|
||||
#
|
||||
# Qwen3's own guidance says a presence penalty may degrade output and
|
||||
# to use it only against repetition loops. The agent already has a
|
||||
# repeat guard for those, in tool output, where it can say why.
|
||||
"presence_penalty": float(os.environ.get("OWUITOOLS_AGENT_PRESENCE_PENALTY", "0.0")),
|
||||
"repeat_last_n": 256,
|
||||
"num_ctx": NUM_CTX,
|
||||
# A CEILING ON ONE TURN, NOT ON THE SESSION.
|
||||
#
|
||||
# There was none, so a single turn could run until it chose to stop -
|
||||
# measured at twenty-two minutes and roughly fifty thousand tokens,
|
||||
# the model designing a whole application inside one reply while the
|
||||
# task list sat on item one. This is generous enough that no ordinary
|
||||
# turn reaches it and low enough that a runaway one ends. A turn cut
|
||||
# short emits no tool call, which the loop already handles by telling
|
||||
# the agent to call one.
|
||||
"num_predict": int(os.environ.get("OWUITOOLS_AGENT_NUM_PREDICT", "16384")),
|
||||
}
|
||||
|
||||
|
||||
class OllamaError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _read_stream(response: Any, on_text: Any) -> dict[str, Any]:
|
||||
"""Assemble one message out of Ollama's newline-delimited chunks.
|
||||
|
||||
Returns the same shape the non-streaming call returns, so nothing
|
||||
downstream can tell the difference. Content and thinking accumulate;
|
||||
tool calls are extended as they appear, because Ollama emits a call
|
||||
whole rather than a character at a time; the counts arrive on the
|
||||
final chunk.
|
||||
|
||||
Anything unreadable in a chunk is skipped rather than raised on. A
|
||||
malformed line is one frame of a progress display, and losing the
|
||||
whole turn over it would be a poor trade.
|
||||
"""
|
||||
content: list[str] = []
|
||||
thinking: list[str] = []
|
||||
tool_calls: list[Any] = []
|
||||
body: dict[str, Any] = {}
|
||||
for raw in response:
|
||||
line = raw.decode("utf-8", "replace").strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
message = chunk.get("message") or {}
|
||||
piece = message.get("content") or ""
|
||||
if piece:
|
||||
content.append(piece)
|
||||
with contextlib.suppress(Exception):
|
||||
on_text("".join(content))
|
||||
thought = message.get("thinking") or ""
|
||||
if thought:
|
||||
thinking.append(thought)
|
||||
# Only shown when there is nothing else to show: a model that
|
||||
# is thinking has not said anything yet, and a blank line for
|
||||
# a minute reads as a hung session.
|
||||
if not content:
|
||||
with contextlib.suppress(Exception):
|
||||
on_text("".join(thinking))
|
||||
if message.get("tool_calls"):
|
||||
tool_calls.extend(message["tool_calls"])
|
||||
if chunk.get("done"):
|
||||
body = chunk
|
||||
body["message"] = {
|
||||
"role": "assistant",
|
||||
"content": "".join(content),
|
||||
"thinking": "".join(thinking),
|
||||
"tool_calls": tool_calls,
|
||||
}
|
||||
return body
|
||||
|
||||
|
||||
# HOW HARD THE AGENT THINKS, AND WHY IT IS A DIAL RATHER THAN A SWITCH.
|
||||
#
|
||||
# Ollama 0.32.15 accepts `think` as a boolean OR one of "high", "medium",
|
||||
# "low", "max" - its own validator says so in as many words. Whether a
|
||||
# given model HONOURS a level is a separate question from whether Ollama
|
||||
# will carry it: levels are native to gpt-oss, and a template that does
|
||||
# not know about them simply renders the same prompt it always did.
|
||||
#
|
||||
# Worth having as a setting either way, because the cost is measurable.
|
||||
# The agent generates at ~52 tokens/second - fast - and still crawls,
|
||||
# because a single turn ran to 15,469 tokens, most of it deliberation that
|
||||
# is then discarded rather than kept. Five minutes of wall clock for one
|
||||
# step, during which the task list cannot move.
|
||||
#
|
||||
# "true" keeps the behaviour that has been solving the hard parts. Set it
|
||||
# to a level, or to "false", and measure n_gen per turn in Ollama's log
|
||||
# before believing it did anything.
|
||||
def _think_setting() -> Any:
|
||||
raw = os.environ.get("OWUITOOLS_AGENT_THINK", "true").strip().lower()
|
||||
if raw in ("true", "1", "yes"):
|
||||
return True
|
||||
if raw in ("false", "0", "no"):
|
||||
return False
|
||||
if raw in ("low", "medium", "high", "max"):
|
||||
return raw
|
||||
return True
|
||||
|
||||
|
||||
THINK = _think_setting()
|
||||
|
||||
|
||||
def chat(messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None,
|
||||
think: Any = None, on_text: Any = None) -> dict[str, Any]:
|
||||
"""One turn. Returns the message plus its usage counts.
|
||||
|
||||
STREAMING IS FOR THE PERSON WATCHING, AND FOR NOTHING ELSE.
|
||||
|
||||
This was non-streaming on the grounds that nothing here needs tokens
|
||||
as they arrive. That was true of the agent - it acts on a finished
|
||||
message - and false of the operator, who asked to see what the model
|
||||
is saying while it says it. A turn on this model runs tens of seconds
|
||||
with nothing on screen, and "still thinking" is not the same as "still
|
||||
working" to somebody deciding whether to wait.
|
||||
|
||||
So `on_text` is the whole of the difference. Without it this streams
|
||||
nothing and behaves exactly as before. With it, the reply is read as
|
||||
it arrives and the callback is handed the text so far; either way the
|
||||
return value is the same assembled message, because the agent is not
|
||||
allowed to care which path produced it.
|
||||
|
||||
The callback is the watcher's, not the loop's: it must never be able
|
||||
to fail a turn, so it is called inside a suppressed block.
|
||||
"""
|
||||
payload: dict[str, Any] = {
|
||||
"model": MODEL,
|
||||
"messages": messages,
|
||||
"stream": on_text is not None,
|
||||
"think": THINK if think is None else think,
|
||||
"keep_alive": KEEP_ALIVE,
|
||||
"options": OPTIONS,
|
||||
}
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
|
||||
request = urllib.request.Request(
|
||||
f"{BASE_URL}/ollama/api/chat",
|
||||
data=json.dumps(payload).encode(),
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
try:
|
||||
with opener.open(request, timeout=REQUEST_TIMEOUT) as response:
|
||||
if on_text is None:
|
||||
body = json.loads(response.read().decode("utf-8", "replace"))
|
||||
else:
|
||||
body = _read_stream(response, on_text)
|
||||
except urllib.error.HTTPError as exc:
|
||||
# Read the body before raising: Ollama puts the real complaint
|
||||
# there - an unknown option, a context that will not fit - and
|
||||
# losing it leaves only "HTTP 400", which explains nothing.
|
||||
detail = exc.read().decode("utf-8", "replace")[:500]
|
||||
raise OllamaError(f"the model refused the request (HTTP {exc.code}): {detail}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise OllamaError(f"could not reach the model: {exc.reason}") from exc
|
||||
|
||||
message = body.get("message") or {}
|
||||
return {
|
||||
"role": message.get("role", "assistant"),
|
||||
"content": message.get("content", "") or "",
|
||||
"thinking": message.get("thinking", "") or "",
|
||||
"tool_calls": message.get("tool_calls") or [],
|
||||
"prompt_tokens": int(body.get("prompt_eval_count") or 0),
|
||||
"eval_tokens": int(body.get("eval_count") or 0),
|
||||
"prompt_seconds": round((body.get("prompt_eval_duration") or 0) / 1e9, 2),
|
||||
"eval_seconds": round((body.get("eval_duration") or 0) / 1e9, 2),
|
||||
"done_reason": body.get("done_reason", ""),
|
||||
}
|
||||
|
||||
|
||||
def resident_context() -> int | None:
|
||||
"""What context length the model is actually loaded with.
|
||||
|
||||
Sending a num_ctx that differs from this forces a reload, so the loop
|
||||
checks rather than trusting its own default - the two drift the first
|
||||
time somebody edits the model row in Open WebUI.
|
||||
"""
|
||||
try:
|
||||
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
with opener.open(f"{BASE_URL}/ollama/api/ps", timeout=10) as response:
|
||||
data = json.loads(response.read().decode())
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
for model in data.get("models", []):
|
||||
if model.get("name") == MODEL:
|
||||
return int(model.get("context_length") or 0) or None
|
||||
return None
|
||||
@@ -0,0 +1,278 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Claims VM jobs from a shared volume and runs them.
|
||||
|
||||
WHY FILES AND NOT HTTP
|
||||
|
||||
Exactly the reasoning in octave/run.sh, and the same protocol. The api
|
||||
container lives inside owuitools-netguard, behind a default-deny filter
|
||||
whose entire outbound allowlist is squid and valkey. Reaching a new
|
||||
service from there means editing netguard/firewall.sh - a file whose
|
||||
whole value is that it is short, verified at start, and rarely touched.
|
||||
|
||||
A shared volume needs none of that:
|
||||
|
||||
/vmjobs/in/<id>/request.json what to do
|
||||
/vmjobs/in/<id>/meta the nonce, written by the api
|
||||
/vmjobs/in/<id>.ready created LAST, so its appearance means
|
||||
the directory is complete. Without it
|
||||
this races the api and reads half a job.
|
||||
/vmjobs/run/<id>/CANCEL the api's stop signal, mid-flight
|
||||
/vmjobs/out/<id>.out the result, moved into place atomically
|
||||
|
||||
THE NONCE
|
||||
|
||||
Same purpose as the Octave runner's: the api stamps each job and checks
|
||||
the stamp on the way back, so a result cannot be forged by anything else
|
||||
that can write the volume. Unlike the Octave case the executed code never
|
||||
sees this volume at all - it runs inside a VM with no access to it - so
|
||||
this is defence in depth rather than the load-bearing control.
|
||||
|
||||
SERIAL, ON PURPOSE
|
||||
|
||||
One job at a time, because there is one VM at a time. A queue would only
|
||||
let a second caller wait in a different place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import signal
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
import loop as loop_mod
|
||||
from client import Vmd, VmdError, VmdUnavailable
|
||||
|
||||
JOBS = os.environ.get("OWUITOOLS_VM_JOBS_DIR", "/vmjobs")
|
||||
POLL_INTERVAL = 0.25
|
||||
REAP_MINUTES = int(os.environ.get("OWUITOOLS_VM_REAP_MINUTES", "180"))
|
||||
|
||||
# stdout and ./logs/vmagent.log both - see app/owuitools/logging_setup.py
|
||||
# for why. Inlined rather than shared because this container deliberately
|
||||
# copies almost nothing from the api image.
|
||||
_handlers: list[logging.Handler] = [logging.StreamHandler(sys.stdout)]
|
||||
_log_dir = os.environ.get("OWUITOOLS_LOG_DIR", "/logs")
|
||||
_log_problem = ""
|
||||
if _log_dir:
|
||||
try:
|
||||
os.makedirs(_log_dir, exist_ok=True)
|
||||
_handlers.append(logging.FileHandler(os.path.join(_log_dir, "vmagent.log"),
|
||||
encoding="utf-8"))
|
||||
except OSError as exc:
|
||||
_log_problem = f"{_log_dir} is not writable ({exc}); logging to stdout only"
|
||||
|
||||
logging.basicConfig(
|
||||
level=os.environ.get("OWUITOOLS_LOG_LEVEL", "INFO").upper(),
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
handlers=_handlers,
|
||||
force=True,
|
||||
)
|
||||
log = logging.getLogger("vmagent.runner")
|
||||
if _log_problem:
|
||||
log.warning(_log_problem)
|
||||
|
||||
_stop = threading.Event()
|
||||
|
||||
|
||||
def _watch_for_cancel(run_dir: str, vmd_path: str) -> threading.Thread:
|
||||
"""Turn the api's CANCEL file into a real stop.
|
||||
|
||||
The archived stack's cancel verb recorded an intention and killed
|
||||
nothing, so a cancelled run went to completion anyway. Here the file
|
||||
reaches vmd, which runs `systemctl stop owuivm.service` - and that
|
||||
kills qemu whatever any Python loop happens to be doing.
|
||||
"""
|
||||
marker = os.path.join(run_dir, "CANCEL")
|
||||
|
||||
def watch() -> None:
|
||||
while not _stop.is_set():
|
||||
if os.path.exists(marker):
|
||||
log.warning("cancellation requested; stopping the machine")
|
||||
try:
|
||||
with Vmd(vmd_path) as vmd:
|
||||
vmd.call("session_cancel")
|
||||
except (VmdError, VmdUnavailable) as exc:
|
||||
log.warning("could not deliver the cancellation: %s", exc)
|
||||
return
|
||||
time.sleep(0.5)
|
||||
|
||||
thread = threading.Thread(target=watch, daemon=True)
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
def run_job(job_id: str) -> None:
|
||||
run_dir = os.path.join(JOBS, "run", job_id)
|
||||
out_tmp = os.path.join(JOBS, "out", f"{job_id}.tmp")
|
||||
out_path = os.path.join(JOBS, "out", f"{job_id}.out")
|
||||
|
||||
nonce = ""
|
||||
meta_path = os.path.join(run_dir, "meta")
|
||||
if os.path.exists(meta_path):
|
||||
for line in open(meta_path, encoding="utf-8", errors="replace"):
|
||||
if line.startswith("NONCE="):
|
||||
nonce = line.strip().split("=", 1)[1]
|
||||
# Deleted before anything else runs, exactly as the Octave runner
|
||||
# does: the nonce should live only in this process's memory.
|
||||
try:
|
||||
os.unlink(meta_path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
try:
|
||||
with open(os.path.join(run_dir, "request.json"), encoding="utf-8") as handle:
|
||||
request = json.load(handle)
|
||||
except (OSError, ValueError) as exc:
|
||||
_write_result(out_tmp, out_path, nonce, "failed",
|
||||
f"FAILED: the job could not be read ({exc}).")
|
||||
return
|
||||
|
||||
job = loop_mod.Job(
|
||||
task=str(request.get("task", ""))[:20000],
|
||||
os_name=str(request.get("os", "ubuntu")),
|
||||
interface=str(request.get("interface", "cli")),
|
||||
project=request.get("project") or None,
|
||||
chat_id=str(request.get("chat_id", "")),
|
||||
message_id=str(request.get("message_id", "")),
|
||||
owner=str(request.get("owner", "")),
|
||||
answer=str(request.get("answer", ""))[:4000],
|
||||
)
|
||||
|
||||
_stop.clear()
|
||||
watcher = _watch_for_cancel(run_dir, os.environ.get("OWUITOOLS_VMD_SOCKET",
|
||||
"/run/owuivm/agent/agent.sock"))
|
||||
started = time.monotonic()
|
||||
try:
|
||||
outcome = loop_mod.run(job)
|
||||
body = outcome.report
|
||||
state = outcome.state
|
||||
steps = outcome.steps
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log.exception("job %s failed outright", job_id)
|
||||
body = (f"FAILED: the VM agent stopped unexpectedly ({type(exc).__name__}). "
|
||||
"Nothing further is known about what it had done. Do not retry "
|
||||
"automatically; tell the user.")
|
||||
state, steps = "failed", 0
|
||||
finally:
|
||||
_stop.set()
|
||||
watcher.join(timeout=2.0)
|
||||
|
||||
_write_result(out_tmp, out_path, nonce, state, body,
|
||||
steps=steps, seconds=round(time.monotonic() - started, 1))
|
||||
shutil.rmtree(run_dir, ignore_errors=True)
|
||||
|
||||
|
||||
def _write_result(tmp: str, final: str, nonce: str, state: str, body: str,
|
||||
steps: int = 0, seconds: float = 0.0) -> None:
|
||||
payload = {"nonce": nonce, "state": state, "steps": steps,
|
||||
"seconds": seconds, "report": body}
|
||||
with open(tmp, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle)
|
||||
os.replace(tmp, final) # atomic: the api never sees a partial file
|
||||
|
||||
|
||||
def reap() -> None:
|
||||
"""Nothing else reclaims these.
|
||||
|
||||
A result the api gave up waiting for, or a job whose .ready never
|
||||
arrived, would sit on the volume until the container restarted.
|
||||
"""
|
||||
cutoff = time.time() - REAP_MINUTES * 60
|
||||
for sub in ("out", "in", "run", "progress"):
|
||||
directory = os.path.join(JOBS, sub)
|
||||
if not os.path.isdir(directory):
|
||||
continue
|
||||
for name in os.listdir(directory):
|
||||
path = os.path.join(directory, name)
|
||||
try:
|
||||
if os.path.getmtime(path) > cutoff:
|
||||
continue
|
||||
shutil.rmtree(path, ignore_errors=True) if os.path.isdir(path) else os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
for sub in ("in", "run", "out"):
|
||||
os.makedirs(os.path.join(JOBS, sub), mode=0o700, exist_ok=True)
|
||||
|
||||
# Anything left over from a previous container start would otherwise
|
||||
# wedge its caller until timeout.
|
||||
shutil.rmtree(os.path.join(JOBS, "run"), ignore_errors=True)
|
||||
os.makedirs(os.path.join(JOBS, "run"), mode=0o700, exist_ok=True)
|
||||
try:
|
||||
with Vmd() as vmd:
|
||||
reaped = vmd.call("reap").get("reaped") or []
|
||||
if reaped:
|
||||
log.info("vmd reaped %d leftover(s) at start", len(reaped))
|
||||
except (VmdError, VmdUnavailable) as exc:
|
||||
log.warning("could not reach vmd at start: %s", exc)
|
||||
|
||||
running = True
|
||||
|
||||
def shutdown(_sig, _frame):
|
||||
nonlocal running
|
||||
running = False
|
||||
_stop.set()
|
||||
|
||||
signal.signal(signal.SIGTERM, shutdown)
|
||||
signal.signal(signal.SIGINT, shutdown)
|
||||
|
||||
log.info("vmagent runner ready, watching %s", JOBS)
|
||||
ticks = 0
|
||||
while running:
|
||||
claimed = False
|
||||
in_dir = os.path.join(JOBS, "in")
|
||||
for name in sorted(os.listdir(in_dir)) if os.path.isdir(in_dir) else []:
|
||||
if not name.endswith(".ready"):
|
||||
continue
|
||||
job_id = name[: -len(".ready")]
|
||||
if not job_id.isalnum():
|
||||
_unlink(os.path.join(in_dir, name))
|
||||
continue
|
||||
source = os.path.join(in_dir, job_id)
|
||||
target = os.path.join(JOBS, "run", job_id)
|
||||
try:
|
||||
os.rename(source, target)
|
||||
except OSError:
|
||||
_unlink(os.path.join(in_dir, name))
|
||||
continue
|
||||
_unlink(os.path.join(in_dir, name))
|
||||
claimed = True
|
||||
log.info("claimed job %s", job_id)
|
||||
run_job(job_id)
|
||||
log.info("finished job %s", job_id)
|
||||
|
||||
ticks += 1
|
||||
if ticks >= 2400: # roughly every ten minutes
|
||||
ticks = 0
|
||||
reap()
|
||||
_touch(os.path.join(JOBS, ".heartbeat"))
|
||||
if not claimed:
|
||||
time.sleep(POLL_INTERVAL)
|
||||
return 0
|
||||
|
||||
|
||||
def _unlink(path: str) -> None:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _touch(path: str) -> None:
|
||||
try:
|
||||
with open(path, "a"):
|
||||
os.utime(path, None)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,363 @@
|
||||
"""The sub-agent's tools: the schemas it reads, and the dispatch to vmd.
|
||||
|
||||
WHERE THE DEFINITIONS LIVE
|
||||
|
||||
In configs/agent/tools.toml, not here. Every description is prompt text
|
||||
the model pays for on every turn, and the wording of a tool description
|
||||
is the single most effective lever on how a tool gets used - so it
|
||||
belongs in a file you can edit and restart, rather than one you have to
|
||||
rebuild an image to change.
|
||||
|
||||
What stays in code is everything that is behaviour rather than wording:
|
||||
which vmd verb a name reaches (`call`, below), which results are worth
|
||||
keeping verbatim (`TERSE`), and DISPATCHABLE - the allowlist of verbs the
|
||||
agent is allowed to reach at all. That last one is why the config file
|
||||
cannot widen the agent's reach: a name in the TOML with nothing in
|
||||
DISPATCHABLE is refused when it is read, loudly, rather than offered to
|
||||
the model and then failing somewhere less obvious.
|
||||
|
||||
Two things are deliberately NOT tools:
|
||||
|
||||
* anything that takes a raw QMP command, a host path, or a network
|
||||
setting. vmd exposes typed verbs; this is where that decision is
|
||||
visible.
|
||||
* anything the machine can answer about itself. DNS, HTTP headers and
|
||||
TLS certificates are one curl, dig or openssl away inside a machine
|
||||
that has internet, and running them THERE is what the machine is
|
||||
for. The lookup suite - web_search, read_page and the research
|
||||
group - does exist, on the host, because reading documentation off a
|
||||
screen costs a screenshot per scroll and reasoning it out from first
|
||||
principles has cost whole sessions.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tomllib
|
||||
from typing import Any
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
# In the container configs/agent is bind-mounted at /configs/agent and the
|
||||
# env var says so. Outside it - the tests, and anyone reading the repo -
|
||||
# fall back to the tree's own copy, so nothing needs an environment set up
|
||||
# to import this module.
|
||||
CONFIG_DIR = (os.environ.get("OWUITOOLS_AGENT_CONFIG_DIR")
|
||||
or os.path.join(_HERE, "..", "..", "configs", "agent"))
|
||||
VMS_CONFIG = os.environ.get("OWUITOOLS_VMS_CONFIG",
|
||||
os.path.join(CONFIG_DIR, "..", "vms.toml"))
|
||||
# The SERVER's tool switch, not this file's. See _server_disabled().
|
||||
SERVER_TOOLS_CONFIG = os.environ.get("OWUITOOLS_TOOLS_CONFIG",
|
||||
os.path.join(CONFIG_DIR, "..", "tools.toml"))
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# what the agent is allowed to reach
|
||||
# ---------------------------------------------------------------------
|
||||
# The allowlist, and the reason it is a list and not a comment: the tool
|
||||
# surface is a config file now, and vmd has verbs the agent must never
|
||||
# call - closing a session, saving a machine, unfreezing a base image.
|
||||
# vmd would refuse them on its own authority, but a refusal at read time
|
||||
# names the file and the line, and a refusal at run time looks like a
|
||||
# broken tool. Adding a name here is a deliberate act.
|
||||
DISPATCHABLE = frozenset({
|
||||
# planning
|
||||
"plan_tasks", "update_task", "add_task",
|
||||
# the machine
|
||||
"run", "write_file", "append_file", "edit_file", "read_file",
|
||||
"web_search", "read_page", "list_dir", "check_internet", "git_commit",
|
||||
"reboot_vm", "ask_operator", "request_unfreeze", "wait", "finish",
|
||||
# the lookup suite, dispatched as one `research` verb
|
||||
"search_stackexchange", "read_man_page", "lookup_package", "wikipedia_lookup",
|
||||
"lookup_rfc", "lookup_cve", "search_papers", "find_datasheet",
|
||||
# the screen
|
||||
"open_page", "launch_app", "screenshot", "click", "move", "key", "type", "scroll",
|
||||
})
|
||||
|
||||
# Agent tool -> the tool server operation it is ultimately a call to.
|
||||
# Only the proxies appear here; everything else runs in the machine.
|
||||
# Used to keep this file honest about configs/tools.toml: a lookup the
|
||||
# server has been told not to offer must not be offered here either, or
|
||||
# the agent gets a tool that 404s, which reads as broken rather than
|
||||
# absent. Note read_page is the server's fetch_page under another name.
|
||||
SERVER_TOOL = {
|
||||
"web_search": "web_search",
|
||||
"read_page": "fetch_page",
|
||||
"search_stackexchange": "search_stackexchange",
|
||||
"read_man_page": "read_man_page",
|
||||
"lookup_package": "lookup_package",
|
||||
"wikipedia_lookup": "wikipedia_lookup",
|
||||
"lookup_rfc": "lookup_rfc",
|
||||
"lookup_cve": "lookup_cve",
|
||||
"search_papers": "search_papers",
|
||||
"find_datasheet": "find_datasheet",
|
||||
}
|
||||
|
||||
# The order the groups are offered in, and it is not arbitrary: planning
|
||||
# first because plan_tasks must be the first call, then the command line
|
||||
# it spends most of its turns in, then the lookup suite, then the screen.
|
||||
GROUP_ORDER = ("task", "cli", "research", "gui")
|
||||
|
||||
# What is offered while the machine is still booting. Only the planning
|
||||
# verbs: there is nothing to run yet, and offering tools that would be
|
||||
# refused teaches the model to retry rather than to wait.
|
||||
BOOTING_GROUPS = ("task",)
|
||||
|
||||
|
||||
class ToolConfigError(RuntimeError):
|
||||
"""The tool definitions could not be read. Phrased for a human."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# loading
|
||||
# ---------------------------------------------------------------------
|
||||
_machines_cache: dict[str, dict[str, str]] | None = None
|
||||
_entries_cache: list[dict[str, Any]] | None = None
|
||||
_schema_cache: dict[tuple[str, bool], list[dict[str, Any]]] = {}
|
||||
|
||||
|
||||
def _read(path: str) -> dict:
|
||||
try:
|
||||
with open(path, "rb") as handle:
|
||||
return tomllib.load(handle)
|
||||
except FileNotFoundError as exc:
|
||||
raise ToolConfigError(
|
||||
f"{path} is not there. It is bind-mounted from configs/ by "
|
||||
"docker-compose.yml; without it the agent has no tools at all."
|
||||
) from exc
|
||||
except (OSError, tomllib.TOMLDecodeError) as exc:
|
||||
raise ToolConfigError(f"{path} could not be read: {exc}") from exc
|
||||
|
||||
|
||||
def machines() -> dict[str, dict[str, str]]:
|
||||
"""The machine registry, keyed by id."""
|
||||
global _machines_cache
|
||||
if _machines_cache is None:
|
||||
found = {}
|
||||
for entry in _read(VMS_CONFIG).get("machine", []):
|
||||
machine_id = str(entry.get("id", "")).strip()
|
||||
if not machine_id:
|
||||
raise ToolConfigError(f"{VMS_CONFIG} has a machine with no id")
|
||||
found[machine_id] = {key: str(entry.get(key, "")) for key in
|
||||
("name", "iso", "shell", "project", "example")}
|
||||
if not found:
|
||||
raise ToolConfigError(f"{VMS_CONFIG} defines no machines")
|
||||
_machines_cache = found
|
||||
return _machines_cache
|
||||
|
||||
|
||||
def _server_disabled() -> set[str]:
|
||||
"""Tool-server operations this deployment has switched off.
|
||||
|
||||
Read leniently and on purpose: this file's job is to avoid offering a
|
||||
lookup that would 404, and if the server's config cannot be read the
|
||||
right guess is that everything is on - which is what it ships as.
|
||||
"""
|
||||
try:
|
||||
with open(SERVER_TOOLS_CONFIG, "rb") as handle:
|
||||
table = tomllib.load(handle).get("tools", {})
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
return set()
|
||||
return {str(name) for name, on in table.items() if not on}
|
||||
|
||||
|
||||
def _entries() -> list[dict[str, Any]]:
|
||||
"""Every tool in the config file, validated, in file order."""
|
||||
global _entries_cache
|
||||
if _entries_cache is not None:
|
||||
return _entries_cache
|
||||
|
||||
path = os.path.join(CONFIG_DIR, "tools.toml")
|
||||
raw = _read(path).get("tool", [])
|
||||
if not raw:
|
||||
raise ToolConfigError(f"{path} defines no tools")
|
||||
|
||||
seen: set[str] = set()
|
||||
entries: list[dict[str, Any]] = []
|
||||
for entry in raw:
|
||||
name = str(entry.get("name", "")).strip()
|
||||
if not name:
|
||||
raise ToolConfigError(f"{path} has a tool with no name")
|
||||
if name in seen:
|
||||
raise ToolConfigError(f"{path} defines '{name}' twice")
|
||||
seen.add(name)
|
||||
if name not in DISPATCHABLE:
|
||||
raise ToolConfigError(
|
||||
f"{path} offers '{name}', which is not a verb the agent may call. "
|
||||
f"Adding one means adding it to DISPATCHABLE in {__file__} and "
|
||||
"implementing it in vmd - it is not something config can operator."
|
||||
)
|
||||
group = str(entry.get("group", "")).strip()
|
||||
if group not in GROUP_ORDER:
|
||||
raise ToolConfigError(
|
||||
f"{path}: '{name}' has group '{group}'; expected one of {GROUP_ORDER}")
|
||||
entries.append(entry)
|
||||
|
||||
missing = sorted(DISPATCHABLE - seen)
|
||||
if missing:
|
||||
# Not fatal: a verb with no entry is simply not offered, which is a
|
||||
# legitimate thing to want. Worth a line, because the other reading
|
||||
# is that someone deleted a tool by accident.
|
||||
log.info("%d dispatchable verb(s) are not offered by %s: %s",
|
||||
len(missing), path, ", ".join(missing))
|
||||
|
||||
_entries_cache = entries
|
||||
return entries
|
||||
|
||||
|
||||
def _render(entry: dict[str, Any], machine: dict[str, str]) -> dict[str, Any]:
|
||||
"""One config entry as an Ollama tool schema, for one machine.
|
||||
|
||||
Substitution is done over the serialised form rather than field by
|
||||
field, because the placeholders appear in parameter descriptions as
|
||||
well as in the tool's own. Measured against the hand-written schemas
|
||||
this replaced: byte-identical for both machines.
|
||||
"""
|
||||
schema = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": entry["name"],
|
||||
"description": str(entry.get("description", "")).strip(),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": entry.get("parameters", {}),
|
||||
"required": list(entry.get("required", [])),
|
||||
},
|
||||
},
|
||||
}
|
||||
text = json.dumps(schema)
|
||||
for key in ("shell", "project", "example"):
|
||||
text = text.replace("{%s}" % key, json.dumps(machine.get(key, ""))[1:-1])
|
||||
return json.loads(text)
|
||||
|
||||
|
||||
def schemas(os_name: str, booting: bool = False) -> list[dict[str, Any]]:
|
||||
"""Everything a session can do on this machine. There is one kind.
|
||||
|
||||
THERE USED TO BE TWO, AND THE SPLIT COST MORE THAN IT SAVED.
|
||||
|
||||
A session was `cli` or `gui`, chosen by the CALLING model, and it got
|
||||
the screen tools or the lookup suite but never both. Three things went
|
||||
wrong with that and all three were measured:
|
||||
|
||||
* the caller chose wrong. Asked to build a GUI application it picked
|
||||
the command line twice, with the rule in the tool docstring and in
|
||||
the system prompt both times, and only a refusal stopped it - a
|
||||
whole mechanism defending a choice that need not have existed.
|
||||
* a `gui` session had no cheap way to look anything up, so it
|
||||
reasoned instead: eleven minutes and eight compactions deriving
|
||||
something the documentation states outright.
|
||||
* a `cli` session could build a window and never see it, so "it
|
||||
parses" was the best answer it could give.
|
||||
|
||||
Now every session has the command line, the lookup suite and the
|
||||
screen. The agent decides what it needs, which it is in a far better
|
||||
position to know, and a job that never looks at the screen simply
|
||||
never calls those tools. The cost is their schemas in the context; the
|
||||
thing it replaces was costing whole sessions.
|
||||
"""
|
||||
key = (os_name, booting)
|
||||
if key in _schema_cache:
|
||||
return list(_schema_cache[key])
|
||||
|
||||
registry = machines()
|
||||
machine = registry.get(os_name) or next(iter(registry.values()))
|
||||
wanted = BOOTING_GROUPS if booting else GROUP_ORDER
|
||||
switched_off = _server_disabled()
|
||||
|
||||
built: list[dict[str, Any]] = []
|
||||
for group in wanted:
|
||||
for entry in _entries():
|
||||
if entry.get("group") != group or not entry.get("enabled", True):
|
||||
continue
|
||||
if SERVER_TOOL.get(entry["name"]) in switched_off:
|
||||
log.info("not offering %s: the tool server has %s switched off",
|
||||
entry["name"], SERVER_TOOL[entry["name"]])
|
||||
continue
|
||||
built.append(_render(entry, machine))
|
||||
|
||||
_schema_cache[key] = built
|
||||
return list(built)
|
||||
|
||||
|
||||
def research_names() -> frozenset[str]:
|
||||
"""The lookup tools, which vmd takes as one `research` verb."""
|
||||
return frozenset(e["name"] for e in _entries() if e.get("group") == "research")
|
||||
|
||||
|
||||
# Kept as a module-level name because `call` below reads it on every
|
||||
# dispatch and the old hand-written list was one too.
|
||||
RESEARCH_NAMES = research_names()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# dispatch
|
||||
# ---------------------------------------------------------------------
|
||||
# Tools whose result is not worth keeping verbatim in the transcript: the
|
||||
# task table is re-rendered every turn anyway, so twenty copies of it is
|
||||
# twenty copies of the same thing.
|
||||
TERSE = frozenset({"plan_tasks", "update_task", "add_task", "wait", "move"})
|
||||
|
||||
|
||||
def call(vmd, name: str, arguments: dict[str, Any]) -> tuple[str, bytes | None]:
|
||||
"""Run one tool. Returns (text for the transcript, image bytes or None).
|
||||
|
||||
Every failure comes back as TEXT, never as an exception: a refusal is
|
||||
something the agent should read and act on, and an exception would
|
||||
end the session over a mistyped path. Same policy as the api
|
||||
container's @tool decorator, for the same reason.
|
||||
"""
|
||||
from client import VmdError, VmdUnavailable # noqa: PLC0415
|
||||
import base64 # noqa: PLC0415
|
||||
|
||||
try:
|
||||
# The lookup suite is one verb on vmd's side with an allowlist,
|
||||
# rather than eight verbs that each do the same thing.
|
||||
if name in RESEARCH_NAMES:
|
||||
result = vmd.call("research", tool=name, args=arguments)
|
||||
else:
|
||||
result = vmd.call(name, **arguments)
|
||||
except VmdError as exc:
|
||||
return f"REFUSED: {exc}", None
|
||||
except VmdUnavailable as exc:
|
||||
return f"FAILED: {exc}", None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return f"FAILED: {type(exc).__name__}: {exc}", None
|
||||
|
||||
if name == "screenshot":
|
||||
png = base64.b64decode(result.get("png_b64", ""))
|
||||
return (f"Screenshot taken: {result.get('width')}x{result.get('height')} pixels. "
|
||||
"Click coordinates are in these pixels from the top left."), png
|
||||
|
||||
if name == "web_search":
|
||||
return result.get("results", "") or "NO RESULTS", None
|
||||
|
||||
if name in RESEARCH_NAMES:
|
||||
return result.get("text", "") or "(no result)", None
|
||||
|
||||
if name == "read_page":
|
||||
return result.get("text", "") or "(the page had no readable text)", None
|
||||
|
||||
if name == "open_page":
|
||||
return (f"Opened {result.get('url')} in {result.get('browser')}. It takes a few "
|
||||
"seconds to render - take a screenshot to see it."), None
|
||||
|
||||
if name == "run":
|
||||
parts = [f"exit code: {result['exit_code']} ({result['seconds']}s)"]
|
||||
if result.get("stdout"):
|
||||
parts.append("stdout:\n" + result["stdout"])
|
||||
if result.get("stderr"):
|
||||
parts.append("stderr:\n" + result["stderr"])
|
||||
if not result.get("stdout") and not result.get("stderr"):
|
||||
parts.append("(no output)")
|
||||
if result.get("truncated"):
|
||||
parts.append("[output was truncated - re-run with grep or tail if you need the rest]")
|
||||
return "\n".join(parts), None
|
||||
|
||||
if name in TERSE:
|
||||
return result.get("tasks") or result.get("status_line") or "ok", None
|
||||
|
||||
import json as _json # noqa: PLC0415
|
||||
return _json.dumps(result, indent=2)[:20000], None
|
||||
Reference in New Issue
Block a user