refactor: Fully ripped out the god Config struct

This commit is contained in:
2026-04-19 19:14:25 -06:00
parent 7facdce6b6
commit c3f6cb8f46
8 changed files with 431 additions and 682 deletions
@@ -0,0 +1,303 @@
# Phase 1 Step 16f — Implementation Notes
## Status
Done. Phase 1 Step 16 (Config → AppConfig migration) complete.
## Plan reference
- Parent plan: `docs/implementation/PHASE-1-STEP-16-NOTES.md`
- Predecessor: `docs/implementation/PHASE-1-STEP-16e-NOTES.md`
- Sub-phase goal: "Delete all #[allow(dead_code)] scaffolding from
Config and bridge.rs, delete runtime fields from Config, delete
bridge.rs entirely."
## Summary
`Config` is now a pure serde POJO. `bridge.rs` is gone. Every
runtime field, every `Config::init*` flavor, and every Config method
that was scaffolding for the old god-init has been deleted. The
project compiles clean, clippy clean, and all 122 tests pass.
## What was changed
### Deleted: `src/config/bridge.rs`
Whole file removed. `mod bridge;` declaration in `config/mod.rs`
removed. The two methods (`Config::to_app_config` and
`Config::to_request_context`) had no remaining callers after 16e.
### `src/config/mod.rs` — Config slimmed to a POJO
**Deleted runtime (`#[serde(skip)]`) fields from `Config`:**
- `vault`, `macro_flag`, `info_flag`, `agent_variables`
- `model`, `functions`, `mcp_registry`, `working_mode`,
`last_message`
- `role`, `session`, `rag`, `agent`, `tool_call_tracker`
- `supervisor`, `parent_supervisor`, `self_agent_id`,
`current_depth`, `inbox`, `root_escalation_queue`
**Deleted methods on `Config`:**
- `init`, `init_bare` (god-init replaced by
`load_with_interpolation` + `AppConfig::from_config` +
`AppState::init` + `RequestContext::bootstrap`)
- `sessions_dir`, `list_sessions` (replaced by
`config::default_sessions_dir` / `config::list_sessions` free
functions for use without a Config; per-context paths live on
`RequestContext::sessions_dir` / `RequestContext::list_sessions`)
- `role_like_mut` (lives on `RequestContext` post-migration)
- `set_wrap`, `setup_document_loaders`, `setup_user_agent`,
`load_envs` (lives on `AppConfig` post-migration)
- `set_model`, `setup_model` (model resolution now in
`AppConfig::resolve_model`; per-scope model selection lives on
`RequestContext`)
- `load_functions`, `load_mcp_servers` (absorbed by
`AppState::init`)
**Default impl entries** for the deleted runtime fields removed.
**Imports cleaned up:** removed unused `ToolCallTracker`,
`McpRegistry`, `Supervisor`, `EscalationQueue`, `Inbox`, `RwLock`,
`ColorScheme`, `QueryOptions`, `color_scheme`, `Handle`. Kept
`Model`, `ModelType`, `GlobalVault` because sibling modules
(`role.rs`, `input.rs`, `agent.rs`, `session.rs`) use
`use super::*;` and depend on those re-exports.
**Removed assertions** for the deleted runtime fields from
`config_defaults_match_expected` test.
### `src/config/mod.rs` — `load_with_interpolation` no longer touches AppConfig::to_app_config
Previously called `config.to_app_config()` to build a Vault for
secret interpolation. Now constructs a minimal `AppConfig` inline
with only `vault_password_file` populated, since that's all
`Vault::init` reads. Also removed the `config.vault = Arc::new(vault)`
assignment that was the last write to the deleted runtime field.
### `src/config/mod.rs` — `vault_password_file` made `pub(super)`
Previously private. Now `pub(super)` so `AppConfig::from_config` (a
sibling module under `config/`) can read it during the field-copy.
### `src/config/app_config.rs` — `AppConfig::from_config` self-contained
Previously delegated to `Config::to_app_config()` (lived on bridge)
for the field-copy. Now inlines the field-copy directly in
`from_config`, then runs `load_envs`, `set_wrap`,
`setup_document_loaders`, `setup_user_agent`, and `resolve_model`
as before.
**Removed `#[allow(dead_code)]` from `AppConfig.model_id`** — it's
read from `app.config.model_id` in `RequestContext::bootstrap` so
the lint exemption was stale.
**Test refactor:** the three `to_app_config_*` tests rewritten as
`from_config_*` tests using `AppConfig::from_config(cfg).unwrap()`.
A `ClientConfig::default()` and non-empty `model_id: "test-model"`
were added so `resolve_model()` doesn't bail with "No available
model" during the runtime initialization.
### `src/config/session.rs` — Test helper rewired
`session_new_from_ctx_captures_save_session` rewritten to build the
test `AppState` directly with `AppConfig::default()`,
`Vault::default()`, `Functions::default()` instead of going through
`cfg.to_app_config()` / `cfg.vault` / `cfg.functions`. Then uses
`RequestContext::new(app_state, WorkingMode::Cmd)` instead of the
deleted `cfg.to_request_context(app_state)`.
### `src/config/request_context.rs` — Test helpers rewired
The `app_state_from_config(&Config)` helper rewritten as
`default_app_state()` — no longer takes a Config, builds AppState
from `AppConfig::default()` + `Vault::default()` + `Functions::default()`
directly. The two callers (`create_test_ctx`,
`update_app_config_persists_changes`) updated.
The `to_request_context_creates_clean_state` test renamed to
`new_creates_clean_state` and rewritten to use `RequestContext::new`
directly.
### Doc comment refresh
Three module docstrings rewritten to reflect the post-16f world:
- `app_config.rs` — was "Phase 1 Step 0 ... not yet wired into the
runtime." Now describes `AppConfig` as the runtime-resolved
view of YAML, built via `AppConfig::from_config`.
- `app_state.rs` — was "Step 6.5 added mcp_factory and rag_cache
... neither wired in yet ... Step 8+ will connect." Now
describes `AppState::init` as the wiring point.
- `request_context.rs` — was an extensive description of the
bridge window with flat fields vs sub-struct fields, citing
`Config::to_request_context`. Now describes the type's actual
ownership/lifecycle without referring to deleted entry points.
- `tool_scope.rs` — was "Step 6.5 scope ... unused parallel
structure ... Step 8 will rewrite." Now describes `ToolScope`
as the live per-scope tool runtime.
(Other phase-era comments in `paths.rs`, `mcp_factory.rs`,
`rag_cache.rs` not touched. They reference Step 2 / Step 6.5 /
Step 8 but the affected types still exist and the descriptions
aren't actively misleading — those files weren't part of 16f
scope. Future cleanup if desired.)
## What Config looks like now
```rust
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct Config {
pub model_id: String,
pub temperature: Option<f64>,
pub top_p: Option<f64>,
pub dry_run: bool,
pub stream: bool,
pub save: bool,
pub keybindings: String,
pub editor: Option<String>,
pub wrap: Option<String>,
pub wrap_code: bool,
pub(super) vault_password_file: Option<PathBuf>,
pub function_calling_support: bool,
pub mapping_tools: IndexMap<String, String>,
pub enabled_tools: Option<String>,
pub visible_tools: Option<Vec<String>>,
pub mcp_server_support: bool,
pub mapping_mcp_servers: IndexMap<String, String>,
pub enabled_mcp_servers: Option<String>,
pub repl_prelude: Option<String>,
pub cmd_prelude: Option<String>,
pub agent_session: Option<String>,
pub save_session: Option<bool>,
pub compression_threshold: usize,
pub summarization_prompt: Option<String>,
pub summary_context_prompt: Option<String>,
pub rag_embedding_model: Option<String>,
pub rag_reranker_model: Option<String>,
pub rag_top_k: usize,
pub rag_chunk_size: Option<usize>,
pub rag_chunk_overlap: Option<usize>,
pub rag_template: Option<String>,
pub document_loaders: HashMap<String, String>,
pub highlight: bool,
pub theme: Option<String>,
pub left_prompt: Option<String>,
pub right_prompt: Option<String>,
pub user_agent: Option<String>,
pub save_shell_history: bool,
pub sync_models_url: Option<String>,
pub clients: Vec<ClientConfig>,
}
impl Config {
pub async fn search_rag(...) -> Result<String> { ... }
pub fn load_macro(name: &str) -> Result<Macro> { ... }
pub async fn sync_models(url: &str, ...) -> Result<()> { ... }
pub async fn load_with_interpolation(info_flag: bool) -> Result<Self> { ... }
pub fn load_from_file(config_path: &Path) -> Result<(Self, String)> { ... }
pub fn load_from_str(content: &str) -> Result<Self> { ... }
pub fn load_dynamic(model_id: &str) -> Result<Self> { ... }
}
```
Just shape + loaders + a few helpers that don't touch runtime
state. `search_rag`/`load_macro`/`sync_models` are associated
functions (no `&self`) that could be free functions someday, but
that's cosmetic and out of scope for 16f.
## Assumptions made
1. **Doc cleanup scope**: The user asked to "delete the dead-code
scaffolding from Config and bridge.rs." Doc comments in
`paths.rs`, `mcp_factory.rs`, `rag_cache.rs` still reference
"Phase 1 Step 6.5 / Step 8" but the types they describe are
still real and the descriptions aren't actively wrong (just
historically dated). Left them alone. Updated only the docs in
`app_config.rs`, `app_state.rs`, `request_context.rs`, and
`tool_scope.rs` because those were either pointing at deleted
types (`Config::to_request_context`) or making explicitly
false claims ("not wired into the runtime yet").
2. **`set_*_default` helpers on AppConfig**: Lines 485528 of
`app_config.rs` define nine `#[allow(dead_code)]`
`set_*_default` methods. These were added in earlier sub-phases
as planned setters for runtime overrides. They're still unused.
The 16-NOTES plan flagged them ("set_*_default ... become
reachable") but reachability never happened. Since the user's
directive was specifically "Config and bridge.rs scaffolding,"
I left these untouched. Removing them is independent cleanup
that doesn't block 16f.
3. **`reload_current_model` on RequestContext**: Same situation —
one `#[allow(dead_code)]` left on a RequestContext method.
Belongs to a different cleanup task; not Config or bridge
scaffolding.
4. **`vault_password_file` visibility**: `Config.vault_password_file`
was a private field. Made it `pub(super)` so
`AppConfig::from_config` (sibling under `config/`) can read it
for the field-copy. This is the minimum viable visibility —
no code outside `config/` can touch it, matching the previous
intent.
5. **Bootstrap vault construction in `load_with_interpolation`**:
Used `AppConfig { vault_password_file: ..., ..AppConfig::default() }`
instead of e.g. a dedicated helper. The vault only reads
`vault_password_file` so this is sufficient. A comment explains
the dual-vault pattern (bootstrap for secret interpolation vs
canonical from `AppState::init`).
## Verification
- `cargo check` — clean, zero warnings
- `cargo clippy --all-targets` — clean, zero warnings
- `cargo test` — 122 passing, zero failures (same count as 16e)
- Grep confirmation:
- `to_app_config` — zero hits in `src/`
- `to_request_context` — zero hits in `src/`
- `Config::init` / `Config::init_bare` — zero hits in `src/`
- `bridge::` / `config::bridge` / `mod bridge` — zero hits in `src/`
- `src/config/bridge.rs` — file deleted
- Config now contains only serde fields and load/helper
functions; no runtime state.
## Phase 1 Step 16 — overall outcome
The full migration is complete:
| Sub-phase | Outcome |
|-----------|---------|
| 16a | `AppConfig::from_config` built |
| 16b | `install_builtins()` extracted |
| 16c | Vault on AppState (already-existing field, `Vault::init` rewired to `&AppConfig`) |
| 16d | `AppState::init` built |
| 16e | `main.rs` + completers + `RequestContext::bootstrap` switched to new flow |
| 16f | Bridge + Config runtime fields + dead methods deleted |
`Config` is a serde POJO. `AppConfig` is the runtime-resolved
process-wide settings. `AppState` owns process-wide services
(vault, MCP registry, base functions, MCP factory, RAG cache).
`RequestContext` owns per-request mutable state. Each struct
owns its initialization. The REST API surface is now trivial:
parse YAML → `AppConfig::from_config``AppState::init`
per-request `RequestContext`.
## Files modified (16f)
- `src/config/mod.rs` — runtime fields/methods/Default entries
deleted, imports cleaned up, `vault_password_file` made
`pub(super)`, `load_with_interpolation` decoupled from
`to_app_config`, default-test simplified
- `src/config/app_config.rs``from_config` inlines field-copy,
`#[allow(dead_code)]` on `model_id` removed, three tests
rewritten, module docstring refreshed
- `src/config/session.rs` — test helper rewired, imports updated
- `src/config/request_context.rs` — test helpers rewired,
imports updated, module docstring refreshed
- `src/config/app_state.rs` — module docstring refreshed
- `src/config/tool_scope.rs` — module docstring refreshed
## Files deleted (16f)
- `src/config/bridge.rs`
+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};