Skip to content

Effect

Effect<Svc, I, F> is the value returned from every reduce() call. It describes what should happen after state mutation. The dispatch loop interprets effects; reduce itself is pure.

crates/core/src/state/effect.rs
pub enum Effect<Svc, I, F> {
/// No effect — only state was mutated.
None,
/// Synchronous follow-up command. Reduced in the same dispatch cycle
/// with depth limiting (max 64) to prevent infinite loops.
Send(Command<I, F>),
/// Synchronous service access. Runs inline on the current thread.
Task(Box<dyn FnOnce(&Svc) + Send>),
/// Async work on a new thread. Feedback via Tx.
Spawn(Box<dyn FnOnce(Arc<Svc>, Tx<I, F>) + Send + 'static>),
/// Multiple effects executed sequentially.
Batch(Vec<Effect<Svc, I, F>>),
}

Reduce functions return an Effect describing what should happen. The dispatch()/execute() loop interprets it. This replaces the old imperative Effects struct.

The reduce arm mutated state directly. No side effects needed.

Command::Intent(MyIntent::SetName { name }) => {
state.synced.name = name;
Effect::none()
}

Effect::Send(cmd) — synchronous follow-up

Section titled “Effect::Send(cmd) — synchronous follow-up”

Queues a command for reduction in the same dispatch() call. Sends can produce more sends — execute() recurses with a depth limit of 64.

Command::Intent(MyIntent::Transition) => {
state.synced.phase = Phase::Next;
Effect::intent(MyIntent::RefreshView)
}

Effect::Task(f) — synchronous service access

Section titled “Effect::Task(f) — synchronous service access”

Runs a closure inline on the current thread with &Svc. Use for agent tool output and lightweight service calls.

Command::Intent(MyIntent::ListItems) => {
let items = state.synced.items.clone();
Effect::task(move |svc| {
svc.agent.tool_output(json!({ "items": items }));
})
}

Runs a closure on a new thread. The closure receives Arc<Svc> and a Tx for sending feedback. Feedback is processed on the next dispatch() call.

Command::Intent(MyIntent::LoadData { id }) => {
state.ephemeral.loading = true;
Effect::spawn(move |svc, tx| {
let bytes = svc.backend.load(&id).unwrap();
tx.feedback(MyFeedback::DataLoaded(bytes));
})
}

The default Reducer::execute() handles Spawn via std::thread::spawn + ensure_spawn_loop() for automatic feedback drain.

Execute multiple effects sequentially. Built via Effect::batch() or the .and() combinator.

Command::Intent(MyIntent::Reset) => {
state.synced = Default::default();
Effect::task(|svc| svc.agent.tool_output(json!({"ok": true})))
.and(Effect::intent(MyIntent::RefreshView))
}
ConstructorReturns
Effect::none()None
Effect::intent(i)Send(Command::Intent(i))
Effect::feedback(f)Send(Command::Feedback(f))
Effect::error(e)Send(Command::Feedback(e.into())) — requires E: Into<F> (satisfied by Reducer::Error)
Effect::task(f)Task(Box::new(f))
Effect::spawn(f)Spawn(Box::new(f))
Effect::batch(vec)Batch(vec)
a.and(b)Combines two effects, flattening None and nested Batch

Tx<I, F> wraps std::sync::mpsc::Sender<Command<I, F>>. Used inside Spawn closures to send feedback commands back to the reducer.

crates/core/src/state/effect.rs
pub struct Tx<I, F>(std::sync::mpsc::Sender<Command<I, F>>);
impl<I, F> Tx<I, F> {
pub fn intent(&self, i: I);
pub fn feedback(&self, f: F);
pub fn error<E: Into<F>>(&self, err: E); // sends err.into() as feedback
pub fn send(&self, cmd: Command<I, F>);
}

Tx implements Clone. Feedback sent via Tx arrives on the spawn channel and is drained at the top of the next dispatch() call.

Effect::error(e) and Tx::error(e) convert a typed error into feedback via the From<Reducer::Error> bound on Feedback. This lets reduce functions return errors without manually constructing Feedback::Error(...):

Command::Intent(MyIntent::Remove { id }) => {
match state.synced.items.remove(&id) {
Some(_) => Effect::none(),
None => Effect::error(MyError::NotFound(id)),
}
}

Tool output from reduce does not flow through Effect fields. Instead, it flows through the AgentService channel, accessed via Effect::task():

Effect::task(|svc| {
svc.agent.tool_output(json!({ "elements": [...] }));
})

AgentService is a write-only bridge with its own mpsc::Sender<AgentEvent>. Session owns the matching receiver and drains events via drain_agent_events() after dispatch.

crates/core/src/state/effects.rs
pub enum AgentEvent {
ToolOutput(serde_json::Value),
ToolError(String),
}
pub struct AgentService {
tx: mpsc::Sender<AgentEvent>,
}

ModalityServices is the default services type for simple modalities. It wraps Arc<AgentService>. Modalities with domain-specific services define their own type.

  • Reducer — interprets effects via dispatch() and execute()
  • Command — the I/F type parameters in Effect<Svc, I, F>