Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/modules/graph/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,4 +146,6 @@ topology or executable code.
- [Sub-agents, recursion, and depth tracking](subagents-recursion.md)
- [Memory and stores boundary](memory-boundary.md)
- [Visualization, introspection, and testkit](visualization-testkit.md)
- [Per-thread goal and graph-native continuation](goals.md)
- [Per-thread task board (kanban todos)](todos.md)
- [Implementation milestones](milestones.md)
68 changes: 68 additions & 0 deletions docs/modules/graph/goals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Per-thread goal and graph-native continuation

`graph::goals` gives a graph a single durable **objective per thread** — a
"completion contract" carried across supersteps, interrupts, and resumes — plus
a graph-native way to keep working it. It is a provider-neutral port of
OpenHuman's `thread_goals`, re-hosted on the harness `Store` and driven off the
graph runtime rather than an out-of-band heartbeat.

See the source module README at `src/graph/goals/README.md` for the full public
surface; this spec captures the design contract.

## Model

- Exactly **one** `ThreadGoal` per thread, keyed by `thread_id`.
- `ThreadGoalStatus`: `Active` → the graph may work it and auto-continue;
`Paused` (host control); `BudgetLimited` (accounting reached the token cap);
`Complete` (model-confirmed success). Ownership is asymmetric: a model
creates/replaces and completes a goal; pause/budget-limit are system-driven.
- Optional `token_budget`; `account_usage` folds usage and flips an active goal
to `BudgetLimited` at the cap.

## Persistence

One serialized `ThreadGoal` per thread in the `graph.goals` namespace of a
`harness::store::Store`, keyed by `hex(thread_id)`. The `Store` trait has no CAS,
so each mutation runs `load → mutate → put` under a per-thread async mutex —
atomic within one process. A `goal_id` compare-and-set guard drops stale
accounting from a replaced goal. Cross-process lost-update is a documented
limitation (a future `Store::compare_and_swap` is the clean fix).

## Tools

`GoalTool` / `GoalToolKind` expose `goal_get`, `goal_set`, `goal_complete` as
the default model-facing set (`goal_tools` / `register_goal_tools`);
`goal_pause` / `goal_resume` / `goal_clear` are host controls. The target thread
comes from `ToolExecutionContext::thread_id` — never a tool argument — so a
model can't address another thread's goal.

## Continuation (heartbeat → graph)

OpenHuman's idle heartbeat becomes three graph-native primitives:

1. **`goal_gate_node`** (primary) — a command-routing node forming a self-driving
bounded loop. Wired `work_node -> gate` with the gate a command node whose
destinations are `[work_node, END]`, it folds each iteration's `GoalProgress`
via `account_usage` and routes back to `work_node` while the goal is Active
and under budget, else to `END`. The graph `recursion_limit` is the hard
backstop; a zero-progress iteration sets a one-shot suppression and stops.
2. **`run_continuation_tick`** — a faithful heartbeat port for callers that have
an external scheduler: selects idle, active, non-suppressed goals (oldest
first, `max_per_tick`) and runs one turn each through a caller closure.
3. **`note_user_turn`** — clears the one-shot suppression and reactivates a
paused goal on a user-initiated run. A loop iteration never clears its own
suppression, so user-vs-continuation is distinguished structurally.

### Token accounting boundary

The graph runtime is provider-neutral and does not meter tokens per node, so
accounting is **explicit**: a work node (typically a `subagent_node`) writes what
it spent into `State`, and the caller's `progress` / `run_turn` closure reports
it. `made_progress == false` is the graph analogue of OpenHuman's "the turn
produced no tool calls".

## Testing

Unit tests in `src/graph/goals/test.rs` (types, store, tools, and the gate loop
on `InMemoryStore`); an end-to-end self-driving loop in
`tests/e2e_graph_goals.rs`.
55 changes: 55 additions & 0 deletions docs/modules/graph/todos.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Per-thread task board (kanban todos)

`graph::todos` gives a graph a per-thread **task board**: an ordered *list* of
task cards with a small kanban lifecycle. It is the concrete-work-items
counterpart to the single-objective [`graph::goals`](goals.md), a
provider-neutral port of OpenHuman's task board / `todos` modules.

See the source module README at `src/graph/todos/README.md` for the full public
surface; this spec captures the design contract.

## Model

- `TaskBoardCard { id, title, status, objective, plan, assigned_agent,
allowed_tools, approval_mode, acceptance_criteria, evidence, notes, blocker,
session_thread_id, source_metadata, order, updated_at }`.
- `TaskCardStatus`: `Todo`, `AwaitingApproval`, `Ready`, `InProgress`,
`Blocked`, `Done`, `Rejected`. `TaskApprovalMode`: `Required`, `NotRequired`.
- `TaskBoard { thread_id, cards, updated_at }`; `TodosSnapshot` (cards +
markdown) is returned by every CRUD op.
- `render_markdown` renders GitHub-flavored markers
(`[ ]`/`[x]`/`[~]`/`[!]`/`[?]`/`[-]`) with indented metadata; `parse_status`
accepts aliases; `normalise_board` generates ids, trims, drops empty-title
cards, backfills a blocker from notes, and recomputes order.

## Persistence

One serialized `TaskBoard` per thread in the `graph.todos` namespace of a
`harness::store::Store`, keyed by `hex(thread_id)`. Each mutation runs
`load → mutate → normalise → put` under a per-thread async mutex (atomic within
one process, same caveat as `graph::goals`).

### Invariants

- **Single in-progress:** at most one card may be `InProgress`; a violation is a
`Validation` error on `add` / `edit` / `replace` / `claim_card` — never
silently fixed.
- `decide_plan` only transitions an `AwaitingApproval` card; a stale decision
errors. `revise_plan` rejects all awaiting cards and is a lenient no-op when
none awaits.
- `claim_card` is an atomic compare-and-set: transition from one of `expected`
to `target` under the lock, else reject.

## Tool

`TodoTool` is a single multiplexer harness `Tool` dispatching on an `op` field
(`add`/`edit`/`update_status`/`decide_plan`/`revise_plan`/`remove`/`replace`/
`clear`/`list`), built with `todo_tools` / `register_todo_tools`. The board is
bound to `ToolExecutionContext::thread_id` (never a tool argument). Domain errors
(unknown id, invariant violation) are surfaced to the model as tool errors
rather than failing the run.

## Testing

Unit tests in `src/graph/todos/test.rs` (types, store invariants, tool); an
end-to-end model-driven tool run in `tests/e2e_graph_todos.rs`.
123 changes: 123 additions & 0 deletions src/graph/goals/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
# graph::goals

A per-thread **goal**: exactly one durable objective per thread, carried across
supersteps, interrupts, and resumes. Ported from OpenHuman's `thread_goals` and
re-hosted on the TinyAgents graph runtime — provider-neutral, offline-testable,
with no app-specific coupling (no event bus, RPC envelopes, or heartbeat).

Distinct from [`graph::todos`](../todos) (a *list* of task cards per thread): a
goal is the single "completion contract" the graph pursues; the board holds the
concrete work items.

## Data model (`types.rs`)

- `ThreadGoalStatus { Active, Paused, BudgetLimited, Complete }` — `is_active` /
`is_terminal` predicates. Ownership is asymmetric: a model creates/replaces a
goal and marks it `Complete`; `Paused` / `BudgetLimited` are system-driven.
- `ThreadGoal { thread_id, goal_id, objective, status, token_budget, tokens_used,
time_used_seconds, created_at_ms, updated_at_ms, continuation_suppressed }`
(serde `camelCase`) with `budget_remaining` / `over_budget`.
- `GoalProgress { tokens_used, elapsed_secs, made_progress }` — usage a work
iteration reports to the continuation gate. `TurnOutcome` is an alias used at
the driver boundary.
- `active_goal_context_block(&ThreadGoal) -> Option<String>` — a prompt block to
prepend to a work node's input (steer text for Active / stop text for
BudgetLimited; `None` for Paused/Complete).

## Persistence (`store.rs`)

One serialized `ThreadGoal` per thread under the `graph.goals` namespace of a
`crate::harness::store::Store`, keyed by `hex(thread_id)` (hex keeps arbitrary
thread ids valid across `InMemoryStore` / `FileStore` / Sqlite). CRUD:
`get` / `set` / `set_if_absent` / `complete` / `pause` / `resume` / `clear` /
`list_all` / `account_usage` / `set_continuation_suppressed_if`.

Semantics preserved from OpenHuman:

- `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 replaced goal.
- `set_continuation_suppressed_if` writes only when the current goal still
matches `expected_goal_id` and is active.

**Concurrency / single-process caveat.** The `Store` trait offers no CAS and no
cross-key transaction, so each 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, or wait for a future
`Store::compare_and_swap`.

## Tools (`tool.rs`)

`GoalTool` dispatches on `GoalToolKind`. The default model-facing set
(`goal_tools` / `register_goal_tools`) is `goal_get`, `goal_set`,
`goal_complete`; `goal_pause` / `goal_resume` / `goal_clear` are constructible
host controls. The target thread comes from `ToolExecutionContext::thread_id`
(never a tool argument), so a model can't address another thread's goal; the
bare `Tool::call` entry point (no context) errors.

## Continuation (`continuation.rs`)

OpenHuman's idle heartbeat becomes graph-native, three ways:

- **`goal_gate_node`** (primary) — a command-routing node forming a self-driving
bounded loop. Wire `work_node -> gate` and register `gate` with
`with_command_destinations([work_node, END])`. Each pass reads the thread id
from `NodeContext`, folds the iteration's `GoalProgress` via `account_usage`,
and routes back to `work_node` while the goal is Active and under budget, else
to `END`. `recursion_limit` is the hard backstop; a zero-progress iteration
sets the one-shot suppression and stops.
- **`run_continuation_tick`** (driver) — for callers with an external scheduler:
selects idle, active, non-suppressed goals (oldest-first, `max_per_tick`) and
runs one turn each through a `run_turn` closure, then accounts + one-shot
suppresses on no progress.
- **`note_user_turn`** — call at the start of a user-initiated run to clear the
one-shot suppression and reactivate a paused goal. A loop iteration never
clears its own suppression.

**Token accounting boundary.** The graph runtime does not meter tokens per node,
so accounting is explicit: a work node writes what it spent into `State` and the
`progress` / `run_turn` closure reports it. `made_progress == false` is the
graph analogue of OpenHuman's "the turn produced no tool calls".

## Example: a self-driving goal loop

```rust,ignore
use std::sync::Arc;
use tinyagents::{GraphBuilder, END, GoalProgress, goal_gate_node, goal_store};
use tinyagents::harness::store::{InMemoryStore, Store};

let store: Arc<dyn Store> = Arc::new(InMemoryStore::default());
goal_store::set(&store, "thread-1", "summarise the repo", Some(50_000)).await?;

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) // 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?;
```

## Files

| File | Role |
| --- | --- |
| `types.rs` | Data model + `GoalProgress` + `active_goal_context_block`. |
| `store.rs` | `Store`-backed CRUD, per-thread RMW lock, budget + CAS guards. |
| `tool.rs` | `GoalTool` / `GoalToolKind` harness tools. |
| `continuation.rs` | `goal_gate_node`, `run_continuation_tick`, `note_user_turn`. |
| `test.rs` | Unit tests (types, store, tools, continuation loop). |
Loading