Skip to main content

perspt_core/
tools_driver.rs

1//! The `genai` tool-calling driver (PSP-9 system 4).
2//!
3//! This is the *driver* half of the transport: it renders the harness's
4//! provider-neutral turn into a `genai` `ChatRequest` with tools, executes
5//! it, and maps the response back. The types here are the **core-native
6//! mirror** of the SDK contract — `perspt-core` must not depend on
7//! `perspt-sdk`, so the shapes exist twice by design and
8//! `perspt-agent::transport` performs the one translation.
9
10use anyhow::{Context, Result};
11use futures::StreamExt;
12use genai::chat::{
13    ChatMessage, ChatOptions, ChatRequest, ChatStreamEvent, Tool, ToolCall, ToolChoice,
14    ToolResponse,
15};
16
17use crate::llm_provider::GenAIProvider;
18
19/// Mirror of the SDK's `ToolSpec`.
20#[derive(Debug, Clone, PartialEq)]
21pub struct CoreToolSpec {
22    pub name: String,
23    pub description: String,
24    pub schema: serde_json::Value,
25    pub strict: bool,
26}
27
28/// Mirror of the SDK's `ProviderToolCall`.
29#[derive(Debug, Clone, PartialEq)]
30pub struct CoreToolCall {
31    pub call_id: String,
32    pub name: String,
33    pub arguments: serde_json::Value,
34}
35
36/// Mirror of the SDK's `TurnOutput`.
37#[derive(Debug, Clone, PartialEq)]
38pub enum CoreTurnOutput {
39    ToolCalls(Vec<CoreToolCall>),
40    Text(String),
41}
42
43/// User-facing stream events from a tool-aware model turn.
44///
45/// Tool protocol chunks and thought signatures stay inside the transport;
46/// callers receive only answer text and genuine model reasoning.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub enum CoreTurnStreamEvent {
49    Text(String),
50    Reasoning(String),
51}
52
53/// Mirror of the SDK's `Message`.
54#[derive(Debug, Clone, PartialEq)]
55pub enum CoreMessage {
56    System { content: String },
57    User { content: String },
58    Assistant { content: String },
59    AssistantToolCalls { calls: Vec<CoreToolCall> },
60    ToolResponse { call_id: String, content: String },
61}
62
63/// Mirror of the SDK's `ToolChoicePolicy`.
64#[derive(Debug, Clone, PartialEq, Eq)]
65pub enum CoreToolChoice {
66    Auto,
67    None,
68    Required,
69    Specific(String),
70}
71
72/// Optional provider generation controls for one core turn.
73#[derive(Debug, Clone, Default, PartialEq)]
74pub struct CoreGenerationConfig {
75    pub max_tokens: Option<u32>,
76    pub temperature: Option<f32>,
77    pub stop_sequences: Vec<String>,
78}
79
80/// Render one provider-neutral message into the vendor shape.
81fn render_message(message: &CoreMessage) -> ChatMessage {
82    match message {
83        CoreMessage::System { content } => ChatMessage::system(content.clone()),
84        CoreMessage::User { content } => ChatMessage::user(content.clone()),
85        CoreMessage::Assistant { content } => ChatMessage::assistant(content.clone()),
86        CoreMessage::AssistantToolCalls { calls } => ChatMessage::from(
87            calls
88                .iter()
89                .map(|call| ToolCall {
90                    call_id: call.call_id.clone(),
91                    fn_name: call.name.clone(),
92                    fn_arguments: call.arguments.clone(),
93                    thought_signatures: None,
94                })
95                .collect::<Vec<_>>(),
96        ),
97        CoreMessage::ToolResponse { call_id, content } => {
98            ChatMessage::from(ToolResponse::new(call_id.clone(), content.clone()))
99        }
100    }
101}
102
103/// Render a tool spec into the vendor shape.
104fn render_tool(spec: &CoreToolSpec) -> Tool {
105    let tool = Tool::new(spec.name.clone())
106        .with_description(spec.description.clone())
107        .with_schema(spec.schema.clone());
108    if spec.strict {
109        tool.with_strict(true)
110    } else {
111        tool
112    }
113}
114
115fn render_choice(choice: &CoreToolChoice) -> ToolChoice {
116    match choice {
117        CoreToolChoice::Auto => ToolChoice::Auto,
118        CoreToolChoice::None => ToolChoice::None,
119        CoreToolChoice::Required => ToolChoice::Required,
120        CoreToolChoice::Specific(name) => ToolChoice::Tool { name: name.clone() },
121    }
122}
123
124fn render_request(messages: &[CoreMessage], tools: &[CoreToolSpec]) -> ChatRequest {
125    let rendered = messages.iter().map(render_message).collect();
126    let mut request = ChatRequest::new(rendered);
127    if !tools.is_empty() {
128        request = request.with_tools(tools.iter().map(render_tool).collect::<Vec<_>>());
129    }
130    request
131}
132
133fn render_options(choice: &CoreToolChoice, config: &CoreGenerationConfig) -> ChatOptions {
134    let mut options = ChatOptions::default().with_tool_choice(render_choice(choice));
135    if let Some(max_tokens) = config.max_tokens {
136        options = options.with_max_tokens(max_tokens);
137    }
138    if let Some(temperature) = config.temperature {
139        options = options.with_temperature(f64::from(temperature));
140    }
141    if !config.stop_sequences.is_empty() {
142        options = options.with_stop_sequences(config.stop_sequences.clone());
143    }
144    options
145}
146
147impl GenAIProvider {
148    /// Execute one tool-calling chat turn against `model`.
149    ///
150    /// Returns the model's tool calls when any were issued, otherwise its
151    /// text. The caller records the output as an observation before acting
152    /// on it (R2); this driver records nothing.
153    pub async fn chat_turn_with_tools(
154        &self,
155        model: &str,
156        messages: &[CoreMessage],
157        tools: &[CoreToolSpec],
158        choice: CoreToolChoice,
159    ) -> Result<CoreTurnOutput> {
160        self.chat_turn_configured(
161            model,
162            messages,
163            tools,
164            choice,
165            CoreGenerationConfig::default(),
166        )
167        .await
168    }
169
170    /// Execute a tool-calling turn with explicit sampling controls.
171    pub async fn chat_turn_configured(
172        &self,
173        model: &str,
174        messages: &[CoreMessage],
175        tools: &[CoreToolSpec],
176        choice: CoreToolChoice,
177        config: CoreGenerationConfig,
178    ) -> Result<CoreTurnOutput> {
179        let request = render_request(messages, tools);
180        let options = render_options(&choice, &config);
181
182        let response = self
183            .client()
184            .exec_chat(model, request, Some(&options))
185            .await
186            .with_context(|| format!("tool-calling chat turn against {model:?}"))?;
187
188        self.add_tokens(response.usage.total_tokens.map(|t| t as usize).unwrap_or(0))
189            .await;
190
191        let calls: Vec<CoreToolCall> = response
192            .tool_calls()
193            .into_iter()
194            .map(|call| CoreToolCall {
195                call_id: call.call_id.clone(),
196                name: call.fn_name.clone(),
197                arguments: call.fn_arguments.clone(),
198            })
199            .collect();
200        if !calls.is_empty() {
201            return Ok(CoreTurnOutput::ToolCalls(calls));
202        }
203        Ok(CoreTurnOutput::Text(
204            response.first_text().unwrap_or_default().to_string(),
205        ))
206    }
207
208    /// Stream one tool-aware turn while retaining the complete tool calls.
209    ///
210    /// Reasoning is forwarded as it arrives. Answer text is held until the
211    /// terminal event proves that the model did not select a tool, preventing
212    /// tool-call preambles and protocol fragments from leaking into the chat.
213    pub async fn stream_tool_turn(
214        &self,
215        model: &str,
216        messages: &[CoreMessage],
217        tools: &[CoreToolSpec],
218        choice: CoreToolChoice,
219        mut emit: impl FnMut(CoreTurnStreamEvent),
220    ) -> Result<CoreTurnOutput> {
221        let request = render_request(messages, tools);
222        let options = render_options(&choice, &CoreGenerationConfig::default())
223            .with_capture_content(true)
224            .with_capture_tool_calls(true)
225            .with_capture_usage(true);
226        let response = self
227            .client()
228            .exec_chat_stream(model, request, Some(&options))
229            .await
230            .with_context(|| format!("streaming tool-aware chat turn against {model:?}"))?;
231        let mut stream = response.stream;
232        let mut end = None;
233
234        while let Some(event) = stream.next().await {
235            match event? {
236                ChatStreamEvent::ReasoningChunk(chunk) if !chunk.content.is_empty() => {
237                    emit(CoreTurnStreamEvent::Reasoning(chunk.content));
238                }
239                ChatStreamEvent::End(value) => {
240                    end = Some(value);
241                    break;
242                }
243                ChatStreamEvent::Start
244                | ChatStreamEvent::Chunk(_)
245                | ChatStreamEvent::ReasoningChunk(_)
246                | ChatStreamEvent::ToolCallChunk(_)
247                | ChatStreamEvent::ThoughtSignatureChunk(_) => {}
248            }
249        }
250
251        let end = end.context("tool-aware model stream ended without a terminal event")?;
252        self.add_tokens(
253            end.captured_usage
254                .as_ref()
255                .and_then(|usage| usage.total_tokens)
256                .map(|tokens| tokens as usize)
257                .unwrap_or(0),
258        )
259        .await;
260
261        let calls = end
262            .captured_tool_calls()
263            .unwrap_or_default()
264            .into_iter()
265            .map(|call| CoreToolCall {
266                call_id: call.call_id.clone(),
267                name: call.fn_name.clone(),
268                arguments: call.fn_arguments.clone(),
269            })
270            .collect::<Vec<_>>();
271        if !calls.is_empty() {
272            return Ok(CoreTurnOutput::ToolCalls(calls));
273        }
274
275        let text = end.captured_first_text().unwrap_or_default().to_string();
276        if !text.is_empty() {
277            emit(CoreTurnStreamEvent::Text(text.clone()));
278        }
279        Ok(CoreTurnOutput::Text(text))
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    #[test]
288    fn renders_every_message_role() {
289        let messages = [
290            CoreMessage::System {
291                content: "s".into(),
292            },
293            CoreMessage::User {
294                content: "u".into(),
295            },
296            CoreMessage::Assistant {
297                content: "a".into(),
298            },
299            CoreMessage::AssistantToolCalls {
300                calls: vec![CoreToolCall {
301                    call_id: "c1".into(),
302                    name: "read_file".into(),
303                    arguments: serde_json::json!({"path": "x"}),
304                }],
305            },
306            CoreMessage::ToolResponse {
307                call_id: "c1".into(),
308                content: "ok".into(),
309            },
310        ];
311        let rendered: Vec<ChatMessage> = messages.iter().map(render_message).collect();
312        assert_eq!(rendered.len(), 5);
313        use genai::chat::ChatRole;
314        assert_eq!(rendered[0].role, ChatRole::System);
315        assert_eq!(rendered[3].role, ChatRole::Assistant);
316        assert_eq!(rendered[4].role, ChatRole::Tool);
317    }
318
319    #[test]
320    fn strict_flag_reaches_the_vendor_tool() {
321        let spec = CoreToolSpec {
322            name: "edit_file".into(),
323            description: "d".into(),
324            schema: serde_json::json!({"type": "object"}),
325            strict: true,
326        };
327        let tool = render_tool(&spec);
328        assert_eq!(tool.strict, Some(true));
329        let lax = render_tool(&CoreToolSpec {
330            strict: false,
331            ..spec
332        });
333        assert_eq!(lax.strict, None);
334    }
335
336    #[test]
337    fn choice_policies_map_one_to_one() {
338        assert!(matches!(
339            render_choice(&CoreToolChoice::Auto),
340            ToolChoice::Auto
341        ));
342        assert!(matches!(
343            render_choice(&CoreToolChoice::Required),
344            ToolChoice::Required
345        ));
346        assert!(matches!(
347            render_choice(&CoreToolChoice::Specific("grep".into())),
348            ToolChoice::Tool { name } if name == "grep"
349        ));
350    }
351}