Repository navigation
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 abstraction the rest of the
harness uses.
The primitives are pure runtime state plus harness tools, driven off the 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_progressorcompleted, with a single-in_progressinvariant.
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.
flowchart LR
subgraph Thread["thread: release-thread"]
Goal["ThreadGoal<br/>“Ship the v2 release”<br/>status=active, budget=100k"]
Todos["TodoList<br/>[x] changelog<br/>[~] tag release<br/>[ ] publish"]
end
Goal -. drives .-> Todos
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).
use std::sync::Arc;
use tinyagents_graph::goal_store;
use tinyagents_harness::store::{InMemoryStore, Store};
let store: Arc<dyn Store> = 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.
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<State> directly (useful when
building a registry standalone); against an AgentHarness, register each tool
from goal_tools individually:
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
}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.
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.
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:
- reads the
thread_idfrom theNodeContext(absent →END); - loads the goal (
None→END); - folds the just-finished iteration's
GoalProgressviaaccount_usage(flips an over-budget goal toBudgetLimited); - 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).
- not Active, or
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".
use tinyagents_graph::{END, GoalProgress, GraphBuilder, goal_gate_node};
let gate = goal_gate_node::<St, St>(store.clone(), "work", |s: &St| GoalProgress {
tokens_used: s.last_turn_tokens,
elapsed_secs: 0,
made_progress: s.made_progress,
});
let graph = GraphBuilder::<St, St>::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?;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.
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?;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 graph runtime is provider-neutral and does not meter tokens per node, so
accounting is explicit: the work node (typically a
subagent_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.
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.
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.
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.
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):
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 aValidationerror onreplace, never silently fixed.
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<Arc<TodoTool>>, 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.
use tinyagents_graph::todo_tools;
for tool in todo_tools(store.clone()) {
harness.register_tool(tool); // exposes the `todo` tool
}The goals_and_todos example 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.
cargo run -p tinyagents-integration-tests --example goals_and_todos
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
| 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.
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.
-
Graph Runtime — the durable executor these primitives run
on (nodes, commands,
recursion_limit, sub-agent nodes). -
Harness — the
Storeabstraction and theTooltrait both primitives build on. -
Examples — the runnable
goals_and_todosexample. - Module specs:
docs/modules/graph/goals.md,docs/modules/graph/todos.md, and the source READMEs undercrates/tinyagents-graph/src/goals/andcrates/tinyagents-graph/src/todos/.
Provider-neutral agent harness and durable state-graph runtime for Rust.
Getting started
Concepts
Modules
Providers
Contributing