use crate::cache::CacheKey;
use crate::graph::{Graph, NodeId};
use crate::message::Messages;
use crate::value::Value;
use serde::{Deserialize, Serialize};
use std::time::Duration;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
#[non_exhaustive]
pub enum Effect {
Llm(LlmRequest),
Tool {
name: String,
args: Value,
},
Graph {
graph: Box<Graph>,
input: Value,
#[serde(default)]
mode: GraphEffectMode,
},
Sleep(Duration),
Custom {
kind: String,
payload: Value,
},
}
impl Effect {
pub fn label(&self) -> String {
match self {
Self::Llm(req) => format!("llm:{}", req.model),
Self::Tool { name, .. } => format!("tool:{name}"),
Self::Graph { graph, .. } => format!("graph:{} nodes", graph.nodes.len()),
Self::Sleep(d) => format!("sleep:{}ms", d.as_millis()),
Self::Custom { kind, .. } => format!("custom:{kind}"),
}
}
pub fn is_pure(&self) -> bool {
match self {
Self::Llm(_) | Self::Sleep(_) => false,
Self::Graph { graph, mode, .. } => {
matches!(mode, GraphEffectMode::Forward) && !graph.contains_steps()
}
Self::Tool { .. } | Self::Custom { .. } => false,
}
}
pub fn cache_key(&self) -> crate::error::Result<CacheKey> {
let encoded = crate::canon::canonical_bytes(self)?;
Ok(CacheKey::from_parts(&[b"soma-effect-v2", &encoded]))
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum GraphEffectMode {
#[default]
Forward,
Fit,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmRequest {
pub model: String,
pub messages: Messages,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tools: Vec<ToolSpec>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub effort: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub schema: Option<serde_json::Value>,
}
impl LlmRequest {
pub fn new(model: impl Into<String>, messages: Messages) -> Self {
Self {
model: model.into(),
messages,
system: None,
max_tokens: None,
tools: Vec::new(),
effort: None,
schema: None,
}
}
pub fn with_system(mut self, system: impl Into<String>) -> Self {
self.system = Some(system.into());
self
}
pub fn with_max_tokens(mut self, n: u32) -> Self {
self.max_tokens = Some(n);
self
}
pub fn with_tools(mut self, tools: Vec<ToolSpec>) -> Self {
self.tools = tools;
self
}
pub fn with_effort(mut self, effort: impl Into<String>) -> Self {
self.effort = Some(effort.into());
self
}
pub fn with_schema(mut self, schema: serde_json::Value) -> Self {
self.schema = Some(schema);
self
}
pub fn into_effect(self) -> Effect {
Effect::Llm(self)
}
}
pub use crate::tool::ToolSpec;
pub trait EffectHandler: Send + Sync {
fn handles(&self, effect: &Effect) -> bool;
fn perform(&self, effect: &Effect) -> crate::error::Result<EffectResult>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
#[non_exhaustive]
pub enum EffectResult {
Llm(LlmResponse),
Tool {
output: Value,
#[serde(default)]
is_error: bool,
},
Graph(Value),
Node(Value),
Slept,
Custom(Value),
Failed {
message: String,
},
}
impl EffectResult {
pub fn is_error(&self) -> bool {
matches!(
self,
Self::Failed { .. } | Self::Tool { is_error: true, .. }
)
}
pub fn value(&self) -> Option<&Value> {
match self {
Self::Tool { output, .. }
| Self::Graph(output)
| Self::Node(output)
| Self::Custom(output) => Some(output),
_ => None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmResponse {
pub message: crate::message::Message,
pub stop_reason: StopReason,
#[serde(default)]
pub usage: Usage,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
}
impl LlmResponse {
pub fn reject_non_answers(&self, node_id: &str) -> crate::error::Result<()> {
match &self.stop_reason {
StopReason::Refusal { category } => Err(crate::error::SomaError::Execution {
node_id: node_id.to_string(),
message: format!(
"the model declined to answer{}. Rephrasing the request or \
changing model is the fix; there is no partial answer to \
salvage",
category
.as_deref()
.map(|c| format!(" ({c})"))
.unwrap_or_default()
),
}),
StopReason::MaxTokens => Err(crate::error::SomaError::Execution {
node_id: node_id.to_string(),
message: format!(
"the model ran out of tokens mid-answer. Raise `max_tokens` \
(or shorten the task). Partial answer: {}",
crate::util::truncate(&self.message.text(), 300)
),
}),
_ => Ok(()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum StopReason {
EndTurn,
MaxTokens,
ToolUse,
Refusal {
#[serde(default, skip_serializing_if = "Option::is_none")]
category: Option<String>,
},
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Usage {
#[serde(default)]
pub input_tokens: u64,
#[serde(default)]
pub output_tokens: u64,
#[serde(default)]
pub cache_read_tokens: u64,
#[serde(default)]
pub cache_write_tokens: u64,
}
impl Usage {
pub fn total(&self) -> u64 {
self.input_tokens + self.output_tokens
}
}
impl std::ops::AddAssign for Usage {
fn add_assign(&mut self, rhs: Self) {
self.input_tokens += rhs.input_tokens;
self.output_tokens += rhs.output_tokens;
self.cache_read_tokens += rhs.cache_read_tokens;
self.cache_write_tokens += rhs.cache_write_tokens;
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeSpec {
pub runs: NodeId,
pub input: Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
impl NodeSpec {
pub fn new(runs: impl Into<NodeId>, input: Value) -> Self {
Self {
runs: runs.into(),
input,
label: None,
}
}
pub fn with_label(mut self, label: impl Into<String>) -> Self {
self.label = Some(label.into());
self
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum JoinPolicy {
#[default]
All,
AllSettled,
First,
}
impl JoinPolicy {
pub fn label(&self) -> &'static str {
match self {
JoinPolicy::All => "all",
JoinPolicy::AllSettled => "all-settled",
JoinPolicy::First => "first",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
#[non_exhaustive]
pub enum SuspendReason {
Human {
prompt: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
schema: Option<serde_json::Value>,
},
External {
token: String,
},
}
impl SuspendReason {
pub fn label(&self) -> String {
match self {
Self::Human { prompt, .. } => format!("waiting on a person: {prompt}"),
Self::External { token } => format!("waiting on `{token}`"),
}
}
pub fn kind(&self) -> &'static str {
match self {
Self::Human { .. } => "human",
Self::External { .. } => "external",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::Message;
fn llm() -> Effect {
Effect::Llm(LlmRequest::new(
"claude-opus-5",
vec![Message::user("hi")].into(),
))
}
#[test]
fn effect_keys_follow_content() {
let a = llm();
let b = Effect::Llm(LlmRequest::new(
"claude-opus-5",
vec![Message::user("something else")].into(),
));
assert_eq!(a.cache_key().unwrap(), llm().cache_key().unwrap());
assert_ne!(a.cache_key().unwrap(), b.cache_key().unwrap());
}
#[test]
fn tool_args_key_is_independent_of_json_key_order() {
let one = Effect::Tool {
name: "search".into(),
args: Value::json(serde_json::json!({"q": "soma", "limit": 3})),
};
let other = Effect::Tool {
name: "search".into(),
args: Value::json(serde_json::json!({"limit": 3, "q": "soma"})),
};
assert_eq!(one.cache_key().unwrap(), other.cache_key().unwrap());
}
#[test]
fn request_options_are_part_of_the_key() {
let base = LlmRequest::new("claude-opus-5", vec![Message::user("hi")].into());
let keys = [
Effect::Llm(base.clone()).cache_key().unwrap(),
Effect::Llm(base.clone().with_system("be terse"))
.cache_key()
.unwrap(),
Effect::Llm(base.clone().with_effort("high"))
.cache_key()
.unwrap(),
Effect::Llm(base.with_max_tokens(10)).cache_key().unwrap(),
];
for (i, a) in keys.iter().enumerate() {
for b in &keys[i + 1..] {
assert_ne!(a, b, "two distinct requests share a journal key");
}
}
}
#[test]
fn model_calls_are_impure() {
assert!(!llm().is_pure());
assert!(!Effect::Sleep(Duration::from_secs(1)).is_pure());
assert!(
!Effect::Tool {
name: "search".into(),
args: Value::Empty
}
.is_pure()
);
}
#[test]
fn a_filter_only_forward_graph_stays_pure() {
let mut graph = crate::graph::Graph::new();
graph.add_node(crate::graph::Node::filter("scale"));
let effect = Effect::Graph {
graph: Box::new(graph),
input: Value::Empty,
mode: GraphEffectMode::Forward,
};
assert!(effect.is_pure());
}
#[test]
fn a_step_containing_graph_effect_is_impure() {
let mut inner = crate::graph::Graph::new();
inner.add_node(crate::graph::Node::step("agent", "ReactStep"));
let mut graph = crate::graph::Graph::new();
graph.add_node(crate::graph::Node::subgraph("nested", inner));
let effect = Effect::Graph {
graph: Box::new(graph),
input: Value::Empty,
mode: GraphEffectMode::Forward,
};
assert!(!effect.is_pure());
}
#[test]
fn a_fit_mode_graph_effect_is_impure() {
let mut graph = crate::graph::Graph::new();
graph.add_node(crate::graph::Node::filter("scale"));
let effect = Effect::Graph {
graph: Box::new(graph),
input: Value::Empty,
mode: GraphEffectMode::Fit,
};
assert!(!effect.is_pure());
}
#[test]
fn labels_do_not_leak_payloads() {
let label = llm().label();
assert!(label.contains("claude-opus-5"));
assert!(!label.contains("hi"));
}
#[test]
fn usage_accumulates() {
let mut total = Usage::default();
total += Usage {
input_tokens: 10,
output_tokens: 5,
..Default::default()
};
total += Usage {
input_tokens: 1,
output_tokens: 2,
..Default::default()
};
assert_eq!(total.input_tokens, 11);
assert_eq!(total.total(), 18);
}
#[test]
fn failed_and_errored_tools_read_as_errors() {
assert!(
EffectResult::Failed {
message: "boom".into()
}
.is_error()
);
assert!(
EffectResult::Tool {
output: Value::text("nope"),
is_error: true
}
.is_error()
);
assert!(
!EffectResult::Tool {
output: Value::text("fine"),
is_error: false
}
.is_error()
);
}
}