1use super::*;
4
5pub struct Psp9Recorder {
7 pub(crate) session_id: String,
8 pub(crate) store: Arc<SessionStore>,
9 ledger: Mutex<Ledger>,
10 event_sender: Option<perspt_core::events::channel::EventSender>,
11}
12
13impl std::fmt::Debug for Psp9Recorder {
14 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
15 f.debug_struct("Psp9Recorder")
16 .field("session_id", &self.session_id)
17 .field("head", &self.head())
18 .finish_non_exhaustive()
19 }
20}
21
22impl Psp9Recorder {
23 pub(crate) fn create(
24 session_id: &str,
25 task: &str,
26 working_dir: &Path,
27 database_path: Option<&Path>,
28 shared_store: Option<Arc<SessionStore>>,
29 event_sender: Option<perspt_core::events::channel::EventSender>,
30 ) -> Result<Self> {
31 let store = match (shared_store, database_path) {
34 (Some(store), _) => store,
35 (None, Some(path)) => Arc::new(SessionStore::open(path).with_context(|| {
36 format!(
37 "opening session database {}; if it is corrupted (for example a \
38 poisoned WAL), run `perspt db repair {}`",
39 path.display(),
40 path.display()
41 )
42 })?),
43 (None, None) => Arc::new(SessionStore::new()?),
44 };
45 store.create_session(&SessionRecord {
46 session_id: session_id.into(),
47 task: task.into(),
48 working_dir: working_dir.display().to_string(),
49 merkle_root: None,
50 detected_toolchain: None,
51 status: "RUNNING_PSP9".into(),
52 })?;
53 store.initialize_authority_epoch(session_id, 0)?;
54 Ok(Self {
55 session_id: session_id.into(),
56 store,
57 ledger: Mutex::new(Ledger::new()),
58 event_sender,
59 })
60 }
61
62 pub(crate) fn attach(
66 session_id: &str,
67 database_path: Option<&Path>,
68 shared_store: Option<Arc<SessionStore>>,
69 event_sender: Option<perspt_core::events::channel::EventSender>,
70 ) -> Result<Self> {
71 let store = match (shared_store, database_path) {
72 (Some(store), _) => store,
73 (None, Some(path)) => Arc::new(SessionStore::open(path).with_context(|| {
74 format!(
75 "opening session database {}; if it is corrupted (for example a \
76 poisoned WAL), run `perspt db repair {}`",
77 path.display(),
78 path.display()
79 )
80 })?),
81 (None, None) => Arc::new(SessionStore::new()?),
82 };
83 let rows = store.get_psp9_events(session_id)?;
84 anyhow::ensure!(!rows.is_empty(), "session has no durable ledger to resume");
85 let mut ledger = Ledger::new();
86 for row in &rows {
87 let event: LedgerEvent = serde_json::from_str(&row.event_json)?;
88 let head = ledger
89 .append(event)
90 .map_err(|e| anyhow::anyhow!(e.to_string()))?;
91 anyhow::ensure!(
92 head == row.hash,
93 "ledger hash mismatch at sequence {}; refusing to resume",
94 row.sequence
95 );
96 }
97 store.update_session_status(session_id, "RUNNING_PSP9")?;
98 Ok(Self {
99 session_id: session_id.into(),
100 store,
101 ledger: Mutex::new(ledger),
102 event_sender,
103 })
104 }
105
106 pub fn record_custom(&self, kind: &str, payload: serde_json::Value) -> Result<()> {
107 self.append(LedgerEvent::Custom {
108 kind: kind.into(),
109 payload,
110 })
111 }
112
113 pub(crate) fn append(&self, event: LedgerEvent) -> Result<()> {
118 let mut guard = self.ledger.lock().unwrap();
119 let record = guard
120 .stage(event)
121 .map_err(|e| anyhow::anyhow!(e.to_string()))?;
122 self.store.record_psp9_event(&Psp9LedgerRow {
123 session_id: self.session_id.clone(),
124 sequence: record.sequence as i64,
125 event_json: serde_json::to_string(&record.event)?,
126 prev_hash: record.prev_hash.clone(),
127 hash: record.hash.clone(),
128 })?;
129 guard
130 .commit_staged(record)
131 .map_err(|e| anyhow::anyhow!(e.to_string()))?;
132 Ok(())
133 }
134
135 pub fn head(&self) -> String {
136 self.ledger.lock().unwrap().head()
137 }
138
139 pub(crate) fn finish(&self, status: &str) -> Result<()> {
140 self.store.update_session_status(&self.session_id, status)
141 }
142
143 pub(crate) fn authority_epoch(&self) -> Result<u64> {
144 self.store.authority_epoch(&self.session_id)
145 }
146
147 pub(crate) fn record_grant_policy(
148 &self,
149 policy: &GrantPolicy,
150 signed: Option<&perspt_sdk::SignedGrantPolicy>,
151 ) -> Result<()> {
152 let durable = signed
153 .map(serde_json::to_string)
154 .transpose()?
155 .unwrap_or_else(|| serde_json::to_string(policy).expect("serializable grant policy"));
156 self.store
157 .record_grant_policy(&self.session_id, &policy.policy_id, &durable)?;
158 self.record_custom(
159 "grant_policy",
160 serde_json::json!({
161 "policy": policy,
162 "signed": signed.is_some(),
163 }),
164 )
165 }
166
167 pub(crate) fn record_external_intent(
168 &self,
169 key: &str,
170 intent: &serde_json::Value,
171 ) -> Result<()> {
172 let bytes = serde_json::to_vec(intent)?;
173 self.store.record_external_effect_intent(
174 &self.session_id,
175 key,
176 &perspt_sdk::ledger::content_hash(&bytes),
177 &String::from_utf8(bytes)?,
178 )?;
179 self.record_custom("external_effect_intent", intent.clone())
180 }
181
182 pub(crate) fn complete_external_effect(
183 &self,
184 key: &str,
185 result: &serde_json::Value,
186 ) -> Result<()> {
187 self.store.complete_external_effect(
188 &self.session_id,
189 key,
190 &serde_json::to_string(result)?,
191 )?;
192 self.record_custom("external_effect_completed", result.clone())
193 }
194}
195
196impl LoopRecorder for Psp9Recorder {
197 fn external_intent(&self, key: &str, intent: &serde_json::Value) -> Result<()> {
198 self.record_external_intent(key, intent)
199 }
200
201 fn external_result(&self, key: &str, result: &serde_json::Value) -> Result<()> {
202 self.complete_external_effect(key, result)
203 }
204
205 fn record(&self, event: &LoopEvent) -> Result<()> {
206 let envelope = crate::toolloop::LoopEventEnvelopeV1::new(event.clone());
209 self.record_custom("tool_loop", serde_json::to_value(&envelope)?)?;
210 if let LoopEvent::ContextCheckpointCreated { checkpoint } = event {
211 self.store.record_psp9_checkpoint(
212 &self.session_id,
213 &checkpoint.covered_event_root,
214 &serde_json::to_string(checkpoint)?,
215 )?;
216 }
217 if let LoopEvent::DurableCandidateCheckpoint {
218 candidate_id,
219 state_root,
220 control,
221 conversation,
222 birth_deps,
223 canonical_scope,
224 files,
225 } = event
226 {
227 self.store.record_psp9_checkpoint(
230 &self.session_id,
231 state_root,
232 &serde_json::to_string(&serde_json::json!({
233 "kind": "candidate",
234 "candidate_id": candidate_id,
235 "state_root": state_root,
236 "control": control,
237 "conversation": conversation,
238 "birth_deps": birth_deps,
239 "canonical_scope": canonical_scope,
240 "files": files,
241 }))?,
242 )?;
243 }
244 if let Some(sender) = &self.event_sender {
245 if let LoopEvent::CandidateMeasured {
248 node_id, energy, ..
249 } = event
250 {
251 let _ = sender.send(perspt_core::AgentEvent::EnergyUpdated {
252 node_id: node_id.clone(),
253 energy: *energy as f32,
254 });
255 }
256 let message = narration(event);
257 if let Some(message) = message {
258 let _ = sender.send(perspt_core::AgentEvent::Log(message));
259 }
260 }
261 Ok(())
262 }
263
264 fn record_artifact(&self, content: &[u8], media_type: &str) -> Result<String> {
265 let handle = perspt_sdk::ledger::content_hash(content);
266 self.store.put_psp9_artifact(&handle, content, media_type)?;
267 Ok(handle)
268 }
269
270 fn fetch_artifact(&self, handle: &str) -> Result<Option<Vec<u8>>> {
271 self.store.get_psp9_artifact(handle)
272 }
273}
274
275fn narration(event: &LoopEvent) -> Option<String> {
277 match event {
278 LoopEvent::CandidateMeasured {
279 node_id,
280 generation,
281 energy,
282 hard_pass,
283 residuals,
284 ..
285 } => Some(format!(
286 "Measured {node_id} generation {generation}: V={energy:.3}, \
287 hard_pass={hard_pass}, residuals={}",
288 residuals.len()
289 )),
290 LoopEvent::GateDecisionRecorded {
291 node_id,
292 generation,
293 decision,
294 ..
295 } => Some(format!(
296 "Gate {node_id} generation {generation}: {decision:?}"
297 )),
298 LoopEvent::EffectDenied {
299 call_id, reason, ..
300 } => Some(format!("Effect {call_id} denied: {reason}")),
301 LoopEvent::EffectApplied {
302 call_id, mutated, ..
303 } => Some(format!(
304 "Effect {call_id} applied to candidate (mutated={mutated})"
305 )),
306 LoopEvent::RouteFailover {
307 from_model,
308 to_model,
309 cause,
310 } => Some(format!(
311 "Route failover {from_model} -> {to_model}: {cause}"
312 )),
313 LoopEvent::ContextCheckpointCreated { checkpoint } => Some(format!(
314 "Context checkpoint {} covers events {}..{}",
315 &checkpoint.covered_event_root[..12.min(checkpoint.covered_event_root.len())],
316 checkpoint.covered_from,
317 checkpoint.covered_to
318 )),
319 LoopEvent::RecoveryContained { reason, .. } => {
320 Some(format!("Recovery contained the node: {reason}"))
321 }
322 _ => None,
323 }
324}
325
326impl super::Psp9AgentRuntime {
327 pub(super) fn record_route_capabilities(&self, recorder: &Psp9Recorder) -> Result<()> {
328 for (position, model) in std::iter::once(&self.model)
329 .chain(self.fallback_models.iter())
330 .enumerate()
331 {
332 let capabilities = self.transport.capabilities(model);
333 let mut degradations = Vec::new();
334 if !capabilities.strict_schema {
335 degradations.push("strict_schema:local_validation");
336 }
337 if !capabilities.parallel_tool_calls {
338 degradations.push("parallel_tool_calls:sequential_execution");
339 }
340 if !capabilities.streaming_tool_calls {
341 degradations.push("streaming_tool_calls:turn_granular_progress");
342 }
343 if !capabilities.prompt_caching {
344 degradations.push("prompt_caching:cache_cold_accounting");
345 }
346 if !capabilities.structured_output {
347 degradations.push("structured_output:local_parse_and_validation");
348 }
349 recorder.record_custom(
350 "provider_capability_evidence",
351 serde_json::json!({
352 "model": model,
353 "route_position": position,
354 "source": "declared",
355 "capabilities": capabilities,
356 "degradations": degradations,
357 }),
358 )?;
359 }
360 Ok(())
361 }
362}
363
364impl Psp9Recorder {
365 pub(crate) fn record_prompt_program(
369 &self,
370 program: &perspt_sdk::prompt::CompiledPromptProgram,
371 ) -> Result<()> {
372 let sections: Vec<&perspt_sdk::prompt::SectionProvenance> = program
373 .messages
374 .iter()
375 .flat_map(|message| message.sections.iter())
376 .collect();
377 self.record_custom(
378 "prompt_program_compiled",
379 serde_json::json!({
380 "stage": program.stage.dir_name(),
381 "route": program.route,
382 "dialect": program.dialect,
383 "sections": sections,
384 "dropped_sections": program.dropped_sections,
385 "tool_spec_hash": program.tool_spec_hash,
386 "digest": program.program_digest,
387 }),
388 )
389 }
390
391 pub(crate) fn record_prompt_invocation(
394 &self,
395 actor: &str,
396 turn: u32,
397 invocation: &perspt_sdk::prompt::CompiledPromptInvocation,
398 ) -> Result<()> {
399 self.record_custom(
400 "prompt_program_invoked",
401 serde_json::json!({
402 "actor": actor,
403 "turn": turn,
404 "platform_digest": invocation.platform.program_digest,
405 "domain_digest": invocation.domain.program_digest,
406 "invocation_digest": invocation.invocation_digest,
407 "tool_spec_hash": invocation.platform.tool_spec_hash,
408 }),
409 )
410 }
411}