Skip to main content

monoloop_contracts/
tool.rs

1//! Canonical tool specification, call, result, and lifecycle contracts.
2
3use 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/// JSON Schema document for tool input/output (object root required).
11#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
12pub struct JsonSchema {
13    schema: serde_json::Value,
14}
15
16impl JsonSchema {
17    /// Construct from a JSON value that must be an object.
18    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    /// Borrow the schema value.
26    pub fn as_value(&self) -> &serde_json::Value {
27        &self.schema
28    }
29}
30
31/// Declared successful tool output shape.
32#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
33pub enum ToolSuccessContract {
34    /// JSON success body with schema.
35    Json {
36        /// Output schema.
37        schema: JsonSchema,
38    },
39    /// Text success body with media type.
40    Text {
41        /// Bounded media type (e.g. `text/plain`).
42        media_type: String,
43    },
44}
45
46impl ToolSuccessContract {
47    /// Construct JSON success contract.
48    pub fn json(schema: JsonSchema) -> Self {
49        Self::Json { schema }
50    }
51
52    /// Construct text success contract with validated media type.
53    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/// Output contract for a registered tool.
66#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
67pub struct ToolOutputContract {
68    /// Success shape.
69    pub success: ToolSuccessContract,
70    /// Optional domain-error data schema.
71    pub error_data_schema: Option<JsonSchema>,
72}
73
74/// Structural execution / termination class for a registered tool (v2 §14).
75///
76/// Names describe real guarantees — not wishful “kill” labels on Tokio tasks.
77#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
78pub enum ToolExecutionClass {
79    /// Cooperative cancel token; runtime cannot force stop. Failure to join
80    /// leaves cleanup pending and can prevent `Stopped`.
81    CooperativeInProcess {
82        /// Grace period before cleanup-pending.
83        grace: Duration,
84    },
85    /// Runtime owns a join handle and may `abort` at an await yield only.
86    /// Not hard-killable.
87    AbortableAtYield {
88        /// Grace period before abort.
89        grace: Duration,
90    },
91    /// Child process (or equivalent) isolation boundary with kill + wait.
92    ProcessIsolated {
93        /// Cooperative cancel grace before kill.
94        grace: Duration,
95        /// Hard cleanup deadline for kill/wait.
96        kill_deadline: Duration,
97    },
98}
99
100/// Immutable tool specification (no handler).
101#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
102pub struct ToolSpec {
103    /// Stable id for request selection.
104    pub id: ToolId,
105    /// Name exposed to models/MCP.
106    pub name: ToolName,
107    /// Bounded description.
108    pub description: String,
109    /// Input JSON schema.
110    pub input_schema: JsonSchema,
111    /// Output contract.
112    pub output_contract: ToolOutputContract,
113    /// Limits.
114    pub limits: ToolLimits,
115    /// Execution / termination class.
116    pub execution_class: ToolExecutionClass,
117}
118
119impl ToolSpec {
120    /// Maximum description bytes.
121    pub const MAX_DESCRIPTION_BYTES: usize = 4 * 1024;
122
123    /// Validate and construct a tool specification.
124    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/// Provider-neutral tool call arguments at dispatch time.
176#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
177pub struct ToolCall {
178    /// Tool name as requested.
179    pub tool_name: ToolName,
180    /// Resolved tool id.
181    pub tool_id: ToolId,
182    /// Provider correlation id (preserved exactly).
183    pub provider_tool_call_id: String,
184    /// JSON arguments.
185    pub arguments: serde_json::Value,
186    /// Model-declared order within the exchange.
187    pub request_ordinal: u32,
188}
189
190/// Correlation context for a tool invocation (no prompts or secrets).
191#[derive(Clone, Debug)]
192pub struct ToolCallContext {
193    /// Owning transaction.
194    pub transaction_id: TransactionId,
195    /// Session key.
196    pub session_key: SessionKey,
197    /// Exchange when known.
198    pub exchange_id: Option<ExchangeId>,
199    /// Internal tool action id.
200    pub tool_action_id: ToolActionId,
201    /// Tool id.
202    pub tool_id: ToolId,
203    /// Absolute deadline.
204    pub deadline: Instant,
205}
206
207/// Canonical successful or domain-failed tool output body.
208#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
209pub enum CanonicalToolOutput {
210    /// JSON body.
211    Json(serde_json::Value),
212    /// Text body.
213    Text(String),
214}
215
216/// Bounded public domain error from a tool.
217#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
218pub struct CanonicalToolError {
219    /// Error code.
220    pub code: String,
221    /// Safe message.
222    pub message: String,
223    /// Optional data.
224    pub data: Option<serde_json::Value>,
225}
226
227impl CanonicalToolError {
228    /// Construct a bounded domain error.
229    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/// Success or declared domain failure (not a runtime failure).
255#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
256pub enum CanonicalToolResultOutcome {
257    /// Validated success.
258    Succeeded(CanonicalToolOutput),
259    /// Declared domain failure.
260    DomainFailed(CanonicalToolError),
261}
262
263/// Sole continuation/MCP success-domain product.
264#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
265pub struct CanonicalToolResult {
266    /// Transaction.
267    pub transaction_id: TransactionId,
268    /// Session key.
269    pub session_key: SessionKey,
270    /// Exchange.
271    pub exchange_id: ExchangeId,
272    /// Internal action id.
273    pub tool_action_id: ToolActionId,
274    /// Tool id.
275    pub tool_id: ToolId,
276    /// Provider tool call id preserved exactly.
277    pub provider_tool_call_id: String,
278    /// Model-declared order.
279    pub request_ordinal: u32,
280    /// Outcome.
281    pub outcome: CanonicalToolResultOutcome,
282}
283
284/// Host tool lifecycle event on the transaction stream (not dialect observation).
285#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
286pub enum ToolLifecycleEvent {
287    /// Dispatch accepted.
288    Started {
289        /// Action id.
290        tool_action_id: ToolActionId,
291        /// Tool id.
292        tool_id: ToolId,
293        /// Tool name.
294        tool_name: ToolName,
295        /// Provider call id.
296        provider_tool_call_id: String,
297        /// Ordinal.
298        request_ordinal: u32,
299    },
300    /// Canonical result ready (success or domain failure).
301    Completed {
302        /// Result.
303        result: CanonicalToolResult,
304    },
305    /// Runtime failure (selects ToolExchangeFailed when policy requires).
306    RuntimeFailed {
307        /// Action id.
308        tool_action_id: ToolActionId,
309        /// Tool id.
310        tool_id: ToolId,
311        /// Safe failure code.
312        code: String,
313    },
314}
315
316/// Tool contract construction error.
317#[derive(Clone, Debug, Error, PartialEq, Eq)]
318pub enum ToolContractError {
319    /// Schema root must be object.
320    #[error("JSON schema must be an object")]
321    SchemaNotObject,
322    /// Description too long.
323    #[error("tool description exceeds maximum length")]
324    DescriptionTooLong,
325    /// Control character.
326    #[error("tool string must not contain control characters")]
327    ControlCharacter,
328    /// Invalid limits.
329    #[error("tool limits must be non-zero")]
330    InvalidLimits,
331    /// Invalid cancellation grace.
332    #[error("cancellation grace must be non-zero")]
333    InvalidCancellationGrace,
334    /// Invalid media type.
335    #[error("invalid media type")]
336    InvalidMediaType,
337    /// Invalid error code.
338    #[error("invalid tool error code")]
339    InvalidErrorCode,
340    /// Invalid error message.
341    #[error("invalid tool error message")]
342    InvalidErrorMessage,
343}
344
345/// Failure starting a linked tool handler.
346#[derive(Clone, Debug, Error, PartialEq, Eq)]
347pub enum ToolStartError {
348    /// Capacity exceeded.
349    #[error("tool capacity exceeded")]
350    CapacityExceeded,
351    /// Handler rejected start.
352    #[error("tool start rejected: {0}")]
353    Rejected(&'static str),
354}
355
356/// Runtime failure from a tool implementation.
357#[derive(Clone, Debug, Error, PartialEq, Eq)]
358pub enum ToolRuntimeError {
359    /// Panic caught.
360    #[error("tool panicked")]
361    Panicked,
362    /// Lost completion.
363    #[error("tool completion lost")]
364    CompletionLost,
365    /// Output contract violation.
366    #[error("tool output contract violated")]
367    OutputContractViolated,
368    /// Termination mechanism failed.
369    #[error("tool termination failed")]
370    TerminationFailed,
371    /// Deadline exceeded.
372    #[error("tool deadline exceeded")]
373    DeadlineExceeded,
374}
375
376/// Completion of a tool execution handle.
377#[derive(Clone, Debug, PartialEq)]
378pub enum ToolCompletion {
379    /// Success output.
380    Succeeded(CanonicalToolOutput),
381    /// Domain failure.
382    DomainFailed(CanonicalToolError),
383    /// Runtime failure.
384    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}