Goals and Todos - tinyhumansai/tinyagents GitHub Wiki

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_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.

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
Loading

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).

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.

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<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
}

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.

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))
Loading

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".

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?;

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.

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 (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.

flowchart LR
    Pending[Pending] --> InProgress[InProgress]
    InProgress --> Completed[Completed]
Loading

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):

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<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
}

Putting them together

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

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 β€” the durable executor these primitives run on (nodes, commands, recursion_limit, sub-agent nodes).
  • Harness β€” the Store abstraction and the Tool trait both primitives build on.
  • 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/.
⚠️ **GitHub.com Fallback** ⚠️