release/1.3.1
rs 266 lines 7.96 KB
Raw
1 //! Host-defined tools and the policies that guard them.
2
3 use async_trait::async_trait;
4 use serde::{Deserialize, Serialize};
5
6 /// A tool the model may ask the host application to run.
7 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
8 pub struct AgentToolDefinition {
9 pub name: String,
10 pub description: String,
11 /// JSON Schema encoded as a string.
12 pub parameters_schema: String,
13 pub risk: ToolRisk,
14 }
15
16 impl AgentToolDefinition {
17 pub fn read_only(
18 name: impl Into<String>,
19 description: impl Into<String>,
20 parameters_schema: impl Into<String>,
21 ) -> Self {
22 Self {
23 name: name.into(),
24 description: description.into(),
25 parameters_schema: parameters_schema.into(),
26 risk: ToolRisk::ReadOnly,
27 }
28 }
29
30 pub fn mutating(
31 name: impl Into<String>,
32 description: impl Into<String>,
33 parameters_schema: impl Into<String>,
34 ) -> Self {
35 Self {
36 name: name.into(),
37 description: description.into(),
38 parameters_schema: parameters_schema.into(),
39 risk: ToolRisk::Mutating,
40 }
41 }
42
43 pub(crate) fn validate(&self) -> Result<(), AgentError> {
44 let mut chars = self.name.chars();
45 let valid_first = chars.next().is_some_and(|c| c.is_ascii_alphabetic());
46 let valid_rest = chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
47 if !valid_first || !valid_rest || self.name.len() > 64 {
48 return Err(AgentError::InvalidTool {
49 reason: format!(
50 "tool name {:?} must start with an ASCII letter and contain only letters, numbers, '_' or '-' (maximum 64 bytes)",
51 self.name
52 ),
53 });
54 }
55 if self.description.trim().is_empty() {
56 return Err(AgentError::InvalidTool {
57 reason: format!("tool {:?} needs a description", self.name),
58 });
59 }
60 let schema: serde_json::Value =
61 serde_json::from_str(&self.parameters_schema).map_err(|error| {
62 AgentError::InvalidTool {
63 reason: format!("tool {:?} has invalid JSON Schema: {error}", self.name),
64 }
65 })?;
66 if schema.get("type").and_then(serde_json::Value::as_str) != Some("object") {
67 return Err(AgentError::InvalidTool {
68 reason: format!("tool {:?} schema must have type object", self.name),
69 });
70 }
71 Ok(())
72 }
73 }
74
75 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
76 pub enum ToolRisk {
77 ReadOnly,
78 Mutating,
79 }
80
81 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82 pub struct ToolCall {
83 pub id: String,
84 pub name: String,
85 pub arguments: String,
86 }
87
88 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
89 pub struct ToolExecutionResult {
90 pub tool_call_id: String,
91 pub content: String,
92 pub is_error: bool,
93 }
94
95 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96 pub struct ApprovalRequest {
97 pub call: ToolCall,
98 pub risk: ToolRisk,
99 }
100
101 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
102 pub enum ApprovalDecision {
103 AllowOnce,
104 AllowForSession,
105 Deny,
106 }
107
108 #[async_trait]
109 pub trait ToolExecutor: Send + Sync + 'static {
110 async fn execute(&self, tool_name: &str, arguments: &str) -> Result<String, String>;
111 }
112
113 #[async_trait]
114 pub trait ApprovalHandler: Send + Sync + 'static {
115 async fn approve(&self, request: ApprovalRequest) -> ApprovalDecision;
116 }
117
118 #[derive(Debug, Default)]
119 pub struct RejectingExecutor;
120
121 #[async_trait]
122 impl ToolExecutor for RejectingExecutor {
123 async fn execute(&self, tool_name: &str, _: &str) -> Result<String, String> {
124 Err(format!("no executor is registered for tool {tool_name:?}"))
125 }
126 }
127
128 #[derive(Debug, Default)]
129 pub struct DenyApprovals;
130
131 #[async_trait]
132 impl ApprovalHandler for DenyApprovals {
133 async fn approve(&self, _: ApprovalRequest) -> ApprovalDecision {
134 ApprovalDecision::Deny
135 }
136 }
137
138 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
139 pub struct AgentConfig {
140 pub max_tool_rounds: u8,
141 pub max_tool_output_chars: usize,
142 }
143
144 impl Default for AgentConfig {
145 fn default() -> Self {
146 Self {
147 max_tool_rounds: 8,
148 max_tool_output_chars: 10_000,
149 }
150 }
151 }
152
153 impl AgentConfig {
154 pub(crate) fn validated(self) -> Self {
155 Self {
156 max_tool_rounds: self.max_tool_rounds.clamp(1, 24),
157 max_tool_output_chars: self.max_tool_output_chars.max(1),
158 }
159 }
160
161 /// Whether another tool round may run, given how many already have.
162 ///
163 /// Asked twice per round, and the pair is what makes the loop terminate:
164 /// once to decide whether to offer tools to the model again, and once at
165 /// the top of the next round to decide whether to run it at all. If only
166 /// the first check existed, a model that requested tools it was never
167 /// offered would keep the loop alive indefinitely.
168 pub(crate) fn allows_round(&self, rounds: u8) -> bool {
169 rounds < self.max_tool_rounds
170 }
171 }
172
173 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
174 pub struct AgentReply {
175 pub text: String,
176 pub duration_seconds: f64,
177 pub duration: String,
178 pub tool_rounds: u8,
179 }
180
181 #[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
182 pub enum AgentError {
183 #[error("invalid tool: {reason}")]
184 InvalidTool { reason: String },
185 #[error("a tool named {name:?} is already registered")]
186 DuplicateTool { name: String },
187 #[error("the loaded model does not support tool calling")]
188 UnsupportedModel,
189 #[error("the agent turn was cancelled")]
190 Cancelled,
191 #[error("the model returned an empty final reply")]
192 EmptyReply,
193 #[error("the agent stopped after {rounds} tool rounds without a final reply")]
194 ToolRoundsExhausted { rounds: u8 },
195 #[error("inference failed: {reason}")]
196 Inference { reason: String },
197 }
198
199 pub(crate) fn truncate_output(value: String, max_chars: usize) -> String {
200 if value.chars().count() <= max_chars {
201 return value;
202 }
203 let mut truncated: String = value.chars().take(max_chars).collect();
204 truncated.push_str("\n[tool output truncated by Ed]");
205 truncated
206 }
207
208 #[cfg(test)]
209 mod tests {
210 use super::*;
211
212 #[test]
213 fn validates_tool_names_and_object_schemas() {
214 AgentToolDefinition::read_only("clock", "Read the clock", r#"{"type":"object"}"#)
215 .validate()
216 .unwrap();
217 assert!(
218 AgentToolDefinition::read_only("bad name", "Read", r#"{"type":"object"}"#)
219 .validate()
220 .is_err()
221 );
222 assert!(
223 AgentToolDefinition::read_only("clock", "Read", r#"{"type":"array"}"#)
224 .validate()
225 .is_err()
226 );
227 }
228
229 #[test]
230 fn a_validated_budget_always_allows_at_least_one_round() {
231 // A zero budget would mean registering tools quietly disabled them:
232 // the model would be offered tools it could never be allowed to use.
233 let config = AgentConfig {
234 max_tool_rounds: 0,
235 max_tool_output_chars: 0,
236 }
237 .validated();
238
239 assert!(config.allows_round(0));
240 assert!(!config.allows_round(1));
241 assert_eq!(config.max_tool_output_chars, 1);
242 }
243
244 #[test]
245 fn the_round_budget_closes_at_the_limit_and_stays_closed() {
246 let config = AgentConfig {
247 max_tool_rounds: 3,
248 max_tool_output_chars: 100,
249 }
250 .validated();
251
252 assert!(config.allows_round(2));
253 assert!(!config.allows_round(3));
254 // Past the limit too: `rounds` is only ever compared, never trusted to
255 // land exactly on the boundary.
256 assert!(!config.allows_round(u8::MAX));
257 }
258
259 #[test]
260 fn truncation_is_character_safe_and_explicit() {
261 assert_eq!(
262 truncate_output("héllo".into(), 2),
263 "hé\n[tool output truncated by Ed]"
264 );
265 }
266 }