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:
@@ -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,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(),
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user