Skip to main content

sim_lib_agent/
planning.rs

1use sim_kernel::{Cx, Error, Expr, Result, Symbol};
2use sim_lib_agent_runner_core::{ModelRequest, ModelResponse, ModelRunner};
3use sim_value::access::{entry_required_bool_any, entry_required_str_any, entry_required_sym_any};
4
5/// Unit of work produced by planning combinators.
6#[derive(Clone, Debug, PartialEq, Eq)]
7pub struct PlanningTask {
8    /// Stable task identifier.
9    pub id: String,
10    /// Prompt that drives the task.
11    pub prompt: String,
12}
13
14impl PlanningTask {
15    /// Builds a planning task from an id and prompt.
16    pub fn new(id: impl Into<String>, prompt: impl Into<String>) -> Self {
17        Self {
18            id: id.into(),
19            prompt: prompt.into(),
20        }
21    }
22}
23
24/// Output captured after running one planning task.
25#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct PlanningOutput {
27    /// Task that produced this output.
28    pub task: PlanningTask,
29    /// Text content captured from the run.
30    pub content: String,
31}
32
33impl PlanningOutput {
34    /// Builds an output from a task and its captured content.
35    pub fn new(task: PlanningTask, content: impl Into<String>) -> Self {
36        Self {
37            task,
38            content: content.into(),
39        }
40    }
41}
42
43/// Result of decomposing a goal and running the ordered subtasks.
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct Decomposition {
46    /// Original goal that was decomposed.
47    pub goal: PlanningTask,
48    /// Ordered subtasks produced by decomposition.
49    pub subtasks: Vec<PlanningTask>,
50    /// Output captured for each executed subtask.
51    pub outputs: Vec<PlanningOutput>,
52    /// Remaining step budget after execution.
53    pub budget_left: u32,
54}
55
56/// Critique result, including a bounded retry when the runner asks for one.
57#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct Reflection {
59    /// Whether the runner accepted the output.
60    pub accept: bool,
61    /// Critique text explaining the verdict.
62    pub critique: String,
63    /// Retry task requested by the runner, when any.
64    pub retry: Option<PlanningTask>,
65    /// Output captured from the executed retry, when one ran.
66    pub retry_output: Option<PlanningOutput>,
67    /// Remaining retry budget after reflection.
68    pub budget_left: u32,
69}
70
71/// Asks a runner to split a goal into deterministic ordered subtasks.
72pub fn decompose(
73    cx: &mut Cx,
74    goal: &PlanningTask,
75    runner: &dyn ModelRunner,
76    max_steps: u32,
77) -> Result<Vec<PlanningTask>> {
78    if max_steps == 0 {
79        return Err(Error::Eval(
80            "decompose budget exhausted before planning".to_owned(),
81        ));
82    }
83    let response = runner.infer(
84        cx,
85        ModelRequest::new(
86            operation_expr("decompose", vec![(Symbol::new("goal"), task_expr(goal))]),
87            Vec::new(),
88        ),
89    )?;
90    let subtasks = parse_tasks(&response, &goal.id)?;
91    if subtasks.is_empty() {
92        return Err(Error::Eval(
93            "decompose runner returned no subtasks".to_owned(),
94        ));
95    }
96    if subtasks.len() as u32 > max_steps {
97        return Err(Error::Eval(format!(
98            "decompose produced {} subtasks over budget {max_steps}",
99            subtasks.len()
100        )));
101    }
102    Ok(subtasks)
103}
104
105/// Decomposes a goal, then executes each returned subtask in order.
106pub fn decompose_and_run(
107    cx: &mut Cx,
108    goal: &PlanningTask,
109    runner: &dyn ModelRunner,
110    max_steps: u32,
111) -> Result<Decomposition> {
112    let subtasks = decompose(cx, goal, runner, max_steps)?;
113    let mut outputs = Vec::with_capacity(subtasks.len());
114    for task in &subtasks {
115        outputs.push(run_task(cx, task, runner)?);
116    }
117    Ok(Decomposition {
118        goal: goal.clone(),
119        budget_left: max_steps.saturating_sub(outputs.len() as u32),
120        subtasks,
121        outputs,
122    })
123}
124
125/// Critiques an output and runs at most one retry requested by the runner.
126pub fn reflect(
127    cx: &mut Cx,
128    output: &PlanningOutput,
129    runner: &dyn ModelRunner,
130    retry_budget: u32,
131) -> Result<Reflection> {
132    let response = runner.infer(
133        cx,
134        ModelRequest::new(
135            operation_expr(
136                "reflect",
137                vec![(Symbol::new("output"), output_expr(output))],
138            ),
139            Vec::new(),
140        ),
141    )?;
142    let mut reflection = parse_reflection(&response, retry_budget)?;
143    if !reflection.accept && reflection.retry.is_some() {
144        if retry_budget == 0 {
145            return Err(Error::Eval(
146                "reflect retry budget exhausted before re-run".to_owned(),
147            ));
148        }
149        let retry = reflection.retry.clone().expect("retry checked above");
150        reflection.retry_output = Some(run_task(cx, &retry, runner)?);
151        reflection.budget_left = retry_budget - 1;
152    }
153    Ok(reflection)
154}
155
156fn run_task(cx: &mut Cx, task: &PlanningTask, runner: &dyn ModelRunner) -> Result<PlanningOutput> {
157    let response = runner.infer(
158        cx,
159        ModelRequest::new(
160            operation_expr("execute", vec![(Symbol::new("task"), task_expr(task))]),
161            Vec::new(),
162        ),
163    )?;
164    Ok(PlanningOutput::new(task.clone(), response_text(&response)))
165}
166
167fn parse_tasks(response: &ModelResponse, goal_id: &str) -> Result<Vec<PlanningTask>> {
168    let payloads = response_payloads(response);
169    let mut tasks = Vec::new();
170    for (index, payload) in payloads.iter().enumerate() {
171        match payload {
172            Expr::List(items) | Expr::Vector(items) => {
173                for (item_index, item) in items.iter().enumerate() {
174                    tasks.push(parse_task(item, &step_id(goal_id, item_index + 1))?);
175                }
176            }
177            Expr::String(text) => {
178                tasks.extend(parse_text_tasks(goal_id, text));
179            }
180            Expr::Map(entries) => {
181                if let Some(text) = text_part(entries) {
182                    tasks.extend(parse_text_tasks(goal_id, &text));
183                } else {
184                    tasks.push(parse_task(payload, &step_id(goal_id, index + 1))?);
185                }
186            }
187            expr => tasks.push(parse_task(expr, &step_id(goal_id, index + 1))?),
188        }
189    }
190    Ok(tasks)
191}
192
193fn parse_reflection(response: &ModelResponse, retry_budget: u32) -> Result<Reflection> {
194    let payloads = response_payloads(response);
195    if let Some(Expr::Map(entries)) = payloads.first() {
196        let accept = bool_field(entries, "accept").unwrap_or(false);
197        let critique = string_field(entries, "critique")
198            .or_else(|| string_field(entries, "reason"))
199            .unwrap_or_else(|| response_text(response));
200        let retry = expr_field(entries, "retry")
201            .or_else(|| expr_field(entries, "retry-task"))
202            .filter(|expr| !matches!(expr, Expr::Nil))
203            .map(|expr| parse_task(expr, "retry-1"))
204            .transpose()?;
205        return Ok(Reflection {
206            accept,
207            critique,
208            retry,
209            retry_output: None,
210            budget_left: retry_budget,
211        });
212    }
213
214    let text = response_text(response);
215    let lower = text.to_ascii_lowercase();
216    let accept = lower.starts_with("accept") || lower.contains("accept: true");
217    let retry = retry_text(&text).map(|prompt| PlanningTask::new("retry-1", prompt));
218    Ok(Reflection {
219        accept,
220        critique: text,
221        retry,
222        retry_output: None,
223        budget_left: retry_budget,
224    })
225}
226
227fn parse_task(expr: &Expr, default_id: &str) -> Result<PlanningTask> {
228    match expr {
229        Expr::Map(entries) => {
230            let id = string_field(entries, "id").unwrap_or_else(|| default_id.to_owned());
231            let prompt = string_field(entries, "prompt")
232                .or_else(|| string_field(entries, "instruction"))
233                .or_else(|| string_field(entries, "task"))
234                .ok_or_else(|| {
235                    Error::Eval(format!(
236                        "planning task {id} requires prompt, instruction, or task"
237                    ))
238                })?;
239            Ok(PlanningTask::new(id, prompt))
240        }
241        Expr::String(text) => Ok(PlanningTask::new(default_id.to_owned(), text.clone())),
242        expr => Ok(PlanningTask::new(default_id.to_owned(), expr_text(expr))),
243    }
244}
245
246fn parse_text_tasks(goal_id: &str, text: &str) -> Vec<PlanningTask> {
247    text.lines()
248        .map(str::trim)
249        .filter(|line| !line.is_empty())
250        .enumerate()
251        .map(|(index, line)| {
252            PlanningTask::new(step_id(goal_id, index + 1), clean_list_marker(line))
253        })
254        .collect()
255}
256
257fn operation_expr(name: &str, fields: Vec<(Symbol, Expr)>) -> Expr {
258    let mut entries = vec![(
259        Expr::Symbol(Symbol::new("op")),
260        Expr::Symbol(Symbol::new(name)),
261    )];
262    entries.extend(
263        fields
264            .into_iter()
265            .map(|(key, value)| (Expr::Symbol(key), value)),
266    );
267    Expr::Map(entries)
268}
269
270fn task_expr(task: &PlanningTask) -> Expr {
271    Expr::Map(vec![
272        (
273            Expr::Symbol(Symbol::new("id")),
274            Expr::String(task.id.clone()),
275        ),
276        (
277            Expr::Symbol(Symbol::new("prompt")),
278            Expr::String(task.prompt.clone()),
279        ),
280    ])
281}
282
283fn output_expr(output: &PlanningOutput) -> Expr {
284    Expr::Map(vec![
285        (Expr::Symbol(Symbol::new("task")), task_expr(&output.task)),
286        (
287            Expr::Symbol(Symbol::new("content")),
288            Expr::String(output.content.clone()),
289        ),
290    ])
291}
292
293fn response_payloads(response: &ModelResponse) -> Vec<Expr> {
294    if response.content.len() == 1
295        && let Expr::Map(entries) = &response.content[0]
296        && let Some(text) = text_part(entries)
297    {
298        return vec![Expr::String(text)];
299    }
300    response.content.clone()
301}
302
303fn response_text(response: &ModelResponse) -> String {
304    response
305        .content
306        .iter()
307        .map(expr_text)
308        .collect::<Vec<_>>()
309        .join("\n")
310}
311
312fn expr_text(expr: &Expr) -> String {
313    match expr {
314        Expr::Nil => "nil".to_owned(),
315        Expr::Bool(value) => value.to_string(),
316        Expr::Symbol(symbol) => symbol.to_string(),
317        Expr::String(text) => text.clone(),
318        Expr::Map(entries) => text_part(entries).unwrap_or_else(|| format!("{expr:?}")),
319        _ => format!("{expr:?}"),
320    }
321}
322
323fn text_part(entries: &[(Expr, Expr)]) -> Option<String> {
324    match string_field(entries, "text") {
325        Some(text) if matches!(symbol_field(entries, "type").as_deref(), Some("text")) => {
326            Some(text)
327        }
328        _ => None,
329    }
330}
331
332// These readers match a field name namespace-agnostically via the shared
333// `sim_value::access` `_any` substrate (bare-symbol OR string key). `string_field`
334// keeps the original String|Symbol value coercion behind thin Option adapters, so
335// the call sites above are unchanged (OVERLAP9.05).
336fn string_field(entries: &[(Expr, Expr)], field: &str) -> Option<String> {
337    entry_required_str_any(entries, field, "string")
338        .ok()
339        .map(str::to_owned)
340        .or_else(|| {
341            entry_required_sym_any(entries, field, "symbol")
342                .ok()
343                .map(Symbol::to_string)
344        })
345}
346
347fn symbol_field(entries: &[(Expr, Expr)], field: &str) -> Option<String> {
348    entry_required_sym_any(entries, field, "symbol")
349        .ok()
350        .map(Symbol::to_string)
351}
352
353fn bool_field(entries: &[(Expr, Expr)], field: &str) -> Option<bool> {
354    entry_required_bool_any(entries, field, "bool").ok()
355}
356
357// Raw any-namespace field borrow used by `parse_reflection` for the untyped
358// `retry`/`retry-task` payload (which is matched then parsed as a task, not
359// coerced to a scalar).
360fn expr_field<'a>(entries: &'a [(Expr, Expr)], field: &str) -> Option<&'a Expr> {
361    entries.iter().find_map(|(key, value)| {
362        if matches!(key, Expr::Symbol(symbol) if symbol.name.as_ref() == field) {
363            Some(value)
364        } else {
365            None
366        }
367    })
368}
369
370fn retry_text(text: &str) -> Option<String> {
371    let lower = text.to_ascii_lowercase();
372    lower.find("retry:").map(|index| {
373        text[index + "retry:".len()..]
374            .trim()
375            .trim_matches('"')
376            .to_owned()
377    })
378}
379
380fn clean_list_marker(line: &str) -> String {
381    let trimmed = line.trim_start_matches(['-', '*', ' ']);
382    let without_number = trimmed
383        .split_once('.')
384        .and_then(|(head, tail)| head.chars().all(|ch| ch.is_ascii_digit()).then_some(tail))
385        .unwrap_or(trimmed);
386    without_number.trim().to_owned()
387}
388
389fn step_id(goal_id: &str, one_based_index: usize) -> String {
390    format!("{goal_id}.{one_based_index}")
391}
392
393#[cfg(test)]
394mod field_reader_tests {
395    use super::{bool_field, string_field, symbol_field};
396    use sim_kernel::{Expr, Symbol};
397
398    fn entry(key: Expr, value: Expr) -> (Expr, Expr) {
399        (key, value)
400    }
401
402    // The readers deliberately match a field name namespace-agnostically via the
403    // shared `entry_required_*_any` substrate: a bare-symbol key OR a string key
404    // resolves. This pins that intended flexible matching before/through the
405    // OVERLAP9.05 migration.
406    #[test]
407    fn readers_match_bare_symbol_and_string_keys() {
408        let bare = [entry(
409            Expr::Symbol(Symbol::new("text")),
410            Expr::String("hi".to_owned()),
411        )];
412        assert_eq!(string_field(&bare, "text"), Some("hi".to_owned()));
413
414        let string_key = [entry(
415            Expr::String("type".to_owned()),
416            Expr::Symbol(Symbol::new("text")),
417        )];
418        assert_eq!(symbol_field(&string_key, "type"), Some("text".to_owned()));
419    }
420
421    // `string_field` coerces both String and Symbol values to an owned String;
422    // `symbol_field` reads only symbols; `bool_field` reads only bools.
423    #[test]
424    fn readers_preserve_value_coercion() {
425        let entries = [
426            entry(
427                Expr::Symbol(Symbol::new("text")),
428                Expr::Symbol(Symbol::new("as-symbol")),
429            ),
430            entry(
431                Expr::Symbol(Symbol::new("type")),
432                Expr::Symbol(Symbol::new("text")),
433            ),
434            entry(Expr::Symbol(Symbol::new("flag")), Expr::Bool(true)),
435        ];
436
437        assert_eq!(string_field(&entries, "text"), Some("as-symbol".to_owned()));
438        assert_eq!(symbol_field(&entries, "type"), Some("text".to_owned()));
439        assert_eq!(bool_field(&entries, "flag"), Some(true));
440
441        // Wrong-typed or absent fields read as None.
442        assert_eq!(symbol_field(&entries, "flag"), None);
443        assert_eq!(bool_field(&entries, "missing"), None);
444    }
445}