From 5081ec2afe7afa953d3d3fde67b90b18517ca56e Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Sat, 25 Jul 2026 10:48:09 +0100 Subject: [PATCH] llm: drop model/agent scope matching; add instance-wide compaction model picker Remove the scope system end-to-end (llm_models.scope column, agent meta scope field, scope-based tier in model selection, UI checkboxes/pills): it was only a soft ranking hint, had drifted (6 UI scopes vs 3 used by agents, 'general' not even selectable) and duplicated what strength already decides. Strength stays the single AUTO-selection axis. Compaction: the summary model is now pickable from the Settings page via a new PropertyType::LlmModel config property (registry key compaction_model), instance-wide and live (no restart). Fallback chain: explicit pick -> compaction.strength from config.yml -> priority order; a deleted configured model degrades to AUTO. ContextCompactor reads the key at compact time through GlobalConfigManager. --- CLAUDE.md | 8 +-- agents/business-analyst/meta.json | 1 - agents/code-explorer/meta.json | 1 - agents/generalist/meta.json | 1 - agents/project-coordinator/meta.json | 1 - agents/researcher/meta.json | 1 - agents/software-architect/meta.json | 1 - agents/software-engineer/meta.json | 1 - agents/spec-writer/meta.json | 1 - agents/tech-lead/meta.json | 1 - crates/core-api/src/config_property.rs | 3 + crates/core-api/src/provider.rs | 1 - crates/skald-core/src/agents.rs | 10 +-- crates/skald-core/src/compactor.rs | 66 ++++++++++++++++--- crates/skald-core/src/db/mod.rs | 1 - crates/skald-core/src/llm/db.rs | 22 ++----- crates/skald-core/src/llm/manager.rs | 29 +++----- crates/skald-core/src/llm/mod.rs | 2 - .../src/session/handler/agent_dispatch.rs | 3 +- .../skald-core/src/session/handler/config.rs | 1 - .../src/session/handler/llm_call.rs | 3 +- .../src/session/handler/llm_loop.rs | 5 +- crates/skald-core/src/skald/bundles.rs | 1 + crates/skald-core/src/skald/runtime.rs | 6 +- crates/skald-core/src/skald/user_context.rs | 4 ++ default.config.yaml | 5 +- docs/index.md | 1 + docs/settings.md | 27 ++++++++ src/frontend/api/agents.rs | 2 +- src/frontend/api/config.rs | 10 +++ src/frontend/api/llm.rs | 2 - web/components/agents.js | 12 ---- web/components/config-page.js | 15 +++++ web/components/models-llm.js | 44 ++----------- web/css/agents.css | 10 --- web/css/models-llm.css | 8 --- web/i18n/en.js | 8 ++- web/i18n/fr.js | 8 ++- web/i18n/it.js | 8 ++- 39 files changed, 174 insertions(+), 160 deletions(-) create mode 100644 docs/settings.md diff --git a/CLAUDE.md b/CLAUDE.md index 58f0224..299435f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,11 +81,11 @@ Two rules keep the boundary real, and both are enforced by the compiler: | `crates/skald-core/src/db/` | sqlx SQLite — see below | | `crates/skald-core/src/users/` | `UserManager` (§11): user directory CRUD on `system.db`, credential check, and the map `userid → SqlitePool` of **unlocked** databases. The pool *is* the unlock token — its connect options carry the DEK as SQLCipher's raw key, so an open pool means the key is in RAM (§9) and dropping it re-locks. Knows nothing about cookies: whatever maps an HTTP session to a user id sits above it | | `crates/skald-core/src/crypto/` | Envelope encryption (§4/§5.1). A random 256-bit DEK encrypts `{userid}.db`; `users.database_password` holds it sealed with AES-256-GCM under `Argon2id(password, salt)`. **The AEAD tag is the password verifier** — one derivation both authenticates and yields the key, and no second hash sits in the admin-readable DB. Cleartext users store the Argon2id output directly, compared constant-time. Argon2 runs in `spawn_blocking` behind a 2-permit semaphore (256 MiB per derivation) | -| `src/config.rs` | Loads `config.yml`; LLM clients, strength/use_cases, data root. All relative paths (db, logs, data, …) resolve against the launch cwd | +| `src/config.rs` | Loads `config.yml`; LLM clients, strength, data root. All relative paths (db, logs, data, …) resolve against the launch cwd | | `crates/skald-core/src/mcp/` | MCP runtimes + the `McpProvider` seam (§7): the shared host **global** runtime and the per-user **container** runtimes, unioned per session as `UserMcpView`. See the MCP connectors section | | `crates/skald-core/src/plugin/` | Plugin system: discovery, enable/disable, tool registration, per-user access grants + per-user config | | `crates/skald-core/src/cron/` | Scheduled job runner | -| `crates/skald-core/src/compactor.rs` | Context compaction (summarises history when token budget exceeded) | +| `crates/skald-core/src/compactor.rs` | Context compaction (summarises history when token budget exceeded). Model for the summary call: the instance-wide Settings pick (`compaction_model`, a `PropertyType::LlmModel` config property declared by `compactor::config_set`) wins; else AUTO by `compaction.strength` (config.yml); a missing configured model degrades to the same AUTO path | | `crates/skald-core/src/approval/` | Approval rules engine | | `crates/skald-core/src/clarification/` | `ClarificationManager`: background-session question/answer | | `crates/skald-core/src/elicitation/` | `ElicitationManager` + bridge: MCP server-initiated input (`elicitation/create`), surfaced in the Inbox; secrets never logged/persisted | @@ -215,8 +215,8 @@ The chat streams tokens live, as a **parallel best-effort side-channel** that ne - Max recursion depth: `MAX_AGENT_DEPTH = 5`. - **Parallel batches:** when a single assistant response emits **≥2** sync sub-agent calls and *nothing else*, `run_agent_turn` fans them out concurrently via `handle_sub_agent_batch` (bounded by `max_parallel_subagents`, default `4`). Ordering is preserved by allocating every `chat_llm_tools` row up front in call order (the LLM reconstructs results by row id), then recording outcomes back in call order; only the middle dispatch is concurrent. Any other shape (a lone call, or a mix with regular tools) keeps the strictly sequential `handle_tool_call` loop — the two paths share the same lower-level seams. Siblings share the session's scratchpad blackboard (session-keyed): concurrent writes to the *same* key are last-writer-wins by design. - **Restart recovery of a parallel batch** is intentionally lossy (single-user app): `resume_turn` first calls `reap_interrupted_parallel_batches`, which detects a batch by ≥2 active `chat_sessions_stack` frames at the same depth (impossible for a linear stack), fails their spawning tool calls and terminates the frames, then lets the normal linear cascade resume the parent. A lone interrupted sub-agent is untouched and still recovers via the cascade. -- Client resolution order: `args.client` → `meta.json client` → AUTO selection by scope/strength. -- **The parent's resolved client is NOT inherited.** Passing a concrete model name to `resolve()` bypasses strength/scope checks; sub-agents always auto-select unless overridden explicitly. +- Client resolution order: `args.client` → `meta.json client` → AUTO selection by strength. +- **The parent's resolved client is NOT inherited.** Passing a concrete model name to `resolve()` bypasses AUTO selection; sub-agents always auto-select unless overridden explicitly. - `list_agents` is a plain tool; returns JSON of **task** agents only (excludes `chat`/`system` agents like the `assistant` entry agent). - `resume_turn` (+ its cascade) is kept only for: app-restart recovery of an active child stack, async task result injection (`inject_async_result`), and the WS resume message — not for the normal sync dispatch. - **The cascade runs each frame with ITS OWN agent's config, not the session root's.** `resume_turn` builds the root config from `self.agent_id`, but for any non-root frame (deepest seed + each parent it walks up) it derives a per-frame config via `build_recovery_frame_config` → `build_sub_agent_config` (keyed on `frame.agent_id`), so a resumed sub-agent runs with its own prompt/tools/client — not the root's (it would otherwise resume e.g. a `researcher` as the `assistant`). `build_sub_agent_config` is the **single** source of a sub-agent's config, shared by live `dispatch_sub_agent` and this recovery path so they can't drift; the per-dispatch `client` override isn't persisted, so recovery re-resolves the model from the frame's agent meta. diff --git a/agents/business-analyst/meta.json b/agents/business-analyst/meta.json index 2825c37..5ab8783 100644 --- a/agents/business-analyst/meta.json +++ b/agents/business-analyst/meta.json @@ -14,7 +14,6 @@ }, "instructions": "Pass the idea, the draft business plan, and any market/competitor evidence you have. Specify an output path/dir for the critique report. The more evidence you provide, the sharper the critique — missing evidence is flagged as open questions, not guessed.", "type": "task", - "scope": "reasoning", "strength": "high", "icon": "icon.png" } diff --git a/agents/code-explorer/meta.json b/agents/code-explorer/meta.json index d75d436..e1b9486 100644 --- a/agents/code-explorer/meta.json +++ b/agents/code-explorer/meta.json @@ -14,7 +14,6 @@ }, "instructions": "Give it a concrete question or area to investigate (a bug, a module, an architecture concern). It writes a Markdown report to data/explorer/ and returns a summary. It never edits code or plans work.", "type": "task", - "scope": "reasoning", "strength": "high", "icon": "icon.png" } diff --git a/agents/generalist/meta.json b/agents/generalist/meta.json index 8b73315..205353c 100644 --- a/agents/generalist/meta.json +++ b/agents/generalist/meta.json @@ -14,7 +14,6 @@ }, "instructions": "Hand it a fully-specified task: what to change and where. It executes but does not plan, decide scope, or QA its own output, so be explicit about the desired outcome.", "type": "task", - "scope": "general", "strength": "average", "icon": "icon.png" } diff --git a/agents/project-coordinator/meta.json b/agents/project-coordinator/meta.json index c17ebd7..fe623af 100644 --- a/agents/project-coordinator/meta.json +++ b/agents/project-coordinator/meta.json @@ -13,7 +13,6 @@ } }, "type": "chat", - "scope": "reasoning", "strength": "average", "inject_memory": ["user-memory/index.md", "shared-memory/index.md", "__PROJECT_ROOT__/SKALD.md"], "icon": "icon.png" diff --git a/agents/researcher/meta.json b/agents/researcher/meta.json index 9eb1eb6..ab7e002 100644 --- a/agents/researcher/meta.json +++ b/agents/researcher/meta.json @@ -14,7 +14,6 @@ }, "instructions": "Pass a specific research question; optionally hint at depth (how many sources) or a time horizon. Optionally specify an output file/dir in the prompt to write the report outside the default `data/research/`. Returns a path + one-line summary, also saved to the scratchpad.", "type": "task", - "scope": "general", "strength": "average", "icon": "icon.png" } diff --git a/agents/software-architect/meta.json b/agents/software-architect/meta.json index 42a7d2a..52221a1 100644 --- a/agents/software-architect/meta.json +++ b/agents/software-architect/meta.json @@ -14,7 +14,6 @@ }, "instructions": "Describe the change or feature and the relevant part of the codebase. It produces an implementation plan and may delegate the actual edits to software-engineer. Use it when the work needs design before coding.", "type": "task", - "scope": "reasoning", "strength": "very_high", "icon": "icon.png" } diff --git a/agents/software-engineer/meta.json b/agents/software-engineer/meta.json index bebefe8..8b0b076 100644 --- a/agents/software-engineer/meta.json +++ b/agents/software-engineer/meta.json @@ -14,7 +14,6 @@ }, "instructions": "Give it a clear, scoped implementation task: which files or behaviour to change and the intended result. Best for executing an already-decided design — pair with software-architect when the approach is still open.", "type": "task", - "scope": "coding", "strength": "high", "icon": "icon.png" } diff --git a/agents/spec-writer/meta.json b/agents/spec-writer/meta.json index 9dec24a..ee310a7 100644 --- a/agents/spec-writer/meta.json +++ b/agents/spec-writer/meta.json @@ -14,7 +14,6 @@ }, "instructions": "Provide the idea, the goals, and any constraints. It researches and produces a thorough Markdown spec document. It never writes implementation code — use it before building, not during.", "type": "task", - "scope": "reasoning", "strength": "high", "icon": "icon.png" } diff --git a/agents/tech-lead/meta.json b/agents/tech-lead/meta.json index 354c085..9b15360 100644 --- a/agents/tech-lead/meta.json +++ b/agents/tech-lead/meta.json @@ -14,7 +14,6 @@ }, "instructions": "Point it at project documentation or high-level requirements (and the working directory if relevant). It decomposes the work, sequences tasks by dependency, and orchestrates software-architect/software-engineer to deliver. Best for whole-project builds, not single edits.", "type": "task", - "scope": "reasoning", "strength": "very_high", "icon": "icon.png" } diff --git a/crates/core-api/src/config_property.rs b/crates/core-api/src/config_property.rs index 4f172a8..679ac45 100644 --- a/crates/core-api/src/config_property.rs +++ b/crates/core-api/src/config_property.rs @@ -27,6 +27,9 @@ pub enum PropertyType { SecurityGroup, /// Dropdown of the interface languages the instance supports. Locale, + /// Dropdown of the LLM models configured on the instance (by model name, + /// the resolution key). Nullable: empty means "auto-select". + LlmModel, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/core-api/src/provider.rs b/crates/core-api/src/provider.rs index bee5f3c..8deae53 100644 --- a/crates/core-api/src/provider.rs +++ b/crates/core-api/src/provider.rs @@ -44,7 +44,6 @@ pub struct LlmModelRecord { pub model_id: String, pub name: String, pub strength: Option, - pub scope: Vec, pub is_default: bool, pub priority: i32, pub extra_params: Option, diff --git a/crates/skald-core/src/agents.rs b/crates/skald-core/src/agents.rs index ae58adf..6aa2874 100644 --- a/crates/skald-core/src/agents.rs +++ b/crates/skald-core/src/agents.rs @@ -60,8 +60,6 @@ struct RawMeta { #[serde(default)] client: Option, #[serde(default)] - scope: Option, - #[serde(default)] strength: Option, /// Required: declares the agent's role. A `meta.json` without `type` fails to load. #[serde(rename = "type")] @@ -104,10 +102,6 @@ pub struct AgentMeta { /// If unset, the sub-agent inherits the caller's client. #[serde(default)] pub client: Option, - /// Task domain this agent operates in (e.g. "coding", "reasoning"). - /// Used by AUTO client selection to find a matching LLM. - #[serde(default)] - pub scope: Option, /// Minimum LLM capability required to run this agent reliably. /// AUTO selection skips clients weaker than this threshold. #[serde(default)] @@ -197,13 +191,12 @@ pub fn discover() -> Result> { instructions: raw.instructions, inject_memory: raw.inject_memory, client: raw.client, - scope: raw.scope, strength: raw.strength, agent_type: raw.agent_type, inject_skills: raw.inject_skills, icon: raw.icon, }; - trace!(agent_id = %meta.id, client = ?meta.client, scope = ?meta.scope, strength = ?meta.strength, "agent meta loaded"); + trace!(agent_id = %meta.id, client = ?meta.client, strength = ?meta.strength, "agent meta loaded"); debug!(agent_id = %meta.id, name = %meta.name, "agent discovered"); agents.push(meta); } @@ -230,7 +223,6 @@ pub fn load_meta(agent_id: &str) -> Result { instructions: raw.instructions, inject_memory: raw.inject_memory, client: raw.client, - scope: raw.scope, strength: raw.strength, agent_type: raw.agent_type, inject_skills: raw.inject_skills, diff --git a/crates/skald-core/src/compactor.rs b/crates/skald-core/src/compactor.rs index c09797c..2698fc5 100644 --- a/crates/skald-core/src/compactor.rs +++ b/crates/skald-core/src/compactor.rs @@ -49,12 +49,40 @@ use serde_json::json; use sqlx::SqlitePool; use tracing::{debug, info, warn}; +use core_api::{ConfigProperty, ConfigSet, PropertyType}; + use crate::chat_event_bus::{ChatEventBus, CompactionEvent}; use crate::chatbot::ChatOptions; use crate::config::CompactionConfig; +use crate::config_store::GlobalConfigManager; use crate::db::{chat_history, chat_llm_tools, chat_summaries}; use crate::llm::LlmManager; +/// Registry `config` key holding the name of the LLM model to use for +/// compaction summaries. Set from the Settings page (instance-wide); empty / +/// unset means AUTO selection by `CompactionConfig.strength` (config.yml). +pub const COMPACTION_MODEL_KEY: &str = "compaction_model"; + +/// Settings-page section for compaction (see `i18n::config_set` for the +/// pattern). Registered in `Runtime::config_properties`. +pub fn config_set() -> ConfigSet { + ConfigSet { + name: "Compaction".into(), + description: "How conversation history is summarised when the context grows too large.".into(), + properties: vec![ + ConfigProperty { + key: COMPACTION_MODEL_KEY.into(), + name: "Compaction model".into(), + description: "Model used to summarise compacted history, for the whole instance. \ + A cheap model is usually enough. Leave empty to auto-select \ + (by `compaction.strength` in config.yml).".into(), + property_type: PropertyType::LlmModel, + default_value: None, + }, + ], + } +} + // ── Compaction constants (ported from Hermes context_compressor.py) ────────── // // SUMMARY_PREFIX — prepended to every stored summary when injected as context. @@ -158,18 +186,20 @@ Write only the summary body. Do not include any preamble or prefix."; // ── Public API ──────────────────────────────────────────────────────────────── pub struct ContextCompactor { - config: CompactionConfig, - llm_manager: Arc, - event_bus: Arc, + config: CompactionConfig, + llm_manager: Arc, + event_bus: Arc, + config_store: Arc, } impl ContextCompactor { pub fn new( - config: CompactionConfig, - llm_manager: Arc, - event_bus: Arc, + config: CompactionConfig, + llm_manager: Arc, + event_bus: Arc, + config_store: Arc, ) -> Self { - Self { config, llm_manager, event_bus } + Self { config, llm_manager, event_bus, config_store } } /// Attempt to compact the conversation history for `stack_id`. @@ -291,9 +321,25 @@ impl ContextCompactor { .format_for_summary(pool, to_summarise, prior_summary.as_ref().map(|s| s.content.as_str())) .await?; - let (client_name, llm) = self.llm_manager - .resolve(None, None, self.config.strength) - .await?; + // Model for the summary call: the instance-wide Settings pick + // (`compaction_model`) wins; empty/unset falls back to AUTO selection by + // `compaction.strength` from config.yml. A configured model that no + // longer exists (renamed/deleted) degrades to the same AUTO path. + let configured = self.config_store.get(COMPACTION_MODEL_KEY).await + .ok() + .flatten() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()); + let (client_name, llm) = match configured { + Some(name) => match self.llm_manager.resolve(Some(&name), None).await { + Ok(r) => r, + Err(e) => { + warn!(model = %name, error = %e, "compactor: configured compaction model unavailable, falling back to AUTO selection"); + self.llm_manager.resolve(None, self.config.strength).await? + } + }, + None => self.llm_manager.resolve(None, self.config.strength).await?, + }; info!( stack_id, diff --git a/crates/skald-core/src/db/mod.rs b/crates/skald-core/src/db/mod.rs index 8df41d7..0b7662f 100644 --- a/crates/skald-core/src/db/mod.rs +++ b/crates/skald-core/src/db/mod.rs @@ -206,7 +206,6 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> { model_id TEXT NOT NULL, name TEXT NOT NULL UNIQUE, strength TEXT, - scope TEXT NOT NULL DEFAULT '[]', is_default INTEGER NOT NULL DEFAULT 0, priority INTEGER NOT NULL DEFAULT 100, extra_params TEXT, diff --git a/crates/skald-core/src/llm/db.rs b/crates/skald-core/src/llm/db.rs index a92d1e5..2542cd4 100644 --- a/crates/skald-core/src/llm/db.rs +++ b/crates/skald-core/src/llm/db.rs @@ -93,7 +93,6 @@ struct ModelRow { model_id: String, name: String, strength: Option, - scope: String, is_default: i64, priority: i64, extra_params: Option, @@ -106,7 +105,7 @@ struct ModelRow { pub async fn load_all_models(pool: &SqlitePool) -> Result> { let rows = sqlx::query_as::<_, ModelRow>( - "SELECT id, provider_id, model_id, name, strength, scope, is_default, priority, extra_params, + "SELECT id, provider_id, model_id, name, strength, is_default, priority, extra_params, context_length, max_output_tokens, knowledge_cutoff, capabilities, reasoning FROM llm_models WHERE removed_at IS NULL @@ -120,7 +119,6 @@ pub async fn load_all_models(pool: &SqlitePool) -> Result> { } pub async fn insert_model(pool: &SqlitePool, r: &LlmModelRecord) -> Result { - let scope = serde_json::to_string(&r.scope)?; let extra_params = r.extra_params.as_ref().map(|v| v.to_string()); let capabilities = serde_json::to_string(&r.capabilities)?; let reasoning = r.reasoning.as_ref().map(|v| v.to_string()); @@ -132,14 +130,13 @@ pub async fn insert_model(pool: &SqlitePool, r: &LlmModelRecord) -> Result // soft-deleted row. Upsert on `name` so that existing row is revived // (removed_at cleared) and every field overwritten. let id = sqlx::query_scalar::<_, i64>( - "INSERT INTO llm_models (provider_id, model_id, name, strength, scope, is_default, priority, extra_params, + "INSERT INTO llm_models (provider_id, model_id, name, strength, is_default, priority, extra_params, context_length, max_output_tokens, knowledge_cutoff, capabilities, reasoning) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12) ON CONFLICT(name) DO UPDATE SET provider_id = excluded.provider_id, model_id = excluded.model_id, strength = excluded.strength, - scope = excluded.scope, is_default = excluded.is_default, priority = excluded.priority, extra_params = excluded.extra_params, @@ -155,7 +152,6 @@ pub async fn insert_model(pool: &SqlitePool, r: &LlmModelRecord) -> Result .bind(&r.model_id) .bind(&r.name) .bind(r.strength.map(strength_str)) - .bind(scope) .bind(r.is_default as i64) .bind(r.priority as i64) .bind(extra_params) @@ -172,23 +168,21 @@ pub async fn insert_model(pool: &SqlitePool, r: &LlmModelRecord) -> Result } pub async fn update_model(pool: &SqlitePool, id: i64, r: &LlmModelRecord) -> Result<()> { - let scope = serde_json::to_string(&r.scope)?; let extra_params = r.extra_params.as_ref().map(|v| v.to_string()); let capabilities = serde_json::to_string(&r.capabilities)?; let reasoning = r.reasoning.as_ref().map(|v| v.to_string()); sqlx::query( "UPDATE llm_models SET provider_id=?1, model_id=?2, name=?3, strength=?4, - scope=?5, is_default=?6, priority=?7, extra_params=?8, - context_length=?9, max_output_tokens=?10, knowledge_cutoff=?11, capabilities=?12, - reasoning=?13 - WHERE id=?14", + is_default=?5, priority=?6, extra_params=?7, + context_length=?8, max_output_tokens=?9, knowledge_cutoff=?10, capabilities=?11, + reasoning=?12 + WHERE id=?13", ) .bind(r.provider_id) .bind(&r.model_id) .bind(&r.name) .bind(r.strength.map(strength_str)) - .bind(scope) .bind(r.is_default as i64) .bind(r.priority as i64) .bind(extra_params) @@ -267,7 +261,6 @@ fn provider_row_to_record(r: ProviderRow) -> Result { } fn model_row_to_record(r: ModelRow) -> Result { - let scope: Vec = serde_json::from_str(&r.scope).unwrap_or_default(); let extra_params = r.extra_params .as_deref() .and_then(|s| serde_json::from_str(s).ok()); @@ -281,7 +274,6 @@ fn model_row_to_record(r: ModelRow) -> Result { model_id: r.model_id, name: r.name, strength: r.strength.as_deref().and_then(parse_strength), - scope, is_default: r.is_default != 0, priority: r.priority as i32, extra_params, diff --git a/crates/skald-core/src/llm/manager.rs b/crates/skald-core/src/llm/manager.rs index 28be443..9d9cd1c 100644 --- a/crates/skald-core/src/llm/manager.rs +++ b/crates/skald-core/src/llm/manager.rs @@ -100,12 +100,11 @@ impl LlmManager { pub async fn resolve( &self, client_name: Option<&str>, - required_scope: Option<&str>, required_strength: Option, ) -> Result<(String, Arc)> { let name = match client_name { None | Some(AUTO_CLIENT) => { - let (name, entry) = self.select(required_scope, required_strength).await?; + let (name, entry) = self.select(required_strength).await?; self.maybe_refresh_meta(&name).await; return Ok((name, entry)); } @@ -368,7 +367,6 @@ impl LlmManager { model_id: slot.model.model_id.clone(), name: slot.model.name.clone(), strength: slot.model.strength, - scope: slot.model.scope.clone(), is_default: slot.model.is_default, priority: slot.model.priority, extra_params: slot.model.extra_params.clone(), @@ -391,7 +389,6 @@ impl LlmManager { pub async fn select_excluding( &self, excluded: &[&str], - required_scope: Option<&str>, required_strength: Option, ) -> Result<(String, Arc)> { let state = self.state.read().await; @@ -401,7 +398,7 @@ impl LlmManager { if slots.is_empty() { anyhow::bail!("no alternative LLM models available"); } - sort_slots_for_agent(&mut slots, required_scope, required_strength); + sort_slots_for_agent(&mut slots, required_strength); if let Some((name, slot)) = slots.iter().find(|(_, s)| s.health.status != ClientStatus::Down) { return Ok((name.to_string(), slot.entry.clone())); } @@ -414,7 +411,6 @@ impl LlmManager { async fn select( &self, - required_scope: Option<&str>, required_strength: Option, ) -> Result<(String, Arc)> { let state = self.state.read().await; @@ -424,7 +420,7 @@ impl LlmManager { } let mut slots: Vec<(&String, &ModelSlot)> = state.models.iter().collect(); - sort_slots_for_agent(&mut slots, required_scope, required_strength); + sort_slots_for_agent(&mut slots, required_strength); if let Some((name, slot)) = slots.iter().find(|(_, s)| s.health.status != ClientStatus::Down) { return Ok((name.to_string(), slot.entry.clone())); @@ -526,7 +522,6 @@ fn build_entry( model: model.model_id.clone(), model_db_id, strength: model.strength, - scope: model.scope.clone(), extra_params: extra, context_length: model.context_length, prompt_cache, @@ -548,28 +543,24 @@ fn build_entry( pub fn sort_models_for_agent( mut models: Vec, - scope: Option<&str>, strength: Option, ) -> Vec { - models.sort_by_key(|m| (model_tier(m.strength, m.scope.as_slice(), scope, strength), m.priority)); + models.sort_by_key(|m| (model_tier(m.strength, strength), m.priority)); models } fn sort_slots_for_agent( slots: &mut Vec<(&String, &ModelSlot)>, - scope: Option<&str>, strength: Option, ) { slots.sort_by_key(|(_, s)| ( - model_tier(s.model.strength, s.model.scope.as_slice(), scope, strength), + model_tier(s.model.strength, strength), s.model.priority, )); } fn model_tier( model_strength: Option, - model_scope: &[String], - req_scope: Option<&str>, req_strength: Option, ) -> u8 { let strength_ok = match (req_strength, model_strength) { @@ -583,11 +574,9 @@ fn model_tier( (Some(req), Some(avail)) => avail == req, _ => true, }; - let scope_ok = req_scope.map_or(true, |sc| model_scope.iter().any(|x| x == sc)); - match (strength_ok && scope_ok, exact_match && scope_ok, strength_ok) { - (true, true, _) => 0, // exact strength + scope ok - (true, false, _) => 1, // over-qualified but scope ok - (false, _, true) => 2, // strength ok, scope mismatch - _ => 3, // doesn't meet minimum bar + match (strength_ok, exact_match) { + (true, true) => 0, // exact strength + (true, false) => 1, // over-qualified + _ => 3, // doesn't meet minimum bar } } diff --git a/crates/skald-core/src/llm/mod.rs b/crates/skald-core/src/llm/mod.rs index 7072d93..4997483 100644 --- a/crates/skald-core/src/llm/mod.rs +++ b/crates/skald-core/src/llm/mod.rs @@ -17,7 +17,6 @@ pub struct LlmEntry { pub model: String, pub model_db_id: i64, pub strength: Option, - pub scope: Vec, pub extra_params: Option, /// Max input context window in tokens, if known. pub context_length: Option, @@ -96,7 +95,6 @@ pub struct LlmModelInfo { pub model_id: String, pub name: String, pub strength: Option, - pub scope: Vec, pub is_default: bool, pub priority: i32, pub extra_params: Option, diff --git a/crates/skald-core/src/session/handler/agent_dispatch.rs b/crates/skald-core/src/session/handler/agent_dispatch.rs index 88cc91d..4f03c29 100644 --- a/crates/skald-core/src/session/handler/agent_dispatch.rs +++ b/crates/skald-core/src/session/handler/agent_dispatch.rs @@ -57,7 +57,6 @@ impl ChatSessionHandler { let explicit_client = args["client"].as_str().or(target_meta.client.as_deref()); let (resolved_client, _) = self.llm_manager.resolve( explicit_client, - target_meta.scope.as_deref(), target_meta.strength, ).await.map_err(|e| anyhow::anyhow!("dispatch_sub_agent: {e}"))?; @@ -241,7 +240,7 @@ impl ChatSessionHandler { let meta = crate::agents::load_task_meta(&frame.agent_id) .map_err(|e| anyhow::anyhow!("resume: cannot load sub-agent `{}`: {e}", frame.agent_id))?; let (client, _) = self.llm_manager.resolve( - meta.client.as_deref(), meta.scope.as_deref(), meta.strength, + meta.client.as_deref(), meta.strength, ).await?; self.build_sub_agent_config(root_config, &frame.agent_id, client.to_string(), frame.id, frame.depth).await } diff --git a/crates/skald-core/src/session/handler/config.rs b/crates/skald-core/src/session/handler/config.rs index 3607eb7..d1b4552 100644 --- a/crates/skald-core/src/session/handler/config.rs +++ b/crates/skald-core/src/session/handler/config.rs @@ -50,7 +50,6 @@ impl ChatSessionHandler { let meta = crate::agents::load_meta(&self.agent_id).ok(); let (key, _) = self.llm_manager.resolve( client_name.as_deref(), - meta.as_ref().and_then(|m| m.scope.as_deref()), meta.as_ref().and_then(|m| m.strength), ).await?; diff --git a/crates/skald-core/src/session/handler/llm_call.rs b/crates/skald-core/src/session/handler/llm_call.rs index 1d81381..926c46e 100644 --- a/crates/skald-core/src/session/handler/llm_call.rs +++ b/crates/skald-core/src/session/handler/llm_call.rs @@ -45,7 +45,6 @@ impl ChatSessionHandler { stack_id: i64, config: &AgentRunConfig, active_grants: &HashSet, - req_scope: Option<&str>, req_strength: Option, cur_name: &mut String, cur_llm: &mut Arc, @@ -155,7 +154,7 @@ impl ChatSessionHandler { } let excluded: Vec<&str> = tried_this_round.iter().map(String::as_str).collect(); - match self.llm_manager.select_excluding(&excluded, req_scope, req_strength).await { + match self.llm_manager.select_excluding(&excluded, req_strength).await { Ok((next_name, next_llm)) => { warn!(session_id = self.session_id, from = %cur_name, to = %next_name, "LLM fallback"); em.model_fallback(cur_name.clone(), next_name.clone(), first_line(&e.to_string())).await; diff --git a/crates/skald-core/src/session/handler/llm_loop.rs b/crates/skald-core/src/session/handler/llm_loop.rs index 9c729a4..6220c32 100644 --- a/crates/skald-core/src/session/handler/llm_loop.rs +++ b/crates/skald-core/src/session/handler/llm_loop.rs @@ -65,9 +65,8 @@ impl ChatSessionHandler { let mut cur_llm = self.llm_manager.get(&cur_name).await .ok_or_else(|| anyhow::anyhow!("LLM client '{}' not found", cur_name))?; - // Scope/strength needed for fallback re-selection. + // Strength needed for fallback re-selection. let meta = crate::agents::load_meta(&config.agent_id).ok(); - let req_scope = meta.as_ref().and_then(|m| m.scope.as_deref()).map(str::to_string); let req_strength = meta.as_ref().and_then(|m| m.strength); // Accumulates tool calls across all rounds for the event bus. @@ -132,7 +131,7 @@ impl ChatSessionHandler { // retriable errors. `cur_name`/`cur_llm`/`messages` are updated in place. let turn_result = match self.call_llm_round( stack_id, config, &active_grants_snapshot, - req_scope.as_deref(), req_strength, + req_strength, &mut cur_name, &mut cur_llm, &mut messages, token, &em, ).await { RoundLlm::Turn(t) => t, diff --git a/crates/skald-core/src/skald/bundles.rs b/crates/skald-core/src/skald/bundles.rs index 7cd13c3..7d69f9b 100644 --- a/crates/skald-core/src/skald/bundles.rs +++ b/crates/skald-core/src/skald/bundles.rs @@ -331,6 +331,7 @@ impl Conversation { cfg.clone(), Arc::clone(&models.llm_manager), Arc::clone(&rt.event_bus), + Arc::clone(&rt.config), )) }); if compactor.is_none() { diff --git a/crates/skald-core/src/skald/runtime.rs b/crates/skald-core/src/skald/runtime.rs index 6619856..72f2b18 100644 --- a/crates/skald-core/src/skald/runtime.rs +++ b/crates/skald-core/src/skald/runtime.rs @@ -63,7 +63,11 @@ impl Runtime { users, sessions, config, - config_properties: vec![crate::i18n::config_set(), crate::tic::config_set()], + config_properties: vec![ + crate::i18n::config_set(), + crate::tic::config_set(), + crate::compactor::config_set(), + ], system_bus, event_bus, global_tx, diff --git a/crates/skald-core/src/skald/user_context.rs b/crates/skald-core/src/skald/user_context.rs index 546b34d..1f0d22b 100644 --- a/crates/skald-core/src/skald/user_context.rs +++ b/crates/skald-core/src/skald/user_context.rs @@ -43,6 +43,7 @@ use crate::chat_hub::ChatHub; use crate::clarification::ClarificationManager; use crate::compactor::ContextCompactor; use crate::config::{CompactionConfig, CoreConfig, DatetimeConfig}; +use crate::config_store::GlobalConfigManager; use crate::container::ContainerManager; use crate::cron::TaskManager; use crate::elicitation::ElicitationManager; @@ -132,6 +133,7 @@ pub(super) struct UserContextFactory { event_bus: Arc, supervisor: Arc, shutdown_token: CancellationToken, + config_store: Arc, max_history_messages: usize, max_tool_rounds: usize, max_parallel_subagents: usize, @@ -166,6 +168,7 @@ impl UserContextFactory { event_bus: Arc::clone(&rt.event_bus), supervisor: Arc::clone(&rt.supervisor), shutdown_token: rt.shutdown_token.clone(), + config_store: Arc::clone(&rt.config), max_history_messages: config.llm.max_history_messages, max_tool_rounds: config.llm.max_tool_rounds.unwrap_or(DEFAULT_MAX_TOOL_ROUNDS), max_parallel_subagents: config.llm.max_parallel_subagents.unwrap_or(DEFAULT_MAX_PARALLEL_SUBAGENTS), @@ -207,6 +210,7 @@ impl UserContextFactory { cfg.clone(), Arc::clone(&self.llm_manager), Arc::clone(&event_bus), + Arc::clone(&self.config_store), )) }); diff --git a/default.config.yaml b/default.config.yaml index 05835db..4120981 100644 --- a/default.config.yaml +++ b/default.config.yaml @@ -30,7 +30,7 @@ marketplace: # ── LLM clients ──────────────────────────────────────────────────────────────── -# LLM clients (providers, models, API keys, strength, scope) are configured +# LLM clients (providers, models, API keys, strength) are configured # via the web app and stored in the database — not in this file. # ─────────────────────────────────────────────────────────────────────────────── llm: @@ -72,6 +72,9 @@ llm: # selector (same strength levels used for agent assignment). Compaction is a # simple writing task — `low` or `average` is usually sufficient. # Omit `strength` to use whatever AUTO picks. + # NOTE: the Settings page has an instance-wide "Compaction model" picker + # (registry config key `compaction_model`) — when set, it wins over this + # `strength` fallback and needs no restart. # # When the LLM provider does not report token usage (e.g. some LM Studio # setups), a rough estimate (total chars / 4) is used as a fallback. diff --git a/docs/index.md b/docs/index.md index f7750a7..010e55b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,6 +11,7 @@ This index will grow over time. Right now it covers projects and plugins; more s | Document | What it covers | | --- | --- | | [projects.md](projects.md) | Projects: shared folders with their own assistant chat, a live file explorer, and member sharing | +| [settings.md](settings.md) | The admin's Config page: interface language, TIC agent, the compaction model picker, debug mode | ## Plugins diff --git a/docs/settings.md b/docs/settings.md new file mode 100644 index 0000000..e0f1f4e --- /dev/null +++ b/docs/settings.md @@ -0,0 +1,27 @@ +# Settings (Config page) + +The **Config** page holds instance-wide settings. It is admin-only: what an admin changes here applies to every user of the instance. + +Each setting is saved individually with its own **Save** button (a few, like the language, save as soon as they are changed). + +## Interface + +- **Language** — the default interface language for the whole instance. Each user can override it on their own profile page. + +## TIC Agent + +TIC is a background agent that watches events from connected MCP servers (new emails, calendar updates, WhatsApp messages…) and decides which ones are worth surfacing as notifications. + +- **Enabled** — turn TIC on or off. +- **Security Group** — the tool permission group TIC's sessions run with; leave empty for the default group. +- **Check Interval (minutes)** — how often TIC runs; leave empty for the value from `config.yml`. + +## Compaction + +When a conversation grows too large, the oldest messages are automatically summarised so the context stays within limits. The summary replaces those messages in future turns while the most recent ones are kept verbatim. + +- **Compaction model** — the model used to write those summaries, for the whole instance. Summarising is a simple writing task, so a cheap, fast model is usually the right choice — there is no reason to spend premium-model tokens on it. Leave it empty for automatic selection (by the `compaction.strength` value in `config.yml`, or the instance's default priority order). If the chosen model is later deleted, compaction silently falls back to automatic selection. + +## Developer + +- **Debug mode** — shows extra technical diagnostics in the interface. diff --git a/src/frontend/api/agents.rs b/src/frontend/api/agents.rs index cc5ed0c..f9d488a 100644 --- a/src/frontend/api/agents.rs +++ b/src/frontend/api/agents.rs @@ -52,7 +52,7 @@ pub async fn get( meta.localize(&locale); let prompt = skald_core::agents::load_prompt(&id)?; let all = skald.llm_manager().list_models_info().await; - let models = sort_models_for_agent(all, meta.scope.as_deref(), meta.strength); + let models = sort_models_for_agent(all, meta.strength); Ok(Json(AgentDetail { meta, prompt, models })) } diff --git a/src/frontend/api/config.rs b/src/frontend/api/config.rs index 3c3716d..4fb6924 100644 --- a/src/frontend/api/config.rs +++ b/src/frontend/api/config.rs @@ -65,6 +65,15 @@ pub async fn list_properties( name: skald_core::i18n::native_language_name(code), }) .collect::>(); + // Configured LLM models, keyed by `name` (the resolution key LlmManager + // uses), labelled with the provider for disambiguation. + let llm_models = skald.llm_manager().list_models_info().await + .into_iter() + .map(|m| SelectOption { + id: m.name.clone(), + name: format!("{} ({})", m.name, m.provider_name), + }) + .collect::>(); let mut sets = Vec::with_capacity(skald.config_properties().len()); for set in skald.config_properties() { @@ -78,6 +87,7 @@ pub async fn list_properties( PropertyType::String => ("string", None), PropertyType::SecurityGroup => ("security_group", Some(security_groups.clone())), PropertyType::Locale => ("locale", Some(locales.clone())), + PropertyType::LlmModel => ("llm_model", Some(llm_models.clone())), }; props.push(PropertyView { key: prop.key.clone(), diff --git a/src/frontend/api/llm.rs b/src/frontend/api/llm.rs index 354efc5..37a7deb 100644 --- a/src/frontend/api/llm.rs +++ b/src/frontend/api/llm.rs @@ -162,7 +162,6 @@ pub struct ModelPayload { pub model_id: String, pub name: String, pub strength: Option, - pub scope: Option>, pub is_default: Option, pub priority: Option, pub extra_params: Option, @@ -184,7 +183,6 @@ impl TryFrom for LlmModelRecord { model_id: p.model_id.clone(), name: if p.name.is_empty() { p.model_id } else { p.name }, strength: p.strength.as_deref().map(parse_strength).transpose()?, - scope: p.scope.unwrap_or_default(), is_default: p.is_default.unwrap_or(false), priority: p.priority.unwrap_or(100), extra_params: p.extra_params, diff --git a/web/components/agents.js b/web/components/agents.js index a36237c..d729795 100644 --- a/web/components/agents.js +++ b/web/components/agents.js @@ -111,10 +111,6 @@ export class AgentsPage extends LightElement { `; } - _scopePill(scope) { - return html`${scope}`; - } - // ── List view ───────────────────────────────────────────────────────────── _renderCard(agent) { @@ -137,7 +133,6 @@ export class AgentsPage extends LightElement { ${this._strengthLabel(agent.strength)} ` : ''} - ${agent.scope ? html`${this._scopePill(agent.scope)}` : ''} ${agent.client ? html` ${agent.client} @@ -189,9 +184,6 @@ export class AgentsPage extends LightElement { ${m.is_default ? html`${t('agents.detail.default')}` : ''} ${m.model_id} - - ${(m.scope ?? []).map(s => this._scopePill(s))} - `; } @@ -235,9 +227,6 @@ export class AgentsPage extends LightElement { ` : ''} - ${meta.scope ? html` - ${t('agents.detail.scope')}${this._scopePill(meta.scope)} - ` : ''} ${meta.client ? html` ${t('agents.detail.pinned_model')}${meta.client} ` : ''} @@ -266,7 +255,6 @@ export class AgentsPage extends LightElement { ${t('agents.table.strength')} ${t('agents.table.name')} ${t('agents.table.model_id')} - ${t('agents.table.scope')} diff --git a/web/components/config-page.js b/web/components/config-page.js index 30c5ca8..aaadfa9 100644 --- a/web/components/config-page.js +++ b/web/components/config-page.js @@ -11,6 +11,7 @@ function _configSetSlug(name) { const slugs = { 'Interface': 'interface', 'TIC Agent': 'tic_agent', + 'Compaction': 'compaction', }; return slugs[name] ?? null; } @@ -193,6 +194,20 @@ export class ConfigPage extends LightElement { `; } + if (prop.property_type === 'llm_model') { + // Configured LLM models, by name. Nullable: the empty choice means + // "auto-select" (the backend's own resolution order applies). + const models = prop.options ?? []; + return html` + `; + } + return html` x !== scope) : [...s, scope] }; - } else { - const s = this._form.scope; - this._form = { ...this._form, scope: s.includes(scope) ? s.filter(x => x !== scope) : [...s, scope] }; - } - } - _closeModal() { this._modal = null; this._error = null; } // ── Render helpers ─────────────────────────────────────────────────────────── @@ -487,10 +471,9 @@ export class ModelsLlmSection extends LightElement { ${this._renderPriceCell(m)} - ${(m.scope ?? []).length > 0 || m.extra_params ? html` + ${m.extra_params ? html`
- ${(m.scope ?? []).map(s => html`${s}`)} - ${m.extra_params ? html`+${t('models.extra_params').toLowerCase()}` : ''} + +${t('models.extra_params').toLowerCase()}
` : ''} @@ -540,7 +523,7 @@ export class ModelsLlmSection extends LightElement { `; } - _renderMetaFields(form, setField, toggleScope, reasoningMode) { + _renderMetaFields(form, setField, reasoningMode) { return html` ${this._renderReasoning(form, setField, reasoningMode)}
@@ -561,19 +544,6 @@ export class ModelsLlmSection extends LightElement {
-
- -
- ${SCOPE_OPTIONS.map(s => html` -
- toggleScope(s)} /> - -
- `)} -
-
-
this._setField('extra_params', e.target.value)} style="font-size:0.78rem;resize:vertical">
- ${this._renderMetaFields(f, (k, v) => this._setField(k, v), (s) => this._toggleScope(s), this._reasoningMode)} + ${this._renderMetaFields(f, (k, v) => this._setField(k, v), this._reasoningMode)}
` : ''} - ${this._renderMetaFields(f, (k, v) => this._setOrField(k, v), (s) => this._toggleScope(s, true), selected?.reasoning)} + ${this._renderMetaFields(f, (k, v) => this._setOrField(k, v), selected?.reasoning)}
@@ -776,7 +746,7 @@ export class ModelsLlmSection extends LightElement { @input=${(e) => this._setField('name', e.target.value)} />
${unsafeHTML(t('models.name_help'))}
- ${this._renderMetaFields(f, (k, v) => this._setField(k, v), (s) => this._toggleScope(s), this._modal.reasoning_mode)} + ${this._renderMetaFields(f, (k, v) => this._setField(k, v), this._modal.reasoning_mode)}