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:
archipelago
2026-08-07 10:54:35 -04:00
co-authored by Claude
parent c2e71bc7e5
commit f15e2b5025
2 changed files with 129 additions and 6 deletions
+10 -1
View File
@@ -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,
+119 -5
View File
@@ -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.