Skip to main content

somatize_core/
effect.rs

1//! Effects: the things a step can ask the runtime to do for it.
2//!
3//! A [`crate::filter::Filter`] computes. A step *decides*, and the deciding
4//! needs the world: a model call, a tool, another graph. Rather than let a
5//! step reach out and do that itself, it **describes** what it wants and
6//! hands the description back. The runtime performs it.
7//!
8//! That indirection buys three things that are otherwise each a project of
9//! their own:
10//!
11//! - **Durability.** Every performed effect is journaled by
12//!   `(node, turn, effect hash)`. Replaying a run re-polls the step and
13//!   serves recorded results instead of re-calling the model. This is the
14//!   record-once-replay discipline of durable-execution engines, on top of
15//!   the content-addressed store Soma already has.
16//! - **A sync trait over async work.** The step never awaits; the driver
17//!   does. No coloured functions, and the Python bridge stays a plain call.
18//! - **Testability.** A fake effect handler is a `match` — no network, no
19//!   mocking framework.
20
21use crate::cache::CacheKey;
22use crate::graph::{Graph, NodeId};
23use crate::message::Messages;
24use crate::value::Value;
25use serde::{Deserialize, Serialize};
26use std::time::Duration;
27
28/// Something the runtime does on a step's behalf.
29// Adjacent tagging, matching `Value`: an internal tag would collide both with
30// the `Custom { kind }` field and with the tag `Value` carries in its own
31// payload.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
34#[non_exhaustive]
35pub enum Effect {
36    /// Call a language model.
37    Llm(LlmRequest),
38
39    /// Invoke a registered tool.
40    Tool {
41        /// The name the tool was registered under — a [`ToolSpec::name`].
42        name: String,
43        /// Arguments, as JSON shaped by the tool's [`ToolSpec::input_schema`].
44        args: Value,
45    },
46
47    /// Run a Soma graph and hand back its output.
48    ///
49    /// This is the bridge that makes a computational pipeline a first-class
50    /// tool for an agent: it runs through the ordinary compiler and executor,
51    /// with the ordinary cache, and the agent just sees a result.
52    Graph {
53        /// The graph to run. Structure travels in the effect itself, so the
54        /// handler needs no registry lookup to know what it is performing —
55        /// only the node *implementations* are resolved on the other side.
56        graph: Box<Graph>,
57        /// The input handed to the sub-graph's root nodes.
58        input: Value,
59        /// Forward with the states already fitted, or fit first.
60        #[serde(default)]
61        mode: GraphEffectMode,
62    },
63
64    /// Wait. Journaled like anything else, so a replay does not sleep again.
65    Sleep(Duration),
66
67    /// An effect this runtime doesn't know about, for host-supplied handlers.
68    Custom {
69        /// Which handler this is meant for — the string
70        /// [`EffectHandler::handles`] implementations match on.
71        kind: String,
72        /// Whatever that handler expects.
73        payload: Value,
74    },
75}
76
77impl Effect {
78    /// A short label for events and logs. Never includes the payload — a
79    /// prompt is not something to leak into a log line.
80    pub fn label(&self) -> String {
81        match self {
82            Self::Llm(req) => format!("llm:{}", req.model),
83            Self::Tool { name, .. } => format!("tool:{name}"),
84            Self::Graph { graph, .. } => format!("graph:{} nodes", graph.nodes.len()),
85            Self::Sleep(d) => format!("sleep:{}ms", d.as_millis()),
86            Self::Custom { kind, .. } => format!("custom:{kind}"),
87        }
88    }
89
90    /// Is this effect safe to memoize by content?
91    ///
92    /// Pure effects are cached like any filter output: same input, same
93    /// result, reused forever. Impure ones are still journaled — recorded
94    /// once per `(node, turn)` so a replay is faithful — but never reused
95    /// across runs, because "the same question asked twice" is genuinely a
96    /// different event for a model call or a clock read.
97    pub fn is_pure(&self) -> bool {
98        match self {
99            Self::Llm(_) | Self::Sleep(_) => false,
100            // A filter-only forward inherits Soma's own determinism
101            // guarantees: its nodes are cached by content already. A graph
102            // that contains a step does not — the step calls a model, so the
103            // same question asked twice is a different event, like `Llm`
104            // itself. `Fit` is impure for a second reason: replaying it must
105            // re-write the fitted states, and serving the recorded summary
106            // alone would leave the graph unfitted for the effects after it.
107            Self::Graph { graph, mode, .. } => {
108                matches!(mode, GraphEffectMode::Forward) && !graph.contains_steps()
109            }
110            Self::Tool { .. } | Self::Custom { .. } => false,
111        }
112    }
113
114    /// Content hash, used as the journal key for this effect.
115    ///
116    /// Canonical CBOR, not raw serializer output: an effect carries
117    /// user-supplied JSON (`Tool { args }`, `Custom { payload }`), and two
118    /// encodings of the same object must not produce two journal keys.
119    ///
120    /// Fallible on purpose. The previous encoding fell back to empty bytes,
121    /// which gave *every* unserializable effect the same key — a replay
122    /// would then serve one effect's recorded result to another. A key that
123    /// cannot be computed has to mean "not journalable", never a guess.
124    pub fn cache_key(&self) -> crate::error::Result<CacheKey> {
125        let encoded = crate::canon::canonical_bytes(self)?;
126        Ok(CacheKey::from_parts(&[b"soma-effect-v2", &encoded]))
127    }
128}
129
130/// What to do with a graph run as an effect.
131#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(rename_all = "snake_case")]
133#[non_exhaustive]
134pub enum GraphEffectMode {
135    /// Run `forward` with whatever states are already fitted.
136    #[default]
137    Forward,
138    /// Fit the graph on the supplied input first.
139    Fit,
140}
141
142/// A model call.
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct LlmRequest {
145    /// Which model to ask, in the provider's naming.
146    pub model: String,
147    /// The conversation so far, oldest turn first.
148    pub messages: Messages,
149    /// System prompt, kept apart from `messages` because providers disagree
150    /// about where it goes — a `system` turn, a top-level field, or folded
151    /// into the first user turn. The client places it at its edge.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub system: Option<String>,
154    /// Cap on generated tokens. A reply that hits it stops with
155    /// [`StopReason::MaxTokens`], which steps treat as an error, not an answer.
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub max_tokens: Option<u32>,
158    /// Tools the model may call, as JSON Schema definitions.
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    pub tools: Vec<ToolSpec>,
161    /// Reasoning depth, in the provider's terms (`low` … `max`).
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub effort: Option<String>,
164    /// JSON Schema the reply must satisfy.
165    ///
166    /// A request, not a guarantee: endpoints that support constrained
167    /// decoding enforce it, and the rest are asked in the prompt and may
168    /// still answer with prose. Whoever consumes the reply validates it —
169    /// see [`crate::schema::Schema`] for the graph-level contract, which is
170    /// a different thing: this constrains one model call, that one
171    /// constrains an edge.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub schema: Option<serde_json::Value>,
174}
175
176impl LlmRequest {
177    /// A request with only the essentials: a model and a conversation.
178    ///
179    /// Everything else is opt-in through the `with_*` builders — and because
180    /// unset options are skipped when the request is serialized, they are
181    /// also absent from the journal key ([`Effect::cache_key`]). Growing this
182    /// struct therefore never moves the keys of requests that predate the
183    /// new knob.
184    pub fn new(model: impl Into<String>, messages: Messages) -> Self {
185        Self {
186            model: model.into(),
187            messages,
188            system: None,
189            max_tokens: None,
190            tools: Vec::new(),
191            effort: None,
192            schema: None,
193        }
194    }
195
196    /// Set the system prompt.
197    ///
198    /// Stated here once, placed by the provider client wherever this
199    /// endpoint wants it — as its own turn, or prepended to the first user
200    /// turn when the endpoint refuses a system role.
201    pub fn with_system(mut self, system: impl Into<String>) -> Self {
202        self.system = Some(system.into());
203        self
204    }
205
206    /// Cap the reply at `n` generated tokens.
207    ///
208    /// A budget, not a target: a reply that runs into it is a cut-off
209    /// thought, and [`LlmResponse::reject_non_answers`] turns it into an
210    /// error rather than letting the fragment flow downstream as an answer.
211    pub fn with_max_tokens(mut self, n: u32) -> Self {
212        self.max_tokens = Some(n);
213        self
214    }
215
216    /// Offer the model these tools, replacing any previous set.
217    ///
218    /// Offering is all this does. When the model wants one, the reply stops
219    /// with [`StopReason::ToolUse`] and it is the step's job to perform the
220    /// calls — typically as [`Effect::Tool`] — and ask the model to continue
221    /// with the results appended to the conversation.
222    pub fn with_tools(mut self, tools: Vec<ToolSpec>) -> Self {
223        self.tools = tools;
224        self
225    }
226
227    /// Ask for this much reasoning, in the provider's vocabulary
228    /// (`low` … `max`).
229    ///
230    /// Passed through verbatim (`reasoning_effort` on OpenAI-shaped
231    /// endpoints); endpoints without the concept ignore the unknown field.
232    /// Like every other knob it is part of the journal key — the same
233    /// question at a different effort is a different request, so a replay
234    /// never serves the cheaper answer.
235    pub fn with_effort(mut self, effort: impl Into<String>) -> Self {
236        self.effort = Some(effort.into());
237        self
238    }
239
240    /// Ask for a reply shaped like `schema`.
241    pub fn with_schema(mut self, schema: serde_json::Value) -> Self {
242        self.schema = Some(schema);
243        self
244    }
245
246    /// Wrap as the effect a step awaits.
247    pub fn into_effect(self) -> Effect {
248        Effect::Llm(self)
249    }
250}
251
252pub use crate::tool::ToolSpec;
253
254/// Performs one kind of effect.
255///
256/// Handlers are tried in order; the first that claims an effect wins.
257///
258/// Lives here rather than beside the driver that runs them, so a crate can
259/// implement a handler without depending on the execution engine —
260/// `soma-llm` is one, and what it needs is a provider client, not a
261/// scheduler.
262pub trait EffectHandler: Send + Sync {
263    /// Will this handler take that effect?
264    fn handles(&self, effect: &Effect) -> bool;
265
266    /// Perform it. Blocking.
267    ///
268    /// A transport failure should come back as [`EffectResult::Failed`],
269    /// not `Err` — the step decides whether to retry, fall back, or stop.
270    /// Reserve `Err` for conditions the step cannot act on.
271    fn perform(&self, effect: &Effect) -> crate::error::Result<EffectResult>;
272}
273
274/// What came back from performing an effect.
275#[derive(Debug, Clone, Serialize, Deserialize)]
276#[serde(tag = "kind", content = "data", rename_all = "snake_case")]
277#[non_exhaustive]
278pub enum EffectResult {
279    /// A model's reply to [`Effect::Llm`].
280    Llm(LlmResponse),
281    /// A tool's output, or its error text.
282    Tool {
283        /// What the tool produced — its error text when `is_error` is set.
284        output: Value,
285        /// The tool ran and reported failure. Reported, not raised, so the
286        /// step (or the model, told via a tool-result block) can adapt.
287        #[serde(default)]
288        is_error: bool,
289    },
290    /// The output of the sub-graph run requested by [`Effect::Graph`].
291    Graph(Value),
292    /// What a node spawned by [`crate::step::Transition::Spawn`] produced.
293    Node(Value),
294    /// The sleep elapsed — or was replayed from the journal, and nobody slept.
295    Slept,
296    /// Whatever a host-supplied handler returned for [`Effect::Custom`].
297    Custom(Value),
298    /// The effect could not be performed. Handed to the step rather than
299    /// raised, so it can retry, fall back, or give up deliberately.
300    Failed {
301        /// What went wrong, as text the step can quote or act on.
302        message: String,
303    },
304}
305
306impl EffectResult {
307    /// Did this effect fail — either outright ([`Self::Failed`]) or as a
308    /// tool that ran and reported an error?
309    pub fn is_error(&self) -> bool {
310        matches!(
311            self,
312            Self::Failed { .. } | Self::Tool { is_error: true, .. }
313        )
314    }
315
316    /// The value this result carries, if it carries one.
317    pub fn value(&self) -> Option<&Value> {
318        match self {
319            Self::Tool { output, .. }
320            | Self::Graph(output)
321            | Self::Node(output)
322            | Self::Custom(output) => Some(output),
323            _ => None,
324        }
325    }
326}
327
328/// A model's reply.
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct LlmResponse {
331    /// The assistant turn, blocks intact — prose and tool calls together.
332    pub message: crate::message::Message,
333    /// Why generation ended. Checked before the text is trusted —
334    /// see [`Self::reject_non_answers`].
335    pub stop_reason: StopReason,
336    /// Token accounting for this one call.
337    #[serde(default)]
338    pub usage: Usage,
339    /// Which model actually served this, which may differ from the one asked
340    /// for when a provider-side fallback kicked in.
341    #[serde(default, skip_serializing_if = "Option::is_none")]
342    pub model: Option<String>,
343}
344
345impl LlmResponse {
346    /// Fail unless this reply is actually an answer.
347    ///
348    /// Two ways a turn can end are not answers: the model was cut off
349    /// mid-sentence, or it declined. Both leave `message` populated with
350    /// something that reads like a reply, so a step that only parses the
351    /// text turns them into a *confident wrong result* — a truncated JSON
352    /// object that fails to parse and scores 0.0, a half-written plan
353    /// treated as the whole plan. Neither is recoverable by reading harder,
354    /// so every step calls this before it looks at the text.
355    ///
356    /// `node_id` names the failing node in the error.
357    pub fn reject_non_answers(&self, node_id: &str) -> crate::error::Result<()> {
358        match &self.stop_reason {
359            StopReason::Refusal { category } => Err(crate::error::SomaError::Execution {
360                node_id: node_id.to_string(),
361                message: format!(
362                    "the model declined to answer{}. Rephrasing the request or \
363                     changing model is the fix; there is no partial answer to \
364                     salvage",
365                    category
366                        .as_deref()
367                        .map(|c| format!(" ({c})"))
368                        .unwrap_or_default()
369                ),
370            }),
371            // The text so far may well be useful, so it goes in the
372            // message — but it is a cut-off thought, and passing it
373            // downstream as complete is a wrong answer nobody can see is
374            // wrong.
375            StopReason::MaxTokens => Err(crate::error::SomaError::Execution {
376                node_id: node_id.to_string(),
377                message: format!(
378                    "the model ran out of tokens mid-answer. Raise `max_tokens` \
379                     (or shorten the task). Partial answer: {}",
380                    crate::util::truncate(&self.message.text(), 300)
381                ),
382            }),
383            _ => Ok(()),
384        }
385    }
386}
387
388/// Why the model stopped.
389#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
390#[serde(tag = "type", rename_all = "snake_case")]
391#[non_exhaustive]
392pub enum StopReason {
393    /// The model finished its turn on its own — the one reason that is an answer.
394    EndTurn,
395    /// Generation hit `max_tokens` mid-answer; the text is a fragment.
396    /// [`LlmResponse::reject_non_answers`] turns this into an error.
397    MaxTokens,
398    /// The model wants tools run before it continues.
399    ToolUse,
400    /// The provider declined. Carried as a value, not an error: the step
401    /// decides whether to rephrase, fall back, or surface it.
402    Refusal {
403        /// The provider's refusal category, when it gave one.
404        #[serde(default, skip_serializing_if = "Option::is_none")]
405        category: Option<String>,
406    },
407}
408
409/// Token accounting for one call.
410#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
411pub struct Usage {
412    /// Tokens the model read.
413    #[serde(default)]
414    pub input_tokens: u64,
415    /// Tokens the model generated.
416    #[serde(default)]
417    pub output_tokens: u64,
418    /// Input tokens served from the provider's prompt cache.
419    #[serde(default)]
420    pub cache_read_tokens: u64,
421    /// Input tokens written to the provider's prompt cache.
422    #[serde(default)]
423    pub cache_write_tokens: u64,
424}
425
426impl Usage {
427    /// Input plus output tokens — the headline number for cost and for
428    /// watching a conversation approach its context limit.
429    pub fn total(&self) -> u64 {
430        self.input_tokens + self.output_tokens
431    }
432}
433
434impl std::ops::AddAssign for Usage {
435    fn add_assign(&mut self, rhs: Self) {
436        self.input_tokens += rhs.input_tokens;
437        self.output_tokens += rhs.output_tokens;
438        self.cache_read_tokens += rhs.cache_read_tokens;
439        self.cache_write_tokens += rhs.cache_write_tokens;
440    }
441}
442
443/// A node to create while the run is in flight.
444///
445/// The unit of dynamic fan-out: a step that discovers it has N things to do
446/// emits N specs, and the runtime runs them. There is no way to pre-declare
447/// this in a static plan, which is why an orchestrator-workers shape cannot
448/// be expressed by topology alone.
449#[derive(Debug, Clone, Serialize, Deserialize)]
450pub struct NodeSpec {
451    /// Which registered step or filter to run.
452    pub runs: NodeId,
453    /// The input it receives.
454    pub input: Value,
455    /// Suffix distinguishing this instance from its siblings.
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub label: Option<String>,
458}
459
460impl NodeSpec {
461    /// A spec for one spawned instance of `runs`, unlabelled.
462    pub fn new(runs: impl Into<NodeId>, input: Value) -> Self {
463        Self {
464            runs: runs.into(),
465            input,
466            label: None,
467        }
468    }
469
470    /// Name this instance, so siblings spawned from the same node stay
471    /// tellable apart in events and the journal.
472    pub fn with_label(mut self, label: impl Into<String>) -> Self {
473        self.label = Some(label.into());
474        self
475    }
476}
477
478/// How spawned work is recombined.
479#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
480#[serde(rename_all = "snake_case")]
481#[non_exhaustive]
482pub enum JoinPolicy {
483    /// Wait for all; a failure fails the join.
484    #[default]
485    All,
486    /// Wait for all, keeping whatever succeeded.
487    AllSettled,
488    /// Take the first success and cancel the rest.
489    First,
490}
491
492impl JoinPolicy {
493    /// A label for telemetry, in the spirit of [`Effect::label`].
494    pub fn label(&self) -> &'static str {
495        match self {
496            JoinPolicy::All => "all",
497            JoinPolicy::AllSettled => "all-settled",
498            JoinPolicy::First => "first",
499        }
500    }
501}
502
503/// Why a run stopped and what would restart it.
504#[derive(Debug, Clone, Serialize, Deserialize)]
505#[serde(tag = "type", rename_all = "snake_case")]
506#[non_exhaustive]
507pub enum SuspendReason {
508    /// Waiting on a person.
509    Human {
510        /// What to ask them.
511        prompt: String,
512        /// JSON Schema the answer should satisfy, when a shape is expected.
513        #[serde(default, skip_serializing_if = "Option::is_none")]
514        schema: Option<serde_json::Value>,
515    },
516    /// Waiting on something outside the run entirely.
517    External {
518        /// Opaque correlation token; whoever resumes the run quotes it back.
519        token: String,
520    },
521}
522
523impl SuspendReason {
524    /// A short description, for an error message or an event payload.
525    pub fn label(&self) -> String {
526        match self {
527            Self::Human { prompt, .. } => format!("waiting on a person: {prompt}"),
528            Self::External { token } => format!("waiting on `{token}`"),
529        }
530    }
531
532    /// The kind, as a stable token events can be grouped by.
533    pub fn kind(&self) -> &'static str {
534        match self {
535            Self::Human { .. } => "human",
536            Self::External { .. } => "external",
537        }
538    }
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use crate::message::Message;
545
546    fn llm() -> Effect {
547        Effect::Llm(LlmRequest::new(
548            "claude-opus-5",
549            vec![Message::user("hi")].into(),
550        ))
551    }
552
553    /// The journal key must follow the request's content, or a replay would
554    /// serve one call's answer to a different call.
555    #[test]
556    fn effect_keys_follow_content() {
557        let a = llm();
558        let b = Effect::Llm(LlmRequest::new(
559            "claude-opus-5",
560            vec![Message::user("something else")].into(),
561        ));
562        assert_eq!(a.cache_key().unwrap(), llm().cache_key().unwrap());
563        assert_ne!(a.cache_key().unwrap(), b.cache_key().unwrap());
564    }
565
566    /// A tool's arguments are user JSON, and a JSON object has no inherent
567    /// key order. Two spellings of the same call are the same call, so they
568    /// must journal under one key — this is why the encoding is canonical
569    /// CBOR and not raw serializer output.
570    #[test]
571    fn tool_args_key_is_independent_of_json_key_order() {
572        let one = Effect::Tool {
573            name: "search".into(),
574            args: Value::json(serde_json::json!({"q": "soma", "limit": 3})),
575        };
576        let other = Effect::Tool {
577            name: "search".into(),
578            args: Value::json(serde_json::json!({"limit": 3, "q": "soma"})),
579        };
580        assert_eq!(one.cache_key().unwrap(), other.cache_key().unwrap());
581    }
582
583    /// Changing any request knob must change the key — otherwise a replay at
584    /// a different effort would reuse the cheaper answer.
585    #[test]
586    fn request_options_are_part_of_the_key() {
587        let base = LlmRequest::new("claude-opus-5", vec![Message::user("hi")].into());
588        let keys = [
589            Effect::Llm(base.clone()).cache_key().unwrap(),
590            Effect::Llm(base.clone().with_system("be terse"))
591                .cache_key()
592                .unwrap(),
593            Effect::Llm(base.clone().with_effort("high"))
594                .cache_key()
595                .unwrap(),
596            Effect::Llm(base.with_max_tokens(10)).cache_key().unwrap(),
597        ];
598        for (i, a) in keys.iter().enumerate() {
599            for b in &keys[i + 1..] {
600                assert_ne!(a, b, "two distinct requests share a journal key");
601            }
602        }
603    }
604
605    /// Model calls must never be reused by content: asking twice is two
606    /// events, and freezing the first answer is the `_deterministic=False`
607    /// foot-gun in a new costume.
608    #[test]
609    fn model_calls_are_impure() {
610        assert!(!llm().is_pure());
611        assert!(!Effect::Sleep(Duration::from_secs(1)).is_pure());
612        assert!(
613            !Effect::Tool {
614                name: "search".into(),
615                args: Value::Empty
616            }
617            .is_pure()
618        );
619    }
620
621    /// A filter-only forward is the one graph effect safe to memoize by
622    /// content: its nodes are deterministic and content-cached already.
623    #[test]
624    fn a_filter_only_forward_graph_stays_pure() {
625        let mut graph = crate::graph::Graph::new();
626        graph.add_node(crate::graph::Node::filter("scale"));
627        let effect = Effect::Graph {
628            graph: Box::new(graph),
629            input: Value::Empty,
630            mode: GraphEffectMode::Forward,
631        };
632        assert!(effect.is_pure());
633    }
634
635    /// A sub-graph with a step calls a model; reusing its first answer
636    /// forever by content key would be the `Llm` mistake with extra steps —
637    /// even when the step hides one sub-graph down.
638    #[test]
639    fn a_step_containing_graph_effect_is_impure() {
640        let mut inner = crate::graph::Graph::new();
641        inner.add_node(crate::graph::Node::step("agent", "ReactStep"));
642        let mut graph = crate::graph::Graph::new();
643        graph.add_node(crate::graph::Node::subgraph("nested", inner));
644        let effect = Effect::Graph {
645            graph: Box::new(graph),
646            input: Value::Empty,
647            mode: GraphEffectMode::Forward,
648        };
649        assert!(!effect.is_pure());
650    }
651
652    /// Fit writes states as a side effect; replaying the recorded summary
653    /// without re-fitting would leave the graph unfitted for what follows.
654    #[test]
655    fn a_fit_mode_graph_effect_is_impure() {
656        let mut graph = crate::graph::Graph::new();
657        graph.add_node(crate::graph::Node::filter("scale"));
658        let effect = Effect::Graph {
659            graph: Box::new(graph),
660            input: Value::Empty,
661            mode: GraphEffectMode::Fit,
662        };
663        assert!(!effect.is_pure());
664    }
665
666    #[test]
667    fn labels_do_not_leak_payloads() {
668        let label = llm().label();
669        assert!(label.contains("claude-opus-5"));
670        assert!(!label.contains("hi"));
671    }
672
673    #[test]
674    fn usage_accumulates() {
675        let mut total = Usage::default();
676        total += Usage {
677            input_tokens: 10,
678            output_tokens: 5,
679            ..Default::default()
680        };
681        total += Usage {
682            input_tokens: 1,
683            output_tokens: 2,
684            ..Default::default()
685        };
686        assert_eq!(total.input_tokens, 11);
687        assert_eq!(total.total(), 18);
688    }
689
690    #[test]
691    fn failed_and_errored_tools_read_as_errors() {
692        assert!(
693            EffectResult::Failed {
694                message: "boom".into()
695            }
696            .is_error()
697        );
698        assert!(
699            EffectResult::Tool {
700                output: Value::text("nope"),
701                is_error: true
702            }
703            .is_error()
704        );
705        assert!(
706            !EffectResult::Tool {
707                output: Value::text("fine"),
708                is_error: false
709            }
710            .is_error()
711        );
712    }
713}