1use std::time::Instant;
13
14use anyhow::Result;
15use perspt_sdk::{Conversation, ModelId, ModelTransport, ToolChoicePolicy, ToolSpec, TurnOutput};
16use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct ProbeReport {
21 pub model: String,
22 pub tool_call_round_trip: bool,
24 pub multi_tool_selection: bool,
26 pub parallel_tool_calls: bool,
28 pub schema_arguments_valid: bool,
30 pub turns: u32,
31 pub tool_calls: u32,
32 pub total_seconds: f64,
33 pub error: Option<String>,
34}
35
36fn probe_specs() -> Vec<ToolSpec> {
37 vec![
38 ToolSpec {
39 name: "read_file".into(),
40 description: "Read the contents of a workspace file".into(),
41 schema: serde_json::json!({
42 "type": "object",
43 "properties": {
44 "path": {"type": "string", "description": "Workspace-relative path"}
45 },
46 "required": ["path"],
47 }),
48 strict: false,
49 },
50 ToolSpec {
51 name: "list_files".into(),
52 description: "List files in a workspace directory".into(),
53 schema: serde_json::json!({
54 "type": "object",
55 "properties": {
56 "path": {"type": "string", "description": "Directory to list"}
57 },
58 "required": ["path"],
59 }),
60 strict: false,
61 },
62 ]
63}
64
65fn scripted_result(name: &str) -> &'static str {
66 match name {
67 "read_file" => "pub fn add(a: i32, b: i32) -> i32 { a + b }",
68 "list_files" => "src/lib.rs\nsrc/main.rs\nCargo.toml",
69 _ => "unknown tool",
70 }
71}
72
73pub async fn probe_route(transport: &dyn ModelTransport, model: &ModelId) -> ProbeReport {
76 let start = Instant::now();
77 let mut report = ProbeReport {
78 model: model.to_string(),
79 tool_call_round_trip: false,
80 multi_tool_selection: false,
81 parallel_tool_calls: false,
82 schema_arguments_valid: true,
83 turns: 0,
84 tool_calls: 0,
85 total_seconds: 0.0,
86 error: None,
87 };
88 if let Err(error) = drive_probe(transport, model, &mut report).await {
89 report.error = Some(format!("{error:#}"));
90 }
91 report.total_seconds = start.elapsed().as_secs_f64();
92 report
93}
94
95async fn drive_probe(
96 transport: &dyn ModelTransport,
97 model: &ModelId,
98 report: &mut ProbeReport,
99) -> Result<()> {
100 let specs = probe_specs();
101 let mut conversation = Conversation::with_system(
102 "You are verifying tool plumbing. First read src/lib.rs with read_file \
103 and list the src directory with list_files (both calls may share one \
104 turn), then summarize what you saw in one sentence.",
105 );
106 conversation.push_user("Inspect the project as instructed, then summarize.");
107
108 let mut names_seen = std::collections::BTreeSet::new();
109 let mut saw_text_after_tools = false;
110 for _ in 0..5 {
111 report.turns += 1;
112 let mut runner = crate::turn::ActorTurnRunner {
115 transport,
116 model: model.clone(),
117 fallbacks: Vec::new(),
118 recorder: None,
119 actor: crate::turn::ActorKind::CapabilityProbe,
120 deadline_secs: crate::turn::DEFAULT_TURN_DEADLINE_SECS,
121 turn: report.turns,
122 };
123 let output = runner
124 .run_turn(&conversation, &specs, ToolChoicePolicy::Auto)
125 .await
126 .map_err(|e| anyhow::anyhow!("{e}"))?;
127 match output {
128 TurnOutput::ToolCalls(calls) => {
129 if calls.len() > 1 {
130 report.parallel_tool_calls = true;
131 }
132 report.tool_calls += calls.len() as u32;
133 conversation.push_tool_calls(calls.clone());
134 for call in &calls {
135 names_seen.insert(call.name.clone());
136 let path_ok = call
137 .arguments
138 .get("path")
139 .map(serde_json::Value::is_string)
140 .unwrap_or(false);
141 if !path_ok {
142 report.schema_arguments_valid = false;
143 }
144 conversation
145 .push_tool_response(call.call_id.clone(), scripted_result(&call.name));
146 }
147 }
148 TurnOutput::Text(_) => {
149 saw_text_after_tools = report.tool_calls > 0;
150 break;
151 }
152 }
153 }
154 report.tool_call_round_trip = saw_text_after_tools;
155 report.multi_tool_selection =
156 names_seen.contains("read_file") && names_seen.contains("list_files");
157 Ok(())
158}