# 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/`.