Skip to main content

perspt_sdk/model/
tool.rs

1//! Wire-neutral tool declaration and call types (PSP-9 system 4).
2//!
3//! `ToolCatalog::specs_for` produces these and the turn loop consumes them;
4//! no vendor's wire format reaches the harness. The `genai` driver mirrors
5//! them in `perspt-core`, and `perspt-agent::transport` performs the one
6//! translation.
7
8use serde::{Deserialize, Serialize};
9
10/// A provider-agnostic tool declaration sent with a chat request.
11#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct ToolSpec {
13    pub name: String,
14    pub description: String,
15    /// JSON Schema for the tool's arguments.
16    pub schema: serde_json::Value,
17    /// Ask the provider to enforce the schema. Routes without
18    /// `strict_schema` capability degrade to local validation with a
19    /// `ToolArgumentInvalid` residual on violation — never silently.
20    pub strict: bool,
21}
22
23/// One tool call requested by the model.
24#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
25pub struct ProviderToolCall {
26    /// Provider-issued id correlating the eventual tool response.
27    pub call_id: String,
28    pub name: String,
29    /// Arguments as returned by the model, before schema validation.
30    pub arguments: serde_json::Value,
31}
32
33/// The result of one assistant turn.
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub enum TurnOutput {
36    /// The model requested tool calls; each becomes an `EffectProposal`.
37    ToolCalls(Vec<ProviderToolCall>),
38    /// The model produced text and requested nothing.
39    Text(String),
40}
41
42impl TurnOutput {
43    /// The requested calls, empty for a text turn.
44    pub fn tool_calls(&self) -> &[ProviderToolCall] {
45        match self {
46            TurnOutput::ToolCalls(calls) => calls,
47            TurnOutput::Text(_) => &[],
48        }
49    }
50}
51
52/// How the harness constrains tool selection for a turn.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum ToolChoicePolicy {
56    /// The model decides whether to call tools.
57    Auto,
58    /// Tool calls are forbidden this turn (e.g. a summary turn).
59    None,
60    /// The model must call at least one tool.
61    Required,
62    /// The model must call this specific tool.
63    Specific(String),
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn a_text_turn_has_no_tool_calls() {
72        assert!(TurnOutput::Text("done".into()).tool_calls().is_empty());
73    }
74
75    #[test]
76    fn tool_calls_round_trip_through_serde() {
77        let turn = TurnOutput::ToolCalls(vec![ProviderToolCall {
78            call_id: "c1".into(),
79            name: "read_file".into(),
80            arguments: serde_json::json!({"path": "src/lib.rs"}),
81        }]);
82        let json = serde_json::to_string(&turn).unwrap();
83        let back: TurnOutput = serde_json::from_str(&json).unwrap();
84        assert_eq!(back, turn);
85        assert_eq!(back.tool_calls().len(), 1);
86    }
87}