monoloop_loop/transaction/
host_tools.rs1use super::tool_handler::{AbortableAtYieldHandler, ToolHandler};
4use monoloop_contracts::{ToolId, ToolName, ToolSpec};
5use std::collections::HashMap;
6use std::sync::Arc;
7
8#[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 pub fn spec(&self) -> &ToolSpec {
31 &self.spec
32 }
33
34 pub fn handler(&self) -> &Arc<dyn ToolHandler> {
36 &self.handler
37 }
38
39 pub fn new(spec: ToolSpec, handler: Arc<dyn ToolHandler>) -> Self {
45 Self::try_new(spec, handler).expect("handler supports declared ToolExecutionClass")
46 }
47
48 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 return Err(super::StartupError::ToolRegistry(
63 "AbortableAtYield requires try_new_abortable(AbortableAtYieldHandler)",
64 ));
65 }
66 ToolExecutionClass::ProcessIsolated { .. } => {
67 return Err(super::StartupError::ToolRegistry(
70 "ProcessIsolated requires try_new_process_isolated(ProcessIsolatedToolHandler)",
71 ));
72 }
73 ToolExecutionClass::CooperativeInProcess { .. } => {
74 }
77 }
78 Ok(Self { spec, handler })
79 }
80
81 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 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#[derive(Clone, Debug, Default)]
141pub struct HostToolRegistry {
142 by_id: HashMap<ToolId, RegisteredTool>,
143 by_name: HashMap<ToolName, ToolId>,
144}
145
146impl HostToolRegistry {
147 pub fn empty() -> Self {
149 Self::default()
150 }
151
152 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 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 pub fn len(&self) -> usize {
203 self.by_id.len()
204 }
205
206 pub fn is_empty(&self) -> bool {
208 self.by_id.is_empty()
209 }
210
211 pub fn get(&self, id: &ToolId) -> Option<&RegisteredTool> {
213 self.by_id.get(id)
214 }
215
216 pub fn get_spec(&self, id: &ToolId) -> Option<&ToolSpec> {
218 self.by_id.get(id).map(|t| &t.spec)
219 }
220
221 pub fn id_for_name(&self, name: &ToolName) -> Option<&ToolId> {
223 self.by_name.get(name)
224 }
225
226 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 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}