# Goals and Todos `tinyagents-graph` ships two **per-thread productivity primitives** — a durable **goal** (`tinyagents_graph::goals`) and a **todo list** (`tinyagents_graph::todos`). They let a graph or agent carry intent across turns: *what* it is trying to achieve (one objective) and *the concrete steps* it is working through (a checklist). Both are provider-neutral, offline-testable, and persist on the same [`tinyagents_harness::store::Store`](Harness) abstraction the rest of the harness uses. The primitives are pure runtime state plus harness tools, driven off the [Graph Runtime](Graph-Runtime). - **Goal** — exactly one durable objective *per thread* (a "completion contract"). A model creates/replaces it, works it across turns, and marks it complete. - **Todo list** — an ordered checklist of steps per thread, each `pending`, `in_progress` or `completed`, with a single-`in_progress` invariant. A goal is the intent; the list holds the steps. They are independent (no code-level containment) but pair naturally: both keyed by the same `thread_id`, persisted side by side. ```mermaid flowchart LR subgraph Thread["thread: release-thread"] Goal["ThreadGoal
“Ship the v2 release”
status=active, budget=100k"] Todos["TodoList
[x] changelog
[~] tag release
[ ] publish"] end Goal -. drives .-> Todos ``` --- ## `tinyagents_graph::goals` — the per-thread goal ### Data model A [`ThreadGoal`] is one objective per thread with a small lifecycle: | Status | Meaning | | --- | --- | | `Active` | The graph may make progress and (when driven) auto-continue. | | `Paused` | Suspended by a host; the objective persists, reactivated on resume. | | `BudgetLimited` | The token budget was reached; substantive work halts. | | `Complete` | Evidence confirms the objective is satisfied. | Ownership is **asymmetric**: a model may create/replace a goal and mark it `Complete`; `Paused` / `BudgetLimited` are system-driven (host control and accounting). The goal carries an optional `token_budget`, cumulative `tokens_used` / `time_used_seconds`, and a `continuation_suppressed` one-shot flag (see [Continuation](#continuation-keeping-a-goal-moving)). ```rust use std::sync::Arc; use tinyagents_graph::goal_store; use tinyagents_harness::store::{InMemoryStore, Store}; let store: Arc = Arc::new(InMemoryStore::default()); // Create or replace the thread's objective, with a token budget. let goal = goal_store::set(&store, "thread-1", "Ship the v2 release", Some(100_000)).await?; assert_eq!(goal.status.as_str(), "active"); // Read it back; mark complete when the objective is met. let current = goal_store::get(&store, "thread-1").await?.unwrap(); goal_store::complete(&store, "thread-1").await?; ``` `set` mints a fresh `goal_id` and resets counters when the objective **changes**; a same-objective re-set preserves counters and re-opens to `Active` (unless still over budget). `account_usage` folds token/time usage and flips an active goal to `BudgetLimited` at the cap; its `expected_goal_id` **compare-and-set** guard silently drops stale accounting from a goal that was already replaced. ### Tools `goal_get`, `goal_set`, and `goal_complete` are the default model-facing tools (`goal_tools` / `register_goal_tools`); `goal_pause` / `goal_resume` / `goal_clear` are host controls. The target thread is resolved from the tool execution context — never a tool argument — so a model cannot address another thread's goal. `register_goal_tools` takes a `&mut ToolRegistry` directly (useful when building a registry standalone); against an `AgentHarness`, register each tool from `goal_tools` individually: ```rust use tinyagents_graph::goal_tools; use tinyagents_harness::runtime::AgentHarness; let mut harness: AgentHarness<()> = AgentHarness::new(); for tool in goal_tools(store.clone()) { harness.register_tool(tool); // goal_get/goal_set/goal_complete } ``` ### Continuation (keeping a goal moving) The interesting part: how a stored goal keeps *driving work* without an external scheduler. There is no heartbeat process and no ambient agent, so continuation is expressed **on the graph runtime**, three ways. #### 1. `goal_gate_node` (primary) — a self-driving bounded loop A command-routing node you wire as `work_node → gate`, with the gate registered as a command node whose destinations are `[work_node, END]`. After each work iteration the gate folds the iteration's usage, then routes back to the work node while the goal is Active and under budget, else to `END`. ```mermaid flowchart TD Start((START)) --> Work[work_node] Work --> Gate{goal gate} Gate -->|active & under budget & made progress| Work Gate -->|complete / budget-limited / no progress| End((END)) ``` Each gate activation: 1. reads the `thread_id` from the `NodeContext` (absent → `END`); 2. loads the goal (`None` → `END`); 3. folds the just-finished iteration's `GoalProgress` via `account_usage` (flips an over-budget goal to `BudgetLimited`); 4. routes one `goto`: - not Active, or `continuation_suppressed` → `END`; - the iteration made **no** progress → set the one-shot suppression, then `END`; - otherwise → back to `work_node` (loop). The graph's `recursion_limit` is the hard backstop, so the loop can never spin forever. `made_progress == false` mirrors "the turn produced no tool calls". ```rust use tinyagents_graph::{END, GoalProgress, GraphBuilder, goal_gate_node}; let gate = goal_gate_node::(store.clone(), "work", |s: &St| GoalProgress { tokens_used: s.last_turn_tokens, elapsed_secs: 0, made_progress: s.made_progress, }); let graph = GraphBuilder::::overwrite() .with_recursion_limit(64) .add_node("work", work_node) // e.g. a subagent_node that calls goal_complete when done .add_node("gate", gate) .set_entry("work") .add_edge("work", "gate") .with_command_destinations("gate", ["work", END]) .compile()?; let exec = graph.run_with_thread("thread-1", St::default()).await?; ``` #### 2. `run_continuation_tick` (driver) — for an external scheduler A faithful heartbeat port for callers that *do* have a cron/scheduler: it selects idle, active, non-suppressed goals (oldest-idle first, capped at `max_per_tick`) and runs one turn each through a caller-supplied closure, then accounts usage and one-shot-suppresses a no-progress turn. ```rust use std::time::Duration; use tinyagents_graph::{TurnOutcome, run_continuation_tick}; let ran = run_continuation_tick(&store, Duration::from_secs(600), 2, |goal| async move { // run one turn toward `goal.objective`, return what it spent Ok(TurnOutcome { tokens_used: 1200, elapsed_secs: 4, made_progress: true }) }).await?; ``` #### 3. `note_user_turn` — resume on user activity Call at the start of a user-initiated run to clear the one-shot suppression and reactivate a paused goal. A loop iteration never calls it, so it can never clear its own suppression — that is how a user turn is distinguished from a self-driving iteration. ### The token-accounting boundary The graph runtime is provider-neutral and **does not meter tokens per node**, so accounting is *explicit*: the work node (typically a [`subagent_node`](Graph-Runtime#sub-agent-nodes-tinyagents_graphsubagent_node) (deprecated; see the orchestration `SubagentDriver`) whose output carries usage) writes what it spent into `State`, and the caller's `progress` closure reports it to `goal_gate_node` / `run_continuation_tick`. ### Single-process persistence caveat The `Store` trait has no compare-and-set, so every mutation runs `load → mutate → put` under a per-thread async mutex — atomic **within one process**. Across processes sharing a `FileStore`, two concurrent read-modify-writes can lose an update; the `goal_id` guard still prevents logical corruption from stale accounting but not lost updates. Funnel goal mutations through one process for multi-writer deployments. --- ## `tinyagents_graph::todos` — the per-thread todo list ### Data model A [`TodoList`] is an ordered list of [`TodoItem`]s. Each item is a line of content plus a status — and nothing else: no ids, no approvals, no assignment. It is the shape Claude Code and Codex keep, and the model rewrites the whole list as it works. ```mermaid flowchart LR Pending[Pending] --> InProgress[InProgress] InProgress --> Completed[Completed] ``` `render_markdown` renders the list as GitHub-flavored markdown with per-status markers — `[ ]` pending, `[~]` in progress, `[x]` completed — one line per item. `parse_status` accepts aliases (`todo`→`Pending`, `done`→`Completed`, …). `normalise_list` trims content and drops empty items. ### Store and invariant The `tinyagents_graph::todos::store` module (re-exported at the crate root as `todo_store`) is the programmatic surface; every op returns a `TodosSnapshot` (normalised items + markdown): ```rust use tinyagents_graph::{TodoItem, TodoStatus, todo_store}; todo_store::replace(&store, "thread-1", vec![ TodoItem::with_status("Write the changelog", TodoStatus::InProgress), TodoItem::new("Tag the release"), ]).await?; println!("{}", todo_store::list(&store, "thread-1").await?.markdown); ``` Ops: `replace`, `clear`, `list`, plus the raw `get` / `delete` for hosts. Invariant: - **Single in-progress** — at most one item may be `InProgress`; a violation is a `Validation` error on `replace`, never silently fixed. ### The `todo` tool `TodoTool` is the harness tool named `todo`. One call writes the whole list (`{"todos": [{"content", "status"}]}`); omitting `todos` reads it back. Build it with `todo_tools` (a `Vec>`, one entry) or `register_todo_tools` against a standalone `ToolRegistry`. The list is bound to the tool context's thread id (never a tool argument); domain errors (blank content, unknown status, two in-progress) are surfaced to the model as tool errors rather than failing the run. ```rust use tinyagents_graph::todo_tools; for tool in todo_tools(store.clone()) { harness.register_tool(tool); // exposes the `todo` tool } ``` --- ## Putting them together The [`goals_and_todos` example](Examples.md) wires both on one thread: a durable goal is the completion contract, a list holds three steps, and a `goal_gate_node` forms a self-driving loop where each iteration rewrites the list one transition further along and completes the goal once every step is done. ```text cargo run -p tinyagents-integration-tests --example goals_and_todos ``` ```text Running the goal-driven loop: → started: Write the changelog ✓ completed: Write the changelog → started: Tag the release ✓ completed: Tag the release → started: Publish the crate ✓ completed: Publish the crate ★ all steps completed → goal complete Goal: Ship the v2 release — status=complete, tokens_used=3500/100000 ``` --- ## Persistence at a glance | | Namespace | Key | Value | | --- | --- | --- | --- | | Goal | `graph.goals` | `hex(thread_id)` | one `ThreadGoal` | | Todos | `graph.todos` | `hex(thread_id)` | one `TodoList` | Both back onto any `Store` implementation — `InMemoryStore` for tests and prototypes, `FileStore` for durable local state, or the `sqlite`-feature backend. Keys are hex-encoded so arbitrary thread ids stay valid across every backend. ## Testing Both modules ship exhaustive unit tests on `InMemoryStore` (types, store invariants, tools, and the gate loop), plus end-to-end integration tests: `crates/tinyagents-integration-tests/tests/e2e_graph_goals.rs` drives a self-driving goal loop until complete, and `crates/tinyagents-integration-tests/tests/e2e_graph_todos.rs` drives the `todo` tool through a `MockModel` agent loop and asserts the list persists to the thread. ## See also - [Graph Runtime](Graph-Runtime) — the durable executor these primitives run on (nodes, commands, `recursion_limit`, sub-agent nodes). - [Harness](Harness) — the `Store` abstraction and the `Tool` trait both primitives build on. - [Examples](Examples) — the runnable `goals_and_todos` example. - Module specs: `docs/modules/graph/goals.md`, `docs/modules/graph/todos.md`, and the source READMEs under `crates/tinyagents-graph/src/goals/` and `crates/tinyagents-graph/src/todos/`.