0004. Model the plan as a depth-ordered tree and add a per-turn editable Objective
Context and problem statement
The Planning panel (formerly “To Dos”) needs two things it could not express before: an Objective (the agent’s evolving, one-line understanding of the user’s goal) and a hierarchical plan (1, 1.1, 1.1.1, 2 …) instead of a flat bullet list. Both must round-trip through the existing snapshot/branch machinery so that “Continue from here” on an earlier node shows the objective and plan exactly as they were then (thesnapshot(k+1)-or-live invariant from
ADR-0003).
The tension is in the data model: branch rows get fresh primary keys, and
snapshots serialise the workspace to plain JSON, so any representation of the
plan’s tree shape and of the objective has to survive both copies cleanly.
Decision drivers
- Branch/restore fidelity. Going back to a node must reproduce its objective
and plan; everything must ride the existing
resolve_restore_state/restore_workspacepath with no new copy mechanism. - Snapshot portability.
workspace_snapshots.todos_jsonis a flat JSON array; the tree must serialise into it and re-materialise without depending on row ids (which change on a branch). - Backward compatibility. Existing rows and pre-feature snapshots must remain valid (additive columns, sensible defaults).
- Tool ergonomics. The agent must address and nest steps unambiguously, and toggling a step must stay stable across a turn.
- Minimal blast radius on the SSE turn loop, the dynamic prompt, and the graph builder.
Considered options
- Plan model: depth column + global pre-order
position. Keep the flat, orderedtodosrows; add onedepthinteger. The ordered list is the depth-first serialisation of the tree. - Plan model:
parent_idself-FK. Maketodosan adjacency list with a nullable self-referential parent. - Objective storage:
objectivestable (one row/thread) + snapshot column. Mirror thedocumentspattern. - Objective storage: LangGraph checkpointer state. Carry the objective in the agent’s message-channel state.
Decision outcome
Chosen: Option 1 (depth column) for the plan, and Option 3 (dedicatedobjectives table + workspace_snapshots.objective_text) for the objective.
The flat, depth-annotated, pre-ordered list is literally the DFS serialisation
of the tree, so it drops into todos_json with one extra scalar key and needs
no parent-id remapping when a branch re-materialises the rows. The objective
mirrors the document: a per-thread row the agent writes via set_objective and
the user edits via PUT /objective, snapshotted per turn so it restores for free
on a branch. Dotted numbering (1, 1.1, …) is derived at render time from depth
(compute_dotted_numbers), so arbitrary nesting depth costs nothing in storage.
The agent nests with add_todos(parent_index=…) and still addresses every step by
the stable 1-based flat index that toggle_todos already used; dotted numbers are
display-only (the mutating todo tools are batch-only, each taking a list of steps).
Positive consequences
- Snapshot round-trip is trivial:
{text, done, position, depth}in, the same out; pre-feature snapshots defaultdepthto 0 (flat) andobjective_texttoNone, exactly like the pre-locklockeddefault. - No new branch-copy code: objective + depth flow through the existing
resolve_restore_state/restore_workspace/delete_all_for_thread. - Arbitrary nesting depth with no schema change; the depth cap (if ever wanted) is a render/validation constant, not a migration.
Negative consequences
add_todosmust compute a pre-order insertion point and shift later positions when nesting under a non-terminal parent (one extraUPDATE).- Drag-reorder stays flat / position-only (depth preserved); moving a subtree to a new parent from the UI is deferred. An orphaned deeper item (e.g. a sub-step dragged above its parent) is clamped at render so numbering stays legible.
- Progress is counted leaf-only (a step with no nested children) so a checked parent doesn’t inflate the bar; the prompt’s notion of “complete” matches this.
Pros and cons of the options
Option 1: depth column (chosen)
- + Flat ordered+depth list = DFS serialisation; ids irrelevant on restore.
- + One additive column; pre-feature rows default to a flat list.
- − Non-terminal nesting needs a position shift; cross-level drag deferred.
Option 2: parent_id self-FK
- + Canonical tree; subtree moves are well-defined.
- − Snapshot JSON has no stable ids, so a positional parent reference must be serialised anyway, reinventing depth/index inside the JSON.
- − Restore must two-pass insert and remap
parent_idto new row ids; forces anON DELETEcascade-vs-orphan decision.
Option 3: objectives table + snapshot column (chosen)
- + Mirrors
documents; user-editable; restores on a branch for free. - + Lives outside the message channel, so it never bloats checkpointer state.
Option 4: objective in checkpointer state
- − Per-node restore of a checkpointer field is awkward; couples the objective to message history and duplicates it every turn.
Links
- Related ADRs: 0003-conversation-branching-via-new-thread-and-state-seeding
- Related issues / PRs: #35 (Objective + rename), #37 (plan-and-execute autonomy)
- Code:
backend/app/db/models.py(Objective,Todo.depth,WorkspaceSnapshot.objective_text),backend/app/tools/objective.py,backend/app/tools/todos.py,backend/app/tools/_planning_format.py,backend/app/services/branching.py,backend/alembic/versions/0007_planning_objective.py,frontend/src/components/app/todo-panel.tsx - Docs: agent-core-logic.md, conversation-branching.md, tools.md