Skip to main content

perspt_agent/toolloop/
contract.rs

1//! The tool loop's ports and durable event vocabulary: executors,
2//! measurers, recorded events, and the rolling event log.
3
4use super::*;
5
6/// One exported file of an accepted candidate. `content` is the accepted
7/// overlay state (`None` = deleted); `source_preimage` is the workspace state
8/// from which the first mutation was derived. Resume must preserve both so a
9/// later promotion cannot overwrite user edits made while the session slept.
10#[derive(Debug, Clone)]
11pub struct SeedFile {
12    pub path: String,
13    pub content: Option<Vec<u8>>,
14    pub source_preimage: Option<Vec<u8>>,
15}
16
17/// Content-addressed form persisted in a durable candidate checkpoint.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct DurableSeedFile {
20    pub path: String,
21    pub content_artifact: Option<String>,
22    pub source_preimage_artifact: Option<String>,
23}
24
25/// What applying one admitted call did to the candidate overlay.
26#[derive(Debug, Clone)]
27pub struct EffectOutcome {
28    /// Tool output returned to the model as the tool response.
29    pub output: String,
30    /// Whether the overlay was mutated.
31    pub mutated: bool,
32    /// Whether the executor observed a definitive completion. A durable
33    /// effect with an uncertain result deliberately leaves its write-ahead
34    /// bracket open for reconciliation.
35    pub completed: bool,
36}
37
38/// Opaque executor checkpoint plus its measured state witness.
39#[derive(Debug, Clone)]
40pub struct CandidateCheckpoint {
41    pub id: String,
42    pub witness: CandidateStateWitness,
43}
44
45/// Applies admitted calls to the candidate overlay (the existing sandboxed
46/// executors are the first driver).
47#[async_trait::async_trait]
48pub trait EffectExecutor: Send + Sync {
49    /// Snapshot the reversible candidate overlay.
50    async fn checkpoint(&self, scope: &[String]) -> Result<CandidateCheckpoint>;
51
52    async fn apply(&self, call: &ProviderToolCall, entry: &ToolEntry) -> Result<EffectOutcome>;
53
54    /// Restore an earlier candidate snapshot exactly.
55    async fn restore(&self, checkpoint: &CandidateCheckpoint) -> Result<()>;
56
57    /// Re-read the materialized candidate after an effect.
58    async fn state_witness(&self) -> Result<CandidateStateWitness>;
59
60    /// The accepted candidate's mutated paths with their current contents
61    /// (`None` = deleted), for durable mid-loop checkpoints. The default is
62    /// empty: fixtures without a filesystem have nothing durable to export.
63    async fn export_accepted(&self) -> Result<Vec<SeedFile>> {
64        Ok(Vec::new())
65    }
66}
67
68/// One measured evaluation of the realized candidate.
69#[derive(Debug, Clone, Default)]
70pub struct Measured {
71    pub hard_pass: bool,
72    pub energy: f64,
73    pub residuals: Vec<ResidualEvent>,
74    /// The domain's directed correction for the dominant residual.
75    pub correction: Option<CorrectionDirection>,
76    /// The typed correction packet folded from all residuals (PSP-10
77    /// system 26). `None` on domains that have not opted in; an empty
78    /// packet never causes a blind retry.
79    pub packet: Option<perspt_sdk::CorrectionPacket>,
80}
81
82/// Re-reads the candidate overlay and runs the declared verifier suite
83/// against it (Paper I Def. 12.2: measurement runs on the realized state).
84#[async_trait::async_trait]
85pub trait CandidateMeasurer: Send + Sync {
86    async fn measure(&self) -> Result<Measured>;
87
88    /// Cheapest realized-state boundary available after one mutation. The
89    /// default is the complete suite, which is conservative; domain drivers
90    /// may provide an incremental parser without weakening the gate suite.
91    async fn measure_incremental(&self) -> Result<Measured> {
92        self.measure().await
93    }
94}
95
96/// The compiled prompt binding for one actor (PSP-10 systems 23–24). The
97/// runtime compiles it through the SDK compiler for the resolved route and
98/// dialect; the loop seeds the text, stamps the digests into every control
99/// frame, and — holding the stage — recompiles whenever the offered tool
100/// surface or the failover route changes mid-loop, so each call's exact
101/// program is ledgered.
102#[derive(Debug, Clone, Default)]
103pub struct PromptEnvelope {
104    pub text: String,
105    pub invocation_digest: String,
106    pub manifest_digest: String,
107    /// The platform stage sections, kept for per-call recompilation.
108    pub stage: Option<perspt_core::prompts::PlatformStage>,
109    /// The domain's same-stage sections, rendered once at assembly.
110    pub domain_sections: Vec<perspt_sdk::prompt::RenderedSection>,
111    /// The invocation compiled at seed time (initial tool surface).
112    pub invocation: Option<perspt_sdk::prompt::CompiledPromptInvocation>,
113}
114
115/// Recorded loop events (the ledger consumes these in system 14).
116#[derive(Debug, Clone, Serialize, Deserialize)]
117#[serde(tag = "event", rename_all = "snake_case")]
118pub enum LoopEvent {
119    /// The fold base for the model context (Gate O). Additive: old ledgers
120    /// without seeds still replay.
121    ConversationSeeded {
122        seed: perspt_sdk::ConversationSeeded,
123    },
124    /// One digest-chained model-context change, recorded before it is
125    /// applied to the live projection.
126    ConversationDelta {
127        record: perspt_sdk::ConversationDeltaRecord,
128    },
129    TurnObserved {
130        turn: u32,
131        /// Which actor observed this output (PSP-10 system 27). Empty on
132        /// pre-PSP-10 rows; the observation is always recorded before any
133        /// parse.
134        #[serde(default)]
135        actor: String,
136        output: TurnOutput,
137    },
138    ToolCallObserved {
139        call: ProviderToolCall,
140    },
141    ProposalObserved {
142        call_id: String,
143        proposal: EffectProposal,
144    },
145    ProposalChecked {
146        call_id: String,
147        proposal: EffectProposal,
148        witness: Box<FullAdmissibilityWitness>,
149    },
150    EffectApplied {
151        call_id: String,
152        mutated: bool,
153        output: String,
154    },
155    /// A same-turn footprint collision (Gate P): the call was returned to
156    /// the model as an observation, never given an invented order.
157    ToolBatchConflict {
158        call_id: String,
159        conflicts_with: String,
160        resources: Vec<String>,
161    },
162    EffectDenied {
163        call_id: String,
164        reason: String,
165        /// Typed admissibility residual (PSP-9 system 9): `CapabilityDenied`
166        /// for kernel denials, `BudgetExhausted` for budget boundaries.
167        #[serde(default = "default_denial_class")]
168        class: perspt_sdk::ResidualClass,
169    },
170    CandidateMeasured {
171        node_id: String,
172        generation: u32,
173        /// Candidate identity `"{node}/{gen}/c{seq}"` (PSP-10 Phase 2). The
174        /// accepted fold keys by it; empty on pre-PSP-10 rows.
175        #[serde(default)]
176        candidate_id: String,
177        energy: f64,
178        hard_pass: bool,
179        residuals: Vec<ResidualEvent>,
180    },
181    GateDecisionRecorded {
182        node_id: String,
183        generation: u32,
184        /// Shares the measurement's candidate identity so the two events
185        /// key together (Proposition 2). Empty on pre-PSP-10 rows.
186        #[serde(default)]
187        candidate_id: String,
188        decision: GateDecision,
189        /// Recorded from the trajectory's `GateDecisionRef`, never recovered
190        /// by correlation. `None` marks a pre-PSP-10 row.
191        #[serde(default)]
192        observed_energy: Option<f64>,
193        #[serde(default)]
194        best_accepted_before: Option<f64>,
195    },
196    /// The loop refused to submit a candidate past the finite-decision bound
197    /// `N_gate = floor(V0/rho) + B + 1` (PSP-10 Gate X). The refusal itself
198    /// appends no gate decision.
199    DecisionBoundRefused {
200        node_id: String,
201        generation: u32,
202        bound: u64,
203        decisions_used: u64,
204    },
205    CandidateRestored {
206        checkpoint_id: String,
207    },
208    EffectBoundaryMeasured {
209        call_id: String,
210        node_id: String,
211        generation: u32,
212        energy: f64,
213        hard_pass: bool,
214        residuals: Vec<ResidualEvent>,
215    },
216    ContextCheckpointCreated {
217        checkpoint: ContextCheckpoint,
218    },
219    /// A gate acceptance made durable: the control frame plus every mutated
220    /// path's content-addressed artifact handle, sufficient to rebuild the
221    /// accepted candidate and continue the loop after a crash.
222    DurableCandidateCheckpoint {
223        /// The accepted candidate this checkpoint makes durable (PSP-10
224        /// Phase 2). Empty on pre-PSP-10 rows.
225        #[serde(default)]
226        candidate_id: String,
227        state_root: String,
228        control: ControlFrame,
229        /// Exact provider-neutral projection selected for the next model turn.
230        conversation: Conversation,
231        /// Per-message birth dependencies, parallel to the conversation
232        /// (Gate AF): a resumed loop restores each page's real birth state
233        /// instead of relabelling everything with the live root. Empty on
234        /// pre-provenance rows.
235        #[serde(default)]
236        birth_deps: Vec<perspt_sdk::prompt::StateDependency>,
237        /// Exact scope used to compute `state_root`, including read paths.
238        canonical_scope: Vec<String>,
239        files: Vec<DurableSeedFile>,
240    },
241    RecoveryControlGranted {
242        failure: FailureKind,
243        level: perspt_sdk::CascadeLevel,
244        forced_escalation: bool,
245        model: ModelId,
246    },
247    RouteFailover {
248        from_model: ModelId,
249        to_model: ModelId,
250        cause: String,
251    },
252    RecoveryContained {
253        reason: String,
254        restored_checkpoint_id: String,
255    },
256    // --- PSP-10 search alphabet (system 21). Emitted by the forest
257    // runtime; every event carries forest_id and branch_id where one
258    // exists. Defined with the envelope so the wire shape is pinned before
259    // emission begins. ---
260    SearchOpened {
261        forest_id: String,
262        node_id: String,
263        generation: u32,
264        accepted_root: String,
265        limits: perspt_sdk::SearchLimits,
266        /// The interrupted forest this one deterministically re-runs, when
267        /// resuming (additive; absent on fresh forests and legacy rows).
268        #[serde(default, skip_serializing_if = "Option::is_none")]
269        resumed_from: Option<String>,
270    },
271    /// The forest's consumption at an epoch boundary, so an interrupted
272    /// run's usage is reconstructible from the ledger (Gate AC + resume).
273    SearchUsageSnapshot {
274        forest_id: String,
275        epoch: u64,
276        usage: perspt_sdk::SearchUsage,
277    },
278    BranchForked {
279        forest_id: String,
280        branch_id: String,
281        #[serde(default)]
282        parent_branch: Option<String>,
283        seed_checkpoint: String,
284        seed_witness: perspt_sdk::WitnessRef,
285    },
286    BranchStrategySelected {
287        forest_id: String,
288        branch_id: String,
289        strategy_id: String,
290    },
291    BranchObservation {
292        forest_id: String,
293        branch_id: String,
294        observation: String,
295    },
296    BranchCandidateMeasured {
297        forest_id: String,
298        branch_id: String,
299        candidate_id: String,
300        measurement: perspt_sdk::BranchMeasurement,
301    },
302    PartialCheckpointed {
303        forest_id: String,
304        branch_id: String,
305        checkpoint: perspt_sdk::PartialCheckpointRef,
306    },
307    FrontierEpochStarted {
308        forest_id: String,
309        epoch: u64,
310        /// Digest of the folded forest state; strict resume recomputes and
311        /// compares it.
312        forest_digest: String,
313    },
314    FrontierEntryServed {
315        forest_id: String,
316        branch_id: String,
317        epoch: u64,
318    },
319    BranchIneligible {
320        forest_id: String,
321        branch_id: String,
322        reason: String,
323    },
324    BranchNotSelected {
325        forest_id: String,
326        branch_id: String,
327    },
328    BranchAbandoned {
329        forest_id: String,
330        branch_id: String,
331        reason: String,
332    },
333    BranchSelected {
334        forest_id: String,
335        branch_id: String,
336        candidate_id: String,
337    },
338    BranchCommitted {
339        forest_id: String,
340        branch_id: String,
341        candidate_id: String,
342        decision: GateDecision,
343    },
344    NoGoodRecorded {
345        forest_id: String,
346        branch_id: String,
347        /// The exact key `K_ng` (Gate AB); support evidence hashed
348        /// separately.
349        key: String,
350        evidence_hash: String,
351        /// The Gate AB support class this record's evidence belongs to
352        /// (compiler-code, failed-test, contract-diagnostic,
353        /// unchanged-state, denied-capability); additive for replay
354        /// verification, empty on legacy rows.
355        #[serde(default)]
356        support_kind: String,
357    },
358    SearchClosed {
359        forest_id: String,
360        usage: perspt_sdk::SearchUsage,
361    },
362    // --- PSP-10 resident-context alphabet (Definition 6, Gate AF). ---
363    ContextWorkingSet {
364        #[serde(default)]
365        forest_id: String,
366        #[serde(default)]
367        branch_id: String,
368        turn: u32,
369        page_ids: Vec<String>,
370    },
371    ContextPagesSelected {
372        #[serde(default)]
373        forest_id: String,
374        #[serde(default)]
375        branch_id: String,
376        turn: u32,
377        resident_digest: String,
378        page_ids: Vec<String>,
379    },
380    ContextMiss {
381        #[serde(default)]
382        forest_id: String,
383        #[serde(default)]
384        branch_id: String,
385        turn: u32,
386        key: String,
387    },
388    ContextPageRecalled {
389        #[serde(default)]
390        forest_id: String,
391        #[serde(default)]
392        branch_id: String,
393        turn: u32,
394        page_id: String,
395    },
396    ContextInfeasible {
397        #[serde(default)]
398        forest_id: String,
399        #[serde(default)]
400        branch_id: String,
401        turn: u32,
402        required: u64,
403        allowance: u64,
404    },
405    ContextCompacted {
406        #[serde(default)]
407        forest_id: String,
408        #[serde(default)]
409        branch_id: String,
410        summary_page: String,
411        source_pages: Vec<String>,
412    },
413    /// A prompt program recompiled mid-loop — the offered tool surface or
414    /// the failover route changed, so the program identity changed
415    /// (PSP-10 Gate Z).
416    PromptProgramCompiled {
417        turn: u32,
418        program: perspt_sdk::prompt::CompiledPromptProgram,
419    },
420    /// The exact prompt binding of one model call: both program digests
421    /// and the invocation digest (one record per call, Gate Z).
422    PromptProgramInvoked {
423        turn: u32,
424        invocation_digest: String,
425        platform_digest: String,
426        domain_digest: String,
427        tool_spec_hash: String,
428        #[serde(default)]
429        resident_context_digest: String,
430    },
431}
432
433/// The versioned runtime-event envelope (PSP-10 system 21, Gate AD). Every
434/// post-cutover `tool_loop` row wraps its event in exactly this shape;
435/// rows without `schema_version` decode through the strict legacy decoder;
436/// unknown versions fail authoritative replay and resume closed.
437#[derive(Debug, Clone, Serialize, Deserialize)]
438pub struct LoopEventEnvelopeV1 {
439    /// Exactly 1.
440    pub schema_version: u16,
441    pub body: LoopEvent,
442}
443
444impl LoopEventEnvelopeV1 {
445    pub fn new(body: LoopEvent) -> Self {
446        Self {
447            schema_version: 1,
448            body,
449        }
450    }
451}
452
453fn default_denial_class() -> perspt_sdk::ResidualClass {
454    perspt_sdk::ResidualClass::CapabilityDenied
455}
456
457/// In-loop event log. With a durable recorder attached every event is already
458/// persisted before use, so only a rolling count and chain root are kept in
459/// memory; without one (conformance fixtures) the events are retained so
460/// tests can inspect them. This keeps long runs O(1) in event memory and
461/// makes each compaction O(1) instead of re-serializing the whole history.
462#[derive(Debug, Default)]
463pub struct EventLog {
464    retained: Vec<LoopEvent>,
465    retain: bool,
466    count: u64,
467    chain_root: String,
468}
469
470impl EventLog {
471    pub(super) fn new(retain: bool) -> Self {
472        Self {
473            retain,
474            ..Self::default()
475        }
476    }
477
478    pub(super) fn push(&mut self, event: &LoopEvent) -> Result<()> {
479        let hashed = perspt_sdk::ledger::content_hash(&serde_json::to_vec(event)?);
480        self.chain_root =
481            perspt_sdk::ledger::content_hash(format!("{}:{hashed}", self.chain_root).as_bytes());
482        self.count += 1;
483        if self.retain {
484            self.retained.push(event.clone());
485        }
486        Ok(())
487    }
488
489    pub fn count(&self) -> u64 {
490        self.count
491    }
492
493    /// Rolling chain root over every event pushed so far.
494    pub fn chain_root(&self) -> &str {
495        &self.chain_root
496    }
497
498    pub fn events(&self) -> &[LoopEvent] {
499        &self.retained
500    }
501
502    pub(super) fn into_events(self) -> Vec<LoopEvent> {
503        self.retained
504    }
505}
506
507/// Synchronous write-ahead event sink. Implementations must durably append
508/// before returning; the loop records observations before inspecting them.
509pub trait LoopRecorder: Send + Sync {
510    fn record(&self, event: &LoopEvent) -> Result<()>;
511
512    /// Write-ahead bracketing for durable external effects (system 13): the
513    /// intent is recorded before the effect runs and the result after, so an
514    /// interrupted run shows the open bracket. Defaults are no-ops for
515    /// in-memory conformance fixtures.
516    fn external_intent(&self, _key: &str, _intent: &serde_json::Value) -> Result<()> {
517        Ok(())
518    }
519
520    fn external_result(&self, _key: &str, _result: &serde_json::Value) -> Result<()> {
521        Ok(())
522    }
523
524    /// Persist exact observation bytes and return their content handle. The
525    /// default supports in-memory conformance fixtures; production recorders
526    /// override it with durable content-addressed storage.
527    fn record_artifact(&self, content: &[u8], _media_type: &str) -> Result<String> {
528        Ok(perspt_sdk::ledger::content_hash(content))
529    }
530
531    /// Retrieve previously recorded artifact bytes by content handle. The
532    /// default supports in-memory conformance fixtures, which never store.
533    fn fetch_artifact(&self, _handle: &str) -> Result<Option<Vec<u8>>> {
534        Ok(None)
535    }
536}
537
538/// The loop's terminal report.
539#[derive(Debug)]
540pub struct LoopOutcome {
541    pub outcome: NodeTerminalOutcome,
542    pub trajectory: AcceptedTrajectory,
543    pub events: Vec<LoopEvent>,
544    pub projection: ProjectionMismatch,
545    pub turns_used: u32,
546    /// Horizontal controls consumed from Paper III's one shared recovery pool.
547    pub recovery_spent: u32,
548    /// True when containment was caused by exhausted provider-transport
549    /// recovery — an infrastructure outcome, not a governance anomaly.
550    pub contained_by_transport: bool,
551}