fix(assistant): a disabled-category request now fires the Settings offer
The 9abc1623 banner waited on refused_categories, but refused_categories
only fills when the model CALLS a gated tool — and D-16 hides ungranted
tools from the prompt, so the model never calls: it answered 'I can't do
that' in prose and the banner never fired. Live-verified: revoke media,
ask for content, no banner.
- build_system_prompt takes the disabled categories and teaches a marker:
'say it can be switched on in Settings → AI Data Access and end with
[[needs:<id>]]' — category names only, never tool names (D-16 holds)
- extract_needs_markers strips the markers from the reply and folds them
into refused_categories; unknown ids pass through as text (an offer is
the worst a bad marker can cause — never a grant)
- egress's seed-screen test now covers the new paragraph too
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -519,11 +519,20 @@ mod tests {
|
||||
let all: std::collections::BTreeSet<_> =
|
||||
crate::assistant::PermissionCategory::ALL.into_iter().collect();
|
||||
let visible = registry.visible_to(&all);
|
||||
let prompt = crate::assistant::build_system_prompt(&visible);
|
||||
let prompt = crate::assistant::build_system_prompt(&visible, &[]);
|
||||
assert!(
|
||||
!has_bip39_length_word_run(&prompt),
|
||||
"the node's own system prompt must never trip the seed screen"
|
||||
);
|
||||
// The disabled-categories paragraph (needs-marker instruction) is
|
||||
// prompt text too — it must hold to the same guarantee.
|
||||
let all_disabled: Vec<_> =
|
||||
crate::assistant::PermissionCategory::ALL.into_iter().collect();
|
||||
let prompt_with_disabled = crate::assistant::build_system_prompt(&visible, &all_disabled);
|
||||
assert!(
|
||||
!has_bip39_length_word_run(&prompt_with_disabled),
|
||||
"the disabled-categories paragraph must never trip the seed screen"
|
||||
);
|
||||
let body = json!({
|
||||
"model": "claude-haiku-4-5",
|
||||
"system": prompt,
|
||||
|
||||
@@ -797,7 +797,10 @@ Tag only specific, real titles you are confident about — never invent a title
|
||||
and never tag a title the tool already returned (its card is already on screen). An untagged \
|
||||
recommendation renders as plain text and is easily missed.";
|
||||
|
||||
pub fn build_system_prompt(visible_tools: &[tools::ToolDef]) -> String {
|
||||
pub fn build_system_prompt(
|
||||
visible_tools: &[tools::ToolDef],
|
||||
disabled_categories: &[PermissionCategory],
|
||||
) -> String {
|
||||
let mut prompt = String::from(SYSTEM_PROMPT_PREAMBLE);
|
||||
if visible_tools.is_empty() {
|
||||
prompt.push_str(
|
||||
@@ -811,9 +814,71 @@ pub fn build_system_prompt(visible_tools: &[tools::ToolDef]) -> String {
|
||||
prompt.push_str(&format!("- {}: {}\n", tool.name, tool.description));
|
||||
}
|
||||
}
|
||||
// A DISABLED capability the user asks for is the moment the trusted
|
||||
// chrome can help — but only if the turn says so in a parseable way.
|
||||
// The model never sees the disabled tools themselves (D-16), only the
|
||||
// category names; the marker it emits can only ever make the app OFFER
|
||||
// the Settings screen, never change a grant.
|
||||
if !disabled_categories.is_empty() {
|
||||
let list = disabled_categories
|
||||
.iter()
|
||||
.map(|c| {
|
||||
serde_json::to_value(c)
|
||||
.ok()
|
||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
||||
.unwrap_or_else(|| format!("{c:?}"))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
prompt.push_str(&format!(
|
||||
"\n\nSome capabilities are currently DISABLED by the operator: {list}. If \
|
||||
fulfilling the request would need one of them, say so plainly — the operator can \
|
||||
switch it on in Settings → AI Data Access — and end the reply with the marker \
|
||||
[[needs:<id>]] (one per disabled category the request touches, e.g. \
|
||||
[[needs:media]]). Never name a tool for a disabled category; the marker is how \
|
||||
the app offers the right toggle."
|
||||
));
|
||||
}
|
||||
prompt
|
||||
}
|
||||
|
||||
/// Pull `[[needs:<category>]]` markers out of a final answer. The model
|
||||
/// emits one when the request needs a disabled category (see the preamble);
|
||||
/// the trusted chrome turns each into a Settings offer. Unknown ids pass
|
||||
/// through untouched — a misspelled marker is a cosmetic bug, not a
|
||||
/// privilege one (the marker can only ever OFFER the Settings screen).
|
||||
fn extract_needs_markers(text: &str) -> (String, Vec<PermissionCategory>) {
|
||||
let mut found: Vec<PermissionCategory> = Vec::new();
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(start) = rest.find("[[needs:") {
|
||||
let after = &rest[start + 8..];
|
||||
match after.find("]]" ) {
|
||||
Some(end) => {
|
||||
let id = after[..end].trim().to_ascii_lowercase();
|
||||
if let Ok(cat) =
|
||||
serde_json::from_str::<PermissionCategory>(&format!("\"{id}\""))
|
||||
{
|
||||
out.push_str(&rest[..start]);
|
||||
if !found.contains(&cat) {
|
||||
found.push(cat);
|
||||
}
|
||||
} else {
|
||||
out.push_str(&rest[..start + 8 + end + 2]);
|
||||
}
|
||||
rest = &after[end + 2..];
|
||||
}
|
||||
None => {
|
||||
out.push_str(&rest[..start]);
|
||||
out.push_str(&rest[start..]);
|
||||
rest = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
(out.trim_end().to_string(), found)
|
||||
}
|
||||
|
||||
/// A completed chat turn: the model's prose answer, plus whatever
|
||||
/// content-producing tools it ran along the way. Callers that only want
|
||||
/// words (the mesh `!archy` path) use [`chat`]; the browser uses
|
||||
@@ -861,11 +926,15 @@ pub async fn chat_with_surfaces(
|
||||
let registry = tools::registry();
|
||||
let grants = caller.granted_categories(handler.data_dir()).await;
|
||||
let visible_tools = registry.visible_to(&grants);
|
||||
let disabled_categories: Vec<PermissionCategory> = PermissionCategory::ALL
|
||||
.into_iter()
|
||||
.filter(|c| !grants.contains(c))
|
||||
.collect();
|
||||
|
||||
let (backend, backend_id) = backends::select_backend(&handler).await;
|
||||
tracing::info!(backend = %backend_id, "assistant.chat: backend selected for this turn");
|
||||
|
||||
let system_prompt = build_system_prompt(&visible_tools);
|
||||
let system_prompt = build_system_prompt(&visible_tools, &disabled_categories);
|
||||
|
||||
let key = history::HistoryKey::from_caller(&caller);
|
||||
|
||||
@@ -924,10 +993,21 @@ pub async fn chat_with_surfaces(
|
||||
);
|
||||
}
|
||||
|
||||
// The model flags a request that needs a disabled category with
|
||||
// `[[needs:<id>]]` (see the preamble); strip the markers and surface
|
||||
// them as refused categories so the trusted chrome can offer Settings.
|
||||
let (answer, needed) = extract_needs_markers(&answer);
|
||||
let mut refused_categories = ctx.refused_categories();
|
||||
for c in needed {
|
||||
if !refused_categories.contains(&c) {
|
||||
refused_categories.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ChatOutcome {
|
||||
text: answer,
|
||||
surfaces: ctx.surfaces(),
|
||||
refused_categories: ctx.refused_categories(),
|
||||
refused_categories,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1219,7 +1299,7 @@ mod tests {
|
||||
let mut grants = BTreeSet::new();
|
||||
grants.insert(PermissionCategory::System);
|
||||
let visible = reg.visible_to(&grants);
|
||||
let prompt = build_system_prompt(&visible);
|
||||
let prompt = build_system_prompt(&visible, &[]);
|
||||
|
||||
for tool in reg.all() {
|
||||
if tool.category == PermissionCategory::System {
|
||||
@@ -1250,7 +1330,7 @@ mod tests {
|
||||
/// recommendations, and the exact tag formats for knowledge picks.
|
||||
#[test]
|
||||
fn system_prompt_teaches_discovery_first_and_preview_tags() {
|
||||
let prompt = build_system_prompt(&[]);
|
||||
let prompt = build_system_prompt(&[], &[]);
|
||||
assert!(
|
||||
prompt.contains("catalogue-and-peers check FIRST"),
|
||||
"prompt must order a catalogue/peers check before freestyle recommendations"
|
||||
@@ -1270,6 +1350,40 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The disabled-categories paragraph exists so the chrome's
|
||||
/// "enable it in Settings" offer has something to key on: no tools are
|
||||
/// named, but a `[[needs:<id>]]` marker ends the reply and becomes a
|
||||
/// refused category. With nothing disabled, no marker instruction.
|
||||
#[test]
|
||||
fn disabled_categories_produce_needs_marker_instruction() {
|
||||
let prompt = build_system_prompt(&[], &[PermissionCategory::Media, PermissionCategory::Apps]);
|
||||
assert!(prompt.contains("media"), "disabled ids must be listed");
|
||||
assert!(prompt.contains("[[needs:media]]"), "marker example must be taught");
|
||||
let nothing_disabled = build_system_prompt(&[], &[]);
|
||||
assert!(
|
||||
!nothing_disabled.contains("[[needs:"),
|
||||
"no disabled categories → no marker instruction"
|
||||
);
|
||||
}
|
||||
|
||||
/// Markers parse into refused categories and leave clean text; unknown
|
||||
/// ids pass through as-is (cosmetic, never a privilege question).
|
||||
#[test]
|
||||
fn needs_markers_become_refused_categories_and_leave_clean_text() {
|
||||
let (text, cats) =
|
||||
extract_needs_markers("I can't reach your media while it is off. [[needs:media]]");
|
||||
assert_eq!(text, "I can't reach your media while it is off.");
|
||||
assert_eq!(cats, vec![PermissionCategory::Media]);
|
||||
|
||||
let (text2, cats2) = extract_needs_markers("no markers here [[needs:bogus]]");
|
||||
assert!(cats2.is_empty());
|
||||
assert!(text2.contains("[[needs:bogus]]"));
|
||||
|
||||
// Duplicates collapse, order is stable.
|
||||
let (_, cats3) = extract_needs_markers("[[needs:apps]] then [[needs:apps]]");
|
||||
assert_eq!(cats3, vec![PermissionCategory::Apps]);
|
||||
}
|
||||
|
||||
/// D-16: revoking a category takes effect on the very next
|
||||
/// `granted_categories` resolution — not only on the next session /
|
||||
/// process restart.
|
||||
|
||||
Reference in New Issue
Block a user