Skip to main content

monoloop_loop/transaction/
host_tools.rs

1//! Immutable host tool registry with linked handlers.
2
3use super::tool_handler::{AbortableAtYieldHandler, ToolHandler};
4use monoloop_contracts::{ToolId, ToolName, ToolSpec};
5use std::collections::HashMap;
6use std::sync::Arc;
7
8/// Spec + linked handler pair registered at runtime startup.
9///
10/// Fields are private so callers cannot bypass [`Self::try_new`] /
11/// [`Self::try_new_abortable`] / [`Self::try_new_process_isolated`] via struct
12/// literals (V2 §14.2–14.3 / D-043 / D-050).
13#[derive(Clone)]
14pub struct RegisteredTool {
15    spec: ToolSpec,
16    handler: Arc<dyn ToolHandler>,
17}
18
19impl std::fmt::Debug for RegisteredTool {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        f.debug_struct("RegisteredTool")
22            .field("spec", &self.spec)
23            .field("handler", &"<dyn ToolHandler>")
24            .finish()
25    }
26}
27
28impl RegisteredTool {
29    /// Canonical specification.
30    pub fn spec(&self) -> &ToolSpec {
31        &self.spec
32    }
33
34    /// Linked implementation.
35    pub fn handler(&self) -> &Arc<dyn ToolHandler> {
36        &self.handler
37    }
38
39    /// Construct a registered tool.
40    ///
41    /// Prefer [`Self::try_new`] / [`Self::try_new_abortable`] /
42    /// [`Self::try_new_process_isolated`] so cancellation policy is checked.
43    /// Panics if the class/handler pair is invalid.
44    pub fn new(spec: ToolSpec, handler: Arc<dyn ToolHandler>) -> Self {
45        Self::try_new(spec, handler).expect("handler supports declared ToolExecutionClass")
46    }
47
48    /// Construct a registered tool, rejecting unstoppable / mismatched class (D-024).
49    ///
50    /// Abortable and ProcessIsolated tools MUST use their structural factories —
51    /// [`Self::try_new_abortable`] and [`Self::try_new_process_isolated`] — so a
52    /// `dyn ToolHandler` cannot self-assert those classes via capability booleans.
53    pub fn try_new(
54        spec: ToolSpec,
55        handler: Arc<dyn ToolHandler>,
56    ) -> Result<Self, super::StartupError> {
57        use monoloop_contracts::ToolExecutionClass;
58        match &spec.execution_class {
59            ToolExecutionClass::AbortableAtYield { .. } => {
60                // Close the boolean-only gate: dyn handlers cannot self-assert
61                // AbortableAtYield. Use try_new_abortable.
62                return Err(super::StartupError::ToolRegistry(
63                    "AbortableAtYield requires try_new_abortable(AbortableAtYieldHandler)",
64                ));
65            }
66            ToolExecutionClass::ProcessIsolated { .. } => {
67                // Close the boolean-only gate: dyn handlers cannot self-assert
68                // ProcessIsolated. Use try_new_process_isolated.
69                return Err(super::StartupError::ToolRegistry(
70                    "ProcessIsolated requires try_new_process_isolated(ProcessIsolatedToolHandler)",
71                ));
72            }
73            ToolExecutionClass::CooperativeInProcess { .. } => {
74                // Cooperative cancel is best-effort. Sync/immediate handlers may
75                // omit supports_abort; cancel is vacuous once completion is already sent.
76            }
77        }
78        Ok(Self { spec, handler })
79    }
80
81    /// Structural AbortableAtYield registration (V2 §14.2 / D-050).
82    ///
83    /// Only a sealed [`AbortableAtYieldHandler`] (crate `AsyncToolHandler` /
84    /// `IsolatedKillableToolHandler`) may satisfy this class — capability
85    /// booleans on `dyn ToolHandler` are rejected.
86    pub fn try_new_abortable<H>(spec: ToolSpec, handler: H) -> Result<Self, super::StartupError>
87    where
88        H: AbortableAtYieldHandler + 'static,
89    {
90        use monoloop_contracts::ToolExecutionClass;
91        match &spec.execution_class {
92            ToolExecutionClass::AbortableAtYield { .. } => {}
93            _ => {
94                return Err(super::StartupError::ToolRegistry(
95                    "try_new_abortable requires ToolExecutionClass::AbortableAtYield",
96                ));
97            }
98        }
99        if !handler.runtime_owns_abortable_drive() || !handler.supports_abort() {
100            return Err(super::StartupError::ToolRegistry(
101                "AbortableAtYieldHandler must expose runtime_owns_abortable_drive + supports_abort",
102            ));
103        }
104        Ok(Self {
105            spec,
106            handler: Arc::new(handler),
107        })
108    }
109
110    /// Structural ProcessIsolated registration (V2 §14.3 / D-043).
111    ///
112    /// Only a concrete [`super::process_tool::ProcessIsolatedToolHandler`] may
113    /// satisfy this class — capability booleans on `dyn ToolHandler` are rejected.
114    pub fn try_new_process_isolated(
115        spec: ToolSpec,
116        handler: super::process_tool::ProcessIsolatedToolHandler,
117    ) -> Result<Self, super::StartupError> {
118        use monoloop_contracts::ToolExecutionClass;
119        match &spec.execution_class {
120            ToolExecutionClass::ProcessIsolated { .. } => {}
121            _ => {
122                return Err(super::StartupError::ToolRegistry(
123                    "try_new_process_isolated requires ToolExecutionClass::ProcessIsolated",
124                ));
125            }
126        }
127        if !handler.os_process_isolated() || !handler.supports_isolated_kill() {
128            return Err(super::StartupError::ToolRegistry(
129                "ProcessIsolatedToolHandler must expose os_process_isolated + supports_isolated_kill",
130            ));
131        }
132        Ok(Self {
133            spec,
134            handler: Arc::new(handler),
135        })
136    }
137}
138
139/// Immutable host tool definitions available to admission.
140#[derive(Clone, Debug, Default)]
141pub struct HostToolRegistry {
142    by_id: HashMap<ToolId, RegisteredTool>,
143    by_name: HashMap<ToolName, ToolId>,
144}
145
146impl HostToolRegistry {
147    /// Empty tool registry (required empty-tool path remains valid).
148    pub fn empty() -> Self {
149        Self::default()
150    }
151
152    /// Build from registered tools; rejects duplicate ids/names.
153    ///
154    /// Re-validates ProcessIsolated and AbortableAtYield entries so a forged
155    /// `RegisteredTool` cannot enter the registry without a structural handler
156    /// (D-043 / D-050).
157    pub fn build(tools: Vec<RegisteredTool>) -> Result<Self, super::StartupError> {
158        use monoloop_contracts::ToolExecutionClass;
159        let mut by_id = HashMap::with_capacity(tools.len());
160        let mut by_name = HashMap::with_capacity(tools.len());
161        for tool in tools {
162            match &tool.spec.execution_class {
163                ToolExecutionClass::ProcessIsolated { .. }
164                    if !tool.handler.os_process_isolated() =>
165                {
166                    return Err(super::StartupError::ToolRegistry(
167                        "ProcessIsolated entry lacks os_process_isolated handler",
168                    ));
169                }
170                ToolExecutionClass::AbortableAtYield { .. }
171                    if !tool.handler.runtime_owns_abortable_drive() =>
172                {
173                    return Err(super::StartupError::ToolRegistry(
174                        "AbortableAtYield entry lacks runtime_owns_abortable_drive handler",
175                    ));
176                }
177                _ => {}
178            }
179            // Schema root object already enforced by JsonSchema::try_new.
180            // Byte ceiling uses TransactionLimits default here; StartedRuntime
181            // re-checks against the runtime's max_tool_schema_bytes (§23 / D-056).
182            let max_schema = monoloop_contracts::TransactionLimits::default().max_tool_schema_bytes;
183            let schema_bytes = serde_json::to_vec(tool.spec.input_schema.as_value())
184                .map(|b| b.len())
185                .unwrap_or(0);
186            if schema_bytes > max_schema {
187                return Err(super::StartupError::ToolRegistry("tool schema too large"));
188            }
189            if by_id.contains_key(&tool.spec.id) {
190                return Err(super::StartupError::ToolRegistry("duplicate ToolId"));
191            }
192            if by_name.contains_key(&tool.spec.name) {
193                return Err(super::StartupError::ToolRegistry("duplicate ToolName"));
194            }
195            by_name.insert(tool.spec.name.clone(), tool.spec.id.clone());
196            by_id.insert(tool.spec.id.clone(), tool);
197        }
198        Ok(Self { by_id, by_name })
199    }
200
201    /// Number of registered tools.
202    pub fn len(&self) -> usize {
203        self.by_id.len()
204    }
205
206    /// Whether empty.
207    pub fn is_empty(&self) -> bool {
208        self.by_id.is_empty()
209    }
210
211    /// Lookup registered tool by id.
212    pub fn get(&self, id: &ToolId) -> Option<&RegisteredTool> {
213        self.by_id.get(id)
214    }
215
216    /// Lookup spec by id.
217    pub fn get_spec(&self, id: &ToolId) -> Option<&ToolSpec> {
218        self.by_id.get(id).map(|t| &t.spec)
219    }
220
221    /// Resolve name to id.
222    pub fn id_for_name(&self, name: &ToolName) -> Option<&ToolId> {
223        self.by_name.get(name)
224    }
225
226    /// Specs sorted by tool id (deterministic projection).
227    pub fn specs_sorted(&self) -> Vec<&ToolSpec> {
228        let mut ids: Vec<_> = self.by_id.keys().collect();
229        ids.sort_by(|a, b| a.as_str().cmp(b.as_str()));
230        ids.into_iter()
231            .filter_map(|id| self.by_id.get(id).map(|t| &t.spec))
232            .collect()
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use crate::transaction::tool_handler::{AsyncToolHandler, ImmediateToolHandler};
240    use monoloop_contracts::{
241        CanonicalToolOutput, JsonSchema, ToolCompletion, ToolExecutionClass, ToolId, ToolLimits,
242        ToolName, ToolOutputContract, ToolSuccessContract,
243    };
244    use std::time::Duration;
245
246    fn abortable_spec() -> ToolSpec {
247        let schema = JsonSchema::try_new(serde_json::json!({"type": "object"})).unwrap();
248        ToolSpec::try_new(
249            ToolId::try_new("a").unwrap(),
250            ToolName::try_new("a").unwrap(),
251            "abortable",
252            schema.clone(),
253            ToolOutputContract {
254                success: ToolSuccessContract::json(schema),
255                error_data_schema: None,
256            },
257            ToolLimits::default(),
258            ToolExecutionClass::AbortableAtYield {
259                grace: Duration::from_secs(1),
260            },
261        )
262        .unwrap()
263    }
264
265    #[test]
266    fn abortable_rejects_dyn_handler_path() {
267        let forged = Arc::new(ImmediateToolHandler::new(|_c, _x| {
268            Ok(ToolCompletion::Succeeded(CanonicalToolOutput::Json(
269                serde_json::json!({}),
270            )))
271        })) as Arc<dyn ToolHandler>;
272        // Even a handler that lied about supports_abort cannot use try_new.
273        let err = RegisteredTool::try_new(abortable_spec(), forged).unwrap_err();
274        let msg = format!("{err}");
275        assert!(
276            msg.contains("try_new_abortable") || msg.contains("AbortableAtYield"),
277            "got {msg}"
278        );
279    }
280
281    #[test]
282    fn abortable_rejects_boolean_self_assert() {
283        struct Liar;
284        impl ToolHandler for Liar {
285            fn start(
286                &self,
287                _call: monoloop_contracts::ToolCall,
288                _ctx: monoloop_contracts::ToolCallContext,
289            ) -> Result<crate::LinkedToolExecutionHandle, monoloop_contracts::ToolStartError>
290            {
291                Err(monoloop_contracts::ToolStartError::Rejected("liar"))
292            }
293            fn supports_abort(&self) -> bool {
294                true
295            }
296        }
297        let err = RegisteredTool::try_new(abortable_spec(), Arc::new(Liar)).unwrap_err();
298        let msg = format!("{err}");
299        assert!(
300            msg.contains("try_new_abortable"),
301            "boolean self-assert must not register: {msg}"
302        );
303    }
304
305    #[test]
306    fn abortable_accepts_structural_handler() {
307        RegisteredTool::try_new_abortable(
308            abortable_spec(),
309            AsyncToolHandler::new(|_c, _x, _ctl| {
310                Box::pin(async {
311                    ToolCompletion::Succeeded(CanonicalToolOutput::Json(serde_json::json!({})))
312                })
313            }),
314        )
315        .expect("structural AbortableAtYield ok");
316    }
317
318    #[test]
319    fn abortable_typed_api_rejects_wrong_class() {
320        let schema = JsonSchema::try_new(serde_json::json!({"type": "object"})).unwrap();
321        let cooperative = ToolSpec::try_new(
322            ToolId::try_new("c").unwrap(),
323            ToolName::try_new("c").unwrap(),
324            "coop",
325            schema.clone(),
326            ToolOutputContract {
327                success: ToolSuccessContract::json(schema),
328                error_data_schema: None,
329            },
330            ToolLimits::default(),
331            ToolExecutionClass::CooperativeInProcess {
332                grace: Duration::from_secs(1),
333            },
334        )
335        .unwrap();
336        let err = RegisteredTool::try_new_abortable(
337            cooperative,
338            AsyncToolHandler::new(|_c, _x, _ctl| {
339                Box::pin(async {
340                    ToolCompletion::Succeeded(CanonicalToolOutput::Json(serde_json::json!({})))
341                })
342            }),
343        )
344        .unwrap_err();
345        assert!(format!("{err}").contains("AbortableAtYield"));
346    }
347}