The current scaffold returns whole state from each node. Durable graphs should move to partial state updates applied through channels. A channel owns the current value, the accepted update type, checkpoint representation, reducer, and step-boundary merge behavior.
pub trait Channel: Send + Sync {
type Value;
type Update;
type Checkpoint;
fn get(&self) -> Result<Self::Value>;
fn update(&mut self, values: Vec<Self::Update>) -> Result<ChannelChange>;
fn checkpoint(&self) -> Result<Self::Checkpoint>;
fn restore(checkpoint: Self::Checkpoint) -> Result<Self>
where
Self: Sized;
fn consume(&mut self) -> Result<ChannelChange> {
Ok(ChannelChange::Unchanged)
}
fn finish(&mut self) -> Result<ChannelChange> {
Ok(ChannelChange::Unchanged)
}
}Required channel policies:
LastValue: accepts one update per step and overwrites the value.Overwrite: explicit overwrite marker for aggregate channels.BinaryAggregate: applies a binary reducer such as append, add, min, or max.Topic: pub/sub collection, optionally accumulating across steps.Ephemeral: value exists only for one step or one trigger.Barrier: waits until named sources have all arrived.NamedBarrier: tracks named arrivals for join semantics.Messages: message merge by id for chat histories.Delta: stores compact deltas plus periodic snapshots for large append-heavy channels.Untracked: excluded from checkpointing when safe.
Why channel-level reducers matter:
- parallel branches can write different fields safely
- map-reduce fanout can aggregate many outputs in one step
- checkpoints can store pending writes instead of only final whole-state values
- failed parallel nodes can rerun without discarding completed writes
- tests can assert exact writes and reducer behavior
- generated/language-defined nodes can use simple partial update contracts
The graph should support both root state and object state. A single root channel is useful for scalar workflows; multi-key state is the default for agent graphs.
The channel model is shipped additively: the monolithic State +
StateReducer path is unchanged, and channels are an opt-in alternative that
runs on the existing executor. The implementation lives in
src/graph/channel/ and is serde_json::Value-backed for generality.
Channel(object-safe trait): per-keymerge(current, incoming) -> valueplusallows_concurrent,is_ephemeral,is_tracked, andis_ready(barrier) hooks. Concrete channels:LastValue(overwrite),Topic(append into an array),Delta(numeric accumulate),Messages(merge byid),Ephemeral(overwrite, cleared next step),Untracked(overwrite, excluded from snapshots),Barrier/NamedBarrier(count/name fan-in with readiness), andBinaryAggregate(fold via a closure or anyReducer<Value>).ChannelSet: a named map ofBox<dyn Channel>plus their current values, withadd_channel/with_channel,apply_update(name, value),get,is_ready, andsnapshot()(the tracked, durable view).ChannelState: a graphStatewrapping aChannelSet. It implementsStateReducer<ChannelState, ChannelUpdate>for itself (the&selfreducer receiver is unused; merge rules travel inside the running state), so a channel graph is built directly withGraphBuilder::<ChannelState, ChannelUpdate>::new().set_reducer(ChannelState::new()).ChannelUpdate: a batch of(name, value)writes a node returns (ChannelUpdate::new().set(..).set(..)). Stamp it with the producing node's superstep via.at_step(ctx.step).
The executor folds a step's branch updates one at a time. Stamping each
ChannelUpdate with ctx.step lets the reducer group a step's writes: when the
step number advances it resets its per-step bookkeeping and clears Ephemeral
channels. A second same-step write to a non-aggregate channel (LastValue,
Ephemeral, Untracked — allows_concurrent == false) raises
TinyAgentsError::InvalidConcurrentUpdate; aggregate channels
(allows_concurrent == true) merge both writes in deterministic active-set
index order. Cross-step overwrites and repeated writes inside one update are
last-wins, not conflicts. Unstamped updates are treated as independent steps
(no conflict detection, no ephemeral clearing), preserving the simplest path.