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}