372 lines
13 KiB
Rust
372 lines
13 KiB
Rust
//! Agent configuration + OpenAI-compatible LLM client.
|
|
//!
|
|
//! Port of `agent_llm.c` and the agent fields of `settings.c` from the C
|
|
//! project. The configuration (providers, active provider, model, max
|
|
//! iterations, and the "Sovereign Browser Skill" fields) is persisted as a
|
|
//! single JSON blob in the per-profile `kv_store` under `agent.config`.
|
|
//!
|
|
//! The HTTP calls are *blocking* (reqwest::blocking) — callers must run
|
|
//! them off the GTK main thread (the agent loop runs in its own thread and
|
|
//! the `sovereign://agents/models` route spawns a worker thread).
|
|
|
|
use once_cell::sync::Lazy;
|
|
use std::sync::Mutex;
|
|
use std::time::Duration;
|
|
|
|
/// Default system prompt / Sovereign Browser Skill template.
|
|
/// Mirrors `SETTINGS_AGENT_SYSTEM_PROMPT_DEFAULT` (settings.h:39).
|
|
pub const AGENT_SYSTEM_PROMPT_DEFAULT: &str = "You are an AI assistant embedded in a web browser. \
|
|
You have access to browser automation tools (navigate, snapshot, click, fill, etc.) and system \
|
|
tools (filesystem read/write, shell command execution). Use the snapshot tool to understand page \
|
|
content, then interact with elements using refs (e.g. @e1). You can read and write files and run \
|
|
shell commands. Be concise in your responses. When a task is complete, summarize what you did.";
|
|
|
|
pub const AGENT_SKILL_NAME_DEFAULT: &str = "Sovereign Browser Default";
|
|
pub const AGENT_SKILL_DESCRIPTION_DEFAULT: &str = "Default agent skill for sovereign_browser";
|
|
pub const AGENT_SKILL_REQUIRES_TOOLS_DEFAULT: &str = "browser, fs, shell";
|
|
pub const AGENT_MAX_ITERATIONS_DEFAULT: i32 = 100;
|
|
|
|
const CONFIG_KEY: &str = "agent.config";
|
|
const LEGACY_PROVIDER_KEY: &str = "agent.llm_provider";
|
|
|
|
/// One OpenAI-compatible LLM endpoint.
|
|
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
|
pub struct LlmProvider {
|
|
#[serde(default)]
|
|
pub name: String,
|
|
#[serde(default)]
|
|
pub base_url: String,
|
|
#[serde(default)]
|
|
pub api_key: String,
|
|
/// Model ids last fetched from `{base_url}/models` (for the dropdown).
|
|
#[serde(default)]
|
|
pub models: Vec<String>,
|
|
}
|
|
|
|
fn default_max_iterations() -> i32 {
|
|
AGENT_MAX_ITERATIONS_DEFAULT
|
|
}
|
|
|
|
/// The full agent configuration.
|
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
|
pub struct AgentConfig {
|
|
#[serde(default)]
|
|
pub providers: Vec<LlmProvider>,
|
|
/// Index into `providers`, or -1 when none.
|
|
#[serde(default)]
|
|
pub active_provider: i32,
|
|
#[serde(default)]
|
|
pub model: String,
|
|
#[serde(default = "default_max_iterations")]
|
|
pub max_iterations: i32,
|
|
#[serde(default)]
|
|
pub skill_name: String,
|
|
#[serde(default)]
|
|
pub skill_description: String,
|
|
#[serde(default)]
|
|
pub skill_template: String,
|
|
#[serde(default)]
|
|
pub skill_requires_tools: String,
|
|
}
|
|
|
|
impl Default for AgentConfig {
|
|
fn default() -> Self {
|
|
AgentConfig {
|
|
providers: vec![LlmProvider {
|
|
name: "Default".to_string(),
|
|
..Default::default()
|
|
}],
|
|
active_provider: 0,
|
|
model: String::new(),
|
|
max_iterations: AGENT_MAX_ITERATIONS_DEFAULT,
|
|
skill_name: String::new(),
|
|
skill_description: String::new(),
|
|
skill_template: String::new(),
|
|
skill_requires_tools: String::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl AgentConfig {
|
|
/// The active provider, if any.
|
|
pub fn active(&self) -> Option<&LlmProvider> {
|
|
usize::try_from(self.active_provider)
|
|
.ok()
|
|
.and_then(|i| self.providers.get(i))
|
|
}
|
|
|
|
pub fn active_mut(&mut self) -> Option<&mut LlmProvider> {
|
|
usize::try_from(self.active_provider)
|
|
.ok()
|
|
.and_then(move |i| self.providers.get_mut(i))
|
|
}
|
|
|
|
pub fn base_url(&self) -> String {
|
|
self.active().map(|p| p.base_url.clone()).unwrap_or_default()
|
|
}
|
|
|
|
pub fn api_key(&self) -> String {
|
|
self.active().map(|p| p.api_key.clone()).unwrap_or_default()
|
|
}
|
|
|
|
/// Effective max iterations (falls back to the default when unset).
|
|
pub fn effective_max_iterations(&self) -> i32 {
|
|
if self.max_iterations > 0 {
|
|
self.max_iterations
|
|
} else {
|
|
AGENT_MAX_ITERATIONS_DEFAULT
|
|
}
|
|
}
|
|
|
|
pub fn effective_skill_name(&self) -> String {
|
|
non_empty_or(&self.skill_name, AGENT_SKILL_NAME_DEFAULT)
|
|
}
|
|
|
|
pub fn effective_skill_description(&self) -> String {
|
|
non_empty_or(&self.skill_description, AGENT_SKILL_DESCRIPTION_DEFAULT)
|
|
}
|
|
|
|
pub fn effective_skill_template(&self) -> String {
|
|
non_empty_or(&self.skill_template, AGENT_SYSTEM_PROMPT_DEFAULT)
|
|
}
|
|
|
|
pub fn effective_skill_requires_tools(&self) -> String {
|
|
non_empty_or(&self.skill_requires_tools, AGENT_SKILL_REQUIRES_TOOLS_DEFAULT)
|
|
}
|
|
|
|
/// Keep `active_provider` pointing at a valid entry.
|
|
fn normalize(&mut self) {
|
|
if self.providers.is_empty() {
|
|
self.active_provider = -1;
|
|
} else if self.active_provider < 0 || self.active_provider as usize >= self.providers.len() {
|
|
self.active_provider = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn non_empty_or(v: &str, default: &str) -> String {
|
|
if v.trim().is_empty() {
|
|
default.to_string()
|
|
} else {
|
|
v.to_string()
|
|
}
|
|
}
|
|
|
|
/// In-memory cache of the config (None = not loaded yet).
|
|
static G_CONFIG: Lazy<Mutex<Option<AgentConfig>>> = Lazy::new(|| Mutex::new(None));
|
|
|
|
fn load_from_db() -> AgentConfig {
|
|
if let Ok(Some(json)) = crate::db::db_kv_get(CONFIG_KEY) {
|
|
if let Ok(mut cfg) = serde_json::from_str::<AgentConfig>(&json) {
|
|
cfg.normalize();
|
|
return cfg;
|
|
}
|
|
}
|
|
// Migrate the legacy single-provider key written by earlier Rust builds.
|
|
let mut cfg = AgentConfig::default();
|
|
if let Ok(Some(json)) = crate::db::db_kv_get(LEGACY_PROVIDER_KEY) {
|
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&json) {
|
|
let s = |k: &str| v.get(k).and_then(|x| x.as_str()).unwrap_or("").to_string();
|
|
cfg.providers = vec![LlmProvider {
|
|
name: if s("name").is_empty() { "Default".into() } else { s("name") },
|
|
base_url: s("base_url"),
|
|
api_key: s("api_key"),
|
|
models: Vec::new(),
|
|
}];
|
|
cfg.model = s("model");
|
|
}
|
|
}
|
|
cfg.normalize();
|
|
cfg
|
|
}
|
|
|
|
fn save_to_db(cfg: &AgentConfig) {
|
|
if let Ok(json) = serde_json::to_string(cfg) {
|
|
let _ = crate::db::db_kv_set(CONFIG_KEY, &json);
|
|
}
|
|
}
|
|
|
|
/// Initialize the LLM/config module (loads lazily on first use).
|
|
pub fn agent_llm_init() {
|
|
agent_config_reload();
|
|
}
|
|
|
|
/// Drop the cached config so it is re-read from the (possibly switched)
|
|
/// per-profile database on next access. Called after login.
|
|
pub fn agent_config_reload() {
|
|
*G_CONFIG.lock().unwrap() = None;
|
|
}
|
|
|
|
/// Get a snapshot of the current agent config.
|
|
pub fn agent_config_get() -> AgentConfig {
|
|
let mut guard = G_CONFIG.lock().unwrap();
|
|
if guard.is_none() {
|
|
*guard = Some(load_from_db());
|
|
}
|
|
guard.as_ref().unwrap().clone()
|
|
}
|
|
|
|
/// Mutate the config and persist it. The closure's result is returned.
|
|
pub fn agent_config_update<R>(f: impl FnOnce(&mut AgentConfig) -> R) -> R {
|
|
let mut guard = G_CONFIG.lock().unwrap();
|
|
if guard.is_none() {
|
|
*guard = Some(load_from_db());
|
|
}
|
|
let cfg = guard.as_mut().unwrap();
|
|
let r = f(cfg);
|
|
cfg.normalize();
|
|
save_to_db(cfg);
|
|
r
|
|
}
|
|
|
|
/// Normalize an OpenAI-compatible base URL: strip trailing slashes and
|
|
/// append `/v1` unless the last path segment is already a version (`/vN`).
|
|
/// Mirrors the C `normalize_base_url` (agent_llm.c:197).
|
|
pub fn normalize_base_url(base_url: &str) -> String {
|
|
let trimmed = base_url.trim().trim_end_matches('/');
|
|
let last = trimmed.rsplit('/').next().unwrap_or("");
|
|
let is_version = last.len() >= 2
|
|
&& last.starts_with('v')
|
|
&& last[1..].chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false);
|
|
if is_version {
|
|
trimmed.to_string()
|
|
} else {
|
|
format!("{}/v1", trimmed)
|
|
}
|
|
}
|
|
|
|
fn http_client(timeout: Duration) -> Result<reqwest::blocking::Client, String> {
|
|
reqwest::blocking::Client::builder()
|
|
.timeout(timeout)
|
|
.connect_timeout(Duration::from_secs(20))
|
|
.build()
|
|
.map_err(|e| format!("HTTP client error: {}", e))
|
|
}
|
|
|
|
/// Extract a readable error from an API error body.
|
|
fn api_error_message(status: reqwest::StatusCode, body: &str) -> String {
|
|
if let Ok(v) = serde_json::from_str::<serde_json::Value>(body) {
|
|
if let Some(msg) = v["error"]["message"].as_str().or_else(|| v["error"].as_str()) {
|
|
return format!("HTTP {}: {}", status.as_u16(), msg);
|
|
}
|
|
}
|
|
let snippet: String = body.chars().take(300).collect();
|
|
format!("HTTP {}: {}", status.as_u16(), snippet)
|
|
}
|
|
|
|
/// Send a chat completion request and return `choices[0].message`.
|
|
///
|
|
/// `messages` must already be in OpenAI format (including the system
|
|
/// message). `tools` is omitted from the request when empty.
|
|
pub fn agent_llm_chat(
|
|
base_url: &str,
|
|
api_key: &str,
|
|
model: &str,
|
|
messages: &[serde_json::Value],
|
|
tools: &[serde_json::Value],
|
|
) -> Result<serde_json::Value, String> {
|
|
if base_url.trim().is_empty() {
|
|
return Err("No LLM base URL configured — open Agent Setup… from the menu".to_string());
|
|
}
|
|
if model.trim().is_empty() {
|
|
return Err("No model selected — open Agent Setup… from the menu".to_string());
|
|
}
|
|
|
|
let url = format!("{}/chat/completions", normalize_base_url(base_url));
|
|
let mut body = serde_json::json!({
|
|
"model": model,
|
|
"messages": messages,
|
|
});
|
|
if !tools.is_empty() {
|
|
body["tools"] = serde_json::Value::Array(tools.to_vec());
|
|
}
|
|
|
|
let client = http_client(Duration::from_secs(300))?;
|
|
let mut req = client.post(&url).json(&body);
|
|
if !api_key.is_empty() {
|
|
req = req.bearer_auth(api_key);
|
|
}
|
|
let resp = req.send().map_err(|e| format!("Request to {} failed: {}", url, e))?;
|
|
let status = resp.status();
|
|
let text = resp.text().map_err(|e| format!("Failed to read response: {}", e))?;
|
|
if !status.is_success() {
|
|
return Err(api_error_message(status, &text));
|
|
}
|
|
|
|
let json: serde_json::Value =
|
|
serde_json::from_str(&text).map_err(|e| format!("Invalid JSON from LLM: {}", e))?;
|
|
if let Some(err) = json.get("error") {
|
|
let msg = err["message"].as_str().or_else(|| err.as_str()).unwrap_or("unknown error");
|
|
return Err(format!("LLM error: {}", msg));
|
|
}
|
|
json["choices"][0]
|
|
.get("message")
|
|
.cloned()
|
|
.ok_or_else(|| "LLM response has no choices[0].message".to_string())
|
|
}
|
|
|
|
/// List model ids from `{base_url}/models`, sorted.
|
|
pub fn agent_llm_list_models(base_url: &str, api_key: &str) -> Result<Vec<String>, String> {
|
|
if base_url.trim().is_empty() {
|
|
return Err("No base URL configured".to_string());
|
|
}
|
|
let url = format!("{}/models", normalize_base_url(base_url));
|
|
let client = http_client(Duration::from_secs(30))?;
|
|
let mut req = client.get(&url);
|
|
if !api_key.is_empty() {
|
|
req = req.bearer_auth(api_key);
|
|
}
|
|
let resp = req.send().map_err(|e| format!("Request to {} failed: {}", url, e))?;
|
|
let status = resp.status();
|
|
let text = resp.text().map_err(|e| format!("Failed to read response: {}", e))?;
|
|
if !status.is_success() {
|
|
return Err(api_error_message(status, &text));
|
|
}
|
|
let json: serde_json::Value =
|
|
serde_json::from_str(&text).map_err(|e| format!("Invalid JSON: {}", e))?;
|
|
let list = json["data"].as_array().or_else(|| json.as_array());
|
|
let mut models: Vec<String> = list
|
|
.map(|a| {
|
|
a.iter()
|
|
.filter_map(|m| m["id"].as_str().or_else(|| m.as_str()).map(String::from))
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
models.sort_by_key(|m| m.to_lowercase());
|
|
models.dedup();
|
|
Ok(models)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn normalizes_base_urls() {
|
|
assert_eq!(normalize_base_url("https://api.openai.com"), "https://api.openai.com/v1");
|
|
assert_eq!(normalize_base_url("https://api.openai.com/"), "https://api.openai.com/v1");
|
|
assert_eq!(normalize_base_url("https://api.openai.com/v1/"), "https://api.openai.com/v1");
|
|
assert_eq!(normalize_base_url("http://localhost:11434/v2"), "http://localhost:11434/v2");
|
|
assert_eq!(
|
|
normalize_base_url("https://openrouter.ai/api"),
|
|
"https://openrouter.ai/api/v1"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn config_defaults_and_active_provider() {
|
|
let mut cfg: AgentConfig = serde_json::from_str("{}").unwrap();
|
|
cfg.normalize();
|
|
assert_eq!(cfg.max_iterations, AGENT_MAX_ITERATIONS_DEFAULT);
|
|
assert_eq!(cfg.active_provider, -1);
|
|
assert!(cfg.active().is_none());
|
|
|
|
let mut cfg = AgentConfig::default();
|
|
cfg.providers.push(LlmProvider { name: "b".into(), ..Default::default() });
|
|
cfg.active_provider = 7;
|
|
cfg.normalize();
|
|
assert_eq!(cfg.active_provider, 0);
|
|
assert_eq!(cfg.effective_skill_template(), AGENT_SYSTEM_PROMPT_DEFAULT);
|
|
}
|
|
}
|