mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
core/agent: todo stack is LIFO of task groups, not per-task push
push(titles) creates one group of siblings; pop removes the whole top group. Matches breakdown: push[T1,T2] → push[T1.1,T1.2] → pop → push[T2.1].
This commit is contained in:
@@ -11,7 +11,7 @@ _OPEN: frozenset[str] = frozenset({"pending", "in_progress"})
|
||||
|
||||
|
||||
class TodoItem(Struct, omit_defaults=True):
|
||||
"""One entry on the todo stack (top of stack = next to work)."""
|
||||
"""One task inside a group (siblings share a group; not stacked individually)."""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
@@ -19,28 +19,34 @@ class TodoItem(Struct, omit_defaults=True):
|
||||
notes: str = ""
|
||||
|
||||
|
||||
class TodoStackData(Struct, omit_defaults=True):
|
||||
"""Serializable stack: ``items[0]`` is bottom, ``items[-1]`` is **top** (LIFO)."""
|
||||
class TodoGroup(Struct, omit_defaults=True):
|
||||
"""One stack entry: a group of sibling tasks pushed together."""
|
||||
|
||||
items: list[TodoItem] = field(default_factory=list)
|
||||
|
||||
|
||||
class TodoStackData(Struct, omit_defaults=True):
|
||||
"""LIFO of **groups**: ``groups[0]`` bottom, ``groups[-1]`` **TOP** group."""
|
||||
|
||||
groups: list[TodoGroup] = field(default_factory=list)
|
||||
next_id: int = 1
|
||||
|
||||
|
||||
class TodoStack:
|
||||
"""True LIFO stack of sub-tasks (not a queue, not a flat checklist).
|
||||
"""LIFO stack of **task groups** (not a queue of individual tasks).
|
||||
|
||||
- **Top** = last element = next work item.
|
||||
- ``push`` / multi-push: new items go on the **top**. When pushing several
|
||||
titles at once, the **first** title becomes the top (worked first); the
|
||||
rest sit under it in order (DFS: children first, then later siblings).
|
||||
- ``pop``: remove **only the top** item.
|
||||
- **Push** always creates **one new group** containing one or more sibling
|
||||
tasks (``push T1, T2`` is one frame, not two stack levels).
|
||||
- **Pop** removes the entire **top group**.
|
||||
- Within a group, update tasks by id (done / in_progress / …).
|
||||
|
||||
Breakdown pattern::
|
||||
|
||||
push T1, T2 # top=T1, under=T2
|
||||
push T1.1, T1.2 # top=T1.1, then T1.2, then T1, then T2
|
||||
pop / done … # clear T1.1, T1.2, then T1
|
||||
push T2.1 # top=T2.1 over T2
|
||||
push [T1, T2] # top group = {T1, T2}
|
||||
push [T1.1, T1.2] # top group = {T1.1, T1.2}; under = {T1, T2}
|
||||
# finish T1.1 / T1.2 via update…
|
||||
pop # leave child group; top again = {T1, T2}
|
||||
push [T2.1] # top group = {T2.1}
|
||||
"""
|
||||
|
||||
def __init__(self, data: TodoStackData | None = None) -> None:
|
||||
@@ -48,15 +54,19 @@ class TodoStack:
|
||||
self._touched_this_turn: bool = False
|
||||
|
||||
@property
|
||||
def items(self) -> list[TodoItem]:
|
||||
"""Bottom → top order (last is top)."""
|
||||
return self._data.items
|
||||
def groups(self) -> list[TodoGroup]:
|
||||
"""Bottom → top (last is top group)."""
|
||||
return self._data.groups
|
||||
|
||||
@property
|
||||
def top(self) -> TodoItem | None:
|
||||
if not self._data.items:
|
||||
def top_group(self) -> TodoGroup | None:
|
||||
if not self._data.groups:
|
||||
return None
|
||||
return self._data.items[-1]
|
||||
return self._data.groups[-1]
|
||||
|
||||
@property
|
||||
def depth(self) -> int:
|
||||
return len(self._data.groups)
|
||||
|
||||
@property
|
||||
def touched_this_turn(self) -> bool:
|
||||
@@ -68,11 +78,14 @@ class TodoStack:
|
||||
def mark_touched(self) -> None:
|
||||
self._touched_this_turn = True
|
||||
|
||||
def all_items(self) -> list[TodoItem]:
|
||||
return [item for group in self._data.groups for item in group.items]
|
||||
|
||||
def open_items(self) -> list[TodoItem]:
|
||||
return [item for item in self._data.items if item.status in _OPEN]
|
||||
return [item for item in self.all_items() if item.status in _OPEN]
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
return not self._data.items
|
||||
return not self._data.groups
|
||||
|
||||
def needs_review(self) -> bool:
|
||||
return bool(self.open_items()) and not self._touched_this_turn
|
||||
@@ -81,68 +94,93 @@ class TodoStack:
|
||||
return self._data
|
||||
|
||||
@classmethod
|
||||
def from_raw(cls, raw: object | None) -> TodoStack:
|
||||
if raw is None:
|
||||
def from_raw(cls, raw: object | None) -> TodoStack: # noqa: C901, PLR0911
|
||||
if raw is None or not isinstance(raw, dict):
|
||||
return cls()
|
||||
# Nested-frame shape (brief intermediate design) → flatten bottom→top.
|
||||
if isinstance(raw, dict) and "frames" in raw:
|
||||
blob = cast("dict[str, object]", raw)
|
||||
|
||||
# Current shape: {groups: [...], next_id}
|
||||
if "groups" in blob:
|
||||
try:
|
||||
frames_raw = cast("dict[str, object]", raw).get("frames")
|
||||
next_raw = cast("dict[str, object]", raw).get("next_id", 1)
|
||||
data = msgspec.convert(raw, type=TodoStackData)
|
||||
except msgspec.ValidationError, TypeError, ValueError:
|
||||
return cls()
|
||||
if data.next_id < 1:
|
||||
data = msgspec.structs.replace(data, next_id=1)
|
||||
return cls(data)
|
||||
|
||||
# Intermediate nested frames → groups (same structure).
|
||||
if "frames" in blob:
|
||||
try:
|
||||
frames_raw = blob.get("frames")
|
||||
next_raw = blob.get("next_id", 1)
|
||||
next_id = max(1, int(next_raw) if isinstance(next_raw, int | str) else 1)
|
||||
items: list[TodoItem] = []
|
||||
groups: list[TodoGroup] = []
|
||||
if isinstance(frames_raw, list):
|
||||
for frame in cast("list[object]", frames_raw):
|
||||
if not isinstance(frame, dict):
|
||||
continue
|
||||
frame_map = cast("dict[str, object]", frame)
|
||||
frame_items = msgspec.convert(frame_map.get("items"), type=list[TodoItem])
|
||||
items.extend(frame_items)
|
||||
return cls(TodoStackData(items=items, next_id=next_id))
|
||||
if frame_items:
|
||||
groups.append(TodoGroup(items=frame_items))
|
||||
return cls(TodoStackData(groups=groups, next_id=next_id))
|
||||
except msgspec.ValidationError, TypeError, ValueError:
|
||||
return cls()
|
||||
try:
|
||||
data = msgspec.convert(raw, type=TodoStackData)
|
||||
except msgspec.ValidationError, TypeError, ValueError:
|
||||
return cls()
|
||||
if data.next_id < 1:
|
||||
data = msgspec.structs.replace(data, next_id=1)
|
||||
return cls(data)
|
||||
|
||||
# Flat items list → one group (legacy).
|
||||
if "items" in blob:
|
||||
try:
|
||||
items = msgspec.convert(blob.get("items"), type=list[TodoItem])
|
||||
next_raw = blob.get("next_id", 1)
|
||||
next_id = max(1, int(next_raw) if isinstance(next_raw, int | str) else 1)
|
||||
except msgspec.ValidationError, TypeError, ValueError:
|
||||
return cls()
|
||||
groups = [TodoGroup(items=items)] if items else []
|
||||
return cls(TodoStackData(groups=groups, next_id=next_id))
|
||||
|
||||
return cls()
|
||||
|
||||
def to_raw(self) -> dict[str, object]:
|
||||
out: object = msgspec.to_builtins(self._data)
|
||||
if not isinstance(out, dict):
|
||||
return {"items": [], "next_id": 1}
|
||||
return {"groups": [], "next_id": 1}
|
||||
raw = cast("dict[object, object]", out)
|
||||
return {str(key): value for key, value in raw.items()}
|
||||
|
||||
def render(self) -> str:
|
||||
if not self._data.items:
|
||||
return "(todo stack empty — top is empty)"
|
||||
lines: list[str] = ["(LIFO stack: TOP = next to work)"]
|
||||
# Show top first so the model sees work order.
|
||||
for offset, item in enumerate(reversed(self._data.items)):
|
||||
mark = {
|
||||
"pending": "[ ]",
|
||||
"in_progress": "[~]",
|
||||
"done": "[x]",
|
||||
"cancelled": "[-]",
|
||||
}.get(item.status, "[?]")
|
||||
note = f" — {item.notes}" if item.notes else ""
|
||||
if not self._data.groups:
|
||||
return "(todo stack empty — no groups)"
|
||||
lines: list[str] = [
|
||||
f"(LIFO of groups: depth={self.depth}; TOP group = current breakdown level)",
|
||||
]
|
||||
# Top group first for the model.
|
||||
for offset, group in enumerate(reversed(self._data.groups)):
|
||||
depth = self.depth - 1 - offset
|
||||
tag = " TOP" if offset == 0 else ""
|
||||
lines.append(f"{mark}{tag} {item.id}: {item.title}{note}")
|
||||
lines.append(f"group d={depth}{tag}:")
|
||||
if not group.items:
|
||||
lines.append(" (empty group)")
|
||||
continue
|
||||
for item in group.items:
|
||||
mark = {
|
||||
"pending": "[ ]",
|
||||
"in_progress": "[~]",
|
||||
"done": "[x]",
|
||||
"cancelled": "[-]",
|
||||
}.get(item.status, "[?]")
|
||||
note = f" — {item.notes}" if item.notes else ""
|
||||
lines.append(f" {mark} {item.id}: {item.title}{note}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def review_prompt(self) -> str:
|
||||
return (
|
||||
"Todo stack review (internal control — not a human message).\n"
|
||||
"This is a LIFO stack (not a queue): TOP is the only pop target and "
|
||||
"the next sub-task to execute. Open work remains and you did not call "
|
||||
"any todo_* tools this turn.\n"
|
||||
"Breakdown: push children onto the top, finish/pop them, then the "
|
||||
"parent (or next sibling under it) becomes top again. "
|
||||
"push [T1,T2] then push [T1.1,T1.2] then pop finished tops, then "
|
||||
"push [T2.1]…\n\n"
|
||||
"This is a LIFO stack of **task groups** (not a queue of single tasks).\n"
|
||||
"push([T1,T2]) creates one group of siblings; push([T1.1,T1.2]) pushes a "
|
||||
"child group; pop removes the whole top group. Update items by id; "
|
||||
"pop when a breakdown level is finished. Open work remains and you "
|
||||
"did not call any todo_* tools this turn.\n\n"
|
||||
f"Current stack:\n{self.render()}"
|
||||
)
|
||||
|
||||
@@ -151,53 +189,47 @@ class TodoStack:
|
||||
self._data.next_id += 1
|
||||
return TodoItem(id=item_id, title=title, status=status, notes=notes)
|
||||
|
||||
def push_titles(
|
||||
def push_group(
|
||||
self,
|
||||
titles: list[str],
|
||||
*,
|
||||
notes: str = "",
|
||||
status: TodoStatus = "pending",
|
||||
) -> list[TodoItem]:
|
||||
"""Push one or more tasks onto the top (LIFO).
|
||||
|
||||
Titles are pushed so the **first** listed title becomes the new **top**
|
||||
(worked first). Example: ``push_titles(["T1","T2"])`` → top=T1, under=T2.
|
||||
"""
|
||||
) -> TodoGroup:
|
||||
"""Push **one** new group containing all *titles* as siblings."""
|
||||
cleaned = [t.strip() for t in titles if t and t.strip()]
|
||||
if not cleaned:
|
||||
msg = "at least one non-empty title is required"
|
||||
raise ValueError(msg)
|
||||
note = notes.strip()
|
||||
# Push last→first so first title ends on top.
|
||||
created_rev: list[TodoItem] = []
|
||||
for title in reversed(cleaned):
|
||||
item = self._alloc(title, notes=note, status=status)
|
||||
self._data.items.append(item)
|
||||
created_rev.append(item)
|
||||
items = [self._alloc(title, notes=note, status=status) for title in cleaned]
|
||||
group = TodoGroup(items=items)
|
||||
self._data.groups.append(group)
|
||||
self.mark_touched()
|
||||
# Return in the same order as *titles* (first = top).
|
||||
return list(reversed(created_rev))
|
||||
return group
|
||||
|
||||
def push(self, title: str, *, notes: str = "", status: TodoStatus = "pending") -> TodoItem:
|
||||
return self.push_titles([title], notes=notes, status=status)[0]
|
||||
"""Push a group with a single task (still one stack level)."""
|
||||
group = self.push_group([title], notes=notes, status=status)
|
||||
return group.items[0]
|
||||
|
||||
def pop(self) -> TodoItem | None:
|
||||
"""Remove and return the **top** item only (classic stack pop)."""
|
||||
if not self._data.items:
|
||||
def pop(self) -> TodoGroup | None:
|
||||
"""Pop and return the **top group** (all siblings in that push)."""
|
||||
if not self._data.groups:
|
||||
return None
|
||||
item = self._data.items.pop()
|
||||
group = self._data.groups.pop()
|
||||
self.mark_touched()
|
||||
return item
|
||||
return group
|
||||
|
||||
def clear(self) -> int:
|
||||
n = len(self._data.items)
|
||||
self._data.items.clear()
|
||||
n = sum(len(g.items) for g in self._data.groups)
|
||||
self._data.groups.clear()
|
||||
if n:
|
||||
self.mark_touched()
|
||||
return n
|
||||
|
||||
def get(self, item_id: str) -> TodoItem | None:
|
||||
for item in self._data.items:
|
||||
for item in self.all_items():
|
||||
if item.id == item_id:
|
||||
return item
|
||||
return None
|
||||
@@ -210,19 +242,20 @@ class TodoStack:
|
||||
status: TodoStatus | None = None,
|
||||
notes: str | None = None,
|
||||
) -> TodoItem:
|
||||
for index, item in enumerate(self._data.items):
|
||||
if item.id != item_id:
|
||||
continue
|
||||
new_title = title.strip() if title is not None else item.title
|
||||
if not new_title:
|
||||
msg = "title must not be empty"
|
||||
raise ValueError(msg)
|
||||
new_status = status if status is not None else item.status
|
||||
new_notes = notes if notes is not None else item.notes
|
||||
updated = TodoItem(id=item.id, title=new_title, status=new_status, notes=new_notes)
|
||||
self._data.items[index] = updated
|
||||
self.mark_touched()
|
||||
return updated
|
||||
for group in self._data.groups:
|
||||
for index, item in enumerate(group.items):
|
||||
if item.id != item_id:
|
||||
continue
|
||||
new_title = title.strip() if title is not None else item.title
|
||||
if not new_title:
|
||||
msg = "title must not be empty"
|
||||
raise ValueError(msg)
|
||||
new_status = status if status is not None else item.status
|
||||
new_notes = notes if notes is not None else item.notes
|
||||
updated = TodoItem(id=item.id, title=new_title, status=new_status, notes=new_notes)
|
||||
group.items[index] = updated
|
||||
self.mark_touched()
|
||||
return updated
|
||||
msg = f"unknown todo id {item_id!r}"
|
||||
raise KeyError(msg)
|
||||
|
||||
|
||||
+11
-14
@@ -952,11 +952,11 @@ def history_cmd(
|
||||
def todos_cmd( # noqa: C901, PLR0911, PLR0912, PLR0915
|
||||
state: ReplState, action: str | None, rest: tuple[str, ...]
|
||||
) -> None:
|
||||
"""Show or edit the LIFO todo stack (TOP = next work / only pop target).
|
||||
"""Show or edit the LIFO stack of **task groups**.
|
||||
|
||||
``/todos`` — list (top first)
|
||||
``/todos push <title>`` or ``T1; T2`` — push (first title becomes TOP)
|
||||
``/todos pop`` — pop TOP only
|
||||
``/todos`` — list (top group first)
|
||||
``/todos push T1; T2`` — one new group of siblings
|
||||
``/todos pop`` — pop entire top group
|
||||
``/todos done <id>`` / ``/todos cancel <id>`` — set status
|
||||
``/todos clear`` — wipe stack
|
||||
"""
|
||||
@@ -977,26 +977,23 @@ def todos_cmd( # noqa: C901, PLR0911, PLR0912, PLR0915
|
||||
click.echo("error: no titles to push")
|
||||
return
|
||||
try:
|
||||
items = stack.push_titles(titles)
|
||||
group = stack.push_group(titles)
|
||||
except ValueError as exc:
|
||||
click.echo(f"error: {exc}")
|
||||
return
|
||||
_await(state.persist_todo_stack())
|
||||
ids = ", ".join(i.id for i in items)
|
||||
top = stack.top
|
||||
top_s = f"{top.id}:{top.title}" if top else "?"
|
||||
click.echo(f"pushed [{ids}] (top now {top_s})")
|
||||
ids = ", ".join(i.id for i in group.items)
|
||||
click.echo(f"pushed group depth={stack.depth} items=[{ids}]")
|
||||
click.echo(stack.render())
|
||||
return
|
||||
if act == "pop":
|
||||
item = stack.pop()
|
||||
if item is None:
|
||||
group = stack.pop()
|
||||
if group is None:
|
||||
click.echo("todo stack empty")
|
||||
return
|
||||
_await(state.persist_todo_stack())
|
||||
top = stack.top
|
||||
top_s = f"{top.id}:{top.title}" if top else "(empty)"
|
||||
click.echo(f"popped TOP {item.id}: {item.title}; new top={top_s}")
|
||||
titles = ", ".join(f"{i.id}:{i.title}" for i in group.items) or "(empty)"
|
||||
click.echo(f"popped TOP group ({titles})")
|
||||
click.echo(stack.render())
|
||||
return
|
||||
if act in {"done", "cancel", "pending", "in_progress"}:
|
||||
|
||||
+19
-20
@@ -41,10 +41,10 @@ def _notify() -> None:
|
||||
|
||||
@tool(name="todo_list")
|
||||
def todo_list() -> str:
|
||||
"""Show the LIFO todo stack (TOP = next sub-task to work / pop).
|
||||
"""Show the LIFO stack of **task groups** (TOP group = current breakdown level).
|
||||
|
||||
Not a queue: only the top is popped. Breakdown: push children on top of a
|
||||
parent, finish and pop them, then the parent (or next sibling) is top again.
|
||||
Push creates one group of siblings; pop removes the whole top group.
|
||||
Pattern: push [T1,T2] → push [T1.1,T1.2] → finish children → pop → push [T2.1]…
|
||||
"""
|
||||
stack = _require_stack()
|
||||
stack.mark_touched()
|
||||
@@ -54,38 +54,37 @@ def todo_list() -> str:
|
||||
|
||||
@tool(name="todo_push")
|
||||
def todo_push(titles: str, notes: str = "") -> str:
|
||||
"""Push task(s) onto the **top** of the LIFO stack.
|
||||
"""Push **one task group** (siblings) onto the stack — not one level per title.
|
||||
|
||||
``titles``: one title, newlines, ``;``, or JSON array. First title becomes
|
||||
the new TOP (worked first). Example: ``T1\\nT2`` → top=T1, under=T2; then
|
||||
``T1.1\\nT1.2`` → top=T1.1 over T1.2 over T1 over T2.
|
||||
``titles``: one title, newlines, ``;``, or JSON array. All listed titles
|
||||
become members of a single new TOP group. Example: ``T1\\nT2`` pushes one
|
||||
group {T1, T2}; a later ``T1.1\\nT1.2`` pushes a child group above it.
|
||||
"""
|
||||
stack = _require_stack()
|
||||
parsed = parse_push_titles(titles)
|
||||
if not parsed:
|
||||
return "error: titles must contain at least one non-empty title"
|
||||
try:
|
||||
items = stack.push_titles(parsed, notes=notes)
|
||||
group = stack.push_group(parsed, notes=notes)
|
||||
except ValueError as exc:
|
||||
return f"error: {exc}"
|
||||
_notify()
|
||||
top = stack.top
|
||||
top_s = f"{top.id}:{top.title}" if top else "?"
|
||||
ids = ", ".join(i.id for i in items)
|
||||
return f"pushed [{ids}] (top now {top_s})\n{stack.render()}"
|
||||
ids = ", ".join(i.id for i in group.items)
|
||||
return f"pushed group (depth={stack.depth}) items=[{ids}]\n{stack.render()}"
|
||||
|
||||
|
||||
@tool(name="todo_pop")
|
||||
def todo_pop() -> str:
|
||||
"""Pop the **top** item only (classic stack). Does not remove items under it."""
|
||||
"""Pop the entire **top group** (all siblings from that push)."""
|
||||
stack = _require_stack()
|
||||
item = stack.pop()
|
||||
if item is None:
|
||||
group = stack.pop()
|
||||
if group is None:
|
||||
return "todo stack empty"
|
||||
_notify()
|
||||
top = stack.top
|
||||
top_s = f"{top.id}:{top.title}" if top else "(empty)"
|
||||
return f"popped TOP {item.id}: {item.title} ({item.status}); new top={top_s}\n{stack.render()}"
|
||||
titles = ", ".join(f"{i.id}:{i.title}" for i in group.items) or "(empty)"
|
||||
top = stack.top_group
|
||||
top_s = "(empty)" if top is None else ", ".join(i.id for i in top.items)
|
||||
return f"popped TOP group ({titles}); new top group=[{top_s}]\n{stack.render()}"
|
||||
|
||||
|
||||
@tool(name="todo_update")
|
||||
@@ -95,7 +94,7 @@ def todo_update(
|
||||
title: str = "",
|
||||
notes: str = "",
|
||||
) -> str:
|
||||
"""Update a todo by id (any depth). Prefer pop when the top task is finished."""
|
||||
"""Update a task by id inside any group. Pop the group when that level is done."""
|
||||
stack = _require_stack()
|
||||
status_arg: TodoStatus | None = None
|
||||
if status.strip():
|
||||
@@ -118,7 +117,7 @@ def todo_update(
|
||||
|
||||
@tool(name="todo_clear")
|
||||
def todo_clear() -> str:
|
||||
"""Clear the entire stack."""
|
||||
"""Clear all groups on the stack."""
|
||||
stack = _require_stack()
|
||||
n = stack.clear()
|
||||
_notify()
|
||||
|
||||
Reference in New Issue
Block a user