1use 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#[derive(Clone, Debug)]
23pub struct DispatchRequest {
24 pub exchange_id: ExchangeId,
26 pub tool_action_id: ToolActionId,
28 pub tool_name: ToolName,
30 pub provider_tool_call_id: String,
32 pub request_ordinal: u32,
34 pub arguments_json: String,
36}
37
38#[derive(Clone, Debug)]
40pub enum DispatchOutcome {
41 Canonical {
43 result: CanonicalToolResult,
45 lifecycle: Vec<ToolLifecycleEvent>,
47 },
48 Rejected {
50 tool_action_id: ToolActionId,
52 code: &'static str,
54 message: String,
56 lifecycle: Vec<ToolLifecycleEvent>,
58 },
59 RuntimeFailed {
61 tool_action_id: ToolActionId,
63 tool_id: Option<ToolId>,
65 code: String,
67 lifecycle: Vec<ToolLifecycleEvent>,
69 },
70}
71
72#[derive(Clone, Copy, Debug)]
74pub struct DispatcherLimits {
75 pub max_concurrent_tools: usize,
77 pub max_queued_tools: usize,
79 pub max_tool_payload_bytes: usize,
81 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
96pub struct TransactionToolDispatcher {
98 transaction_id: TransactionId,
99 session_key: SessionKey,
100 tools: ResolvedToolSet,
101 capacity: Arc<TransactionToolCapacity>,
102 max_tool_payload_bytes: usize,
104 max_tool_output_bytes: usize,
106 max_error_message_bytes: usize,
107 max_json_depth: u32,
108}
109
110impl TransactionToolDispatcher {
111 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 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 pub fn tools(&self) -> &ResolvedToolSet {
164 &self.tools
165 }
166
167 pub fn transaction_id(&self) -> TransactionId {
169 self.transaction_id
170 }
171
172 pub fn session_key(&self) -> &SessionKey {
174 &self.session_key
175 }
176
177 pub async fn dispatch(self: &Arc<Self>, request: DispatchRequest) -> DispatchOutcome {
179 self.dispatch_with_cancel(request, None).await
180 }
181
182 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 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 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 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 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 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 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 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
452async 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 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}