somatize_core/step.rs
1//! The effectful counterpart to [`crate::filter::Filter`].
2//!
3//! A `Filter` is a function: same config, same state, same input, same
4//! output — which is what makes content-addressed caching sound. A `Step` is
5//! not a function. It calls models, reads the world, decides what to run
6//! next, and may pause for a person. Forcing that into `forward()` would
7//! either make the trait async (colouring the whole runtime and complicating
8//! the GIL story) or make caching lie.
9//!
10//! So `Step` gets its own shape: **advance one turn, describe what you need,
11//! hand control back.**
12//!
13//! ```ignore
14//! loop {
15//! match step.poll(&ctx, resume)? {
16//! Transition::Await(effects) => resume = Some(driver.perform(effects)),
17//! Transition::Done(value) => break value,
18//! // Spawn / Goto / Suspend hand control to the runtime
19//! }
20//! }
21//! ```
22//!
23//! `poll` is synchronous and cheap. The driver owns the concurrency. The
24//! five [`Transition`] variants are, deliberately, the union of the control
25//! primitives every agent framework converges on: awaiting work, dynamic
26//! fan-out, handoff, interrupt, and finishing.
27
28use crate::cache::CacheKey;
29use crate::effect::{Effect, EffectResult, JoinPolicy, NodeSpec, SuspendReason};
30use crate::error::Result;
31use crate::graph::NodeId;
32use crate::schema::Schema;
33use crate::value::Value;
34use serde::{Deserialize, Serialize};
35
36/// What a step wants to happen next.
37///
38/// Deliberately NOT `#[non_exhaustive]`, for the same reason as
39/// [`crate::node::NodeOutcome`], its other half: the driver decides control
40/// flow off this value, and a wildcard arm there is a silent wrong answer.
41/// Adding a variant *should* break every consumer — each one has to decide
42/// what the new transition means for it.
43pub enum Transition {
44 /// Perform these effects — concurrently — then poll again with the
45 /// results in the same order.
46 Await(Vec<Effect>),
47
48 /// Create and run these nodes now, then poll again with their outputs.
49 ///
50 /// The map half of map-reduce, where the fan-out width is only known at
51 /// runtime. LangGraph calls this `Send`.
52 Spawn {
53 /// The nodes to create — one per unit of discovered work.
54 specs: Vec<NodeSpec>,
55 /// How their outputs recombine, and what one failure does to the rest.
56 join: JoinPolicy,
57 },
58
59 /// Hand control to another node, with a value. The current step is done.
60 ///
61 /// A handoff, in the sense the OpenAI Agents SDK uses: delegation that
62 /// transfers control rather than nesting a call.
63 Goto {
64 /// Where control goes — a node reachable over a declared handoff edge.
65 target: NodeId,
66 /// The value it receives as its input.
67 carry: Value,
68 },
69
70 /// Stop the run and persist it. Resuming replays the journal up to here
71 /// and continues with whatever came back.
72 Suspend {
73 /// What the run is waiting for, and what would restart it.
74 reason: SuspendReason,
75 },
76
77 /// Finished, with this output.
78 Done(Value),
79}
80
81impl Transition {
82 /// A short label for events. Payload-free by construction.
83 pub fn label(&self) -> String {
84 match self {
85 Self::Await(effects) => {
86 let names: Vec<String> = effects.iter().map(Effect::label).collect();
87 format!("await[{}]", names.join(", "))
88 }
89 Self::Spawn { specs, join } => format!("spawn[{} x {join:?}]", specs.len()),
90 Self::Goto { target, .. } => format!("goto:{target}"),
91 Self::Suspend { .. } => "suspend".to_string(),
92 Self::Done(_) => "done".to_string(),
93 }
94 }
95
96 /// Is this the step's last word? `Done` and `Goto` are — nothing polls
97 /// the step again this run. The other three expect another poll: after
98 /// the effects, after the spawned nodes, or after a resume.
99 pub fn is_terminal(&self) -> bool {
100 matches!(self, Self::Done(_) | Self::Goto { .. })
101 }
102}
103
104impl std::fmt::Debug for Transition {
105 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106 f.write_str(&self.label())
107 }
108}
109
110/// What the runtime tells a step about where it is.
111///
112/// Deliberately small. A step's own reasoning state belongs in the value it
113/// carries, not here — that is what makes replay work: re-polling with the
114/// same journal reproduces the same decisions.
115pub struct StepCtx<'a> {
116 /// This node's id in the graph.
117 pub node_id: &'a str,
118 /// The run this belongs to.
119 pub run_id: &'a str,
120 /// Input resolved from predecessors.
121 pub input: &'a Value,
122 /// Which turn this is, counting from 0. Part of the journal key, so a
123 /// step that asks the same question twice gets two recorded answers.
124 pub turn: usize,
125 /// Results of the effects requested last turn, in request order.
126 /// Empty on turn 0.
127 pub results: &'a [EffectResult],
128 /// Every turn's results so far, oldest first.
129 ///
130 /// A step that accumulates — a conversation, a running total — rebuilds
131 /// it from here rather than holding it in `self`. That is what keeps
132 /// replay honest: the history is replayed identically, so the same
133 /// decisions follow, whereas hidden state would not survive a restart.
134 pub history: &'a [Vec<EffectResult>],
135}
136
137impl<'a> StepCtx<'a> {
138 /// A context with no results yet — what a first poll sees. Later turns
139 /// attach theirs with [`Self::with_history`] or [`Self::with_results`].
140 pub fn new(node_id: &'a str, run_id: &'a str, input: &'a Value, turn: usize) -> Self {
141 Self {
142 node_id,
143 run_id,
144 input,
145 turn,
146 results: &[],
147 history: &[],
148 }
149 }
150
151 /// Set the full history; `results` becomes its last entry.
152 pub fn with_history(mut self, history: &'a [Vec<EffectResult>]) -> Self {
153 self.history = history;
154 self.results = history.last().map(Vec::as_slice).unwrap_or(&[]);
155 self
156 }
157
158 /// Set only the current turn's results — for tests and single-turn steps.
159 pub fn with_results(mut self, results: &'a [EffectResult]) -> Self {
160 self.results = results;
161 self
162 }
163
164 /// The single result of a one-effect turn.
165 pub fn result(&self) -> Option<&EffectResult> {
166 self.results.first()
167 }
168
169 /// Every result from every turn, oldest first, flattened.
170 pub fn all_results(&self) -> impl Iterator<Item = &EffectResult> {
171 self.history.iter().flatten()
172 }
173}
174
175/// What the compiler and runtime need to know about a step without running it.
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct StepMeta {
178 /// The step type's name, for events and error messages.
179 pub name: String,
180
181 /// Cap on turns before the runtime gives up.
182 ///
183 /// Not a safety net so much as a budget: an agent that has not finished
184 /// in this many turns is looping, and the honest response is to stop and
185 /// say so rather than burn tokens.
186 pub max_turns: usize,
187
188 /// May this step's effects be written to the journal?
189 ///
190 /// Journaling is what makes a run replayable, but it also means prompts
191 /// land on disk under `$SOMA_CACHE_DIR`. Turn it off for a step handling
192 /// anything that must not be persisted.
193 pub journal: bool,
194
195 /// What it accepts (`None` = anything).
196 pub input_schema: Option<Schema>,
197 /// What it produces (`None` = unknown).
198 pub output_schema: Option<Schema>,
199
200 /// Where it may run.
201 pub distribution: crate::filter::Distribution,
202}
203
204impl StepMeta {
205 /// Defaults: a 24-turn budget, journaling on, schemas undeclared, runs
206 /// locally.
207 pub fn new(name: impl Into<String>) -> Self {
208 Self {
209 name: name.into(),
210 max_turns: 24,
211 journal: true,
212 input_schema: None,
213 output_schema: None,
214 distribution: crate::filter::Distribution::Local,
215 }
216 }
217
218 /// Set the turn budget — see [`StepMeta::max_turns`] for what running
219 /// out means.
220 pub fn with_max_turns(mut self, n: usize) -> Self {
221 self.max_turns = n;
222 self
223 }
224
225 /// Keep this step's effects out of the journal — and give up replay for
226 /// it in exchange.
227 pub fn without_journal(mut self) -> Self {
228 self.journal = false;
229 self
230 }
231
232 /// Declare what this step accepts, so an impossible edge into it fails
233 /// at compile rather than mid-run.
234 pub fn with_input_schema(mut self, schema: Schema) -> Self {
235 self.input_schema = Some(schema);
236 self
237 }
238
239 /// Declare what this step produces, for its successors' input checks.
240 pub fn with_output_schema(mut self, schema: Schema) -> Self {
241 self.output_schema = Some(schema);
242 self
243 }
244}
245
246/// An effectful node.
247///
248/// Implementors are registered in the runtime's step library by node id, the
249/// same way filters are.
250pub trait Step: crate::any::AsAny + Send + Sync {
251 /// Hash of this step's configuration. Same config, same hash — it is part
252 /// of every journal key this step writes.
253 fn config_hash(&self) -> CacheKey;
254
255 /// This step's [`StepMeta`]: turn budget, journaling, schemas, placement.
256 fn meta(&self) -> StepMeta;
257
258 /// Advance one turn.
259 ///
260 /// Called with `ctx.turn == 0` and no results to start; thereafter with
261 /// the results of whatever it last asked for. Must be **deterministic
262 /// given the same context and results** — that is the whole basis of
263 /// replay. Put the non-determinism in an [`Effect`], where it gets
264 /// recorded.
265 fn poll(&self, ctx: &StepCtx<'_>) -> Result<Transition>;
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use crate::effect::LlmRequest;
272 use crate::message::Message;
273
274 /// A step that asks a model once and returns its text.
275 struct Once;
276
277 impl Step for Once {
278 fn config_hash(&self) -> CacheKey {
279 CacheKey::from_parts(&[b"Once"])
280 }
281 fn meta(&self) -> StepMeta {
282 StepMeta::new("Once")
283 }
284 fn poll(&self, ctx: &StepCtx<'_>) -> Result<Transition> {
285 match ctx.result() {
286 None => Ok(Transition::Await(vec![Effect::Llm(LlmRequest::new(
287 "claude-opus-5",
288 vec![Message::user(ctx.input.as_text().unwrap_or_default())].into(),
289 ))])),
290 Some(EffectResult::Llm(resp)) => {
291 Ok(Transition::Done(Value::text(resp.message.text())))
292 }
293 Some(other) => Ok(Transition::Done(Value::text(format!(
294 "unexpected {other:?}"
295 )))),
296 }
297 }
298 }
299
300 #[test]
301 fn first_poll_asks_for_the_model() {
302 let input = Value::text("hello");
303 let ctx = StepCtx::new("n", "r", &input, 0);
304 let t = Once.poll(&ctx).unwrap();
305 match t {
306 Transition::Await(effects) => {
307 assert_eq!(effects.len(), 1);
308 assert!(matches!(effects[0], Effect::Llm(_)));
309 }
310 other => panic!("expected Await, got {other:?}"),
311 }
312 }
313
314 #[test]
315 fn second_poll_finishes_with_the_reply() {
316 use crate::effect::{LlmResponse, StopReason, Usage};
317
318 let input = Value::text("hello");
319 let results = [EffectResult::Llm(LlmResponse {
320 message: Message::assistant("hi there"),
321 stop_reason: StopReason::EndTurn,
322 usage: Usage::default(),
323 model: None,
324 })];
325 let ctx = StepCtx::new("n", "r", &input, 1).with_results(&results);
326
327 match Once.poll(&ctx).unwrap() {
328 Transition::Done(v) => assert_eq!(v.as_text(), Some("hi there")),
329 other => panic!("expected Done, got {other:?}"),
330 }
331 }
332
333 /// Same context in, same decision out — the property replay rests on.
334 #[test]
335 fn polling_is_deterministic() {
336 let input = Value::text("hello");
337 let ctx = StepCtx::new("n", "r", &input, 0);
338 let a = Once.poll(&ctx).unwrap();
339 let b = Once.poll(&ctx).unwrap();
340 assert_eq!(a.label(), b.label());
341 }
342
343 #[test]
344 fn labels_describe_without_leaking() {
345 let input = Value::text("a secret prompt");
346 let ctx = StepCtx::new("n", "r", &input, 0);
347 let label = Once.poll(&ctx).unwrap().label();
348 assert!(label.starts_with("await["), "{label}");
349 assert!(!label.contains("secret"), "{label}");
350 }
351
352 #[test]
353 fn terminal_transitions() {
354 assert!(Transition::Done(Value::Empty).is_terminal());
355 assert!(
356 Transition::Goto {
357 target: "next".into(),
358 carry: Value::Empty
359 }
360 .is_terminal()
361 );
362 assert!(!Transition::Await(vec![]).is_terminal());
363 }
364
365 #[test]
366 fn journal_can_be_declined() {
367 assert!(StepMeta::new("s").journal);
368 assert!(!StepMeta::new("s").without_journal().journal);
369 }
370}