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/// How a tool can be terminated.
75#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
76pub enum ToolCancellationPolicy {
77    /// Cooperative cancel with grace.
78    Cooperative {
79        /// Grace period.
80        grace: Duration,
81    },
82    /// Abortable in-process.
83    Abortable,
84    /// Isolated killable worker with grace.
85    IsolatedKillable {
86        /// Grace period.
87        grace: Duration,
88    },
89}
90
91/// Immutable tool specification (no handler).
92#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
93pub struct ToolSpec {
94    /// Stable id for request selection.
95    pub id: ToolId,
96    /// Name exposed to models/MCP.
97    pub name: ToolName,
98    /// Bounded description.
99    pub description: String,
100    /// Input JSON schema.
101    pub input_schema: JsonSchema,
102    /// Output contract.
103    pub output_contract: ToolOutputContract,
104    /// Limits.
105    pub limits: ToolLimits,
106    /// Cancellation policy.
107    pub cancellation: ToolCancellationPolicy,
108}
109
110impl ToolSpec {
111    /// Maximum description bytes.
112    pub const MAX_DESCRIPTION_BYTES: usize = 4 * 1024;
113
114    /// Validate and construct a tool specification.
115    pub fn try_new(
116        id: ToolId,
117        name: ToolName,
118        description: impl Into<String>,
119        input_schema: JsonSchema,
120        output_contract: ToolOutputContract,
121        limits: ToolLimits,
122        cancellation: ToolCancellationPolicy,
123    ) -> Result<Self, ToolContractError> {
124        let description = description.into();
125        if description.len() > Self::MAX_DESCRIPTION_BYTES {
126            return Err(ToolContractError::DescriptionTooLong);
127        }
128        if description.chars().any(|c| c.is_control()) {
129            return Err(ToolContractError::ControlCharacter);
130        }
131        if limits.max_concurrent == 0
132            || limits.max_input_bytes == 0
133            || limits.max_output_bytes == 0
134            || limits.execution_deadline.is_zero()
135        {
136            return Err(ToolContractError::InvalidLimits);
137        }
138        match &cancellation {
139            ToolCancellationPolicy::Cooperative { grace }
140            | ToolCancellationPolicy::IsolatedKillable { grace } => {
141                if grace.is_zero() {
142                    return Err(ToolContractError::InvalidCancellationGrace);
143                }
144            }
145            ToolCancellationPolicy::Abortable => {}
146        }
147        Ok(Self {
148            id,
149            name,
150            description,
151            input_schema,
152            output_contract,
153            limits,
154            cancellation,
155        })
156    }
157}
158
159/// Provider-neutral tool call arguments at dispatch time.
160#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
161pub struct ToolCall {
162    /// Tool name as requested.
163    pub tool_name: ToolName,
164    /// Resolved tool id.
165    pub tool_id: ToolId,
166    /// Provider correlation id (preserved exactly).
167    pub provider_tool_call_id: String,
168    /// JSON arguments.
169    pub arguments: serde_json::Value,
170    /// Model-declared order within the exchange.
171    pub request_ordinal: u32,
172}
173
174/// Correlation context for a tool invocation (no prompts or secrets).
175#[derive(Clone, Debug)]
176pub struct ToolCallContext {
177    /// Owning transaction.
178    pub transaction_id: TransactionId,
179    /// Session key.
180    pub session_key: SessionKey,
181    /// Exchange when known.
182    pub exchange_id: Option<ExchangeId>,
183    /// Internal tool action id.
184    pub tool_action_id: ToolActionId,
185    /// Tool id.
186    pub tool_id: ToolId,
187    /// Absolute deadline.
188    pub deadline: Instant,
189}
190
191/// Canonical successful or domain-failed tool output body.
192#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
193pub enum CanonicalToolOutput {
194    /// JSON body.
195    Json(serde_json::Value),
196    /// Text body.
197    Text(String),
198}
199
200/// Bounded public domain error from a tool.
201#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
202pub struct CanonicalToolError {
203    /// Error code.
204    pub code: String,
205    /// Safe message.
206    pub message: String,
207    /// Optional data.
208    pub data: Option<serde_json::Value>,
209}
210
211impl CanonicalToolError {
212    /// Construct a bounded domain error.
213    pub fn try_new(
214        code: impl Into<String>,
215        message: impl Into<String>,
216        data: Option<serde_json::Value>,
217        max_message_bytes: usize,
218    ) -> Result<Self, ToolContractError> {
219        let code = code.into();
220        let message = message.into();
221        if code.is_empty() || code.len() > 64 || code.chars().any(|c| c.is_control()) {
222            return Err(ToolContractError::InvalidErrorCode);
223        }
224        if message.is_empty()
225            || message.len() > max_message_bytes
226            || message.chars().any(|c| c.is_control())
227        {
228            return Err(ToolContractError::InvalidErrorMessage);
229        }
230        Ok(Self {
231            code,
232            message,
233            data,
234        })
235    }
236}
237
238/// Success or declared domain failure (not a runtime failure).
239#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
240pub enum CanonicalToolResultOutcome {
241    /// Validated success.
242    Succeeded(CanonicalToolOutput),
243    /// Declared domain failure.
244    DomainFailed(CanonicalToolError),
245}
246
247/// Sole continuation/MCP success-domain product.
248#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
249pub struct CanonicalToolResult {
250    /// Transaction.
251    pub transaction_id: TransactionId,
252    /// Session key.
253    pub session_key: SessionKey,
254    /// Exchange.
255    pub exchange_id: ExchangeId,
256    /// Internal action id.
257    pub tool_action_id: ToolActionId,
258    /// Tool id.
259    pub tool_id: ToolId,
260    /// Provider tool call id preserved exactly.
261    pub provider_tool_call_id: String,
262    /// Model-declared order.
263    pub request_ordinal: u32,
264    /// Outcome.
265    pub outcome: CanonicalToolResultOutcome,
266}
267
268/// Host tool lifecycle event on the transaction stream (not dialect observation).
269#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
270pub enum ToolLifecycleEvent {
271    /// Dispatch accepted.
272    Started {
273        /// Action id.
274        tool_action_id: ToolActionId,
275        /// Tool id.
276        tool_id: ToolId,
277        /// Tool name.
278        tool_name: ToolName,
279        /// Provider call id.
280        provider_tool_call_id: String,
281        /// Ordinal.
282        request_ordinal: u32,
283    },
284    /// Canonical result ready (success or domain failure).
285    Completed {
286        /// Result.
287        result: CanonicalToolResult,
288    },
289    /// Runtime failure (selects ToolExchangeFailed when policy requires).
290    RuntimeFailed {
291        /// Action id.
292        tool_action_id: ToolActionId,
293        /// Tool id.
294        tool_id: ToolId,
295        /// Safe failure code.
296        code: String,
297    },
298}
299
300/// Tool contract construction error.
301#[derive(Clone, Debug, Error, PartialEq, Eq)]
302pub enum ToolContractError {
303    /// Schema root must be object.
304    #[error("JSON schema must be an object")]
305    SchemaNotObject,
306    /// Description too long.
307    #[error("tool description exceeds maximum length")]
308    DescriptionTooLong,
309    /// Control character.
310    #[error("tool string must not contain control characters")]
311    ControlCharacter,
312    /// Invalid limits.
313    #[error("tool limits must be non-zero")]
314    InvalidLimits,
315    /// Invalid cancellation grace.
316    #[error("cancellation grace must be non-zero")]
317    InvalidCancellationGrace,
318    /// Invalid media type.
319    #[error("invalid media type")]
320    InvalidMediaType,
321    /// Invalid error code.
322    #[error("invalid tool error code")]
323    InvalidErrorCode,
324    /// Invalid error message.
325    #[error("invalid tool error message")]
326    InvalidErrorMessage,
327}
328
329/// Failure starting a linked tool handler.
330#[derive(Clone, Debug, Error, PartialEq, Eq)]
331pub enum ToolStartError {
332    /// Capacity exceeded.
333    #[error("tool capacity exceeded")]
334    CapacityExceeded,
335    /// Handler rejected start.
336    #[error("tool start rejected: {0}")]
337    Rejected(&'static str),
338}
339
340/// Runtime failure from a tool implementation.
341#[derive(Clone, Debug, Error, PartialEq, Eq)]
342pub enum ToolRuntimeError {
343    /// Panic caught.
344    #[error("tool panicked")]
345    Panicked,
346    /// Lost completion.
347    #[error("tool completion lost")]
348    CompletionLost,
349    /// Output contract violation.
350    #[error("tool output contract violated")]
351    OutputContractViolated,
352    /// Termination mechanism failed.
353    #[error("tool termination failed")]
354    TerminationFailed,
355    /// Deadline exceeded.
356    #[error("tool deadline exceeded")]
357    DeadlineExceeded,
358}
359
360/// Completion of a tool execution handle.
361#[derive(Clone, Debug, PartialEq)]
362pub enum ToolCompletion {
363    /// Success output.
364    Succeeded(CanonicalToolOutput),
365    /// Domain failure.
366    DomainFailed(CanonicalToolError),
367    /// Runtime failure.
368    RuntimeFailed(ToolRuntimeError),
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use crate::id::{ChannelId, SessionId};
375
376    #[test]
377    fn tool_spec_construction() {
378        let schema = JsonSchema::try_new(serde_json::json!({
379            "type": "object",
380            "properties": { "q": { "type": "string" } }
381        }))
382        .unwrap();
383        let out = ToolOutputContract {
384            success: ToolSuccessContract::json(schema.clone()),
385            error_data_schema: None,
386        };
387        let spec = ToolSpec::try_new(
388            ToolId::try_new("search").unwrap(),
389            ToolName::try_new("search").unwrap(),
390            "Search the workspace",
391            schema,
392            out,
393            ToolLimits::default(),
394            ToolCancellationPolicy::Abortable,
395        )
396        .unwrap();
397        assert_eq!(spec.id.as_str(), "search");
398    }
399
400    #[test]
401    fn schema_must_be_object() {
402        assert!(JsonSchema::try_new(serde_json::json!([])).is_err());
403    }
404
405    #[test]
406    fn lifecycle_result_serializes() {
407        let tid = TransactionId::generate();
408        let sk = SessionKey::new(
409            ChannelId::try_new("ch").unwrap(),
410            SessionId::try_new("s").unwrap(),
411        );
412        let result = CanonicalToolResult {
413            transaction_id: tid,
414            session_key: sk,
415            exchange_id: ExchangeId::generate(),
416            tool_action_id: ToolActionId::new("a1"),
417            tool_id: ToolId::try_new("t").unwrap(),
418            provider_tool_call_id: "p1".into(),
419            request_ordinal: 0,
420            outcome: CanonicalToolResultOutcome::Succeeded(CanonicalToolOutput::Text("ok".into())),
421        };
422        let ev = ToolLifecycleEvent::Completed { result };
423        let json = serde_json::to_string(&ev).unwrap();
424        let _back: ToolLifecycleEvent = serde_json::from_str(&json).unwrap();
425    }
426}