1use crate::canonical::ToolActionId;
4use crate::id::{ExchangeId, SessionKey, ToolId, ToolName, TransactionId};
5use crate::limits::ToolLimits;
6use serde::{Deserialize, Serialize};
7use std::time::{Duration, Instant};
8use thiserror::Error;
9
10#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
12pub struct JsonSchema {
13 schema: serde_json::Value,
14}
15
16impl JsonSchema {
17 pub fn try_new(schema: serde_json::Value) -> Result<Self, ToolContractError> {
19 if !schema.is_object() {
20 return Err(ToolContractError::SchemaNotObject);
21 }
22 Ok(Self { schema })
23 }
24
25 pub fn as_value(&self) -> &serde_json::Value {
27 &self.schema
28 }
29}
30
31#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
33pub enum ToolSuccessContract {
34 Json {
36 schema: JsonSchema,
38 },
39 Text {
41 media_type: String,
43 },
44}
45
46impl ToolSuccessContract {
47 pub fn json(schema: JsonSchema) -> Self {
49 Self::Json { schema }
50 }
51
52 pub fn text(media_type: impl Into<String>) -> Result<Self, ToolContractError> {
54 let media_type = media_type.into();
55 if media_type.is_empty()
56 || media_type.len() > 128
57 || media_type.chars().any(|c| c.is_control())
58 {
59 return Err(ToolContractError::InvalidMediaType);
60 }
61 Ok(Self::Text { media_type })
62 }
63}
64
65#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
67pub struct ToolOutputContract {
68 pub success: ToolSuccessContract,
70 pub error_data_schema: Option<JsonSchema>,
72}
73
74#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
78pub enum ToolExecutionClass {
79 CooperativeInProcess {
82 grace: Duration,
84 },
85 AbortableAtYield {
88 grace: Duration,
90 },
91 ProcessIsolated {
93 grace: Duration,
95 kill_deadline: Duration,
97 },
98}
99
100#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
102pub struct ToolSpec {
103 pub id: ToolId,
105 pub name: ToolName,
107 pub description: String,
109 pub input_schema: JsonSchema,
111 pub output_contract: ToolOutputContract,
113 pub limits: ToolLimits,
115 pub execution_class: ToolExecutionClass,
117}
118
119impl ToolSpec {
120 pub const MAX_DESCRIPTION_BYTES: usize = 4 * 1024;
122
123 pub fn try_new(
125 id: ToolId,
126 name: ToolName,
127 description: impl Into<String>,
128 input_schema: JsonSchema,
129 output_contract: ToolOutputContract,
130 limits: ToolLimits,
131 execution_class: ToolExecutionClass,
132 ) -> Result<Self, ToolContractError> {
133 let description = description.into();
134 if description.len() > Self::MAX_DESCRIPTION_BYTES {
135 return Err(ToolContractError::DescriptionTooLong);
136 }
137 if description.chars().any(|c| c.is_control()) {
138 return Err(ToolContractError::ControlCharacter);
139 }
140 if limits.max_concurrent == 0
141 || limits.max_input_bytes == 0
142 || limits.max_output_bytes == 0
143 || limits.execution_deadline.is_zero()
144 {
145 return Err(ToolContractError::InvalidLimits);
146 }
147 match &execution_class {
148 ToolExecutionClass::CooperativeInProcess { grace }
149 | ToolExecutionClass::AbortableAtYield { grace } => {
150 if grace.is_zero() {
151 return Err(ToolContractError::InvalidCancellationGrace);
152 }
153 }
154 ToolExecutionClass::ProcessIsolated {
155 grace,
156 kill_deadline,
157 } => {
158 if grace.is_zero() || kill_deadline.is_zero() {
159 return Err(ToolContractError::InvalidCancellationGrace);
160 }
161 }
162 }
163 Ok(Self {
164 id,
165 name,
166 description,
167 input_schema,
168 output_contract,
169 limits,
170 execution_class,
171 })
172 }
173}
174
175#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
177pub struct ToolCall {
178 pub tool_name: ToolName,
180 pub tool_id: ToolId,
182 pub provider_tool_call_id: String,
184 pub arguments: serde_json::Value,
186 pub request_ordinal: u32,
188}
189
190#[derive(Clone, Debug)]
192pub struct ToolCallContext {
193 pub transaction_id: TransactionId,
195 pub session_key: SessionKey,
197 pub exchange_id: Option<ExchangeId>,
199 pub tool_action_id: ToolActionId,
201 pub tool_id: ToolId,
203 pub deadline: Instant,
205}
206
207#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
209pub enum CanonicalToolOutput {
210 Json(serde_json::Value),
212 Text(String),
214}
215
216#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
218pub struct CanonicalToolError {
219 pub code: String,
221 pub message: String,
223 pub data: Option<serde_json::Value>,
225}
226
227impl CanonicalToolError {
228 pub fn try_new(
230 code: impl Into<String>,
231 message: impl Into<String>,
232 data: Option<serde_json::Value>,
233 max_message_bytes: usize,
234 ) -> Result<Self, ToolContractError> {
235 let code = code.into();
236 let message = message.into();
237 if code.is_empty() || code.len() > 64 || code.chars().any(|c| c.is_control()) {
238 return Err(ToolContractError::InvalidErrorCode);
239 }
240 if message.is_empty()
241 || message.len() > max_message_bytes
242 || message.chars().any(|c| c.is_control())
243 {
244 return Err(ToolContractError::InvalidErrorMessage);
245 }
246 Ok(Self {
247 code,
248 message,
249 data,
250 })
251 }
252}
253
254#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
256pub enum CanonicalToolResultOutcome {
257 Succeeded(CanonicalToolOutput),
259 DomainFailed(CanonicalToolError),
261}
262
263#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
265pub struct CanonicalToolResult {
266 pub transaction_id: TransactionId,
268 pub session_key: SessionKey,
270 pub exchange_id: ExchangeId,
272 pub tool_action_id: ToolActionId,
274 pub tool_id: ToolId,
276 pub provider_tool_call_id: String,
278 pub request_ordinal: u32,
280 pub outcome: CanonicalToolResultOutcome,
282}
283
284#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
286pub enum ToolLifecycleEvent {
287 Started {
289 tool_action_id: ToolActionId,
291 tool_id: ToolId,
293 tool_name: ToolName,
295 provider_tool_call_id: String,
297 request_ordinal: u32,
299 },
300 Completed {
302 result: CanonicalToolResult,
304 },
305 RuntimeFailed {
307 tool_action_id: ToolActionId,
309 tool_id: ToolId,
311 code: String,
313 },
314}
315
316#[derive(Clone, Debug, Error, PartialEq, Eq)]
318pub enum ToolContractError {
319 #[error("JSON schema must be an object")]
321 SchemaNotObject,
322 #[error("tool description exceeds maximum length")]
324 DescriptionTooLong,
325 #[error("tool string must not contain control characters")]
327 ControlCharacter,
328 #[error("tool limits must be non-zero")]
330 InvalidLimits,
331 #[error("cancellation grace must be non-zero")]
333 InvalidCancellationGrace,
334 #[error("invalid media type")]
336 InvalidMediaType,
337 #[error("invalid tool error code")]
339 InvalidErrorCode,
340 #[error("invalid tool error message")]
342 InvalidErrorMessage,
343}
344
345#[derive(Clone, Debug, Error, PartialEq, Eq)]
347pub enum ToolStartError {
348 #[error("tool capacity exceeded")]
350 CapacityExceeded,
351 #[error("tool start rejected: {0}")]
353 Rejected(&'static str),
354}
355
356#[derive(Clone, Debug, Error, PartialEq, Eq)]
358pub enum ToolRuntimeError {
359 #[error("tool panicked")]
361 Panicked,
362 #[error("tool completion lost")]
364 CompletionLost,
365 #[error("tool output contract violated")]
367 OutputContractViolated,
368 #[error("tool termination failed")]
370 TerminationFailed,
371 #[error("tool deadline exceeded")]
373 DeadlineExceeded,
374}
375
376#[derive(Clone, Debug, PartialEq)]
378pub enum ToolCompletion {
379 Succeeded(CanonicalToolOutput),
381 DomainFailed(CanonicalToolError),
383 RuntimeFailed(ToolRuntimeError),
385}
386
387#[cfg(test)]
388mod tests {
389 use super::*;
390 use crate::id::{ChannelId, SessionId};
391
392 #[test]
393 fn tool_spec_construction() {
394 let schema = JsonSchema::try_new(serde_json::json!({
395 "type": "object",
396 "properties": { "q": { "type": "string" } }
397 }))
398 .unwrap();
399 let out = ToolOutputContract {
400 success: ToolSuccessContract::json(schema.clone()),
401 error_data_schema: None,
402 };
403 let spec = ToolSpec::try_new(
404 ToolId::try_new("search").unwrap(),
405 ToolName::try_new("search").unwrap(),
406 "Search the workspace",
407 schema,
408 out,
409 ToolLimits::default(),
410 ToolExecutionClass::AbortableAtYield {
411 grace: Duration::from_secs(1),
412 },
413 )
414 .unwrap();
415 assert_eq!(spec.id.as_str(), "search");
416 }
417
418 #[test]
419 fn schema_must_be_object() {
420 assert!(JsonSchema::try_new(serde_json::json!([])).is_err());
421 }
422
423 #[test]
424 fn lifecycle_result_serializes() {
425 let tid = TransactionId::generate();
426 let sk = SessionKey::new(
427 ChannelId::try_new("ch").unwrap(),
428 SessionId::try_new("s").unwrap(),
429 );
430 let result = CanonicalToolResult {
431 transaction_id: tid,
432 session_key: sk,
433 exchange_id: ExchangeId::generate(),
434 tool_action_id: ToolActionId::new("a1"),
435 tool_id: ToolId::try_new("t").unwrap(),
436 provider_tool_call_id: "p1".into(),
437 request_ordinal: 0,
438 outcome: CanonicalToolResultOutcome::Succeeded(CanonicalToolOutput::Text("ok".into())),
439 };
440 let ev = ToolLifecycleEvent::Completed { result };
441 let json = serde_json::to_string(&ev).unwrap();
442 let _back: ToolLifecycleEvent = serde_json::from_str(&json).unwrap();
443 }
444}