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#[derive(Clone, Debug, PartialEq, Eq)]
7pub struct PlanningTask {
8 pub id: String,
10 pub prompt: String,
12}
13
14impl PlanningTask {
15 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#[derive(Clone, Debug, PartialEq, Eq)]
26pub struct PlanningOutput {
27 pub task: PlanningTask,
29 pub content: String,
31}
32
33impl PlanningOutput {
34 pub fn new(task: PlanningTask, content: impl Into<String>) -> Self {
36 Self {
37 task,
38 content: content.into(),
39 }
40 }
41}
42
43#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct Decomposition {
46 pub goal: PlanningTask,
48 pub subtasks: Vec<PlanningTask>,
50 pub outputs: Vec<PlanningOutput>,
52 pub budget_left: u32,
54}
55
56#[derive(Clone, Debug, PartialEq, Eq)]
58pub struct Reflection {
59 pub accept: bool,
61 pub critique: String,
63 pub retry: Option<PlanningTask>,
65 pub retry_output: Option<PlanningOutput>,
67 pub budget_left: u32,
69}
70
71pub 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
105pub 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
125pub 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
332fn 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
357fn 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 #[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 #[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 assert_eq!(symbol_field(&entries, "flag"), None);
443 assert_eq!(bool_field(&entries, "missing"), None);
444 }
445}