Skip to main content

perspt_agent/toolloop/
mod.rs

1//! The SRBN tool loop (PSP-9 system 6) — Paper II Appendix A Algorithm 1
2//! with tool calls substituted for bundle generation.
3//!
4//! Every model-issued tool call becomes a Paper III proposal; the
5//! deterministic admissibility kernel decides whether it may affect the
6//! candidate; the gate is evaluated on the re-measured candidate, never on
7//! the model's account of it (Paper I Definition 12.2); and every descent
8//! and rejection consumes the stated budget (Paper II Lemma 1).
9//!
10//! The loop is written against `dyn ModelTransport` and cannot name a vendor
11//! type or credential — that is Gate S. Executor and measurer are ports too,
12//! so the whole loop runs in tests against scripted fixtures without a
13//! network.
14
15use anyhow::{Context, Result};
16use perspt_sdk::{
17    check_full_admissibility, classify_failure, AcceptedTrajectory, BarrierEvaluator,
18    CandidateStateWitness, CandidateTransition, Capability, ContextCheckpoint, ContractEvaluator,
19    ControlFrame, Conversation, CorrectionDirection, EffectProposal, FailureKind,
20    FullAdmissibilityWitness, GateDecision, KernelState, ModelId, ModelTransport,
21    NodeTerminalOutcome, ProviderToolCall, RecoveryCascade, ResidualEvent, StaticCatalog,
22    ToolCatalog, ToolChoicePolicy, ToolEntry, TurnOutput, VerificationCadence,
23};
24use serde::{Deserialize, Serialize};
25use std::collections::BTreeSet;
26
27use crate::realize::ProjectionMismatch;
28
29mod artifact;
30mod batch;
31mod binding;
32mod context;
33mod contract;
34mod effects;
35pub mod envelope;
36mod proposal;
37mod resident;
38use binding::PromptBinding;
39pub use contract::*;
40pub use envelope::{decode_tool_loop, DecodedLoopEvent};
41pub use proposal::candidate_mutating_effect;
42pub use resident::ResidentReserves;
43
44pub(crate) use context::refold_session_context;
45use context::LoopContext;
46use proposal::{promote_matching_capability, proposal_from, proposal_scope, uncertified_reason};
47
48/// The loop's finite budgets (PSP-9 system 6).
49#[derive(Debug, Clone)]
50pub struct LoopBudgets {
51    /// `N`: maximum model turns.
52    pub max_turns: u32,
53    /// `M`: maximum tool calls honored in one turn.
54    pub max_calls_per_turn: u32,
55    /// `B`: Paper II's rejection budget (descents do not consume it).
56    pub rejection_budget: u32,
57    /// `ρ_gate`: the measured descent tolerance.
58    pub rho_gate: f64,
59    /// The domain's declared energy floor, if any.
60    pub declared_energy_floor: Option<f64>,
61    /// Provider-neutral context projection threshold. The live route's
62    /// declared context size may lower this bound.
63    pub context_soft_limit_chars: usize,
64    /// Paper III's one non-replenishing recovery pool for this node.
65    pub recovery_budget: u32,
66    /// Per-model-call wall-clock deadline in seconds; a finite turn count
67    /// alone never bounds wall time.
68    pub turn_deadline_secs: u64,
69    /// Definition 6 reserves and working-set bounds (`[context]`).
70    pub resident: resident::ResidentReserves,
71}
72
73impl LoopBudgets {
74    /// A configuration permitting an unbounded unchecked interval is
75    /// rejected at startup (Paper II Theorem 3).
76    pub fn validate(&self, cadence: &VerificationCadence) -> Result<()> {
77        anyhow::ensure!(self.max_turns > 0, "turn budget N must be at least 1");
78        anyhow::ensure!(
79            self.max_calls_per_turn > 0,
80            "per-turn call budget M must be at least 1"
81        );
82        anyhow::ensure!(
83            self.rho_gate.is_finite() && self.rho_gate > 0.0,
84            "descent tolerance rho_gate must be positive and finite"
85        );
86        anyhow::ensure!(
87            self.context_soft_limit_chars > 0,
88            "context soft limit must be finite and nonzero"
89        );
90        cadence.validate().map_err(|e| anyhow::anyhow!("{e}"))?;
91        Ok(())
92    }
93}
94
95/// The assembled tool loop for one node generation.
96pub struct ToolLoop<'a> {
97    pub transport: &'a dyn ModelTransport,
98    pub model: ModelId,
99    /// Sticky failover chain. A route is consumed only after an observed
100    /// transport failure and never selected per turn.
101    pub fallback_models: Vec<ModelId>,
102    pub catalog: &'a StaticCatalog,
103    pub capabilities: Vec<Capability>,
104    pub contract: Option<&'a dyn ContractEvaluator>,
105    pub barrier: Option<&'a dyn BarrierEvaluator>,
106    /// The capability contract's ceiling on one certified increment.
107    pub c_c_max: f64,
108    pub executor: &'a dyn EffectExecutor,
109    pub measurer: &'a dyn CandidateMeasurer,
110    pub budgets: LoopBudgets,
111    pub cadence: VerificationCadence,
112    pub kernel_state: KernelState,
113    pub node_id: String,
114    pub generation: u32,
115    /// The composed platform envelope for this actor (PSP-10 Phase 5). The
116    /// runtime composes it from the section library and records its
117    /// provenance; the loop seeds the text and stamps the digests into
118    /// every control frame.
119    pub system_prompt: PromptEnvelope,
120    pub recorder: Option<&'a dyn LoopRecorder>,
121    /// The partial-checkpoint root this loop was seeded from (a branch
122    /// continuing a partial); enters the resident dependency environment.
123    pub partial_seed_root: Option<String>,
124    /// Birth dependencies restored from a durable checkpoint, parallel to
125    /// the resumed conversation (Gate AF): pages keep their birth roots.
126    pub resumed_birth_deps: Vec<perspt_sdk::prompt::StateDependency>,
127    /// The forest's shared budget when this loop is a search branch: every
128    /// model turn and verifier run reserves first (Gate AC); `None` outside.
129    pub search_budget: Option<perspt_sdk::search::SharedSearchBudget>,
130}
131
132/// Where the last context checkpoint left off, so each new checkpoint covers
133/// exactly the log since the previous one instead of claiming the whole
134/// history from zero.
135#[derive(Debug, Default)]
136struct CompactionCursor {
137    parent: Option<String>,
138    next_from: u64,
139}
140
141fn finish(
142    outcome: NodeTerminalOutcome,
143    trajectory: AcceptedTrajectory,
144    log: EventLog,
145    projection: ProjectionMismatch,
146    turns_used: u32,
147    recovery_spent: u32,
148) -> LoopOutcome {
149    LoopOutcome {
150        outcome,
151        trajectory,
152        events: log.into_events(),
153        projection,
154        turns_used,
155        recovery_spent,
156        contained_by_transport: false,
157    }
158}
159
160/// Loop-lifetime mutable state threaded through every turn.
161struct TurnState {
162    log: EventLog,
163    projection: ProjectionMismatch,
164    recovery: RecoveryCascade,
165    cursor: CompactionCursor,
166    accepted_checkpoint: CandidateCheckpoint,
167    trajectory: AcceptedTrajectory,
168    /// `N_gate` computed at node entry; the loop refuses the
169    /// `(N_gate + 1)`-th gate submission (Gate X).
170    decision_bound: u64,
171    /// Monotone per-(node, generation) candidate counter (PSP-10 Phase 2).
172    candidate_seq: u64,
173    /// The candidate identity shared by the current measurement, gate
174    /// decision, and durable checkpoint (Proposition 2 keys by it).
175    current_candidate: String,
176    turns_used: u32,
177    /// The prompt binding of the most recent compile (PSP-10 Gate Z): the
178    /// tool-surface hash and route it was compiled for, plus its digests.
179    prompt: PromptBinding,
180}
181
182impl TurnState {
183    fn finish(self, outcome: NodeTerminalOutcome) -> LoopOutcome {
184        finish(
185            outcome,
186            self.trajectory,
187            self.log,
188            self.projection,
189            self.turns_used,
190            self.recovery.spent,
191        )
192    }
193
194    /// Mint the next candidate identity `"{node}/{gen}/c{seq}"`. Held for
195    /// the measurement → gate → durable-checkpoint triple.
196    fn next_candidate(&mut self, node_id: &str, generation: u32) -> String {
197        let id = format!("{node_id}/{generation}/c{}", self.candidate_seq);
198        self.candidate_seq += 1;
199        self.current_candidate = id.clone();
200        id
201    }
202}
203
204/// Turn-scoped accounting shared by top-level and nested calls.
205struct TurnBudget {
206    calls_seen: u32,
207    mutations: u32,
208    immediate_boundary: bool,
209    max_mutations: u32,
210}
211
212impl TurnBudget {
213    fn new(max_mutations: u32) -> Self {
214        Self {
215            calls_seen: 0,
216            mutations: 0,
217            immediate_boundary: false,
218            max_mutations,
219        }
220    }
221}
222
223/// Marker error: the transport recovery chain is exhausted (no retry or
224/// eligible fallback absorbed a provider failure). Containment caused by
225/// this error is an infrastructure outcome, not a governance anomaly, and
226/// preserves the session's authority so `perspt resume` stays viable.
227#[derive(Debug)]
228pub(crate) struct TransportExhausted {
229    detail: String,
230}
231
232impl std::fmt::Display for TransportExhausted {
233    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
234        formatter.write_str(&self.detail)
235    }
236}
237
238impl std::error::Error for TransportExhausted {}
239
240/// Whether an error chain bottoms out in exhausted transport recovery.
241pub(crate) fn is_transport_exhaustion(error: &anyhow::Error) -> bool {
242    error
243        .chain()
244        .any(|cause| cause.downcast_ref::<TransportExhausted>().is_some())
245}
246
247/// One loop iteration's verdict after a gate boundary.
248enum BoundaryStep {
249    Terminal(NodeTerminalOutcome),
250    Continue,
251    Exhausted,
252}
253
254impl ToolLoop<'_> {
255    /// Run the loop to a classified terminal state (Paper II Lemma 1).
256    pub async fn run(self, goal: &str) -> Result<LoopOutcome> {
257        self.run_with_conversation(goal, None, Vec::new()).await
258    }
259
260    /// Re-enter from a durable provider-neutral conversation projection. The
261    /// runtime restores candidate state and budgets; this method restores only
262    /// model context and deferred tool activation.
263    pub async fn run_with_conversation(
264        mut self,
265        goal: &str,
266        resumed: Option<Conversation>,
267        restored_activated_tools: Vec<String>,
268    ) -> Result<LoopOutcome> {
269        self.budgets.validate(&self.cadence)?;
270        let (baseline, mut state) = self.open_turn_state().await?;
271        if let Some(outcome) = self.baseline_terminal(&baseline) {
272            return Ok(state.finish(outcome));
273        }
274        // The first call carries the measured baseline diagnostics: the
275        // model starts from evidence, not from a blind read of the tree.
276        let seeded_goal = goal_with_baseline(goal, &baseline);
277        let was_resumed = resumed.is_some();
278        let mut context =
279            self.open_context(&seeded_goal, resumed, restored_activated_tools, &mut state)?;
280        context.stamp_after_open(
281            was_resumed,
282            self.partial_seed_root.as_deref(),
283            &state.accepted_checkpoint.witness.state_root,
284            &self.resumed_birth_deps,
285        );
286
287        let mut mutations_since_boundary = 0u32;
288        for turn in 1..=self.budgets.max_turns {
289            state.turns_used = turn;
290            let turn_result = self
291                .model_turn(turn, mutations_since_boundary, &mut context, &mut state)
292                .await;
293            let (output, mutations, immediate_boundary) = match turn_result {
294                Ok(result) => result,
295                Err(error) => {
296                    let transport = is_transport_exhaustion(&error);
297                    let outcome = self
298                        .contain(&error, &state.accepted_checkpoint, &mut state.log)
299                        .await?;
300                    let mut contained = state.finish(outcome);
301                    contained.contained_by_transport = transport;
302                    return Ok(contained);
303                }
304            };
305            mutations_since_boundary = mutations_since_boundary.saturating_add(mutations);
306            if !self.boundary_due(&output, immediate_boundary, mutations_since_boundary, turn) {
307                continue;
308            }
309            mutations_since_boundary = 0;
310
311            match self
312                .boundary_step(goal, turn, &mut state, &mut context)
313                .await?
314            {
315                BoundaryStep::Terminal(outcome) => return Ok(state.finish(outcome)),
316                BoundaryStep::Exhausted => break,
317                BoundaryStep::Continue => {}
318            }
319        }
320
321        let outcome = NodeTerminalOutcome::Escalated {
322            certificate_id: uuid::Uuid::new_v4().to_string(),
323        };
324        Ok(state.finish(outcome))
325    }
326
327    /// Measure the baseline and open the loop's turn state around its real
328    /// restore point.
329    async fn open_turn_state(&mut self) -> Result<(Measured, TurnState)> {
330        let mut log = EventLog::new(self.recorder.is_none());
331        let accepted_checkpoint = self.executor.checkpoint(&[]).await?;
332        let (baseline, trajectory) = self.measured_baseline(&mut log).await?;
333        let decision_bound = loop_decision_bound(baseline.energy, &self.budgets)?;
334        let state = TurnState {
335            log,
336            projection: ProjectionMismatch::default(),
337            recovery: RecoveryCascade::new(self.budgets.recovery_budget),
338            cursor: CompactionCursor::default(),
339            accepted_checkpoint,
340            trajectory,
341            decision_bound,
342            candidate_seq: 1,
343            current_candidate: format!("{}/{}/c0", self.node_id, self.generation),
344            turns_used: 0,
345            prompt: PromptBinding::seed(&self.system_prompt, &self.model),
346        };
347        Ok((baseline, state))
348    }
349
350    /// Seed a fresh model context or re-enter a resumed one.
351    fn open_context(
352        &self,
353        goal: &str,
354        resumed: Option<Conversation>,
355        restored_activated_tools: Vec<String>,
356        state: &mut TurnState,
357    ) -> Result<LoopContext> {
358        let mut restored_tools = restored_activated_tools;
359        if let Some(conversation) = resumed.as_ref() {
360            restored_tools.extend(activated_tools_from_conversation(conversation));
361        }
362        match resumed {
363            Some(conversation) => {
364                LoopContext::resume(conversation, restored_tools, self.recorder, &mut state.log)
365            }
366            None => LoopContext::seed(
367                &self.system_prompt.text,
368                goal,
369                self.recorder,
370                &mut state.log,
371            ),
372        }
373    }
374
375    /// Unconditional containment: restore the last accepted state and
376    /// escalate (Theorem 6's terminal class for an unrecoverable turn).
377    async fn contain(
378        &self,
379        error: &anyhow::Error,
380        accepted_checkpoint: &CandidateCheckpoint,
381        log: &mut EventLog,
382    ) -> Result<NodeTerminalOutcome> {
383        self.executor.restore(accepted_checkpoint).await?;
384        emit(
385            self.recorder,
386            log,
387            LoopEvent::RecoveryContained {
388                reason: error.to_string(),
389                restored_checkpoint_id: accepted_checkpoint.id.clone(),
390            },
391        )?;
392        Ok(NodeTerminalOutcome::Escalated {
393            certificate_id: uuid::Uuid::new_v4().to_string(),
394        })
395    }
396
397    /// Measure the baseline candidate and open the accepted trajectory.
398    async fn measured_baseline(
399        &mut self,
400        log: &mut EventLog,
401    ) -> Result<(Measured, AcceptedTrajectory)> {
402        self.reserve_verifier_action(log)?;
403        let baseline = self.measurer.measure().await?;
404        emit(
405            self.recorder,
406            log,
407            LoopEvent::CandidateMeasured {
408                node_id: self.node_id.clone(),
409                generation: self.generation,
410                candidate_id: format!("{}/{}/c0", self.node_id, self.generation),
411                energy: baseline.energy,
412                hard_pass: baseline.hard_pass,
413                residuals: baseline.residuals.clone(),
414            },
415        )?;
416        let trajectory = AcceptedTrajectory::new(
417            self.node_id.clone(),
418            self.generation,
419            baseline.energy,
420            self.budgets.rho_gate,
421            self.budgets.rejection_budget,
422        )?;
423        Ok((baseline, trajectory))
424    }
425
426    /// Gate the measured candidate: accept (new checkpoint), restore on
427    /// rejection, classify terminals, and otherwise steer the next turn with
428    /// a directed correction (Lemma 1: descent and rejection are
429    /// non-terminal).
430    async fn boundary_step(
431        &mut self,
432        goal: &str,
433        turn: u32,
434        state: &mut TurnState,
435        context: &mut LoopContext,
436    ) -> Result<BoundaryStep> {
437        if self.refuse_past_bound(state)? {
438            return Ok(BoundaryStep::Exhausted);
439        }
440        let candidate_id = state.next_candidate(&self.node_id, self.generation);
441        let (measured, decision) = self
442            .measure_and_gate(&candidate_id, &mut state.trajectory, &mut state.log)
443            .await?;
444
445        let accepted = decision.is_accepted();
446        if accepted {
447            state.accepted_checkpoint = self.executor.checkpoint(&[]).await?;
448            // New pages depend on the new root; older root-dependent pages
449            // are now stale as optionals (still pinned/recallable).
450            context.set_state_dependency(perspt_sdk::prompt::StateDependency::AcceptedRoot(
451                state.accepted_checkpoint.witness.state_root.clone(),
452            ));
453        } else if !matches!(decision, GateDecision::StoppedAtDeclaredFloor) {
454            self.executor.restore(&state.accepted_checkpoint).await?;
455            emit(
456                self.recorder,
457                &mut state.log,
458                LoopEvent::CandidateRestored {
459                    checkpoint_id: state.accepted_checkpoint.id.clone(),
460                },
461            )?;
462        }
463
464        if let Some(outcome) = self.classify_decision(&decision, &measured) {
465            if accepted {
466                self.durable_checkpoint(goal, turn, &measured, state, context)
467                    .await?;
468            }
469            return Ok(BoundaryStep::Terminal(outcome));
470        }
471        push_correction(context, &measured, accepted, self.recorder, &mut state.log)?;
472        self.maybe_compact(goal, turn, &measured, state, context)?;
473        if accepted {
474            self.durable_checkpoint(goal, turn, &measured, state, context)
475                .await?;
476        } else {
477            if state.trajectory.budget_exhausted() {
478                return Ok(BoundaryStep::Exhausted);
479            }
480            let granted = state
481                .recovery
482                .grant(classify_failure(FailureKind::GateRejection));
483            emit(
484                self.recorder,
485                &mut state.log,
486                LoopEvent::RecoveryControlGranted {
487                    failure: FailureKind::GateRejection,
488                    level: granted.level,
489                    forced_escalation: granted.forced_escalation,
490                    model: self.model.clone(),
491                },
492            )?;
493            if granted.level > perspt_sdk::CascadeLevel::Retry {
494                return Ok(BoundaryStep::Exhausted);
495            }
496        }
497        Ok(BoundaryStep::Continue)
498    }
499
500    /// Gate X: the finite-decision bound is enforced, not advisory. Once the
501    /// bound is reached the loop refuses to submit — the refusal appends no
502    /// gate decision and routes through the ordinary exhaustion terminal.
503    fn refuse_past_bound(&self, state: &mut TurnState) -> Result<bool> {
504        let decisions_used = state.trajectory.gate_decisions.len() as u64;
505        if decisions_used < state.decision_bound {
506            return Ok(false);
507        }
508        emit(
509            self.recorder,
510            &mut state.log,
511            LoopEvent::DecisionBoundRefused {
512                node_id: self.node_id.clone(),
513                generation: self.generation,
514                bound: state.decision_bound,
515                decisions_used,
516            },
517        )?;
518        Ok(true)
519    }
520
521    /// Send the conversation, consuming the sticky failover chain on observed
522    /// transport failures (a route is never selected per turn).
523    async fn chat_with_failover(
524        &mut self,
525        conversation: &Conversation,
526        specs: &[perspt_sdk::ToolSpec],
527        recovery: &mut RecoveryCascade,
528        log: &mut EventLog,
529    ) -> Result<TurnOutput> {
530        loop {
531            match crate::turn::chat_turn_with_deadline(
532                self.transport,
533                &self.model,
534                conversation,
535                specs,
536                ToolChoicePolicy::Auto,
537                self.budgets.turn_deadline_secs,
538            )
539            .await
540            {
541                Ok(output) => return Ok(output),
542                Err(error) => {
543                    let cause = error.to_string();
544                    // One shared failure taxonomy for every actor (PSP-10
545                    // system 27).
546                    let failure = crate::turn::transport_failure_kind(&cause);
547                    let granted = recovery.grant(classify_failure(failure));
548                    emit(
549                        self.recorder,
550                        log,
551                        LoopEvent::RecoveryControlGranted {
552                            failure,
553                            level: granted.level,
554                            forced_escalation: granted.forced_escalation,
555                            model: self.model.clone(),
556                        },
557                    )?;
558                    if granted.level != perspt_sdk::CascadeLevel::Fallback || granted.terminal {
559                        return Err(anyhow::Error::new(TransportExhausted {
560                            detail: format!(
561                                "transport recovery reached {:?}: {cause}",
562                                granted.level
563                            ),
564                        }));
565                    }
566                    let Some(next) = self.fallback_models.first().cloned() else {
567                        return Err(anyhow::Error::new(TransportExhausted {
568                            detail: format!("transport recovery has no eligible fallback: {cause}"),
569                        }));
570                    };
571                    self.fallback_models.remove(0);
572                    let previous = std::mem::replace(&mut self.model, next.clone());
573                    emit(
574                        self.recorder,
575                        log,
576                        LoopEvent::RouteFailover {
577                            from_model: previous,
578                            to_model: next,
579                            cause,
580                        },
581                    )?;
582                }
583            }
584        }
585    }
586
587    /// Measure boundary: text turn, cadence bound `H`, or turn budget.
588    fn boundary_due(
589        &self,
590        output: &TurnOutput,
591        immediate_boundary: bool,
592        mutations_since_boundary: u32,
593        turn: u32,
594    ) -> bool {
595        matches!(output, TurnOutput::Text(_))
596            || immediate_boundary
597            || mutations_since_boundary >= self.cadence.max_mutations_between_checks
598            || turn == self.budgets.max_turns
599    }
600
601    /// One model turn: send the conversation, record the observation, and
602    /// route every returned call through the kernel. `mutations_so_far` is
603    /// the count since the last boundary; the cadence bound `H` caps what
604    /// this turn may add.
605    async fn model_turn(
606        &mut self,
607        turn: u32,
608        mutations_so_far: u32,
609        context: &mut LoopContext,
610        state: &mut TurnState,
611    ) -> Result<(TurnOutput, u32, bool)> {
612        let specs =
613            self.catalog
614                .deferred_specs_for(&self.capabilities, context.activated_tools(), false);
615        // Definition 6, transport half: the model sees the resident view
616        // (evictions tombstoned, recallable) and the composed request is
617        // verified against the allowance and dialect byte limit before any
618        // transport call; the whole projection stays the backing store.
619        let assembled = self.assemble_resident_context(turn, &specs, context, state)?;
620        let conversation = self.fit_composed_request(turn, &specs, context, assembled, state)?;
621        self.bind_prompt_program(turn, &specs, state)?;
622        // Gate AC: reserve the worst case before transport, settle actuals
623        // after; both sides snapshot so a crash never refills the turn.
624        let turn_reservation = self.reserve_model_turn(&conversation)?;
625        self.snapshot_search_usage(&mut state.log)?;
626        let output = self
627            .chat_with_failover(&conversation, &specs, &mut state.recovery, &mut state.log)
628            .await?;
629        // R2: record the observation before inspecting it.
630        emit(
631            self.recorder,
632            &mut state.log,
633            LoopEvent::TurnObserved {
634                turn,
635                actor: "worker".into(),
636                output: output.clone(),
637            },
638        )?;
639        let max_mutations = self
640            .cadence
641            .max_mutations_between_checks
642            .saturating_sub(mutations_so_far)
643            .max(1);
644        let (mutations, immediate_boundary) = self
645            .execute_turn(
646                &output,
647                turn,
648                max_mutations,
649                context,
650                &mut state.log,
651                &mut state.projection,
652            )
653            .await?;
654        self.settle_model_turn(turn_reservation, &output, mutations)?;
655        self.snapshot_search_usage(&mut state.log)?;
656        Ok((output, mutations, immediate_boundary))
657    }
658
659    /// Make a gate acceptance durably resumable (PSP-9 phase 6): export the
660    /// accepted candidate's mutated contents as content-addressed artifacts
661    /// and ledger them with the exact control frame, so a crashed loop can be
662    /// continued from this acceptance instead of restarted.
663    async fn durable_checkpoint(
664        &self,
665        goal: &str,
666        turn: u32,
667        measured: &Measured,
668        state: &mut TurnState,
669        context: &LoopContext,
670    ) -> Result<()> {
671        let Some(recorder) = self.recorder else {
672            return Ok(());
673        };
674        let authority_epoch = self
675            .kernel_state
676            .witnesses
677            .get("__authority_epoch")
678            .map(String::as_str)
679            .and_then(|value| value.parse().ok())
680            .unwrap_or(0);
681        let control = self.control_frame(goal, turn, measured, state, context, authority_epoch);
682        let exported = self.executor.export_accepted().await?;
683        let mut files = Vec::new();
684        for seed in exported {
685            let content_artifact = seed
686                .content
687                .as_deref()
688                .map(|bytes| recorder.record_artifact(bytes, "application/octet-stream"))
689                .transpose()?;
690            let source_preimage_artifact = seed
691                .source_preimage
692                .as_deref()
693                .map(|bytes| recorder.record_artifact(bytes, "application/octet-stream"))
694                .transpose()?;
695            files.push(DurableSeedFile {
696                path: seed.path,
697                content_artifact,
698                source_preimage_artifact,
699            });
700        }
701        emit(
702            self.recorder,
703            &mut state.log,
704            LoopEvent::DurableCandidateCheckpoint {
705                candidate_id: state.current_candidate.clone(),
706                state_root: state.accepted_checkpoint.witness.state_root.clone(),
707                control,
708                conversation: context.conversation().clone(),
709                birth_deps: context.birth_deps().to_vec(),
710                canonical_scope: state.accepted_checkpoint.witness.canonical_scope.clone(),
711                files,
712            },
713        )
714    }
715
716    /// The verbatim control frame a compaction must preserve (resolved
717    /// design decision 3): goal, accepted state binding, live authority, and
718    /// exact remaining budgets.
719    fn control_frame(
720        &self,
721        goal: &str,
722        turn: u32,
723        measured: &Measured,
724        state: &TurnState,
725        context: &LoopContext,
726        authority_epoch: u64,
727    ) -> ControlFrame {
728        ControlFrame {
729            projection_digest: context.digest().to_string(),
730            event_schema_version: perspt_sdk::CONVERSATION_EVENT_SCHEMA_VERSION,
731            prompt_invocation_digest: if state.prompt.invocation_digest.is_empty() {
732                self.system_prompt.invocation_digest.clone()
733            } else {
734                state.prompt.invocation_digest.clone()
735            },
736            prompt_manifest_digest: self.system_prompt.manifest_digest.clone(),
737            resident_context_digest: state.prompt.resident_digest.clone(),
738            node_id: self.node_id.clone(),
739            goal: goal.to_string(),
740            node_generation: self.generation,
741            accepted_state_root: state.accepted_checkpoint.witness.state_root.clone(),
742            graph_revision: state.accepted_checkpoint.witness.graph_revision.clone(),
743            capability_ids: self
744                .capabilities
745                .iter()
746                .map(|capability| capability.capability_id.clone())
747                .collect(),
748            authority_epoch,
749            remaining_rejection_budget: state
750                .trajectory
751                .rejection_budget
752                .saturating_sub(state.trajectory.rejections_used.max(state.recovery.spent)),
753            remaining_turns: self.budgets.max_turns.saturating_sub(turn),
754            active_model: self.model.clone(),
755            remaining_fallback_models: self.fallback_models.clone(),
756            activated_tools: context.activated_tools().iter().cloned().collect(),
757            unresolved_call_ids: context.conversation().unresolved_call_ids(),
758            residual_summary: measured
759                .residuals
760                .iter()
761                .map(|residual| (format!("{:?}", residual.class), residual.score))
762                .collect(),
763        }
764    }
765
766    fn maybe_compact(
767        &self,
768        goal: &str,
769        turn: u32,
770        measured: &Measured,
771        state: &mut TurnState,
772        context: &mut LoopContext,
773    ) -> Result<()> {
774        let route_limit = self.transport.capabilities(&self.model).max_context_tokens;
775        let route_chars = usize::try_from(route_limit)
776            .unwrap_or(usize::MAX)
777            .saturating_mul(3);
778        let threshold = if route_chars == 0 {
779            self.budgets.context_soft_limit_chars
780        } else {
781            self.budgets.context_soft_limit_chars.min(route_chars)
782        };
783        if context.conversation().estimated_chars() <= threshold {
784            return Ok(());
785        }
786
787        let authority_epoch = self
788            .kernel_state
789            .witnesses
790            .get("__authority_epoch")
791            .map(String::as_str)
792            .and_then(|value| value.parse().ok())
793            .unwrap_or(0);
794        let control = self.control_frame(goal, turn, measured, state, context, authority_epoch);
795        // The rolling chain root commits to every event so far in O(1); the
796        // cursor makes each checkpoint cover exactly the span since the
797        // previous one instead of claiming the whole history from zero.
798        let covered_root = state.log.chain_root().to_string();
799        let checkpoint = ContextCheckpoint {
800            parent: state.cursor.parent.clone(),
801            covered_from: state.cursor.next_from,
802            covered_to: state.log.count().saturating_sub(1),
803            covered_event_root: covered_root.clone(),
804            control,
805            artifact_refs: vec![state.accepted_checkpoint.witness.state_root.clone()],
806            narrative_observation: None,
807        };
808        checkpoint
809            .validate_against(
810                &state.accepted_checkpoint.witness.state_root,
811                &state.accepted_checkpoint.witness.graph_revision,
812                authority_epoch,
813            )
814            .map_err(|error| anyhow::anyhow!(error.to_string()))?;
815        emit(
816            self.recorder,
817            &mut state.log,
818            LoopEvent::ContextCheckpointCreated {
819                checkpoint: checkpoint.clone(),
820            },
821        )?;
822        self.apply_compaction(state, context, &checkpoint, covered_root)
823    }
824
825    /// Apply one compaction: replace the projection with the verbatim
826    /// control frame and record the eviction (PSP-10 Definition 6:
827    /// compaction moves material to the backing store, never destroys it).
828    fn apply_compaction(
829        &self,
830        state: &mut TurnState,
831        context: &mut LoopContext,
832        checkpoint: &ContextCheckpoint,
833        covered_root: String,
834    ) -> Result<()> {
835        let control_json = serde_json::to_string(&checkpoint.control)?;
836        context.compact(
837            format!("PERSPECTIVE_CONTROL_FRAME_V1\n{control_json}"),
838            self.recorder,
839            &mut state.log,
840        )?;
841        emit(
842            self.recorder,
843            &mut state.log,
844            LoopEvent::ContextCompacted {
845                forest_id: String::new(),
846                branch_id: String::new(),
847                summary_page: covered_root.clone(),
848                source_pages: checkpoint.artifact_refs.clone(),
849            },
850        )?;
851        state.cursor.parent = Some(covered_root);
852        state.cursor.next_from = checkpoint.covered_to.saturating_add(1);
853        Ok(())
854    }
855
856    /// Measure the realized candidate and submit it to the gate. The two
857    /// events share one candidate identity, and the gate event records the
858    /// trajectory's own `GateDecisionRef` fields — never recovered by
859    /// correlation (PSP-10 Phase 2).
860    async fn measure_and_gate(
861        &mut self,
862        candidate_id: &str,
863        trajectory: &mut AcceptedTrajectory,
864        log: &mut EventLog,
865    ) -> Result<(Measured, GateDecision)> {
866        self.reserve_verifier_action(log)?;
867        let measured = self.measurer.measure().await?;
868        emit(
869            self.recorder,
870            log,
871            LoopEvent::CandidateMeasured {
872                node_id: self.node_id.clone(),
873                generation: self.generation,
874                candidate_id: candidate_id.to_string(),
875                energy: measured.energy,
876                hard_pass: measured.hard_pass,
877                residuals: measured.residuals.clone(),
878            },
879        )?;
880        let decision = trajectory.submit_with_floor(
881            measured.hard_pass,
882            measured.energy,
883            self.budgets.declared_energy_floor,
884        )?;
885        let recorded = trajectory
886            .gate_decisions
887            .last()
888            .context("submit_with_floor appended no decision")?;
889        emit(
890            self.recorder,
891            log,
892            LoopEvent::GateDecisionRecorded {
893                node_id: self.node_id.clone(),
894                generation: self.generation,
895                candidate_id: candidate_id.to_string(),
896                decision: decision.clone(),
897                observed_energy: Some(recorded.observed_energy),
898                best_accepted_before: Some(recorded.best_accepted_before),
899            },
900        )?;
901        Ok((measured, decision))
902    }
903
904    /// Terminal classification of the measured baseline, if any.
905    fn baseline_terminal(&self, baseline: &Measured) -> Option<NodeTerminalOutcome> {
906        if baseline.hard_pass {
907            return Some(NodeTerminalOutcome::HardPass);
908        }
909        if let Some(floor) = self.budgets.declared_energy_floor {
910            if baseline.energy <= floor {
911                return Some(NodeTerminalOutcome::DeclaredFloor { floor });
912            }
913        }
914        None
915    }
916
917    /// Map a gate decision to a terminal outcome, or `None` to continue.
918    fn classify_decision(
919        &self,
920        decision: &GateDecision,
921        measured: &Measured,
922    ) -> Option<NodeTerminalOutcome> {
923        match decision {
924            GateDecision::HardPass => Some(NodeTerminalOutcome::HardPass),
925            GateDecision::StoppedAtDeclaredFloor => {
926                let floor = self
927                    .budgets
928                    .declared_energy_floor
929                    .unwrap_or(measured.energy);
930                Some(NodeTerminalOutcome::DeclaredFloor { floor })
931            }
932            _ => None,
933        }
934    }
935
936    /// Record a denial as evidence: counted in the projection mismatch,
937    /// ledgered with its typed residual class, and returned to the model.
938    fn deny(
939        &self,
940        log: &mut EventLog,
941        projection: &mut ProjectionMismatch,
942        call_id: &str,
943        reason: String,
944        class: perspt_sdk::ResidualClass,
945    ) -> Result<String> {
946        projection.denied_proposals += 1;
947        emit(
948            self.recorder,
949            log,
950            LoopEvent::EffectDenied {
951                call_id: call_id.to_string(),
952                reason: reason.clone(),
953                class,
954            },
955        )?;
956        Ok(format!("denied: {reason}"))
957    }
958
959    /// Per-turn budget denials (Gate J: a call above the budget still gets a
960    /// recorded proposal-and-result pair; it is denied, not silently
961    /// dropped). Returns `None` when the call is within budget.
962    #[allow(clippy::too_many_arguments)]
963    fn budget_denial(
964        &self,
965        ordinal: u32,
966        mutating: bool,
967        mutations: u32,
968        max_mutations: u32,
969    ) -> Option<String> {
970        if ordinal >= self.budgets.max_calls_per_turn {
971            return Some("per-turn tool-call budget exceeded".into());
972        }
973        if mutating && mutations >= max_mutations {
974            return Some("verification boundary required before another mutation".into());
975        }
976        None
977    }
978
979    async fn record_unchecked_proposal(
980        &self,
981        call: &ProviderToolCall,
982        entry: &ToolEntry,
983        log: &mut EventLog,
984    ) -> Result<()> {
985        let before = self.executor.checkpoint(&[]).await?;
986        let proposal = proposal_from(call, entry, &self.node_id, self.generation, &before.witness);
987        emit(
988            self.recorder,
989            log,
990            LoopEvent::ProposalObserved {
991                call_id: call.call_id.clone(),
992                proposal,
993            },
994        )
995    }
996
997    fn high_risk(entry: Option<&ToolEntry>) -> bool {
998        entry.is_some_and(|entry| {
999            matches!(
1000                entry.risk,
1001                perspt_sdk::RiskClass::High | perspt_sdk::RiskClass::Critical
1002            )
1003        })
1004    }
1005
1006    /// Evaluate all five clauses for a transition and ledger the witness.
1007    fn certify(
1008        &self,
1009        call_id: &str,
1010        transition: &CandidateTransition,
1011        log: &mut EventLog,
1012    ) -> Result<FullAdmissibilityWitness> {
1013        let witness = check_full_admissibility(
1014            transition,
1015            &self.capabilities,
1016            &self.kernel_state,
1017            self.contract,
1018            self.barrier,
1019            self.c_c_max,
1020        )
1021        .map_err(|e| anyhow::anyhow!("kernel: {e}"))?;
1022        emit(
1023            self.recorder,
1024            log,
1025            LoopEvent::ProposalChecked {
1026                call_id: call_id.to_string(),
1027                proposal: transition.proposal.clone(),
1028                witness: Box::new(witness.clone()),
1029            },
1030        )?;
1031        Ok(witness)
1032    }
1033
1034    /// Kernel check, promote, and execute one call.
1035    ///
1036    /// Both effect classes are certified by exactly one evaluator
1037    /// (`check_full_admissibility`): non-mutating effects on the identity
1038    /// transition before any process launches, mutating effects again on the
1039    /// realized transition after the reversible overlay was touched.
1040    async fn check_and_apply(
1041        &mut self,
1042        call: &ProviderToolCall,
1043        recall: Option<&resident::RecallIndex>,
1044        log: &mut EventLog,
1045        projection: &mut ProjectionMismatch,
1046        mutations: &mut u32,
1047    ) -> Result<String> {
1048        let Some(entry) = self.catalog.lookup(&call.name).cloned() else {
1049            return self.deny(
1050                log,
1051                projection,
1052                &call.call_id,
1053                format!("unknown tool {:?}", call.name),
1054                perspt_sdk::ResidualClass::CapabilityDenied,
1055            );
1056        };
1057        let scope = proposal_scope(call, &entry);
1058        let before = self.executor.checkpoint(&scope).await?;
1059        let proposal = proposal_from(
1060            call,
1061            &entry,
1062            &self.node_id,
1063            self.generation,
1064            &before.witness,
1065        );
1066        emit(
1067            self.recorder,
1068            log,
1069            LoopEvent::ProposalObserved {
1070                call_id: call.call_id.clone(),
1071                proposal: proposal.clone(),
1072            },
1073        )?;
1074        self.kernel_state
1075            .set_witness("__candidate_root", before.witness.state_root.clone());
1076
1077        // Full five-clause certification on the identity transition before
1078        // even the reversible candidate overlay is touched.
1079        let identity = CandidateTransition::new(
1080            proposal.clone(),
1081            before.witness.clone(),
1082            before.witness.clone(),
1083        );
1084        let witness = self.certify(&call.call_id, &identity, log)?;
1085        if let Some(reason) = uncertified_reason(&witness) {
1086            return self.deny(
1087                log,
1088                projection,
1089                &call.call_id,
1090                reason,
1091                perspt_sdk::ResidualClass::CapabilityDenied,
1092            );
1093        }
1094
1095        if !candidate_mutating_effect(entry.effect) {
1096            // Reads and governed verifier processes do not change the logical
1097            // candidate state, so the identity certification above is their
1098            // complete Def. 3.2 witness; no external process launches on a
1099            // partial one.
1100            promote_matching_capability(&mut self.capabilities, &witness)
1101                .map_err(|error| anyhow::anyhow!("promotion: {error}"))?;
1102            return self.execute_non_mutating(call, &entry, recall, log).await;
1103        }
1104
1105        self.apply_mutating(call, &entry, proposal, &before, log, projection, mutations)
1106            .await
1107    }
1108
1109    /// Apply a mutating call to the reversible overlay, certify the realized
1110    /// transition, and either keep it (debiting the capability) or restore.
1111    #[allow(clippy::too_many_arguments)]
1112    async fn apply_mutating(
1113        &mut self,
1114        call: &ProviderToolCall,
1115        entry: &ToolEntry,
1116        proposal: EffectProposal,
1117        before: &CandidateCheckpoint,
1118        log: &mut EventLog,
1119        projection: &mut ProjectionMismatch,
1120        mutations: &mut u32,
1121    ) -> Result<String> {
1122        let bracket_key = self.open_effect_bracket(call, entry)?;
1123        let outcome = self.executor.apply(call, entry).await?;
1124        if outcome.completed {
1125            if let (Some(recorder), Some(key)) = (self.recorder, bracket_key.as_deref()) {
1126                recorder.external_result(key, &serde_json::json!({"mutated": outcome.mutated}))?;
1127            }
1128        }
1129        let after = self.executor.state_witness().await?;
1130        let transition = CandidateTransition::new(proposal, before.witness.clone(), after);
1131        let witness = self.certify(&call.call_id, &transition, log)?;
1132        let output = bounded_model_output(self.recorder, outcome.output)?;
1133
1134        if let Some(reason) = uncertified_reason(&witness) {
1135            self.executor.restore(before).await?;
1136            // Denials are evidence, not errors: returned to the model so the
1137            // loop can adapt, and recorded for the ledger.
1138            return self.deny(
1139                log,
1140                projection,
1141                &call.call_id,
1142                reason,
1143                perspt_sdk::ResidualClass::CapabilityDenied,
1144            );
1145        }
1146
1147        // The debit and the effect stand or fall together: a failed promotion
1148        // restores the overlay and consumes no capability budget.
1149        if let Err(e) = promote_matching_capability(&mut self.capabilities, &witness) {
1150            self.executor.restore(before).await?;
1151            return self.deny(
1152                log,
1153                projection,
1154                &call.call_id,
1155                format!("promotion: {e}"),
1156                perspt_sdk::ResidualClass::CapabilityDenied,
1157            );
1158        }
1159        if outcome.mutated {
1160            *mutations += 1;
1161            self.reserve_verifier_action(log)?;
1162            let boundary = self.measurer.measure_incremental().await?;
1163            emit(
1164                self.recorder,
1165                log,
1166                LoopEvent::EffectBoundaryMeasured {
1167                    call_id: call.call_id.clone(),
1168                    node_id: self.node_id.clone(),
1169                    generation: self.generation,
1170                    energy: boundary.energy,
1171                    hard_pass: boundary.hard_pass,
1172                    residuals: boundary.residuals,
1173                },
1174            )?;
1175        }
1176        emit(
1177            self.recorder,
1178            log,
1179            LoopEvent::EffectApplied {
1180                call_id: call.call_id.clone(),
1181                mutated: outcome.mutated,
1182                output: output.clone(),
1183            },
1184        )?;
1185        Ok(output)
1186    }
1187}
1188
1189const MODEL_OUTPUT_PREVIEW_BYTES: usize = 8 * 1024;
1190
1191fn bounded_model_output(recorder: Option<&dyn LoopRecorder>, output: String) -> Result<String> {
1192    if output.len() <= MODEL_OUTPUT_PREVIEW_BYTES || recorder.is_none() {
1193        return Ok(output);
1194    }
1195    let recorder = recorder.expect("checked above");
1196    let handle = recorder.record_artifact(output.as_bytes(), "text/plain; charset=utf-8")?;
1197    let mut boundary = MODEL_OUTPUT_PREVIEW_BYTES;
1198    while !output.is_char_boundary(boundary) {
1199        boundary -= 1;
1200    }
1201    Ok(format!(
1202        "{}\n[full output: artifact:{handle}; {} bytes]",
1203        &output[..boundary],
1204        output.len()
1205    ))
1206}
1207
1208fn activated_tools_from_conversation(conversation: &Conversation) -> BTreeSet<String> {
1209    let mut search_calls = BTreeSet::new();
1210    let mut activated = BTreeSet::new();
1211    for message in conversation.messages() {
1212        match message {
1213            perspt_sdk::Message::AssistantToolCalls { calls } => {
1214                search_calls.extend(
1215                    calls
1216                        .iter()
1217                        .filter(|call| call.name == "tool_search")
1218                        .map(|call| call.call_id.clone()),
1219                );
1220            }
1221            perspt_sdk::Message::ToolResponse { call_id, content }
1222                if search_calls.contains(call_id) =>
1223            {
1224                if let Ok(specs) = serde_json::from_str::<Vec<perspt_sdk::ToolSpec>>(content) {
1225                    activated.extend(specs.into_iter().map(|spec| spec.name));
1226                }
1227            }
1228            _ => {}
1229        }
1230    }
1231    activated
1232}
1233
1234/// Paths a call names, for the checkpoint scope. An entry that declares
1235/// `proposal_bindings` is authoritative; the conventional `path`/`to`/`from`
1236/// fields cover the builtins, which declare none.
1237fn push_correction(
1238    context: &mut LoopContext,
1239    measured: &Measured,
1240    accepted: bool,
1241    recorder: Option<&dyn LoopRecorder>,
1242    log: &mut EventLog,
1243) -> Result<()> {
1244    // Infrastructure-only residuals are not code defects: steering the
1245    // model to "fix" them loops forever. Name the gap instead — the model
1246    // can add missing project configuration or report the blocker.
1247    let infra_only = !measured.residuals.is_empty()
1248        && measured
1249            .residuals
1250            .iter()
1251            .all(|residual| residual.class == perspt_sdk::ResidualClass::SensorUnavailable);
1252    if infra_only {
1253        let gaps: Vec<&str> = measured
1254            .residuals
1255            .iter()
1256            .take(4)
1257            .map(|residual| residual.evidence.summary.as_str())
1258            .collect();
1259        return context.push_user(
1260            format!(
1261                "Verification is blocked by unavailable sensors, not by the \
1262                 code: {}. If project configuration can enable them (for \
1263                 example a missing manifest), add it; otherwise state the \
1264                 blocker plainly and stop.",
1265                gaps.join("; ")
1266            ),
1267            recorder,
1268            log,
1269        );
1270    }
1271    let instruction = measured
1272        .correction
1273        .as_ref()
1274        .map(|c| c.instruction.clone())
1275        .unwrap_or_else(|| {
1276            if accepted {
1277                format!(
1278                    "The candidate descended to V = {:.3}. Continue addressing the remaining residuals.",
1279                    measured.energy
1280                )
1281            } else {
1282                format!(
1283                    "The candidate did not descend (V = {:.3}). Address the dominant residual.",
1284                    measured.energy
1285                )
1286            }
1287        });
1288    context.push_user(instruction, recorder, log)
1289}
1290
1291/// The seeded goal plus the baseline's typed diagnostics (up to six),
1292/// so the first model turn starts from measured evidence.
1293fn goal_with_baseline(goal: &str, baseline: &Measured) -> String {
1294    if baseline.residuals.is_empty() {
1295        return goal.to_string();
1296    }
1297    let mut text = goal.to_string();
1298    text.push_str("\n\nBaseline diagnostics (already measured):\n");
1299    for residual in baseline.residuals.iter().take(6) {
1300        text.push_str("- ");
1301        text.push_str(&residual.evidence.summary);
1302        text.push('\n');
1303    }
1304    text
1305}
1306
1307/// The finite decision bound the loop must respect. Computed once at node
1308/// entry from the measured baseline; the loop refuses the `(N_gate + 1)`-th
1309/// gate submission and terminates through the exhaustion path (Gate X).
1310pub fn loop_decision_bound(baseline_energy: f64, budgets: &LoopBudgets) -> Result<u64> {
1311    perspt_sdk::finite_decision_bound(baseline_energy, budgets.rho_gate, budgets.rejection_budget)
1312        .map_err(|e| anyhow::anyhow!("{e}"))
1313}
1314
1315fn emit(recorder: Option<&dyn LoopRecorder>, log: &mut EventLog, event: LoopEvent) -> Result<()> {
1316    if let Some(recorder) = recorder {
1317        recorder.record(&event)?;
1318    }
1319    log.push(&event)?;
1320    Ok(())
1321}