loopd: dispatch subagent, not the port

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-10-03 19:50:13 -07:00
co-authored by ornith-1.5-35b-a3b
parent bab22bb4c8
commit fe145fb83a
5 changed files with 125 additions and 0 deletions
+57
View File
@@ -20,3 +20,60 @@ pub fn schema() -> crate::llama::ToolSchema {
}),
}
}
/// `sub-<parent>-<n>`, or the raw string that would not fit a `SessionId`.
pub fn child_id(
parent: &bxcore::SessionId,
n: bxcore::CallId,
) -> Result<bxcore::SessionId, String> {
let raw = format!("sub-{}-{}", parent.as_str(), n.0);
bxcore::SessionId::new(&raw).map_err(|_| raw)
}
/// Inner arguments of `subagent` (or of `call_tool` after unwrap). Unknown keys are errors.
pub fn parse_task(arguments: &str) -> Result<String, String> {
let value: serde_json::Value = match serde_json::from_str(arguments) {
Ok(value) => value,
Err(_) => return Err("arguments must be a JSON object".to_string()),
};
let obj = match value.as_object() {
Some(obj) => obj,
None => return Err("arguments must be a JSON object".to_string()),
};
for key in obj.keys() {
if key != "task" {
return Err(format!("unknown field: {key}"));
}
}
match obj.get("task") {
Some(serde_json::Value::String(text)) => Ok(text.clone()),
Some(_) => Err("task must be a string".to_string()),
None => Err("task is required".to_string()),
}
}
pub fn dispatch_args(arguments: &str) -> crate::tools::Dispatch {
match parse_task(arguments) {
Ok(task) => crate::tools::Dispatch::Subagent { task },
Err(detail) => crate::tools::Dispatch::Local(crate::tools::denial_message(
bxcore::DenyReason::InvalidArguments,
Some(&detail),
)),
}
}
/// Spawn is task 03. The match arm in `run_call` needs this name now.
pub fn run(
rt: &crate::turn::Runtime<'_>,
parent: &crate::session::Session,
task: &str,
call_id: bxcore::CallId,
on_event: &mut dyn FnMut(&bxcore::TurnEvent),
) -> (String, bxcore::DataClass, bool) {
let _ = (rt, parent, task, call_id, on_event);
(
"subagent stub".to_string(),
bxcore::DataClass::Public,
false,
)
}
+7
View File
@@ -288,6 +288,7 @@ fn call_tool_schema() -> ToolSchema {
pub enum Dispatch {
Local(String),
Port { tool: String, arguments: String },
Subagent { task: String },
}
/// Decides whether `loopd` handles a tool request itself or sends it to the port.
@@ -301,6 +302,9 @@ pub fn dispatch(registry: &Registry, name: &str, arguments: &str) -> Dispatch {
if name == CLOCK {
return Dispatch::Local(bxcore::Timestamp::now().to_rfc3339());
}
if name == crate::subagent::SUBAGENT {
return crate::subagent::dispatch_args(arguments);
}
Dispatch::Port {
tool: name.to_string(),
arguments: arguments.to_string(),
@@ -348,6 +352,9 @@ fn dispatch_call_tool(registry: &Registry, arguments: &str) -> Dispatch {
Some(_) => {
let args_text =
serde_json::to_string(args_value).unwrap_or_else(|_| "null".to_string());
if name == crate::subagent::SUBAGENT {
return crate::subagent::dispatch_args(&args_text);
}
Dispatch::Port {
tool: name.to_string(),
arguments: args_text,
+3
View File
@@ -281,6 +281,9 @@ fn run_call(
) -> (String, DataClass, bool) {
match dispatch(rt.registry, &call.name, &call.arguments) {
crate::tools::Dispatch::Local(text) => (text, DataClass::Public, false),
crate::tools::Dispatch::Subagent { task } => {
crate::subagent::run(rt, session, &task, call_id, on_event)
}
crate::tools::Dispatch::Port { tool, arguments } => {
let request = ToolRequest {
session: session.id().clone(),
+57
View File
@@ -0,0 +1,57 @@
//! Dispatch of `subagent`: not the port, unknown keys, child ids. Do not edit.
use bxcore::CallId;
use bxcore::SessionId;
use loopd::subagent::{self, SUBAGENT};
use loopd::tools::{Dispatch, Registry, dispatch};
#[test]
fn call_tool_unwraps_subagent_and_does_not_send_it_to_the_port() {
let r = Registry::m5c();
match dispatch(
&r,
"call_tool",
r#"{"name":"subagent","arguments":{"task":"look around"}}"#,
) {
Dispatch::Subagent { task } => assert_eq!(task, "look around"),
other => panic!("{other:?}"),
}
match dispatch(&r, SUBAGENT, r#"{"task":"look around"}"#) {
Dispatch::Subagent { task } => assert_eq!(task, "look around"),
other => panic!("{other:?}"),
}
}
#[test]
fn unknown_keys_and_a_missing_task_are_invalid_arguments() {
let r = Registry::m5c();
let cases = [
(r#"{"task":"x","extra":1}"#, "unknown field: extra"),
(r#"{"text":"x"}"#, "unknown field: text"),
("{}", "task is required"),
(r#"{"task":1}"#, "task must be a string"),
("[]", "arguments must be a JSON object"),
("not json", "arguments must be a JSON object"),
];
for (args, want) in cases {
match dispatch(&r, SUBAGENT, args) {
Dispatch::Local(text) => {
assert!(
text.starts_with("Denied: the arguments are not valid for this tool:"),
"{args}: {text}"
);
assert!(text.contains(want), "{args}: {text}");
}
other => panic!("{args}: {other:?}"),
}
}
}
#[test]
fn a_parent_id_that_cannot_fit_a_child_is_named_in_the_denial() {
let parent = SessionId::new(&"a".repeat(59)).unwrap();
let err = subagent::child_id(&parent, CallId(1)).unwrap_err();
assert!(err.len() > 64, "{err}");
assert!(err.starts_with("sub-"), "{err}");
assert!(subagent::child_id(&SessionId::new("mm-thread").unwrap(), CallId(3)).is_ok());
}