monoloop-contracts 0.1.1

Shared identities, dialect descriptors, errors, and port contracts for Monoloop
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
//! Canonical tool specification, call, result, and lifecycle contracts.

use crate::canonical::ToolActionId;
use crate::id::{ExchangeId, SessionKey, ToolId, ToolName, TransactionId};
use crate::limits::ToolLimits;
use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant};
use thiserror::Error;

/// JSON Schema document for tool input/output (object root required).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct JsonSchema {
    schema: serde_json::Value,
}

impl JsonSchema {
    /// Construct from a JSON value that must be an object.
    pub fn try_new(schema: serde_json::Value) -> Result<Self, ToolContractError> {
        if !schema.is_object() {
            return Err(ToolContractError::SchemaNotObject);
        }
        Ok(Self { schema })
    }

    /// Borrow the schema value.
    pub fn as_value(&self) -> &serde_json::Value {
        &self.schema
    }
}

/// Declared successful tool output shape.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum ToolSuccessContract {
    /// JSON success body with schema.
    Json {
        /// Output schema.
        schema: JsonSchema,
    },
    /// Text success body with media type.
    Text {
        /// Bounded media type (e.g. `text/plain`).
        media_type: String,
    },
}

impl ToolSuccessContract {
    /// Construct JSON success contract.
    pub fn json(schema: JsonSchema) -> Self {
        Self::Json { schema }
    }

    /// Construct text success contract with validated media type.
    pub fn text(media_type: impl Into<String>) -> Result<Self, ToolContractError> {
        let media_type = media_type.into();
        if media_type.is_empty()
            || media_type.len() > 128
            || media_type.chars().any(|c| c.is_control())
        {
            return Err(ToolContractError::InvalidMediaType);
        }
        Ok(Self::Text { media_type })
    }
}

/// Output contract for a registered tool.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ToolOutputContract {
    /// Success shape.
    pub success: ToolSuccessContract,
    /// Optional domain-error data schema.
    pub error_data_schema: Option<JsonSchema>,
}

/// How a tool can be terminated.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ToolCancellationPolicy {
    /// Cooperative cancel with grace.
    Cooperative {
        /// Grace period.
        grace: Duration,
    },
    /// Abortable in-process.
    Abortable,
    /// Isolated killable worker with grace.
    IsolatedKillable {
        /// Grace period.
        grace: Duration,
    },
}

/// Immutable tool specification (no handler).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ToolSpec {
    /// Stable id for request selection.
    pub id: ToolId,
    /// Name exposed to models/MCP.
    pub name: ToolName,
    /// Bounded description.
    pub description: String,
    /// Input JSON schema.
    pub input_schema: JsonSchema,
    /// Output contract.
    pub output_contract: ToolOutputContract,
    /// Limits.
    pub limits: ToolLimits,
    /// Cancellation policy.
    pub cancellation: ToolCancellationPolicy,
}

impl ToolSpec {
    /// Maximum description bytes.
    pub const MAX_DESCRIPTION_BYTES: usize = 4 * 1024;

    /// Validate and construct a tool specification.
    pub fn try_new(
        id: ToolId,
        name: ToolName,
        description: impl Into<String>,
        input_schema: JsonSchema,
        output_contract: ToolOutputContract,
        limits: ToolLimits,
        cancellation: ToolCancellationPolicy,
    ) -> Result<Self, ToolContractError> {
        let description = description.into();
        if description.len() > Self::MAX_DESCRIPTION_BYTES {
            return Err(ToolContractError::DescriptionTooLong);
        }
        if description.chars().any(|c| c.is_control()) {
            return Err(ToolContractError::ControlCharacter);
        }
        if limits.max_concurrent == 0
            || limits.max_input_bytes == 0
            || limits.max_output_bytes == 0
            || limits.execution_deadline.is_zero()
        {
            return Err(ToolContractError::InvalidLimits);
        }
        match &cancellation {
            ToolCancellationPolicy::Cooperative { grace }
            | ToolCancellationPolicy::IsolatedKillable { grace } => {
                if grace.is_zero() {
                    return Err(ToolContractError::InvalidCancellationGrace);
                }
            }
            ToolCancellationPolicy::Abortable => {}
        }
        Ok(Self {
            id,
            name,
            description,
            input_schema,
            output_contract,
            limits,
            cancellation,
        })
    }
}

/// Provider-neutral tool call arguments at dispatch time.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ToolCall {
    /// Tool name as requested.
    pub tool_name: ToolName,
    /// Resolved tool id.
    pub tool_id: ToolId,
    /// Provider correlation id (preserved exactly).
    pub provider_tool_call_id: String,
    /// JSON arguments.
    pub arguments: serde_json::Value,
    /// Model-declared order within the exchange.
    pub request_ordinal: u32,
}

/// Correlation context for a tool invocation (no prompts or secrets).
#[derive(Clone, Debug)]
pub struct ToolCallContext {
    /// Owning transaction.
    pub transaction_id: TransactionId,
    /// Session key.
    pub session_key: SessionKey,
    /// Exchange when known.
    pub exchange_id: Option<ExchangeId>,
    /// Internal tool action id.
    pub tool_action_id: ToolActionId,
    /// Tool id.
    pub tool_id: ToolId,
    /// Absolute deadline.
    pub deadline: Instant,
}

/// Canonical successful or domain-failed tool output body.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum CanonicalToolOutput {
    /// JSON body.
    Json(serde_json::Value),
    /// Text body.
    Text(String),
}

/// Bounded public domain error from a tool.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CanonicalToolError {
    /// Error code.
    pub code: String,
    /// Safe message.
    pub message: String,
    /// Optional data.
    pub data: Option<serde_json::Value>,
}

impl CanonicalToolError {
    /// Construct a bounded domain error.
    pub fn try_new(
        code: impl Into<String>,
        message: impl Into<String>,
        data: Option<serde_json::Value>,
        max_message_bytes: usize,
    ) -> Result<Self, ToolContractError> {
        let code = code.into();
        let message = message.into();
        if code.is_empty() || code.len() > 64 || code.chars().any(|c| c.is_control()) {
            return Err(ToolContractError::InvalidErrorCode);
        }
        if message.is_empty()
            || message.len() > max_message_bytes
            || message.chars().any(|c| c.is_control())
        {
            return Err(ToolContractError::InvalidErrorMessage);
        }
        Ok(Self {
            code,
            message,
            data,
        })
    }
}

/// Success or declared domain failure (not a runtime failure).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum CanonicalToolResultOutcome {
    /// Validated success.
    Succeeded(CanonicalToolOutput),
    /// Declared domain failure.
    DomainFailed(CanonicalToolError),
}

/// Sole continuation/MCP success-domain product.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CanonicalToolResult {
    /// Transaction.
    pub transaction_id: TransactionId,
    /// Session key.
    pub session_key: SessionKey,
    /// Exchange.
    pub exchange_id: ExchangeId,
    /// Internal action id.
    pub tool_action_id: ToolActionId,
    /// Tool id.
    pub tool_id: ToolId,
    /// Provider tool call id preserved exactly.
    pub provider_tool_call_id: String,
    /// Model-declared order.
    pub request_ordinal: u32,
    /// Outcome.
    pub outcome: CanonicalToolResultOutcome,
}

/// Host tool lifecycle event on the transaction stream (not dialect observation).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub enum ToolLifecycleEvent {
    /// Dispatch accepted.
    Started {
        /// Action id.
        tool_action_id: ToolActionId,
        /// Tool id.
        tool_id: ToolId,
        /// Tool name.
        tool_name: ToolName,
        /// Provider call id.
        provider_tool_call_id: String,
        /// Ordinal.
        request_ordinal: u32,
    },
    /// Canonical result ready (success or domain failure).
    Completed {
        /// Result.
        result: CanonicalToolResult,
    },
    /// Runtime failure (selects ToolExchangeFailed when policy requires).
    RuntimeFailed {
        /// Action id.
        tool_action_id: ToolActionId,
        /// Tool id.
        tool_id: ToolId,
        /// Safe failure code.
        code: String,
    },
}

/// Tool contract construction error.
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum ToolContractError {
    /// Schema root must be object.
    #[error("JSON schema must be an object")]
    SchemaNotObject,
    /// Description too long.
    #[error("tool description exceeds maximum length")]
    DescriptionTooLong,
    /// Control character.
    #[error("tool string must not contain control characters")]
    ControlCharacter,
    /// Invalid limits.
    #[error("tool limits must be non-zero")]
    InvalidLimits,
    /// Invalid cancellation grace.
    #[error("cancellation grace must be non-zero")]
    InvalidCancellationGrace,
    /// Invalid media type.
    #[error("invalid media type")]
    InvalidMediaType,
    /// Invalid error code.
    #[error("invalid tool error code")]
    InvalidErrorCode,
    /// Invalid error message.
    #[error("invalid tool error message")]
    InvalidErrorMessage,
}

/// Failure starting a linked tool handler.
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum ToolStartError {
    /// Capacity exceeded.
    #[error("tool capacity exceeded")]
    CapacityExceeded,
    /// Handler rejected start.
    #[error("tool start rejected: {0}")]
    Rejected(&'static str),
}

/// Runtime failure from a tool implementation.
#[derive(Clone, Debug, Error, PartialEq, Eq)]
pub enum ToolRuntimeError {
    /// Panic caught.
    #[error("tool panicked")]
    Panicked,
    /// Lost completion.
    #[error("tool completion lost")]
    CompletionLost,
    /// Output contract violation.
    #[error("tool output contract violated")]
    OutputContractViolated,
    /// Termination mechanism failed.
    #[error("tool termination failed")]
    TerminationFailed,
    /// Deadline exceeded.
    #[error("tool deadline exceeded")]
    DeadlineExceeded,
}

/// Completion of a tool execution handle.
#[derive(Clone, Debug, PartialEq)]
pub enum ToolCompletion {
    /// Success output.
    Succeeded(CanonicalToolOutput),
    /// Domain failure.
    DomainFailed(CanonicalToolError),
    /// Runtime failure.
    RuntimeFailed(ToolRuntimeError),
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::id::{ChannelId, SessionId};

    #[test]
    fn tool_spec_construction() {
        let schema = JsonSchema::try_new(serde_json::json!({
            "type": "object",
            "properties": { "q": { "type": "string" } }
        }))
        .unwrap();
        let out = ToolOutputContract {
            success: ToolSuccessContract::json(schema.clone()),
            error_data_schema: None,
        };
        let spec = ToolSpec::try_new(
            ToolId::try_new("search").unwrap(),
            ToolName::try_new("search").unwrap(),
            "Search the workspace",
            schema,
            out,
            ToolLimits::default(),
            ToolCancellationPolicy::Abortable,
        )
        .unwrap();
        assert_eq!(spec.id.as_str(), "search");
    }

    #[test]
    fn schema_must_be_object() {
        assert!(JsonSchema::try_new(serde_json::json!([])).is_err());
    }

    #[test]
    fn lifecycle_result_serializes() {
        let tid = TransactionId::generate();
        let sk = SessionKey::new(
            ChannelId::try_new("ch").unwrap(),
            SessionId::try_new("s").unwrap(),
        );
        let result = CanonicalToolResult {
            transaction_id: tid,
            session_key: sk,
            exchange_id: ExchangeId::generate(),
            tool_action_id: ToolActionId::new("a1"),
            tool_id: ToolId::try_new("t").unwrap(),
            provider_tool_call_id: "p1".into(),
            request_ordinal: 0,
            outcome: CanonicalToolResultOutcome::Succeeded(CanonicalToolOutput::Text("ok".into())),
        };
        let ev = ToolLifecycleEvent::Completed { result };
        let json = serde_json::to_string(&ev).unwrap();
        let _back: ToolLifecycleEvent = serde_json::from_str(&json).unwrap();
    }
}