Skip to main content

somatize_agent/
research.rs

1//! The research loop, as a [`Step`].
2//!
3//! An autonomous researcher is not a special kind of runtime — it is a node
4//! that thinks between experiments. Writing it as a `Step` means it gets the
5//! same journal, cache, events and replay every other node gets, and it
6//! means an agent can be *part of* a pipeline rather than something that
7//! wraps one.
8//!
9//! One turn of the loop:
10//!
11//! 1. Ask the model what to try next, given the objective and what the pool
12//!    already knows.
13//! 2. Read an [`Action`] out of the reply — a constrained schema, not prose.
14//! 3. `RunExperiment` → [`Effect::Graph`], which runs the pipeline with the
15//!    proposed parameters and hands back its metrics.
16//! 4. `Conclude` → done.
17//!
18//! The previous version of this file generated experiments by cycling a
19//! list of values through a rule-based planner, with a comment saying an LLM
20//! would go here. This is that.
21
22use crate::action::Action;
23use somatize_core::effect::{Effect, EffectResult, GraphEffectMode, LlmRequest};
24use somatize_core::error::{Result, SomaError};
25use somatize_core::graph::Graph;
26use somatize_core::message::{Message, Messages};
27use somatize_core::step::{Step, StepCtx, StepMeta, Transition};
28use somatize_core::util::{extract_json, truncate};
29use somatize_core::value::Value;
30use somatize_memory::ExperimentRecord;
31
32/// How much of the pool to put in front of the model each turn.
33const HISTORY_LIMIT: usize = 20;
34
35/// An agent that proposes experiments, runs them, and decides when to stop.
36#[derive(serde::Serialize, somatize_core::SomaStep)]
37#[soma(cache_version = "soma-research-step-v1")]
38pub struct ResearchStep {
39    model: String,
40    objective: String,
41    /// The pipeline being investigated. Its parameters are what the agent
42    /// proposes values for.
43    pipeline: Graph,
44    max_iterations: usize,
45    /// What was already known when the loop started. Everything the loop
46    /// itself learns is reconstructed from `ctx.history` instead of kept
47    /// here — see [`ResearchStep::completed`].
48    seed: Vec<ExperimentRecord>,
49}
50
51impl ResearchStep {
52    /// A researcher that pursues `objective` by experimenting on
53    /// `pipeline`, asking `model` what to try next. Starts with an empty
54    /// seed and 20 iterations; adjust with
55    /// [`with_history`](Self::with_history) and
56    /// [`with_max_iterations`](Self::with_max_iterations).
57    pub fn new(model: impl Into<String>, objective: impl Into<String>, pipeline: Graph) -> Self {
58        Self {
59            model: model.into(),
60            objective: objective.into(),
61            pipeline,
62            max_iterations: 20,
63            seed: Vec::new(),
64        }
65    }
66
67    /// Seed the loop with what is already known. An agent that starts from
68    /// an empty pool repeats work someone already did.
69    pub fn with_history(mut self, records: Vec<ExperimentRecord>) -> Self {
70        self.seed = records;
71        self
72    }
73
74    /// Cap the loop at `n` experiments. The cap is the budget, not the
75    /// goal — the agent may `Conclude` well before reaching it.
76    pub fn with_max_iterations(mut self, n: usize) -> Self {
77        self.max_iterations = n;
78        self
79    }
80
81    /// Every experiment behind this run, seed first, newest last.
82    ///
83    /// Rebuilt from the turns rather than accumulated in a field. A step is
84    /// a description of behaviour, not a place to keep state: derive the
85    /// history and a replay reconstructs exactly the history the original
86    /// run had, because it replays exactly the same results.
87    pub fn completed(&self, ctx: &StepCtx<'_>) -> Vec<ExperimentRecord> {
88        let mut records = self.seed.clone();
89        let mut proposed: Option<Action> = None;
90
91        for turn in ctx.history {
92            match turn.first() {
93                // A reply: whatever it proposed is what the next result
94                // belongs to.
95                Some(EffectResult::Llm(response)) => {
96                    proposed = self.parse_action(&response.message.text()).ok();
97                }
98                // An experiment came back. Pair it with what asked for it.
99                Some(outcome) => {
100                    if let Some(action) = proposed.take()
101                        && let Some(record) = self.record(&action, outcome)
102                    {
103                        records.push(record);
104                    }
105                }
106                None => {}
107            }
108        }
109        records
110    }
111
112    /// Ask the model what to do next.
113    fn ask(&self, ctx: &StepCtx<'_>) -> Effect {
114        LlmRequest::new(
115            &self.model,
116            Messages::from(vec![Message::user(
117                self.history_prompt(&self.completed(ctx)),
118            )]),
119        )
120        .with_system(self.system())
121        // The shape is declared once, as a schema. It used to be written
122        // out a second time as prose inside `system()` while
123        // `Action::response_schema` sat unused — two descriptions of one
124        // contract, and only the prose could drift. Declaring it here
125        // also lets an endpoint that supports constrained decoding
126        // *enforce* it rather than be asked nicely.
127        .with_schema(crate::action::Action::response_schema())
128        .into_effect()
129    }
130
131    fn system(&self) -> String {
132        format!(
133            "You are running an experimental campaign on a Soma pipeline.\n\n\
134             Objective: {}\n\n\
135             Each turn, propose ONE experiment or conclude; the reply must \
136             match the declared schema. Use `params` keys of the form \
137             `<node>.<param>`.\n\n\
138             Every experiment needs a falsifiable hypothesis — a result \
139             nobody can interpret later is a result nobody will read. Vary \
140             one thing at a time so the comparison means something, and \
141             conclude when the objective is met or the line has stopped \
142             paying.\n\n\
143             Pipeline nodes: {}",
144            self.objective,
145            self.pipeline.node_ids().join(", ")
146        )
147    }
148
149    /// What is known so far, as a compact table.
150    fn history_prompt(&self, history: &[ExperimentRecord]) -> String {
151        if history.is_empty() {
152            return "No experiments yet. Propose the first one.".into();
153        }
154
155        let mut lines = vec![format!("{} experiments so far:", history.len())];
156        for record in history.iter().rev().take(HISTORY_LIMIT).rev() {
157            let mut metrics: Vec<String> = record
158                .metrics
159                .iter()
160                .map(|(k, v)| format!("{k}={v:.4}"))
161                .collect();
162            metrics.sort();
163            lines.push(format!(
164                "- {} [{}] {} → {}",
165                record.name,
166                record.research_line.as_deref().unwrap_or("unfiled"),
167                serde_json::to_string(&record.params).unwrap_or_default(),
168                if metrics.is_empty() {
169                    "no metrics".to_string()
170                } else {
171                    metrics.join(" ")
172                }
173            ));
174        }
175        lines.push("\nWhat next?".into());
176        lines.join("\n")
177    }
178
179    /// What the loop produced: the conclusion, and every experiment behind
180    /// it. The records are the point — a conclusion nobody can trace back
181    /// to the runs that support it is an opinion.
182    fn report(&self, reason: &str, done: &[ExperimentRecord]) -> Value {
183        Value::json(serde_json::json!({
184            "concluded": reason,
185            "objective": self.objective,
186            "experiments": done.len(),
187            "records": done,
188        }))
189    }
190
191    /// Read an action out of a reply, tolerating the ways models wrap JSON.
192    fn parse_action(&self, text: &str) -> Result<Action> {
193        let json = extract_json(text).ok_or_else(|| {
194            SomaError::Other(format!(
195                "the model replied with no JSON object, so there is no action \
196                 to take: {}",
197                truncate(text, 200)
198            ))
199        })?;
200
201        serde_json::from_value(json).map_err(|e| {
202            SomaError::Other(format!(
203                "the model's reply is not an action ({e}): {}",
204                truncate(text, 200)
205            ))
206        })
207    }
208
209    /// The graph to run for an experiment.
210    ///
211    /// Parameters travel as the effect's *input*, not baked into the graph:
212    /// the pipeline is the thing being studied and must stay identical
213    /// across experiments, or the comparison is between two different
214    /// pipelines and means nothing.
215    fn experiment_effect(&self, params: &serde_json::Map<String, serde_json::Value>) -> Effect {
216        Effect::Graph {
217            graph: Box::new(self.pipeline.clone()),
218            input: Value::json(serde_json::Value::Object(params.clone())),
219            mode: GraphEffectMode::Fit,
220        }
221    }
222
223    fn record(&self, action: &Action, result: &EffectResult) -> Option<ExperimentRecord> {
224        let Action::RunExperiment {
225            name,
226            research_line,
227            hypothesis,
228            params,
229            parent,
230        } = action
231        else {
232            return None;
233        };
234
235        let mut record = ExperimentRecord::new(name.clone(), name.clone());
236        record.hypothesis = Some(hypothesis.clone());
237        record.research_line = Some(research_line.clone());
238        record.parent = parent.clone();
239        record.params = params.clone();
240        record.tags = vec!["agent".into()];
241        record.pipeline_summary = self.pipeline.node_ids().join(" → ");
242
243        match result {
244            EffectResult::Graph(value) => {
245                record.metrics = read_metrics(value);
246            }
247            // A failed experiment is a finding. Recording it is what stops
248            // the agent proposing the same broken configuration next turn.
249            EffectResult::Failed { message } => {
250                record.notes = Some(format!("failed: {message}"));
251            }
252            other => {
253                record.notes = Some(format!("unexpected result: {other:?}"));
254            }
255        }
256        Some(record)
257    }
258}
259
260impl Step for ResearchStep {
261    /// Derived: the pipeline under investigation is part of the key too,
262    /// which the hand-written version left out — two research loops over
263    /// different graphs shared a journal.
264    fn config_hash(&self) -> somatize_core::cache::CacheKey {
265        ResearchStep::config_hash(self)
266    }
267
268    fn meta(&self) -> StepMeta {
269        StepMeta::new("research")
270            .with_max_turns(self.max_iterations * 2 + 2)
271            .with_output_schema(somatize_core::schema::Schema::json())
272    }
273
274    fn poll(&self, ctx: &StepCtx<'_>) -> Result<Transition> {
275        // Turn 0, and every turn after an experiment: ask what to do next.
276        let last = ctx.results.first();
277
278        match last {
279            None => Ok(Transition::Await(vec![self.ask(ctx)])),
280
281            // The model answered. Either run what it proposed, or stop.
282            Some(EffectResult::Llm(response)) => {
283                // A cut-off proposal is not a proposal. Parsing it fails
284                // with "no JSON action found", which reads like the model
285                // misbehaving when it actually ran out of tokens — and the
286                // fix for each is different.
287                response.reject_non_answers(ctx.node_id)?;
288                let action = self.parse_action(&response.message.text())?;
289                let done = self.completed(ctx);
290                match &action {
291                    Action::Conclude { reason } => Ok(Transition::Done(self.report(reason, &done))),
292                    Action::RunExperiment { params, .. } => {
293                        if done.len() >= self.max_iterations {
294                            return Ok(Transition::Done(
295                                self.report("iteration budget exhausted", &done),
296                            ));
297                        }
298                        Ok(Transition::Await(vec![
299                            self.experiment_effect(&to_object(params)),
300                        ]))
301                    }
302                }
303            }
304
305            // An experiment finished. `completed` folds it into the
306            // history the next question is asked against.
307            Some(_) => Ok(Transition::Await(vec![self.ask(ctx)])),
308        }
309    }
310}
311
312fn to_object(
313    params: &std::collections::BTreeMap<String, serde_json::Value>,
314) -> serde_json::Map<String, serde_json::Value> {
315    params.iter().map(|(k, v)| (k.clone(), v.clone())).collect()
316}
317
318/// Metrics out of whatever the pipeline produced.
319///
320/// Every number in the result is a metric, named by where it sits:
321/// `{"classifier": {"f1": 0.9}}` gives `classifier.f1`. Qualifying by node
322/// is what keeps two nodes reporting `loss` from being the same series when
323/// they are compared across experiments later.
324fn read_metrics(value: &Value) -> std::collections::BTreeMap<String, f64> {
325    let mut metrics = std::collections::BTreeMap::new();
326    collect_numbers(&value.to_plain_json(), "", &mut metrics);
327    metrics
328}
329
330fn collect_numbers(
331    json: &serde_json::Value,
332    path: &str,
333    out: &mut std::collections::BTreeMap<String, f64>,
334) {
335    match json {
336        serde_json::Value::Number(n) => {
337            if let Some(f) = n.as_f64() {
338                let name = if path.is_empty() { "value" } else { path };
339                out.insert(name.to_string(), f);
340            }
341        }
342        serde_json::Value::Object(map) => {
343            for (key, val) in map {
344                let child = if path.is_empty() {
345                    key.clone()
346                } else {
347                    format!("{path}.{key}")
348                };
349                collect_numbers(val, &child, out);
350            }
351        }
352        // An array is data, not a metric. A learned weight vector is not a
353        // number anyone wants to compare experiments on.
354        _ => {}
355    }
356}
357
358#[cfg(test)]
359mod tests {
360    use super::*;
361    use serde_json::json;
362
363    fn step() -> ResearchStep {
364        let mut graph = Graph::new();
365        graph.add_node(somatize_core::graph::Node::filter_with_id(
366            "classifier",
367            "svm",
368        ));
369        ResearchStep::new("mock/model", "beat 0.8 F1", graph)
370    }
371
372    #[test]
373    fn the_history_prompt_starts_empty() {
374        assert!(step().history_prompt(&[]).contains("No experiments yet"));
375    }
376
377    #[test]
378    fn the_history_prompt_lists_what_ran() {
379        let mut record = ExperimentRecord::new("exp_1", "exp_1");
380        record.research_line = Some("regularization".into());
381        record.params = [("classifier.C".to_string(), json!(0.1))]
382            .into_iter()
383            .collect();
384        record.metrics = [("f1".to_string(), 0.72)].into_iter().collect();
385
386        let prompt = step().history_prompt(&[record]);
387        assert!(prompt.contains("exp_1"), "{prompt}");
388        assert!(prompt.contains("regularization"), "{prompt}");
389        assert!(prompt.contains("f1=0.7200"), "{prompt}");
390    }
391
392    #[test]
393    fn an_action_is_read_out_of_a_fenced_reply() {
394        let action = step()
395            .parse_action(
396                "Here is my plan:\n```json\n{\"action\": \"conclude\", \
397                 \"reason\": \"plateaued\"}\n```",
398            )
399            .unwrap();
400        assert!(action.is_terminal());
401    }
402
403    /// Prose is not an action. Guessing one would run an experiment nobody
404    /// asked for, on a budget somebody is paying.
405    #[test]
406    fn prose_is_not_an_action() {
407        let err = step().parse_action("I think we should try more C values.");
408        assert!(err.is_err());
409    }
410
411    #[test]
412    fn a_reply_that_is_not_an_action_is_refused() {
413        let err = step().parse_action("{\"thoughts\": \"hmm\"}");
414        assert!(err.is_err());
415    }
416
417    #[test]
418    fn metrics_are_read_from_a_mapping() {
419        let value = Value::json(json!({"f1": 0.9, "notes": "fine", "loss": 0.1}));
420        let metrics = read_metrics(&value);
421        assert_eq!(metrics.len(), 2);
422        assert_eq!(metrics["f1"], 0.9);
423    }
424
425    /// A metric is named by where it sits, so two nodes both reporting
426    /// `loss` stay two series.
427    #[test]
428    fn nested_metrics_are_qualified_by_node() {
429        let value = Value::json(json!({
430            "encoder": {"loss": 0.3},
431            "classifier": {"loss": 0.1, "f1": 0.9, "weights": [1.0, 2.0]}
432        }));
433        let metrics = read_metrics(&value);
434        assert_eq!(metrics["encoder.loss"], 0.3);
435        assert_eq!(metrics["classifier.loss"], 0.1);
436        assert_eq!(metrics["classifier.f1"], 0.9);
437        assert_eq!(metrics.len(), 3, "an array is data, not a metric");
438    }
439
440    #[test]
441    fn a_failed_experiment_is_still_recorded() {
442        let action: Action = serde_json::from_value(json!({
443            "action": "run_experiment",
444            "name": "exp_bad",
445            "research_line": "l",
446            "hypothesis": "h",
447            "params": {}
448        }))
449        .unwrap();
450
451        let record = step()
452            .record(
453                &action,
454                &EffectResult::Failed {
455                    message: "no such filter".into(),
456                },
457            )
458            .unwrap();
459
460        assert!(record.metrics.is_empty());
461        assert!(record.notes.unwrap().contains("no such filter"));
462    }
463}