use crate::cache::CacheKey;
use crate::effect::{Effect, EffectResult, JoinPolicy, NodeSpec, SuspendReason};
use crate::error::Result;
use crate::graph::NodeId;
use crate::schema::Schema;
use crate::value::Value;
use serde::{Deserialize, Serialize};
pub enum Transition {
Await(Vec<Effect>),
Spawn {
specs: Vec<NodeSpec>,
join: JoinPolicy,
},
Goto {
target: NodeId,
carry: Value,
},
Suspend {
reason: SuspendReason,
},
Done(Value),
}
impl Transition {
pub fn label(&self) -> String {
match self {
Self::Await(effects) => {
let names: Vec<String> = effects.iter().map(Effect::label).collect();
format!("await[{}]", names.join(", "))
}
Self::Spawn { specs, join } => format!("spawn[{} x {join:?}]", specs.len()),
Self::Goto { target, .. } => format!("goto:{target}"),
Self::Suspend { .. } => "suspend".to_string(),
Self::Done(_) => "done".to_string(),
}
}
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Done(_) | Self::Goto { .. })
}
}
impl std::fmt::Debug for Transition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.label())
}
}
pub struct StepCtx<'a> {
pub node_id: &'a str,
pub run_id: &'a str,
pub input: &'a Value,
pub turn: usize,
pub results: &'a [EffectResult],
pub history: &'a [Vec<EffectResult>],
}
impl<'a> StepCtx<'a> {
pub fn new(node_id: &'a str, run_id: &'a str, input: &'a Value, turn: usize) -> Self {
Self {
node_id,
run_id,
input,
turn,
results: &[],
history: &[],
}
}
pub fn with_history(mut self, history: &'a [Vec<EffectResult>]) -> Self {
self.history = history;
self.results = history.last().map(Vec::as_slice).unwrap_or(&[]);
self
}
pub fn with_results(mut self, results: &'a [EffectResult]) -> Self {
self.results = results;
self
}
pub fn result(&self) -> Option<&EffectResult> {
self.results.first()
}
pub fn all_results(&self) -> impl Iterator<Item = &EffectResult> {
self.history.iter().flatten()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepMeta {
pub name: String,
pub max_turns: usize,
pub journal: bool,
pub input_schema: Option<Schema>,
pub output_schema: Option<Schema>,
pub distribution: crate::filter::Distribution,
}
impl StepMeta {
pub fn new(name: impl Into<String>) -> Self {
Self {
name: name.into(),
max_turns: 24,
journal: true,
input_schema: None,
output_schema: None,
distribution: crate::filter::Distribution::Local,
}
}
pub fn with_max_turns(mut self, n: usize) -> Self {
self.max_turns = n;
self
}
pub fn without_journal(mut self) -> Self {
self.journal = false;
self
}
pub fn with_input_schema(mut self, schema: Schema) -> Self {
self.input_schema = Some(schema);
self
}
pub fn with_output_schema(mut self, schema: Schema) -> Self {
self.output_schema = Some(schema);
self
}
}
pub trait Step: crate::any::AsAny + Send + Sync {
fn config_hash(&self) -> CacheKey;
fn meta(&self) -> StepMeta;
fn poll(&self, ctx: &StepCtx<'_>) -> Result<Transition>;
}
#[cfg(test)]
mod tests {
use super::*;
use crate::effect::LlmRequest;
use crate::message::Message;
struct Once;
impl Step for Once {
fn config_hash(&self) -> CacheKey {
CacheKey::from_parts(&[b"Once"])
}
fn meta(&self) -> StepMeta {
StepMeta::new("Once")
}
fn poll(&self, ctx: &StepCtx<'_>) -> Result<Transition> {
match ctx.result() {
None => Ok(Transition::Await(vec![Effect::Llm(LlmRequest::new(
"claude-opus-5",
vec![Message::user(ctx.input.as_text().unwrap_or_default())].into(),
))])),
Some(EffectResult::Llm(resp)) => {
Ok(Transition::Done(Value::text(resp.message.text())))
}
Some(other) => Ok(Transition::Done(Value::text(format!(
"unexpected {other:?}"
)))),
}
}
}
#[test]
fn first_poll_asks_for_the_model() {
let input = Value::text("hello");
let ctx = StepCtx::new("n", "r", &input, 0);
let t = Once.poll(&ctx).unwrap();
match t {
Transition::Await(effects) => {
assert_eq!(effects.len(), 1);
assert!(matches!(effects[0], Effect::Llm(_)));
}
other => panic!("expected Await, got {other:?}"),
}
}
#[test]
fn second_poll_finishes_with_the_reply() {
use crate::effect::{LlmResponse, StopReason, Usage};
let input = Value::text("hello");
let results = [EffectResult::Llm(LlmResponse {
message: Message::assistant("hi there"),
stop_reason: StopReason::EndTurn,
usage: Usage::default(),
model: None,
})];
let ctx = StepCtx::new("n", "r", &input, 1).with_results(&results);
match Once.poll(&ctx).unwrap() {
Transition::Done(v) => assert_eq!(v.as_text(), Some("hi there")),
other => panic!("expected Done, got {other:?}"),
}
}
#[test]
fn polling_is_deterministic() {
let input = Value::text("hello");
let ctx = StepCtx::new("n", "r", &input, 0);
let a = Once.poll(&ctx).unwrap();
let b = Once.poll(&ctx).unwrap();
assert_eq!(a.label(), b.label());
}
#[test]
fn labels_describe_without_leaking() {
let input = Value::text("a secret prompt");
let ctx = StepCtx::new("n", "r", &input, 0);
let label = Once.poll(&ctx).unwrap().label();
assert!(label.starts_with("await["), "{label}");
assert!(!label.contains("secret"), "{label}");
}
#[test]
fn terminal_transitions() {
assert!(Transition::Done(Value::Empty).is_terminal());
assert!(
Transition::Goto {
target: "next".into(),
carry: Value::Empty
}
.is_terminal()
);
assert!(!Transition::Await(vec![]).is_terminal());
}
#[test]
fn journal_can_be_declined() {
assert!(StepMeta::new("s").journal);
assert!(!StepMeta::new("s").without_journal().journal);
}
}