feat(commands): expose runtime web toggle

AC:
- :web on/off updates tool exposure immediately and persists the toggle.
- owlen providers web --enable/--disable reflects the same setting and reports current status.
- Help/docs cover the new toggle paths and troubleshooting guidance.

Tests:
- cargo test -p owlen-cli
- cargo test -p owlen-core toggling_web_search_updates_config_and_registry
This commit is contained in:
2025-10-24 13:23:47 +02:00
parent 7e2c6ea037
commit e813736b47
7 changed files with 370 additions and 0 deletions

View File

@@ -35,6 +35,8 @@ pub enum ProvidersCommand {
/// Provider identifier to disable.
provider: String,
},
/// Enable or disable the web.search tool exposure.
Web(WebCommand),
}
/// Arguments for the `owlen models` command.
@@ -45,12 +47,36 @@ pub struct ModelsArgs {
pub provider: Option<String>,
}
/// Arguments for managing the web.search tool exposure.
#[derive(Debug, Args)]
pub struct WebCommand {
/// Enable the web.search tool and allow remote lookups.
#[arg(long, conflicts_with = "disable")]
enable: bool,
/// Disable the web.search tool to keep sessions local-only.
#[arg(long, conflicts_with = "enable")]
disable: bool,
}
impl WebCommand {
fn desired_state(&self) -> Option<bool> {
if self.enable {
Some(true)
} else if self.disable {
Some(false)
} else {
None
}
}
}
pub async fn run_providers_command(command: ProvidersCommand) -> Result<()> {
match command {
ProvidersCommand::List => list_providers(),
ProvidersCommand::Status { provider } => status_providers(provider.as_deref()).await,
ProvidersCommand::Enable { provider } => toggle_provider(&provider, true),
ProvidersCommand::Disable { provider } => toggle_provider(&provider, false),
ProvidersCommand::Web(args) => handle_web_command(args),
}
}
@@ -206,6 +232,70 @@ fn verify_provider_filter(config: &Config, filter: Option<&str>) -> Result<()> {
Ok(())
}
fn handle_web_command(args: WebCommand) -> Result<()> {
let mut config = tui_config::try_load_config().unwrap_or_default();
let initial = web_tool_enabled(&config);
if let Some(desired) = args.desired_state() {
apply_web_toggle(&mut config, desired);
tui_config::save_config(&config).map_err(|err| anyhow!(err))?;
if initial == desired {
println!(
"Web search tool already {}.",
if desired { "enabled" } else { "disabled" }
);
} else {
println!(
"Web search tool {}.",
if desired { "enabled" } else { "disabled" }
);
}
println!(
"Remote search is {}.",
if config.privacy.enable_remote_search {
"enabled"
} else {
"disabled"
}
);
} else {
println!(
"Web search tool is {}.",
if initial { "enabled" } else { "disabled" }
);
println!(
"Remote search is {}.",
if config.privacy.enable_remote_search {
"enabled"
} else {
"disabled"
}
);
}
Ok(())
}
fn apply_web_toggle(config: &mut Config, enabled: bool) {
config.tools.web_search.enabled = enabled;
config.privacy.enable_remote_search = enabled;
if enabled
&& !config
.security
.allowed_tools
.iter()
.any(|tool| tool.eq_ignore_ascii_case("web_search"))
{
config.security.allowed_tools.push("web_search".to_string());
}
}
fn web_tool_enabled(config: &Config) -> bool {
config.tools.web_search.enabled && config.privacy.enable_remote_search
}
fn toggle_provider(provider: &str, enable: bool) -> Result<()> {
let mut config = tui_config::try_load_config().unwrap_or_default();
let canonical = canonical_provider_id(provider);
@@ -649,3 +739,56 @@ impl ProviderStatusRow {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn apply_web_toggle_updates_flags_and_allowed_tools() {
let mut config = Config::default();
config.privacy.enable_remote_search = false;
config.tools.web_search.enabled = false;
config.security.allowed_tools.clear();
apply_web_toggle(&mut config, true);
assert!(config.tools.web_search.enabled);
assert!(config.privacy.enable_remote_search);
assert_eq!(
1,
config
.security
.allowed_tools
.iter()
.filter(|tool| tool.eq_ignore_ascii_case("web_search"))
.count()
);
apply_web_toggle(&mut config, false);
assert!(!config.tools.web_search.enabled);
assert!(!config.privacy.enable_remote_search);
}
#[test]
fn apply_web_toggle_does_not_duplicate_allowed_entries() {
let mut config = Config::default();
config
.security
.allowed_tools
.retain(|tool| !tool.eq_ignore_ascii_case("web_search"));
config.security.allowed_tools.push("web_search".to_string());
apply_web_toggle(&mut config, true);
apply_web_toggle(&mut config, true);
assert_eq!(
1,
config
.security
.allowed_tools
.iter()
.filter(|tool| tool.eq_ignore_ascii_case("web_search"))
.count()
);
}
}

View File

@@ -0,0 +1,140 @@
use std::{any::Any, collections::HashMap, sync::Arc};
use async_trait::async_trait;
use futures::stream;
use owlen_core::{
ChatStream, Provider, Result,
config::Config,
llm::ProviderConfig,
session::SessionController,
storage::StorageManager,
types::{ChatRequest, ChatResponse, Message, ModelInfo},
ui::NoOpUiController,
};
use serde_json::Value;
use tempfile::tempdir;
struct StubCloudProvider;
#[async_trait]
impl Provider for StubCloudProvider {
fn name(&self) -> &str {
"ollama_cloud"
}
async fn list_models(&self) -> Result<Vec<ModelInfo>> {
Ok(vec![ModelInfo {
id: "stub-cloud-model".to_string(),
name: "Stub Cloud Model".to_string(),
description: Some("Stub model for web toggle tests".to_string()),
provider: self.name().to_string(),
context_window: Some(8192),
capabilities: vec!["chat".to_string(), "tools".to_string()],
supports_tools: true,
}])
}
async fn send_prompt(&self, _request: ChatRequest) -> Result<ChatResponse> {
Ok(ChatResponse {
message: Message::assistant(String::new()),
usage: None,
is_streaming: false,
is_final: true,
})
}
async fn stream_prompt(&self, _request: ChatRequest) -> Result<ChatStream> {
Ok(Box::pin(stream::empty()))
}
async fn health_check(&self) -> Result<()> {
Ok(())
}
fn as_any(&self) -> &(dyn Any + Send + Sync) {
self
}
}
#[tokio::test(flavor = "multi_thread")]
async fn toggling_web_search_updates_config_and_registry() {
let temp_dir = tempdir().expect("temp dir");
let storage = Arc::new(
StorageManager::with_database_path(temp_dir.path().join("owlen-tests.db"))
.await
.expect("storage"),
);
let mut config = Config::default();
config.privacy.encrypt_local_data = false;
config.general.default_model = Some("stub-cloud-model".into());
config.general.default_provider = "ollama_cloud".into();
let mut provider_cfg = ProviderConfig {
enabled: true,
provider_type: "ollama_cloud".to_string(),
base_url: Some("https://ollama.com".to_string()),
api_key: Some("test-key".to_string()),
api_key_env: None,
extra: HashMap::new(),
};
provider_cfg.extra.insert(
"web_search_endpoint".into(),
Value::String("/api/web_search".into()),
);
config.providers.insert("ollama_cloud".into(), provider_cfg);
let provider: Arc<dyn Provider> = Arc::new(StubCloudProvider);
let ui = Arc::new(NoOpUiController);
let mut session = SessionController::new(provider, config, storage, ui, false, None)
.await
.expect("session controller");
assert!(
!session
.tool_registry()
.tools()
.iter()
.any(|tool| tool == "web_search"),
"web_search should be disabled by default"
);
session
.set_tool_enabled("web_search", true)
.await
.expect("enable web_search");
{
let cfg = session.config_async().await;
assert!(cfg.tools.web_search.enabled);
assert!(cfg.privacy.enable_remote_search);
}
assert!(
session
.tool_registry()
.tools()
.iter()
.any(|tool| tool == "web_search"),
"web_search should be registered when enabled"
);
session
.set_tool_enabled("web_search", false)
.await
.expect("disable web_search");
{
let cfg = session.config_async().await;
assert!(!cfg.tools.web_search.enabled);
assert!(!cfg.privacy.enable_remote_search);
}
assert!(
!session
.tool_registry()
.tools()
.iter()
.any(|tool| tool == "web_search"),
"web_search should be removed when disabled"
);
}

View File

@@ -1661,6 +1661,16 @@ impl ChatApp {
self.error = None;
}
async fn set_web_tool_enabled(&mut self, enabled: bool) -> Result<()> {
self.controller
.set_tool_enabled("web_search", enabled)
.await
.map_err(|err| anyhow!(err))?;
config::save_config(&self.controller.config())?;
self.refresh_usage_summary().await?;
Ok(())
}
/// Override the status line with a custom message.
pub fn set_status_message<S: Into<String>>(&mut self, status: S) {
self.status = status.into();
@@ -7459,6 +7469,78 @@ impl ChatApp {
self.set_input_mode(InputMode::Normal);
return Ok(AppState::Running);
}
"web" => {
let action =
args.get(0).map(|value| value.to_ascii_lowercase());
match action.as_deref() {
Some("on") | Some("enable") => {
match self.set_web_tool_enabled(true).await {
Ok(_) => {
self.status = "Web search enabled; remote lookups allowed."
.to_string();
self.error = None;
self.push_toast(
ToastLevel::Info,
"Web search enabled; remote lookups allowed.",
);
}
Err(err) => {
self.status =
"Failed to enable web search".to_string();
self.error = Some(format!(
"Failed to enable web search: {}",
err
));
}
}
}
Some("off") | Some("disable") => {
match self.set_web_tool_enabled(false).await {
Ok(_) => {
self.status =
"Web search disabled; staying local."
.to_string();
self.error = None;
self.push_toast(
ToastLevel::Warning,
"Web search disabled; staying local.",
);
}
Err(err) => {
self.status =
"Failed to disable web search".to_string();
self.error = Some(format!(
"Failed to disable web search: {}",
err
));
}
}
}
Some("status") | None => {
{
let config = self.controller.config();
let enabled = config.tools.web_search.enabled
&& config.privacy.enable_remote_search;
if enabled {
self.status =
"Web search is enabled.".to_string();
} else {
self.status =
"Web search is disabled.".to_string();
}
}
self.error = None;
}
_ => {
self.status = "Usage: :web <on|off|status>".to_string();
self.error =
Some("Usage: :web <on|off|status>".to_string());
}
}
self.command_palette.clear();
self.set_input_mode(InputMode::Normal);
return Ok(AppState::Running);
}
"privacy-enable" => {
if let Some(tool) = args.first() {
match self.controller.set_tool_enabled(tool, true).await {

View File

@@ -4244,6 +4244,7 @@ fn render_help(frame: &mut Frame<'_>, app: &ChatApp) {
Line::from(" :provider <name> [auto|local|cloud] → switch provider or set mode"),
Line::from(" :models --local | --cloud → focus models by scope"),
Line::from(" :cloud setup [--force-cloud-base-url] → configure Ollama Cloud"),
Line::from(" :web on|off → expose or hide the web.search tool"),
Line::from(""),
Line::from(vec![Span::styled(
"SESSION MANAGEMENT",