bxcore, brokerd, loopd: an invalid-arguments denial says why

Co-Authored-By: ornith-1.5-35b-a3b <agent@wntrmute.dev>
This commit is contained in:
2026-09-27 19:08:42 -07:00
co-authored by ornith-1.5-35b-a3b
parent 89a50bc3c6
commit de60c9e13a
21 changed files with 520 additions and 33 deletions
+14
View File
@@ -378,6 +378,19 @@ pub fn denial_text(reason: bxcore::DenyReason) -> &'static str {
}
}
/// What a denial reaches the model as: the reason's sentence, and the broker's detail, if it sent
/// one, after a colon (`Denied: the arguments are not valid for this tool: <detail>.`).
pub fn denial_message(reason: bxcore::DenyReason, detail: Option<&str>) -> String {
let sentence = denial_text(reason);
match detail {
None => sentence.to_string(),
Some(detail) => {
let stem = sentence.strip_suffix('.').unwrap_or(sentence);
format!("{stem}: {detail}.")
}
}
}
pub struct FakeTools {
calls: Mutex<Vec<bxcore::ToolRequest>>,
}
@@ -432,6 +445,7 @@ impl ToolPort for FakeTools {
},
_ => bxcore::ToolResponse::Denied {
reason: bxcore::DenyReason::NoGrant,
detail: None,
},
}
}
+7 -3
View File
@@ -7,7 +7,7 @@ use crate::config::Config;
use crate::llama::info::{CacheOutcome, cache_outcome};
use crate::llama::{ChatEvent, ChatRequest, Client, InferError};
use crate::session::{Session, SessionError};
use crate::tools::{Registry, ToolPort, cap_result, denial_text, dispatch};
use crate::tools::{Registry, ToolPort, cap_result, denial_message, dispatch};
use bxcore::{
CallId, DataClass, LogRecord, Timestamp, ToolRequest, ToolResponse, TurnEvent, Usage,
};
@@ -292,12 +292,16 @@ fn run_call(
DataClass::Public,
false,
),
ToolResponse::Denied { reason } => {
ToolResponse::Denied { reason, detail } => {
on_event(&TurnEvent::ToolDenied {
name: request.tool.clone(),
reason,
});
(denial_text(reason).to_string(), DataClass::Public, false)
(
denial_message(reason, detail.as_deref()),
DataClass::Public,
false,
)
}
ToolResponse::PendingApproval { .. } => (
"The tool failed: the tool broker gave no final answer".to_string(),
+8 -1
View File
@@ -48,8 +48,15 @@ fn every_denial_and_a_failure_come_back_as_they_were_sent() {
DenyReason::StateUnreadable,
]
.into_iter()
.map(|reason| ToolResponse::Denied { reason })
.map(|reason| ToolResponse::Denied {
reason,
detail: None,
})
.collect();
answers.push(ToolResponse::Denied {
reason: DenyReason::InvalidArguments,
detail: Some("invalid path: n/a".to_string()),
});
answers.push(ToolResponse::Failed {
message: "the runner arrives in M3b".to_string(),
});
+30 -2
View File
@@ -5,7 +5,8 @@ mod support;
use bxcore::{CallId, DenyReason, SessionId, ToolRequest, ToolResponse};
use loopd::tools::{
Dispatch, FakeTools, Pending, Registry, ToolPort, cap_result, denial_text, dispatch,
Dispatch, FakeTools, Pending, Registry, ToolPort, cap_result, denial_message, denial_text,
dispatch,
};
fn req(tool: &str, arguments: &str) -> ToolRequest {
@@ -252,9 +253,35 @@ fn every_deny_reason_has_its_sentence() {
];
for (reason, want) in table {
assert_eq!(denial_text(reason), want, "{reason:?}");
assert_eq!(
denial_message(reason, None),
want,
"{reason:?}: without a detail, the sentence alone"
);
}
}
/// The broker's detail goes after the sentence, before its full stop.
#[test]
fn a_denial_with_a_detail_appends_it_to_the_sentence() {
assert_eq!(
denial_message(
DenyReason::InvalidArguments,
Some("invalid argument shape: unknown field `file`, expected `text`")
),
"Denied: the arguments are not valid for this tool: invalid argument shape: unknown field `file`, expected `text`."
);
assert_eq!(
denial_message(DenyReason::InvalidArguments, Some("invalid path: n/a")),
"Denied: the arguments are not valid for this tool: invalid path: n/a."
);
assert_eq!(
denial_message(DenyReason::NoGrant, Some("x")),
"Denied: no grant allows this call: x.",
"any reason's detail is shown the same way"
);
}
#[test]
fn results_are_cut_on_a_character_boundary_and_marked() {
assert_eq!(cap_result("short", 100), ("short".to_string(), false));
@@ -296,7 +323,8 @@ fn fake_tools_answer_echo_deny_the_rest_and_record_calls() {
assert_eq!(
fake.call(&req(tool, "{}"), &mut on_pending),
ToolResponse::Denied {
reason: DenyReason::NoGrant
reason: DenyReason::NoGrant,
detail: None,
},
"{tool}: the clock is loopd's own now, not the port's"
);
+47
View File
@@ -31,6 +31,7 @@ fn tool_events(events: &[TurnEvent]) -> Vec<String> {
fn a_denial_is_a_fixed_sentence_for_the_model_and_an_event_for_the_owner() {
let s = setup(vec![ToolResponse::Denied {
reason: DenyReason::TaintTooHigh,
detail: None,
}]);
s.server.route(
CHAT,
@@ -78,10 +79,55 @@ fn a_denial_is_a_fixed_sentence_for_the_model_and_an_event_for_the_owner() {
);
}
/// The broker's detail reaches the model, in the log and in the next request; the owner's event
/// is the same as for any denial.
#[test]
fn an_invalid_call_tells_the_model_why() {
let s = setup(vec![ToolResponse::Denied {
reason: DenyReason::InvalidArguments,
detail: Some("invalid path: n/a".to_string()),
}]);
s.server.route(
CHAT,
vec![Reply::fixture("tool_call"), Reply::fixture("plain")],
);
let mut session = s.session("a");
let (result, events) = s.turn(&mut session, "x");
assert!(result.is_ok(), "{result:?}");
let want = "Denied: the arguments are not valid for this tool: invalid path: n/a.";
match &session.records()[4] {
LogRecord::ToolResult {
content,
class,
untrusted,
truncated,
..
} => {
assert_eq!(content, want);
assert_eq!(
(*class, *untrusted, *truncated),
(DataClass::Public, false, false)
);
}
other => panic!("{other:?}"),
}
assert_eq!(
tool_events(&events),
[
"started read_file",
"denied read_file InvalidArguments",
"result read_file"
]
);
let m2 = s.server.requests_to(CHAT)[1].json();
assert_eq!(m2["messages"][3]["content"], want);
}
#[test]
fn a_denied_call_tool_names_the_target_tool_in_the_denial() {
let s = setup(vec![ToolResponse::Denied {
reason: DenyReason::NoGrant,
detail: None,
}]);
s.server.route(
CHAT,
@@ -158,6 +204,7 @@ fn a_pending_approval_that_ends_in_a_refusal() {
pending(7, "2026-09-18T12:15:00.000Z"),
ToolResponse::Denied {
reason: DenyReason::ApprovalRefused,
detail: None,
},
]);
s.server.route(