1use 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#[derive(Debug, Clone)]
50pub struct LoopBudgets {
51 pub max_turns: u32,
53 pub max_calls_per_turn: u32,
55 pub rejection_budget: u32,
57 pub rho_gate: f64,
59 pub declared_energy_floor: Option<f64>,
61 pub context_soft_limit_chars: usize,
64 pub recovery_budget: u32,
66 pub turn_deadline_secs: u64,
69 pub resident: resident::ResidentReserves,
71}
72
73impl LoopBudgets {
74 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
95pub struct ToolLoop<'a> {
97 pub transport: &'a dyn ModelTransport,
98 pub model: ModelId,
99 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 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 pub system_prompt: PromptEnvelope,
120 pub recorder: Option<&'a dyn LoopRecorder>,
121 pub partial_seed_root: Option<String>,
124 pub resumed_birth_deps: Vec<perspt_sdk::prompt::StateDependency>,
127 pub search_budget: Option<perspt_sdk::search::SharedSearchBudget>,
130}
131
132#[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
160struct TurnState {
162 log: EventLog,
163 projection: ProjectionMismatch,
164 recovery: RecoveryCascade,
165 cursor: CompactionCursor,
166 accepted_checkpoint: CandidateCheckpoint,
167 trajectory: AcceptedTrajectory,
168 decision_bound: u64,
171 candidate_seq: u64,
173 current_candidate: String,
176 turns_used: u32,
177 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 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
204struct 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#[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
240pub(crate) fn is_transport_exhaustion(error: &anyhow::Error) -> bool {
242 error
243 .chain()
244 .any(|cause| cause.downcast_ref::<TransportExhausted>().is_some())
245}
246
247enum BoundaryStep {
249 Terminal(NodeTerminalOutcome),
250 Continue,
251 Exhausted,
252}
253
254impl ToolLoop<'_> {
255 pub async fn run(self, goal: &str) -> Result<LoopOutcome> {
257 self.run_with_conversation(goal, None, Vec::new()).await
258 }
259
260 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 #[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 return self.deny(
1139 log,
1140 projection,
1141 &call.call_id,
1142 reason,
1143 perspt_sdk::ResidualClass::CapabilityDenied,
1144 );
1145 }
1146
1147 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
1234fn push_correction(
1238 context: &mut LoopContext,
1239 measured: &Measured,
1240 accepted: bool,
1241 recorder: Option<&dyn LoopRecorder>,
1242 log: &mut EventLog,
1243) -> Result<()> {
1244 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
1291fn 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
1307pub 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}