Skip to main content

perspt_agent/runtime/
explore.rs

1//! Exploration-only sessions: the read-only explorer tool loop.
2
3use super::*;
4
5impl Psp9AgentRuntime {
6    /// Exploration-only session (PSP-9 phase 7): the deterministic repository
7    /// map plus an interactive read-only explorer tool loop. The explorer
8    /// holds only read capabilities; every tool call still passes the kernel,
9    /// so a mutation attempt is denied and recorded rather than prevented by
10    /// convention. Nothing is measured, gated, or promoted.
11    pub async fn run_exploration(mut self, task: String) -> Result<Psp9RunSummary> {
12        let session_id = uuid::Uuid::new_v4().to_string();
13        let node_id = "explore-1".to_string();
14        let (recorder, _goal, mut scheduler, running_graph) =
15            self.open_session(&task, &session_id, &node_id).await?;
16        let candidate = self.open_candidate(&node_id, 0, &running_graph.revision_id)?;
17        let explorer = perspt_sdk::exploration_capability(perspt_sdk::ActorId::new("toolloop"));
18        debug_assert!(perspt_sdk::is_read_only_capability(&explorer));
19        let mut capability = explorer;
20        capability.session_id = session_id.clone();
21        capability.graph_revision = running_graph.revision_id.clone();
22        capability.role = CapabilityRole::Explorer;
23        let domain = self.domain.clone();
24        let scope = perspt_sdk::DomainScope {
25            label: node_id.clone(),
26            paths: Vec::new(),
27        };
28        let catalog = StaticCatalog::with_base(domain.tool_entries(&scope))
29            .map_err(|e| anyhow::anyhow!(e.to_string()))?;
30        let mut kernel_state = perspt_sdk::KernelState::new();
31        kernel_state.set_witness("__graph_revision", running_graph.revision_id.clone());
32        kernel_state.set_witness("__authority_epoch", capability.authority_epoch.to_string());
33        let outcome = self
34            .explorer_loop(
35                &recorder,
36                &candidate,
37                &catalog,
38                capability,
39                kernel_state,
40                &task,
41            )
42            .await;
43        scheduler.finish(&node_id, 0);
44        let status = if outcome.is_ok() {
45            "COMPLETED_PSP9"
46        } else {
47            "FAILED_PSP9"
48        };
49        recorder.finish(status)?;
50        outcome?;
51        Ok(Psp9RunSummary {
52            session_id,
53            node_id,
54            outcome: NodeTerminalOutcome::HardPass,
55            turns_used: 0,
56            ledger_head: recorder.head(),
57            promoted_paths: Vec::new(),
58        })
59    }
60
61    /// The interactive explorer: read-only calls execute after a kernel
62    /// check; denials (including any mutation attempt) are returned to the
63    /// model and ledgered.
64    async fn explorer_loop(
65        &self,
66        recorder: &Psp9Recorder,
67        candidate: &CandidateWorkspace,
68        catalog: &StaticCatalog,
69        capability: Capability,
70        kernel_state: perspt_sdk::KernelState,
71        task: &str,
72    ) -> Result<()> {
73        let model = self.explorer_model.as_ref().unwrap_or(&self.model);
74        // PSP-10 interim (phase 7): the explorer runs under SearchLimits'
75        // tool-call cap; phase 9 re-drives this loop through
76        // ActorTurnRunner under the same limits.
77        let limits = perspt_sdk::SearchLimits::release_default();
78        let specs = catalog.specs_for(std::slice::from_ref(&capability), false);
79        let stage =
80            perspt_core::prompts::PlatformPromptLibrary::repository_explore(&self.prompt_overrides)
81                .map_err(|e| anyhow::anyhow!("repository explore prompt: {e}"))?;
82        let invocation = self.actor_invocation(recorder, "explorer", &stage, model, &specs)?;
83        let mut conversation = Conversation::with_system(invocation.platform.system_text());
84        conversation.push_user(task.to_string());
85        let mut tool_calls = 0u32;
86        for turn in 0..16u32 {
87            // PSP-10 system 27: every actor turn runs through the shared
88            // runner — feasibility gate, sticky failover, raw actor-tagged
89            // observation before any parse.
90            let mut runner = crate::turn::ActorTurnRunner {
91                transport: self.transport.as_ref(),
92                model: model.clone(),
93                fallbacks: self.fallback_models.clone(),
94                recorder: Some(recorder),
95                actor: crate::turn::ActorKind::Explorer,
96                deadline_secs: self.config.turn_deadline_secs,
97                turn: turn + 1,
98            };
99            let output = runner
100                .run_turn(&conversation, &specs, ToolChoicePolicy::Auto)
101                .await?;
102            match output {
103                TurnOutput::Text(text) => {
104                    recorder
105                        .record_custom("exploration_answer", serde_json::json!({"text": text}))?;
106                    self.emit(perspt_core::AgentEvent::Log(format!("Explorer: {text}")));
107                    println!("{text}");
108                    return Ok(());
109                }
110                TurnOutput::ToolCalls(calls) => {
111                    conversation.push_tool_calls(calls.clone());
112                    for call in calls {
113                        tool_calls += 1;
114                        let response = if tool_calls > limits.tool_calls {
115                            "denied: exploration tool-call budget exhausted".to_string()
116                        } else {
117                            self.explorer_call(
118                                recorder,
119                                candidate,
120                                catalog,
121                                &capability,
122                                &kernel_state,
123                                &call,
124                            )
125                            .await?
126                        };
127                        conversation.push_tool_response(call.call_id.clone(), response);
128                    }
129                }
130            }
131        }
132        anyhow::bail!("explorer did not produce a final answer within the turn budget")
133    }
134
135    /// One explorer call through the kernel; the read-only capability makes
136    /// every mutation a recorded denial.
137    async fn explorer_call(
138        &self,
139        recorder: &Psp9Recorder,
140        candidate: &CandidateWorkspace,
141        catalog: &StaticCatalog,
142        capability: &Capability,
143        kernel_state: &perspt_sdk::KernelState,
144        call: &perspt_sdk::ProviderToolCall,
145    ) -> Result<String> {
146        use crate::toolloop::EffectExecutor as _;
147        let Some(entry) = catalog.lookup(&call.name).cloned() else {
148            return Ok(format!("denied: unknown tool {:?}", call.name));
149        };
150        let mut proposal =
151            perspt_sdk::EffectProposal::new(capability.holder.clone(), "explore-1", entry.effect);
152        if let Some(path) = call.arguments.get("path").and_then(|v| v.as_str()) {
153            proposal = proposal.with_path(path);
154        }
155        let witness = perspt_sdk::check_admissibility(
156            &proposal,
157            std::slice::from_ref(capability),
158            kernel_state,
159        );
160        if !matches!(witness.decision, perspt_sdk::AdmissibilityDecision::Allow) {
161            recorder.record_custom(
162                "exploration_denied",
163                serde_json::json!({
164                    "tool": call.name,
165                    "decision": format!("{:?}", witness.decision),
166                }),
167            )?;
168            return Ok(format!("denied: {:?}", witness.decision));
169        }
170        let outcome = candidate.apply(call, &entry).await?;
171        recorder.record_custom(
172            "exploration_tool_call",
173            serde_json::json!({"tool": call.name, "mutated": outcome.mutated}),
174        )?;
175        Ok(outcome.output)
176    }
177}
178
179impl Psp9AgentRuntime {
180    pub(super) async fn explore(&self, recorder: &Psp9Recorder, task: &str) -> Result<String> {
181        let root = self.working_dir.clone();
182        let report = tokio::task::spawn_blocking(move || crate::exploration::map_workspace(&root))
183            .await
184            .context("repository exploration worker panicked")??;
185        recorder.record_custom("exploration_report", serde_json::to_value(&report)?)?;
186        self.emit(perspt_core::AgentEvent::Log(format!(
187            "Exploration mapped {} language groups and {} package roots",
188            report.project_map.languages.len(),
189            report.project_map.package_roots.len()
190        )));
191
192        let mut advisory = None;
193        if let Some(model) = &self.explorer_model {
194            recorder.record_custom(
195                "route_resolved",
196                serde_json::json!({
197                    "phase": "explore",
198                    "model": model,
199                    "reason": "configured speculator/explorer route",
200                    "authority": "no_tools",
201                }),
202            )?;
203            let stage = perspt_core::prompts::PlatformPromptLibrary::evidence_summarize(
204                &self.prompt_overrides,
205            )
206            .map_err(|e| anyhow::anyhow!("evidence summarize prompt: {e}"))?;
207            let invocation = self.actor_invocation(recorder, "summarizer", &stage, model, &[])?;
208            let mut conversation = Conversation::with_system(invocation.platform.system_text());
209            conversation.push_user(format!(
210                "Task: {task}\nRepository map:\n{}",
211                serde_json::to_string_pretty(&report.project_map)?
212            ));
213            let mut runner = crate::turn::ActorTurnRunner {
214                transport: self.transport.as_ref(),
215                model: model.clone(),
216                fallbacks: self.fallback_models.clone(),
217                recorder: Some(recorder),
218                actor: crate::turn::ActorKind::Summarizer,
219                deadline_secs: self.config.turn_deadline_secs,
220                turn: 1,
221            };
222            match runner
223                .run_turn(&conversation, &[], ToolChoicePolicy::None)
224                .await
225            {
226                Ok(output) => {
227                    recorder.record_custom(
228                        "exploration_model_observation",
229                        serde_json::json!({"model": model, "output": output}),
230                    )?;
231                    if let TurnOutput::Text(text) = output {
232                        advisory = Some(text);
233                    }
234                }
235                Err(error) => {
236                    recorder.record_custom(
237                        "exploration_model_unavailable",
238                        serde_json::json!({"model": model, "error": error.to_string()}),
239                    )?;
240                }
241            }
242        }
243
244        Ok(format!(
245            "{task}\n\nDeterministic repository map (advisory orientation, not acceptance evidence):\n{}{}",
246            serde_json::to_string_pretty(&report.project_map)?,
247            advisory
248                .map(|summary| format!("\n\nUntrusted explorer summary:\n{summary}"))
249                .unwrap_or_default()
250        ))
251    }
252}