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}