use super::tool_handler::{AbortableAtYieldHandler, ToolHandler};
use monoloop_contracts::{ToolId, ToolName, ToolSpec};
use std::collections::HashMap;
use std::sync::Arc;
#[derive(Clone)]
pub struct RegisteredTool {
spec: ToolSpec,
handler: Arc<dyn ToolHandler>,
}
impl std::fmt::Debug for RegisteredTool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RegisteredTool")
.field("spec", &self.spec)
.field("handler", &"<dyn ToolHandler>")
.finish()
}
}
impl RegisteredTool {
pub fn spec(&self) -> &ToolSpec {
&self.spec
}
pub fn handler(&self) -> &Arc<dyn ToolHandler> {
&self.handler
}
pub fn new(spec: ToolSpec, handler: Arc<dyn ToolHandler>) -> Self {
Self::try_new(spec, handler).expect("handler supports declared ToolExecutionClass")
}
pub fn try_new(
spec: ToolSpec,
handler: Arc<dyn ToolHandler>,
) -> Result<Self, super::StartupError> {
use monoloop_contracts::ToolExecutionClass;
match &spec.execution_class {
ToolExecutionClass::AbortableAtYield { .. } => {
return Err(super::StartupError::ToolRegistry(
"AbortableAtYield requires try_new_abortable(AbortableAtYieldHandler)",
));
}
ToolExecutionClass::ProcessIsolated { .. } => {
return Err(super::StartupError::ToolRegistry(
"ProcessIsolated requires try_new_process_isolated(ProcessIsolatedToolHandler)",
));
}
ToolExecutionClass::CooperativeInProcess { .. } => {
}
}
Ok(Self { spec, handler })
}
pub fn try_new_abortable<H>(spec: ToolSpec, handler: H) -> Result<Self, super::StartupError>
where
H: AbortableAtYieldHandler + 'static,
{
use monoloop_contracts::ToolExecutionClass;
match &spec.execution_class {
ToolExecutionClass::AbortableAtYield { .. } => {}
_ => {
return Err(super::StartupError::ToolRegistry(
"try_new_abortable requires ToolExecutionClass::AbortableAtYield",
));
}
}
if !handler.runtime_owns_abortable_drive() || !handler.supports_abort() {
return Err(super::StartupError::ToolRegistry(
"AbortableAtYieldHandler must expose runtime_owns_abortable_drive + supports_abort",
));
}
Ok(Self {
spec,
handler: Arc::new(handler),
})
}
pub fn try_new_process_isolated(
spec: ToolSpec,
handler: super::process_tool::ProcessIsolatedToolHandler,
) -> Result<Self, super::StartupError> {
use monoloop_contracts::ToolExecutionClass;
match &spec.execution_class {
ToolExecutionClass::ProcessIsolated { .. } => {}
_ => {
return Err(super::StartupError::ToolRegistry(
"try_new_process_isolated requires ToolExecutionClass::ProcessIsolated",
));
}
}
if !handler.os_process_isolated() || !handler.supports_isolated_kill() {
return Err(super::StartupError::ToolRegistry(
"ProcessIsolatedToolHandler must expose os_process_isolated + supports_isolated_kill",
));
}
Ok(Self {
spec,
handler: Arc::new(handler),
})
}
}
#[derive(Clone, Debug, Default)]
pub struct HostToolRegistry {
by_id: HashMap<ToolId, RegisteredTool>,
by_name: HashMap<ToolName, ToolId>,
}
impl HostToolRegistry {
pub fn empty() -> Self {
Self::default()
}
pub fn build(tools: Vec<RegisteredTool>) -> Result<Self, super::StartupError> {
use monoloop_contracts::ToolExecutionClass;
let mut by_id = HashMap::with_capacity(tools.len());
let mut by_name = HashMap::with_capacity(tools.len());
for tool in tools {
match &tool.spec.execution_class {
ToolExecutionClass::ProcessIsolated { .. }
if !tool.handler.os_process_isolated() =>
{
return Err(super::StartupError::ToolRegistry(
"ProcessIsolated entry lacks os_process_isolated handler",
));
}
ToolExecutionClass::AbortableAtYield { .. }
if !tool.handler.runtime_owns_abortable_drive() =>
{
return Err(super::StartupError::ToolRegistry(
"AbortableAtYield entry lacks runtime_owns_abortable_drive handler",
));
}
_ => {}
}
let max_schema = monoloop_contracts::TransactionLimits::default().max_tool_schema_bytes;
let schema_bytes = serde_json::to_vec(tool.spec.input_schema.as_value())
.map(|b| b.len())
.unwrap_or(0);
if schema_bytes > max_schema {
return Err(super::StartupError::ToolRegistry("tool schema too large"));
}
if by_id.contains_key(&tool.spec.id) {
return Err(super::StartupError::ToolRegistry("duplicate ToolId"));
}
if by_name.contains_key(&tool.spec.name) {
return Err(super::StartupError::ToolRegistry("duplicate ToolName"));
}
by_name.insert(tool.spec.name.clone(), tool.spec.id.clone());
by_id.insert(tool.spec.id.clone(), tool);
}
Ok(Self { by_id, by_name })
}
pub fn len(&self) -> usize {
self.by_id.len()
}
pub fn is_empty(&self) -> bool {
self.by_id.is_empty()
}
pub fn get(&self, id: &ToolId) -> Option<&RegisteredTool> {
self.by_id.get(id)
}
pub fn get_spec(&self, id: &ToolId) -> Option<&ToolSpec> {
self.by_id.get(id).map(|t| &t.spec)
}
pub fn id_for_name(&self, name: &ToolName) -> Option<&ToolId> {
self.by_name.get(name)
}
pub fn specs_sorted(&self) -> Vec<&ToolSpec> {
let mut ids: Vec<_> = self.by_id.keys().collect();
ids.sort_by(|a, b| a.as_str().cmp(b.as_str()));
ids.into_iter()
.filter_map(|id| self.by_id.get(id).map(|t| &t.spec))
.collect()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transaction::tool_handler::{AsyncToolHandler, ImmediateToolHandler};
use monoloop_contracts::{
CanonicalToolOutput, JsonSchema, ToolCompletion, ToolExecutionClass, ToolId, ToolLimits,
ToolName, ToolOutputContract, ToolSuccessContract,
};
use std::time::Duration;
fn abortable_spec() -> ToolSpec {
let schema = JsonSchema::try_new(serde_json::json!({"type": "object"})).unwrap();
ToolSpec::try_new(
ToolId::try_new("a").unwrap(),
ToolName::try_new("a").unwrap(),
"abortable",
schema.clone(),
ToolOutputContract {
success: ToolSuccessContract::json(schema),
error_data_schema: None,
},
ToolLimits::default(),
ToolExecutionClass::AbortableAtYield {
grace: Duration::from_secs(1),
},
)
.unwrap()
}
#[test]
fn abortable_rejects_dyn_handler_path() {
let forged = Arc::new(ImmediateToolHandler::new(|_c, _x| {
Ok(ToolCompletion::Succeeded(CanonicalToolOutput::Json(
serde_json::json!({}),
)))
})) as Arc<dyn ToolHandler>;
let err = RegisteredTool::try_new(abortable_spec(), forged).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("try_new_abortable") || msg.contains("AbortableAtYield"),
"got {msg}"
);
}
#[test]
fn abortable_rejects_boolean_self_assert() {
struct Liar;
impl ToolHandler for Liar {
fn start(
&self,
_call: monoloop_contracts::ToolCall,
_ctx: monoloop_contracts::ToolCallContext,
) -> Result<crate::LinkedToolExecutionHandle, monoloop_contracts::ToolStartError>
{
Err(monoloop_contracts::ToolStartError::Rejected("liar"))
}
fn supports_abort(&self) -> bool {
true
}
}
let err = RegisteredTool::try_new(abortable_spec(), Arc::new(Liar)).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("try_new_abortable"),
"boolean self-assert must not register: {msg}"
);
}
#[test]
fn abortable_accepts_structural_handler() {
RegisteredTool::try_new_abortable(
abortable_spec(),
AsyncToolHandler::new(|_c, _x, _ctl| {
Box::pin(async {
ToolCompletion::Succeeded(CanonicalToolOutput::Json(serde_json::json!({})))
})
}),
)
.expect("structural AbortableAtYield ok");
}
#[test]
fn abortable_typed_api_rejects_wrong_class() {
let schema = JsonSchema::try_new(serde_json::json!({"type": "object"})).unwrap();
let cooperative = ToolSpec::try_new(
ToolId::try_new("c").unwrap(),
ToolName::try_new("c").unwrap(),
"coop",
schema.clone(),
ToolOutputContract {
success: ToolSuccessContract::json(schema),
error_data_schema: None,
},
ToolLimits::default(),
ToolExecutionClass::CooperativeInProcess {
grace: Duration::from_secs(1),
},
)
.unwrap();
let err = RegisteredTool::try_new_abortable(
cooperative,
AsyncToolHandler::new(|_c, _x, _ctl| {
Box::pin(async {
ToolCompletion::Succeeded(CanonicalToolOutput::Json(serde_json::json!({})))
})
}),
)
.unwrap_err();
assert!(format!("{err}").contains("AbortableAtYield"));
}
}