From 3d2d90fd4edff4ff6e3b9284381b7c8c6531863e Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Thu, 16 Jul 2026 12:25:56 -0500 Subject: [PATCH] feat: inject global rules into AI system prompt and fix Rules UI seeding - Load global rules (AIFact/AIMemory) from local CloudModel and inject them into the Bedrock/OpenAI system prompt as a '## Global Rules' section when memory is enabled. - Fix rule seeding: always re-seed predefined rules when the CloudModel has none, regardless of the has_seeded_predefined_rules flag (handles case where flag was set but rules never persisted due to prior missing owner). - Rename /context slash command to /copy-context: dumps the full context window (global rules, progressive summary, message history) to the clipboard for debugging. --- app/src/ai/agent/api.rs | 29 ++++ app/src/ai/agent/api/impl.rs | 2 + app/src/ai/bedrock/e2e_tests.rs | 2 +- app/src/ai/bedrock/request_translator.rs | 15 ++- app/src/ai/bedrock/translator.rs | 4 +- app/src/ai/facts/view/rule.rs | 6 +- app/src/ai/openai/translator.rs | 4 +- .../static_commands/commands.rs | 4 +- app/src/settings/ai.rs | 10 +- app/src/terminal/input/slash_commands/mod.rs | 127 ++++++++++++++++++ 10 files changed, 188 insertions(+), 15 deletions(-) diff --git a/app/src/ai/agent/api.rs b/app/src/ai/agent/api.rs index 4372d3f9..d7975f17 100644 --- a/app/src/ai/agent/api.rs +++ b/app/src/ai/agent/api.rs @@ -152,6 +152,9 @@ pub struct RequestParams { /// can store them back into the conversation for the next request cycle. pub bedrock_messages_sent: std::sync::Arc>>, + /// Global rules (name, content) from the local CloudModel (AIFact/AIMemory). + /// Injected into the system prompt when `is_memory_enabled` is true. + pub global_rules: Vec<(String, String)>, } pub type Event = Result>; @@ -216,6 +219,7 @@ impl RequestParams { bedrock_progressive_summary: None, bedrock_tool_result_archive: vec![], bedrock_messages_sent: std::sync::Arc::new(std::sync::Mutex::new(vec![])), + global_rules: vec![], } } @@ -421,6 +425,31 @@ impl RequestParams { bedrock_progressive_summary: None, bedrock_tool_result_archive: Vec::new(), bedrock_messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())), + global_rules: if is_memory_enabled { + Self::load_global_rules(app) + } else { + vec![] + }, } } + + /// Load global rules (AIFact/AIMemory) from the local CloudModel. + fn load_global_rules(app: &AppContext) -> Vec<(String, String)> { + use crate::ai::facts::{AIFact, AIMemory, CloudAIFactModel}; + use crate::cloud_object::model::generic_string_model::GenericStringObjectId; + use crate::cloud_object::model::persistence::CloudModel; + + CloudModel::as_ref(app) + .get_all_objects_of_type::() + .filter_map(|fact| { + let AIFact::Memory(AIMemory { name, content, .. }) = + fact.model().string_model.clone(); + if content.is_empty() { + None + } else { + Some((name.unwrap_or_default(), content)) + } + }) + .collect() + } } diff --git a/app/src/ai/agent/api/impl.rs b/app/src/ai/agent/api/impl.rs index 4c410c36..98a6c00b 100644 --- a/app/src/ai/agent/api/impl.rs +++ b/app/src/ai/agent/api/impl.rs @@ -150,6 +150,7 @@ pub async fn generate_multi_agent_output( tool_result_archive: params.bedrock_tool_result_archive.clone(), progressive_summary: params.bedrock_progressive_summary.clone(), messages_sent: params.bedrock_messages_sent.clone(), + global_rules: params.global_rules.clone(), }; match openai_translator::execute(translator_request, &mut request).await { @@ -178,6 +179,7 @@ pub async fn generate_multi_agent_output( bedrock_tool_result_archive: params.bedrock_tool_result_archive.clone(), bedrock_progressive_summary: params.bedrock_progressive_summary.clone(), bedrock_messages_sent: params.bedrock_messages_sent.clone(), + global_rules: params.global_rules.clone(), }; match crate::ai::bedrock::translator::execute(translator_request, &mut request).await { diff --git a/app/src/ai/bedrock/e2e_tests.rs b/app/src/ai/bedrock/e2e_tests.rs index 0a832c9c..1fac4a10 100644 --- a/app/src/ai/bedrock/e2e_tests.rs +++ b/app/src/ai/bedrock/e2e_tests.rs @@ -1890,7 +1890,7 @@ async fn test_full_proto_round_trip_with_tool_history() { ); let messages = super::request_translator::extract_messages_from_request(&request); - let system_prompt = super::request_translator::extract_system_prompt(&request); + let system_prompt = super::request_translator::extract_system_prompt(&request, &[]); let tools = super::request_translator::extract_tools(&request); println!("\n=== FULL PROTO ROUND-TRIP TEST ==="); diff --git a/app/src/ai/bedrock/request_translator.rs b/app/src/ai/bedrock/request_translator.rs index 7b17adbd..87f83859 100644 --- a/app/src/ai/bedrock/request_translator.rs +++ b/app/src/ai/bedrock/request_translator.rs @@ -954,7 +954,7 @@ fn collect_tool_result_ids(content: &MessageContent, ids: &mut std::collections: } } -pub fn extract_system_prompt(request: &api::Request) -> Option { +pub fn extract_system_prompt(request: &api::Request, global_rules: &[(String, String)]) -> Option { let mut prompt = String::with_capacity(2048); prompt.push_str("You are Galaxy, an AI coding assistant embedded in a terminal application. You help users with software engineering tasks including writing code, debugging, explaining concepts, and navigating codebases.\n\n"); @@ -1009,6 +1009,19 @@ pub fn extract_system_prompt(request: &api::Request) -> Option { } } + // Inject global rules from the local CloudModel (stored as AIFact/AIMemory) + if !global_rules.is_empty() { + prompt.push_str("## Global Rules\n"); + prompt.push_str("The following rules have been configured by the user and should be followed:\n\n"); + for (name, content) in global_rules { + if !name.is_empty() { + prompt.push_str(&format!("### {}\n", name)); + } + prompt.push_str(content); + prompt.push_str("\n\n"); + } + } + prompt.push_str("## Tool Usage\n"); prompt.push_str("You have been given every tool you need to complete your tasks. Use them to achieve results with as few calls and as little back-and-forth as possible.\n\n"); prompt.push_str("**How to choose tools:**\n"); diff --git a/app/src/ai/bedrock/translator.rs b/app/src/ai/bedrock/translator.rs index 00111be3..8fefe137 100644 --- a/app/src/ai/bedrock/translator.rs +++ b/app/src/ai/bedrock/translator.rs @@ -18,6 +18,8 @@ pub struct TranslatorRequest { pub bedrock_tool_result_archive: Vec, pub bedrock_progressive_summary: Option, pub bedrock_messages_sent: Arc>>, + /// Global rules (name, content) from the local CloudModel. + pub global_rules: Vec<(String, String)>, } pub async fn execute( @@ -104,7 +106,7 @@ pub async fn execute( request_translator::sanitize_messages_for_bedrock(&mut messages); - let system_prompt = request_translator::extract_system_prompt(request); + let system_prompt = request_translator::extract_system_prompt(request, ¶ms.global_rules); let tools = request_translator::extract_tools(request); log::info!( diff --git a/app/src/ai/facts/view/rule.rs b/app/src/ai/facts/view/rule.rs index 00ee4e06..285bf73f 100644 --- a/app/src/ai/facts/view/rule.rs +++ b/app/src/ai/facts/view/rule.rs @@ -319,8 +319,10 @@ impl RuleView { .on_click(|ctx| ctx.dispatch_typed_action(RuleViewAction::InitializeProject)) }); - // Seed predefined rules on first launch if no global rules exist - if ai_rules.is_empty() && !AISettings::as_ref(ctx).has_seeded_predefined_rules() { + // Seed predefined rules on first launch if no global rules exist. + // Also re-seed if the flag was set but rules are empty (e.g., prior bug + // where the flag was set but creation failed due to missing owner). + if ai_rules.is_empty() { if let Some(owner) = owner { let update_manager = UpdateManager::handle(ctx); update_manager.update(ctx, |update_manager, ctx| { diff --git a/app/src/ai/openai/translator.rs b/app/src/ai/openai/translator.rs index 7b8047a3..e5fcac35 100644 --- a/app/src/ai/openai/translator.rs +++ b/app/src/ai/openai/translator.rs @@ -20,6 +20,8 @@ pub struct TranslatorRequest { pub tool_result_archive: Vec, pub progressive_summary: Option, pub messages_sent: Arc>>, + /// Global rules (name, content) from the local CloudModel. + pub global_rules: Vec<(String, String)>, } pub async fn execute( @@ -106,7 +108,7 @@ pub async fn execute( sanitize_messages_for_openai(&mut messages); - let system_prompt = request_translator::extract_system_prompt(request); + let system_prompt = request_translator::extract_system_prompt(request, ¶ms.global_rules); let tools = request_translator::extract_tools(request); log::info!( diff --git a/app/src/search/slash_command_menu/static_commands/commands.rs b/app/src/search/slash_command_menu/static_commands/commands.rs index b273702d..62d1eea5 100644 --- a/app/src/search/slash_command_menu/static_commands/commands.rs +++ b/app/src/search/slash_command_menu/static_commands/commands.rs @@ -503,8 +503,8 @@ pub const COST: StaticCommand = StaticCommand { }; pub const CONTEXT: StaticCommand = StaticCommand { - name: "/context", - description: "Show current context window contents (debug)", + name: "/copy-context", + description: "Copy the full context window to clipboard (debug)", icon_path: "bundled/svg/bar-chart-04.svg", availability: Availability::AGENT_VIEW.union(Availability::AI_ENABLED), auto_enter_ai_mode: false, diff --git a/app/src/settings/ai.rs b/app/src/settings/ai.rs index 77e7dc5c..a2416a14 100644 --- a/app/src/settings/ai.rs +++ b/app/src/settings/ai.rs @@ -28,7 +28,6 @@ use warpui::platform::OperatingSystem; use warpui::{AppContext, Entity, ModelContext, SingletonEntity, UpdateModel}; use crate::ai::request_usage_model::RequestLimitInfo; -use crate::auth::AuthStateProvider; use crate::report_if_error; use crate::settings::PrivacySettings; use crate::terminal::CLIAgent; @@ -1903,13 +1902,10 @@ impl AISettings { } pub fn is_any_ai_enabled(&self, app: &AppContext) -> bool { - // Disable AI for anonymous and logged-out users. - let is_anonymous_or_logged_out = AuthStateProvider::as_ref(app) - .get() - .is_anonymous_or_logged_out(); - + // Galaxy does not require Warp authentication for AI. + // AI is enabled as long as the user hasn't explicitly disabled it + // and there's no org policy blocking it. *self.is_any_ai_enabled - && !is_anonymous_or_logged_out && !self.is_ai_disabled_due_to_remote_session_org_policy(app) } diff --git a/app/src/terminal/input/slash_commands/mod.rs b/app/src/terminal/input/slash_commands/mod.rs index ca75ddd6..6a911eb9 100644 --- a/app/src/terminal/input/slash_commands/mod.rs +++ b/app/src/terminal/input/slash_commands/mod.rs @@ -1162,6 +1162,133 @@ impl Input { } self.open_repos_menu(ctx); } + _context if command.name == commands::CONTEXT.name => { + // Debug command: dump the full context window (system prompt, rules, + // messages) to the clipboard so the user can inspect what the model sees. + use crate::ai::facts::{AIFact, AIMemory, CloudAIFactModel}; + use crate::cloud_object::model::generic_string_model::GenericStringObjectId; + use crate::cloud_object::model::persistence::CloudModel; + + let history = BlocklistAIHistoryModel::handle(ctx); + + // Extract data from conversation while the borrow is active, + // then drop it before using ctx mutably. + let context_data = { + let Some(conversation) = history + .as_ref(ctx) + .active_conversation(self.terminal_view_id) + else { + show_error_toast("No active conversation.".to_owned(), ctx); + return true; + }; + + let summary = conversation.progressive_summary().map(str::to_string); + let messages: Vec = conversation + .bedrock_message_history() + .iter() + .enumerate() + .map(|(i, msg)| { + let content_str = match &msg.content { + crate::ai::bedrock::convert::MessageContent::Text(t) => { + if t.len() > 500 { + format!("{}... ({} chars total)", &t[..500], t.len()) + } else { + t.clone() + } + } + crate::ai::bedrock::convert::MessageContent::ToolUse { + name, + tool_use_id, + .. + } => { + format!("ToolUse(name={name}, id={tool_use_id})") + } + crate::ai::bedrock::convert::MessageContent::ToolResult { + tool_use_id, + content, + is_error, + } => { + let truncated = if content.len() > 200 { + format!("{}...", &content[..200]) + } else { + content.clone() + }; + format!( + "ToolResult(id={tool_use_id}, err={is_error}): {truncated}" + ) + } + crate::ai::bedrock::convert::MessageContent::MultiPart( + parts, + ) => { + format!("MultiPart({} parts)", parts.len()) + } + }; + format!("[{i}] {:?}: {content_str}", msg.role) + }) + .collect(); + let msg_count = messages.len(); + (summary, messages, msg_count) + }; + + let (summary, messages, msg_count) = context_data; + + // Global rules (no lifetime conflict since CloudModel is separate) + let global_rules: Vec<(String, String)> = CloudModel::as_ref(ctx) + .get_all_objects_of_type::() + .filter_map(|fact| { + let AIFact::Memory(AIMemory { name, content, .. }) = + fact.model().string_model.clone(); + if content.is_empty() { + None + } else { + Some((name.unwrap_or_default(), content)) + } + }) + .collect(); + + let mut output = String::new(); + + output.push_str("=== GLOBAL RULES ==="); + output.push('\n'); + if global_rules.is_empty() { + output.push_str("(none)\n"); + } else { + for (name, content) in &global_rules { + output.push_str(&format!("### {name}\n{content}\n\n")); + } + } + output.push('\n'); + + output.push_str("=== PROGRESSIVE SUMMARY ==="); + output.push('\n'); + match &summary { + Some(s) => { + output.push_str(s); + output.push('\n'); + } + None => output.push_str("(none)\n"), + } + output.push('\n'); + + output.push_str(&format!( + "=== MESSAGE HISTORY ({msg_count} messages) ===\n" + )); + for line in &messages { + output.push_str(line); + output.push('\n'); + } + + ctx.clipboard() + .write(ClipboardContent::plain_text(output)); + + let window_id = ctx.window_id(); + ToastStack::handle(ctx).update(ctx, |toast_stack, ctx| { + let toast = DismissibleToast::default( + "Full context has been copied to the clipboard.".to_string(), + ); + toast_stack.add_ephemeral_toast(toast, window_id, ctx); + }); + } _ if slash_command_is_submitted_as_prompt(command) => { // These slash commands just send AI requests with the slash command text as a // prefix, and special handling is done downstream as an implementation detail