1use crate::error::{BudgetAxis, RunError};
48use crate::plan::PlanGraph;
49use crate::state::{
50 DecideInFlight, EdgeRes, FoldInFlight, NodeState, PendingAsk, Phase, SchedulerState,
51};
52use crate::types::{
53 Ask, Budgets, Command, DecisionRecord, EdgeOutcome, EffectKind, EffectOutcome, EventIn,
54 FailCause, FoldRecord, JournalKey, LlmNodeConfig, NodeExecutor, OfferedTool, RunOutcome,
55 DECIDE_TOOL,
56};
57use areev_core::format::tool_schema::normalize_tool_name;
58use serde_json::Value;
59use std::collections::BTreeMap;
60
61pub const DEFAULT_MAX_EFFECTS_PER_ATTEMPT: u32 = 16;
66
67pub const KEEP_TAIL: usize = 4;
77
78pub const FOLD_PROMPT: &str = "Summarize the working state of this task so far \
85for someone who will continue it: what has been established, which tools were \
86called and what they returned in substance, what remains to be done, and any \
87errors or dead ends. Be concrete; keep identifiers, paths and values verbatim. \
88Reply with the summary only.";
89
90pub const FOLD_PROMPT_V: u32 = 1;
93
94pub const FOLD_DECIDE_V: u32 = 1;
100
101pub const KEEP_CALL_QUESTION: &str = "Does the fact that this tool call happened still \
103matter for finishing the task?";
104
105pub const KEEP_RESULT_QUESTION: &str = "Are this tool result's contents still needed \
107verbatim to finish the task?";
108
109pub const OFFER_QUESTION: &str = "Which one of these tools is this step most likely to \
111need to call in order to finish its task?";
112
113pub const FOLD_TRUNCATE_CHARS: usize = 300;
116
117pub const OFFER_TOP_K: usize = 8;
119
120pub const OFFER_KEEP_P: f64 = 0.05;
123
124pub struct DecideEnv<'a> {
129 pub calibrated: bool,
134 pub plan_label: Option<&'a str>,
137 pub tool_descriptions: &'a BTreeMap<String, String>,
141}
142
143pub struct StepEnv<'a> {
146 pub plan: &'a PlanGraph,
147 pub executors: &'a [NodeExecutor],
149 pub budgets: Budgets,
150 pub reduce: &'a dyn Fn(&str, Option<&Value>, &Value) -> Value,
154 pub eval_cond: &'a dyn Fn(&crate::plan::PlanEdge, &Value) -> bool,
158 pub ask_ttl_sec: Option<i64>,
160 pub validate_args: &'a dyn Fn(&str, &Value) -> std::result::Result<(), String>,
165 pub llm_reserve_tokens: u64,
170 pub max_effects_per_attempt: u32,
176 pub llm_tool_result_chars: Option<usize>,
181 pub llm_context_tokens: Option<u64>,
187 pub decide: Option<DecideEnv<'a>>,
191}
192
193pub struct StepOutcome {
195 pub commands: Vec<Command>,
196 pub state: SchedulerState,
197}
198
199pub fn step(env: &StepEnv<'_>, mut st: SchedulerState, events: &[EventIn]) -> StepOutcome {
202 let mut out: Vec<Command> = Vec::new();
203 for ev in events {
204 apply_event(env, &mut st, ev, &mut out);
205 }
206 let hold = events.iter().any(|e| matches!(e, EventIn::PauseRequested));
209 progress(env, &mut st, &mut out, hold);
210 StepOutcome { commands: out, state: st }
211}
212
213fn apply_event(
214 env: &StepEnv<'_>,
215 st: &mut SchedulerState,
216 ev: &EventIn,
217 out: &mut Vec<Command>,
218) {
219 match ev {
220 EventIn::ClockReading { unix_ms } => {
221 st.clock_ms = (*unix_ms).max(st.clock_ms);
222 }
223 EventIn::Start { input } => {
224 if st.superstep == 0 && matches!(st.phase, Phase::Idle) {
225 st.context = match input {
229 Value::Object(_) => input.clone(),
230 other => serde_json::json!({ "input": other }),
231 };
232 if st.node_state[0] == NodeState::Waiting {
236 st.node_state[0] = NodeState::Ready;
237 }
238 }
239 }
240 EventIn::CancelSeen { principal, reason } => {
241 if st.cancel.is_none() && !st.is_terminal() {
242 st.cancel = Some((principal.clone(), reason.clone()));
243 }
244 }
245 EventIn::EffectResolved { key, outcome } => {
246 resolve_effect(env, st, key, outcome);
247 }
248 EventIn::Resumed => {
249 if matches!(st.phase, Phase::Idle) && st.paused_at.is_none() {
255 st.paused_at = Some(st.clock_ms);
256 }
257 }
258 EventIn::InputSeen { message } => {
259 st.inputs_seen += 1;
260 st.inbox.push(message.clone());
261 }
262 EventIn::PauseRequested => {}
264 EventIn::AskForwarded { tool_call_id } => {
265 let Some(pending) = st.pending_asks.get(tool_call_id).cloned() else {
266 return;
267 };
268 drop_bubble(st, &pending.key);
269 st.node_state[pending.node_idx] = NodeState::Dispatched;
270 let executor = env.executors[pending.node_idx].clone();
271 let input = st.context.clone();
272 let (superstep, clock_ms) = (st.superstep, st.clock_ms);
273 out.push(Command::WriteIntent {
274 key: pending.key.clone(),
275 executor: executor.clone(),
276 input: input.clone(),
277 superstep,
278 clock_ms,
279 });
280 out.push(Command::Dispatch { key: pending.key, executor, input });
281 }
282 EventIn::ResponseSettled { tool_call_id, outcome } => {
283 let Some(pending) = st.pending_asks.remove(tool_call_id) else {
284 return;
287 };
288 st.announced_asks.remove(tool_call_id);
297 st.node_state[pending.node_idx] = NodeState::Dispatched;
298 resolve_effect(env, st, &pending.key, outcome);
299 }
300 }
301}
302
303fn resolve_effect(
308 env: &StepEnv<'_>,
309 st: &mut SchedulerState,
310 key: &JournalKey,
311 outcome: &EffectOutcome,
312) {
313 let Some(node_idx) = env.plan.nodes.iter().position(|n| *n == key.node) else {
314 return;
315 };
316 {
317 let Phase::Open { outstanding, .. } = &mut st.phase else {
318 return; };
320 if !outstanding.remove(key) {
321 return; }
323 }
324 match outcome {
327 EffectOutcome::Completed {
328 journal_bytes,
329 input_tokens,
330 output_tokens,
331 usd_micros,
332 ..
333 } => {
334 st.spent.journal_grains += 2; st.spent.storage_bytes += journal_bytes;
336 st.spent.input_tokens += input_tokens;
337 st.spent.output_tokens += output_tokens;
338 st.spent.usd_micros += usd_micros;
339 }
340 EffectOutcome::Failed { journal_bytes, .. } => {
341 st.spent.journal_grains += 2;
342 st.spent.storage_bytes += journal_bytes;
343 }
344 }
345
346 let flow = flow_key(node_idx, &key.task_path);
349 if st.abstract_flows.contains_key(&flow) {
350 match key.kind {
351 EffectKind::Llm => {
352 handle_llm_outcome(env, st, node_idx, &key.task_path, outcome);
353 return;
354 }
355 EffectKind::Tool
356 if st.abstract_flows[&flow]
357 .deciding
358 .as_ref()
359 .is_some_and(|d| d.effect_seq() == key.effect_seq) =>
360 {
361 handle_flow_decide(env, st, node_idx, &key.task_path, outcome);
362 return;
363 }
364 EffectKind::Tool
365 if st.abstract_flows[&flow].pending_tools.contains_key(&key.effect_seq) =>
366 {
367 handle_flow_tool_outcome(
368 env,
369 st,
370 node_idx,
371 &key.task_path,
372 key.effect_seq,
373 outcome,
374 );
375 return;
376 }
377 _ => {}
378 }
379 }
380
381 if !key.task_path.is_empty() {
384 handle_send_task_outcome(env, st, key, outcome);
385 return;
386 }
387 if matches!(
391 st.node_state[node_idx],
392 NodeState::DoneOk | NodeState::DoneFailed | NodeState::Dead
393 ) {
394 return;
395 }
396
397 if let EffectOutcome::Completed { result, .. } = outcome {
398 if matches!(env.executors[node_idx], NodeExecutor::Subgraph { .. }) {
399 if let Some(asks) = result
400 .get(crate::types::PARKED_ASKS)
401 .and_then(|v| serde_json::from_value::<Vec<Ask>>(v.clone()).ok())
402 .filter(|a| !a.is_empty())
403 {
404 park_bubbled(st, node_idx, key, asks);
405 return;
406 }
407 }
408 }
409
410 let attempts_used = st.attempt[node_idx] - st.attempt_base[node_idx];
411 let retry_budget = env.plan.retries[node_idx];
412 let canceling = st.cancel.is_some();
413 match outcome {
414 EffectOutcome::Completed { result, .. } => {
415 st.node_state[node_idx] = NodeState::DoneOk;
416 if let Phase::Open { results, .. } = &mut st.phase {
417 results.insert(node_idx, result.clone());
418 }
419 }
420 EffectOutcome::Failed { cause, detail, .. } => {
421 let can_retry =
422 cause.retryable(key.kind) && attempts_used <= retry_budget && !canceling;
423 if can_retry {
424 st.node_state[node_idx] = NodeState::Ready;
427 } else {
428 st.node_state[node_idx] = NodeState::DoneFailed;
429 let named = st.failed.as_ref().map(|(i, _)| *i).unwrap_or(usize::MAX);
430 if node_idx < named {
431 st.failed = Some((node_idx, format!("{cause:?}: {detail}")));
432 }
433 }
434 }
435 }
436}
437
438pub fn flow_key(node_idx: usize, task_path: &str) -> String {
442 if task_path.is_empty() {
443 node_idx.to_string()
444 } else {
445 format!("{node_idx}@{task_path}")
446 }
447}
448
449fn flow_attempt(st: &SchedulerState, i: usize, path: &str) -> u32 {
450 match st.send_tasks.get(path) {
451 Some(task) => task.attempt,
452 None => st.attempt[i],
453 }
454}
455
456fn start_flow(env: &StepEnv<'_>, st: &mut SchedulerState, i: usize, path: &str, input: Value, out: &mut Vec<Command>) {
459 let content = match first_message(env, i, &input) {
463 Ok(c) => c,
464 Err(why) => {
465 fail_abstract_coded(env, st, i, path, &format!("SchemaValidationFailed: {why}"));
466 return;
467 }
468 };
469 st.abstract_flows.insert(
470 flow_key(i, path),
471 crate::state::AbstractFlow {
472 messages: vec![serde_json::json!({
473 "role": "user",
474 "content": content,
475 })],
476 next_effect_seq: 0,
477 pending_tools: BTreeMap::new(),
478 round_results: BTreeMap::new(),
479 need: None,
480 unknown_strikes: 0,
481 last_prompt_tokens: 0,
482 folding: None,
483 fold_forced: false,
484 folds: 0,
485 deciding: None,
486 decide_fold_tried: false,
487 offer: None,
488 response_strikes: 0,
489 tool_log: Vec::new(),
490 },
491 );
492 if emit_offer_decide(env, st, i, path, out) {
496 return;
497 }
498 dispatch_llm_turn(env, st, i, path, out);
499}
500
501fn llm_cfg<'e>(env: &'e StepEnv<'_>, i: usize) -> Option<&'e LlmNodeConfig> {
512 match &env.executors[i] {
513 NodeExecutor::Abstract { llm, .. } => llm.as_deref(),
514 _ => None,
515 }
516}
517
518fn reserve_for(env: &StepEnv<'_>, i: usize) -> u64 {
522 llm_cfg(env, i)
523 .and_then(|c| c.max_tokens)
524 .map(u64::from)
525 .unwrap_or(env.llm_reserve_tokens)
526}
527
528fn json_kind(v: &Value) -> &'static str {
529 match v {
530 Value::Null => "null",
531 Value::Bool(_) => "boolean",
532 Value::Number(_) => "number",
533 Value::String(_) => "string",
534 Value::Array(_) => "array",
535 Value::Object(_) => "object",
536 }
537}
538
539fn first_message(env: &StepEnv<'_>, i: usize, input: &Value) -> Result<Value, String> {
541 let cfg = llm_cfg(env, i);
542 let instruction = match (cfg.and_then(|c| c.instruction.as_ref()), cfg.and_then(|c| c.instruction_from.as_ref())) {
543 (Some(text), _) => text.clone(),
544 (None, Some(ptr)) => match input.pointer(ptr) {
545 Some(Value::String(text)) => text.clone(),
546 Some(other) => {
547 return Err(format!(
548 "instruction_from {ptr:?} resolves to a {} in the node's input, not a string",
549 json_kind(other)
550 ))
551 }
552 None => return Err(format!("instruction_from {ptr:?} resolves to nothing in the node's input")),
553 },
554 (None, None) => env.plan.nodes[i].clone(),
555 };
556 let mut content = serde_json::Map::new();
557 content.insert("instruction".into(), Value::String(instruction));
558 let whole = cfg.and_then(|c| c.state.as_ref()).is_none_or(|s| s.as_str() == Some("all"));
563 let pruned;
564 let input = match cfg.and_then(|c| c.tools_from.as_deref()) {
565 Some(ptr) if whole && !ptr.is_empty() => {
566 let mut copy = input.clone();
567 remove_pointer(&mut copy, ptr);
568 pruned = copy;
569 &pruned
570 }
571 _ => input,
572 };
573 if let Some(state) = select_state(cfg.and_then(|c| c.state.as_ref()), input) {
574 content.insert("state".into(), state);
575 }
576 Ok(Value::Object(content))
577}
578
579fn remove_pointer(v: &mut Value, ptr: &str) {
581 let Some((parent, last)) = ptr.rsplit_once('/') else { return };
582 let last = last.replace("~1", "/").replace("~0", "~");
583 let target = if parent.is_empty() { Some(v) } else { v.pointer_mut(parent) };
584 match target {
585 Some(Value::Object(o)) => {
586 o.remove(&last);
587 }
588 Some(Value::Array(a)) => {
589 if let Ok(n) = last.parse::<usize>() {
590 if n < a.len() {
591 a.remove(n);
592 }
593 }
594 }
595 _ => {}
596 }
597}
598
599fn select_state(sel: Option<&Value>, input: &Value) -> Option<Value> {
604 let keep = |keys: &[&str]| {
605 let mut o = serde_json::Map::new();
606 if let Value::Object(src) = input {
607 for k in keys.iter().copied().chain([crate::types::INBOX]) {
608 if let Some(v) = src.get(k) {
609 o.insert(k.to_string(), v.clone());
610 }
611 }
612 }
613 o
614 };
615 match sel {
616 None => Some(input.clone()),
617 Some(Value::String(s)) if s == "all" => Some(input.clone()),
618 Some(Value::String(s)) if s == "none" => {
619 let o = keep(&[]);
620 (!o.is_empty()).then_some(Value::Object(o))
621 }
622 Some(Value::Array(keys)) => {
623 let keys: Vec<&str> = keys.iter().filter_map(Value::as_str).collect();
624 Some(Value::Object(keep(&keys)))
625 }
626 Some(_) => Some(input.clone()),
629 }
630}
631
632fn response_name(node: &str) -> String {
635 let safe: String = node
636 .chars()
637 .map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '_' })
638 .take(64)
639 .collect();
640 if safe.is_empty() { "response".into() } else { safe }
641}
642
643fn request_block(env: &StepEnv<'_>, i: usize, fold: bool, instruction: &str) -> Option<Value> {
652 let c = llm_cfg(env, i)?;
653 let mut b = serde_json::Map::new();
654 if let Some(s) = &c.system {
655 let wants_json_object = !fold && c.response.as_ref().is_some_and(|r| r.get("json").is_some());
659 let says_json = |t: &str| t.to_ascii_lowercase().contains("json");
660 let s = if wants_json_object && !says_json(s) && !says_json(instruction) {
661 format!("{s} Answer with a JSON object.")
662 } else {
663 s.clone()
664 };
665 b.insert("system".into(), Value::String(s));
666 }
667 if let Some(t) = &c.temperature {
668 b.insert("temperature".into(), t.clone());
669 }
670 if let Some(m) = c.max_tokens {
671 b.insert("max_tokens".into(), Value::from(m));
672 }
673 if !fold {
674 if let Some(p) = c.parallel_tool_calls {
675 b.insert("parallel_tool_calls".into(), Value::Bool(p));
676 }
677 if let Some(r) = &c.response {
678 let wire = match r.get("json_schema") {
679 Some(schema) => serde_json::json!({
680 "type": "json_schema",
681 "json_schema": {
682 "name": response_name(&env.plan.nodes[i]),
683 "schema": schema,
684 "strict": true,
685 },
686 }),
687 None => serde_json::json!({ "type": "json_object" }),
688 };
689 b.insert("response_format".into(), wire);
690 }
691 }
692 (!b.is_empty()).then_some(Value::Object(b))
693}
694
695fn unfence(text: &str) -> &str {
703 let t = text.trim();
704 let Some(rest) = t.strip_prefix("```") else { return t };
705 let Some(body) = rest.strip_suffix("```") else { return t };
706 match body.split_once('\n') {
708 Some((info, inner)) if !info.contains('{') && !info.contains('[') => inner.trim(),
709 _ => body.trim(),
710 }
711}
712
713fn check_response(response: &Value, text: &str) -> Result<(), String> {
714 let value: Value = serde_json::from_str(unfence(text))
715 .map_err(|e| format!("the answer is not JSON ({e})"))?;
716 if !value.is_object() {
717 return Err(format!("the answer is a JSON {}, not an object", json_kind(&value)));
718 }
719 if let Some(schema) = response.get("json_schema") {
720 areev_core::types::validate_instance(&value, schema)
721 .map_err(|k| format!("schema violation: {}", k.as_str()))?;
722 }
723 Ok(())
724}
725
726fn validated_dispatch_input(
733 env: &StepEnv<'_>,
734 tool: &OfferedTool,
735 arguments: &Value,
736) -> Result<Value, String> {
737 if let Some(d) = &tool.dynamic {
738 let schema = d.model_schema(tool.locked.as_ref());
744 if let Value::Object(args) = arguments {
745 if d.dispatch.args_into.is_none() {
746 if let Some(Value::Object(lock)) = &tool.locked {
747 if let Some(k) = args.keys().find(|k| lock.contains_key(k.as_str())) {
748 return Err(format!(
749 "argument '{k}' is fixed by the host and may not be set"
750 ));
751 }
752 }
753 }
754 let closed = d.dispatch.args_into.is_none()
761 || schema.get("additionalProperties") == Some(&Value::Bool(false));
762 let named = schema.get("properties").and_then(Value::as_object);
763 if closed {
764 if let Some(k) = args.keys().find(|k| !named.is_some_and(|p| p.contains_key(k.as_str()))) {
765 return Err(format!("argument '{k}' is not one of this tool's parameters"));
766 }
767 }
768 }
769 areev_core::types::validate_instance(arguments, &schema)
772 .map_err(|k| format!("schema violation: {}", k.as_str()))?;
773 }
774 let input = tool.dispatch_input(arguments);
775 if let Some(schema) = tool.dynamic.as_ref().and_then(|d| d.dispatch.definition_schema.as_ref()) {
776 areev_core::types::validate_instance(&input, schema)
777 .map_err(|k| format!("schema violation: {} (the call as dispatched)", k.as_str()))?;
778 }
779 (env.validate_args)(tool.dispatch_name(), &input)?;
780 Ok(input)
781}
782
783fn flow_owners(st: &SchedulerState) -> Vec<(usize, String)> {
784 let mut owners: Vec<(usize, String)> = st
785 .abstract_flows
786 .iter()
787 .filter(|(_, f)| f.need.is_some())
788 .filter_map(|(k, _)| match k.split_once('@') {
789 Some((i, path)) => Some((i.parse().ok()?, path.to_string())),
790 None => Some((k.parse().ok()?, String::new())),
791 })
792 .collect();
793 owners.sort();
794 owners
795}
796
797fn apply_inbox(st: &mut SchedulerState) {
798 let Value::Object(o) = &mut st.context else {
799 return;
800 };
801 if st.inbox.is_empty() {
802 o.remove(crate::types::INBOX);
803 } else {
804 o.insert(crate::types::INBOX.into(), Value::Array(std::mem::take(&mut st.inbox)));
805 }
806}
807
808fn drop_bubble(st: &mut SchedulerState, key: &JournalKey) {
809 let ids: Vec<String> = st
810 .pending_asks
811 .iter()
812 .filter(|(_, p)| p.key == *key)
813 .map(|(id, _)| id.clone())
814 .collect();
815 for id in ids {
816 st.pending_asks.remove(&id);
817 st.announced_asks.remove(&id);
818 }
819}
820
821fn park_bubbled(st: &mut SchedulerState, i: usize, resolved: &JournalKey, asks: Vec<Ask>) {
822 if !matches!(st.phase, Phase::Open { .. }) {
823 return;
824 }
825 let stale: Vec<JournalKey> = st
826 .pending_asks
827 .values()
828 .filter(|p| p.node_idx == i)
829 .map(|p| p.key.clone())
830 .collect();
831 for key in stale {
832 drop_bubble(st, &key);
833 }
834 let key = JournalKey { effect_seq: resolved.effect_seq + 1, ..resolved.clone() };
839 if let Phase::Open { outstanding, .. } = &mut st.phase {
840 outstanding.insert(key.clone());
841 }
842 st.node_state[i] = NodeState::AwaitingClient;
843 for ask in asks {
844 st.pending_asks.insert(
845 ask.tool_call_id.clone(),
846 PendingAsk { key: key.clone(), node_idx: i, ask },
847 );
848 }
849}
850
851fn handle_send_task_outcome(
855 env: &StepEnv<'_>,
856 st: &mut SchedulerState,
857 key: &JournalKey,
858 outcome: &EffectOutcome,
859) {
860 let Some(task) = st.send_tasks.get_mut(&key.task_path) else { return };
861 if task.state == crate::state::SendTaskState::Done {
866 return;
867 }
868 let target = task.node_idx;
869 match outcome {
870 EffectOutcome::Completed { result, .. } => {
871 settle_send_task(st, &key.task_path, result.clone());
872 }
873 EffectOutcome::Failed { cause, detail, .. } => {
874 let can_retry = cause.retryable(key.kind)
875 && task.attempt <= env.plan.retries[target]
876 && st.cancel.is_none();
877 if can_retry {
878 task.state = crate::state::SendTaskState::Queued;
879 return;
880 }
881 task.state = crate::state::SendTaskState::Done;
882 st.node_state[target] = NodeState::DoneFailed;
883 let named = st.failed.as_ref().map(|(i, _)| *i).unwrap_or(usize::MAX);
884 if target < named {
885 st.failed = Some((
886 target,
887 format!("{cause:?}: {detail} (task {})", key.task_path),
888 ));
889 }
890 }
891 }
892}
893
894fn settle_send_task(st: &mut SchedulerState, path: &str, result: Value) {
897 let Some(task) = st.send_tasks.get_mut(path) else { return };
898 let target = task.node_idx;
899 task.state = crate::state::SendTaskState::Done;
900 if let Phase::Open { send_results, .. } = &mut st.phase {
901 send_results.insert(path.to_string(), result);
902 }
903 let all_done = st
904 .send_tasks
905 .values()
906 .filter(|t| t.node_idx == target)
907 .all(|t| t.state == crate::state::SendTaskState::Done);
908 if all_done && st.node_state[target] == NodeState::Dispatched {
909 st.node_state[target] = NodeState::DoneOk;
910 if let Phase::Open { results, .. } = &mut st.phase {
911 results.entry(target).or_insert(Value::Null);
912 }
913 }
914}
915
916fn canonical_tool_name(offered: &[OfferedTool], called: &str) -> String {
933 if offered.iter().any(|t| t.tool_name == called) {
934 return called.to_string();
935 }
936 let mut hits = offered.iter().filter(|t| normalize_tool_name(&t.tool_name) == called);
937 match (hits.next(), hits.next()) {
938 (Some(t), None) => t.tool_name.clone(),
939 _ => called.to_string(),
940 }
941}
942
943fn handle_llm_outcome(
945 env: &StepEnv<'_>,
946 st: &mut SchedulerState,
947 i: usize,
948 path: &str,
949 outcome: &EffectOutcome,
950) {
951 let NodeExecutor::Abstract { tools, .. } = &env.executors[i] else { return };
952 let flow_id = flow_key(i, path);
953 let folding = st.abstract_flows.get_mut(&flow_id).and_then(|f| f.folding.take());
958 if let Some(f) = folding {
959 resolve_fold(env, st, i, path, f, outcome);
960 return;
961 }
962 let narrowed = narrowed_offer(st.abstract_flows.get(&flow_id), tools);
966 let tools: &[OfferedTool] = &narrowed;
967 let offered: Vec<&str> = tools.iter().map(|t| t.tool_name.as_str()).collect();
968 match outcome {
969 EffectOutcome::Failed { cause, detail, .. } => match cause {
970 FailCause::Timeout | FailCause::ExecutorError => {
975 if let Some(flow) = st.abstract_flows.get_mut(&flow_id) {
976 flow.need = Some(crate::state::FlowNeed::NextTurn);
977 }
978 }
979 FailCause::SchemaValidationFailed => {
980 if let Some(flow) = st.abstract_flows.get_mut(&flow_id) {
981 flow.messages.push(serde_json::json!({
982 "role": "user",
983 "content": format!(
984 "Your previous output failed validation: {detail}. \
985 Correct it and answer again."
986 ),
987 }));
988 flow.need = Some(crate::state::FlowNeed::NextTurn);
989 }
990 }
991 FailCause::ContextOverflow => {
998 if let Some(flow) = st.abstract_flows.get_mut(&flow_id) {
999 flow.fold_forced = true;
1000 flow.need = Some(crate::state::FlowNeed::NextTurn);
1001 }
1002 }
1003 FailCause::UserAborted | FailCause::Unknown => {
1004 fail_abstract(env, st, i, path, detail);
1005 }
1006 },
1007 EffectOutcome::Completed { result, input_tokens, .. } => {
1008 if let Some(flow) = st.abstract_flows.get_mut(&flow_id) {
1013 flow.last_prompt_tokens = *input_tokens;
1014 }
1015 let text = result.get("text").and_then(|t| t.as_str()).map(str::to_string);
1016 let provider_content = result.get("provider_content").cloned();
1022 let calls: Vec<crate::state::PendingToolCall> = result
1023 .get("tool_calls")
1024 .and_then(|c| c.as_array())
1025 .map(|arr| {
1026 arr.iter()
1027 .map(|c| crate::state::PendingToolCall {
1028 model_call_id: c
1029 .get("id")
1030 .and_then(|v| v.as_str())
1031 .unwrap_or_default()
1032 .to_string(),
1033 tool_name: canonical_tool_name(
1040 tools,
1041 c.get("name").and_then(|v| v.as_str()).unwrap_or_default(),
1042 ),
1043 arguments: c.get("arguments").cloned().unwrap_or(Value::Null),
1044 })
1045 .collect()
1046 })
1047 .unwrap_or_default();
1048
1049 let unknown: Vec<String> = calls
1055 .iter()
1056 .map(|c| c.tool_name.clone())
1057 .filter(|n| !offered.contains(&n.as_str()))
1058 .collect();
1059 if !unknown.is_empty() {
1060 let strikes = st
1061 .abstract_flows
1062 .get(&flow_id)
1063 .map(|f| f.unknown_strikes)
1064 .unwrap_or(0);
1065 if strikes >= 1 {
1066 fail_abstract(
1067 env,
1068 st,
1069 i,
1070 path,
1071 &format!("model called unknown tool(s) {unknown:?} after a corrective re-prompt"),
1072 );
1073 return;
1074 }
1075 let Some(flow) = st.abstract_flows.get_mut(&flow_id) else { return };
1076 flow.unknown_strikes += 1;
1077 let choices: Vec<String> =
1082 offered.iter().map(|n| normalize_tool_name(n)).collect();
1083 flow.messages.push(serde_json::json!({
1084 "role": "user",
1085 "content": format!(
1086 "Unknown tool(s) {:?}; choose among: {:?}",
1087 unknown, choices
1088 ),
1089 }));
1090 flow.need = Some(crate::state::FlowNeed::NextTurn);
1091 return;
1092 }
1093
1094 let invalid: Option<(String, String)> = calls.iter().find_map(|c| {
1101 if !c.arguments.is_object() {
1102 return Some((
1103 c.tool_name.clone(),
1104 "arguments are not a JSON object".to_string(),
1105 ));
1106 }
1107 let tool = tools.iter().find(|t| t.tool_name == c.tool_name)?;
1108 validated_dispatch_input(env, tool, &c.arguments)
1109 .err()
1110 .map(|e| (c.tool_name.clone(), e))
1111 });
1112 let Some(flow) = st.abstract_flows.get_mut(&flow_id) else { return };
1113 if let Some((tool, why)) = invalid {
1114 flow.messages.push(serde_json::json!({
1115 "role": "user",
1116 "content": format!(
1117 "Arguments for tool '{tool}' failed validation ({why}). \
1118 Correct the call and try again."
1119 ),
1120 }));
1121 flow.need = Some(crate::state::FlowNeed::NextTurn);
1122 return;
1123 }
1124 if calls.is_empty() {
1125 let final_text = text.unwrap_or_default();
1129 let cfg = llm_cfg(env, i);
1134 if let Some(response) = cfg.and_then(|c| c.response.as_ref()) {
1135 if let Err(why) = check_response(response, &final_text) {
1136 if flow.response_strikes >= 1 {
1137 fail_abstract_coded(
1138 env,
1139 st,
1140 i,
1141 path,
1142 &format!(
1143 "SchemaValidationFailed: the final answer failed the node's \
1144 response declaration after a corrective re-prompt ({why})"
1145 ),
1146 );
1147 return;
1148 }
1149 flow.response_strikes += 1;
1150 let mut entry = serde_json::json!({
1151 "role": "assistant",
1152 "text": final_text,
1153 "tool_calls": [],
1154 });
1155 if let Some(pc) = provider_content {
1156 entry["provider_content"] = pc;
1157 }
1158 flow.messages.push(entry);
1159 flow.messages.push(serde_json::json!({
1160 "role": "user",
1161 "content": format!(
1162 "Your final answer failed validation: {why}. Answer again with \
1163 only a JSON object that satisfies the required response format."
1164 ),
1165 }));
1166 flow.need = Some(crate::state::FlowNeed::NextTurn);
1167 return;
1168 }
1169 }
1170 let merge_text = if cfg.is_some_and(|c| c.response.is_some()) {
1174 unfence(&final_text).to_string()
1175 } else {
1176 final_text.clone()
1177 };
1178 let mut value = serde_json::from_str::<Value>(&merge_text)
1179 .ok()
1180 .filter(|v| v.is_object())
1181 .unwrap_or_else(|| {
1182 serde_json::json!({ env.plan.nodes[i].clone(): final_text })
1183 });
1184 if let Some(key) = cfg.and_then(|c| c.tool_results_into.as_ref()) {
1188 if let Value::Object(o) = &mut value {
1189 o.insert(key.clone(), Value::Array(std::mem::take(&mut flow.tool_log)));
1190 }
1191 }
1192 st.abstract_flows.remove(&flow_id);
1193 if path.is_empty() {
1194 st.node_state[i] = NodeState::DoneOk;
1195 if let Phase::Open { results, .. } = &mut st.phase {
1196 results.insert(i, value);
1197 }
1198 } else {
1199 settle_send_task(st, path, value);
1200 }
1201 return;
1202 }
1203 let mut entry = serde_json::json!({
1206 "role": "assistant",
1207 "text": text,
1208 "tool_calls": calls.iter().map(|c| serde_json::json!({
1209 "id": c.model_call_id,
1210 "name": c.tool_name,
1211 "arguments": c.arguments,
1212 })).collect::<Vec<_>>(),
1213 });
1214 if let Some(pc) = provider_content {
1215 entry["provider_content"] = pc;
1216 }
1217 flow.messages.push(entry);
1218 flow.round_results.clear();
1219 flow.need = Some(crate::state::FlowNeed::Tools(calls));
1220 }
1221 }
1222}
1223
1224pub fn bound_tool_content(
1242 raw: &Value,
1243 cap: Option<usize>,
1244 attempt: u32,
1245 effect_seq: u32,
1246) -> Value {
1247 let Some(cap) = cap else { return raw.clone() };
1248 let text = match raw {
1251 Value::String(s) => s.clone(),
1252 other => other.to_string(),
1253 };
1254 let total = text.chars().count();
1255 if total <= cap {
1256 return raw.clone();
1257 }
1258 let half = cap / 2;
1259 serde_json::json!({
1260 "truncated": true,
1261 "chars": total,
1262 "head": text.chars().take(half).collect::<String>(),
1263 "tail": text.chars().skip(total - half).collect::<String>(),
1264 "journal": { "attempt": attempt, "effect_seq": effect_seq },
1265 })
1266}
1267
1268fn resolve_fold(
1276 env: &StepEnv<'_>,
1277 st: &mut SchedulerState,
1278 i: usize,
1279 path: &str,
1280 f: FoldInFlight,
1281 outcome: &EffectOutcome,
1282) {
1283 let flow_id = flow_key(i, path);
1284 match outcome {
1285 EffectOutcome::Completed { result, .. } => {
1286 let summary =
1287 result.get("text").and_then(Value::as_str).unwrap_or_default().to_string();
1288 let attempt = flow_attempt(st, i, path);
1289 let Some(flow) = st.abstract_flows.get_mut(&flow_id) else { return };
1290 if f.from >= f.to || f.to > flow.messages.len() {
1295 flow.need = Some(crate::state::FlowNeed::NextTurn);
1296 return;
1297 }
1298 let n = flow.folds + 1;
1299 let fold_msg = serde_json::json!({
1300 "role": "user",
1301 "content": format!(
1302 "[Areev fold {n}: transcript entries {}..{} of attempt {attempt} were \
1303 replaced by this summary, journaled at effect_seq {}. The full record \
1304 — every turn and every tool result — is in this run's journal.]\n{summary}",
1305 f.from, f.to, f.effect_seq,
1306 ),
1307 });
1308 flow.messages.splice(f.from..f.to, [fold_msg]);
1309 flow.folds = n;
1310 flow.decide_fold_tried = false;
1313 flow.last_prompt_tokens = 0;
1317 flow.need = Some(crate::state::FlowNeed::NextTurn);
1318 st.folds = st.folds.saturating_add(1);
1322 }
1323 EffectOutcome::Failed { cause, detail, .. } => match cause {
1324 FailCause::Timeout | FailCause::ExecutorError => {
1328 if let Some(flow) = st.abstract_flows.get_mut(&flow_id) {
1329 flow.need = Some(crate::state::FlowNeed::NextTurn);
1330 }
1331 }
1332 FailCause::ContextOverflow => {
1338 let detail = RunError::ContextExceeded {
1339 node: env.plan.nodes[i].clone(),
1340 tokens: st
1341 .abstract_flows
1342 .get(&flow_id)
1343 .map(|f| f.last_prompt_tokens)
1344 .unwrap_or(0),
1345 ceiling: env.llm_context_tokens,
1346 }
1347 .to_string();
1348 fail_abstract_coded(env, st, i, path, &detail);
1349 }
1350 FailCause::Unknown
1353 | FailCause::UserAborted
1354 | FailCause::SchemaValidationFailed => {
1355 fail_abstract(env, st, i, path, detail);
1356 }
1357 },
1358 }
1359}
1360
1361fn handle_flow_tool_outcome(
1365 env: &StepEnv<'_>,
1366 st: &mut SchedulerState,
1367 i: usize,
1368 path: &str,
1369 effect_seq: u32,
1370 outcome: &EffectOutcome,
1371) {
1372 let attempt = flow_attempt(st, i, path);
1375 let cap = env.llm_tool_result_chars;
1376 let logging = llm_cfg(env, i).is_some_and(|c| c.tool_results_into.is_some());
1377 let journal_id = JournalKey {
1378 run_id: st.run_id.clone(),
1379 task_path: path.to_string(),
1380 node: env.plan.nodes[i].clone(),
1381 attempt,
1382 effect_seq,
1383 kind: EffectKind::Tool,
1384 }
1385 .tool_call_id();
1386 let Some(flow) = st.abstract_flows.get_mut(&flow_key(i, path)) else { return };
1387 let Some(call) = flow.pending_tools.remove(&effect_seq) else { return };
1388 if logging {
1389 let NodeExecutor::Abstract { tools, .. } = &env.executors[i] else { return };
1394 let input = tools
1395 .iter()
1396 .find(|t| t.tool_name == call.tool_name)
1397 .map(|t| t.dispatch_input(&call.arguments))
1398 .unwrap_or_else(|| call.arguments.clone());
1399 let (result, is_error) = match outcome {
1400 EffectOutcome::Completed { result, .. } => (result.clone(), false),
1401 EffectOutcome::Failed { cause, detail, .. } => {
1402 (Value::String(format!("{cause:?}: {detail}")), true)
1403 }
1404 };
1405 let entry = serde_json::json!({
1406 "name": call.tool_name,
1407 "tool_call_id": journal_id,
1408 "model_call_id": call.model_call_id,
1409 "input": input,
1410 "result": result,
1411 "is_error": is_error,
1412 "effect_seq": effect_seq,
1413 });
1414 let at = flow
1415 .tool_log
1416 .iter()
1417 .position(|e| e.get("effect_seq").and_then(Value::as_u64).unwrap_or(0) > u64::from(effect_seq))
1418 .unwrap_or(flow.tool_log.len());
1419 flow.tool_log.insert(at, entry);
1420 }
1421 let entry = match outcome {
1422 EffectOutcome::Completed { result, .. } => serde_json::json!({
1423 "role": "tool",
1424 "tool_call_id": call.model_call_id,
1425 "content": bound_tool_content(result, cap, attempt, effect_seq),
1426 "is_error": false,
1427 }),
1428 EffectOutcome::Failed { cause, detail, .. } => serde_json::json!({
1432 "role": "tool",
1433 "tool_call_id": call.model_call_id,
1434 "content": bound_tool_content(
1435 &Value::String(format!("{cause:?}: {detail}")),
1436 cap,
1437 attempt,
1438 effect_seq,
1439 ),
1440 "is_error": true,
1441 }),
1442 };
1443 flow.round_results.insert(effect_seq, entry);
1444 if flow.pending_tools.is_empty() {
1445 let entries: Vec<Value> = flow.round_results.values().cloned().collect();
1448 flow.messages.extend(entries);
1449 flow.round_results.clear();
1450 flow.need = Some(crate::state::FlowNeed::NextTurn);
1451 }
1452}
1453
1454fn progress(env: &StepEnv<'_>, st: &mut SchedulerState, out: &mut Vec<Command>, hold: bool) {
1455 loop {
1456 if st.is_terminal() {
1457 return;
1458 }
1459 if matches!(st.phase, Phase::Open { .. }) {
1460 if progress_open(env, st, out) {
1461 continue;
1462 }
1463 return;
1464 }
1465 if !progress_idle(env, st, out, hold) {
1466 return;
1467 }
1468 }
1469}
1470
1471fn progress_open(env: &StepEnv<'_>, st: &mut SchedulerState, out: &mut Vec<Command>) -> bool {
1474 let draining = st.cancel.is_some() || st.exhausted.is_some();
1475
1476 if !draining {
1479 let needy = flow_owners(st);
1480 if !needy.is_empty() {
1481 for (i, path) in needy {
1482 let need =
1483 st.abstract_flows.get_mut(&flow_key(i, &path)).and_then(|f| f.need.take());
1484 match need {
1485 Some(crate::state::FlowNeed::NextTurn) => {
1486 dispatch_llm_turn(env, st, i, &path, out);
1487 }
1488 Some(crate::state::FlowNeed::Tools(calls)) => {
1489 for call in calls {
1490 dispatch_flow_tool(env, st, i, &path, call, out);
1491 }
1492 }
1493 None => {}
1494 }
1495 }
1496 return true;
1497 }
1498 }
1499
1500 let retry_nodes: Vec<usize> = (0..env.plan.nodes.len())
1502 .filter(|&i| st.node_state[i] == NodeState::Ready)
1503 .collect();
1504 if !retry_nodes.is_empty() && !draining {
1505 for i in retry_nodes {
1506 dispatch_node(env, st, i, out);
1507 }
1508 return true;
1509 }
1510
1511 let retry_tasks: Vec<String> = st
1513 .send_tasks
1514 .iter()
1515 .filter(|(_, t)| t.state == crate::state::SendTaskState::Queued)
1516 .map(|(p, _)| p.clone())
1517 .collect();
1518 if !retry_tasks.is_empty() && !draining {
1519 for p in retry_tasks {
1520 dispatch_send_task(env, st, &p, out);
1521 }
1522 return true;
1523 }
1524
1525 let (outstanding_empty, only_asks_left) = match &st.phase {
1526 Phase::Open { outstanding, .. } => {
1527 let only_asks = !outstanding.is_empty()
1528 && outstanding
1529 .iter()
1530 .all(|k| st.pending_asks.values().any(|p| p.key == *k));
1531 (outstanding.is_empty(), only_asks)
1532 }
1533 _ => unreachable!("progress_open requires Phase::Open"),
1534 };
1535
1536 if outstanding_empty || (st.cancel.is_some() && only_asks_left) {
1537 if st.cancel.is_some() && only_asks_left {
1538 if let Phase::Open { outstanding, .. } = &mut st.phase {
1540 outstanding.clear();
1541 }
1542 st.pending_asks.clear();
1543 st.announced_asks.clear();
1544 }
1545 close_superstep(env, st, out);
1546 return true;
1547 }
1548 if only_asks_left {
1549 if let Some(open) = st.wall_open.take() {
1551 st.spent.wall_ms += st.clock_ms.saturating_sub(open);
1552 st.paused_at = Some(st.clock_ms);
1553 }
1554 park(st, out);
1555 }
1556 false
1557}
1558
1559fn progress_idle(
1561 env: &StepEnv<'_>,
1562 st: &mut SchedulerState,
1563 out: &mut Vec<Command>,
1564 hold: bool,
1565) -> bool {
1566 if let Some((by, reason)) = st.cancel.clone() {
1567 finish(st, out, RunOutcome::Canceled { by, reason });
1568 return false;
1569 }
1570 if let Some(axis) = st.exhausted {
1571 finish(st, out, RunOutcome::BudgetExhausted { axis });
1572 return false;
1573 }
1574 if let Some((idx, detail)) = st.failed.clone() {
1575 finish(
1576 st,
1577 out,
1578 RunOutcome::Failed { node: env.plan.nodes[idx].clone(), detail },
1579 );
1580 return false;
1581 }
1582 let ready: Vec<usize> = (0..env.plan.nodes.len())
1583 .filter(|&i| st.node_state[i] == NodeState::Ready)
1584 .collect();
1585 let queued_tasks: Vec<String> = st
1586 .send_tasks
1587 .iter()
1588 .filter(|(_, t)| t.state == crate::state::SendTaskState::Queued)
1589 .map(|(p, _)| p.clone())
1590 .collect();
1591 let needy_flows = flow_owners(st);
1594 if ready.is_empty() && queued_tasks.is_empty() && needy_flows.is_empty() {
1595 if st.superstep == 0 && st.node_state[0] == NodeState::Waiting {
1598 return false;
1599 }
1600 if st
1603 .node_state
1604 .iter()
1605 .any(|s| matches!(s, NodeState::Dispatched | NodeState::AwaitingClient))
1606 {
1607 return false;
1608 }
1609 let completed = env
1610 .plan
1611 .terminals()
1612 .iter()
1613 .any(|&t| st.node_state[t] == NodeState::DoneOk);
1614 if completed {
1615 finish(st, out, RunOutcome::Completed);
1616 } else {
1617 let node = stalled_culprit(env, st);
1618 finish(st, out, RunOutcome::Stalled { node });
1619 }
1620 return false;
1621 }
1622
1623 let axes = [
1625 (BudgetAxis::Supersteps, st.spent.supersteps, Some(env.budgets.max_supersteps)),
1626 (BudgetAxis::WallMs, st.spent.wall_ms, env.budgets.max_wall_ms),
1627 (
1628 BudgetAxis::Tokens,
1629 st.spent.input_tokens + st.spent.output_tokens,
1630 env.budgets.max_tokens,
1631 ),
1632 (BudgetAxis::Usd, st.spent.usd_micros, env.budgets.max_usd_micros),
1633 (BudgetAxis::Storage, st.spent.storage_bytes, env.budgets.max_storage_bytes),
1634 (BudgetAxis::Effects, st.spent.journal_grains / 2, env.budgets.max_effects),
1638 (BudgetAxis::ToolCalls, st.spent.tool_calls, env.budgets.max_tool_calls),
1639 ];
1640 for (axis, spent, max) in axes {
1641 if let Some(max) = max {
1642 if spent >= max {
1643 finish(st, out, RunOutcome::BudgetExhausted { axis });
1644 return false;
1645 }
1646 }
1647 }
1648
1649 if hold {
1653 return false;
1654 }
1655
1656 st.superstep += 1;
1658 st.spent.supersteps += 1;
1659 let resumed_at = st.paused_at.take().map(|paused| {
1665 st.elapsed_ms += st.clock_ms.saturating_sub(paused);
1666 st.clock_ms
1667 });
1668 st.wall_open = Some(st.clock_ms);
1669 st.phase = Phase::Open {
1670 outstanding: Default::default(),
1671 results: BTreeMap::new(),
1672 send_results: BTreeMap::new(),
1673 record: DecisionRecord {
1674 superstep: st.superstep,
1675 clock_open_ms: st.clock_ms,
1676 resumed_at,
1677 ..DecisionRecord::default()
1678 },
1679 };
1680 apply_inbox(st);
1681 for i in ready {
1682 dispatch_node(env, st, i, out);
1683 }
1684 for p in queued_tasks {
1685 dispatch_send_task(env, st, &p, out);
1686 }
1687 for (i, path) in needy_flows {
1688 let need = st.abstract_flows.get_mut(&flow_key(i, &path)).and_then(|fl| fl.need.take());
1689 match need {
1690 Some(crate::state::FlowNeed::NextTurn) => dispatch_llm_turn(env, st, i, &path, out),
1691 Some(crate::state::FlowNeed::Tools(calls)) => {
1692 for call in calls {
1693 dispatch_flow_tool(env, st, i, &path, call, out);
1694 }
1695 }
1696 None => {}
1697 }
1698 }
1699 true
1700}
1701
1702fn dispatch_send_task(
1705 env: &StepEnv<'_>,
1706 st: &mut SchedulerState,
1707 path: &str,
1708 out: &mut Vec<Command>,
1709) {
1710 let Some(task) = st.send_tasks.get_mut(path) else { return };
1711 task.attempt += 1;
1712 task.state = crate::state::SendTaskState::Running;
1713 let (target, attempt, input) = (task.node_idx, task.attempt, task.input.clone());
1714 let executor = env.executors[target].clone();
1715 if let NodeExecutor::Abstract { .. } = &executor {
1716 start_flow(env, st, target, path, input, out);
1717 return;
1718 }
1719 let key = JournalKey {
1720 run_id: st.run_id.clone(),
1721 task_path: path.to_string(),
1722 node: env.plan.nodes[target].clone(),
1723 attempt,
1724 effect_seq: 0,
1725 kind: EffectKind::Tool,
1726 };
1727 let clock = st.clock_ms;
1728 let superstep = st.superstep;
1729 if let Phase::Open { outstanding, record, .. } = &mut st.phase {
1730 outstanding.insert(key.clone());
1731 record.task_dispatched.push((path.to_string(), attempt));
1732 }
1733 out.push(Command::WriteIntent {
1734 key: key.clone(),
1735 executor: executor.clone(),
1736 input: input.clone(),
1737 superstep,
1738 clock_ms: clock,
1739 });
1740 out.push(Command::Dispatch { key, executor, input });
1741}
1742
1743fn dispatch_node(env: &StepEnv<'_>, st: &mut SchedulerState, i: usize, out: &mut Vec<Command>) {
1747 st.attempt[i] += 1;
1748 let executor = env.executors[i].clone();
1749
1750 if let NodeExecutor::Abstract { .. } = &executor {
1752 st.node_state[i] = NodeState::Dispatched;
1753 let input = st.context.clone();
1754 start_flow(env, st, i, "", input, out);
1755 return;
1756 }
1757
1758 let key = JournalKey {
1759 run_id: st.run_id.clone(),
1760 task_path: String::new(),
1761 node: env.plan.nodes[i].clone(),
1762 attempt: st.attempt[i],
1763 effect_seq: 0,
1764 kind: EffectKind::Tool,
1765 };
1766 let input = st.context.clone();
1767 let clock = st.clock_ms;
1768 let superstep = st.superstep;
1769
1770 {
1771 let Phase::Open { outstanding, record, .. } = &mut st.phase else {
1772 return;
1773 };
1774 outstanding.insert(key.clone());
1775 record.dispatched.push((i, st.attempt[i]));
1776 }
1777
1778 out.push(Command::WriteIntent {
1779 key: key.clone(),
1780 executor: executor.clone(),
1781 input: input.clone(),
1782 superstep,
1783 clock_ms: clock,
1784 });
1785 match &executor {
1786 NodeExecutor::Host { .. }
1787 | NodeExecutor::Subgraph { .. }
1788 | NodeExecutor::MemoryRead { .. }
1789 | NodeExecutor::Decide { .. } => {
1790 st.node_state[i] = NodeState::Dispatched;
1791 out.push(Command::Dispatch { key, executor, input });
1792 }
1793 NodeExecutor::Client { tool_name, approval, .. } => {
1794 st.node_state[i] = NodeState::AwaitingClient;
1795 let ask = Ask {
1796 tool_call_id: key.tool_call_id(),
1797 node: env.plan.nodes[i].clone(),
1798 tool_name: tool_name.clone(),
1799 input,
1800 expires_at_sec: env
1801 .ask_ttl_sec
1802 .map(|ttl| (st.clock_ms / 1000) as i64 + ttl),
1803 approval: *approval,
1812 };
1813 st.pending_asks
1814 .insert(ask.tool_call_id.clone(), PendingAsk { key, node_idx: i, ask });
1815 }
1816 NodeExecutor::Abstract { .. } => unreachable!("handled above"),
1817 }
1818}
1819
1820fn fold_range(messages: &[Value]) -> Option<(usize, usize)> {
1839 let from = 1;
1840 let mut to = messages.len().saturating_sub(KEEP_TAIL);
1841 if to <= from {
1842 return None;
1843 }
1844 while to < messages.len() && messages[to].get("role").and_then(Value::as_str) == Some("tool") {
1847 to += 1;
1848 }
1849 if to <= from || to > messages.len() {
1850 return None;
1851 }
1852 Some((from, to))
1853}
1854
1855fn emit_fold_turn(
1864 env: &StepEnv<'_>,
1865 st: &mut SchedulerState,
1866 i: usize,
1867 path: &str,
1868 from: usize,
1869 to: usize,
1870 out: &mut Vec<Command>,
1871) {
1872 let flow_id = flow_key(i, path);
1873 let Some(flow) = st.abstract_flows.get_mut(&flow_id) else { return };
1874 if flow.next_effect_seq >= env.max_effects_per_attempt {
1875 fail_abstract(env, st, i, path, "llm loop exceeded max_effects_per_attempt");
1876 return;
1877 }
1878 let effect_seq = flow.next_effect_seq;
1879 flow.next_effect_seq += 1;
1880 flow.need = None;
1881 let mut messages: Vec<Value> = flow.messages[from..to].to_vec();
1882 messages.push(serde_json::json!({ "role": "user", "content": FOLD_PROMPT }));
1883 flow.folding = Some(crate::state::FoldInFlight { from, to, effect_seq });
1884 flow.fold_forced = false;
1887 let key = JournalKey {
1888 run_id: st.run_id.clone(),
1889 task_path: path.to_string(),
1890 node: env.plan.nodes[i].clone(),
1891 attempt: flow_attempt(st, i, path),
1892 effect_seq,
1893 kind: EffectKind::Llm,
1894 };
1895 let mut input = serde_json::json!({
1896 "messages": messages,
1897 "fold": { "from": from, "to": to, "seq": effect_seq, "prompt_v": FOLD_PROMPT_V },
1898 });
1899 if let Some(block) = request_block(env, i, true, "") {
1900 input["llm"] = block;
1901 }
1902 emit_effect(env, st, i, path, key, input, out);
1903}
1904
1905fn dispatch_llm_turn(
1910 env: &StepEnv<'_>,
1911 st: &mut SchedulerState,
1912 i: usize,
1913 path: &str,
1914 out: &mut Vec<Command>,
1915) {
1916 let flow_id = flow_key(i, path);
1917 let reserve = reserve_for(env, i);
1918 if let Some(max) = env.budgets.max_tokens {
1919 let spent = st.spent.input_tokens + st.spent.output_tokens;
1920 if spent + reserve > max {
1921 st.exhausted = Some(crate::error::BudgetAxis::Tokens);
1922 if let Some(flow) = st.abstract_flows.get_mut(&flow_id) {
1925 flow.need = Some(crate::state::FlowNeed::NextTurn);
1926 }
1927 return;
1928 }
1929 }
1930 {
1938 let flow_now = st.abstract_flows.get(&flow_id);
1939 let projected = flow_now
1940 .map(|f| f.last_prompt_tokens)
1941 .unwrap_or(0)
1942 .saturating_add(reserve);
1943 let idle = flow_now.is_some_and(|f| f.folding.is_none());
1944 let forced = flow_now.is_some_and(|f| f.fold_forced);
1947 let over_ceiling = env.llm_context_tokens.is_some_and(|c| projected > c);
1948 if idle && (forced || over_ceiling) {
1949 match st.abstract_flows.get(&flow_id).and_then(|f| fold_range(&f.messages)) {
1950 Some((from, to)) => {
1951 let tried = st.abstract_flows.get(&flow_id).is_some_and(|f| f.decide_fold_tried);
1957 if !tried && emit_fold_decide(env, st, i, path, from, to, out) {
1958 return;
1959 }
1960 emit_fold_turn(env, st, i, path, from, to, out);
1961 return;
1962 }
1963 None => {
1967 let tokens = st
1968 .abstract_flows
1969 .get(&flow_id)
1970 .map(|f| f.last_prompt_tokens)
1971 .unwrap_or(0);
1972 let detail = RunError::ContextExceeded {
1973 node: env.plan.nodes[i].clone(),
1974 tokens,
1975 ceiling: env.llm_context_tokens,
1976 }
1977 .to_string();
1978 fail_abstract_coded(env, st, i, path, &detail);
1979 return;
1980 }
1981 }
1982 }
1983 }
1984 let Some(flow) = st.abstract_flows.get_mut(&flow_id) else { return };
1985 if flow.next_effect_seq >= env.max_effects_per_attempt {
1986 fail_abstract(env, st, i, path, "llm loop exceeded max_effects_per_attempt");
1987 return;
1988 }
1989 if flow.decide_fold_tried && flow.last_prompt_tokens > 0 {
1993 flow.decide_fold_tried = false;
1994 }
1995 let effect_seq = flow.next_effect_seq;
1996 flow.next_effect_seq += 1;
1997 flow.need = None;
1998 let messages = flow.messages.clone();
1999 let offer = flow.offer.clone();
2000 let key = JournalKey {
2001 run_id: st.run_id.clone(),
2002 task_path: path.to_string(),
2003 node: env.plan.nodes[i].clone(),
2004 attempt: flow_attempt(st, i, path),
2005 effect_seq,
2006 kind: EffectKind::Llm,
2007 };
2008 let mut input = serde_json::json!({ "messages": messages });
2009 if let Some(offer) = offer {
2014 input["offer"] = offer;
2015 }
2016 let instruction = messages
2020 .first()
2021 .and_then(|m| m.pointer("/content/instruction"))
2022 .and_then(Value::as_str)
2023 .unwrap_or_default();
2024 if let Some(block) = request_block(env, i, false, instruction) {
2025 input["llm"] = block;
2026 }
2027 emit_effect(env, st, i, path, key, input, out);
2028}
2029
2030fn dispatch_flow_tool(
2032 env: &StepEnv<'_>,
2033 st: &mut SchedulerState,
2034 i: usize,
2035 path: &str,
2036 call: crate::state::PendingToolCall,
2037 out: &mut Vec<Command>,
2038) {
2039 let flow_id = flow_key(i, path);
2040 if st.exhausted.is_some() {
2043 if let Some(flow) = st.abstract_flows.get_mut(&flow_id) {
2044 match &mut flow.need {
2045 Some(crate::state::FlowNeed::Tools(calls)) => calls.push(call),
2046 other => *other = Some(crate::state::FlowNeed::Tools(vec![call])),
2047 }
2048 }
2049 return;
2050 }
2051 if let Some(max) = env.budgets.max_tool_calls {
2056 if st.spent.tool_calls >= max {
2057 st.exhausted = Some(crate::error::BudgetAxis::ToolCalls);
2058 if let Some(flow) = st.abstract_flows.get_mut(&flow_id) {
2059 match &mut flow.need {
2060 Some(crate::state::FlowNeed::Tools(calls)) => calls.push(call),
2061 other => *other = Some(crate::state::FlowNeed::Tools(vec![call])),
2062 }
2063 }
2064 return;
2065 }
2066 }
2067 let Some(flow) = st.abstract_flows.get_mut(&flow_id) else { return };
2068 if flow.next_effect_seq >= env.max_effects_per_attempt {
2069 fail_abstract(env, st, i, path, "llm loop exceeded max_effects_per_attempt");
2070 return;
2071 }
2072 let NodeExecutor::Abstract { tools, .. } = &env.executors[i] else { return };
2076 let Some(tool) = tools.iter().find(|t| t.tool_name == call.tool_name).cloned() else {
2077 fail_abstract(
2078 env,
2079 st,
2080 i,
2081 path,
2082 &format!("offered tool '{}' missing from the manifest's executors", call.tool_name),
2083 );
2084 return;
2085 };
2086 let arguments = match validated_dispatch_input(env, &tool, &call.arguments) {
2094 Ok(input) => input,
2095 Err(why) => {
2096 fail_abstract(
2097 env,
2098 st,
2099 i,
2100 path,
2101 &format!("tool '{}' arguments no longer validate: {why}", call.tool_name),
2102 );
2103 return;
2104 }
2105 };
2106 let Some(flow) = st.abstract_flows.get_mut(&flow_id) else { return };
2107 let effect_seq = flow.next_effect_seq;
2108 flow.next_effect_seq += 1;
2109 flow.pending_tools.insert(effect_seq, call.clone());
2110 let key = JournalKey {
2111 run_id: st.run_id.clone(),
2112 task_path: path.to_string(),
2113 node: env.plan.nodes[i].clone(),
2114 attempt: flow_attempt(st, i, path),
2115 effect_seq,
2116 kind: EffectKind::Tool,
2117 };
2118 let host = NodeExecutor::Host {
2119 tool_hash: tool.tool_hash.clone(),
2120 tool_name: tool.tool_name.clone(),
2121 };
2122 if env.budgets.max_tool_calls.is_some() {
2125 st.spent.tool_calls += 1;
2126 }
2127 let clock = st.clock_ms;
2128 let superstep = st.superstep;
2129 if let Phase::Open { outstanding, .. } = &mut st.phase {
2130 outstanding.insert(key.clone());
2131 }
2132 out.push(Command::WriteIntent {
2133 key: key.clone(),
2134 executor: host.clone(),
2135 input: arguments.clone(),
2136 superstep,
2137 clock_ms: clock,
2138 });
2139 out.push(Command::Dispatch { key, executor: host, input: arguments });
2140}
2141
2142fn emit_effect(
2144 env: &StepEnv<'_>,
2145 st: &mut SchedulerState,
2146 i: usize,
2147 path: &str,
2148 key: JournalKey,
2149 input: Value,
2150 out: &mut Vec<Command>,
2151) {
2152 let executor = env.executors[i].clone();
2153 let clock = st.clock_ms;
2154 let superstep = st.superstep;
2155 if let Phase::Open { outstanding, record, .. } = &mut st.phase {
2156 outstanding.insert(key.clone());
2157 if key.effect_seq == 0 {
2158 if path.is_empty() {
2159 record.dispatched.push((i, key.attempt));
2160 } else {
2161 record.task_dispatched.push((path.to_string(), key.attempt));
2162 }
2163 }
2164 }
2165 out.push(Command::WriteIntent {
2166 key: key.clone(),
2167 executor: executor.clone(),
2168 input: input.clone(),
2169 superstep,
2170 clock_ms: clock,
2171 });
2172 out.push(Command::Dispatch { key, executor, input });
2173}
2174
2175fn narrowed_offer(flow: Option<&crate::state::AbstractFlow>, tools: &[OfferedTool]) -> Vec<OfferedTool> {
2196 let names: Option<Vec<&str>> = flow
2197 .and_then(|f| f.offer.as_ref())
2198 .and_then(|o| o.get("tools"))
2199 .and_then(Value::as_array)
2200 .map(|a| a.iter().filter_map(Value::as_str).collect());
2201 match names {
2202 Some(names) => tools.iter().filter(|t| names.contains(&t.tool_name.as_str())).cloned().collect(),
2203 None => tools.to_vec(),
2204 }
2205}
2206
2207fn decide_provenance(result: &Value) -> Value {
2210 serde_json::json!({
2211 "provider": result.get("provider").cloned().unwrap_or(Value::Null),
2212 "model": result.get("model").cloned().unwrap_or(Value::Null),
2213 "calibrated": result.get("calibrated").cloned().unwrap_or(Value::Null),
2214 "latency_ms": result.get("latency_ms").cloned().unwrap_or(Value::Null),
2215 })
2216}
2217
2218fn emit_decide_effect(
2225 env: &StepEnv<'_>,
2226 st: &mut SchedulerState,
2227 i: usize,
2228 path: &str,
2229 request: Value,
2230 in_flight: impl FnOnce(u32) -> DecideInFlight,
2231 out: &mut Vec<Command>,
2232) -> bool {
2233 if st.exhausted.is_some() || st.cancel.is_some() {
2234 return false;
2235 }
2236 let attempt = flow_attempt(st, i, path);
2237 let Some(flow) = st.abstract_flows.get_mut(&flow_key(i, path)) else { return false };
2238 if flow.next_effect_seq >= env.max_effects_per_attempt {
2239 return false;
2240 }
2241 let effect_seq = flow.next_effect_seq;
2242 flow.next_effect_seq += 1;
2243 flow.need = None;
2244 flow.deciding = Some(in_flight(effect_seq));
2245 let key = JournalKey {
2246 run_id: st.run_id.clone(),
2247 task_path: path.to_string(),
2248 node: env.plan.nodes[i].clone(),
2249 attempt,
2250 effect_seq,
2251 kind: EffectKind::Tool,
2252 };
2253 let executor = NodeExecutor::Decide { tool_hash: String::new(), tool_name: DECIDE_TOOL.into() };
2254 let input = serde_json::json!({ "decide": request });
2255 let (clock, superstep) = (st.clock_ms, st.superstep);
2256 if let Phase::Open { outstanding, record, .. } = &mut st.phase {
2257 outstanding.insert(key.clone());
2258 if effect_seq == 0 {
2259 if path.is_empty() {
2260 record.dispatched.push((i, attempt));
2261 } else {
2262 record.task_dispatched.push((path.to_string(), attempt));
2263 }
2264 }
2265 }
2266 out.push(Command::WriteIntent {
2267 key: key.clone(),
2268 executor: executor.clone(),
2269 input: input.clone(),
2270 superstep,
2271 clock_ms: clock,
2272 });
2273 out.push(Command::Dispatch { key, executor, input });
2274 true
2275}
2276
2277fn emit_offer_decide(
2281 env: &StepEnv<'_>,
2282 st: &mut SchedulerState,
2283 i: usize,
2284 path: &str,
2285 out: &mut Vec<Command>,
2286) -> bool {
2287 let Some(d) = env.decide.as_ref().filter(|d| d.calibrated) else { return false };
2288 let NodeExecutor::Abstract { tools, .. } = &env.executors[i] else { return false };
2289 let Some(top_k) = narrow_top_k(env, i) else { return false };
2292 if tools.len() <= top_k || tools.len() > 255 {
2295 return false;
2296 }
2297 let Some(flow) = st.abstract_flows.get(&flow_key(i, path)) else { return false };
2298 let first = flow.messages.first().and_then(|m| m.get("content")).cloned().unwrap_or(Value::Null);
2299 let describe = |t: &OfferedTool| {
2304 t.dynamic
2305 .as_ref()
2306 .map(|dt| dt.description.lines().next().unwrap_or_default().trim().to_string())
2307 .filter(|s| !s.is_empty())
2308 .or_else(|| d.tool_descriptions.get(&t.tool_name).filter(|s| !s.trim().is_empty()).cloned())
2309 .unwrap_or_else(|| t.tool_name.clone())
2310 };
2311 let criteria: serde_json::Map<String, Value> =
2312 tools.iter().map(|t| (t.tool_name.clone(), Value::String(describe(t)))).collect();
2313 let listed: Vec<Value> = tools
2314 .iter()
2315 .map(|t| serde_json::json!({ "name": t.tool_name, "description": describe(t) }))
2316 .collect();
2317 let request = serde_json::json!({
2318 "purpose": "offer",
2319 "v": FOLD_DECIDE_V,
2320 "state": {
2321 "plan": d.plan_label,
2322 "step": first.get("instruction").cloned().unwrap_or(Value::Null),
2323 "input": first.get("state").cloned().unwrap_or(Value::Null),
2324 "tools": listed,
2325 },
2326 "questions": {
2327 "tool": { "type": "choice", "instructions": OFFER_QUESTION, "criteria": criteria },
2328 },
2329 });
2330 emit_decide_effect(env, st, i, path, request, |effect_seq| DecideInFlight::Offer { effect_seq }, out)
2331}
2332
2333fn tool_entry_name(messages: &[Value], n: usize) -> Value {
2336 let id = messages[n].get("tool_call_id").and_then(Value::as_str).unwrap_or_default();
2337 messages[..n]
2338 .iter()
2339 .rev()
2340 .find(|m| m.get("role").and_then(Value::as_str) == Some("assistant"))
2341 .and_then(|a| a.get("tool_calls").and_then(Value::as_array))
2342 .and_then(|calls| {
2343 calls.iter().find(|c| c.get("id").and_then(Value::as_str) == Some(id))
2344 })
2345 .and_then(|c| c.get("name").cloned())
2346 .unwrap_or(Value::Null)
2347}
2348
2349fn content_text(m: &Value) -> String {
2351 match m.get("content") {
2352 Some(Value::String(s)) => s.clone(),
2353 Some(other) => other.to_string(),
2354 None => String::new(),
2355 }
2356}
2357
2358fn emit_fold_decide(
2362 env: &StepEnv<'_>,
2363 st: &mut SchedulerState,
2364 i: usize,
2365 path: &str,
2366 from: usize,
2367 to: usize,
2368 out: &mut Vec<Command>,
2369) -> bool {
2370 if !env.decide.as_ref().is_some_and(|d| d.calibrated) {
2371 return false;
2372 }
2373 let Some(flow) = st.abstract_flows.get(&flow_key(i, path)) else { return false };
2374 let messages = &flow.messages;
2375 let mut transcript = Vec::with_capacity(to.saturating_sub(from));
2376 let mut questions = serde_json::Map::new();
2377 for (n, m) in messages.iter().enumerate().take(to).skip(from) {
2378 match m.get("role").and_then(Value::as_str).unwrap_or_default() {
2379 "tool" => {
2380 let failed = m.get("is_error").and_then(Value::as_bool).unwrap_or(false);
2381 transcript.push(serde_json::json!({
2382 "n": n,
2383 "role": "tool",
2384 "tool": tool_entry_name(messages, n),
2385 "result": format!(
2386 "{}, {} chars (omitted)",
2387 if failed { "error" } else { "ok" },
2388 content_text(m).chars().count()
2389 ),
2390 }));
2391 questions.insert(
2392 format!("keep_call_{n}"),
2393 serde_json::json!({ "type": "noul", "instructions": KEEP_CALL_QUESTION }),
2394 );
2395 questions.insert(
2396 format!("keep_result_{n}"),
2397 serde_json::json!({ "type": "noul", "instructions": KEEP_RESULT_QUESTION }),
2398 );
2399 }
2400 "assistant" => transcript.push(serde_json::json!({
2401 "n": n,
2402 "role": "assistant",
2403 "text": m.get("text").cloned().unwrap_or(Value::Null),
2404 "tool_calls": m.get("tool_calls").cloned().unwrap_or(Value::Null),
2405 })),
2406 role => transcript.push(serde_json::json!({
2407 "n": n,
2408 "role": role,
2409 "content": m.get("content").cloned().unwrap_or(Value::Null),
2410 })),
2411 }
2412 }
2413 if questions.is_empty() {
2415 return false;
2416 }
2417 let task = messages
2418 .first()
2419 .and_then(|m| m.get("content"))
2420 .map(|c| c.get("instruction").cloned().unwrap_or_else(|| c.clone()))
2421 .unwrap_or(Value::Null);
2422 let request = serde_json::json!({
2423 "purpose": "fold",
2424 "v": FOLD_DECIDE_V,
2425 "from": from,
2426 "to": to,
2427 "state": { "task": task, "transcript": transcript },
2428 "questions": questions,
2429 });
2430 emit_decide_effect(
2431 env,
2432 st,
2433 i,
2434 path,
2435 request,
2436 |effect_seq| DecideInFlight::Fold { from, to, effect_seq },
2437 out,
2438 )
2439}
2440
2441fn handle_flow_decide(
2444 env: &StepEnv<'_>,
2445 st: &mut SchedulerState,
2446 i: usize,
2447 path: &str,
2448 outcome: &EffectOutcome,
2449) {
2450 let Some(asked) = st.abstract_flows.get_mut(&flow_key(i, path)).and_then(|f| f.deciding.take())
2451 else {
2452 return;
2453 };
2454 match asked {
2455 DecideInFlight::Offer { effect_seq } => {
2456 resolve_offer_decide(env, st, i, path, effect_seq, outcome)
2457 }
2458 DecideInFlight::Fold { from, to, effect_seq } => {
2459 resolve_fold_decide(env, st, i, path, from, to, effect_seq, outcome)
2460 }
2461 }
2462}
2463
2464fn narrow_top_k(env: &StepEnv<'_>, i: usize) -> Option<usize> {
2471 match llm_cfg(env, i) {
2472 Some(c) => c.narrow_top_k(OFFER_TOP_K),
2473 None => Some(OFFER_TOP_K),
2474 }
2475}
2476
2477fn narrow_offer(
2478 tools: &[OfferedTool],
2479 probs: &serde_json::Map<String, Value>,
2480 top_k: usize,
2481) -> Option<Vec<String>> {
2482 let mut ranked: Vec<(usize, f64)> = Vec::with_capacity(tools.len());
2483 for (idx, t) in tools.iter().enumerate() {
2484 let p = probs.get(&t.tool_name).and_then(Value::as_f64).filter(|p| p.is_finite())?;
2485 ranked.push((idx, p));
2486 }
2487 ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal).then(a.0.cmp(&b.0)));
2488 let keep: std::collections::BTreeSet<usize> = ranked
2489 .iter()
2490 .enumerate()
2491 .filter(|(rank, (_, p))| *rank < top_k || *p >= OFFER_KEEP_P)
2492 .map(|(_, (idx, _))| *idx)
2493 .collect();
2494 Some(keep.into_iter().map(|idx| tools[idx].tool_name.clone()).collect())
2495}
2496
2497fn resolve_offer_decide(
2498 env: &StepEnv<'_>,
2499 st: &mut SchedulerState,
2500 i: usize,
2501 path: &str,
2502 effect_seq: u32,
2503 outcome: &EffectOutcome,
2504) {
2505 let NodeExecutor::Abstract { tools, .. } = &env.executors[i] else { return };
2506 let narrowed = match outcome {
2510 EffectOutcome::Completed { result, .. }
2511 if result.get("calibrated") == Some(&Value::Bool(true)) =>
2512 {
2513 result
2514 .pointer("/answers/tool/probabilities")
2515 .and_then(Value::as_object)
2516 .and_then(|probs| narrow_offer(tools, probs, narrow_top_k(env, i).unwrap_or(OFFER_TOP_K)))
2517 .filter(|keep| !keep.is_empty() && keep.len() < tools.len())
2518 .map(|keep| {
2519 let mut record = decide_provenance(result);
2520 record["tools"] = serde_json::json!(keep);
2521 record["seq"] = serde_json::json!(effect_seq);
2522 record
2523 })
2524 }
2525 _ => None,
2526 };
2527 let Some(flow) = st.abstract_flows.get_mut(&flow_key(i, path)) else { return };
2528 flow.offer = narrowed;
2529 flow.need = Some(crate::state::FlowNeed::NextTurn);
2530}
2531
2532struct Prune {
2534 kept: Vec<usize>,
2535 truncated: Vec<usize>,
2536 dropped: Vec<usize>,
2537}
2538
2539fn plan_prune(messages: &[Value], from: usize, to: usize, result: &Value) -> Result<Prune, String> {
2552 if result.get("calibrated") != Some(&Value::Bool(true)) {
2553 return Err("the backend answered uncalibrated — an uncalibrated backend may reorder, \
2554 never omit"
2555 .into());
2556 }
2557 let answers = result.get("answers").ok_or("the decision carries no answers")?;
2558 let p = |id: &str| -> Result<f64, String> {
2559 answers
2560 .get(id)
2561 .and_then(|a| a.get("noul"))
2562 .and_then(Value::as_f64)
2563 .filter(|x| x.is_finite() && (0.0..=1.0).contains(x))
2564 .ok_or_else(|| format!("no valid answer for {id}"))
2565 };
2566 #[derive(Clone, Copy, PartialEq)]
2567 enum Verdict {
2568 Keep,
2569 Truncate,
2570 Drop,
2571 }
2572 type Round = (Option<usize>, Vec<(usize, Verdict)>);
2574 let mut rounds: Vec<Round> = Vec::new();
2575 let mut owner: Option<usize> = None;
2576 for (n, m) in messages.iter().enumerate().take(to).skip(from) {
2577 match m.get("role").and_then(Value::as_str).unwrap_or_default() {
2578 "assistant" => {
2579 owner = Some(n);
2580 rounds.push((owner, Vec::new()));
2581 }
2582 "tool" => {
2583 let (keep_call, keep_result) =
2584 (p(&format!("keep_call_{n}"))?, p(&format!("keep_result_{n}"))?);
2585 let v = if keep_result >= 0.5 {
2586 Verdict::Keep
2587 } else if keep_call >= 0.5 {
2588 Verdict::Truncate
2589 } else {
2590 Verdict::Drop
2591 };
2592 match rounds.last_mut() {
2593 Some((o, members)) if *o == owner && owner.is_some() => members.push((n, v)),
2594 _ => rounds.push((None, vec![(n, v)])),
2597 }
2598 }
2599 _ => owner = None,
2601 }
2602 }
2603 let (mut kept, mut truncated, mut dropped) = (Vec::new(), Vec::new(), Vec::new());
2604 for (own, members) in rounds {
2605 if members.is_empty() {
2606 continue;
2607 }
2608 let whole = own.is_some() && members.iter().all(|(_, v)| *v == Verdict::Drop);
2609 if whole {
2610 dropped.extend(own);
2611 dropped.extend(members.iter().map(|(n, _)| *n));
2612 continue;
2613 }
2614 for (n, v) in members {
2615 let short = content_text(&messages[n]).chars().count() <= FOLD_TRUNCATE_CHARS;
2618 if v == Verdict::Keep || short {
2619 kept.push(n);
2620 } else {
2621 truncated.push(n);
2622 }
2623 }
2624 }
2625 if truncated.is_empty() && dropped.is_empty() {
2626 return Err("the decision kept every entry verbatim".into());
2627 }
2628 kept.sort_unstable();
2629 truncated.sort_unstable();
2630 dropped.sort_unstable();
2631 Ok(Prune { kept, truncated, dropped })
2632}
2633
2634#[allow(clippy::too_many_arguments)]
2635fn resolve_fold_decide(
2636 env: &StepEnv<'_>,
2637 st: &mut SchedulerState,
2638 i: usize,
2639 path: &str,
2640 from: usize,
2641 to: usize,
2642 effect_seq: u32,
2643 outcome: &EffectOutcome,
2644) {
2645 let flow_id = flow_key(i, path);
2646 let attempt = flow_attempt(st, i, path);
2647 let Some(flow) = st.abstract_flows.get(&flow_id) else { return };
2648 let (plan, provenance) = match outcome {
2649 EffectOutcome::Completed { result, .. } => {
2650 let plan = if from < to && to <= flow.messages.len() {
2651 plan_prune(&flow.messages, from, to, result)
2652 } else {
2653 Err("the window no longer fits the transcript".into())
2654 };
2655 (plan, Some(decide_provenance(result)))
2656 }
2657 EffectOutcome::Failed { cause, detail, .. } => {
2658 (Err(format!("the decision failed ({cause:?}: {detail})")), None)
2659 }
2660 };
2661 let mut record = FoldRecord {
2662 kind: "decide".into(),
2663 v: FOLD_DECIDE_V,
2664 node: env.plan.nodes[i].clone(),
2665 task_path: path.to_string(),
2666 attempt,
2667 seq: effect_seq,
2668 from,
2669 to,
2670 provenance: provenance.clone(),
2671 ..FoldRecord::default()
2672 };
2673 let Some(flow) = st.abstract_flows.get_mut(&flow_id) else { return };
2674 flow.decide_fold_tried = true;
2677 flow.need = Some(crate::state::FlowNeed::NextTurn);
2678 match plan {
2679 Err(reason) => {
2680 record.reason = Some(reason);
2684 }
2685 Ok(prune) => {
2686 let n = flow.folds + 1;
2687 let who = provenance
2688 .as_ref()
2689 .map(|p| {
2690 format!(
2691 "{}/{}",
2692 p.get("provider").and_then(Value::as_str).unwrap_or("?"),
2693 p.get("model").and_then(Value::as_str).unwrap_or("?")
2694 )
2695 })
2696 .unwrap_or_default();
2697 let note = serde_json::json!({
2698 "role": "user",
2699 "content": format!(
2700 "[Areev fold {n} (decision): of transcript entries {from}..{to} of attempt \
2701 {attempt}, entries {:?} were dropped and {:?} truncated to \
2702 {FOLD_TRUNCATE_CHARS} characters by a decision model ({who}), journaled at \
2703 effect_seq {effect_seq}. The full record — every turn and every tool result \
2704 — is in this run's journal.]",
2705 prune.dropped, prune.truncated,
2706 ),
2707 });
2708 let mut next = Vec::with_capacity(flow.messages.len() + 1);
2709 for (idx, m) in flow.messages.iter().enumerate() {
2710 if idx == from {
2711 next.push(note.clone());
2712 }
2713 if prune.dropped.contains(&idx) {
2714 continue;
2715 }
2716 if prune.truncated.contains(&idx) {
2717 let text = content_text(m);
2718 let total = text.chars().count();
2719 let head: String = text.chars().take(FOLD_TRUNCATE_CHARS).collect();
2720 let mut cut = m.clone();
2721 cut["content"] = Value::String(format!(
2722 "{head}… [truncated to {FOLD_TRUNCATE_CHARS} of {total} characters by a \
2723 decision model; the full result is in this run's journal]"
2724 ));
2725 next.push(cut);
2726 } else {
2727 next.push(m.clone());
2728 }
2729 }
2730 flow.messages = next;
2731 flow.folds = n;
2732 flow.last_prompt_tokens = 0;
2735 flow.fold_forced = false;
2736 st.folds = st.folds.saturating_add(1);
2737 record.applied = true;
2738 record.kept = prune.kept;
2739 record.truncated = prune.truncated;
2740 record.dropped = prune.dropped;
2741 }
2742 }
2743 if let Phase::Open { record: rec, .. } = &mut st.phase {
2744 rec.folds.push(record);
2745 }
2746}
2747
2748fn fail_abstract(
2753 env: &StepEnv<'_>,
2754 st: &mut SchedulerState,
2755 i: usize,
2756 path: &str,
2757 detail: &str,
2758) {
2759 fail_abstract_coded(env, st, i, path, &format!("ExecutorError: {detail}"));
2760}
2761
2762fn fail_abstract_coded(
2768 env: &StepEnv<'_>,
2769 st: &mut SchedulerState,
2770 i: usize,
2771 path: &str,
2772 detail: &str,
2773) {
2774 st.abstract_flows.remove(&flow_key(i, path));
2775 if let Some(task) = st.send_tasks.get_mut(path) {
2776 task.state = crate::state::SendTaskState::Done;
2777 }
2778 st.node_state[i] = NodeState::DoneFailed;
2779 let named = st.failed.as_ref().map(|(n, _)| *n).unwrap_or(usize::MAX);
2780 if i < named {
2781 let at = if path.is_empty() { String::new() } else { format!(" (task {path})") };
2782 st.failed = Some((i, format!("{detail}{at}")));
2783 }
2784 let _ = env;
2785}
2786
2787fn park(st: &mut SchedulerState, out: &mut Vec<Command>) {
2790 let fresh: Vec<Ask> = st
2791 .pending_asks
2792 .values()
2793 .filter(|p| !st.announced_asks.contains(&p.ask.tool_call_id))
2794 .map(|p| p.ask.clone())
2795 .collect();
2796 if fresh.is_empty() {
2797 return;
2798 }
2799 for a in &fresh {
2800 st.announced_asks.insert(a.tool_call_id.clone());
2801 }
2802 if let Phase::Open { record, .. } = &st.phase {
2803 let mut record = record.clone();
2804 record.clock_close_ms = st.clock_ms;
2810 let state_json = serde_json::to_value(&*st).unwrap_or(Value::Null);
2811 out.push(Command::WriteCheckpoint {
2812 superstep: st.superstep,
2813 decision_record: record,
2814 state_json,
2815 });
2816 }
2817 out.push(Command::EmitEnvelope { asks: fresh });
2818}
2819
2820fn close_superstep(env: &StepEnv<'_>, st: &mut SchedulerState, out: &mut Vec<Command>) {
2823 let Phase::Open { mut results, mut send_results, mut record, .. } =
2824 std::mem::replace(&mut st.phase, Phase::Idle)
2825 else {
2826 return;
2827 };
2828
2829 if st.failed.is_none() && st.cancel.is_none() {
2835 let mut decisions: Vec<(String, usize, Vec<Value>)> = Vec::new();
2836 for (i, r) in results.iter_mut() {
2837 if let Some(list) = take_send(r) {
2838 decisions.push((String::new(), *i, list));
2839 }
2840 }
2841 let task_nodes: BTreeMap<String, usize> = st
2842 .send_tasks
2843 .iter()
2844 .map(|(p, t)| (p.clone(), t.node_idx))
2845 .collect();
2846 for (path, r) in send_results.iter_mut() {
2847 if let Some(list) = take_send(r) {
2848 decisions.push((path.clone(), task_nodes[path], list));
2849 }
2850 }
2851 apply_spawns(env, st, &mut record, decisions);
2852 }
2853
2854 let send_merges: Vec<(usize, Value)> = send_results
2860 .iter()
2861 .map(|(p, v)| (st.send_tasks[p].node_idx, v.clone()))
2862 .collect();
2863 for (node_idx, result) in results
2864 .iter()
2865 .map(|(i, v)| (*i, v.clone()))
2866 .chain(send_merges)
2867 {
2868 match result {
2869 Value::Object(map) => {
2870 for (k, v) in map {
2871 let prev = st.context.get(&k).cloned();
2872 let merged = (env.reduce)(&k, prev.as_ref(), &v);
2873 st.context[k.as_str()] = merged;
2874 }
2875 }
2876 Value::Null => {}
2877 other => {
2878 let key = env.plan.nodes[node_idx].clone();
2879 let prev = st.context.get(&key).cloned();
2880 let merged = (env.reduce)(&key, prev.as_ref(), &other);
2881 st.context[key.as_str()] = merged;
2882 }
2883 }
2884 }
2885
2886 if st.failed.is_none() && st.cancel.is_none() {
2891 for i in results.keys().copied().collect::<Vec<_>>() {
2892 for ei in env.plan.out_edges[i].clone() {
2893 let edge = &env.plan.edges[ei];
2894 let outcome = if edge.max_cycles.is_some_and(|m| st.edge_fired[ei] >= m) {
2895 EdgeOutcome::CycleExhausted
2896 } else {
2897 let fires = (edge.cond.is_none() && edge.cond_raw.is_none())
2900 || (env.eval_cond)(edge, &st.context);
2901 if fires { EdgeOutcome::Fired } else { EdgeOutcome::CondFalse }
2902 };
2903 if outcome == EdgeOutcome::Fired {
2904 st.edge_fired[ei] += 1;
2905 }
2906 let gen = deliver(env, st, ei, outcome == EdgeOutcome::Fired);
2907 record.edges.push((ei, gen, outcome));
2908 }
2909 }
2910 propagate_death(env, st, &mut record);
2911 }
2912
2913 record.clock_close_ms = st.clock_ms;
2914 if let Some(open) = st.wall_open.take() {
2921 st.spent.wall_ms += st.clock_ms.saturating_sub(open);
2922 }
2923 if let Some(paused) = st.paused_at.take() {
2924 st.elapsed_ms += st.clock_ms.saturating_sub(paused);
2925 }
2926 let state_json = serde_json::to_value(&*st).unwrap_or(Value::Null);
2927 out.push(Command::WriteCheckpoint {
2928 superstep: st.superstep,
2929 decision_record: record,
2930 state_json,
2931 });
2932}
2933
2934fn take_send(result: &mut Value) -> Option<Vec<Value>> {
2937 let obj = result.as_object_mut()?;
2938 let taken = obj.remove("$send")?;
2939 match taken {
2940 Value::Array(list) => Some(list),
2941 other => Some(vec![other]), }
2943}
2944
2945const MAX_SPAWNS: usize = 4096;
2949
2950fn apply_spawns(
2955 env: &StepEnv<'_>,
2956 st: &mut SchedulerState,
2957 record: &mut DecisionRecord,
2958 decisions: Vec<(String, usize, Vec<Value>)>,
2959) {
2960 let fail = |st: &mut SchedulerState, spawner: usize, why: String| {
2961 st.node_state[spawner] = NodeState::DoneFailed;
2962 let named = st.failed.as_ref().map(|(i, _)| *i).unwrap_or(usize::MAX);
2963 if spawner < named {
2964 st.failed = Some((spawner, format!("ExecutorError: {why}")));
2965 }
2966 };
2967 type ValidatedSpawn = (String, usize, Vec<(usize, Value)>);
2971 let mut validated: Vec<ValidatedSpawn> = Vec::new();
2972 for (parent, spawner, list) in decisions {
2973 if list.len() > MAX_SPAWNS {
2974 fail(st, spawner, format!("$send spawns {} tasks (cap {MAX_SPAWNS})", list.len()));
2975 return;
2976 }
2977 let mut entries = Vec::with_capacity(list.len());
2978 for e in &list {
2979 let Some(target_name) = e.get("node").and_then(|n| n.as_str()) else {
2980 fail(
2981 st,
2982 spawner,
2983 "$send entries must be {\"node\": \"<name>\", \"input\": …}".into(),
2984 );
2985 return;
2986 };
2987 let Some(target) = env.plan.nodes.iter().position(|n| n == target_name) else {
2988 fail(st, spawner, format!("$send targets unknown node '{target_name}'"));
2989 return;
2990 };
2991 if !matches!(
2995 env.executors[target],
2996 NodeExecutor::Host { .. }
2997 | NodeExecutor::Abstract { .. }
2998 | NodeExecutor::MemoryRead { .. }
2999 | NodeExecutor::Decide { .. }
3000 ) {
3001 fail(
3002 st,
3003 spawner,
3004 format!(
3005 "$send target '{target_name}' is not a Host tool, abstract node, memory \
3006 read or decision node"
3007 ),
3008 );
3009 return;
3010 }
3011 if target == spawner {
3012 fail(st, spawner, format!("$send target '{target_name}' is the spawner itself"));
3013 return;
3014 }
3015 let input = e.get("input").cloned().unwrap_or_else(|| serde_json::json!({}));
3016 entries.push((target, input));
3017 }
3018 validated.push((parent, spawner, entries));
3019 }
3020 for (parent, spawner, entries) in validated {
3021 for (target, input) in entries {
3022 let counter = st.spawn_counter.entry(parent.clone()).or_insert(0);
3023 let ordinal = *counter;
3024 if ordinal > 9999 {
3025 fail(st, spawner, format!(
3029 "spawn ordinal space exhausted under '{parent}' (9999 max per parent)"
3030 ));
3031 return;
3032 }
3033 *counter += 1;
3034 let path = format!("{parent}/{ordinal:04}");
3035 st.send_tasks.insert(
3036 path.clone(),
3037 crate::state::SendTask {
3038 node_idx: target,
3039 input,
3040 attempt: 0,
3041 state: crate::state::SendTaskState::Queued,
3042 },
3043 );
3044 st.node_state[target] = NodeState::Dispatched;
3045 record.spawns.push((path, target));
3046 }
3047 }
3048}
3049
3050fn deliver(env: &StepEnv<'_>, st: &mut SchedulerState, ei: usize, fired: bool) -> u32 {
3053 let dst = env.plan.edges[ei].dst;
3054 let resolved = matches!(
3055 st.node_state[dst],
3056 NodeState::DoneOk | NodeState::DoneFailed | NodeState::Dead
3057 );
3058 if resolved {
3059 if !fired {
3060 return st.node_gen[dst]; }
3062 st.node_gen[dst] += 1;
3063 st.node_state[dst] = NodeState::Waiting;
3064 st.attempt_base[dst] = st.attempt[dst];
3067 st.in_res[dst].clear();
3068 for &other in &env.plan.in_edges[dst] {
3069 if other == ei {
3070 continue;
3071 }
3072 let src = env.plan.edges[other].src;
3073 let src_unresolved = matches!(
3074 st.node_state[src],
3075 NodeState::Waiting
3076 | NodeState::Ready
3077 | NodeState::Dispatched
3078 | NodeState::AwaitingClient
3079 );
3080 if !src_unresolved {
3081 st.in_res[dst].insert(other, EdgeRes::Dead);
3082 }
3083 }
3084 st.in_res[dst].insert(ei, EdgeRes::Fired);
3085 } else {
3086 st.in_res[dst]
3087 .insert(ei, if fired { EdgeRes::Fired } else { EdgeRes::Dead });
3088 }
3089 refresh_readiness(env, st, dst);
3090 st.node_gen[dst]
3091}
3092
3093fn refresh_readiness(env: &StepEnv<'_>, st: &mut SchedulerState, i: usize) {
3110 if st.node_state[i] != NodeState::Waiting {
3111 return;
3112 }
3113 let ins = &env.plan.in_edges[i];
3114 let gen0 = st.node_gen[i] == 0;
3115 let all_gated_resolved = ins
3116 .iter()
3117 .all(|&e| (gen0 && env.plan.cycle_edge[e]) || st.in_res[i].contains_key(&e));
3118 if !all_gated_resolved {
3119 return;
3120 }
3121 let any_fired = ins.iter().any(|e| st.in_res[i].get(e) == Some(&EdgeRes::Fired));
3122 st.node_state[i] = if any_fired { NodeState::Ready } else { NodeState::Dead };
3123}
3124
3125fn propagate_death(env: &StepEnv<'_>, st: &mut SchedulerState, record: &mut DecisionRecord) {
3127 loop {
3128 let mut changed = false;
3129 for i in 0..env.plan.nodes.len() {
3130 if st.node_state[i] != NodeState::Dead {
3131 continue;
3132 }
3133 for &ei in &env.plan.out_edges[i] {
3134 let dst = env.plan.edges[ei].dst;
3135 if st.node_state[dst] == NodeState::Waiting
3136 && !st.in_res[dst].contains_key(&ei)
3137 {
3138 st.in_res[dst].insert(ei, EdgeRes::Dead);
3139 record.edges.push((ei, st.node_gen[dst], EdgeOutcome::DeadPath));
3140 refresh_readiness(env, st, dst);
3141 changed = true;
3142 }
3143 }
3144 }
3145 if !changed {
3146 return;
3147 }
3148 }
3149}
3150
3151fn stalled_culprit(env: &StepEnv<'_>, st: &SchedulerState) -> String {
3154 for i in 0..env.plan.nodes.len() {
3155 if st.node_state[i] == NodeState::DoneOk
3156 && !env.plan.out_edges[i].is_empty()
3157 && env.plan.out_edges[i].iter().all(|&ei| st.edge_fired[ei] == 0)
3158 {
3159 return env.plan.nodes[i].clone();
3160 }
3161 }
3162 for i in 0..env.plan.nodes.len() {
3167 if st.node_state[i] == NodeState::Waiting {
3168 return env.plan.nodes[i].clone();
3169 }
3170 }
3171 env.plan.nodes[0].clone()
3172}
3173
3174fn finish(st: &mut SchedulerState, out: &mut Vec<Command>, outcome: RunOutcome) {
3175 st.phase = Phase::Finished(outcome.clone());
3176 out.push(Command::Finish { outcome });
3177}
3178
3179#[cfg(test)]
3180mod tests {
3181 use super::*;
3182 use serde_json::json;
3183
3184 #[test]
3188 fn no_cap_passes_the_result_through_verbatim() {
3189 let big = json!({"rows": vec!["x"; 5_000]});
3190 assert_eq!(bound_tool_content(&big, None, 3, 7), big);
3191 }
3192
3193 #[test]
3196 fn the_cap_is_inclusive() {
3197 let at = Value::String("a".repeat(64));
3198 assert_eq!(bound_tool_content(&at, Some(64), 0, 0), at);
3199
3200 let over = Value::String("a".repeat(65));
3201 let bounded = bound_tool_content(&over, Some(64), 3, 7);
3202 assert_eq!(bounded["truncated"], true);
3203 assert_eq!(bounded["chars"], 65, "the TRUE length, not the kept length");
3204 assert_eq!(bounded["head"].as_str().unwrap().chars().count(), 32);
3205 assert_eq!(bounded["tail"].as_str().unwrap().chars().count(), 32);
3206 assert_eq!(bounded["journal"], json!({"attempt": 3, "effect_seq": 7}));
3210 }
3211
3212 #[test]
3216 fn a_multibyte_result_is_cut_on_character_boundaries() {
3217 let text: String = "日本語です".repeat(8);
3219 assert_eq!(text.chars().count(), 40);
3220 assert!(text.len() > 40, "the point of the case");
3221
3222 let bounded = bound_tool_content(&Value::String(text.clone()), Some(20), 1, 2);
3223 assert_eq!(bounded["chars"], 40);
3224 let head = bounded["head"].as_str().unwrap();
3225 let tail = bounded["tail"].as_str().unwrap();
3226 assert_eq!(head.chars().count(), 10);
3227 assert_eq!(tail.chars().count(), 10);
3228 assert!(text.starts_with(head), "the head is a real prefix");
3229 assert!(text.ends_with(tail), "the tail is a real suffix");
3230 }
3231
3232 #[test]
3235 fn a_json_result_is_bounded_as_its_serialized_form() {
3236 let big = json!({"k": "v".repeat(200)});
3237 let bounded = bound_tool_content(&big, Some(40), 0, 1);
3238 assert_eq!(bounded["chars"], big.to_string().chars().count());
3239 assert!(bounded["head"].as_str().unwrap().starts_with(r#"{"k":"#), "{bounded}");
3240 assert!(bounded["tail"].as_str().unwrap().ends_with(r#""}"#), "{bounded}");
3241 }
3242
3243 #[test]
3246 fn a_cap_of_zero_keeps_nothing_and_still_points_at_the_journal() {
3247 let bounded = bound_tool_content(&Value::String("abc".into()), Some(0), 9, 4);
3248 assert_eq!(bounded["chars"], 3);
3249 assert_eq!(bounded["head"], "");
3250 assert_eq!(bounded["tail"], "");
3251 assert_eq!(bounded["journal"]["effect_seq"], 4);
3252 }
3253
3254 #[test]
3257 fn a_state_selection_keeps_the_steering_inbox_whatever_it_lists() {
3258 let input = serde_json::json!({"a": 1, "b": 2, "$inbox": [{"text": "stop"}]});
3259 assert_eq!(select_state(None, &input), Some(input.clone()));
3260 assert_eq!(select_state(Some(&serde_json::json!("all")), &input), Some(input.clone()));
3261 assert_eq!(
3262 select_state(Some(&serde_json::json!(["b", "zz"])), &input),
3263 Some(serde_json::json!({"b": 2, "$inbox": [{"text": "stop"}]}))
3264 );
3265 assert_eq!(
3266 select_state(Some(&serde_json::json!("none")), &input),
3267 Some(serde_json::json!({"$inbox": [{"text": "stop"}]}))
3268 );
3269 assert_eq!(select_state(Some(&serde_json::json!("none")), &serde_json::json!({"a": 1})), None);
3270 }
3271
3272 #[test]
3273 fn a_response_name_is_what_every_provider_accepts() {
3274 assert_eq!(response_name("reply"), "reply");
3275 assert_eq!(response_name("ap.reply now"), "ap_reply_now");
3276 assert_eq!(response_name(&"n".repeat(80)).len(), 64);
3277 assert_eq!(response_name(""), "response");
3278 }
3279
3280 fn msg(role: &str) -> Value {
3281 json!({"role": role, "content": "x"})
3282 }
3283
3284 #[test]
3289 fn nothing_to_fold_below_the_kept_tail() {
3290 for n in 0..=KEEP_TAIL + 1 {
3291 let messages: Vec<Value> = (0..n).map(|_| msg("user")).collect();
3292 assert_eq!(fold_range(&messages), None, "len {n} must not be foldable");
3293 }
3294 }
3295
3296 #[test]
3299 fn the_nodes_input_is_never_folded() {
3300 let messages: Vec<Value> = (0..12).map(|_| msg("user")).collect();
3301 let (from, to) = fold_range(&messages).expect("a middle exists");
3302 assert_eq!(from, 1, "messages[0] is the node's own input");
3303 assert_eq!(to, 12 - KEEP_TAIL);
3304 }
3305
3306 #[test]
3311 fn the_cut_never_separates_a_tool_result_from_its_assistant_turn() {
3312 let messages = vec![
3315 msg("user"), msg("assistant"), msg("tool"), msg("user"), msg("assistant"), msg("tool"), msg("user"), msg("assistant"), msg("tool"), msg("tool"), msg("user"), msg("assistant"), ];
3328 let (from, to) = fold_range(&messages).expect("a middle exists");
3329 assert_eq!(from, 1);
3330 assert_eq!(to, 10, "advanced past both tool entries");
3331 assert_ne!(
3332 messages[to].get("role").and_then(Value::as_str),
3333 Some("tool"),
3334 "the entry AFTER the fold must never be an orphaned tool result"
3335 );
3336 }
3337
3338 #[test]
3345 fn a_round_that_fills_the_tail_folds_entirely_rather_than_orphaning_it() {
3346 let mut messages = vec![msg("user"), msg("assistant")];
3347 messages.extend((0..8).map(|_| msg("tool")));
3348 assert_eq!(fold_range(&messages), Some((1, 10)));
3349 }
3350}