Skip to content

Latest commit

 

History

History
68 lines (53 loc) · 3.38 KB

File metadata and controls

68 lines (53 loc) · 3.38 KB

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.