Skip to main content

monoloop_contracts/
channel.rs

1//! Channel capability data contracts (no live factory arcs).
2
3use crate::config::{ContinuationPolicy, OptionPolicy};
4use crate::dialect::DialectDescriptor;
5use crate::limits::ChannelLimits;
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeSet;
8use thiserror::Error;
9
10/// Whether the Channel talks to an external agent or a direct LLM API.
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
12pub enum ChannelKind {
13    /// External coding agent (ACP family, etc.).
14    ExternalAgent,
15    /// Direct model API (e.g. OpenAI Chat Completions).
16    DirectLlm,
17}
18
19/// How tools execute for transactions on this Channel.
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
21pub enum ToolExecutionMode {
22    /// External agent calls Monoloop MCP gateway.
23    McpGateway,
24    /// Model emits tool calls handled by LoopRuntime.
25    ModelToolCalls,
26    /// No host tools.
27    None,
28}
29
30/// MCP configuration installability on the external agent.
31#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
32pub enum McpConfigurationCapability {
33    /// No Monoloop MCP attach.
34    None,
35    /// MCP only at external session creation.
36    CreationOnly,
37    /// MCP can be refreshed across transactions on one session.
38    Refreshable,
39}
40
41/// Session topology for the Channel.
42#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
43pub enum SessionMode {
44    /// Ephemeral routing ids only (direct LLM).
45    Stateless,
46    /// External durable session owned outside Monoloop.
47    External,
48}
49
50/// Whether the external agent can reach Monoloop's MCP listener.
51#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
52pub enum McpReachability {
53    /// Not applicable.
54    None,
55    /// Agent and runtime share loopback namespace.
56    SameLoopbackNamespace,
57    /// Qualified remote transport (not initial product path).
58    QualifiedRemoteTransport,
59}
60
61/// Provider exchange shape.
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
63pub enum ExchangeMode {
64    /// HTTP-style one request/response cycle.
65    RequestResponse,
66    /// Retained session / bidirectional exchange.
67    Bidirectional,
68}
69
70/// Declared Channel capabilities (immutable registry data).
71#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
72pub struct ChannelCapabilities {
73    /// Session topology.
74    pub session_mode: SessionMode,
75    /// MCP install mode.
76    pub mcp_configuration: McpConfigurationCapability,
77    /// MCP reachability.
78    pub mcp_reachability: McpReachability,
79    /// Exchange shape.
80    pub exchange_mode: ExchangeMode,
81    /// Supported continuation policies.
82    pub continuation_policies: BTreeSet<ContinuationPolicy>,
83    /// Must be true for production Channels.
84    pub supports_distinct_session_concurrency: bool,
85    /// Input dialect declaration.
86    pub input_dialect: DialectDescriptor,
87    /// Output dialect declaration.
88    pub output_dialect: DialectDescriptor,
89    /// Immutable option/extension policy for this Channel (D-023).
90    pub option_policy: OptionPolicy,
91}
92
93/// Data-only Channel descriptor used before live binding construction.
94#[derive(Clone, Debug, PartialEq, Eq)]
95pub struct ChannelDescriptor {
96    /// Kind.
97    pub kind: ChannelKind,
98    /// Tool mode.
99    pub tool_mode: ToolExecutionMode,
100    /// Capabilities.
101    pub capabilities: ChannelCapabilities,
102    /// Limits.
103    pub limits: ChannelLimits,
104}
105
106impl ChannelDescriptor {
107    /// Validate capability combinations (startup matrix).
108    pub fn validate(&self) -> Result<(), ChannelCapabilityError> {
109        if self.limits.max_active_transactions == 0
110            || self.limits.max_distinct_sessions == 0
111            || self.limits.max_encoded_exchange_bytes == 0
112        {
113            return Err(ChannelCapabilityError::ZeroLimit);
114        }
115
116        match self.kind {
117            ChannelKind::DirectLlm => {
118                if self.capabilities.session_mode != SessionMode::Stateless {
119                    return Err(ChannelCapabilityError::DirectLlmRequiresStateless);
120                }
121                if self.capabilities.exchange_mode != ExchangeMode::RequestResponse {
122                    return Err(ChannelCapabilityError::DirectLlmRequiresRequestResponse);
123                }
124            }
125            ChannelKind::ExternalAgent => {
126                if self.capabilities.session_mode != SessionMode::External {
127                    return Err(ChannelCapabilityError::ExternalAgentRequiresExternalSession);
128                }
129            }
130        }
131
132        match self.tool_mode {
133            ToolExecutionMode::McpGateway => {
134                if self.capabilities.mcp_configuration == McpConfigurationCapability::None {
135                    return Err(ChannelCapabilityError::McpGatewayRequiresConfiguration);
136                }
137                if self.capabilities.mcp_reachability == McpReachability::None {
138                    return Err(ChannelCapabilityError::McpGatewayRequiresReachability);
139                }
140            }
141            ToolExecutionMode::ModelToolCalls | ToolExecutionMode::None => {
142                if self.capabilities.mcp_configuration != McpConfigurationCapability::None {
143                    return Err(ChannelCapabilityError::NonMcpMustDisableConfiguration);
144                }
145                if self.capabilities.mcp_reachability != McpReachability::None {
146                    return Err(ChannelCapabilityError::NonMcpMustDisableReachability);
147                }
148            }
149        }
150
151        if self
152            .capabilities
153            .continuation_policies
154            .contains(&ContinuationPolicy::InlineToolContinuation)
155            && self.tool_mode != ToolExecutionMode::ModelToolCalls
156        {
157            return Err(ChannelCapabilityError::InlineContinuationRequiresModelTools);
158        }
159
160        if !self.capabilities.supports_distinct_session_concurrency {
161            return Err(ChannelCapabilityError::DistinctSessionConcurrencyRequired);
162        }
163
164        if self.capabilities.input_dialect != self.capabilities.output_dialect {
165            // Allow asymmetric only when both families match for initial product?
166            // Spec: encoder/Connector/Interpreter declarations must match exactly —
167            // for descriptor data we require equal descriptors initially.
168            return Err(ChannelCapabilityError::DialectMismatch);
169        }
170
171        Ok(())
172    }
173}
174
175/// Whether `SendAndRetain` is legal for this Channel.
176pub fn send_and_retain_allowed(caps: &ChannelCapabilities) -> bool {
177    caps.exchange_mode == ExchangeMode::Bidirectional
178}
179
180/// Channel capability validation error.
181#[derive(Clone, Debug, Error, PartialEq, Eq)]
182pub enum ChannelCapabilityError {
183    /// Zero capacity/limit.
184    #[error("channel limits must be non-zero")]
185    ZeroLimit,
186    /// Direct LLM must be Stateless.
187    #[error("DirectLlm requires Stateless session mode")]
188    DirectLlmRequiresStateless,
189    /// Direct LLM initially RequestResponse only.
190    #[error("DirectLlm requires RequestResponse exchange mode")]
191    DirectLlmRequiresRequestResponse,
192    /// External agent must use External session mode.
193    #[error("ExternalAgent requires External session mode")]
194    ExternalAgentRequiresExternalSession,
195    /// MCP gateway needs non-None configuration.
196    #[error("McpGateway requires non-None mcp_configuration")]
197    McpGatewayRequiresConfiguration,
198    /// MCP gateway needs reachability.
199    #[error("McpGateway requires declared mcp_reachability")]
200    McpGatewayRequiresReachability,
201    /// Non-MCP modes cannot declare MCP configuration.
202    #[error("non-MCP tool mode requires mcp_configuration == None")]
203    NonMcpMustDisableConfiguration,
204    /// Non-MCP modes cannot declare MCP reachability.
205    #[error("non-MCP tool mode requires mcp_reachability == None")]
206    NonMcpMustDisableReachability,
207    /// Inline continuation needs model tools.
208    #[error("InlineToolContinuation requires ModelToolCalls")]
209    InlineContinuationRequiresModelTools,
210    /// Production concurrency flag.
211    #[error("supports_distinct_session_concurrency must be true")]
212    DistinctSessionConcurrencyRequired,
213    /// Input/output dialect descriptors must match for the binding.
214    #[error("input and output dialect descriptors must match")]
215    DialectMismatch,
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use crate::dialect::DialectDescriptor;
222
223    fn base_caps(mode: SessionMode, exchange: ExchangeMode) -> ChannelCapabilities {
224        let d = DialectDescriptor::openai_chat_completions("v1");
225        ChannelCapabilities {
226            session_mode: mode,
227            mcp_configuration: McpConfigurationCapability::None,
228            mcp_reachability: McpReachability::None,
229            exchange_mode: exchange,
230            continuation_policies: BTreeSet::from([ContinuationPolicy::CallerControlled]),
231            supports_distinct_session_concurrency: true,
232            input_dialect: d.clone(),
233            output_dialect: d,
234            option_policy: crate::config::OptionPolicy::direct_llm(),
235        }
236    }
237
238    #[test]
239    fn direct_llm_matrix() {
240        let d = ChannelDescriptor {
241            kind: ChannelKind::DirectLlm,
242            tool_mode: ToolExecutionMode::ModelToolCalls,
243            capabilities: base_caps(SessionMode::Stateless, ExchangeMode::RequestResponse),
244            limits: ChannelLimits::default(),
245        };
246        assert!(d.validate().is_ok());
247    }
248
249    #[test]
250    fn mcp_gateway_requires_config() {
251        let mut caps = base_caps(SessionMode::External, ExchangeMode::Bidirectional);
252        let d = ChannelDescriptor {
253            kind: ChannelKind::ExternalAgent,
254            tool_mode: ToolExecutionMode::McpGateway,
255            capabilities: caps.clone(),
256            limits: ChannelLimits::default(),
257        };
258        assert_eq!(
259            d.validate(),
260            Err(ChannelCapabilityError::McpGatewayRequiresConfiguration)
261        );
262        caps.mcp_configuration = McpConfigurationCapability::CreationOnly;
263        caps.mcp_reachability = McpReachability::SameLoopbackNamespace;
264        let d = ChannelDescriptor {
265            kind: ChannelKind::ExternalAgent,
266            tool_mode: ToolExecutionMode::McpGateway,
267            capabilities: caps,
268            limits: ChannelLimits::default(),
269        };
270        assert!(d.validate().is_ok());
271    }
272}