705 lines
30 KiB
Python
705 lines
30 KiB
Python
"""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
|