Initial Commit

This commit is contained in:
2026-09-15 15:24:03 -04:00
commit 4dc7086e7e
605 changed files with 101380 additions and 0 deletions
+208
View File
@@ -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.
"""
+287
View File
@@ -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 ""
+704
View File
@@ -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
+283
View File
@@ -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
+278
View File
@@ -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())
+363
View File
@@ -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