226 lines
9.0 KiB
Python
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}.")
|