feat(todo): nested subtasks via optional parent field

The todo tool now supports hierarchical task lists: an item's optional
'parent' field points at another item's id, making it a subtask.

- tools/todo_tool.py: parent validated (self-ref dropped), dangling refs
  and cycles sanitized; merge mode can set/clear parent; post-compression
  injection renders the tree indented and keeps a finished parent visible
  while any descendant is still active; the in-progress reorder pass is
  skipped for nested lists (a flat move would tear subtasks from parents).
- Schema cost: ~45 tokens added to the cached tool schema (one string
  property + one behavior sentence).
- acp_adapter/tools.py: todo result markdown indents by parent depth.
- Desktop: TodoItem carries parent; todoTree() DFS helper; composer
  status stack renders subtask rows indented (depth-capped), stabilizer
  compares depth.
- Docs: tools-reference todo entry mentions nesting.

Hydration/replay paths (gateway fresh-agent, API-server history) work
unchanged: parent rides inside the same todos array.
This commit is contained in:
Teknium
2026-08-29 06:47:26 -07:00
parent d5632392c7
commit b6bd681e89
8 changed files with 376 additions and 22 deletions
+18 -4
View File
@@ -274,13 +274,27 @@ def _format_todo_result(result: Optional[str]) -> Optional[str]:
"cancelled": "✗",
}
lines = ["**Todo list**", ""]
for item in data["todos"]:
if not isinstance(item, dict):
continue
todos = [t for t in data["todos"] if isinstance(t, dict)]
ids = {str(t.get("id") or "") for t in todos}
def _depth(item: Dict[str, Any]) -> int:
depth, seen = 0, set()
node: Optional[Dict[str, Any]] = item
by_id = {str(t.get("id") or ""): t for t in todos}
while node is not None:
parent = str(node.get("parent") or "")
if not parent or parent not in ids or parent in seen:
break
seen.add(parent)
depth += 1
node = by_id.get(parent)
return min(depth, 4)
for item in todos:
status = str(item.get("status") or "pending")
content = str(item.get("content") or item.get("id") or "").strip()
if content:
lines.append(f"- {icon.get(status, '•')} {content}")
lines.append(f"{' ' * _depth(item)}- {icon.get(status, '•')} {content}")
if summary:
cancelled = summary.get("cancelled", 0)
lines.extend([
@@ -114,7 +114,15 @@ export const StatusItemRow = memo(function StatusItemRow({ item, onDismiss, onOp
return (
<Fragment>
<StatusRow
leading={leadingGlyph(item, s)}
leading={
item.depth ? (
<span className="flex items-center" style={{ paddingLeft: `${Math.min(item.depth, 4) * 0.8}rem` }}>
{leadingGlyph(item, s)}
</span>
) : (
leadingGlyph(item, s)
)
}
onActivate={onActivate}
trailing={
action ? (
+46 -1
View File
@@ -1,6 +1,51 @@
import { describe, expect, it } from 'vitest'
import { latestSessionTodos, parseTodos } from './todos'
import { latestSessionTodos, parseTodos, todoTree } from './todos'
describe('todoTree', () => {
it('orders parents before children with depths', () => {
const tree = todoTree([
{ content: 'WP1', id: 'wp1', status: 'in_progress' },
{ content: 'WP2', id: 'wp2', status: 'pending' },
{ content: 'T1', id: 't1', parent: 'wp1', status: 'pending' },
{ content: 'T2', id: 't2', parent: 'wp1', status: 'pending' }
])
expect(tree.map(([t, d]) => [t.id, d])).toEqual([
['wp1', 0],
['t1', 1],
['t2', 1],
['wp2', 0]
])
})
it('degrades dangling and self parents to roots', () => {
const tree = todoTree([
{ content: 'A', id: 'a', parent: 'ghost', status: 'pending' },
{ content: 'B', id: 'b', parent: 'b', status: 'pending' }
])
expect(tree.map(([t, d]) => [t.id, d])).toEqual([
['a', 0],
['b', 0]
])
})
it('keeps cycle members instead of dropping them', () => {
const tree = todoTree([
{ content: 'A', id: 'a', parent: 'b', status: 'pending' },
{ content: 'B', id: 'b', parent: 'a', status: 'pending' }
])
expect(tree.map(([t]) => t.id).sort()).toEqual(['a', 'b'])
})
it('preserves parent through parseTodos', () => {
expect(parseTodos([{ content: 'x', id: 'c', parent: 'p', status: 'pending' }])).toEqual([
{ content: 'x', id: 'c', parent: 'p', status: 'pending' }
])
})
})
describe('parseTodos', () => {
it('parses todo arrays with valid ids, content, and statuses', () => {
+52 -1
View File
@@ -3,6 +3,8 @@ export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled'
export interface TodoItem {
content: string
id: string
/** Optional id of another item — renders this as a nested subtask. */
parent?: string
status: TodoStatus
}
@@ -19,8 +21,9 @@ function parseArray(value: unknown[]): TodoItem[] {
const id = String(item.id ?? '').trim()
const content = String(item.content ?? '').trim()
const parent = String(item.parent ?? '').trim()
return id && content ? [{ content, id, status: item.status }] : []
return id && content ? [{ content, id, status: item.status, ...(parent && parent !== id ? { parent } : {}) }] : []
})
}
@@ -50,6 +53,54 @@ function parse(value: unknown, depth: number): null | TodoItem[] {
export const parseTodos = (value: unknown): null | TodoItem[] => parse(value, 0)
/** DFS order of a (possibly nested) todo list: [item, depth] pairs, parents
* before children. Dangling/cyclic parents degrade to depth 0. */
export function todoTree(todos: readonly TodoItem[]): [TodoItem, number][] {
const ids = new Set(todos.map(t => t.id))
const kids = new Map<string, TodoItem[]>()
const roots: TodoItem[] = []
for (const t of todos) {
if (t.parent && ids.has(t.parent) && t.parent !== t.id) {
const list = kids.get(t.parent) ?? []
list.push(t)
kids.set(t.parent, list)
} else {
roots.push(t)
}
}
const out: [TodoItem, number][] = []
const seen = new Set<string>()
const walk = (item: TodoItem, depth: number) => {
if (seen.has(item.id)) {
return
}
seen.add(item.id)
out.push([item, depth])
for (const kid of kids.get(item.id) ?? []) {
walk(kid, depth + 1)
}
}
for (const root of roots) {
walk(root, 0)
}
// Cycle members never reach a root — append them flat so nothing is lost.
for (const t of todos) {
if (!seen.has(t.id)) {
seen.add(t.id)
out.push([t, 0])
}
}
return out
}
/** Latest parseable todo list from one message's aui content parts (tool-call
* parts named `todo`; live parts carry `todos`, hydrated ones args/result). */
export function todosFromMessageContent(content: unknown): null | TodoItem[] {
+7 -3
View File
@@ -3,7 +3,7 @@ import { atom, computed } from 'nanostores'
import { translateNow } from '@/i18n'
import { stableArray } from '@/lib/stable-array'
import type { TodoItem, TodoStatus } from '@/lib/todos'
import { type TodoItem, type TodoStatus, todoTree } from '@/lib/todos'
import { $gateway } from './gateway'
import { $goalsBySession, type GoalStatus } from './goals'
@@ -23,6 +23,8 @@ export interface ComposerStatusItem {
exitCode?: number
/** subagent: active tool label shown on the right. */
currentTool?: string
/** todo: nesting depth (0 = top-level) for indented subtask rows. */
depth?: number
/** goal: active | paused | waiting | done. */
goalStatus?: GoalStatus
id: string
@@ -146,7 +148,8 @@ const subToItem = (s: SubagentProgress): ComposerStatusItem => ({
type: 'subagent'
})
const todoToItem = (t: TodoItem): ComposerStatusItem => ({
const todoToItem = (t: TodoItem, depth: number): ComposerStatusItem => ({
depth,
id: `todo:${t.id}`,
state: t.status === 'in_progress' ? 'running' : 'done',
title: t.content,
@@ -183,6 +186,7 @@ const sameStatusItem = (a: ComposerStatusItem, b: ComposerStatusItem) =>
a.currentTool === b.currentTool &&
a.goalStatus === b.goalStatus &&
a.todoStatus === b.todoStatus &&
a.depth === b.depth &&
a.sessionId === b.sessionId
const stabilizeItems = (prev: ComposerStatusItem[] | undefined, next: ComposerStatusItem[]): ComposerStatusItem[] => {
@@ -209,7 +213,7 @@ export const $statusItemsBySession = computed(
}
for (const [sid, list] of Object.entries(todos)) {
push(sid, list.map(todoToItem))
push(sid, todoTree(list).map(([t, depth]) => todoToItem(t, depth)))
}
for (const [sid, goal] of Object.entries(goals)) {
+167
View File
@@ -0,0 +1,167 @@
"""Tests for nested subtasks in the todo tool (parent field).
Covers: validation of the parent field, dangling/cyclic parent
sanitization, merge-mode parent updates, tree-aware post-compression
injection, and the flat-only reorder guard.
"""
import json
from tools.todo_tool import TodoStore, todo_tool
def _item(i, status="pending", parent=None, content=None):
d = {"id": i, "content": content or f"task {i}", "status": status}
if parent is not None:
d["parent"] = parent
return d
class TestParentValidation:
def test_parent_preserved(self):
store = TodoStore()
items = store.write([_item("a"), _item("a1", parent="a")])
assert items[1]["parent"] == "a"
def test_self_parent_dropped(self):
store = TodoStore()
items = store.write([_item("a", parent="a")])
assert "parent" not in items[0]
def test_dangling_parent_dropped(self):
store = TodoStore()
items = store.write([_item("a", parent="ghost")])
assert "parent" not in items[0]
def test_cycle_broken(self):
store = TodoStore()
items = store.write([_item("a", parent="b"), _item("b", parent="a")])
# At least one link removed; no item can walk a loop
by_id = {i["id"]: i for i in items}
for item in items:
seen = set()
node = item
while node.get("parent"):
assert node["parent"] not in seen
seen.add(node["id"])
node = by_id[node["parent"]]
def test_empty_parent_omitted(self):
store = TodoStore()
items = store.write([_item("a", parent="")])
assert "parent" not in items[0]
class TestMergeParent:
def test_merge_sets_parent(self):
store = TodoStore()
store.write([_item("a"), _item("b")])
items = store.write([{"id": "b", "parent": "a"}], merge=True)
by_id = {i["id"]: i for i in items}
assert by_id["b"]["parent"] == "a"
def test_merge_clears_parent_with_empty_string(self):
store = TodoStore()
store.write([_item("a"), _item("b", parent="a")])
items = store.write([{"id": "b", "parent": ""}], merge=True)
by_id = {i["id"]: i for i in items}
assert "parent" not in by_id["b"]
def test_merge_new_child_appended(self):
store = TodoStore()
store.write([_item("a")])
items = store.write([_item("a2", parent="a")], merge=True)
assert items[-1] == {"id": "a2", "content": "task a2", "status": "pending", "parent": "a"}
class TestNestedInjection:
def test_children_indented(self):
store = TodoStore()
store.write([
_item("wp1", status="in_progress"),
_item("t1", parent="wp1"),
_item("t2", parent="wp1"),
])
text = store.format_for_injection()
assert text is not None
lines = text.split("\n")
assert lines[1].startswith("- [>] wp1.")
assert lines[2].startswith(" - [ ] t1.")
assert lines[3].startswith(" - [ ] t2.")
def test_completed_parent_kept_when_child_active(self):
store = TodoStore()
store.write([
_item("wp1", status="completed"),
_item("t1", status="pending", parent="wp1"),
])
text = store.format_for_injection()
assert text is not None
assert "wp1" in text # parent context survives
assert "[x]" in text
assert " - [ ] t1." in text
def test_finished_subtree_omitted(self):
store = TodoStore()
store.write([
_item("wp1", status="completed"),
_item("t1", status="completed", parent="wp1"),
_item("wp2", status="pending"),
])
text = store.format_for_injection()
assert text is not None
assert "wp1" not in text
assert "t1" not in text
assert "wp2" in text
def test_all_finished_returns_none(self):
store = TodoStore()
store.write([
_item("a", status="completed"),
_item("a1", status="cancelled", parent="a"),
])
assert store.format_for_injection() is None
def test_flat_injection_unchanged(self):
store = TodoStore()
store.write([_item("a", status="in_progress"), _item("b")])
text = store.format_for_injection()
assert text is not None
assert text.split("\n")[1] == "- [>] a. task a (in_progress)"
class TestOrderGuard:
def test_nested_list_keeps_authored_order(self):
store = TodoStore()
items = store.write([
_item("a", status="pending"),
_item("b", status="in_progress"),
_item("b1", parent="b"),
])
assert [i["id"] for i in items] == ["a", "b", "b1"]
def test_flat_list_still_reordered(self):
store = TodoStore()
items = store.write([
_item("a", status="pending"),
_item("b", status="in_progress"),
])
assert [i["id"] for i in items] == ["b", "a"]
class TestToolRoundTrip:
def test_parent_survives_json(self):
store = TodoStore()
out = todo_tool(todos=[_item("a"), _item("a1", parent="a")], store=store)
data = json.loads(out)
assert data["todos"][1]["parent"] == "a"
assert data["summary"]["total"] == 2
def test_hydration_replay_preserves_parent(self):
# Simulate _hydrate_todo_store: write the previous tool result's
# todos array back into a fresh store in replace mode.
store = TodoStore()
out = json.loads(todo_tool(todos=[_item("a"), _item("a1", parent="a")], store=store))
fresh = TodoStore()
replayed = fresh.write(out["todos"], merge=False)
assert replayed[1]["parent"] == "a"
+76 -11
View File
@@ -51,6 +51,7 @@ class TodoStore:
- id: unique string identifier (agent-chosen)
- content: task description
- status: pending | in_progress | completed | cancelled
- parent: optional id of another item, for nested subtasks
"""
def __init__(self):
@@ -86,6 +87,12 @@ class TodoStore:
status = str(t["status"]).strip().lower()
if status in VALID_STATUSES:
existing[item_id]["status"] = status
if "parent" in t:
parent = str(t["parent"] or "").strip()
if parent:
existing[item_id]["parent"] = parent
else:
existing[item_id].pop("parent", None)
else:
# New item -- validate fully and append to end
validated = self._validate(t)
@@ -105,6 +112,7 @@ class TodoStore:
# (list order is priority).
if len(self._items) > MAX_TODO_ITEMS:
self._items = self._items[:MAX_TODO_ITEMS]
self._sanitize_parents(self._items)
return self.read()
def read(self) -> List[Dict[str, str]]:
@@ -134,18 +142,39 @@ class TodoStore:
}
# Only inject pending/in_progress items — completed/cancelled ones
# cause the model to re-do finished work after compression.
active_items = [
item for item in self._items
if item["status"] in {"pending", "in_progress"}
]
if not active_items:
return None
# cause the model to re-do finished work after compression. A parent
# is kept (with its real status marker) when any descendant is
# active, so subtasks keep their context.
active = {"pending", "in_progress"}
children: Dict[str, List[Dict[str, str]]] = {}
roots: List[Dict[str, str]] = []
for item in self._items:
parent = item.get("parent")
if parent:
children.setdefault(parent, []).append(item)
else:
roots.append(item)
def render(item: Dict[str, str], depth: int, out: List[str]) -> bool:
kid_lines: List[str] = []
has_active_kid = False
for kid in children.get(item["id"], []):
has_active_kid |= render(kid, depth + 1, kid_lines)
keep = item["status"] in active or has_active_kid
if keep:
marker = markers.get(item["status"], "[?]")
out.append(
f"{' ' * depth}- {marker} {item['id']}. "
f"{item['content']} ({item['status']})"
)
out.extend(kid_lines)
return keep
lines = [TODO_INJECTION_HEADER]
for item in active_items:
marker = markers.get(item["status"], "[?]")
lines.append(f"- {marker} {item['id']}. {item['content']} ({item['status']})")
for item in roots:
render(item, 0, lines)
if len(lines) == 1:
return None
return "\n".join(lines)
@@ -187,7 +216,34 @@ class TodoStore:
if status not in VALID_STATUSES:
status = "pending"
return {"id": item_id, "content": content, "status": status}
result = {"id": item_id, "content": content, "status": status}
parent = str(item.get("parent") or "").strip()
if parent and parent != item_id:
result["parent"] = parent
return result
@staticmethod
def _sanitize_parents(items: List[Dict[str, str]]) -> None:
"""Drop dangling parent refs and break cycles (in place).
A parent pointing at a missing id, or a chain that loops back on
itself, would corrupt tree rendering — such items become roots.
"""
ids = {item["id"] for item in items}
by_id = {item["id"]: item for item in items}
for item in items:
parent = item.get("parent")
if parent and parent not in ids:
item.pop("parent", None)
for item in items:
seen = {item["id"]}
node = item
while node.get("parent"):
if node["parent"] in seen:
item.pop("parent", None)
break
seen.add(node["parent"])
node = by_id[node["parent"]]
@staticmethod
def _dedupe_by_id(todos: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
@@ -205,6 +261,10 @@ class TodoStore:
@staticmethod
def _normalize_order(items: List[Dict[str, str]]) -> List[Dict[str, str]]:
"""Lift the active step ahead of any earlier unfinished placeholders."""
# Nested lists keep authored order — reordering a flat position would
# tear a subtask away from its siblings.
if any(item.get("parent") for item in items):
return items
active_index = next(
(i for i, item in enumerate(items) if item["status"] == "in_progress"),
None,
@@ -303,6 +363,7 @@ TODO_SCHEMA = {
"item so none are silently dropped. "
"Call with no parameters to read the current list.\n"
"List order is priority. Only ONE item in_progress at a time. "
"Break large phases into subtasks via parent. "
"Mark an item completed only after the work is verified done, never "
"based on intent. If something fails, cancel it and add a revised "
"item. Always returns the full current list."
@@ -326,6 +387,10 @@ TODO_SCHEMA = {
"status": {
"type": "string",
"enum": ["pending", "in_progress", "completed", "cancelled"]
},
"parent": {
"type": "string",
"description": "Optional id of another item, making this a nested subtask. Omit for top-level."
}
},
"required": ["id", "content", "status"]
+1 -1
View File
@@ -278,7 +278,7 @@ hours of quiet from the rotation.
| Tool | Description | Requires environment |
|------|-------------|----------------------|
| `todo` | Manage your task list for the current session. Use for complex tasks with 3+ steps or when the user provides multiple tasks. Call with no parameters to read the current list. Writing: - Provide 'todos' array to create/update items - merge=… | — |
| `todo` | Manage your task list for the current session. Use for complex tasks with 3+ steps or when the user provides multiple tasks. Call with no parameters to read the current list. Items may nest: an item's optional `parent` field points at another item's id, making it a subtask — surfaces render the tree indented. | — |
## `vision` toolset