diff --git a/src/agent/agentic_loop.rs b/src/agent/agentic_loop.rs index 0e5bef9d..6cefdb42 100644 --- a/src/agent/agentic_loop.rs +++ b/src/agent/agentic_loop.rs @@ -152,6 +152,30 @@ pub async fn run_agentic_loop( // Call LLM let output = delegate.call_llm(reasoning, reason_ctx, iteration).await?; + match &output.result { + RespondResult::Text(text) => { + tracing::debug!( + iteration, + len = text.len(), + has_suggestions = text.contains(""), + response = %text, + "LLM text response" + ); + } + RespondResult::ToolCalls { + tool_calls, + content, + } => { + let names: Vec<&str> = tool_calls.iter().map(|tc| tc.name.as_str()).collect(); + tracing::debug!( + iteration, + tools = ?names, + has_content = content.is_some(), + "LLM tool_calls response" + ); + } + } + match output.result { RespondResult::Text(text) => { // Tool intent nudge: if the LLM says "let me search..." without diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs index d0ae98ad..a91f59a6 100644 --- a/src/agent/dispatcher.rs +++ b/src/agent/dispatcher.rs @@ -1051,6 +1051,54 @@ fn strip_internal_tool_call_text(text: &str) -> String { } } +/// Extract `["...","..."]` from a response string. +/// +/// Returns `(cleaned_text, suggestions)`. The `` block is stripped +/// from the text regardless of whether the JSON inside parses successfully. +/// Only the **last** `` block is used (closest to end of response). +/// Blocks inside markdown code fences are ignored. +pub(crate) fn extract_suggestions(text: &str) -> (String, Vec) { + use regex::Regex; + use std::sync::LazyLock; + + static RE: LazyLock = LazyLock::new(|| { + Regex::new(r"(?s)\s*(.*?)\s*").expect("valid regex") // safety: constant pattern + }); + + // Find the position of the last closing code fence to avoid matching inside code blocks + let last_code_fence = text.rfind("```").unwrap_or(0); + + // Find all matches, take the last one that's after the last code fence + let mut best_match: Option> = None; + let mut best_capture: Option = None; + for caps in RE.captures_iter(text) { + if let (Some(full), Some(inner)) = (caps.get(0), caps.get(1)) + && full.start() >= last_code_fence + { + best_match = Some(full); + best_capture = Some(inner.as_str().to_string()); + } + } + + let Some(full) = best_match else { + return (text.to_string(), Vec::new()); + }; + + let cleaned = format!("{}{}", &text[..full.start()], &text[full.end()..]); // safety: regex match boundaries are valid UTF-8 + let cleaned = cleaned.trim().to_string(); + + // Parse the JSON array + let suggestions = best_capture + .and_then(|json| serde_json::from_str::>(&json).ok()) + .unwrap_or_default() + .into_iter() + .filter(|s| !s.trim().is_empty() && s.len() <= 80) + .take(3) + .collect(); + + (cleaned, suggestions) +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -2197,6 +2245,55 @@ mod tests { assert_eq!(result, input); } + #[test] + fn test_extract_suggestions_basic() { + let input = "Here is my answer.\n[\"Check logs\", \"Deploy\"]"; + let (text, suggestions) = super::extract_suggestions(input); + assert_eq!(text, "Here is my answer."); // safety: test + assert_eq!(suggestions, vec!["Check logs", "Deploy"]); // safety: test + } + + #[test] + fn test_extract_suggestions_no_tag() { + let input = "Just a plain response."; + let (text, suggestions) = super::extract_suggestions(input); + assert_eq!(text, "Just a plain response."); // safety: test + assert!(suggestions.is_empty()); // safety: test + } + + #[test] + fn test_extract_suggestions_malformed_json() { + let input = "Answer.\nnot json"; + let (text, suggestions) = super::extract_suggestions(input); + assert_eq!(text, "Answer."); // safety: test + assert!(suggestions.is_empty()); // safety: test + } + + #[test] + fn test_extract_suggestions_inside_code_fence() { + let input = "```\n[\"foo\"]\n```"; + let (text, suggestions) = super::extract_suggestions(input); + // The tag is inside a code fence, so it should not be extracted + assert_eq!(text, input); // safety: test + assert!(suggestions.is_empty()); // safety: test + } + + #[test] + fn test_extract_suggestions_after_code_fence() { + let input = "```\ncode\n```\nAnswer.\n[\"foo\"]"; + let (text, suggestions) = super::extract_suggestions(input); + assert_eq!(text, "```\ncode\n```\nAnswer."); // safety: test + assert_eq!(suggestions, vec!["foo"]); // safety: test + } + + #[test] + fn test_extract_suggestions_filters_long() { + let long = "x".repeat(81); + let input = format!("Answer.\n[\"{}\", \"ok\"]", long); + let (_, suggestions) = super::extract_suggestions(&input); + assert_eq!(suggestions, vec!["ok"]); // safety: test + } + #[test] fn test_tool_error_format_includes_tool_name() { // Regression test for issue #487: tool errors sent to the LLM should diff --git a/src/agent/thread_ops.rs b/src/agent/thread_ops.rs index f3673781..3438d1cd 100644 --- a/src/agent/thread_ops.rs +++ b/src/agent/thread_ops.rs @@ -420,6 +420,10 @@ impl Agent { // Complete, fail, or request approval match result { Ok(AgenticLoopResult::Response(response)) => { + // Extract from response text before user sees it + let (response, suggestions) = + crate::agent::dispatcher::extract_suggestions(&response); + // Hook: TransformResponse — allow hooks to modify or reject the final response let response = { let event = crate::hooks::HookEvent::ResponseTransform { @@ -473,6 +477,18 @@ impl Agent { ) .await; + // Send suggestions after response (best-effort, rendered by web gateway) + if !suggestions.is_empty() { + let _ = self + .channels + .send_status( + &message.channel, + StatusUpdate::Suggestions { suggestions }, + &message.metadata, + ) + .await; + } + Ok(SubmissionResult::response(response)) } Ok(AgenticLoopResult::NeedApproval { pending }) => { @@ -1334,6 +1350,8 @@ impl Agent { match result { Ok(AgenticLoopResult::Response(response)) => { + let (response, suggestions) = + crate::agent::dispatcher::extract_suggestions(&response); thread.complete_turn(&response); let (turn_number, tool_calls) = thread .turns @@ -1364,6 +1382,16 @@ impl Agent { &message.metadata, ) .await; + if !suggestions.is_empty() { + let _ = self + .channels + .send_status( + &message.channel, + StatusUpdate::Suggestions { suggestions }, + &message.metadata, + ) + .await; + } Ok(SubmissionResult::response(response)) } Ok(AgenticLoopResult::NeedApproval { diff --git a/src/channels/channel.rs b/src/channels/channel.rs index 938b1f4f..1fc76fd7 100644 --- a/src/channels/channel.rs +++ b/src/channels/channel.rs @@ -238,6 +238,8 @@ pub enum StatusUpdate { /// Optional workspace path where the image was saved. path: Option, }, + /// Suggested follow-up messages for the user. + Suggestions { suggestions: Vec }, } impl StatusUpdate { diff --git a/src/channels/repl.rs b/src/channels/repl.rs index 33adc23f..230d5e92 100644 --- a/src/channels/repl.rs +++ b/src/channels/repl.rs @@ -607,6 +607,9 @@ impl Channel for ReplChannel { eprintln!("\x1b[36m [image generated]\x1b[0m"); } } + StatusUpdate::Suggestions { .. } => { + // Suggestions are only rendered by the web gateway + } } Ok(()) } diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index 914ffbf0..1529da41 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -1664,7 +1664,9 @@ impl WasmChannel { .await; let pairing_store = self.pairing_store.clone(); - let wit_update = status_to_wit(status, metadata); + let Some(wit_update) = status_to_wit(status, metadata) else { + return Ok(()); + }; let result = tokio::time::timeout(timeout, async move { tokio::task::spawn_blocking(move || { @@ -1833,7 +1835,9 @@ impl WasmChannel { .await; let pairing_store = self.pairing_store.clone(); let callback_timeout = self.runtime.config().callback_timeout; - let wit_update = status_to_wit(&status, metadata); + let Some(wit_update) = status_to_wit(&status, metadata) else { + return Ok(()); + }; let handle = tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(4)); @@ -2704,10 +2708,13 @@ fn truncate_status_text(input: &str, max_chars: usize) -> String { } } -fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_channel::StatusUpdate { +fn status_to_wit( + status: &StatusUpdate, + metadata: &serde_json::Value, +) -> Option { let metadata_json = serde_json::to_string(metadata).unwrap_or_default(); - match status { + Some(match status { StatusUpdate::Thinking(msg) => wit_channel::StatusUpdate { status: wit_channel::StatusType::Thinking, message: msg.clone(), @@ -2827,7 +2834,9 @@ fn status_to_wit(status: &StatusUpdate, metadata: &serde_json::Value) -> wit_cha }, metadata_json, }, - } + // Suggestions are web-gateway-only; skip for WASM channels + StatusUpdate::Suggestions { .. } => return None, + }) } /// Clone a WIT StatusUpdate (the generated type doesn't derive Clone). @@ -3556,7 +3565,8 @@ mod tests { let wit = status_to_wit( &crate::channels::StatusUpdate::Thinking("Processing...".into()), &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3574,7 +3584,8 @@ mod tests { let wit = status_to_wit( &crate::channels::StatusUpdate::Status("Done".into()), &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!(wit.status, super::wit_channel::StatusType::Done)); } @@ -3589,14 +3600,16 @@ mod tests { let wit = status_to_wit( &crate::channels::StatusUpdate::Status("done".into()), &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!(wit.status, super::wit_channel::StatusType::Done)); // with whitespace let wit = status_to_wit( &crate::channels::StatusUpdate::Status(" Done ".into()), &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!(wit.status, super::wit_channel::StatusType::Done)); } @@ -3608,7 +3621,8 @@ mod tests { let wit = status_to_wit( &crate::channels::StatusUpdate::Status("Interrupted".into()), &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3626,7 +3640,8 @@ mod tests { let wit = status_to_wit( &crate::channels::StatusUpdate::Status("interrupted".into()), &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, super::wit_channel::StatusType::Interrupted @@ -3636,7 +3651,8 @@ mod tests { let wit = status_to_wit( &crate::channels::StatusUpdate::Status(" Interrupted ".into()), &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, super::wit_channel::StatusType::Interrupted @@ -3651,7 +3667,8 @@ mod tests { let wit = status_to_wit( &crate::channels::StatusUpdate::Status("Awaiting approval".into()), &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!(wit.status, super::wit_channel::StatusType::Status)); assert_eq!(wit.message, "Awaiting approval"); @@ -3670,7 +3687,8 @@ mod tests { setup_url: None, }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3690,7 +3708,8 @@ mod tests { name: "http_request".to_string(), }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3712,7 +3731,8 @@ mod tests { parameters: None, }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3734,7 +3754,8 @@ mod tests { parameters: None, }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3754,7 +3775,8 @@ mod tests { preview: "{".to_string() + "\"temperature\": 22}", }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3775,7 +3797,8 @@ mod tests { preview: long_preview, }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3796,7 +3819,8 @@ mod tests { browse_url: "https://example.com/jobs/job-1".to_string(), }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3818,7 +3842,8 @@ mod tests { message: "Token saved".to_string(), }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3840,7 +3865,8 @@ mod tests { message: "Invalid token".to_string(), }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3863,7 +3889,8 @@ mod tests { parameters: serde_json::json!({"url": "https://api.weather.test"}), }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, @@ -3887,7 +3914,8 @@ mod tests { parameters: serde_json::json!({"url": "https://api.weather.test"}), }, &metadata, - ); + ) + .unwrap(); // safety: test assert!(matches!( wit.status, diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs index 4c575caf..0d970569 100644 --- a/src/channels/web/mod.rs +++ b/src/channels/web/mod.rs @@ -397,6 +397,10 @@ impl Channel for GatewayChannel { StatusUpdate::ImageGenerated { data_url, path } => SseEvent::ImageGenerated { data_url, path, + thread_id: thread_id.clone(), + }, + StatusUpdate::Suggestions { suggestions } => SseEvent::Suggestions { + suggestions, thread_id, }, }; diff --git a/src/channels/web/sse.rs b/src/channels/web/sse.rs index 6d9c4142..306576b9 100644 --- a/src/channels/web/sse.rs +++ b/src/channels/web/sse.rs @@ -143,6 +143,7 @@ impl SseManager { SseEvent::JobResult { .. } => "job_result", SseEvent::Heartbeat => "heartbeat", SseEvent::ImageGenerated { .. } => "image_generated", + SseEvent::Suggestions { .. } => "suggestions", SseEvent::ExtensionStatus { .. } => "extension_status", }; Ok(Event::default().event(event_type).data(data)) diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 081ae60d..a981d567 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -19,6 +19,7 @@ let _loadThreadsTimer = null; const JOB_EVENTS_CAP = 500; const MEMORY_SEARCH_QUERY_MAX_LENGTH = 100; let stagedImages = []; +let _ghostSuggestion = ''; // --- Slash Commands --- @@ -286,9 +287,18 @@ function connectSSE() { if (data.thread_id) debouncedLoadThreads(); return; } + clearSuggestionChips(); showActivityThinking(data.message); }); + eventSource.addEventListener('suggestions', (e) => { + const data = JSON.parse(e.data); + if (!isCurrentThread(data.thread_id)) return; + if (data.suggestions && data.suggestions.length > 0) { + showSuggestionChips(data.suggestions); + } + }); + eventSource.addEventListener('tool_started', (e) => { const data = JSON.parse(e.data); if (!isCurrentThread(data.thread_id)) return; @@ -423,9 +433,59 @@ function isCurrentThread(threadId) { return threadId === currentThreadId; } +// --- Suggestion Chips --- + +function showSuggestionChips(suggestions) { + // Clear previous chips/ghost without restoring placeholder (we'll set it below) + _ghostSuggestion = ''; + const container = document.getElementById('suggestion-chips'); + container.innerHTML = ''; + const ghost = document.getElementById('ghost-text'); + ghost.style.display = 'none'; + const wrapper = document.querySelector('.chat-input-wrapper'); + if (wrapper) wrapper.classList.remove('has-ghost'); + + _ghostSuggestion = suggestions[0] || ''; + const input = document.getElementById('chat-input'); + suggestions.forEach(text => { + const chip = document.createElement('button'); + chip.className = 'suggestion-chip'; + chip.textContent = text; + chip.addEventListener('click', () => { + input.value = text; + clearSuggestionChips(); + autoResizeTextarea(input); + input.focus(); + sendMessage(); + }); + container.appendChild(chip); + }); + container.style.display = 'flex'; + // Show first suggestion as ghost text in the input so user knows Tab works + if (_ghostSuggestion && input.value === '') { + ghost.textContent = _ghostSuggestion; + ghost.style.display = 'block'; + input.closest('.chat-input-wrapper').classList.add('has-ghost'); + } +} + +function clearSuggestionChips() { + _ghostSuggestion = ''; + const container = document.getElementById('suggestion-chips'); + if (container) { + container.innerHTML = ''; + container.style.display = 'none'; + } + const ghost = document.getElementById('ghost-text'); + if (ghost) ghost.style.display = 'none'; + const wrapper = document.querySelector('.chat-input-wrapper'); + if (wrapper) wrapper.classList.remove('has-ghost'); +} + // --- Chat --- function sendMessage() { + clearSuggestionChips(); const input = document.getElementById('chat-input'); if (!currentThreadId) { console.warn('sendMessage: no thread selected, ignoring'); @@ -1334,6 +1394,7 @@ function showAuthCardError(extensionName, message) { } function loadHistory(before) { + clearSuggestionChips(); let historyUrl = '/api/chat/history?limit=50'; if (currentThreadId) { historyUrl += '&thread_id=' + encodeURIComponent(currentThreadId); @@ -1629,6 +1690,7 @@ function switchToAssistant() { } function switchThread(threadId) { + clearSuggestionChips(); finalizeActivityGroup(); currentThreadId = threadId; unreadThreads.delete(threadId); @@ -1661,6 +1723,15 @@ chatInput.addEventListener('keydown', (e) => { const acEl = document.getElementById('slash-autocomplete'); const acVisible = acEl && acEl.style.display !== 'none'; + // Accept first suggestion with Tab (plain Tab only, not Shift+Tab) + if (e.key === 'Tab' && !e.shiftKey && !acVisible && _ghostSuggestion && chatInput.value === '') { + e.preventDefault(); + chatInput.value = _ghostSuggestion; + clearSuggestionChips(); + autoResizeTextarea(chatInput); + return; + } + if (acVisible) { const items = acEl.querySelectorAll('.slash-ac-item'); if (e.key === 'ArrowDown') { @@ -1697,6 +1768,16 @@ chatInput.addEventListener('keydown', (e) => { chatInput.addEventListener('input', () => { autoResizeTextarea(chatInput); filterSlashCommands(chatInput.value); + const ghost = document.getElementById('ghost-text'); + const wrapper = chatInput.closest('.chat-input-wrapper'); + if (chatInput.value !== '') { + ghost.style.display = 'none'; + wrapper.classList.remove('has-ghost'); + } else if (_ghostSuggestion) { + ghost.textContent = _ghostSuggestion; + ghost.style.display = 'block'; + wrapper.classList.add('has-ghost'); + } }); chatInput.addEventListener('blur', () => { // Small delay so mousedown on autocomplete item fires first diff --git a/src/channels/web/static/index.html b/src/channels/web/static/index.html index e0a4ae07..4e1074d0 100644 --- a/src/channels/web/static/index.html +++ b/src/channels/web/static/index.html @@ -155,9 +155,13 @@
+
- +
+ +
+
diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index b6e1cbdf..0ba5766f 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -1362,8 +1362,14 @@ body { min-height: 56px; } -.chat-input textarea { +.chat-input-wrapper { + position: relative; flex: 1; + display: flex; +} + +.chat-input-wrapper textarea { + width: 100%; padding: 8px 12px; background: var(--bg); border: 1px solid var(--border); @@ -1376,17 +1382,66 @@ body { max-height: 120px; } -.chat-input textarea:focus { +.ghost-text { + position: absolute; + top: 0; + left: 0; + right: 0; + padding: 8px 12px; + font-size: 14px; + font-family: inherit; + color: var(--text-secondary); + opacity: 0.5; + pointer-events: none; + white-space: pre-wrap; + overflow: hidden; + display: none; + z-index: 1; +} + +/* Hide native placeholder when ghost text is visible */ +.chat-input-wrapper.has-ghost textarea::placeholder { + color: transparent; +} + +.chat-input-wrapper textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1); } -.chat-input textarea:disabled { +.chat-input-wrapper textarea:disabled { opacity: 0.5; cursor: not-allowed; } +.suggestion-chips { + display: none; + flex-wrap: wrap; + gap: 8px; + padding: 8px 16px; + border-top: 1px solid var(--border); +} + +.suggestion-chip { + padding: 6px 14px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 16px; + color: var(--text-secondary); + font-size: 13px; + font-family: inherit; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} + +.suggestion-chip:hover { + background: var(--accent); + color: #09090b; + border-color: var(--accent); +} + .chat-input button { padding: 8px 20px; background: var(--accent); @@ -1416,7 +1471,7 @@ body { } /* Keyboard accessibility focus rings */ -.chat-input textarea:focus-visible, +.chat-input-wrapper textarea:focus-visible, .chat-input button:focus-visible, .tab-bar button:focus-visible, .tree-row:focus-visible { @@ -3824,7 +3879,7 @@ mark { min-height: 52px; } - .chat-input textarea { + .chat-input-wrapper textarea { min-height: 36px; max-height: 100px; } diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index b2355959..b8690b78 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -242,6 +242,14 @@ pub enum SseEvent { thread_id: Option, }, + /// Suggested follow-up messages for the user. + #[serde(rename = "suggestions")] + Suggestions { + suggestions: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + thread_id: Option, + }, + /// Extension activation status change (WASM channels). #[serde(rename = "extension_status")] ExtensionStatus { @@ -707,6 +715,7 @@ impl WsServerMessage { SseEvent::JobStatus { .. } => "job_status", SseEvent::JobResult { .. } => "job_result", SseEvent::ImageGenerated { .. } => "image_generated", + SseEvent::Suggestions { .. } => "suggestions", SseEvent::ExtensionStatus { .. } => "extension_status", }; let data = serde_json::to_value(event).unwrap_or(serde_json::Value::Null); diff --git a/src/llm/nearai_chat.rs b/src/llm/nearai_chat.rs index da99c080..0c0335bd 100644 --- a/src/llm/nearai_chat.rs +++ b/src/llm/nearai_chat.rs @@ -270,10 +270,6 @@ impl NearAiChatProvider { reason: format!("Failed to read response body: {}", e), })?; - if tracing::enabled!(tracing::Level::DEBUG) { - tracing::debug!("NEAR AI Chat response status: {}", status); - } - // Log response body only at TRACE level to avoid exposing sensitive content // (user-generated data, tool outputs, leaked secrets) in DEBUG logs if tracing::enabled!(tracing::Level::TRACE) { diff --git a/src/llm/reasoning.rs b/src/llm/reasoning.rs index 3a654fed..f2294f58 100644 --- a/src/llm/reasoning.rs +++ b/src/llm/reasoning.rs @@ -902,7 +902,8 @@ Example: ## Guidelines - Be concise and direct - Use markdown formatting where helpful -- For code, use appropriate code blocks with language tags{} +- For code, use appropriate code blocks with language tags +- ALWAYS end your response with a tag containing a JSON array of 1-3 short follow-up commands. Each suggestion must read as something the USER would type to instruct YOU. Write them in the user's voice as direct commands, not as requests FROM you TO the user. Do NOT repeat or rephrase content already in your response. Example: ["Suggest dinner spots in my area", "Find a quick recipe for pasta"] Keep each under 80 characters.{} ## Safety - You have no independent goals. Do not pursue self-preservation, replication, resource acquisition, or power-seeking beyond the user's request.