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

226 lines
9.0 KiB
Python

"""The session's task list, held by vmd rather than by the agent.
WHY THIS EXISTS AT ALL
The archived sub-agent stack died of accuracy, not plumbing: its coding
agent hallucinated library APIs and wrote self-confirming tests around
them, and agents that were allowed to skip a tool guessed instead. Both
are the same failure - the agent deciding when it is finished, against no
standard but its own prose.
So the first thing an agent does here is write down what it intends to
do, and it cannot call finish() until every item is either done or
explicitly abandoned with a reason. It is arguing with a checklist it
wrote itself, and the refusal arrives as TOOL OUTPUT - which this model
treats as fact, where it treats system-prompt text as advice. That
distinction is the most reliably reproduced finding in TUNING.md.
WHY vmd HOLDS IT AND NOT THE AGENT
If the list lived in the agent's context it would be editable by the same
process that reports on it: a task could be quietly deleted, reworded to
match what actually happened, or marked done in the summary while the
transcript says otherwise. Here the agent can only send verbs, and the
table that reaches the user is rendered from this record. It is the same
reasoning that puts the result header in vmd's words rather than the
agent's.
It is also the progress feed. Every transition is a status line in the
chat, which is far more legible than a step counter, and it costs the
main model nothing because status events never enter the model payload.
"""
from __future__ import annotations
import json
from dataclasses import asdict, dataclass, field
from typing import Literal
Status = Literal["pending", "in_progress", "done", "blocked", "dropped"]
OPEN_STATUSES = frozenset({"pending", "in_progress"})
CLOSED_STATUSES = frozenset({"done", "blocked", "dropped"})
NEEDS_NOTE = frozenset({"blocked", "dropped"})
MIN_TASKS = 2
MAX_TASKS = 20
MAX_TEXT = 160
MAX_NOTE = 400
_GLYPH = {
"pending": "[ ]", "in_progress": "[~]", "done": "[x]",
"blocked": "[!]", "dropped": "[-]",
}
class TaskError(ValueError):
"""Phrased for the agent to read and act on, not for a log."""
@dataclass
class Task:
id: int
text: str
status: Status = "pending"
note: str = ""
added: bool = False # added mid-session rather than planned
@dataclass
class TaskList:
tasks: list[Task] = field(default_factory=list)
planned: bool = False
# -- the verbs -----------------------------------------------------
def plan(self, texts: list[str]) -> str:
if self.planned:
raise TaskError(
"the task list has already been written. Use update_task to "
"change a task's status, or add_task if the work turned out "
"to need a step you did not plan."
)
cleaned = [t.strip() for t in texts if t and t.strip()]
if len(cleaned) < MIN_TASKS:
raise TaskError(
f"give at least {MIN_TASKS} tasks. If the work really is one "
"step, say so as two: what to do, and how you will check it."
)
if len(cleaned) > MAX_TASKS:
raise TaskError(
f"{len(cleaned)} tasks is too many; the limit is {MAX_TASKS}. "
"Group the small steps - each task should be something you "
"could report on, not every command you expect to type."
)
for text in cleaned:
if len(text) > MAX_TEXT:
raise TaskError(
f"one task is {len(text)} characters; keep each under "
f"{MAX_TEXT}. Put the detail in the work, not the title."
)
self.tasks = [Task(id=i, text=t) for i, t in enumerate(cleaned, start=1)]
self.planned = True
return self.render()
def update(self, task_id: int, status: str, note: str = "") -> str:
self._require_planned()
task = self._get(task_id)
if status not in OPEN_STATUSES | CLOSED_STATUSES:
raise TaskError(
f"'{status}' is not a task status. Use in_progress, done, "
"blocked or dropped."
)
note = (note or "").strip()[:MAX_NOTE]
if status in NEEDS_NOTE and not note:
raise TaskError(
f"marking a task '{status}' needs a reason. Say what stopped "
"you, in one sentence - that reason is what the user reads "
"when the work comes back unfinished."
)
task.status = status # type: ignore[assignment]
task.note = note
return self.render()
def add(self, text: str, after_id: int | None = None) -> str:
self._require_planned()
text = (text or "").strip()
if not text:
raise TaskError("a task needs some text.")
if len(text) > MAX_TEXT:
raise TaskError(f"keep the task under {MAX_TEXT} characters.")
if len(self.tasks) >= MAX_TASKS:
raise TaskError(
f"there are already {MAX_TASKS} tasks. Finish or drop one "
"before adding another."
)
# `added` is kept and rendered so the final table shows that the
# plan grew. A list that can be silently extended to match what
# happened is not a standard to be measured against.
new = Task(id=max((t.id for t in self.tasks), default=0) + 1, text=text, added=True)
if after_id is None:
self.tasks.append(new)
else:
index = next((i for i, t in enumerate(self.tasks) if t.id == after_id), len(self.tasks) - 1)
self.tasks.insert(index + 1, new)
return self.render()
# -- queries -------------------------------------------------------
def open_tasks(self) -> list[Task]:
return [t for t in self.tasks if t.status in OPEN_STATUSES]
def why_not_finished(self) -> str | None:
"""The refusal text for finish(), or None if it may proceed."""
if not self.planned:
return (
"there is no task list. Call plan_tasks first and say what "
"you intend to do."
)
still_open = self.open_tasks()
if not still_open:
return None
listed = "\n".join(f" {t.id}. {t.text}" for t in still_open)
return (
f"{len(still_open)} of {len(self.tasks)} tasks are still open:\n{listed}\n\n"
"Finish them, or mark each one blocked or dropped with the reason. "
"An unexplained gap is the one outcome that is not allowed - the "
"user needs to know what was not done and why."
)
def status_line(self) -> str:
"""One line for the chat, e.g. '3/7 · installing dependencies'."""
if not self.planned:
return "planning"
done = sum(1 for t in self.tasks if t.status in CLOSED_STATUSES)
current = next((t for t in self.tasks if t.status == "in_progress"), None)
if current is None:
current = next((t for t in self.tasks if t.status == "pending"), None)
label = current.text if current else "wrapping up"
return f"{done}/{len(self.tasks)} · {label}"
# -- rendering -----------------------------------------------------
def render(self) -> str:
if not self.planned:
return "(no task list yet)"
lines = []
for task in self.tasks:
suffix = ""
if task.note:
suffix = f" - {task.note}"
if task.added:
suffix += " (added mid-session)"
lines.append(f"{_GLYPH[task.status]} {task.id}. {task.text}{suffix}")
return "\n".join(lines)
def summary(self) -> str:
"""One sentence of counts, for the top of the result header."""
if not self.planned:
return "No task list was written."
counts: dict[str, int] = {}
for task in self.tasks:
counts[task.status] = counts.get(task.status, 0) + 1
parts = [f"{counts[k]} {k}" for k in
("done", "blocked", "dropped", "in_progress", "pending") if k in counts]
return f"{len(self.tasks)} tasks: " + ", ".join(parts) + "."
# -- persistence ---------------------------------------------------
def to_json(self) -> str:
return json.dumps({"planned": self.planned,
"tasks": [asdict(t) for t in self.tasks]})
@classmethod
def from_json(cls, blob: str) -> "TaskList":
data = json.loads(blob)
return cls(tasks=[Task(**t) for t in data.get("tasks", [])],
planned=bool(data.get("planned")))
# -- internals -----------------------------------------------------
def _require_planned(self) -> None:
if not self.planned:
raise TaskError("call plan_tasks first: nothing has been written down yet.")
def _get(self, task_id: int) -> Task:
for task in self.tasks:
if task.id == task_id:
return task
known = ", ".join(str(t.id) for t in self.tasks)
raise TaskError(f"there is no task {task_id}. The tasks are: {known}.")