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}