Skip to main content

monoloop_loop/transaction/
dispatcher.rs

1//! Single validated execution path for linked tools (MCP and model share this).
2
3use super::resolved_tools::ResolvedToolSet;
4use super::tool_capacity::{SharedToolCapacity, TransactionToolCapacity};
5use super::tool_handler::{ToolExecutionControl, ToolKillHandle};
6use super::validation::{
7    validate_tool_completion, validate_tool_input, InputValidationFailure, DEFAULT_MAX_JSON_DEPTH,
8};
9use monoloop_contracts::{
10    CanonicalToolError, CanonicalToolOutput, CanonicalToolResult, CanonicalToolResultOutcome,
11    ExchangeId, SessionKey, ToolActionId, ToolCall, ToolCallContext, ToolCancellationPolicy,
12    ToolCompletion, ToolId, ToolLifecycleEvent, ToolName, ToolRuntimeError, ToolStartError,
13    TransactionId,
14};
15use std::future::Future;
16use std::panic::{catch_unwind, AssertUnwindSafe};
17use std::pin::Pin;
18use std::sync::Arc;
19use std::time::{Duration, Instant};
20
21/// Request to dispatch one complete tool call through the linked handler path.
22#[derive(Clone, Debug)]
23pub struct DispatchRequest {
24    /// Exchange owning this call (model path); MCP may synthesize one.
25    pub exchange_id: ExchangeId,
26    /// Internal action id.
27    pub tool_action_id: ToolActionId,
28    /// Public tool name as requested.
29    pub tool_name: ToolName,
30    /// Provider correlation id preserved exactly.
31    pub provider_tool_call_id: String,
32    /// Model-declared order.
33    pub request_ordinal: u32,
34    /// Complete JSON argument payload.
35    pub arguments_json: String,
36}
37
38/// Outcome of a dispatch attempt.
39#[derive(Clone, Debug)]
40pub enum DispatchOutcome {
41    /// Canonical success or declared domain failure (continuation product).
42    Canonical {
43        /// Validated result.
44        result: CanonicalToolResult,
45        /// Lifecycle events produced (Started + Completed).
46        lifecycle: Vec<ToolLifecycleEvent>,
47    },
48    /// Invalid/disallowed arguments — rejected tool result, not transaction failure.
49    Rejected {
50        /// Action id.
51        tool_action_id: ToolActionId,
52        /// Safe reason code.
53        code: &'static str,
54        /// Safe message.
55        message: String,
56        /// Lifecycle (Started may be omitted when never accepted).
57        lifecycle: Vec<ToolLifecycleEvent>,
58    },
59    /// Handler/runtime failure — selects `ToolExchangeFailed` when policy requires.
60    RuntimeFailed {
61        /// Action id.
62        tool_action_id: ToolActionId,
63        /// Tool id when known.
64        tool_id: Option<ToolId>,
65        /// Safe failure code.
66        code: String,
67        /// Lifecycle events (may include Started + RuntimeFailed).
68        lifecycle: Vec<ToolLifecycleEvent>,
69    },
70}
71
72/// Capacity and payload bounds for one transaction dispatcher (D-015).
73#[derive(Clone, Copy, Debug)]
74pub struct DispatcherLimits {
75    /// Max concurrent tool executions for this transaction.
76    pub max_concurrent_tools: usize,
77    /// Max queued tool starts for this transaction.
78    pub max_queued_tools: usize,
79    /// Transaction-wide payload cap; applied as min with per-tool limit.
80    pub max_tool_payload_bytes: usize,
81    /// Transaction-wide output cap; applied as min with per-tool limit.
82    pub max_tool_output_bytes: usize,
83}
84
85impl Default for DispatcherLimits {
86    fn default() -> Self {
87        Self {
88            max_concurrent_tools: 16,
89            max_queued_tools: 64,
90            max_tool_payload_bytes: usize::MAX,
91            max_tool_output_bytes: usize::MAX,
92        }
93    }
94}
95
96/// Transaction-owned dispatcher: allowlist, validation, capacity, handler, output check.
97pub struct TransactionToolDispatcher {
98    transaction_id: TransactionId,
99    session_key: SessionKey,
100    tools: ResolvedToolSet,
101    capacity: Arc<TransactionToolCapacity>,
102    /// Transaction-wide payload cap (D-015); applied as min with per-tool limit.
103    max_tool_payload_bytes: usize,
104    /// Transaction-wide output cap (D-015); applied as min with per-tool limit.
105    max_tool_output_bytes: usize,
106    max_error_message_bytes: usize,
107    max_json_depth: u32,
108}
109
110impl TransactionToolDispatcher {
111    /// Build a dispatcher for one admitted transaction.
112    pub fn new(
113        transaction_id: TransactionId,
114        session_key: SessionKey,
115        tools: ResolvedToolSet,
116        shared_capacity: Arc<SharedToolCapacity>,
117        max_concurrent_tools: usize,
118        max_queued_tools: usize,
119    ) -> Arc<Self> {
120        Self::with_limits(
121            transaction_id,
122            session_key,
123            tools,
124            shared_capacity,
125            DispatcherLimits {
126                max_concurrent_tools,
127                max_queued_tools,
128                max_tool_payload_bytes: usize::MAX,
129                max_tool_output_bytes: usize::MAX,
130            },
131        )
132    }
133
134    /// Build with explicit concurrency and payload/output caps (D-015).
135    pub fn with_limits(
136        transaction_id: TransactionId,
137        session_key: SessionKey,
138        tools: ResolvedToolSet,
139        shared_capacity: Arc<SharedToolCapacity>,
140        limits: DispatcherLimits,
141    ) -> Arc<Self> {
142        let capacity = TransactionToolCapacity::new(
143            shared_capacity,
144            limits.max_concurrent_tools,
145            limits.max_queued_tools,
146        );
147        for spec in tools.specs() {
148            capacity.configure_tool(spec.id.clone(), spec.limits.max_concurrent);
149        }
150        Arc::new(Self {
151            transaction_id,
152            session_key,
153            tools,
154            capacity,
155            max_tool_payload_bytes: limits.max_tool_payload_bytes.max(1),
156            max_tool_output_bytes: limits.max_tool_output_bytes.max(1),
157            max_error_message_bytes: 1024,
158            max_json_depth: DEFAULT_MAX_JSON_DEPTH,
159        })
160    }
161
162    /// Resolved tool set (encoder / MCP projection).
163    pub fn tools(&self) -> &ResolvedToolSet {
164        &self.tools
165    }
166
167    /// Transaction identity.
168    pub fn transaction_id(&self) -> TransactionId {
169        self.transaction_id
170    }
171
172    /// Session key.
173    pub fn session_key(&self) -> &SessionKey {
174        &self.session_key
175    }
176
177    /// Dispatch one call end-to-end.
178    pub async fn dispatch(self: &Arc<Self>, request: DispatchRequest) -> DispatchOutcome {
179        self.dispatch_with_cancel(request, None).await
180    }
181
182    /// Dispatch with an optional external cancel signal (D-028).
183    ///
184    /// When `cancel` is notified, the dispatcher runs the same termination path as
185    /// an execution deadline so the worker is cancel/kill/joined instead of detached.
186    pub async fn dispatch_with_cancel(
187        self: &Arc<Self>,
188        request: DispatchRequest,
189        cancel: Option<Arc<tokio::sync::Notify>>,
190    ) -> DispatchOutcome {
191        let action = request.tool_action_id.clone();
192
193        // Allowlist by public name.
194        let Some(resolved) = self.tools.get_by_name(&request.tool_name) else {
195            return DispatchOutcome::Rejected {
196                tool_action_id: action,
197                code: "tool_not_allowed",
198                message: "tool not in resolved set".into(),
199                lifecycle: vec![],
200            };
201        };
202        let tool_id = resolved.spec.id.clone();
203        let spec = resolved.spec.clone();
204        let handler = Arc::clone(&resolved.handler);
205
206        if !self.capacity.try_enqueue() {
207            return DispatchOutcome::Rejected {
208                tool_action_id: action,
209                code: "tool_queue_full",
210                message: "per-transaction tool queue full".into(),
211                lifecycle: vec![],
212            };
213        }
214
215        // Input validation before capacity acquire for execution.
216        // Effective payload limit is min(per-tool, transaction-wide) (D-015).
217        let max_payload = spec.limits.max_input_bytes.min(self.max_tool_payload_bytes);
218        let arguments = match validate_tool_input(
219            &request.arguments_json,
220            &spec.input_schema,
221            max_payload,
222            self.max_json_depth,
223        ) {
224            Ok(v) => v,
225            Err(f) => {
226                self.capacity.dequeue();
227                return reject_input(action, f);
228            }
229        };
230
231        // Wait briefly for concurrency (bounded spin/yield); fail closed if not acquired.
232        let permit = {
233            let mut acquired = None;
234            let deadline = Instant::now() + Duration::from_millis(50);
235            while Instant::now() < deadline {
236                if let Some(p) = self.capacity.try_acquire(&tool_id) {
237                    acquired = Some(p);
238                    break;
239                }
240                tokio::task::yield_now().await;
241            }
242            match acquired {
243                Some(p) => p,
244                None => {
245                    // try_acquire dequeues only on success; still queued.
246                    self.capacity.dequeue();
247                    return DispatchOutcome::Rejected {
248                        tool_action_id: action,
249                        code: "tool_capacity_exceeded",
250                        message: "tool concurrency capacity exceeded".into(),
251                        lifecycle: vec![],
252                    };
253                }
254            }
255        };
256
257        let mut lifecycle = vec![ToolLifecycleEvent::Started {
258            tool_action_id: action.clone(),
259            tool_id: tool_id.clone(),
260            tool_name: request.tool_name.clone(),
261            provider_tool_call_id: request.provider_tool_call_id.clone(),
262            request_ordinal: request.request_ordinal,
263        }];
264
265        let call = ToolCall {
266            tool_name: request.tool_name.clone(),
267            tool_id: tool_id.clone(),
268            provider_tool_call_id: request.provider_tool_call_id.clone(),
269            arguments,
270            request_ordinal: request.request_ordinal,
271        };
272        let context = ToolCallContext {
273            transaction_id: self.transaction_id,
274            session_key: self.session_key.clone(),
275            exchange_id: Some(request.exchange_id),
276            tool_action_id: action.clone(),
277            tool_id: tool_id.clone(),
278            deadline: Instant::now() + spec.limits.execution_deadline,
279        };
280
281        let start_result = catch_unwind(AssertUnwindSafe(|| handler.start(call, context)));
282        let handle = match start_result {
283            Ok(Ok(h)) => h,
284            Ok(Err(ToolStartError::CapacityExceeded)) => {
285                drop(permit);
286                return DispatchOutcome::Rejected {
287                    tool_action_id: action,
288                    code: "tool_capacity_exceeded",
289                    message: "handler capacity exceeded".into(),
290                    lifecycle,
291                };
292            }
293            Ok(Err(ToolStartError::Rejected(reason))) => {
294                drop(permit);
295                lifecycle.push(ToolLifecycleEvent::RuntimeFailed {
296                    tool_action_id: action.clone(),
297                    tool_id: tool_id.clone(),
298                    code: "tool_start_rejected".into(),
299                });
300                return DispatchOutcome::RuntimeFailed {
301                    tool_action_id: action,
302                    tool_id: Some(tool_id),
303                    code: format!("start_rejected:{reason}"),
304                    lifecycle,
305                };
306            }
307            Err(_) => {
308                drop(permit);
309                lifecycle.push(ToolLifecycleEvent::RuntimeFailed {
310                    tool_action_id: action.clone(),
311                    tool_id: tool_id.clone(),
312                    code: "tool_panicked".into(),
313                });
314                return DispatchOutcome::RuntimeFailed {
315                    tool_action_id: action,
316                    tool_id: Some(tool_id),
317                    code: "panicked".into(),
318                    lifecycle,
319                };
320            }
321        };
322
323        // Bounded execution: deadline / external cancel → grace → kill → join (D-024 / D-028).
324        let deadline = spec.limits.execution_deadline;
325        let policy = spec.cancellation.clone();
326        let control = handle.control.clone();
327        let kill = handle.kill.clone();
328        // Structural: declared Abortable/IsolatedKillable must carry a kill handle (D-028).
329        let kill_ok = match &policy {
330            ToolCancellationPolicy::Abortable | ToolCancellationPolicy::IsolatedKillable { .. } => {
331                kill.is_some()
332            }
333            ToolCancellationPolicy::Cooperative { .. } => true,
334        };
335        if !kill_ok {
336            drop(permit);
337            lifecycle.push(ToolLifecycleEvent::RuntimeFailed {
338                tool_action_id: action.clone(),
339                tool_id: tool_id.clone(),
340                code: "missing_kill_handle".into(),
341            });
342            return DispatchOutcome::RuntimeFailed {
343                tool_action_id: action,
344                tool_id: Some(tool_id),
345                code: "missing_kill_handle".into(),
346                lifecycle,
347            };
348        }
349        let wait = handle.completion.wait();
350        tokio::pin!(wait);
351        let cancel_fut = async {
352            if let Some(n) = cancel.as_ref() {
353                n.notified().await;
354            } else {
355                std::future::pending::<()>().await;
356            }
357        };
358        let completion = tokio::select! {
359            biased;
360            c = &mut wait => c,
361            _ = cancel_fut => {
362                await_tool_termination(&mut wait, &control, kill.as_ref(), &policy).await
363            }
364            _ = tokio::time::sleep(deadline) => {
365                await_tool_termination(&mut wait, &control, kill.as_ref(), &policy).await
366            }
367        };
368        // Capacity released only after worker joined / terminal selected.
369        drop(permit);
370
371        let max_output = spec.limits.max_output_bytes.min(self.max_tool_output_bytes);
372        let validated = match validate_tool_completion(
373            completion,
374            &spec.output_contract,
375            max_output,
376            self.max_error_message_bytes,
377            self.max_json_depth,
378        ) {
379            Ok(c) => c,
380            Err(_) => {
381                lifecycle.push(ToolLifecycleEvent::RuntimeFailed {
382                    tool_action_id: action.clone(),
383                    tool_id: tool_id.clone(),
384                    code: "output_contract_violated".into(),
385                });
386                return DispatchOutcome::RuntimeFailed {
387                    tool_action_id: action,
388                    tool_id: Some(tool_id),
389                    code: "output_contract_violated".into(),
390                    lifecycle,
391                };
392            }
393        };
394
395        match validated {
396            ToolCompletion::Succeeded(output) => {
397                let result = CanonicalToolResult {
398                    transaction_id: self.transaction_id,
399                    session_key: self.session_key.clone(),
400                    exchange_id: request.exchange_id,
401                    tool_action_id: action.clone(),
402                    tool_id: tool_id.clone(),
403                    provider_tool_call_id: request.provider_tool_call_id,
404                    request_ordinal: request.request_ordinal,
405                    outcome: CanonicalToolResultOutcome::Succeeded(output),
406                };
407                lifecycle.push(ToolLifecycleEvent::Completed {
408                    result: result.clone(),
409                });
410                DispatchOutcome::Canonical { result, lifecycle }
411            }
412            ToolCompletion::DomainFailed(err) => {
413                let result = CanonicalToolResult {
414                    transaction_id: self.transaction_id,
415                    session_key: self.session_key.clone(),
416                    exchange_id: request.exchange_id,
417                    tool_action_id: action.clone(),
418                    tool_id: tool_id.clone(),
419                    provider_tool_call_id: request.provider_tool_call_id,
420                    request_ordinal: request.request_ordinal,
421                    outcome: CanonicalToolResultOutcome::DomainFailed(err),
422                };
423                lifecycle.push(ToolLifecycleEvent::Completed {
424                    result: result.clone(),
425                });
426                DispatchOutcome::Canonical { result, lifecycle }
427            }
428            ToolCompletion::RuntimeFailed(e) => {
429                let code = match e {
430                    ToolRuntimeError::Panicked => "panicked",
431                    ToolRuntimeError::CompletionLost => "completion_lost",
432                    ToolRuntimeError::OutputContractViolated => "output_contract_violated",
433                    ToolRuntimeError::TerminationFailed => "termination_failed",
434                    ToolRuntimeError::DeadlineExceeded => "deadline_exceeded",
435                };
436                lifecycle.push(ToolLifecycleEvent::RuntimeFailed {
437                    tool_action_id: action.clone(),
438                    tool_id: tool_id.clone(),
439                    code: code.into(),
440                });
441                DispatchOutcome::RuntimeFailed {
442                    tool_action_id: action,
443                    tool_id: Some(tool_id),
444                    code: code.into(),
445                    lifecycle,
446                }
447            }
448        }
449    }
450}
451
452/// After execution deadline: cooperative cancel, optional grace, then kill+join (D-024).
453async fn await_tool_termination(
454    wait: &mut Pin<&mut impl Future<Output = ToolCompletion>>,
455    control: &ToolExecutionControl,
456    kill: Option<&ToolKillHandle>,
457    policy: &ToolCancellationPolicy,
458) -> ToolCompletion {
459    control.cancel();
460    let join_grace = Duration::from_millis(200);
461    match policy {
462        ToolCancellationPolicy::Abortable => {
463            if let Some(k) = kill {
464                k.kill();
465            }
466            match tokio::time::timeout(join_grace, wait).await {
467                Ok(c) => c,
468                Err(_) => ToolCompletion::RuntimeFailed(ToolRuntimeError::DeadlineExceeded),
469            }
470        }
471        ToolCancellationPolicy::Cooperative { grace } => {
472            match tokio::time::timeout(*grace, &mut *wait).await {
473                Ok(c) => c,
474                Err(_) => ToolCompletion::RuntimeFailed(ToolRuntimeError::DeadlineExceeded),
475            }
476        }
477        ToolCancellationPolicy::IsolatedKillable { grace } => {
478            match tokio::time::timeout(*grace, &mut *wait).await {
479                Ok(c) => c,
480                Err(_) => {
481                    if let Some(k) = kill {
482                        k.kill();
483                    }
484                    match tokio::time::timeout(join_grace, wait).await {
485                        Ok(c) => c,
486                        Err(_) => {
487                            ToolCompletion::RuntimeFailed(ToolRuntimeError::TerminationFailed)
488                        }
489                    }
490                }
491            }
492        }
493    }
494}
495
496fn reject_input(action: ToolActionId, f: InputValidationFailure) -> DispatchOutcome {
497    let (code, message) = match f {
498        InputValidationFailure::OversizedInput => ("oversized_input", "tool input exceeds limit"),
499        InputValidationFailure::InvalidJson => {
500            ("invalid_json", "tool arguments are not valid JSON")
501        }
502        InputValidationFailure::DepthExceeded => ("json_depth_exceeded", "tool arguments too deep"),
503        InputValidationFailure::SchemaInvalid => {
504            ("schema_invalid", "tool arguments fail input schema")
505        }
506    };
507    // Rejected path still yields a domain-style rejection result shape for tests.
508    let _ = CanonicalToolError::try_new(code, message, None, 256);
509    let _ = CanonicalToolOutput::Text(String::new());
510    DispatchOutcome::Rejected {
511        tool_action_id: action,
512        code,
513        message: message.into(),
514        lifecycle: vec![],
515    }
516}