Skip to main content

monoloop_loop/transaction/
host_tools.rs

1//! Immutable host tool registry with linked handlers.
2
3use super::tool_handler::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#[derive(Clone)]
10pub struct RegisteredTool {
11    /// Canonical specification.
12    pub spec: ToolSpec,
13    /// Linked implementation.
14    pub handler: Arc<dyn ToolHandler>,
15}
16
17impl std::fmt::Debug for RegisteredTool {
18    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
19        f.debug_struct("RegisteredTool")
20            .field("spec", &self.spec)
21            .field("handler", &"<dyn ToolHandler>")
22            .finish()
23    }
24}
25
26impl RegisteredTool {
27    /// Construct a registered tool.
28    ///
29    /// Prefer [`Self::try_new`] so cancellation policy is checked against the handler.
30    pub fn new(spec: ToolSpec, handler: Arc<dyn ToolHandler>) -> Self {
31        Self::try_new(spec, handler).expect("handler supports declared ToolCancellationPolicy")
32    }
33
34    /// Construct a registered tool, rejecting unstoppable / mismatched policy (D-024).
35    pub fn try_new(
36        spec: ToolSpec,
37        handler: Arc<dyn ToolHandler>,
38    ) -> Result<Self, super::StartupError> {
39        use monoloop_contracts::ToolCancellationPolicy;
40        match &spec.cancellation {
41            ToolCancellationPolicy::Abortable => {
42                if !handler.supports_abort() {
43                    return Err(super::StartupError::ToolRegistry(
44                        "Abortable tool requires supports_abort handler",
45                    ));
46                }
47            }
48            ToolCancellationPolicy::IsolatedKillable { .. } => {
49                if !handler.supports_isolated_kill() {
50                    return Err(super::StartupError::ToolRegistry(
51                        "IsolatedKillable tool requires supports_isolated_kill handler",
52                    ));
53                }
54            }
55            ToolCancellationPolicy::Cooperative { .. } => {
56                // Cooperative cancel is best-effort. Sync/immediate handlers may
57                // omit supports_abort; cancel is vacuous once completion is already sent.
58            }
59        }
60        Ok(Self { spec, handler })
61    }
62}
63
64/// Immutable host tool definitions available to admission.
65#[derive(Clone, Debug, Default)]
66pub struct HostToolRegistry {
67    by_id: HashMap<ToolId, RegisteredTool>,
68    by_name: HashMap<ToolName, ToolId>,
69}
70
71impl HostToolRegistry {
72    /// Empty tool registry (required empty-tool path remains valid).
73    pub fn empty() -> Self {
74        Self::default()
75    }
76
77    /// Build from registered tools; rejects duplicate ids/names.
78    ///
79    /// Every entry already carries a [`ToolCancellationPolicy`] on its spec
80    /// (validated by [`ToolSpec::try_new`]); unstoppable handlers are rejected
81    /// by not offering that policy.
82    pub fn build(tools: Vec<RegisteredTool>) -> Result<Self, super::StartupError> {
83        let mut by_id = HashMap::with_capacity(tools.len());
84        let mut by_name = HashMap::with_capacity(tools.len());
85        for tool in tools {
86            // Schema root object already enforced by JsonSchema::try_new.
87            let schema_bytes = serde_json::to_vec(tool.spec.input_schema.as_value())
88                .map(|b| b.len())
89                .unwrap_or(0);
90            if schema_bytes > 64 * 1024 {
91                return Err(super::StartupError::ToolRegistry("tool schema too large"));
92            }
93            if by_id.contains_key(&tool.spec.id) {
94                return Err(super::StartupError::ToolRegistry("duplicate ToolId"));
95            }
96            if by_name.contains_key(&tool.spec.name) {
97                return Err(super::StartupError::ToolRegistry("duplicate ToolName"));
98            }
99            by_name.insert(tool.spec.name.clone(), tool.spec.id.clone());
100            by_id.insert(tool.spec.id.clone(), tool);
101        }
102        Ok(Self { by_id, by_name })
103    }
104
105    /// Number of registered tools.
106    pub fn len(&self) -> usize {
107        self.by_id.len()
108    }
109
110    /// Whether empty.
111    pub fn is_empty(&self) -> bool {
112        self.by_id.is_empty()
113    }
114
115    /// Lookup registered tool by id.
116    pub fn get(&self, id: &ToolId) -> Option<&RegisteredTool> {
117        self.by_id.get(id)
118    }
119
120    /// Lookup spec by id.
121    pub fn get_spec(&self, id: &ToolId) -> Option<&ToolSpec> {
122        self.by_id.get(id).map(|t| &t.spec)
123    }
124
125    /// Resolve name to id.
126    pub fn id_for_name(&self, name: &ToolName) -> Option<&ToolId> {
127        self.by_name.get(name)
128    }
129
130    /// Specs sorted by tool id (deterministic projection).
131    pub fn specs_sorted(&self) -> Vec<&ToolSpec> {
132        let mut ids: Vec<_> = self.by_id.keys().collect();
133        ids.sort_by(|a, b| a.as_str().cmp(b.as_str()));
134        ids.into_iter()
135            .filter_map(|id| self.by_id.get(id).map(|t| &t.spec))
136            .collect()
137    }
138}