1use crate::cache::MemoryCache;
13use crate::effects::{EffectDriver, EffectHandler, EffectJournal};
14use crate::event_bus::EventBus;
15use crate::graph_session::GraphSession;
16use crate::node_catalog::NodeCatalog;
17use somatize_core::cache::CacheStore;
18use somatize_core::effect::{Effect, EffectResult, GraphEffectMode};
19use somatize_core::error::{Result, SomaError};
20use std::sync::Arc;
21
22pub const MAX_GRAPH_DEPTH: usize = 8;
29
30#[derive(Clone)]
36struct StepRuntime {
37 handlers: Vec<Arc<dyn EffectHandler>>,
38 journal: EffectJournal,
39 event_bus: Option<Arc<EventBus>>,
40 depth: usize,
41}
42
43pub struct GraphHandler {
48 library: NodeCatalog,
49 cache: Arc<dyn CacheStore>,
50 step_runtime: Option<StepRuntime>,
51}
52
53impl GraphHandler {
54 pub fn new(library: NodeCatalog) -> Self {
60 Self {
61 library,
62 cache: Arc::new(MemoryCache::new(64 * 1024 * 1024)),
63 step_runtime: None,
64 }
65 }
66
67 pub fn with_cache(mut self, cache: Arc<dyn CacheStore>) -> Self {
70 self.cache = cache;
71 self
72 }
73
74 pub fn with_step_runtime(
81 mut self,
82 handlers: Vec<Arc<dyn EffectHandler>>,
83 journal: EffectJournal,
84 ) -> Self {
85 self.step_runtime = Some(StepRuntime {
86 handlers,
87 journal,
88 event_bus: None,
89 depth: 0,
90 });
91 self
92 }
93
94 pub fn with_event_bus(mut self, bus: Arc<EventBus>) -> Self {
99 if let Some(rt) = &mut self.step_runtime {
100 rt.event_bus = Some(bus);
101 }
102 self
103 }
104
105 pub fn library(&self) -> &NodeCatalog {
107 &self.library
108 }
109
110 fn child_driver(&self) -> Option<EffectDriver> {
113 let rt = self.step_runtime.as_ref()?;
114 if rt.depth + 1 >= MAX_GRAPH_DEPTH {
115 return None;
116 }
117 let mut child_rt = rt.clone();
118 child_rt.depth += 1;
119 let mut driver = EffectDriver::new(rt.journal.clone())
120 .with_catalog(Arc::new(self.library.clone()))
121 .with_handler(Arc::new(GraphHandler {
122 library: self.library.clone(),
123 cache: self.cache.clone(),
124 step_runtime: Some(child_rt),
125 }));
126 for handler in &rt.handlers {
127 driver = driver.with_handler(handler.clone());
128 }
129 if let Some(bus) = &rt.event_bus {
130 driver = driver.with_event_bus(bus.clone());
131 }
132 Some(driver)
133 }
134}
135
136impl EffectHandler for GraphHandler {
137 fn handles(&self, effect: &Effect) -> bool {
138 matches!(effect, Effect::Graph { .. })
139 }
140
141 fn perform(&self, effect: &Effect) -> Result<EffectResult> {
142 let Effect::Graph { graph, input, mode } = effect else {
143 return Err(SomaError::Other("not a graph effect".into()));
144 };
145
146 let mut session = GraphSession::new((**graph).clone(), self.library.clone())
149 .with_cache(self.cache.clone());
150
151 if graph.contains_steps() {
152 match self.child_driver() {
153 Some(driver) => session = session.with_driver(driver),
154 None => {
158 return Ok(EffectResult::Failed {
159 message: match &self.step_runtime {
160 None => "the sub-graph contains a step, but this graph handler \
161 was built without a step runtime; build it with \
162 `GraphHandler::with_step_runtime(...)`"
163 .into(),
164 Some(rt) => format!(
165 "the sub-graph contains a step, but nesting agents inside \
166 pipelines inside agents stops at depth {MAX_GRAPH_DEPTH} \
167 (this call is at depth {})",
168 rt.depth + 1
169 ),
170 },
171 });
172 }
173 }
174 }
175
176 let outcome = match mode {
177 GraphEffectMode::Fit => session
178 .fit(input, None)
179 .map(|outputs| somatize_core::value::Value::json(outputs_summary(&outputs))),
180 GraphEffectMode::Forward => session.forward(input),
184 other => Err(SomaError::Other(format!(
185 "unsupported graph effect mode: {other:?}"
186 ))),
187 };
188
189 match outcome {
190 Ok(value) => Ok(EffectResult::Graph(value)),
191 Err(e) => Ok(EffectResult::Failed {
196 message: e.to_string(),
197 }),
198 }
199 }
200}
201
202fn outputs_summary(
212 outputs: &std::collections::HashMap<String, somatize_core::value::Value>,
213) -> serde_json::Value {
214 let mut summary = serde_json::Map::new();
215 for (node_id, value) in outputs {
216 if node_id.starts_with("__") {
217 continue;
218 }
219 summary.insert(node_id.clone(), summarize_state(value));
220 }
221 serde_json::Value::Object(summary)
222}
223
224const WEIGHTS_THRESHOLD: usize = 32;
226
227fn summarize_state(state: &somatize_core::value::Value) -> serde_json::Value {
228 let json = state.to_plain_json();
229 if is_bulk(&json) {
230 return serde_json::json!({ "fitted": true });
231 }
232 json
233}
234
235fn is_bulk(json: &serde_json::Value) -> bool {
236 match json {
237 serde_json::Value::Array(items) => {
238 items.len() > WEIGHTS_THRESHOLD || items.iter().any(is_bulk)
239 }
240 serde_json::Value::Object(map) => map.values().any(is_bulk),
241 _ => false,
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use somatize_core::filter::{Filter, FilterKind, FilterMeta, StreamMode};
249 use somatize_core::graph::{Graph, Node};
250 use somatize_core::value::Value;
251
252 struct Doubler;
253
254 impl Filter for Doubler {
255 fn meta(&self) -> FilterMeta {
256 FilterMeta {
257 name: "doubler".into(),
258 kind: FilterKind::Stateless,
259 cacheable: true,
260 differentiable: false,
261 deterministic: true,
262 stream_mode: StreamMode::FixedState,
263 distribution: somatize_core::filter::Distribution::Local,
264 input_schema: None,
265 output_schema: None,
266 }
267 }
268 fn fit(&self, _x: &Value, _y: Option<&Value>) -> Result<Value> {
269 Ok(Value::Empty)
270 }
271 fn forward(&self, x: &Value, _state: &Value) -> Result<Value> {
272 let (data, shape) = x
273 .as_tensor()
274 .ok_or(SomaError::Other("not a tensor".into()))?;
275 Ok(Value::tensor(
276 data.iter().map(|v| v * 2.0).collect(),
277 shape.to_vec(),
278 ))
279 }
280 fn config_hash(&self) -> somatize_core::cache::CacheKey {
281 somatize_core::cache::CacheKey::from_parts(&[b"doubler"])
282 }
283 }
284
285 fn handler() -> GraphHandler {
286 let mut library = NodeCatalog::new();
287 library.register("double", Box::new(Doubler));
288 GraphHandler::new(library)
289 }
290
291 fn one_node_graph() -> Graph {
292 let mut graph = Graph::new();
293 graph.add_node(Node::filter_with_id("double", "doubler"));
294 graph
295 }
296
297 #[test]
298 fn a_graph_effect_runs_the_graph() {
299 let result = handler()
300 .perform(&Effect::Graph {
301 graph: Box::new(one_node_graph()),
302 input: Value::tensor(vec![1.0, 2.0], vec![2]),
303 mode: GraphEffectMode::Forward,
304 })
305 .unwrap();
306
307 match result {
308 EffectResult::Graph(value) => {
309 let (data, _) = value.as_tensor().unwrap();
310 assert_eq!(data, &[2.0, 4.0]);
311 }
312 other => panic!("{other:?}"),
313 }
314 }
315
316 #[test]
319 fn a_failing_graph_comes_back_as_a_result() {
320 let mut graph = Graph::new();
321 graph.add_node(Node::filter_with_id("missing", "nowhere"));
322
323 let result = handler()
324 .perform(&Effect::Graph {
325 graph: Box::new(graph),
326 input: Value::tensor(vec![1.0], vec![1]),
327 mode: GraphEffectMode::Forward,
328 })
329 .unwrap();
330
331 assert!(matches!(result, EffectResult::Failed { .. }));
332 }
333
334 use somatize_core::effect::{LlmRequest, LlmResponse, StopReason};
335 use somatize_core::message::Message;
336 use somatize_core::step::{StepCtx, StepMeta, Transition};
337
338 struct CannedLlm(&'static str);
340
341 impl EffectHandler for CannedLlm {
342 fn handles(&self, effect: &Effect) -> bool {
343 matches!(effect, Effect::Llm(_))
344 }
345 fn perform(&self, _effect: &Effect) -> Result<EffectResult> {
346 Ok(EffectResult::Llm(LlmResponse {
347 message: Message::assistant(self.0),
348 stop_reason: StopReason::EndTurn,
349 usage: Default::default(),
350 model: None,
351 }))
352 }
353 }
354
355 struct AskOnce;
357
358 impl somatize_core::step::Step for AskOnce {
359 fn config_hash(&self) -> somatize_core::cache::CacheKey {
360 somatize_core::cache::CacheKey::from_parts(&[b"AskOnce"])
361 }
362 fn meta(&self) -> StepMeta {
363 StepMeta::new("AskOnce")
364 }
365 fn poll(&self, ctx: &StepCtx<'_>) -> Result<Transition> {
366 if ctx.turn == 0 {
367 return Ok(Transition::Await(vec![Effect::Llm(LlmRequest::new(
368 "claude-opus-5",
369 vec![Message::user("inner question")].into(),
370 ))]));
371 }
372 let text = match ctx.result() {
373 Some(EffectResult::Llm(r)) => r.message.text(),
374 other => format!("unexpected result: {other:?}"),
375 };
376 Ok(Transition::Done(Value::text(text)))
377 }
378 }
379
380 fn journal() -> EffectJournal {
381 let dir = tempfile::tempdir().unwrap();
382 let store = Arc::new(crate::cache::FsActionStore::new(dir.keep()).unwrap());
383 EffectJournal::new(store.clone(), store)
384 }
385
386 #[test]
390 fn a_sub_graph_containing_a_step_runs() {
391 let mut library = NodeCatalog::new();
392 library.register_step("ask", Box::new(AskOnce));
393
394 let llm: Arc<dyn EffectHandler> = Arc::new(CannedLlm("the inner answer"));
395 let handler = GraphHandler::new(library).with_step_runtime(vec![llm], journal());
396
397 let mut graph = Graph::new();
398 graph.add_node(Node::step("ask", "AskOnce"));
399
400 let result = handler
401 .perform(&Effect::Graph {
402 graph: Box::new(graph),
403 input: Value::text("outer input"),
404 mode: GraphEffectMode::Forward,
405 })
406 .unwrap();
407
408 match result {
409 EffectResult::Graph(value) => {
410 assert_eq!(value.as_text(), Some("the inner answer"));
411 }
412 other => panic!("expected the inner step's output, got {other:?}"),
413 }
414 }
415
416 #[test]
419 fn a_step_sub_graph_without_a_runtime_names_the_fix() {
420 let mut library = NodeCatalog::new();
421 library.register_step("ask", Box::new(AskOnce));
422
423 let mut graph = Graph::new();
424 graph.add_node(Node::step("ask", "AskOnce"));
425
426 let result = GraphHandler::new(library)
427 .perform(&Effect::Graph {
428 graph: Box::new(graph),
429 input: Value::Empty,
430 mode: GraphEffectMode::Forward,
431 })
432 .unwrap();
433
434 match result {
435 EffectResult::Failed { message } => {
436 assert!(message.contains("with_step_runtime"), "{message}");
437 }
438 other => panic!("expected a failure, got {other:?}"),
439 }
440 }
441
442 struct Recurse;
446
447 impl somatize_core::step::Step for Recurse {
448 fn config_hash(&self) -> somatize_core::cache::CacheKey {
449 somatize_core::cache::CacheKey::from_parts(&[b"Recurse"])
450 }
451 fn meta(&self) -> StepMeta {
452 StepMeta::new("Recurse")
453 }
454 fn poll(&self, ctx: &StepCtx<'_>) -> Result<Transition> {
455 if ctx.turn == 0 {
456 let mut graph = Graph::new();
457 graph.add_node(Node::step("recurse", "Recurse"));
458 return Ok(Transition::Await(vec![Effect::Graph {
459 graph: Box::new(graph),
460 input: Value::text("again"),
461 mode: GraphEffectMode::Forward,
462 }]));
463 }
464 let text = match ctx.result() {
465 Some(EffectResult::Graph(v)) => v.as_text().unwrap_or_default().to_string(),
466 Some(EffectResult::Failed { message }) => message.clone(),
467 other => format!("unexpected: {other:?}"),
468 };
469 Ok(Transition::Done(Value::text(text)))
470 }
471 }
472
473 #[test]
474 fn nesting_stops_at_the_depth_cap() {
475 let mut library = NodeCatalog::new();
476 library.register_step("recurse", Box::new(Recurse));
477
478 let handler = GraphHandler::new(library).with_step_runtime(Vec::new(), journal());
479
480 let mut graph = Graph::new();
481 graph.add_node(Node::step("recurse", "Recurse"));
482
483 let result = handler
484 .perform(&Effect::Graph {
485 graph: Box::new(graph),
486 input: Value::text("go"),
487 mode: GraphEffectMode::Forward,
488 })
489 .unwrap();
490
491 match result {
494 EffectResult::Graph(value) => {
495 let text = value.as_text().unwrap_or_default();
496 assert!(
497 text.contains(&format!("depth {MAX_GRAPH_DEPTH}")),
498 "the failure should name the cap, got: {text}"
499 );
500 }
501 other => panic!("expected the propagated cap message, got {other:?}"),
502 }
503 }
504
505 use crate::effects::NodeOutcome;
506 use std::sync::atomic::{AtomicUsize, Ordering};
507
508 struct CountingDoubler {
514 calls: Arc<AtomicUsize>,
515 }
516
517 impl Filter for CountingDoubler {
518 fn config_hash(&self) -> somatize_core::cache::CacheKey {
519 somatize_core::cache::CacheKey::from_parts(&[b"CountingDoubler"])
520 }
521 fn fit(&self, _x: &Value, _y: Option<&Value>) -> Result<Value> {
522 Ok(Value::Empty)
523 }
524 fn forward(&self, x: &Value, _state: &Value) -> Result<Value> {
525 self.calls.fetch_add(1, Ordering::SeqCst);
526 let (data, shape) = x
527 .as_tensor()
528 .ok_or(SomaError::Other("not a tensor".into()))?;
529 Ok(Value::tensor(
530 data.iter().map(|v| v * 2.0).collect(),
531 shape.to_vec(),
532 ))
533 }
534 fn meta(&self) -> FilterMeta {
535 FilterMeta {
536 name: "counting".into(),
537 kind: FilterKind::Stateless,
538 cacheable: false,
539 differentiable: false,
540 deterministic: true,
541 stream_mode: StreamMode::FixedState,
542 distribution: somatize_core::filter::Distribution::Local,
543 input_schema: None,
544 output_schema: None,
545 }
546 }
547 }
548
549 struct RunsPipeline;
551
552 impl somatize_core::step::Step for RunsPipeline {
553 fn config_hash(&self) -> somatize_core::cache::CacheKey {
554 somatize_core::cache::CacheKey::from_parts(&[b"RunsPipeline"])
555 }
556 fn meta(&self) -> StepMeta {
557 StepMeta::new("RunsPipeline")
558 }
559 fn poll(&self, ctx: &StepCtx<'_>) -> Result<Transition> {
560 if ctx.turn == 0 {
561 let mut graph = Graph::new();
562 graph.add_node(Node::filter_with_id("count", "counting"));
563 return Ok(Transition::Await(vec![Effect::Graph {
564 graph: Box::new(graph),
565 input: Value::tensor(vec![3.0], vec![1]),
566 mode: GraphEffectMode::Forward,
567 }]));
568 }
569 match ctx.result() {
570 Some(EffectResult::Graph(v)) => Ok(Transition::Done(v.clone())),
571 other => Ok(Transition::Done(Value::text(format!(
572 "unexpected: {other:?}"
573 )))),
574 }
575 }
576 }
577
578 #[test]
585 fn an_identical_pure_graph_effect_is_served_from_the_journal() {
586 let calls = Arc::new(AtomicUsize::new(0));
587 let mut library = NodeCatalog::new();
588 library.register(
589 "count",
590 Box::new(CountingDoubler {
591 calls: calls.clone(),
592 }),
593 );
594
595 let d = EffectDriver::new(journal()).with_handler(Arc::new(GraphHandler::new(library)));
596
597 let first = d
600 .run(&RunsPipeline, "run-A", "agent", &Value::Empty)
601 .unwrap();
602 let second = d
603 .run(&RunsPipeline, "run-B", "agent", &Value::Empty)
604 .unwrap();
605
606 assert_eq!(
607 calls.load(Ordering::SeqCst),
608 1,
609 "an identical pure graph effect re-ran the pipeline"
610 );
611 match (first, second) {
612 (NodeOutcome::Produced(a), NodeOutcome::Produced(b)) => {
613 assert_eq!(a.as_tensor().map(|(d, _)| d.to_vec()), Some(vec![6.0]));
614 assert_eq!(a, b, "the journal served a different answer");
615 }
616 other => panic!("expected two Done outcomes, got {other:?}"),
617 }
618 }
619
620 struct MeanFilter;
623 impl Filter for MeanFilter {
624 fn config_hash(&self) -> somatize_core::cache::CacheKey {
625 somatize_core::cache::CacheKey::from_parts(&[b"Mean"])
626 }
627 fn fit(&self, x: &Value, _y: Option<&Value>) -> Result<Value> {
628 let (data, _) = x
629 .as_tensor()
630 .ok_or(SomaError::Other("need tensor".into()))?;
631 let mean = data.iter().sum::<f64>() / data.len() as f64;
632 Ok(Value::json(serde_json::json!({ "mean": mean })))
633 }
634 fn forward(&self, x: &Value, state: &Value) -> Result<Value> {
635 let (data, shape) = x
636 .as_tensor()
637 .ok_or(SomaError::Other("need tensor".into()))?;
638 let mean = state
639 .as_json()
640 .and_then(|j| j["mean"].as_f64())
641 .unwrap_or(0.0);
642 Ok(Value::tensor(
643 data.iter().map(|v| v - mean).collect(),
644 shape.to_vec(),
645 ))
646 }
647 fn meta(&self) -> FilterMeta {
648 FilterMeta {
649 name: "mean".into(),
650 kind: FilterKind::Trainable,
651 cacheable: true,
652 differentiable: false,
653 deterministic: true,
654 stream_mode: StreamMode::FixedState,
655 distribution: somatize_core::filter::Distribution::Local,
656 input_schema: None,
657 output_schema: None,
658 }
659 }
660 }
661
662 #[test]
669 fn fit_mode_fits_and_summarizes() {
670 let mut library = NodeCatalog::new();
671 library.register("mean", Box::new(MeanFilter));
672 let handler = GraphHandler::new(library);
673
674 let mut graph = Graph::new();
675 graph.add_node(Node::filter_with_id("mean", "mean"));
676 let input = Value::tensor(vec![10.0, 20.0, 30.0], vec![3]);
677
678 let fitted = handler
679 .perform(&Effect::Graph {
680 graph: Box::new(graph.clone()),
681 input: input.clone(),
682 mode: GraphEffectMode::Fit,
683 })
684 .unwrap();
685 let EffectResult::Graph(summary) = fitted else {
686 panic!("expected a graph result, got {fitted:?}");
687 };
688 let json = summary
689 .as_json()
690 .expect("a fit answers with a JSON summary, not bulk output");
691 assert!(json.get("mean").is_some(), "no entry for the node: {json}");
692
693 let forwarded = handler
696 .perform(&Effect::Graph {
697 graph: Box::new(graph),
698 input,
699 mode: GraphEffectMode::Forward,
700 })
701 .unwrap();
702 let EffectResult::Graph(out) = forwarded else {
703 panic!("expected a graph result, got {forwarded:?}");
704 };
705 let (data, _) = out.as_tensor().expect("a tensor");
706 assert_eq!(
707 data,
708 &[-10.0, 0.0, 10.0],
709 "the forward did not see the state the fit just learned"
710 );
711 }
712
713 #[test]
714 fn the_handler_claims_only_graph_effects() {
715 let h = handler();
716 assert!(h.handles(&Effect::Graph {
717 graph: Box::new(Graph::new()),
718 input: Value::Empty,
719 mode: GraphEffectMode::Forward,
720 }));
721 assert!(!h.handles(&Effect::Sleep(std::time::Duration::from_secs(1))));
722 }
723}