"""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}.")