Initial Commit
This commit is contained in:
+225
@@ -0,0 +1,225 @@
|
||||
"""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}.")
|
||||
Reference in New Issue
Block a user