///|
/// Tool dispatch service for a program with a fixed composed capability
/// snapshot. This is NOT an alternate agent loop or a reducer-owned Effect.
/// It executes only leaf tools that are passed explicitly at composition.
/// Every request is checked against the same catalog validator and the
/// supplied PipelineHook::before_tool chain as normal tool calls.
/// Consumers must not grant recursive program execution.
pub struct ScopedToolDispatch {
priv catalog : @kernel_exec.ToolCatalogSnapshot
priv routes : Map[String, &@port.ToolProvider]
priv hooks : Array[&@port.PipelineHook]
priv mut next_call : Int32
}
///|
pub fn ScopedToolDispatch::ScopedToolDispatch(
tools : Array[&@port.ToolProvider],
hooks~ : Array[&@port.PipelineHook] = [],
) -> ScopedToolDispatch raise @error.CompositionError {
let routing = build_tool_routing(tools)
let catalog = build_agent_catalog(tools)
{ catalog, routes: routing.tool_map, hooks: hooks.copy(), next_call: 0, }
}
///|
/// A nested program call cannot be recursively dispatched to run_code,
/// regardless of whether a host accidentally included it in the catalog.
pub async fn ScopedToolDispatch::call(
self : ScopedToolDispatch,
name : String,
arguments : Json,
) -> @kernel.ToolOutcome {
if name == "run_code" {
return @kernel.ToolReportedError(
content="recursive program calls are not admitted",
structured=None,
)
}
self.next_call += 1
let call_id = @kernel.CallId::unchecked(
"scoped-" + self.next_call.to_string(),
)
let mut call : @kernel.ToolCall = {
call_id,
name: @kernel.ToolName::unchecked(name),
arguments,
}
let mut rejection : String? = None
let mut consented : @kernel.ToolCall? = None
for hook in self.hooks {
let decision = hook.before_tool({
call_id: call.call_id,
name: call.name,
arguments: @json.parse(call.arguments.stringify()) catch {
_ => Json::null()
},
}) catch {
error =>
return @kernel.ToolReportedError(
content="scoped hook failed: " + error.to_string(),
structured=None,
)
}
match decision {
@port.ToolDecision::Approve(call=rewritten) => {
if rewritten.call_id != call_id {
return @kernel.ToolReportedError(
content="nested hook attempted to change call identity",
structured=None,
)
}
call = rewritten
}
@port.ToolDecision::ApproveAfterConsent(call=approved, consent_scope=_) => {
if approved.call_id != call_id {
return @kernel.ToolReportedError(
content="nested hook attempted to change call identity",
structured=None,
)
}
call = approved
consented = Some(approved)
}
@port.ToolDecision::Reject(reason~) => rejection = Some(reason)
@port.ToolDecision::Defer(reason~) =>
return @kernel.ToolReportedError(
content="nested tool deferred: " + reason,
structured=None,
)
}
}
if consented is Some(granted) && granted != call {
return @kernel.ToolReportedError(
content="nested tool changed after approval",
structured=None,
)
}
if rejection is Some(reason) && consented is None {
return @kernel.ToolReportedError(content=reason, structured=None)
}
let selected = call.name.to_string()
if selected == "run_code" || !self.catalog.contains(call.name) {
return @kernel.ToolReportedError(
content="nested tool capability unavailable: " + selected,
structured=None,
)
}
match self.catalog.validate_arguments(call.name, call.arguments) {
Err(diagnostic) =>
return @kernel.ToolReportedError(
content="nested tool schema mismatch: " + diagnostic.json_path,
structured=None,
)
Ok(_) => ()
}
let provider = match self.routes.get(selected) {
Some(provider) => provider
None =>
return @kernel.ToolReportedError(
content="nested tool provider unavailable",
structured=None,
)
}
// This provisional dispatch is owned by the enclosing program; its
// cancellation must unwind the child as part of the program's task group.
// The reducer never allocates a separate EffectId for this operation.
let call_ctx : @port.ToolCallContext = {
effect_id: @kernel.EffectId::first(),
call_id,
name: call.name,
arguments: call.arguments,
register_cancel: fn(_ : &@port.ToolCancellable) { () },
}
provider.execute(call_ctx) catch {
error =>
@kernel.ToolOutcome::RuntimeFailure(
error_category="ScopedToolProviderError",
message=error.to_string(),
)
}
}