llm: drop model/agent scope matching; add instance-wide compaction model picker
Nightly Build / build (push) Successful in 6m50s

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.
This commit is contained in:
2026-07-25 10:48:09 +01:00
parent 9dafc4bfaa
commit 5081ec2afe
39 changed files with 174 additions and 160 deletions
+1 -9
View File
@@ -60,8 +60,6 @@ struct RawMeta {
#[serde(default)]
client: Option<String>,
#[serde(default)]
scope: Option<String>,
#[serde(default)]
strength: Option<LlmStrength>,
/// 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<String>,
/// 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<String>,
/// 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<Vec<AgentMeta>> {
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<AgentMeta> {
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,
+56 -10
View File
@@ -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<LlmManager>,
event_bus: Arc<ChatEventBus>,
config: CompactionConfig,
llm_manager: Arc<LlmManager>,
event_bus: Arc<ChatEventBus>,
config_store: Arc<GlobalConfigManager>,
}
impl ContextCompactor {
pub fn new(
config: CompactionConfig,
llm_manager: Arc<LlmManager>,
event_bus: Arc<ChatEventBus>,
config: CompactionConfig,
llm_manager: Arc<LlmManager>,
event_bus: Arc<ChatEventBus>,
config_store: Arc<GlobalConfigManager>,
) -> 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,
-1
View File
@@ -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,
+7 -15
View File
@@ -93,7 +93,6 @@ struct ModelRow {
model_id: String,
name: String,
strength: Option<String>,
scope: String,
is_default: i64,
priority: i64,
extra_params: Option<String>,
@@ -106,7 +105,7 @@ struct ModelRow {
pub async fn load_all_models(pool: &SqlitePool) -> Result<Vec<LlmModelRecord>> {
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<Vec<LlmModelRecord>> {
}
pub async fn insert_model(pool: &SqlitePool, r: &LlmModelRecord) -> Result<i64> {
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<i64>
// 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<i64>
.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<i64>
}
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<LlmProviderRecord> {
}
fn model_row_to_record(r: ModelRow) -> Result<LlmModelRecord> {
let scope: Vec<String> = 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<LlmModelRecord> {
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,
+9 -20
View File
@@ -100,12 +100,11 @@ impl LlmManager {
pub async fn resolve(
&self,
client_name: Option<&str>,
required_scope: Option<&str>,
required_strength: Option<LlmStrength>,
) -> Result<(String, Arc<LlmEntry>)> {
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<LlmStrength>,
) -> Result<(String, Arc<LlmEntry>)> {
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<LlmStrength>,
) -> Result<(String, Arc<LlmEntry>)> {
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<LlmModelInfo>,
scope: Option<&str>,
strength: Option<LlmStrength>,
) -> Vec<LlmModelInfo> {
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<LlmStrength>,
) {
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<LlmStrength>,
model_scope: &[String],
req_scope: Option<&str>,
req_strength: Option<LlmStrength>,
) -> 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
}
}
-2
View File
@@ -17,7 +17,6 @@ pub struct LlmEntry {
pub model: String,
pub model_db_id: i64,
pub strength: Option<LlmStrength>,
pub scope: Vec<String>,
pub extra_params: Option<serde_json::Value>,
/// Max input context window in tokens, if known.
pub context_length: Option<i64>,
@@ -96,7 +95,6 @@ pub struct LlmModelInfo {
pub model_id: String,
pub name: String,
pub strength: Option<LlmStrength>,
pub scope: Vec<String>,
pub is_default: bool,
pub priority: i32,
pub extra_params: Option<serde_json::Value>,
@@ -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
}
@@ -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?;
@@ -45,7 +45,6 @@ impl ChatSessionHandler {
stack_id: i64,
config: &AgentRunConfig,
active_grants: &HashSet<String>,
req_scope: Option<&str>,
req_strength: Option<LlmStrength>,
cur_name: &mut String,
cur_llm: &mut Arc<LlmEntry>,
@@ -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;
@@ -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,
+1
View File
@@ -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() {
+5 -1
View File
@@ -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,
@@ -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<ChatEventBus>,
supervisor: Arc<super::supervisor::TaskSupervisor>,
shutdown_token: CancellationToken,
config_store: Arc<GlobalConfigManager>,
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),
))
});