Skip to main content

monoloop_loop/transaction/
loop_adapters.rs

1//! ToolRegistry / ToolRuntime adapters that delegate to TransactionToolDispatcher.
2
3use super::dispatcher::{DispatchOutcome, DispatchRequest, TransactionToolDispatcher};
4use super::lifecycle::session_identity::provider_tool_call_id_from_action;
5use super::lifecycle::{TaskClass, TransactionTaskSpawner};
6use crate::registry::{
7    ResolveToolRequest, ToolDescriptorRef, ToolRegistry, ToolRegistryError, ToolResolution,
8};
9use crate::tools::{
10    StartToolExecution, ToolExecutionHandle, ToolRuntime, ToolRuntimeError, ToolRuntimeTerminal,
11};
12use monoloop_contracts::{
13    ExchangeId, OutboundToolOutcome, ToolActionId, ToolName, ToolUnavailableReason, TransactionId,
14};
15use std::future::Future;
16use std::pin::Pin;
17use std::sync::Arc;
18use tokio::sync::oneshot;
19
20type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
21
22/// Registry backed by the transaction-resolved allowlist.
23pub struct ResolvedToolRegistry {
24    tools: super::resolved_tools::ResolvedToolSet,
25}
26
27impl ResolvedToolRegistry {
28    /// Construct from the admitted resolved set.
29    pub fn new(tools: super::resolved_tools::ResolvedToolSet) -> Self {
30        Self { tools }
31    }
32}
33
34impl ToolRegistry for ResolvedToolRegistry {
35    fn resolve<'a>(
36        &'a self,
37        request: ResolveToolRequest,
38    ) -> BoxFuture<'a, Result<ToolResolution, ToolRegistryError>> {
39        Box::pin(async move {
40            let name = match ToolName::try_new(&request.tool_name) {
41                Ok(n) => n,
42                Err(_) => {
43                    return Ok(ToolResolution::Unavailable(ToolUnavailableReason::NotFound));
44                }
45            };
46            if self.tools.contains_name(&name) {
47                Ok(ToolResolution::Available(ToolDescriptorRef {
48                    name: request.tool_name,
49                }))
50            } else if self.tools.is_empty() {
51                Ok(ToolResolution::Unavailable(
52                    ToolUnavailableReason::NoRegisteredTool,
53                ))
54            } else {
55                Ok(ToolResolution::Unavailable(ToolUnavailableReason::NotFound))
56            }
57        })
58    }
59}
60
61/// Runtime that starts linked tools through the shared dispatcher.
62pub struct HostToolRuntime {
63    dispatcher: Arc<TransactionToolDispatcher>,
64    exchange_id: ExchangeId,
65    spawner: TransactionTaskSpawner,
66    transaction_id: TransactionId,
67    /// Absolute transaction Instant — caps each tool's execution budget.
68    transaction_deadline: std::time::Instant,
69}
70
71impl HostToolRuntime {
72    /// Supervisor-owned tool workers (no ambient `tokio::spawn`; Law 23).
73    pub fn with_spawner(
74        dispatcher: Arc<TransactionToolDispatcher>,
75        exchange_id: ExchangeId,
76        transaction_id: TransactionId,
77        spawner: TransactionTaskSpawner,
78        transaction_deadline: std::time::Instant,
79    ) -> Self {
80        Self {
81            dispatcher,
82            exchange_id,
83            spawner,
84            transaction_id,
85            transaction_deadline,
86        }
87    }
88}
89
90impl ToolRuntime for HostToolRuntime {
91    fn start(&self, request: StartToolExecution) -> Result<ToolExecutionHandle, ToolRuntimeError> {
92        let name = ToolName::try_new(&request.tool_name)
93            .map_err(|_| ToolRuntimeError("invalid tool name".into()))?;
94        let action_id = ToolActionId::new(request.tool_action_id.clone());
95        // Prefer exchange-scoped strip; fall back to raw action id (unscoped units).
96        let provider_id = provider_tool_call_id_from_action(self.exchange_id, &action_id)
97            .unwrap_or(action_id.as_str())
98            .to_string();
99        let dispatcher = Arc::clone(&self.dispatcher);
100        let exchange_id = self.exchange_id;
101        let payload = request.request_payload;
102        let ordinal = request.request_generation as u32;
103        let execution_id = request.execution_id.clone();
104        let transaction_deadline = self.transaction_deadline;
105        let (tx, rx) = oneshot::channel();
106        let work = async move {
107            let outcome = dispatcher
108                .dispatch(DispatchRequest {
109                    exchange_id,
110                    tool_action_id: action_id,
111                    tool_name: name,
112                    provider_tool_call_id: provider_id,
113                    request_ordinal: ordinal,
114                    arguments_json: payload,
115                    transaction_deadline,
116                })
117                .await;
118            let _ = tx.send(map_outcome(outcome));
119        };
120
121        let class = TaskClass::ToolWorker(self.transaction_id, execution_id.clone());
122        self.spawner
123            .try_spawn_owned(class, work)
124            .map_err(|_| ToolRuntimeError("tool worker spawn capacity exceeded".into()))?;
125
126        Ok(ToolExecutionHandle {
127            execution_id: request.execution_id,
128            completion: Some(rx),
129        })
130    }
131}
132
133fn map_outcome(outcome: DispatchOutcome) -> ToolRuntimeTerminal {
134    match outcome {
135        DispatchOutcome::Canonical { result, .. } => {
136            // Round-trip the full CanonicalToolResult so lifecycle publish preserves
137            // Succeeded vs DomainFailed (and tool_id / ordinal).
138            let payload =
139                serde_json::to_string(&result).unwrap_or_else(|_| "{\"error\":\"encode\"}".into());
140            let outcome = match &result.outcome {
141                monoloop_contracts::CanonicalToolResultOutcome::Succeeded(_) => {
142                    OutboundToolOutcome::Success
143                }
144                monoloop_contracts::CanonicalToolResultOutcome::DomainFailed(_) => {
145                    OutboundToolOutcome::ExecutionFailed
146                }
147            };
148            ToolRuntimeTerminal { outcome, payload }
149        }
150        DispatchOutcome::Rejected { code, message, .. } => ToolRuntimeTerminal {
151            outcome: OutboundToolOutcome::DispatchRejected,
152            payload: format!("{code}:{message}"),
153        },
154        DispatchOutcome::RuntimeFailed { code, .. } => ToolRuntimeTerminal {
155            outcome: OutboundToolOutcome::ExecutionFailed,
156            payload: code,
157        },
158    }
159}
160
161/// Dispatch one ready tool directly through the transaction dispatcher.
162pub async fn dispatch_ready_tool(
163    dispatcher: &Arc<TransactionToolDispatcher>,
164    exchange_id: ExchangeId,
165    tool_action_id: ToolActionId,
166    tool_name: &str,
167    provider_tool_call_id: &str,
168    request_ordinal: u32,
169    arguments_json: &str,
170) -> DispatchOutcome {
171    dispatch_ready_tool_cancellable(
172        dispatcher,
173        exchange_id,
174        tool_action_id,
175        tool_name,
176        provider_tool_call_id,
177        request_ordinal,
178        arguments_json,
179        None,
180    )
181    .await
182}
183
184/// Dispatch with an actor-owned cancel signal so mid-dispatch cancel joins the worker (D-028).
185#[allow(clippy::too_many_arguments)]
186pub async fn dispatch_ready_tool_cancellable(
187    dispatcher: &Arc<TransactionToolDispatcher>,
188    exchange_id: ExchangeId,
189    tool_action_id: ToolActionId,
190    tool_name: &str,
191    provider_tool_call_id: &str,
192    request_ordinal: u32,
193    arguments_json: &str,
194    cancel: Option<std::sync::Arc<super::sticky_cancel::StickyCancel>>,
195) -> DispatchOutcome {
196    let name = match ToolName::try_new(tool_name) {
197        Ok(n) => n,
198        Err(_) => {
199            return DispatchOutcome::Rejected {
200                tool_action_id,
201                code: "invalid_tool_name",
202                message: "tool name failed identity validation".into(),
203                lifecycle: vec![],
204            };
205        }
206    };
207    dispatcher
208        .dispatch_with_cancel(
209            DispatchRequest {
210                exchange_id,
211                tool_action_id,
212                tool_name: name,
213                provider_tool_call_id: provider_tool_call_id.into(),
214                request_ordinal,
215                arguments_json: arguments_json.into(),
216                // No transaction Instant on this helper — use a far ceiling.
217                transaction_deadline: std::time::Instant::now()
218                    + std::time::Duration::from_secs(365 * 24 * 3600),
219            },
220            cancel,
221        )
222        .await
223}