From f15e2b502511abb48475e2843fdde28c3969244c Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 7 Aug 2026 10:54:35 -0400 Subject: [PATCH] fix(assistant): a disabled-category request now fires the Settings offer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:]]' — 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 --- core/archipelago/src/assistant/egress.rs | 11 +- core/archipelago/src/assistant/mod.rs | 124 ++++++++++++++++++++++- 2 files changed, 129 insertions(+), 6 deletions(-) diff --git a/core/archipelago/src/assistant/egress.rs b/core/archipelago/src/assistant/egress.rs index 24137b4b..d6331a69 100644 --- a/core/archipelago/src/assistant/egress.rs +++ b/core/archipelago/src/assistant/egress.rs @@ -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, diff --git a/core/archipelago/src/assistant/mod.rs b/core/archipelago/src/assistant/mod.rs index d241f8e8..f82b2f5f 100644 --- a/core/archipelago/src/assistant/mod.rs +++ b/core/archipelago/src/assistant/mod.rs @@ -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::>() + .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:]] (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:]]` 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) { + let mut found: Vec = 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::(&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::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:]]` (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:]]` 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.