refactor: Fully ripped out the god Config struct

This commit is contained in:
2026-04-19 19:14:25 -06:00
parent dc86aaa835
commit 6c2c6f9908
8 changed files with 431 additions and 682 deletions
+80 -25
View File
@@ -1,23 +1,19 @@
//! Immutable, server-wide application configuration.
//! Immutable, process-wide application configuration.
//!
//! `AppConfig` contains the settings loaded from `config.yaml` that are
//! global to the Loki process: LLM provider configs, UI preferences, tool
//! and MCP settings, RAG defaults, etc.
//! global to the Loki process: LLM provider configs, UI preferences,
//! tool and MCP settings, RAG defaults, etc.
//!
//! This is Phase 1, Step 0 of the REST API refactor: the struct is
//! introduced alongside the existing [`Config`](super::Config) and is not
//! yet wired into the runtime. See `docs/PHASE-1-IMPLEMENTATION-PLAN.md`
//! for the full migration plan.
//!
//! # Relationship to `Config`
//!
//! `AppConfig` mirrors the **serialized** fields of [`Config`] — that is,
//! every field that is NOT marked `#[serde(skip)]`. The deserialization
//! shape is identical so an existing `config.yaml` can be loaded into
//! either type without modification.
//! `AppConfig` mirrors the field shape of [`Config`](super::Config) (the
//! serde POJO loaded from YAML) but is the runtime-resolved form: env
//! var overrides applied, wrap validated, default document loaders
//! installed, user agent resolved, default model picked. Build it via
//! [`AppConfig::from_config`].
//!
//! Runtime-only state (current role, session, agent, supervisor, etc.)
//! lives on [`RequestContext`](super::request_context::RequestContext).
//! Process-wide services (vault, MCP registry, function registry) live
//! on [`AppState`](super::app_state::AppState).
use crate::client::{ClientConfig, list_models};
use crate::render::{MarkdownRender, RenderOptions};
@@ -38,7 +34,6 @@ use terminal_colorsaurus::{ColorScheme, QueryOptions, color_scheme};
pub struct AppConfig {
#[serde(rename(serialize = "model", deserialize = "model"))]
#[serde(default)]
#[allow(dead_code)]
pub model_id: String,
pub temperature: Option<f64>,
pub top_p: Option<f64>,
@@ -151,7 +146,58 @@ impl Default for AppConfig {
impl AppConfig {
pub fn from_config(config: super::Config) -> Result<Self> {
let mut app_config = config.to_app_config();
let mut app_config = Self {
model_id: config.model_id,
temperature: config.temperature,
top_p: config.top_p,
dry_run: config.dry_run,
stream: config.stream,
save: config.save,
keybindings: config.keybindings,
editor: config.editor,
wrap: config.wrap,
wrap_code: config.wrap_code,
vault_password_file: config.vault_password_file,
function_calling_support: config.function_calling_support,
mapping_tools: config.mapping_tools,
enabled_tools: config.enabled_tools,
visible_tools: config.visible_tools,
mcp_server_support: config.mcp_server_support,
mapping_mcp_servers: config.mapping_mcp_servers,
enabled_mcp_servers: config.enabled_mcp_servers,
repl_prelude: config.repl_prelude,
cmd_prelude: config.cmd_prelude,
agent_session: config.agent_session,
save_session: config.save_session,
compression_threshold: config.compression_threshold,
summarization_prompt: config.summarization_prompt,
summary_context_prompt: config.summary_context_prompt,
rag_embedding_model: config.rag_embedding_model,
rag_reranker_model: config.rag_reranker_model,
rag_top_k: config.rag_top_k,
rag_chunk_size: config.rag_chunk_size,
rag_chunk_overlap: config.rag_chunk_overlap,
rag_template: config.rag_template,
document_loaders: config.document_loaders,
highlight: config.highlight,
theme: config.theme,
left_prompt: config.left_prompt,
right_prompt: config.right_prompt,
user_agent: config.user_agent,
save_shell_history: config.save_shell_history,
sync_models_url: config.sync_models_url,
clients: config.clients,
};
app_config.load_envs();
if let Some(wrap) = app_config.wrap.clone() {
app_config.set_wrap(&wrap)?;
@@ -491,7 +537,7 @@ mod tests {
}
#[test]
fn to_app_config_copies_serialized_fields() {
fn from_config_copies_serialized_fields() {
let cfg = Config {
model_id: "test-model".to_string(),
temperature: Some(0.7),
@@ -502,10 +548,11 @@ mod tests {
highlight: false,
compression_threshold: 2000,
rag_top_k: 10,
clients: vec![ClientConfig::default()],
..Config::default()
};
let app = cfg.to_app_config();
let app = AppConfig::from_config(cfg).unwrap();
assert_eq!(app.model_id, "test-model");
assert_eq!(app.temperature, Some(0.7));
@@ -519,22 +566,30 @@ mod tests {
}
#[test]
fn to_app_config_copies_clients() {
let cfg = Config::default();
let app = cfg.to_app_config();
fn from_config_copies_clients() {
let cfg = Config {
model_id: "test-model".to_string(),
clients: vec![ClientConfig::default()],
..Config::default()
};
let app = AppConfig::from_config(cfg).unwrap();
assert!(app.clients.is_empty());
assert_eq!(app.clients.len(), 1);
}
#[test]
fn to_app_config_copies_mapping_fields() {
let mut cfg = Config::default();
fn from_config_copies_mapping_fields() {
let mut cfg = Config {
model_id: "test-model".to_string(),
clients: vec![ClientConfig::default()],
..Config::default()
};
cfg.mapping_tools
.insert("alias".to_string(), "real_tool".to_string());
cfg.mapping_mcp_servers
.insert("gh".to_string(), "github-mcp".to_string());
let app = cfg.to_app_config();
let app = AppConfig::from_config(cfg).unwrap();
assert_eq!(
app.mapping_tools.get("alias"),
+9 -23
View File
@@ -3,30 +3,16 @@
//! `AppState` holds the services that are genuinely process-wide and
//! immutable during request handling: the frozen [`AppConfig`], the
//! credential [`Vault`](GlobalVault), the [`McpFactory`](super::mcp_factory::McpFactory)
//! for MCP subprocess sharing, and the [`RagCache`](super::rag_cache::RagCache)
//! for shared RAG instances. It is intended to be wrapped in `Arc`
//! and shared across every [`RequestContext`] that a frontend (CLI,
//! REPL, API) creates.
//! for MCP subprocess sharing, the [`RagCache`](super::rag_cache::RagCache)
//! for shared RAG instances, the global MCP registry, and the base
//! [`Functions`] declarations seeded into per-request `ToolScope`s. It
//! is wrapped in `Arc` and shared across every [`RequestContext`] that
//! a frontend (CLI, REPL, API) creates.
//!
//! This struct deliberately does **not** hold a live `McpRegistry`.
//! MCP server processes are scoped to whichever `RoleLike`
//! (role/session/agent) is currently active, because each scope may
//! demand a different enabled server set. Live MCP processes are
//! owned by per-scope
//! [`ToolScope`](super::tool_scope::ToolScope)s on the
//! [`RequestContext`] and acquired through `McpFactory`.
//!
//! # Phase 1 scope
//!
//! This is Phase 1 of the REST API refactor:
//!
//! * **Step 0** introduced this struct alongside the existing
//! [`Config`](super::Config)
//! * **Step 6.5** added the `mcp_factory` and `rag_cache` fields
//!
//! Neither field is wired into the runtime yet — they exist as
//! additive scaffolding that Step 8+ will connect when the entry
//! points migrate. See `docs/PHASE-1-IMPLEMENTATION-PLAN.md`.
//! Built via [`AppState::init`] from an `Arc<AppConfig>` plus
//! startup context (log path, MCP-start flag, abort signal). The
//! `init` call is the single place that wires the vault, MCP
//! registry, and global functions together.
use super::mcp_factory::{McpFactory, McpServerKey};
use super::rag_cache::RagCache;
-104
View File
@@ -1,104 +0,0 @@
//! Transitional conversions between the legacy [`Config`] struct and the
//! new [`AppConfig`] + [`RequestContext`] split.
use crate::config::todo::TodoList;
use super::{AppConfig, AppState, Config, RequestContext};
use std::sync::Arc;
impl Config {
pub fn to_app_config(&self) -> AppConfig {
AppConfig {
model_id: self.model_id.clone(),
temperature: self.temperature,
top_p: self.top_p,
dry_run: self.dry_run,
stream: self.stream,
save: self.save,
keybindings: self.keybindings.clone(),
editor: self.editor.clone(),
wrap: self.wrap.clone(),
wrap_code: self.wrap_code,
vault_password_file: self.vault_password_file.clone(),
function_calling_support: self.function_calling_support,
mapping_tools: self.mapping_tools.clone(),
enabled_tools: self.enabled_tools.clone(),
visible_tools: self.visible_tools.clone(),
mcp_server_support: self.mcp_server_support,
mapping_mcp_servers: self.mapping_mcp_servers.clone(),
enabled_mcp_servers: self.enabled_mcp_servers.clone(),
repl_prelude: self.repl_prelude.clone(),
cmd_prelude: self.cmd_prelude.clone(),
agent_session: self.agent_session.clone(),
save_session: self.save_session,
compression_threshold: self.compression_threshold,
summarization_prompt: self.summarization_prompt.clone(),
summary_context_prompt: self.summary_context_prompt.clone(),
rag_embedding_model: self.rag_embedding_model.clone(),
rag_reranker_model: self.rag_reranker_model.clone(),
rag_top_k: self.rag_top_k,
rag_chunk_size: self.rag_chunk_size,
rag_chunk_overlap: self.rag_chunk_overlap,
rag_template: self.rag_template.clone(),
document_loaders: self.document_loaders.clone(),
highlight: self.highlight,
theme: self.theme.clone(),
left_prompt: self.left_prompt.clone(),
right_prompt: self.right_prompt.clone(),
user_agent: self.user_agent.clone(),
save_shell_history: self.save_shell_history,
sync_models_url: self.sync_models_url.clone(),
clients: self.clients.clone(),
}
}
#[allow(dead_code)]
pub fn to_request_context(&self, app: Arc<AppState>) -> RequestContext {
let mut mcp_runtime = super::tool_scope::McpRuntime::default();
if let Some(registry) = &self.mcp_registry {
mcp_runtime.sync_from_registry(registry);
}
let tool_tracker = self
.tool_call_tracker
.clone()
.unwrap_or_else(crate::function::ToolCallTracker::default);
RequestContext {
app,
macro_flag: self.macro_flag,
info_flag: self.info_flag,
working_mode: self.working_mode,
model: self.model.clone(),
agent_variables: self.agent_variables.clone(),
role: self.role.clone(),
session: self.session.clone(),
rag: self.rag.clone(),
agent: self.agent.clone(),
last_message: self.last_message.clone(),
tool_scope: super::tool_scope::ToolScope {
functions: self.functions.clone(),
mcp_runtime,
tool_tracker,
},
supervisor: self.supervisor.clone(),
parent_supervisor: self.parent_supervisor.clone(),
self_agent_id: self.self_agent_id.clone(),
inbox: self.inbox.clone(),
escalation_queue: self.root_escalation_queue.clone(),
current_depth: self.current_depth,
auto_continue_count: 0,
todo_list: TodoList::default(),
last_continuation_response: None,
}
}
}
+8 -454
View File
@@ -1,7 +1,6 @@
mod agent;
mod app_config;
mod app_state;
mod bridge;
mod input;
mod macros;
mod mcp_factory;
@@ -28,25 +27,20 @@ pub use self::role::{
use self::session::Session;
use crate::client::{
ClientConfig, MessageContentToolCalls, Model, ModelType, OPENAI_COMPATIBLE_PROVIDERS,
ProviderModels, create_client_config, list_client_types, list_models,
ProviderModels, create_client_config, list_client_types,
};
use crate::function::{FunctionDeclaration, Functions, ToolCallTracker};
use crate::function::{FunctionDeclaration, Functions};
use crate::rag::Rag;
use crate::utils::*;
pub use macros::macro_execute;
use crate::config::macros::Macro;
use crate::mcp::McpRegistry;
use crate::supervisor::Supervisor;
use crate::supervisor::escalation::EscalationQueue;
use crate::supervisor::mailbox::Inbox;
use crate::vault::{GlobalVault, Vault, create_vault_password_file, interpolate_secrets};
use anyhow::{Context, Result, anyhow, bail};
use fancy_regex::Regex;
use indexmap::IndexMap;
use indoc::formatdoc;
use inquire::{Confirm, Select};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
@@ -59,8 +53,6 @@ use std::{
process,
sync::{Arc, OnceLock},
};
use terminal_colorsaurus::{ColorScheme, QueryOptions, color_scheme};
use tokio::runtime::Handle;
pub const TEMP_ROLE_NAME: &str = "temp";
pub const TEMP_RAG_NAME: &str = "temp";
@@ -142,7 +134,7 @@ pub struct Config {
pub editor: Option<String>,
pub wrap: Option<String>,
pub wrap_code: bool,
vault_password_file: Option<PathBuf>,
pub(super) vault_password_file: Option<PathBuf>,
pub function_calling_support: bool,
pub mapping_tools: IndexMap<String, String>,
@@ -182,50 +174,6 @@ pub struct Config {
pub sync_models_url: Option<String>,
pub clients: Vec<ClientConfig>,
#[serde(skip)]
pub vault: GlobalVault,
#[serde(skip)]
pub macro_flag: bool,
#[serde(skip)]
pub info_flag: bool,
#[serde(skip)]
pub agent_variables: Option<AgentVariables>,
#[serde(skip)]
pub model: Model,
#[serde(skip)]
pub functions: Functions,
#[serde(skip)]
pub mcp_registry: Option<McpRegistry>,
#[serde(skip)]
pub working_mode: WorkingMode,
#[serde(skip)]
pub last_message: Option<LastMessage>,
#[serde(skip)]
pub role: Option<Role>,
#[serde(skip)]
pub session: Option<Session>,
#[serde(skip)]
pub rag: Option<Arc<Rag>>,
#[serde(skip)]
pub agent: Option<Agent>,
#[serde(skip)]
pub(crate) tool_call_tracker: Option<ToolCallTracker>,
#[serde(skip)]
pub supervisor: Option<Arc<RwLock<Supervisor>>>,
#[serde(skip)]
pub parent_supervisor: Option<Arc<RwLock<Supervisor>>>,
#[serde(skip)]
pub self_agent_id: Option<String>,
#[serde(skip)]
pub current_depth: usize,
#[serde(skip)]
pub inbox: Option<Arc<Inbox>>,
#[serde(skip)]
pub root_escalation_queue: Option<Arc<EscalationQueue>>,
}
impl Default for Config {
@@ -281,30 +229,6 @@ impl Default for Config {
sync_models_url: None,
clients: vec![],
vault: Default::default(),
macro_flag: false,
info_flag: false,
agent_variables: None,
model: Default::default(),
functions: Default::default(),
mcp_registry: Default::default(),
working_mode: WorkingMode::Cmd,
last_message: None,
role: None,
session: None,
rag: None,
agent: None,
tool_call_tracker: Some(ToolCallTracker::default()),
supervisor: None,
parent_supervisor: None,
self_agent_id: None,
current_depth: 0,
inbox: None,
root_escalation_queue: None,
}
}
}
@@ -328,154 +252,6 @@ pub fn list_sessions() -> Vec<String> {
}
impl Config {
#[allow(dead_code)]
pub fn init_bare() -> Result<Self> {
let h = Handle::current();
tokio::task::block_in_place(|| {
h.block_on(Self::init(
WorkingMode::Cmd,
true,
false,
None,
create_abort_signal(),
))
})
}
pub async fn init(
working_mode: WorkingMode,
info_flag: bool,
start_mcp_servers: bool,
log_path: Option<PathBuf>,
abort_signal: AbortSignal,
) -> Result<Self> {
let config_path = paths::config_file();
let (mut config, content) = if !config_path.exists() {
match env::var(get_env_name("provider"))
.ok()
.or_else(|| env::var(get_env_name("platform")).ok())
{
Some(v) => (Self::load_dynamic(&v)?, String::new()),
None => {
if *IS_STDOUT_TERMINAL {
create_config_file(&config_path).await?;
}
Self::load_from_file(&config_path)?
}
}
} else {
Self::load_from_file(&config_path)?
};
let setup = async |config: &mut Self| -> Result<()> {
let vault = Vault::init(&config.to_app_config());
let (parsed_config, missing_secrets) = interpolate_secrets(&content, &vault);
if !missing_secrets.is_empty() && !info_flag {
debug!(
"Global config references secrets that are missing from the vault: {missing_secrets:?}"
);
return Err(anyhow!(formatdoc!(
"
Global config file references secrets that are missing from the vault: {:?}
Please add these secrets to the vault and try again.",
missing_secrets
)));
}
if !parsed_config.is_empty() && !info_flag {
debug!("Global config is invalid once secrets are injected: {parsed_config}");
let new_config = Self::load_from_str(&parsed_config).with_context(|| {
formatdoc!(
"
Global config is invalid once secrets are injected.
Double check the secret values and file syntax, then try again.
"
)
})?;
*config = new_config.clone();
}
config.working_mode = working_mode;
config.info_flag = info_flag;
config.vault = Arc::new(vault);
config.load_envs();
if let Some(wrap) = config.wrap.clone() {
config.set_wrap(&wrap)?;
}
config.load_functions()?;
config
.load_mcp_servers(log_path, start_mcp_servers, abort_signal)
.await?;
config.setup_model()?;
config.setup_document_loaders();
config.setup_user_agent();
Ok(())
};
let ret = setup(&mut config).await;
if !info_flag {
ret?;
}
Ok(config)
}
#[allow(dead_code)]
pub fn sessions_dir(&self) -> PathBuf {
match &self.agent {
None => match env::var(get_env_name("sessions_dir")) {
Ok(value) => PathBuf::from(value),
Err(_) => paths::local_path(SESSIONS_DIR_NAME),
},
Some(agent) => paths::agent_data_dir(agent.name()).join(SESSIONS_DIR_NAME),
}
}
pub fn role_like_mut(&mut self) -> Option<&mut dyn RoleLike> {
if let Some(session) = self.session.as_mut() {
Some(session)
} else if let Some(agent) = self.agent.as_mut() {
Some(agent)
} else if let Some(role) = self.role.as_mut() {
Some(role)
} else {
None
}
}
pub fn set_wrap(&mut self, value: &str) -> Result<()> {
if value == "no" {
self.wrap = None;
} else if value == "auto" {
self.wrap = Some(value.into());
} else {
value
.parse::<u16>()
.map_err(|_| anyhow!("Invalid wrap value"))?;
self.wrap = Some(value.into())
}
Ok(())
}
pub fn set_model(&mut self, model_id: &str) -> Result<()> {
let model = Model::retrieve_model(&self.to_app_config(), model_id, ModelType::Chat)?;
match self.role_like_mut() {
Some(role_like) => role_like.set_model(model),
None => {
self.model = model;
}
}
Ok(())
}
#[allow(dead_code)]
pub fn list_sessions(&self) -> Vec<String> {
list_file_names(self.sessions_dir(), ".yaml")
}
pub async fn search_rag(
app: &AppConfig,
rag: &Rag,
@@ -548,7 +324,11 @@ impl Config {
Self::load_from_file(&config_path)?
};
let vault = Vault::init(&config.to_app_config());
let bootstrap_app = AppConfig {
vault_password_file: config.vault_password_file.clone(),
..AppConfig::default()
};
let vault = Vault::init(&bootstrap_app);
let (parsed_config, missing_secrets) = interpolate_secrets(&content, &vault);
if !missing_secrets.is_empty() && !info_flag {
debug!(
@@ -573,7 +353,6 @@ impl Config {
})?;
config = new_config;
}
config.vault = Arc::new(vault);
Ok(config)
}
@@ -633,227 +412,6 @@ impl Config {
serde_json::from_value(config).with_context(|| "Failed to load config from env")?;
Ok(config)
}
fn load_envs(&mut self) {
if let Ok(v) = env::var(get_env_name("model")) {
self.model_id = v;
}
if let Some(v) = read_env_value::<f64>(&get_env_name("temperature")) {
self.temperature = v;
}
if let Some(v) = read_env_value::<f64>(&get_env_name("top_p")) {
self.top_p = v;
}
if let Some(Some(v)) = read_env_bool(&get_env_name("dry_run")) {
self.dry_run = v;
}
if let Some(Some(v)) = read_env_bool(&get_env_name("stream")) {
self.stream = v;
}
if let Some(Some(v)) = read_env_bool(&get_env_name("save")) {
self.save = v;
}
if let Ok(v) = env::var(get_env_name("keybindings"))
&& v == "vi"
{
self.keybindings = v;
}
if let Some(v) = read_env_value::<String>(&get_env_name("editor")) {
self.editor = v;
}
if let Some(v) = read_env_value::<String>(&get_env_name("wrap")) {
self.wrap = v;
}
if let Some(Some(v)) = read_env_bool(&get_env_name("wrap_code")) {
self.wrap_code = v;
}
if let Some(Some(v)) = read_env_bool(&get_env_name("function_calling_support")) {
self.function_calling_support = v;
}
if let Ok(v) = env::var(get_env_name("mapping_tools"))
&& let Ok(v) = serde_json::from_str(&v)
{
self.mapping_tools = v;
}
if let Some(v) = read_env_value::<String>(&get_env_name("enabled_tools")) {
self.enabled_tools = v;
}
if let Some(Some(v)) = read_env_bool(&get_env_name("mcp_server_support")) {
self.mcp_server_support = v;
}
if let Ok(v) = env::var(get_env_name("mapping_mcp_servers"))
&& let Ok(v) = serde_json::from_str(&v)
{
self.mapping_mcp_servers = v;
}
if let Some(v) = read_env_value::<String>(&get_env_name("enabled_mcp_servers")) {
self.enabled_mcp_servers = v;
}
if let Some(v) = read_env_value::<String>(&get_env_name("repl_prelude")) {
self.repl_prelude = v;
}
if let Some(v) = read_env_value::<String>(&get_env_name("cmd_prelude")) {
self.cmd_prelude = v;
}
if let Some(v) = read_env_value::<String>(&get_env_name("agent_session")) {
self.agent_session = v;
}
if let Some(v) = read_env_bool(&get_env_name("save_session")) {
self.save_session = v;
}
if let Some(Some(v)) = read_env_value::<usize>(&get_env_name("compression_threshold")) {
self.compression_threshold = v;
}
if let Some(v) = read_env_value::<String>(&get_env_name("summarization_prompt")) {
self.summarization_prompt = v;
}
if let Some(v) = read_env_value::<String>(&get_env_name("summary_context_prompt")) {
self.summary_context_prompt = v;
}
if let Some(v) = read_env_value::<String>(&get_env_name("rag_embedding_model")) {
self.rag_embedding_model = v;
}
if let Some(v) = read_env_value::<String>(&get_env_name("rag_reranker_model")) {
self.rag_reranker_model = v;
}
if let Some(Some(v)) = read_env_value::<usize>(&get_env_name("rag_top_k")) {
self.rag_top_k = v;
}
if let Some(v) = read_env_value::<usize>(&get_env_name("rag_chunk_size")) {
self.rag_chunk_size = v;
}
if let Some(v) = read_env_value::<usize>(&get_env_name("rag_chunk_overlap")) {
self.rag_chunk_overlap = v;
}
if let Some(v) = read_env_value::<String>(&get_env_name("rag_template")) {
self.rag_template = v;
}
if let Ok(v) = env::var(get_env_name("document_loaders"))
&& let Ok(v) = serde_json::from_str(&v)
{
self.document_loaders = v;
}
if let Some(Some(v)) = read_env_bool(&get_env_name("highlight")) {
self.highlight = v;
}
if *NO_COLOR {
self.highlight = false;
}
if self.highlight && self.theme.is_none() {
if let Some(v) = read_env_value::<String>(&get_env_name("theme")) {
self.theme = v;
} else if *IS_STDOUT_TERMINAL
&& let Ok(color_scheme) = color_scheme(QueryOptions::default())
{
let theme = match color_scheme {
ColorScheme::Dark => "dark",
ColorScheme::Light => "light",
};
self.theme = Some(theme.into());
}
}
if let Some(v) = read_env_value::<String>(&get_env_name("left_prompt")) {
self.left_prompt = v;
}
if let Some(v) = read_env_value::<String>(&get_env_name("right_prompt")) {
self.right_prompt = v;
}
if let Some(v) = read_env_value::<String>(&get_env_name("user_agent")) {
self.user_agent = v;
}
if let Some(Some(v)) = read_env_bool(&get_env_name("save_shell_history")) {
self.save_shell_history = v;
}
if let Some(v) = read_env_value::<String>(&get_env_name("sync_models_url")) {
self.sync_models_url = v;
}
}
fn load_functions(&mut self) -> Result<()> {
self.functions = Functions::init(self.visible_tools.as_ref().unwrap_or(&Vec::new()))?;
if self.working_mode.is_repl() {
self.functions.append_user_interaction_functions();
}
Ok(())
}
async fn load_mcp_servers(
&mut self,
log_path: Option<PathBuf>,
start_mcp_servers: bool,
abort_signal: AbortSignal,
) -> Result<()> {
let app_config = self.to_app_config();
let mcp_registry = McpRegistry::init(
log_path,
start_mcp_servers,
self.enabled_mcp_servers.clone(),
abort_signal.clone(),
&app_config,
&self.vault,
)
.await?;
match mcp_registry.is_empty() {
false => {
if self.mcp_server_support {
self.functions
.append_mcp_meta_functions(mcp_registry.list_started_servers());
} else {
debug!(
"Skipping global MCP functions registration since 'mcp_server_support' was 'false'"
);
}
}
_ => debug!(
"Skipping global MCP functions registration since 'start_mcp_servers' was 'false'"
),
}
self.mcp_registry = Some(mcp_registry);
Ok(())
}
fn setup_model(&mut self) -> Result<()> {
let mut model_id = self.model_id.clone();
if model_id.is_empty() {
let models = list_models(&self.to_app_config(), ModelType::Chat);
if models.is_empty() {
bail!("No available model");
}
model_id = models[0].id()
}
self.set_model(&model_id)?;
self.model_id = model_id;
Ok(())
}
fn setup_document_loaders(&mut self) {
[("pdf", "pdftotext $1 -"), ("docx", "pandoc --to plain $1")]
.into_iter()
.for_each(|(k, v)| {
let (k, v) = (k.to_string(), v.to_string());
self.document_loaders.entry(k).or_insert(v);
});
}
fn setup_user_agent(&mut self) {
if let Some("auto") = self.user_agent.as_deref() {
self.user_agent = Some(format!(
"{}/{}",
env!("CARGO_CRATE_NAME"),
env!("CARGO_PKG_VERSION")
));
}
}
}
pub fn load_env_file() -> Result<()> {
@@ -1089,10 +647,6 @@ mod tests {
assert!(cfg.save_shell_history);
assert_eq!(cfg.keybindings, "emacs");
assert!(cfg.clients.is_empty());
assert!(cfg.role.is_none());
assert!(cfg.session.is_none());
assert!(cfg.agent.is_none());
assert!(cfg.rag.is_none());
assert!(cfg.save_session.is_none());
assert!(cfg.enabled_tools.is_none());
assert!(cfg.enabled_mcp_servers.is_none());
+20 -57
View File
@@ -1,9 +1,11 @@
//! Per-request mutable state for a single Loki interaction.
//!
//! `RequestContext` holds everything that was formerly the runtime
//! (`#[serde(skip)]`) half of [`Config`](super::Config): the active role,
//! session, agent, RAG, supervisor state, inbox/escalation queues, and
//! the conversation's "last message" cursor.
//! `RequestContext` owns the runtime state that was previously stored
//! on `Config` as `#[serde(skip)]` fields: the active role, session,
//! agent, RAG, supervisor state, inbox/escalation queues, the
//! conversation's "last message" cursor, and the per-scope
//! [`ToolScope`](super::tool_scope::ToolScope) carrying functions and
//! live MCP handles.
//!
//! Each frontend constructs and owns a `RequestContext`:
//!
@@ -12,43 +14,11 @@
//! * **API** — one `RequestContext` per HTTP request, hydrated from a
//! persisted session and written back at the end.
//!
//! # Tool scope and agent runtime (planned)
//!
//! # Flat fields and sub-struct fields coexist during the bridge
//!
//! The flat fields (`functions`, `tool_call_tracker`, `supervisor`,
//! `inbox`, `root_escalation_queue`, `self_agent_id`, `current_depth`,
//! `parent_supervisor`) mirror the runtime half of today's `Config`
//! and are populated by
//! [`Config::to_request_context`](super::Config::to_request_context)
//! during the bridge.
//!
//! Step 6.5 added two **sub-struct fields** alongside the flat ones:
//!
//! * [`tool_scope: ToolScope`](super::tool_scope::ToolScope) — the
//! planned home for `functions`, `mcp_runtime`, and `tool_tracker`
//! * [`agent_runtime: Option<AgentRuntime>`](super::agent_runtime::AgentRuntime) —
//! the planned home for `supervisor`, `inbox`, `escalation_queue`,
//! `todo_list`, `self_agent_id`, `parent_supervisor`, and `depth`
//!
//! During the bridge window the sub-struct fields are **additive
//! scaffolding**: they're initialized to defaults in
//! [`RequestContext::new`] and stay empty until Step 8 rewrites the
//! entry points to build them explicitly. The flat fields continue
//! to carry the actual state from `Config` during the bridge.
//!
//! # Phase 1 refactor history
//!
//! * **Step 0** — struct introduced alongside `Config`
//! * **Step 5** — added 13 request-read methods
//! * **Step 6** — added 12 request-write methods
//! * **Step 6.5** — added `tool_scope` and `agent_runtime` sub-struct
//! fields as additive scaffolding
//! * **Step 7** — added 14 mixed-method splits that take `&AppConfig`
//! as a parameter for the serialized half
//!
//! See `docs/PHASE-1-IMPLEMENTATION-PLAN.md` for the full migration
//! plan.
//! `RequestContext` is built via [`RequestContext::bootstrap`] (CLI/REPL
//! entry point) or [`RequestContext::new`] (test/child-agent helper).
//! It holds an `Arc<AppState>` for shared, immutable services
//! (config, vault, MCP factory, RAG cache, MCP registry, base
//! functions).
use super::MessageContentToolCalls;
use super::rag_cache::{RagCache, RagKey};
@@ -2396,7 +2366,7 @@ impl RequestContext {
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{AppState, Config};
use crate::config::AppState;
use crate::utils::get_env_name;
use std::env;
use std::fs::{create_dir_all, remove_dir_all, write};
@@ -2446,31 +2416,26 @@ mod tests {
}
}
fn app_state_from_config(cfg: &Config) -> Arc<AppState> {
fn default_app_state() -> Arc<AppState> {
Arc::new(AppState {
config: Arc::new(cfg.to_app_config()),
vault: cfg.vault.clone(),
config: Arc::new(AppConfig::default()),
vault: Arc::new(crate::vault::Vault::default()),
mcp_factory: Arc::new(super::super::mcp_factory::McpFactory::default()),
rag_cache: Arc::new(RagCache::default()),
mcp_config: None,
mcp_log_path: None,
mcp_registry: None,
functions: cfg.functions.clone(),
functions: Functions::default(),
})
}
fn create_test_ctx() -> RequestContext {
let cfg = Config::default();
let app_state = app_state_from_config(&cfg);
cfg.to_request_context(app_state)
RequestContext::new(default_app_state(), WorkingMode::Cmd)
}
#[test]
fn to_request_context_creates_clean_state() {
let cfg = Config::default();
let app_state = app_state_from_config(&cfg);
let ctx = cfg.to_request_context(app_state);
fn new_creates_clean_state() {
let ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd);
assert!(ctx.role.is_none());
assert!(ctx.session.is_none());
@@ -2483,9 +2448,7 @@ mod tests {
#[test]
fn update_app_config_persists_changes() {
let cfg = Config::default();
let app_state = app_state_from_config(&cfg);
let mut ctx = RequestContext::new(app_state, WorkingMode::Cmd);
let mut ctx = RequestContext::new(default_app_state(), WorkingMode::Cmd);
let previous = Arc::clone(&ctx.app.config);
ctx.update_app_config(|app| {
+6 -6
View File
@@ -674,7 +674,8 @@ impl AutoName {
mod tests {
use super::*;
use crate::client::{Message, MessageContent, MessageRole, Model};
use crate::config::{AppState, Config};
use crate::config::{AppConfig, AppState, RequestContext, WorkingMode};
use crate::function::Functions;
use std::sync::Arc;
#[test]
@@ -688,19 +689,18 @@ mod tests {
#[test]
fn session_new_from_ctx_captures_save_session() {
let cfg = Config::default();
let app_config = Arc::new(cfg.to_app_config());
let app_config = Arc::new(AppConfig::default());
let app_state = Arc::new(AppState {
config: app_config.clone(),
vault: cfg.vault.clone(),
vault: Arc::new(crate::vault::Vault::default()),
mcp_factory: Arc::new(mcp_factory::McpFactory::default()),
rag_cache: Arc::new(rag_cache::RagCache::default()),
mcp_config: None,
mcp_log_path: None,
mcp_registry: None,
functions: cfg.functions.clone(),
functions: Functions::default(),
});
let ctx = cfg.to_request_context(app_state);
let ctx = RequestContext::new(app_state, WorkingMode::Cmd);
let session = Session::new_from_ctx(&ctx, &app_config, "test-session");
assert_eq!(session.name(), "test-session");
+5 -13
View File
@@ -12,19 +12,11 @@
//! * `tool_tracker` — per-scope tool call history for auto-continuation
//! and looping detection
//!
//! # Phase 1 Step 6.5 scope
//!
//! This file introduces the type scaffolding. Scope transitions
//! (`use_role`, `use_session`, `use_agent`, `exit_*`) that actually
//! build and swap `ToolScope` instances are deferred to Step 8 when
//! the entry points (`main.rs`, `repl/mod.rs`) get rewritten to thread
//! `RequestContext` through the pipeline. During the bridge window,
//! `Config.functions` / `Config.mcp_registry` keep serving today's
//! callers and `ToolScope` sits alongside them on `RequestContext` as
//! an unused (but compiling and tested) parallel structure.
//!
//! The fields mirror the plan in `docs/REST-API-ARCHITECTURE.md`
//! section 5 and `docs/PHASE-1-IMPLEMENTATION-PLAN.md` Step 6.5.
//! `ToolScope` lives on [`RequestContext`](super::request_context::RequestContext)
//! and is built/replaced as the active scope changes (role swap,
//! session swap, agent enter/exit). The base `functions` are seeded
//! from [`AppState`](super::app_state::AppState) and per-scope filters
//! narrow the visible set.
use crate::function::{Functions, ToolCallTracker};
use crate::mcp::{CatalogItem, ConnectedServer, McpRegistry};