Skip to main content

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}