Skip to main content

areev_run_core/
step.rs

1//! The sans-IO step function (governed-agents §4): apply the driver's
2//! events, progress the BSP machine as far as pure reasoning allows, return
3//! the commands the driver must perform. Same plan + same env + same events
4//! in the same order ⇒ byte-identical state and commands — that is the
5//! property the DST harness and the three CI gates exercise.
6//!
7//! Operational semantics (v1, refining §6.2's generation model into a
8//! per-node re-entry form with the same observable contract):
9//!
10//! - **Superstep**: open (record the journaled clock; check every budget
11//!   axis) → dispatch every Ready node in canonical order → collect ALL
12//!   results (retries included) → merge results through the reducers in
13//!   canonical order → evaluate completed nodes' out-edges in canonical
14//!   order, journaling every outcome → deliver firings → checkpoint → close
15//!   (record the clock again; wall charges close − open, so a three-day
16//!   HITL park costs nothing).
17//! - **Re-entry**: an edge firing into a node already resolved at its
18//!   current generation increments the node's generation and resets it to
19//!   Waiting: the firing edge is Fired at the new generation; every other
20//!   in-edge is Pending if its source is still unresolved (it may yet fire)
21//!   or Dead otherwise. This is how a bounded cycle iterates — the Rev-2
22//!   edge machine, whose states were all terminal, could not.
23//! - **Death**: a Waiting node whose in-edges all resolved with none Fired
24//!   is Dead; its out-edges propagate Dead in canonical order — a join
25//!   below an untaken branch (or a died-out cycle) resolves instead of
26//!   pending forever.
27//! - **Fail-fast** (v1's only failure policy): a node that exhausts its
28//!   retries marks the run draining — no retries elsewhere are suppressed
29//!   (each node's retry decision is independent, or determinism under
30//!   permutation breaks), but no edges evaluate at close and no new
31//!   superstep opens; the run finishes `Failed` naming the LOWEST-index
32//!   failed node, chosen at close, not at arrival.
33//! - **Cancel**: drain; retries stop; parked Client asks are abandoned (the
34//!   journal keeps their grains); finish `Canceled`.
35//! - **Pause** (#344): `EventIn::PauseRequested` in a batch stops that call
36//!   from OPENING a superstep — nothing else. The open one finishes normally
37//!   and closes into an ordinary `Idle` checkpoint; terminal outcomes (cancel
38//!   included) still win. Nothing is stored, so the checkpoint is the one an
39//!   uninterrupted run writes, and resuming is `EventIn::Resumed`.
40//! - **Budgets** (§6.7): every axis is checked at superstep open, before
41//!   any dispatch. Per-dispatch reservation refines this in Wave 2, where
42//!   LLM effects carry a mandatory `max_tokens` reserve to check — a v1
43//!   Host tool has no reserve quantity, so open-time is the honest v1
44//!   enforcement point, with overshoot bounded by one superstep's
45//!   dispatches (stated, not hidden).
46
47use 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
61/// Effects one node attempt may spend when the host pins no other number.
62/// Named because three places must agree — both of the driver's `StepEnv`
63/// sites and the manifest accessor that feeds them — and because changing it
64/// changes the behaviour of every existing plan that relies on the bound.
65pub const DEFAULT_MAX_EFFECTS_PER_ATTEMPT: u32 = 16;
66
67/// Transcript entries a fold keeps at the END, beyond `messages[0]`.
68///
69/// Four is two complete rounds at one tool call per turn, which is the shortest
70/// tail that still shows the model what it just did and what came back. It is
71/// expressed in entries rather than "rounds" because a round has no fixed
72/// length — a turn may issue three tool calls or none — and a count of entries
73/// is something `fold_range` can check without parsing the transcript's shape.
74/// The cut then moves forward off any `tool` entry, which is what actually
75/// keeps rounds intact.
76pub const KEEP_TAIL: usize = 4;
77
78/// The instruction a summarizer turn carries.
79///
80/// This text RIDES THE JOURNAL: it is part of the fold intent's `input`, so a
81/// stored run records the exact prompt its summary was produced from. Changing
82/// it therefore changes what a journal contains — bump [`FOLD_PROMPT_V`] and
83/// say so in the changelog rather than editing it quietly.
84pub 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
90/// The version of [`FOLD_PROMPT`], journaled beside it so a reader of an old
91/// run knows which wording produced its summaries.
92pub const FOLD_PROMPT_V: u32 = 1;
93
94/// Version of the decision-guided fold's questions (C2) and of the
95/// narrowing question (C1). Their wording rides the journal inside each
96/// decision effect's input, so a change to it bumps this — the
97/// [`FOLD_PROMPT_V`] convention. The summarizer fold's own prompt did not
98/// change when these arrived, so `FOLD_PROMPT_V` did not move.
99pub const FOLD_DECIDE_V: u32 = 1;
100
101/// The `keep_call_<n>` question of a decision-guided fold (C2).
102pub const KEEP_CALL_QUESTION: &str = "Does the fact that this tool call happened still \
103matter for finishing the task?";
104
105/// The `keep_result_<n>` question of a decision-guided fold (C2).
106pub const KEEP_RESULT_QUESTION: &str = "Are this tool result's contents still needed \
107verbatim to finish the task?";
108
109/// The narrowing question (C1).
110pub 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
113/// A decision-guided fold keeps a truncated result's first this-many
114/// CHARACTERS (not tokens — the [`bound_tool_content`] honesty rule).
115pub const FOLD_TRUNCATE_CHARS: usize = 300;
116
117/// A narrowed offer (C1) keeps the top this-many tools by probability …
118pub const OFFER_TOP_K: usize = 8;
119
120/// … plus every tool at or above this probability. Narrowing is only ever
121/// asked when MORE than [`OFFER_TOP_K`] tools are pinned.
122pub const OFFER_KEEP_P: f64 = 0.05;
123
124/// What the scheduler may ask a decision backend, as FROZEN in the run
125/// manifest (`docs/decision-model-proposal.md` C1/C2). Pure data: the
126/// scheduler never calls a backend — it emits a decision EFFECT the driver
127/// answers and journals, the same shape every other effect has.
128pub struct DecideEnv<'a> {
129    /// The pinned backend's `calibrated()`. The scheduler's two uses of a
130    /// decision both OMIT something (tools from an offer, entries from a
131    /// prompt), and rule 2 of the proposal is that an uncalibrated backend may
132    /// reorder but never omit — so with `false` nothing is asked at all.
133    pub calibrated: bool,
134    /// The plan's human label (`name`, and its goal/description when it has
135    /// one) — narrowing context (C1).
136    pub plan_label: Option<&'a str>,
137    /// Pinned tool name → its one-line description — narrowing context (C1).
138    /// A pure function of the manifest's pinned Definitions (immutable by
139    /// hash), so live and verify build the same table.
140    pub tool_descriptions: &'a BTreeMap<String, String>,
141}
142
143/// The injected pure behavior. Everything here is REQUIRED to be pure —
144/// enforced by the journaled decision record + replay assertion, not trust.
145pub struct StepEnv<'a> {
146    pub plan: &'a PlanGraph,
147    /// Per node: the resolved executor (the driver's V7 freeze).
148    pub executors: &'a [NodeExecutor],
149    pub budgets: Budgets,
150    /// Merge one result key into the accumulated state:
151    /// `(key, prev, new) -> merged`. The driver's registry with LWW
152    /// fallback; must be pure, deterministic, batching-invariant (§6.5).
153    pub reduce: &'a dyn Fn(&str, Option<&Value>, &Value) -> Value,
154    /// Evaluate an edge's condition against the state — the built-in
155    /// (`cond::eval` over the parsed form) or a host evaluator over the raw
156    /// string. Must be pure; only consulted for edges that carry a cond.
157    pub eval_cond: &'a dyn Fn(&crate::plan::PlanEdge, &Value) -> bool,
158    /// TTL for Client asks (seconds); None = asks never expire.
159    pub ask_ttl_sec: Option<i64>,
160    /// Validate a model-issued tool call's arguments against the pinned
161    /// Definition's `input_schema` (§6.11 — the driver wires
162    /// `validate_instance` over strict definitions; Ok(()) for non-strict).
163    /// Must be pure: it runs identically on replay.
164    pub validate_args: &'a dyn Fn(&str, &Value) -> std::result::Result<(), String>,
165    /// The per-call token reservation for LLM dispatches (§6.7's
166    /// per-dispatch enforcement: `spent + reserve` must fit before an LLM
167    /// effect is emitted). The driver sets this to the request's mandatory
168    /// `max_tokens`.
169    pub llm_reserve_tokens: u64,
170    /// Hard bound on effects per node attempt — an abstract node's LLM loop
171    /// (turns + tool calls + re-prompts) that exceeds it fails the node
172    /// with `ExecutorError` rather than looping forever. Frozen in the run
173    /// manifest, so a resume bounds the loop exactly as the start did;
174    /// [`DEFAULT_MAX_EFFECTS_PER_ATTEMPT`] when the manifest pins none.
175    pub max_effects_per_attempt: u32,
176    /// Bound on ONE tool result's size in the TRANSCRIPT (characters — there
177    /// is no tokenizer here, and calling it tokens would be a lie). `None` =
178    /// unbounded, which is what every run did before the knob existed. The
179    /// journal is never bounded: see [`bound_tool_content`].
180    pub llm_tool_result_chars: Option<usize>,
181    /// Ceiling on the WHOLE transcript, in the provider's own prompt tokens
182    /// (`AbstractFlow.last_prompt_tokens`). `None` = no ceiling, which is what
183    /// every run did before the fold existed. Reaching it emits one journaled
184    /// summarizer turn and splices its result over the folded range; failing
185    /// to find a foldable range is `RUN-E024`.
186    pub llm_context_tokens: Option<u64>,
187    /// The decision backend the manifest pinned, if any (C1/C2). `None` —
188    /// every run before decisions existed, and every host without one —
189    /// asks nothing and builds exactly the commands it always did.
190    pub decide: Option<DecideEnv<'a>>,
191}
192
193/// One step's output.
194pub struct StepOutcome {
195    pub commands: Vec<Command>,
196    pub state: SchedulerState,
197}
198
199/// Apply events, progress, emit commands. Driver contract: any call that
200/// may open or close a superstep includes a fresh `ClockReading`.
201pub 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    // A pause is a property of THIS call's batch, never of the state (#344):
207    // see `EventIn::PauseRequested`.
208    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                // The accumulated state is an object by construction; a
226                // non-object input is carried under "input" rather than
227                // refused (the driver validates and warns at its surface).
228                st.context = match input {
229                    Value::Object(_) => input.clone(),
230                    other => serde_json::json!({ "input": other }),
231                };
232                // Bootstrap exception: the entry node (OMS §8.4: nodes[0])
233                // activates unconditionally at generation 0, even if a
234                // back-edge targets it.
235                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            // Mark where the run stopped, so the coming open can measure the
250            // gap. The checkpoint's own clock IS that point: it is the last
251            // reading the previous close journaled. Only meaningful between
252            // supersteps — a park already set `paused_at` at the park, and
253            // overwriting it here would erase the parked span.
254            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        // Read by `step` itself, over the whole batch; nothing to apply.
263        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                // Unknown/settled asks are validated, journaled, and
285                // refused by the driver (§6.6) before ever reaching here.
286                return;
287            };
288            // Deliberately NO wall/elapsed bookkeeping here: the reading in
289            // effect at response-apply is the driver's resume time, which is
290            // NOT journaled — accruing from it would make checkpoint state a
291            // function of when the operator typed `resume`, and verify could
292            // never reproduce it. Both accruals happen at superstep close,
293            // from the journaled close reading (see `close_superstep`); the
294            // park→close tail is never charged as wall (it is µs of
295            // bookkeeping), and elapsed = park→close, both ends journaled.
296            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
303/// Book a resolved effect: buffer the result, or retry / mark failure per
304/// the §6.3 table. Retries are decided PER NODE, independent of other
305/// nodes' failures — otherwise whether a retry happens would depend on
306/// which failure arrived first (permutation-variant).
307fn 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; // Late/duplicate resolution of a closed superstep.
319        };
320        if !outstanding.remove(key) {
321            return; // Duplicate delivery for this key: first-commit won (§6.6).
322        }
323    }
324    // Accounting is per-effect, whatever the effect serves (§6.7: every
325    // figure is a pure function of the journal).
326    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; // intent + result supersession
335            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    // Abstract-flow effects continue the LOOP rather than resolving what
347    // owns it — a node, or one Send task against an abstract target.
348    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    // Send-task effects resolve the TASK, never the target node directly —
382    // the node completes when its batch drains.
383    if !key.task_path.is_empty() {
384        handle_send_task_outcome(env, st, key, outcome);
385        return;
386    }
387    // A resolved node's straggler (a flow-tool result landing after
388    // `fail_abstract` tore its flow down mid-round): accounted above, but it
389    // must not flip a DoneFailed node back to DoneOk with a raw tool result.
390    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                // Flip to Ready inside the open superstep; `progress`
425                // dispatches the next attempt.
426                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
438/// The `abstract_flows` key. A node's own loop keys on its index alone, so
439/// the serialized shape is exactly what it was before Send could target an
440/// abstract node; a task's loop qualifies that with its path.
441pub 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
456/// Open a fresh LLM loop for a node or one Send task against an abstract
457/// node, and emit its first turn.
458fn start_flow(env: &StepEnv<'_>, st: &mut SchedulerState, i: usize, path: &str, input: Value, out: &mut Vec<Command>) {
459    // The task, as the node's `llm` block shapes it (#391). A pointer that
460    // lands on nothing fails the node here, before any effect — the same
461    // state would fail the same way on every retry, so it is not one.
462    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    // C1: with a calibrated backend and more than OFFER_TOP_K pinned tools,
493    // narrow the offer before the first turn. Anything that prevents asking
494    // falls through to the full offer.
495    if emit_offer_decide(env, st, i, path, out) {
496        return;
497    }
498    dispatch_llm_turn(env, st, i, path, out);
499}
500
501// ---- the per-node `llm` block (#391, #392) -----------------------------------
502//
503// Everything here is a pure function of the frozen block and the node's input,
504// and every choice it makes that reaches the provider RIDES THE JOURNAL — the
505// first message in `messages[0]`, the request fields in each turn's `llm` —
506// so the driver builds the request from the journaled input alone and verify
507// needs nothing else. A node with no block takes none of these paths and
508// builds byte-identical commands and state.
509
510/// The node's frozen `llm` block, if it has one.
511fn 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
518/// The per-dispatch token reservation for node `i`'s turns: its own
519/// `max_tokens` when the block sets one (it is that turn's output ceiling, so
520/// it is what must fit), else the run's.
521fn 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
539/// `messages[0]`'s content: `{"instruction", "state"}`, shaped by the block.
540fn 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    // The tool list `tools_from` read is configuration, not state: under the
559    // whole-state default it would put every entry — and every value the host
560    // LOCKED away from the model — into the prompt. Removed from the copy the
561    // first message carries; a key list naming it is the plan's explicit ask.
562    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
579/// Remove the value at a JSON pointer (RFC 6901, `~0`/`~1` unescaped), if any.
580fn 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
599/// The merged state a node's first message carries. `"all"` (or no block):
600/// the whole input. `"none"`: nothing. A key list: those keys that are
601/// present, nothing else. A steering `$inbox` rides along in every case — an
602/// operator's message to the run is not state the plan may filter out.
603fn 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        // The manifest refused every other shape at start; a hand-edited one
627        // gets the whole state rather than a guess.
628        Some(_) => Some(input.clone()),
629    }
630}
631
632/// The wire name of a node's response schema: what OpenAI accepts
633/// (`^[a-zA-Z0-9_-]{1,64}$`), derived from the node id.
634fn 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
643/// The request fields a node's block sets, as journaled on each of its turns
644/// under `llm` — `response_format` already in the OpenAI-compatible wire
645/// shape, so the journal shows what was asked for as it went out. A fold's
646/// summarizer turn takes the node's system text, temperature and ceiling (a
647/// GPT-5 node's fold must not 400 on a temperature either) but neither the
648/// schema nor `parallel_tool_calls`: it answers in prose and is offered no
649/// tools. `None` when nothing is set, so such a turn is byte-identical to one
650/// from a node with no block.
651fn 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        // OpenAI's `json_object` mode refuses (HTTP 400) a request in which
656        // no message says "JSON". The built-in system prompt does; a node's
657        // own may not, and neither may its instruction — so say it once.
658        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
695/// Check a final answer against the node's `response` declaration: a JSON
696/// object, conforming to the schema when one is declared. The runtime checks
697/// whatever the provider did with `response_format`, because a provider that
698/// ignored the field must not hand prose through as the schema's object.
699/// The answer inside a Markdown code fence, when the whole text is one —
700/// ```` ```json … ``` ```` or ```` ``` … ``` ````. Anthropic models wrap a JSON
701/// answer that way even when told not to. Anything else is returned trimmed.
702fn 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    // The info string runs to the first newline (`json`, `JSON`, or nothing).
707    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
726/// Validate one model-issued call and build the input it dispatches with
727/// (#392, #393). A dynamic tool's arguments are checked against ITS schema,
728/// always — that is the contract the host declared for it. Then the locks are
729/// merged, and the strict-argument table judges the MERGED input under the
730/// Definition the call reaches, so a key the model was never shown (stripped
731/// from its schema because locked) is not reported missing.
732fn validated_dispatch_input(
733    env: &StepEnv<'_>,
734    tool: &OfferedTool,
735    arguments: &Value,
736) -> Result<Value, String> {
737    if let Some(d) = &tool.dynamic {
738        // The entry's schema is CLOSED (#392 review): a key it does not name
739        // could otherwise set a Definition parameter the host never offered
740        // — `method: "DELETE"` on an http-shaped Definition that locks only
741        // the URL. A key the host LOCKED is refused outright rather than
742        // silently overridden, so the model learns it cannot choose it.
743        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            // Closed only where the arguments land at the Definition's TOP
755            // LEVEL (no `args_into`): there an unnamed key is a Definition
756            // parameter the host never offered. Wrapped under `args_into`,
757            // the arguments are the remote tool's own, and the entry's schema
758            // speaks for itself — an MCP tool's `{"type": "object"}` takes
759            // any key, and only `additionalProperties: false` closes it.
760            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        // Against what the model was SHOWN — locked keys stripped — so a
770        // locked key the entry also lists as required is not demanded of it.
771        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    // The next ROUND of the same attempt, never the next attempt: the child
835    // run id is derived from the attempt, so bumping it here would forward
836    // the answered ask into a brand-new child. Holding `attempt` still also
837    // means a park costs no retry budget without touching `attempt_base`.
838    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
851/// A Send task's effect resolved: buffer the result (or retry / fail per
852/// the same §6.3 table nodes use), and complete the target node when its
853/// batch has drained — the join below a fan-out.
854fn 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    // A settled task's straggler — an abstract target's flow tool landing
862    // after `fail_abstract` tore the loop down. Accounted above, but it must
863    // not overwrite the task's contribution with a raw tool result the model
864    // never saw. The node-level guard below does the same job for nodes.
865    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
894/// One task settled with a result: buffer it, then complete the target node
895/// if its whole batch has drained — the join below a fan-out.
896fn 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
916/// Fold a model-supplied tool name back onto the **canonical** `tool_name` of
917/// the Definition it was offered as.
918///
919/// Anthropic and OpenAI forbid `.` in a tool name, so a pinned Definition
920/// called `receipt.prepare` reaches the model as `receipt_prepare`
921/// ([`normalize_tool_name`]) — and the model, correctly, calls back with what
922/// it was given. Without this fold every call to a dotted tool from inside an
923/// abstract node read as an unknown tool and failed the node after one
924/// corrective re-prompt the model could not possibly satisfy.
925///
926/// Exact match wins, so a plan whose offered set contains BOTH `a.b` and `a_b`
927/// still resolves `a_b` to the tool that is literally named that, and an
928/// ambiguous normalized match resolves to nothing rather than to a guess — the
929/// caller then treats it as unknown and re-prompts, which is the honest answer.
930/// A name matching neither form is returned unchanged, for the same reason: the
931/// unknown-tool path owns that decision, not this function.
932fn 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
943/// A model turn resolved: end the loop, request tools, or re-prompt.
944fn 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    // A summarizer turn resolves differently from a model turn: its result is
954    // not the node's answer, and any tool calls in it are ignored (it was
955    // offered none). Taken here so a retryable failure also clears the marker
956    // and `dispatch_llm_turn` re-derives the decision from the same transcript.
957    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    // The offer the model actually saw: the narrowed set when C1 narrowed it,
963    // else every pinned tool. A pinned tool the model was NOT shown is
964    // unknown to it — narrowing may only remove, never re-admit.
965    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            // Transient model failures retry the TURN (same transcript,
971            // next effect_seq) — idempotent and journal-clean; schema
972            // failures re-prompt with the error appended (§6.11). Both are
973            // bounded by max_effects_per_attempt.
974            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            // The provider itself said the prompt was too long. Retrying is
992            // pointless and failing is premature: fold, then re-send the same
993            // turn. `dispatch_llm_turn` re-derives everything from the
994            // (unchanged) transcript, and the effect cap bounds this exactly
995            // as it bounds every other loop in here — a node that cannot get
996            // under the limit runs out of effects rather than spinning.
997            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            // The transcript's size at the MODEL's own tokenizer, for the
1009            // context ceiling to measure on the next dispatch. The provider
1010            // reported it for the transcript exactly as sent, which is why the
1011            // runtime never estimates.
1012            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            // The provider's own content blocks for this turn (#284), carried
1017            // straight through: the scheduler never inspects them, it only
1018            // has to keep them so the adapter can echo the turn back
1019            // unchanged on the next dispatch. Absent for every provider that
1020            // has none, so the transcript is byte-identical to 1.8.5 there.
1021            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                            // The model answers with the name it was
1034                            // OFFERED, which for a dotted Definition is the
1035                            // normalized spelling. Canonicalize here, once,
1036                            // so the unknown-tool check, the argument
1037                            // validator and the dispatch below all see the
1038                            // Definition's own `tool_name`.
1039                            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            // Unknown tool names: ONE corrective re-prompt, then the node
1050            // fails `ExecutorError` (§6.11) — the model must choose among
1051            // what was offered, and a model that cannot is not looped on.
1052            // `canonical_tool_name` already folded the offered spelling back,
1053            // so what survives here matches NEITHER form.
1054            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                // The list names the tools the way the MODEL was offered
1078                // them: it has to emit one of these, and handing it a dotted
1079                // name its own provider forbids is a correction that cannot
1080                // be obeyed. Both spellings are accepted on the way back.
1081                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            // Argument validation (§6.11): unparseable argument payloads and
1095            // strict-schema violations re-prompt with the error appended —
1096            // consuming `effect_seq`, bounded by `max_effects_per_attempt`.
1097            // The check runs on the input the call will DISPATCH with —
1098            // locks merged (#393), a dynamic tool's own schema first (#392) —
1099            // so a strict Definition is judged on what it will receive.
1100            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                // The loop ends: the final text is the node's result — a
1126                // JSON object merges as one; anything else lands under the
1127                // node's own key.
1128                let final_text = text.unwrap_or_default();
1129                // Structured output (#391): the answer must be what the block
1130                // asked for. ONE corrective re-prompt, with the model's own
1131                // answer kept in the transcript so it can see what it said,
1132                // then the node fails — never a silent merge of prose.
1133                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                // A declared response was checked on the unfenced text, so it
1171                // merges from the same text; a node with no declaration merges
1172                // exactly what it always did.
1173                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                // What the model read, for whatever is bound below this node
1185                // (#391): every tool call of the attempt, off the journaled
1186                // outcomes. The runtime's key wins over one the model wrote.
1187                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            // Tool round: the assistant entry is journal-derived, so the
1204            // transcript is replay-stable.
1205            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
1224/// Bound ONE tool result for the TRANSCRIPT. The journal is untouched: the
1225/// driver wrote the full result grain before this event ever reached `step`,
1226/// so what is dropped here is dropped from scheduler state only and stays
1227/// addressable at `(run, task_path, node, attempt, effect_seq)` forever.
1228///
1229/// `cap` is in **characters**, and is named that because that is what it is.
1230/// This crate holds no tokenizer, and a "token" bound implemented as
1231/// `chars / 4` would be a guess wearing a precise name. It is also why this is
1232/// a separate bound from the context ceiling, which uses the provider's own
1233/// reported prompt tokens: one oversized entry is a different problem from a
1234/// transcript that grew, and no fold can shrink a single entry.
1235///
1236/// Under the cap the value passes through **byte-identically**, so a run with
1237/// no cap configured builds exactly the transcript it always did. Over it, the
1238/// model sees the head, the tail, the true length, and where the whole thing
1239/// lives. Pure and total: a function of the journaled outcome and the manifest,
1240/// so replay rebuilds the same entry.
1241pub 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    // A string result is measured as its text; anything else as the JSON the
1249    // model would have been shown.
1250    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
1268/// A summarizer turn resolved: splice its result over the range it stood in
1269/// for, and hand the loop back to an ordinary next turn.
1270///
1271/// The splice is an edit to **scheduler state only**. Every entry it removes
1272/// remains a journaled intent + result grain — nothing is deleted, nothing is
1273/// rewritten, and the fold message says where the record is. That is the root
1274/// `CLAUDE.md`'s immutability invariant applied to the runtime.
1275fn 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            // The range was computed against this very transcript and nothing
1291            // has touched it since — but a scheduler must not panic on a range
1292            // it cannot apply. Dropping the fold and taking an ordinary turn
1293            // re-derives the decision on the next dispatch.
1294            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            // The summarizer answered the trigger; a later one may try a
1311            // decision first again.
1312            flow.decide_fold_tried = false;
1313            // The spliced transcript has not been measured yet. Zero means "ask
1314            // the provider again", not "it is small" — the next real turn's
1315            // reported prompt tokens decide whether another fold is due.
1316            flow.last_prompt_tokens = 0;
1317            flow.need = Some(crate::state::FlowNeed::NextTurn);
1318            // Run-level too, once the flow borrow is done: this flow is gone
1319            // by the terminal checkpoint, and that is where the run-outcome
1320            // record is written from.
1321            st.folds = st.folds.saturating_add(1);
1322        }
1323        EffectOutcome::Failed { cause, detail, .. } => match cause {
1324            // Transient: retry the TURN, which re-derives the fold decision
1325            // from the unchanged transcript. Bounded by the effect cap like
1326            // every other retry in the loop, so it cannot spin.
1327            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            // Even the SUMMARIZER's own request can overflow — it carries the
1333            // range it is summarizing. Re-deriving the fold from the same
1334            // transcript would ask the identical question again, so this is
1335            // terminal: the range that has to shrink is the one thing a fold
1336            // cannot shrink for itself.
1337            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            // Terminal, including a schema failure: there is no schema on a
1351            // summarizer turn, so a corrective re-prompt would be theatre.
1352            FailCause::Unknown
1353            | FailCause::UserAborted
1354            | FailCause::SchemaValidationFailed => {
1355                fail_abstract(env, st, i, path, detail);
1356            }
1357        },
1358    }
1359}
1360
1361/// A model-issued tool call resolved. Tool failures inside an abstract loop
1362/// are MODEL-VISIBLE error results, never scheduler retries — the model
1363/// decides what to do about its own tool's failure.
1364fn 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    // Read before the flow is borrowed mutably — the attempt is what makes the
1373    // truncation's journal pointer resolvable.
1374    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        // `tool_results_into` (#391): the record a downstream gate reads. The
1390        // input is what was DISPATCHED (locks merged), the result is the WHOLE
1391        // journaled result — never the transcript's bounded excerpt — and the
1392        // entry is placed by `effect_seq`, so arrival order cannot reorder it.
1393        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        // Failure details are short by construction, but they carry a tool's
1429        // stderr — so the same rule applies rather than one honest path and one
1430        // hopeful one.
1431        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        // The round is settled: append results in effect_seq order —
1446        // canonical transcript order regardless of completion order.
1447        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
1471/// One pass over an open superstep. Returns true when the caller should
1472/// loop (state changed), false to yield for external events.
1473fn 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    // Abstract flows with settled needs: emit their next effects in
1477    // canonical node order (never emission-time order).
1478    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    // Retries (nodes flipped back to Ready inside the open step).
1501    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    // Send-task retries (tasks flipped back to Queued inside the open step).
1512    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            // A canceled run does not wait for humans: abandon the asks.
1539            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        // Pause the wall segment: parked time is elapsed, never wall.
1550        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
1559/// One pass over the idle phase. Returns true to keep looping.
1560fn 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    // Abstract flows whose next turn survived a reservation refusal: a
1592    // fork with raised budgets resumes exactly here (the pinned promise).
1593    let needy_flows = flow_owners(st);
1594    if ready.is_empty() && queued_tasks.is_empty() && needy_flows.is_empty() {
1595        // A step call before any Start is a driver protocol slip, not a
1596        // stall — yield; terminalizing would brick the run id.
1597        if st.superstep == 0 && st.node_state[0] == NodeState::Waiting {
1598            return false;
1599        }
1600        // Defensive: an in-flight node at Idle means a driver protocol bug,
1601        // not a stall — yield rather than misreport.
1602        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    // Budget pre-flight, every axis, before any dispatch (§6.7).
1624    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        // #295. The effects axis is derived, not counted: `journal_grains`
1635        // is an intent plus a result per settled effect, so halving it is
1636        // the count with no new state to diverge an existing checkpoint.
1637        (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    // A host pause (#344) holds HERE — after every terminal decision above,
1650    // before anything is opened. The state is left exactly as the last close
1651    // checkpointed it, so the parked checkpoint is the uninterrupted run's.
1652    if hold {
1653        return false;
1654    }
1655
1656    // Open the superstep.
1657    st.superstep += 1;
1658    st.spent.supersteps += 1;
1659    // A resume boundary: the span from the previous close to this open is
1660    // time the run did not exist. It accrues as `elapsed` and is NEVER
1661    // charged as wall — the same rule a park follows, applied to the gap a
1662    // crash leaves. Journaling the reading is what lets `verify` reproduce
1663    // it: the driver's own clock is not derivable from the journal.
1664    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
1702/// Dispatch one queued Send task: its own attempt counter, its own
1703/// task-scoped input, the target node's Host executor.
1704fn 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
1743/// Allocate the next attempt for a Ready node and emit its first effect —
1744/// a Host/Subgraph/MemoryRead dispatch, a Client ask, or an abstract node's
1745/// opening LLM turn.
1746fn 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    // Abstract nodes run as an LLM loop: fresh flow, then the first turn.
1751    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                // Read from the pinned executor (#294) rather than hard-coded
1804                // true. An approval boundary is still the default and still
1805                // what an unannotated Definition means; a `confirmation` is
1806                // an explicit, host-opted-in declaration that the run's own
1807                // initiator may answer this one — so a reversible write can
1808                // be self-confirmed in ONE run, with one journal and one
1809                // `verify`, instead of a prepare run plus a commit run and a
1810                // product-side ledger joining them.
1811                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
1820/// Which transcript entries a fold may replace: everything between the node's
1821/// input and the last [`KEEP_TAIL`] entries. `None` when that middle is empty —
1822/// there is nothing a summary could stand in for.
1823///
1824/// Two rules make the result safe to send:
1825///
1826/// - **`messages[0]` is never folded.** It is the node's instruction and its
1827///   input state — the only entry that says what the node is for. A summary of
1828///   the task cannot replace the task.
1829/// - **The cut never separates a `tool` result from the `assistant` entry that
1830///   issued it.** Providers reject a tool result with no preceding tool call,
1831///   so the cut moves FORWARD past any leading `tool` entries, shrinking the
1832///   kept tail rather than splitting a round. Moving it backward would instead
1833///   fold the assistant entry and orphan the results.
1834///
1835/// Pure, and only ever called at a `NextTurn` boundary (`dispatch_llm_turn`
1836/// runs with `pending_tools` empty by construction), so a fold cannot land
1837/// mid-round.
1838fn 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    // Keep the round intact: a `tool` entry at the cut belongs to an assistant
1845    // entry inside the folded range, so advance until the boundary is not one.
1846    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
1855/// Emit the summarizer turn for `from..to` of node `i`'s transcript.
1856///
1857/// An ordinary [`EffectKind::Llm`] effect in every respect that matters: it
1858/// consumes an `effect_seq`, spends the per-call reservation, and is journaled
1859/// as intent + result. That is the whole design — a fold is one more journaled
1860/// turn, so `verify` answers it from the journal like any other and never calls
1861/// the model. The `fold` key in its input is what tells the driver to offer the
1862/// summarizer NO tools, and what makes the decision visible in `run inspect`.
1863fn 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    // Consumed: the refusal has been answered with a fold. A further refusal
1885    // sets it again, and the effect cap is what bounds that.
1886    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
1905/// Emit the next LLM turn of node `i`'s abstract flow, with the §6.7
1906/// per-dispatch token reservation: `spent + reserve` must fit BEFORE the
1907/// effect is emitted — the refinement pre-flight-only checking could not
1908/// give, now that LLM effects carry a reservable `max_tokens`.
1909fn 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            // The un-dispatched turn survives as a need, so a resume with a
1923            // raised ceiling picks the loop up exactly here.
1924            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    // The context ceiling, checked AFTER the budget reservation and BEFORE the
1931    // effect cap: a fold spends an effect like any turn, so it must not be the
1932    // thing that pushes the node over its count without the count having been
1933    // checked. Measured on the PROVIDER's reported prompt tokens for the last
1934    // completed turn — never on an estimate — plus the output we are about to
1935    // reserve, because a prompt that fits and a reply that does not is the
1936    // same rejection.
1937    {
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        // Two independent reasons to fold. The ceiling is a prediction and can
1945        // be unset; the provider's refusal is a fact and always wins.
1946        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                    // C2: a calibrated decision backend gets the first look at
1952                    // the window — once per trigger. Anything that stops it
1953                    // (none pinned, uncalibrated, already tried, nothing to
1954                    // judge, the effect cap) is the summarizer fold,
1955                    // unchanged.
1956                    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                // Nothing foldable: the node's input plus the kept tail alone
1964                // exceed the ceiling. Another fold cannot help, and looping
1965                // would spend the effect budget summarizing summaries.
1966                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    // A decision-guided fold was tried and the transcript has since been
1990    // MEASURED under its ceiling (the check above did not fire on a real
1991    // count): a later trigger may ask a decision again.
1992    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    // C1: the narrowed offer rides the turn's journaled input. The driver
2010    // offers exactly these Definitions, keyed off the journal (as the fold's
2011    // no-tools rule is), so verify makes the identical choice. Absent when
2012    // nothing was narrowed, so such a turn is byte-identical to before.
2013    if let Some(offer) = offer {
2014        input["offer"] = offer;
2015    }
2016    // #391: the node's request fields ride the journaled input too, which is
2017    // how the driver builds the request from the journal alone — and how a
2018    // reader of the run sees the system text and response format as sent.
2019    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
2030/// Emit one model-issued tool call from node `i`'s flow.
2031fn 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    // Draining (exhausted mid-pass): preserve the call as a need instead of
2041    // emitting new work — state.rs pins "no new work is emitted".
2042    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    // Run-level tool-call ceiling (#295), checked per dispatch the way the
2052    // token reservation is. Exhaustion is a resumable BUDGET stop, never a
2053    // node failure: the undispatched call survives as the flow's `need`, so
2054    // `run fork` under a raised cap continues exactly here.
2055    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    // Resolve the offered tool BEFORE booking anything: a resolution set
2073    // that changed across a fork must fail the node loudly, not panic —
2074    // and never leave a booked pending tool with no outstanding key.
2075    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    // The input it DISPATCHES with (#392, #393): locks merged over the
2087    // model's arguments, a dynamic tool's wrapped first. Validated when the
2088    // turn resolved; re-derived here because only the model's own arguments
2089    // are kept in state (the transcript must show what the model said). A
2090    // failure now means the offer changed under the call — a fork that
2091    // migrated the plan — and fails the node loudly, as the missing-tool case
2092    // above does.
2093    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    // Counted only when a cap is set (#295), so a run with no cap keeps a
2123    // byte-identical `Spent` and `verify` of an existing run is untouched.
2124    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
2142/// Shared effect emission for abstract turns.
2143fn 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
2175// ---- decisions inside an abstract flow (C1, C2) ------------------------------
2176//
2177// Both are OPTIONAL looks a decision backend gets before the deterministic
2178// rule runs. The scheduler never calls a backend: it emits a decision EFFECT
2179// (`NodeExecutor::Decide`, `EffectKind::Tool`, the flow's next `effect_seq`)
2180// whose input carries the request, the driver answers it through the host's
2181// backend and journals it like any other effect, and the answer comes back as
2182// an ordinary `EffectResolved`. So `verify` answers a decision from the
2183// journal and never re-asks, a crash re-delivers it under the same key, and
2184// the only new thing replay has to reproduce is what the scheduler did with
2185// the answer — a pure function of it.
2186//
2187// The fail-open rule (proposal §2, rules 2 and 3) is structural here: every
2188// path that does not produce a CALIBRATED, well-formed answer resolves to
2189// exactly the deterministic behaviour the flow would have had with no backend
2190// at all — the full offer, the summarizer fold.
2191
2192/// The offer the model sees: every pinned tool, or — once C1 narrowed it —
2193/// only the names the flow's journaled `offer` lists. Manifest order either
2194/// way; narrowing only ever removes.
2195fn 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
2207/// `{provider, model, calibrated, latency_ms}` off a decision effect's result
2208/// (`Decision::to_json()`, as the driver journals it) — proposal rule 4.
2209fn 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
2218/// Emit one decision effect for node `i`'s flow and mark it in flight.
2219///
2220/// Returns `false`, emitting nothing, when the flow is gone, the run is
2221/// draining, or the next `effect_seq` would pass the effect cap. A decision is
2222/// optional: running out of effects for one must not fail the node — the
2223/// deterministic path that runs instead meets the cap on its own terms.
2224fn 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
2277/// C1: before an abstract flow's first turn, ask a calibrated backend which
2278/// of the pinned tools this step needs — only when more than
2279/// [`OFFER_TOP_K`] are pinned. `false` = not asked; the full offer stands.
2280fn 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    // The node's own rule (#396): `narrow: false` keeps its whole offer,
2290    // `narrow: {top_k}` moves the K.
2291    let Some(top_k) = narrow_top_k(env, i) else { return false };
2292    // A choice carries 2..=255 options; past that the question cannot be
2293    // asked, and the full offer is the honest answer.
2294    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    // A dynamic tool (#392) is described by its OWN pinned description, its
2300    // first line — exactly what a Definition's `tool_description` gives
2301    // (#396). Read off the offer rather than the run-wide table, because two
2302    // nodes may each declare a dynamic tool of one name.
2303    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
2333/// The tool a `tool` transcript entry answers, by the model's call id, read
2334/// off the nearest preceding assistant entry.
2335fn 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
2349/// A transcript entry's content as the text a model would have been shown.
2350fn 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
2358/// C2: before the summarizer fold, show a calibrated backend the foldable
2359/// window — every tool result replaced by a short note — and ask two `noul`
2360/// questions per result. `false` = not asked; the summarizer runs.
2361fn 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    // Nothing a decision could keep or drop: the summarizer's job.
2414    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
2441/// A decision effect inside a flow resolved: route it to what it was asked
2442/// for. Either way the loop continues with an ordinary next turn.
2443fn 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
2464/// C1's keep rule over a validated probability table: the top
2465/// [`OFFER_TOP_K`] by probability (ties by manifest order), plus every tool at
2466/// or above [`OFFER_KEEP_P`], returned in MANIFEST order. `None` when the
2467/// table does not cover every pinned tool with a finite probability — a
2468/// malformed answer narrows nothing.
2469/// The K node `i` narrows to, or `None` when its block says `narrow: false`.
2470fn 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    // Rule 2: only a CALIBRATED answer may omit a tool. Anything else — an
2507    // uncalibrated answer, a failure, a malformed table — keeps the full
2508    // offer, which is what the flow would have had with no backend.
2509    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
2532/// What a decision-guided fold will do to the window, per entry.
2533struct Prune {
2534    kept: Vec<usize>,
2535    truncated: Vec<usize>,
2536    dropped: Vec<usize>,
2537}
2538
2539/// The decision → edit plan (C2). Pure; `Err` names why nothing is applied.
2540///
2541/// Per tool-result entry `n`: `keep_result ≥ 0.5` stays verbatim;
2542/// `keep_call ≥ 0.5 > keep_result` keeps the call and truncates the result;
2543/// both below drops it. Then the never-separate rule, at ROUND granularity: a
2544/// round (an assistant entry and the results that answer it) leaves the prompt
2545/// only WHOLE — every result dropped, and the assistant entry with them. A
2546/// round with anything kept keeps every call, and its would-be-dropped
2547/// results are truncated instead. Editing an assistant entry's `tool_calls`
2548/// would desynchronize it from its `provider_content` (#284), which the
2549/// adapter replays verbatim — and a call with no result, or a result with no
2550/// call, is a request every provider refuses.
2551fn 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    // (owning assistant index, [(tool index, verdict)]) in transcript order.
2573    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                    // A result with no assistant entry in the window: never
2595                    // dropped, since its call could not go with it.
2596                    _ => rounds.push((None, vec![(n, v)])),
2597                }
2598            }
2599            // Anything else (a user note, a re-prompt) closes the round.
2600            _ => 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            // Truncating what is already short changes nothing but the note;
2616            // it is reported as kept, because it was.
2617            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    // Either way this trigger has had its decision: the next one goes to the
2675    // summarizer until the transcript is measured under the ceiling again.
2676    flow.decide_fold_tried = true;
2677    flow.need = Some(crate::state::FlowNeed::NextTurn);
2678    match plan {
2679        Err(reason) => {
2680            // Fail open: nothing is edited, and the NextTurn above re-enters
2681            // `dispatch_llm_turn`, whose trigger still holds — the summarizer
2682            // fold runs exactly as it would have with no backend.
2683            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            // The pruned transcript is unmeasured: zero means "ask the
2733            // provider", and a refusal that provoked this has been answered.
2734            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
2748/// Fail an abstract node (loop bound, terminal model failure): fail-fast
2749/// semantics, the same path a Host node's retry exhaustion takes. The detail is
2750/// classified `ExecutorError:` — the loop's own failures are the executor's as
2751/// far as a reader of the outcome is concerned.
2752fn 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
2762/// Fail an abstract node with a detail that already leads with its own
2763/// `RUN-Ennn`. Separate from [`fail_abstract`] because the workspace rule is
2764/// that the code is the LEADING token of what a user reads, and an
2765/// `ExecutorError:` prefix in front of one buries it — besides being wrong:
2766/// a transcript the scheduler refused to send is not an executor's failure.
2767fn 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
2787/// Park: checkpoint + envelope, once per ask (a parked run polled again
2788/// must not re-announce).
2789fn 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        // The park reading MUST be journaled: the wall segment accrued at
2805        // park uses st.clock_ms, and verify can only reproduce it from the
2806        // checkpoint's own record (feeding an unjournaled reading would
2807        // false-diverge any superstep that resolved an effect before
2808        // parking).
2809        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
2820/// Close the open superstep: extract Send spawns, reduce in canonical
2821/// order, evaluate edges in canonical order (unless draining), checkpoint.
2822fn 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    // Send extraction (§5.1): a result's reserved `$send` key is a spawn
2830    // decision, made HERE at close in canonical order — static spawners by
2831    // node index, then task spawners by path — journaled in the decision
2832    // record, and stripped before reduction. A malformed decision is
2833    // fail-fast (the spawner is named; no tasks spawn; the run drains).
2834    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    // Reducers, canonical order (§5.2: state-merge order is canonical —
2855    // node index, never completion order; Send results after the static
2856    // set, in spawn order — task paths zero-pad their ordinals so
2857    // lexicographic IS numeric). An object result merges per top-level
2858    // key; anything else merges under the node's own id.
2859    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    // Edge evaluation — skipped when the run is dying (fail-fast /
2887    // cancel). An EXHAUSTED run still evaluates: it is designed to resume
2888    // under raised budgets, so its last superstep's firings must land
2889    // (dispatch stays drained either way — readiness without dispatch).
2890    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                    // Unconditional edges always fire; the evaluator is only
2898                    // consulted for edges that carry a condition.
2899                    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    // Close the active wall segment (open→park segments were already
2915    // charged at park time). A superstep that parked charges NOTHING after
2916    // the park; the park→close span accrues as `elapsed` instead — both
2917    // endpoints are journaled readings, so replay reproduces both figures
2918    // byte-exactly (§6.7: a three-day approval pause is reported, never
2919    // billed — and never a replay divergence either).
2920    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
2934/// Pull a result's `$send` list out (stripping the key so reducers never
2935/// see it). Returns None when the result carries no spawn decision.
2936fn 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]), // validated (and refused) in apply_spawns
2942    }
2943}
2944
2945/// Per-decision spawn cap (also the per-parent lifetime cap via the
2946/// 4-digit ordinal space). Far above any sane fan-out; a backstop, not a
2947/// tuning knob.
2948const MAX_SPAWNS: usize = 4096;
2949
2950/// Validate and apply the superstep's spawn decisions: fresh task paths
2951/// (`parent/NNNN`, monotonic per parent), targets pinned Host-executed,
2952/// the target node preempted into `Dispatched`. Violations are fail-fast
2953/// naming the spawner.
2954fn 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    // Validate everything before spawning anything: a half-applied spawn
2968    // decision would leave tasks the journaled record never mentions.
2969    // (parent path, spawner idx, [(target idx, task input)]).
2970    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            // A declared memory read is a legal target: a fan-out of as-of
2992            // reads (one per policy in an aggregate) resolves each task's
2993            // pointers against that task's own input.
2994            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                // The 4-digit pad is what makes lexicographic order spawn
3026                // order; past it the merge-order invariant breaks — fail
3027                // the spawner loudly instead.
3028                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
3050/// Deliver an edge resolution to its destination, with re-entry. Returns
3051/// the destination generation the delivery landed in.
3052fn 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]; // a dead edge never re-enters
3061        }
3062        st.node_gen[dst] += 1;
3063        st.node_state[dst] = NodeState::Waiting;
3064        // Attempt stays MONOTONIC (journal keys must be generation-unique);
3065        // the new generation's retry budget measures from here.
3066        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
3093/// Waiting → Ready when no in-edge is Pending and ≥1 Fired; → Dead when all
3094/// resolved and none Fired (§6.2: edges must RESOLVE, not fire).
3095///
3096/// A node's FIRST generation (`node_gen[i] == 0`) only gates on in-edges
3097/// that could possibly have resolved by then: a back-edge that closes a
3098/// cycle through this very node (`env.plan.cycle_edge`) cannot fire before
3099/// the node's own first run, so requiring it here would deadlock any cycle
3100/// whose re-entry point isn't the plan's entry node (RUN issue #33) — every
3101/// OTHER in-edge into a reachable non-root node is guaranteed non-back (the
3102/// DFS tree edge that first discovered it, at minimum), so this never
3103/// starves a node of every gating edge. The entry node sidesteps this rule
3104/// entirely via an unconditional bootstrap (`apply_event`'s `Start`
3105/// handler), since it may have zero in-edges at all. Once a node is
3106/// re-entered (generation ≥ 1), `deliver`'s re-entry branch already
3107/// resolves every other in-edge from concrete current node state, so the
3108/// full AND-join applies again from generation 1 onward.
3109fn 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
3125/// Dead-path propagation to fixpoint, canonical order.
3126fn 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
3151/// The `Stalled` diagnosis: the lowest-index completed node whose out-edges
3152/// all resolved without ever firing.
3153fn 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    // No completed node's own out-edges explain it (every completed node
3163    // fired something downstream) — name the lowest-index node that never
3164    // got a chance to run at all, the actual culprit, rather than blaming
3165    // the entry unconditionally when its own edge fired correctly.
3166    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    /// No cap = the transcript entry every run before this knob built. Not
3185    /// "close enough": byte-identical, so an unbounded run's checkpoints are
3186    /// unchanged and its `verify` still passes.
3187    #[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    /// The boundary, from both sides. At exactly the cap nothing happens; one
3194    /// character more and the entry becomes the bounded shape.
3195    #[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        // Where the whole thing still is. A model told "truncated" with no
3207        // pointer has learned nothing it can act on, and neither has a human
3208        // reading the transcript back.
3209        assert_eq!(bounded["journal"], json!({"attempt": 3, "effect_seq": 7}));
3210    }
3211
3212    /// The cap counts CHARACTERS, which is what it is called. Slicing by bytes
3213    /// here would both mis-measure a multibyte result and be able to cut a
3214    /// UTF-8 sequence in half.
3215    #[test]
3216    fn a_multibyte_result_is_cut_on_character_boundaries() {
3217        // 40 characters, 120 bytes: a byte cap of 20 would land mid-sequence.
3218        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    /// A structured result is measured and shown as the JSON the model would
3233    /// have been handed, not as Rust's `Debug` of it.
3234    #[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    /// A degenerate cap must not panic or produce nonsense — it keeps nothing
3244    /// and still says how much there was and where it is.
3245    #[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    // ---- the fold's range decision -------------------------------------
3255
3256    #[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    /// A short transcript has no middle: `messages[0]` is the node's input and
3285    /// the rest is the kept tail, so there is nothing a summary could replace.
3286    /// Returning `None` here is what produces `RUN-E024` rather than a fold
3287    /// that would delete the task description.
3288    #[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    /// The first entry is the node's instruction and input state. A summary of
3297    /// the task cannot stand in for the task, so the range always starts at 1.
3298    #[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    /// Providers reject a `tool` result whose `assistant` tool call is not in
3307    /// the request. So the cut moves FORWARD off a tool entry — shrinking the
3308    /// kept tail — rather than backward, which would fold the assistant entry
3309    /// and orphan the results that follow it.
3310    #[test]
3311    fn the_cut_never_separates_a_tool_result_from_its_assistant_turn() {
3312        // Tail of 4 would start at index 8, which is a `tool`: the cut must
3313        // advance to 10, the next non-tool entry.
3314        let messages = vec![
3315            msg("user"),      // 0 node input
3316            msg("assistant"), // 1
3317            msg("tool"),      // 2
3318            msg("user"),      // 3
3319            msg("assistant"), // 4
3320            msg("tool"),      // 5
3321            msg("user"),      // 6
3322            msg("assistant"), // 7
3323            msg("tool"),      // 8  <- naive cut lands here
3324            msg("tool"),      // 9
3325            msg("user"),      // 10
3326            msg("assistant"), // 11
3327        ];
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    /// A round wide enough to fill the whole tail — one assistant turn issuing
3339    /// eight tool calls — folds ENTIRELY, keeping no tail at all. That is the
3340    /// right answer, not a missing guard: every candidate tail entry is a `tool`
3341    /// result whose `assistant` entry sits inside the folded range, so keeping
3342    /// any of them would orphan it. Fold all of it and make progress, rather
3343    /// than refuse and kill the node with `RUN-E024`.
3344    #[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}