1use super::*;
4
5impl Psp9AgentRuntime {
6 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 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 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 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 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}