1use std::path::{Path, PathBuf};
4use std::str::FromStr;
5use std::sync::{Arc, Mutex};
6
7use anyhow::{Context, Result};
8use perspt_coding::{CodingDomain, OperationalSafetyBarrier};
9use perspt_sdk::capability::CommandPattern;
10use perspt_sdk::capability::PathPattern;
11use perspt_sdk::{
12 ActorId, AgentDomainPackage, ApprovalPolicy, CandidateTransition, Capability, CapabilityRole,
13 ContractEvaluator, ContractWitness, Conversation, EffectKind, Footprint, GrantPolicy, Ledger,
14 LedgerEvent, ModelId, ModelTransport, NodeClass, NodeTerminalOutcome, Resource, RiskBudget,
15 Scheduler, StaticCatalog, ToolCatalog, ToolChoicePolicy, TurnOutput, WorkGraphRevision,
16 WorkNode, WorkNodeState,
17};
18use perspt_store::{Psp9LedgerRow, Psp9VerdictRow, SessionRecord, SessionStore};
19
20use crate::candidate::CandidateWorkspace;
21use crate::toolloop::{EffectExecutor, LoopBudgets, LoopEvent, LoopRecorder};
22use crate::transport::GenAiTransport;
23
24mod adjudicate;
25mod attempt;
26mod dispatch;
27mod explore;
28mod external;
29mod integrate;
30mod node;
31mod plan;
32mod prompt_bind;
33mod recorder;
34mod resume;
35mod search;
36mod settings;
37
38use external::{external_runtime_from, registry_with_external};
39use node::*;
40pub use recorder::Psp9Recorder;
41pub use settings::{Psp9ModelRoutes, Psp9RunConfig, Psp9RunSummary};
42
43pub struct Psp9AgentRuntime {
45 working_dir: PathBuf,
46 transport: Arc<dyn ModelTransport>,
47 model: ModelId,
48 fallback_models: Vec<ModelId>,
49 explorer_model: Option<ModelId>,
50 adjudicator_model: Option<ModelId>,
51 handoff_model: Option<ModelId>,
53 config: Psp9RunConfig,
54 event_sender: Option<perspt_core::events::channel::EventSender>,
55 action_receiver: tokio::sync::Mutex<Option<perspt_core::events::channel::ActionReceiver>>,
56 database_path: Option<PathBuf>,
57 shared_store: Option<Arc<SessionStore>>,
58 tool_handlers: Arc<crate::tools::handlers::CandidateHandlerRegistry>,
61 extra_tool_entries: Vec<perspt_sdk::ToolEntry>,
64 domain: Arc<dyn AgentDomainPackage>,
68 external: Option<Arc<tokio::sync::Mutex<crate::external_tools::ExternalToolRuntime>>>,
71 external_entries: Mutex<Option<Vec<perspt_sdk::ToolEntry>>>,
73 search: search::SearchSettings,
75 search_seed: Mutex<Option<search::SearchSeed>>,
79 prompt_overrides: perspt_core::prompts::SectionOverrides,
83}
84
85impl std::fmt::Debug for Psp9AgentRuntime {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 f.debug_struct("Psp9AgentRuntime")
88 .field("working_dir", &self.working_dir)
89 .field("model", &self.model)
90 .field("config", &self.config)
91 .finish_non_exhaustive()
92 }
93}
94
95impl Psp9AgentRuntime {
96 pub fn from_config(
97 working_dir: PathBuf,
98 config: &perspt_core::Config,
99 routes: Psp9ModelRoutes,
100 run_config: Psp9RunConfig,
101 ) -> Result<Self> {
102 let portfolio = Arc::new(perspt_core::ModelPortfolio::from_config(config)?);
103 let route = routes
104 .primary
105 .as_deref()
106 .or(routes.actuator.as_deref())
107 .or_else(|| config.models.as_ref().and_then(|m| m.actuator.as_deref()))
108 .or(config.actuator_model.as_deref())
109 .or(config.model.as_deref())
110 .unwrap_or_else(|| perspt_core::ModelTier::default_model_name());
111 let model = qualify_model(route, config, &portfolio)?;
112 portfolio.resolve(&model.provider)?;
114 let transport = Arc::new(GenAiTransport::new(portfolio));
115 let primary_capabilities = transport.capabilities(&model);
116 anyhow::ensure!(
117 primary_capabilities.tool_calling,
118 "route {model} does not declare native tool calling; PSP-9 \
119 tool-loop mode never emulates tool calls in text"
120 );
121 let (explorer_model, adjudicator_model, handoff_model) =
122 resolve_role_routes(&routes, config, &transport)?;
123 let fallback_models = resolve_fallbacks(&routes.fallbacks, &model, config, &transport)?;
124 let sampling = Arc::new(crate::external_tools::ModelTransportSamplingProvider::new(
125 transport.clone(),
126 model.clone(),
127 ));
128 let external = external_runtime_from(config, Some(sampling))?;
129 let handlers = registry_with_external(&external);
130 let run_config = settings::apply_config_overrides(run_config, config);
131 Ok(Self {
132 working_dir,
133 transport,
134 model,
135 fallback_models,
136 explorer_model,
137 adjudicator_model,
138 handoff_model,
139 config: run_config,
140 event_sender: None,
141 action_receiver: tokio::sync::Mutex::new(None),
142 database_path: None,
143 shared_store: None,
144 tool_handlers: Arc::new(handlers),
145 extra_tool_entries: Vec::new(),
146 domain: Arc::new(CodingDomain::new()),
147 external,
148 external_entries: Mutex::new(None),
149 search: search::SearchSettings::from_config(config.exploration.as_ref()),
150 search_seed: Mutex::new(None),
151 prompt_overrides: Default::default(),
152 })
153 }
154
155 pub fn with_transport(
158 working_dir: PathBuf,
159 transport: Arc<dyn ModelTransport>,
160 model: ModelId,
161 run_config: Psp9RunConfig,
162 ) -> Self {
163 Self {
164 working_dir,
165 transport,
166 model,
167 fallback_models: Vec::new(),
168 explorer_model: None,
169 adjudicator_model: None,
170 handoff_model: None,
171 config: run_config,
172 event_sender: None,
173 action_receiver: tokio::sync::Mutex::new(None),
174 database_path: None,
175 shared_store: None,
176 tool_handlers: Arc::new(
177 crate::tools::handlers::CandidateHandlerRegistry::with_builtins(),
178 ),
179 extra_tool_entries: Vec::new(),
180 domain: Arc::new(CodingDomain::new()),
181 external: None,
182 external_entries: Mutex::new(None),
183 search: search::SearchSettings::default(),
184 search_seed: Mutex::new(None),
185 prompt_overrides: Default::default(),
186 }
187 }
188
189 pub fn with_tool_handlers(
193 mut self,
194 mut handlers: crate::tools::handlers::CandidateHandlerRegistry,
195 ) -> Self {
196 if let (Some(external), false) = (&self.external, handlers.has_fallback()) {
197 handlers.set_fallback(Arc::new(
198 crate::tools::handlers::external::ExternalDispatcher::new(external.clone()),
199 ));
200 }
201 self.tool_handlers = Arc::new(handlers);
202 self
203 }
204
205 pub fn with_tool_family(mut self, entries: Vec<perspt_sdk::ToolEntry>) -> Self {
209 self.extra_tool_entries.extend(entries);
210 self
211 }
212
213 fn opted_in_effects(&self) -> Vec<EffectKind> {
215 if self.config.allow_dependency_mutation {
216 vec![EffectKind::MutateDependencies]
217 } else {
218 Vec::new()
219 }
220 }
221
222 pub fn with_domain(mut self, domain: Arc<dyn AgentDomainPackage>) -> Self {
225 self.domain = domain;
226 self
227 }
228
229 pub fn with_database_path(mut self, path: PathBuf) -> Self {
230 self.database_path = Some(path);
231 self
232 }
233
234 pub fn with_session_store(mut self, store: Arc<SessionStore>) -> Self {
238 self.database_path = Some(store.database_path().to_path_buf());
239 self.shared_store = Some(store);
240 self
241 }
242
243 pub fn with_prompt_overrides(
248 mut self,
249 overrides: perspt_core::prompts::SectionOverrides,
250 ) -> Self {
251 self.prompt_overrides = overrides;
252 self
253 }
254
255 pub fn with_search_limits(mut self, limits: perspt_sdk::SearchLimits) -> Self {
257 self.search.limits = limits;
258 self
259 }
260
261 pub fn with_fallback_models(mut self, models: Vec<ModelId>) -> Self {
262 self.fallback_models = models;
263 self
264 }
265
266 pub fn with_explorer_model(mut self, model: ModelId) -> Self {
267 self.explorer_model = Some(model);
268 self
269 }
270
271 pub fn with_adjudicator_model(mut self, model: ModelId) -> Self {
272 self.adjudicator_model = Some(model);
273 self
274 }
275
276 pub fn connect_tui(
277 mut self,
278 sender: perspt_core::events::channel::EventSender,
279 receiver: perspt_core::events::channel::ActionReceiver,
280 ) -> Self {
281 self.event_sender = Some(sender);
282 self.action_receiver = tokio::sync::Mutex::new(Some(receiver));
283 self
284 }
285
286 async fn open_session(
290 &mut self,
291 task: &str,
292 session_id: &str,
293 node_id: &str,
294 ) -> Result<(Psp9Recorder, String, Scheduler, WorkGraphRevision)> {
295 let recorder = Psp9Recorder::create(
296 session_id,
297 task,
298 &self.working_dir,
299 self.database_path.as_deref(),
300 self.shared_store.clone(),
301 self.event_sender.clone(),
302 )?;
303 recorder.record_custom(
304 "session_started",
305 serde_json::json!({
306 "task": task,
307 "model": self.model,
308 "fallback_models": self.fallback_models,
309 "mode": "tool_loop",
310 "test_policy": self.config.test_policy.as_str(),
311 }),
312 )?;
313 self.record_route_capabilities(&recorder)?;
314 self.record_prompt_overrides(&recorder)?;
315
316 let agent_goal = self.explore(&recorder, task).await?;
317
318 self.emit(perspt_core::AgentEvent::Log(format!(
319 "PSP-9 session {} using {}",
320 &session_id[..8],
321 self.model
322 )));
323 self.emit(perspt_core::AgentEvent::TaskStatusChanged {
324 node_id: node_id.to_string(),
325 status: perspt_core::NodeStatus::Coding,
326 });
327
328 let graph = self.plan_initial_graph(&recorder, node_id, task).await?;
329 recorder.record_custom("graph_revision", serde_json::to_value(&graph)?)?;
330 if self.config.max_parallel_nodes > 1 {
331 return Ok((recorder, agent_goal, Scheduler::new(1), graph));
333 }
334 let mut scheduler = Scheduler::new(1);
335 let ready = scheduler.ready_nodes(&graph, node_footprint);
336 let selected = ready
337 .first()
338 .context("initial work graph has no schedulable node")?;
339 scheduler.start(selected, node_footprint(selected));
340 recorder.record_custom(
341 "scheduler_dispatch",
342 serde_json::json!({
343 "node_id": selected.node_id,
344 "generation": selected.generation,
345 "parallel_slot": 0,
346 }),
347 )?;
348 let running_graph = execution_revision(&graph, node_id, WorkNodeState::Running)?;
349 recorder.record_custom("graph_revision", serde_json::to_value(&running_graph)?)?;
350 Ok((recorder, agent_goal, scheduler, running_graph))
351 }
352
353 fn record_prompt_overrides(&self, recorder: &Psp9Recorder) -> Result<()> {
356 if self.prompt_overrides.is_empty() {
357 return Ok(());
358 }
359 recorder.record_custom(
360 "prompt_overrides_active",
361 serde_json::json!({
362 "experimental": true,
363 "sections": self
364 .prompt_overrides
365 .provenance()
366 .iter()
367 .map(|(id, hash)| serde_json::json!({"id": id, "content_hash": hash}))
368 .collect::<Vec<_>>(),
369 }),
370 )
371 }
372
373 fn mint_grant(
375 &self,
376 recorder: &Psp9Recorder,
377 revision_id: &str,
378 catalog: &dyn ToolCatalog,
379 ) -> Result<perspt_sdk::GrantPolicy> {
380 let grant_policy = session_grant_policy(
381 &self.working_dir,
382 revision_id,
383 self.config.persistent_grants,
384 catalog,
385 &self.opted_in_effects(),
386 )?;
387 let signed_grant = if self.config.persistent_grants {
388 let key = crate::grant::GrantSigningKey::resolve()?;
389 let signed = perspt_sdk::SignedGrantPolicy::sign(grant_policy.clone(), &key.bytes)?;
390 signed.verify_against(&key.public_key())?;
391 recorder.record_custom(
392 "grant_signing_key_resolved",
393 serde_json::json!({"source": format!("{:?}", key.source)}),
394 )?;
395 Some(signed)
396 } else {
397 None
398 };
399 recorder.record_grant_policy(&grant_policy, signed_grant.as_ref())?;
400 Ok(grant_policy)
401 }
402
403 fn open_candidate(
404 &self,
405 node_id: &str,
406 generation: u32,
407 revision_id: &str,
408 ) -> Result<CandidateWorkspace> {
409 let exclusions = self.candidate_exclusions();
410 let mut candidate = CandidateWorkspace::create_filtered(
411 &self.working_dir,
412 node_id,
413 generation,
414 revision_id,
415 self.config.allow_unisolated_verifiers,
416 &exclusions,
417 )?;
418 candidate.set_tool_handlers(self.tool_handlers.clone());
419 candidate.set_verifier_timeouts(self.config.verifier_timeouts);
420 Ok(candidate)
421 }
422
423 fn candidate_exclusions(&self) -> Vec<PathBuf> {
427 let Some(configured) = &self.database_path else {
428 return Vec::new();
429 };
430 let absolute = if configured.is_absolute() {
431 configured.clone()
432 } else {
433 std::env::current_dir()
434 .unwrap_or_else(|_| self.working_dir.clone())
435 .join(configured)
436 };
437 let target = absolute.canonicalize().unwrap_or(absolute);
438 let Some((parent, name)) = target.parent().zip(target.file_name()) else {
439 return vec![target];
440 };
441 let prefix = format!("{}.", name.to_string_lossy());
442 let mut excluded = vec![target.clone()];
443 if let Ok(entries) = std::fs::read_dir(parent) {
444 excluded.extend(
445 entries
446 .filter_map(|entry| entry.ok())
447 .map(|entry| entry.path())
448 .filter(|path| {
449 path.file_name()
450 .is_some_and(|file| file.to_string_lossy().starts_with(&prefix))
451 }),
452 );
453 }
454 excluded
455 }
456
457 async fn recovery_ladder(
464 &self,
465 session: LadderSession<'_>,
466 initial_budget: u32,
467 mut graph: WorkGraphRevision,
468 mut goal: String,
469 mut attempt: NodeAttempt,
470 ) -> Result<(NodeAttempt, WorkGraphRevision, u32)> {
471 let LadderSession {
472 recorder,
473 session_id,
474 node_id,
475 scheduler,
476 } = session;
477 let mut generation = graph.node(node_id).map_or(0, |node| node.generation);
481 let mut model = self.model.clone();
482 let mut remaining_budget = initial_budget.saturating_sub(spent_of(&attempt));
483 for level in [
484 perspt_sdk::CascadeLevel::Refine,
485 perspt_sdk::CascadeLevel::Escalate,
486 ] {
487 if !matches!(
488 attempt.outcome.outcome,
489 NodeTerminalOutcome::Escalated { .. }
490 ) {
491 break;
492 }
493 match level {
494 perspt_sdk::CascadeLevel::Refine => {
495 let refined = refine_rung(
496 recorder, scheduler, &graph, node_id, generation, &goal, &attempt,
497 )?;
498 let Some((revised, refined_goal)) = refined else {
499 continue;
500 };
501 graph = revised;
502 generation += 1;
503 goal = refined_goal;
504 }
505 perspt_sdk::CascadeLevel::Escalate => {
506 let Some(handoff) = self.escalation_handoff(recorder, &model)? else {
507 continue;
508 };
509 model = handoff;
510 }
511 _ => unreachable!("ladder iterates refine and escalate only"),
512 }
513 let next = self
514 .rung_forest(
515 recorder,
516 session_id,
517 &goal,
518 node_id,
519 generation,
520 &model,
521 &graph,
522 &attempt,
523 remaining_budget,
524 )
525 .await?;
526 let spent = spent_of(&next);
527 attempt = next;
528 remaining_budget = remaining_budget.saturating_sub(spent);
529 }
530 contain_if_escalated(recorder, session_id, &attempt)?;
531 Ok((attempt, graph, generation))
532 }
533
534 #[allow(clippy::too_many_arguments)]
539 async fn rung_forest(
540 &self,
541 recorder: &Psp9Recorder,
542 session_id: &str,
543 goal: &str,
544 node_id: &str,
545 generation: u32,
546 model: &ModelId,
547 graph: &WorkGraphRevision,
548 attempt: &NodeAttempt,
549 remaining_budget: u32,
550 ) -> Result<NodeAttempt> {
551 let seed = seed_from_attempt(recorder, node_id, attempt).await?;
552 let baseline_energy = attempt.outcome.trajectory.best_accepted_energy;
553 let rho_gate = attempt.assembly.energy.rho_gate;
554 self.run_search_forest(
555 recorder,
556 session_id,
557 goal,
558 node_id,
559 generation,
560 model,
561 graph,
562 seed.as_ref(),
563 remaining_budget,
564 baseline_energy,
565 rho_gate,
566 )
567 .await
568 }
569
570 async fn run_graph_session(
573 &self,
574 recorder: &Psp9Recorder,
575 session_id: &str,
576 graph: WorkGraphRevision,
577 ) -> Result<Psp9RunSummary> {
578 let dispatched = match self.run_dispatched(recorder, session_id, graph).await {
579 Ok(dispatched) => dispatched,
580 Err(error) => {
581 recorder.finish("FAILED_PSP9").ok();
582 return Err(error);
583 }
584 };
585 recorder.finish(dispatched.status)?;
586 self.emit(perspt_core::AgentEvent::Complete {
587 success: matches!(dispatched.outcome, NodeTerminalOutcome::HardPass),
588 message: format!("PSP-9 outcome: {:?}", dispatched.outcome),
589 });
590 Ok(Psp9RunSummary {
591 session_id: session_id.to_string(),
592 node_id: "graph".into(),
593 outcome: dispatched.outcome,
594 turns_used: dispatched.turns_used,
595 ledger_head: recorder.head(),
596 promoted_paths: dispatched.promoted_paths,
597 })
598 }
599
600 async fn conclude_attempt(
603 &self,
604 recorder: &Psp9Recorder,
605 attempt: &NodeAttempt,
606 node_id: &str,
607 task: &str,
608 ) -> Result<(NodeTerminalOutcome, &'static str, Vec<String>)> {
609 self.conclude_run(ConcludeContext {
610 recorder,
611 candidate: &attempt.candidate,
612 node_id,
613 task,
614 grant_policy: &attempt.assembly.grant_policy,
615 capability: &attempt.assembly.capability,
616 contract: &attempt.assembly.contract,
617 barrier: &attempt.assembly.barrier,
618 kernel_state: &attempt.kernel_state,
619 loop_outcome: &attempt.outcome.outcome,
620 calibration: &attempt.assembly.calibration,
621 })
622 .await
623 }
624
625 async fn validate_and_approve_attempt(
628 &self,
629 recorder: &Psp9Recorder,
630 attempt: &NodeAttempt,
631 node_id: &str,
632 task: &str,
633 ) -> Result<bool> {
634 let ctx = ConcludeContext {
635 recorder,
636 candidate: &attempt.candidate,
637 node_id,
638 task,
639 grant_policy: &attempt.assembly.grant_policy,
640 capability: &attempt.assembly.capability,
641 contract: &attempt.assembly.contract,
642 barrier: &attempt.assembly.barrier,
643 kernel_state: &attempt.kernel_state,
644 loop_outcome: &attempt.outcome.outcome,
645 calibration: &attempt.assembly.calibration,
646 };
647 self.validate_and_approve(&ctx, true).await
648 }
649
650 fn escalation_handoff(
653 &self,
654 recorder: &Psp9Recorder,
655 current: &perspt_sdk::ModelId,
656 ) -> Result<Option<perspt_sdk::ModelId>> {
657 let Some(handoff) = self.handoff_model.clone() else {
658 return Ok(None);
659 };
660 if handoff == *current {
661 return Ok(None);
662 }
663 recorder.record_custom(
664 "recovery_handoff",
665 serde_json::json!({
666 "level": "escalate",
667 "from_model": current,
668 "to_model": handoff,
669 }),
670 )?;
671 Ok(Some(handoff))
672 }
673
674 pub async fn run(mut self, task: String) -> Result<Psp9RunSummary> {
675 let session_id = uuid::Uuid::new_v4().to_string();
676 let node_id = "implement-1".to_string();
677 let (recorder, agent_goal, mut scheduler, running_graph) =
678 self.open_session(&task, &session_id, &node_id).await?;
679
680 if self.config.max_parallel_nodes > 1 {
681 return self
682 .run_graph_session(&recorder, &session_id, running_graph)
683 .await;
684 }
685
686 let first = match self
687 .attempt_node(
688 &recorder,
689 &session_id,
690 &agent_goal,
691 &node_id,
692 0,
693 &self.model.clone(),
694 &running_graph,
695 None,
696 self.config.rejection_budget,
697 )
698 .await
699 {
700 Ok(attempt) => attempt,
701 Err(error) => return Err(self.fail_session(&recorder, error)),
702 };
703 let (attempt, graph, final_generation) = match self
704 .recovery_ladder(
705 LadderSession {
706 recorder: &recorder,
707 session_id: &session_id,
708 node_id: &node_id,
709 scheduler: &mut scheduler,
710 },
711 self.config.rejection_budget,
712 running_graph,
713 agent_goal,
714 first,
715 )
716 .await
717 {
718 Ok(result) => result,
719 Err(error) => return Err(self.fail_session(&recorder, error)),
720 };
721
722 let verdict = match self
723 .conclude_attempt(&recorder, &attempt, &node_id, &task)
724 .await
725 {
726 Ok(verdict) => verdict,
727 Err(error) => return Err(self.fail_session(&recorder, error)),
728 };
729 let (final_outcome, status, promoted_paths) = verdict;
730 scheduler.finish(&node_id, final_generation);
731 if let Err(error) = self.finish_node(&recorder, &graph, &node_id, &final_outcome, status) {
732 return Err(self.fail_session(&recorder, error));
733 }
734
735 Ok(Psp9RunSummary {
736 session_id,
737 node_id,
738 outcome: final_outcome,
739 turns_used: attempt.outcome.turns_used,
740 ledger_head: recorder.head(),
741 promoted_paths,
742 })
743 }
744
745 fn emit(&self, event: perspt_core::AgentEvent) {
746 if let Some(sender) = &self.event_sender {
747 let _ = sender.send(event);
748 }
749 }
750
751 fn fail_session(&self, recorder: &Psp9Recorder, error: anyhow::Error) -> anyhow::Error {
753 let recording = recorder
754 .record_custom(
755 "node_terminal",
756 serde_json::json!({"class": "failed", "error": error.to_string()}),
757 )
758 .and_then(|()| recorder.finish("FAILED_PSP9"));
759 if let Err(recording_error) = recording {
760 log::warn!("failed to record session failure: {recording_error}");
761 }
762 self.emit(perspt_core::AgentEvent::Error(error.to_string()));
763 error
764 }
765
766 fn loop_budgets(&self, domain_rho_gate: f64, shared_recovery_budget: u32) -> LoopBudgets {
767 LoopBudgets {
768 max_turns: self.config.max_turns,
769 max_calls_per_turn: self.config.max_calls_per_turn,
770 rejection_budget: shared_recovery_budget,
771 rho_gate: self.config.rho_gate.max(domain_rho_gate),
772 declared_energy_floor: None,
773 context_soft_limit_chars: 240_000,
774 recovery_budget: shared_recovery_budget,
775 turn_deadline_secs: self.config.turn_deadline_secs,
776 resident: crate::toolloop::ResidentReserves {
777 paging_enabled: self.config.resident.paging_enabled
778 && !self.config.ablate_context_paging,
779 ..self.config.resident
780 },
781 }
782 }
783
784 fn finish_node(
786 &self,
787 recorder: &Psp9Recorder,
788 running_graph: &WorkGraphRevision,
789 node_id: &str,
790 final_outcome: &NodeTerminalOutcome,
791 status: &str,
792 ) -> Result<()> {
793 let terminal_state = if matches!(final_outcome, NodeTerminalOutcome::HardPass) {
794 WorkNodeState::Stable
795 } else {
796 WorkNodeState::Stopped {
797 certificate_id: match final_outcome {
798 NodeTerminalOutcome::Escalated { certificate_id } => certificate_id.clone(),
799 _ => uuid::Uuid::new_v4().to_string(),
800 },
801 }
802 };
803 let terminal_graph = execution_revision(running_graph, node_id, terminal_state)?;
804 recorder.record_custom("graph_revision", serde_json::to_value(&terminal_graph)?)?;
805 recorder.record_custom(
806 "node_terminal",
807 serde_json::json!({"node_id": node_id, "outcome": final_outcome}),
808 )?;
809 recorder.finish(status)?;
810 self.emit(perspt_core::AgentEvent::Complete {
811 success: matches!(final_outcome, NodeTerminalOutcome::HardPass),
812 message: format!("PSP-9 outcome: {final_outcome:?}"),
813 });
814 Ok(())
815 }
816
817 #[allow(clippy::too_many_arguments)]
820 fn assemble_node(
821 &self,
822 recorder: &Psp9Recorder,
823 session_id: &str,
824 running_graph: &WorkGraphRevision,
825 node_id: &str,
826 generation: u32,
827 external_entries: &[perspt_sdk::ToolEntry],
828 ) -> Result<NodeAssembly> {
829 let domain = self.domain.clone();
830 let scope = perspt_sdk::DomainScope {
831 label: node_id.to_string(),
832 paths: Vec::new(),
833 };
834 let catalog = self.assemble_catalog(node_id, external_entries)?;
835 let grant_policy = self.mint_grant(recorder, &running_graph.revision_id, &catalog)?;
836 let mut capability = grant_policy
839 .mint(worker_capability(
840 session_id,
841 &running_graph.revision_id,
842 grant_policy.authority_epoch,
843 generation,
844 &catalog,
845 &self.opted_in_effects(),
846 ))
847 .map_err(|e| anyhow::anyhow!("grant intersection: {e}"))?;
848 if let Some(node) = running_graph.node(node_id) {
854 capability.write_scope = write_scope_from_targets(&node.output_targets);
855 }
856 let contract = CodingContract {
857 graph_revision: running_graph.revision_id.clone(),
858 node_id: node_id.to_string(),
859 generation,
860 policy: perspt_policy::engine::PolicyEngine::new()?,
861 };
862 let barrier = OperationalSafetyBarrier::default();
863 let cadence = domain.verifier_suite(&scope).cadence;
864 let energy = domain.energy_model(&scope);
865 let calibration =
866 self.record_calibration_readiness(recorder, &catalog, &capability, &cadence)?;
867 Ok(NodeAssembly {
868 catalog,
869 grant_policy,
870 capability,
871 contract,
872 barrier,
873 cadence,
874 energy,
875 calibration,
876 })
877 }
878
879 async fn conclude_run(
883 &self,
884 ctx: ConcludeContext<'_>,
885 ) -> Result<(NodeTerminalOutcome, &'static str, Vec<String>)> {
886 let hard_pass = matches!(ctx.loop_outcome, NodeTerminalOutcome::HardPass);
887 let promotion_approved = self.validate_and_approve(&ctx, hard_pass).await?;
888 let mut final_outcome = if hard_pass && !promotion_approved {
889 NodeTerminalOutcome::Escalated {
890 certificate_id: uuid::Uuid::new_v4().to_string(),
891 }
892 } else {
893 ctx.loop_outcome.clone()
894 };
895 let mut promoted_paths = Vec::new();
896 let status = if matches!(final_outcome, NodeTerminalOutcome::HardPass) {
897 let certified = self
898 .promote_certified(
899 ctx.recorder,
900 ctx.candidate,
901 ctx.node_id,
902 ctx.grant_policy,
903 ctx.capability,
904 ctx.contract,
905 ctx.barrier,
906 ctx.kernel_state,
907 ctx.calibration,
908 )
909 .await?;
910 match certified {
911 Some(paths) => {
912 promoted_paths = paths;
913 self.emit(perspt_core::AgentEvent::NodeCompleted {
914 node_id: ctx.node_id.to_string(),
915 goal: ctx.task.to_string(),
916 });
917 "COMPLETED_PSP9"
918 }
919 None => {
920 final_outcome = NodeTerminalOutcome::Escalated {
921 certificate_id: uuid::Uuid::new_v4().to_string(),
922 };
923 self.emit(perspt_core::AgentEvent::TaskStatusChanged {
924 node_id: ctx.node_id.to_string(),
925 status: perspt_core::NodeStatus::Escalated,
926 });
927 "ESCALATED_PSP9"
928 }
929 }
930 } else {
931 self.emit(perspt_core::AgentEvent::TaskStatusChanged {
932 node_id: ctx.node_id.to_string(),
933 status: perspt_core::NodeStatus::Escalated,
934 });
935 "ESCALATED_PSP9"
936 };
937 Ok((final_outcome, status, promoted_paths))
938 }
939
940 async fn validate_and_approve(
944 &self,
945 ctx: &ConcludeContext<'_>,
946 hard_pass: bool,
947 ) -> Result<bool> {
948 let candidate_id = ctx.candidate.checkpoint(&[]).await?.witness.state_root;
949 record_deterministic_verdict(
950 ctx.recorder,
951 &candidate_id,
952 &ctx.calibration.stratum,
953 hard_pass,
954 )?;
955 if !hard_pass {
956 return Ok(false);
957 }
958 let adjudicated = self
959 .adjudicate_candidate(
960 ctx.recorder,
961 ctx.candidate,
962 ctx.task,
963 &ctx.calibration.stratum,
964 )
965 .await?;
966 Ok(adjudicated && self.conformal_or_human_approval(ctx).await?)
967 }
968
969 async fn conformal_or_human_approval(&self, ctx: &ConcludeContext<'_>) -> Result<bool> {
976 const HARD_PASS_SCORE: f64 = 1.0;
978 let certified = self.config.approval_policy == ApprovalPolicy::Ask
979 && ctx.calibration.state == "active"
980 && ctx
981 .calibration
982 .threshold
983 .is_some_and(|theta| HARD_PASS_SCORE > theta);
984 if certified {
985 ctx.recorder.record_custom(
986 "conformal_certified_accept",
987 serde_json::json!({
988 "epoch_id": ctx.calibration.epoch_id,
989 "threshold": ctx.calibration.threshold,
990 "score": HARD_PASS_SCORE,
991 "node_id": ctx.node_id,
992 }),
993 )?;
994 return Ok(true);
995 }
996 self.approve_promotion(
997 ctx.recorder,
998 ctx.node_id,
999 ctx.candidate.touched_paths(),
1000 ctx.candidate.realized_diff().ok(),
1001 )
1002 .await
1003 }
1004
1005 #[allow(clippy::too_many_arguments)]
1011 async fn promote_certified(
1012 &self,
1013 recorder: &Psp9Recorder,
1014 candidate: &CandidateWorkspace,
1015 node_id: &str,
1016 grant_policy: &perspt_sdk::GrantPolicy,
1017 capability: &Capability,
1018 contract: &CodingContract,
1019 barrier: &OperationalSafetyBarrier,
1020 kernel_state: &perspt_sdk::KernelState,
1021 calibration: &CalibrationBinding,
1022 ) -> Result<Option<Vec<String>>> {
1023 anyhow::ensure!(
1024 recorder.authority_epoch()? == grant_policy.authority_epoch,
1025 "authority epoch changed before promotion"
1026 );
1027 recorder.record_custom(
1028 "authority_epoch_rechecked",
1029 serde_json::json!({
1030 "node_id": node_id,
1031 "generation": capability.node_generation,
1032 "epoch": grant_policy.authority_epoch,
1033 }),
1034 )?;
1035 let touched = candidate.touched_paths();
1036 let realized = candidate.checkpoint(&[]).await?.witness;
1037 let certified = certify_promotion(
1038 recorder,
1039 node_id,
1040 &touched,
1041 &realized,
1042 capability,
1043 contract,
1044 barrier,
1045 kernel_state,
1046 )?;
1047 if !certified {
1048 return Ok(None);
1049 }
1050
1051 let promoted_paths = commit_promotion(
1052 recorder,
1053 candidate,
1054 node_id,
1055 grant_policy.authority_epoch,
1056 &realized.state_root,
1057 &touched,
1058 &self.working_dir,
1059 )?;
1060 record_promotion_sample(recorder, calibration, &realized.state_root)?;
1061 Ok(Some(promoted_paths))
1062 }
1063 fn calibration_stratum(
1066 &self,
1067 catalog: &StaticCatalog,
1068 capability: &Capability,
1069 cadence: &perspt_sdk::VerificationCadence,
1070 ) -> Result<perspt_sdk::CalibrationStratum> {
1071 let verifier_suite_fingerprint =
1072 perspt_sdk::content_hash(&serde_json::to_vec(&serde_json::json!({
1073 "cadence": cadence,
1074 "plugins": perspt_core::PluginRegistry::new()
1075 .detect_all(&self.working_dir)
1076 .into_iter()
1077 .map(|plugin| plugin.name().to_string())
1078 .collect::<Vec<_>>(),
1079 }))?);
1080 let tool_catalog_fingerprint = perspt_sdk::content_hash(&serde_json::to_vec(
1081 &catalog.specs_for(std::slice::from_ref(capability), true),
1082 )?);
1083 Ok(perspt_sdk::CalibrationStratum {
1084 domain_package: "perspt-coding".into(),
1085 domain_version: env!("CARGO_PKG_VERSION").into(),
1086 effect_kind: "candidate_promotion".into(),
1087 risk_class: "workspace_mutation".into(),
1088 model_route: self.model.to_string(),
1089 verifier_suite_fingerprint,
1090 tool_catalog_fingerprint,
1091 policy_version: "coding-contract-v1".into(),
1092 score_definition: "hard-gate-plus-quadratic-energy-v1".into(),
1093 })
1094 }
1095
1096 fn record_calibration_readiness(
1097 &self,
1098 recorder: &Psp9Recorder,
1099 catalog: &StaticCatalog,
1100 capability: &Capability,
1101 cadence: &perspt_sdk::VerificationCadence,
1102 ) -> Result<CalibrationBinding> {
1103 const TARGET_RHO: f64 = 0.05;
1104 let stratum = self.calibration_stratum(catalog, capability, cadence)?;
1105 let serialized = serde_json::to_string(&stratum)?;
1106 let epoch = match recorder.store.latest_psp9_calibration_epoch(&serialized)? {
1107 Some(epoch) => epoch,
1108 None => {
1109 let epoch = perspt_store::Psp9CalibrationEpochRow {
1110 epoch_id: uuid::Uuid::new_v4().to_string(),
1111 stratum: serialized,
1112 target_rho: TARGET_RHO,
1113 threshold: None,
1114 state: "insufficient_samples".into(),
1115 sample_count: 0,
1116 };
1117 recorder.store.record_psp9_calibration_epoch(&epoch)?;
1118 epoch
1119 }
1120 };
1121 let need = perspt_sdk::sample_floor(epoch.target_rho)
1122 .map_err(|error| anyhow::anyhow!(error.to_string()))?;
1123 let certified = epoch.state == "active" && epoch.threshold.is_some();
1124 recorder.record_custom(
1125 "calibration_readiness",
1126 serde_json::json!({
1127 "epoch_id": epoch.epoch_id,
1128 "stratum": stratum,
1129 "state": epoch.state,
1130 "sample_count": epoch.sample_count,
1131 "sample_floor": need,
1132 "target_rho": epoch.target_rho,
1133 "threshold": epoch.threshold,
1134 "certified_for_promotion": certified,
1135 "reason": if certified {
1136 "an activated epoch at the finite sample floor backs the \
1137 conformal accepted-unsafe bound for this stratum"
1138 } else {
1139 "coding promotion currently relies on deterministic contract, \
1140 barrier, and verifier evidence; no probabilistic claim is used"
1141 },
1142 }),
1143 )?;
1144 Ok(CalibrationBinding {
1145 epoch_id: epoch.epoch_id,
1146 stratum: epoch.stratum,
1147 state: epoch.state,
1148 threshold: epoch.threshold,
1149 })
1150 }
1151
1152 async fn approve_promotion(
1153 &self,
1154 recorder: &Psp9Recorder,
1155 node_id: &str,
1156 paths: Vec<String>,
1157 diff: Option<String>,
1158 ) -> Result<bool> {
1159 match self.config.approval_policy {
1160 ApprovalPolicy::Auto | ApprovalPolicy::SessionApproved => return Ok(true),
1161 ApprovalPolicy::Deny => return Ok(false),
1162 ApprovalPolicy::Ask => {}
1163 }
1164 let request_id = uuid::Uuid::new_v4().to_string();
1165 recorder.record_custom(
1166 "approval_requested",
1167 serde_json::json!({"request_id": request_id, "node_id": node_id, "paths": paths}),
1168 )?;
1169 self.emit(perspt_core::AgentEvent::ApprovalRequest {
1170 request_id: request_id.clone(),
1171 node_id: node_id.into(),
1172 action_type: perspt_core::ActionType::BundleWrite {
1173 node_id: node_id.into(),
1174 files: paths,
1175 },
1176 description: "Promote the verified PSP-9 candidate to the workspace".into(),
1177 diff,
1178 });
1179 let mut receiver_slot = self.action_receiver.lock().await;
1180 let Some(receiver) = receiver_slot.as_mut() else {
1181 recorder.record_custom(
1182 "approval_unavailable",
1183 serde_json::json!({"request_id": request_id}),
1184 )?;
1185 return Ok(false);
1186 };
1187 while let Some(action) = receiver.recv().await {
1188 let approved = match action {
1189 perspt_core::AgentAction::Approve { request_id: id }
1190 | perspt_core::AgentAction::ApproveWithEdit { request_id: id, .. }
1191 if id == request_id =>
1192 {
1193 Some(true)
1194 }
1195 perspt_core::AgentAction::Reject { request_id: id, .. } if id == request_id => {
1196 Some(false)
1197 }
1198 perspt_core::AgentAction::Abort => Some(false),
1199 _ => None,
1200 };
1201 if let Some(approved) = approved {
1202 recorder.record_custom(
1203 "approval_resolved",
1204 serde_json::json!({"request_id": request_id, "approved": approved}),
1205 )?;
1206 return Ok(approved);
1207 }
1208 }
1209 Ok(false)
1210 }
1211}