Files
loki/docs/implementation/PHASE-1-STEP-4-NOTES.md
2026-04-10 15:45:51 -06:00

14 KiB
Raw Blame History

Phase 1 Step 4 — Implementation Notes

Status

Done.

Plan reference

  • Plan: docs/PHASE-1-IMPLEMENTATION-PLAN.md
  • Section: "Step 4: Migrate global-write methods"

Summary

Added 4 of 8 planned global-write methods to AppConfig as inherent methods, duplicating the bodies that still exist on Config. The other 4 methods were deferred: 2 to Step 7 (mixed methods that call into set_* methods slated for Step 7), and 2 kept on Config because they populate runtime-only fields (functions, mcp_registry) that don't belong on AppConfig.

Same duplication-no-caller-migration pattern as Step 3 — during the bridge window both Config and AppConfig have these methods; caller migration happens organically in Steps 8-9 when frontends switch from GlobalConfig to AppState + RequestContext.

What was changed

Modified files

  • src/config/app_config.rs — added 4 new imports (NO_COLOR, get_env_name via crate::utils, terminal_colorsaurus types) and a new impl AppConfig block with 4 methods under #[allow(dead_code)]:

    • set_wrap(&mut self, value: &str) -> Result<()> — parses and sets self.wrap for the .set wrap REPL command
    • setup_document_loaders(&mut self) — seeds default PDF/DOCX loaders into self.document_loaders if not already present
    • setup_user_agent(&mut self) — expands "auto" into loki/<version> in self.user_agent
    • load_envs(&mut self) — ~140 lines of env-var overrides that populate all 30+ serialized fields from LOKI_* environment variables

    All bodies are copy-pasted verbatim from the originals on Config, with references updated for the new module location:

    • read_env_value::<T>super::read_env_value::<T>
    • read_env_boolsuper::read_env_bool
    • NO_COLOR, IS_STDOUT_TERMINAL, get_env_name, decode_bin → imported from crate::utils
    • terminal_colorsaurus → direct import

Unchanged files

  • src/config/mod.rs — the original Config::set_wrap, load_envs, setup_document_loaders, setup_user_agent definitions are deliberately left intact. They continue to work for every existing caller. They get deleted in Step 10 when Config is removed entirely.
  • src/config/mod.rs — the read_env_value and read_env_bool private helpers are unchanged and accessed via super::read_env_value from app_config.rs.

Key decisions

1. Only 4 of 8 methods migrated

The plan's Step 4 table listed 8 methods. After reading each one carefully, I classified them:

Method Classification Action
set_wrap Pure global-write Migrated
load_envs Pure global-write Migrated
setup_document_loaders Pure global-write Migrated
setup_user_agent Pure global-write Migrated
setup_model Calls self.set_model() (Step 7 mixed) Deferred to Step 7
load_functions Writes runtime self.functions field Not migrated (stays on Config)
load_mcp_servers Writes runtime self.mcp_registry field (going away in Step 6.5) Not migrated (stays on Config)
update Dispatches to 10+ set_* methods, all Step 7 mixed Deferred to Step 7

See "Deviations from plan" for detail on each deferral.

2. Same duplication-no-forwarder pattern as Step 3

Step 4's target callers are all .write() on a GlobalConfig / Config instance. Like Step 3, giving these callers an AppConfig instance would require either (a) a sync'd Arc<AppConfig> field on Config (breaks because Step 4 itself mutates Config), (b) cloning on every call (expensive for load_envs which touches 30+ fields), or (c) duplicating the method bodies.

Option (c) is the same choice Step 3 made and for the same reasons. The duplication is 4 methods (~180 lines total dominated by load_envs) that auto-delete in Step 10.

3. load_envs body copied verbatim despite being long

load_envs is ~140 lines of repetitive if let Some(v) = read_env_value(...) { self.X = v; } blocks — one per serialized field. I considered refactoring it to reduce repetition (e.g., a macro or a data-driven table) but resisted that urge because:

  • The refactor would be a behavior change (even if subtle) during a mechanical code-move step
  • The verbatim copy is easy to audit for correctness (line-by-line diff against the original)
  • It gets deleted in Step 10 anyway, so the repetition is temporary
  • Any cleanup belongs in a dedicated tidying pass after Phase 1, not in the middle of a split

4. Methods stay in a separate impl AppConfig block

Step 3 added its 7 read methods in one impl AppConfig block. Step 4 adds its 4 write methods in a second impl AppConfig block directly below it. Rust allows multiple impl blocks on the same type, and the visual separation makes it obvious which methods are reads vs writes during the bridge window. When Step 10 deletes Config, both blocks can be merged or left separate based on the cleanup maintainer's preference.

Deviations from plan

setup_model deferred to Step 7

The plan lists setup_model as a Step 4 target. Reading its body:

fn setup_model(&mut self) -> Result<()> {
    let mut model_id = self.model_id.clone();
    if model_id.is_empty() {
        let models = list_models(self, ModelType::Chat);
        // ...
    }
    self.set_model(&model_id)?;  // ← this is Step 7 "mixed"
    self.model_id = model_id;
    Ok(())
}

It calls self.set_model(&model_id), which the plan explicitly lists in Step 7 ("mixed methods") because set_model conditionally writes to role_like (runtime) or model_id (serialized) depending on whether a role/session/agent is active. Since setup_model can't be migrated until set_model exists on AppConfig / RequestContext, it has to wait for Step 7.

Action: left Config::setup_model intact. Step 7 picks it up.

update deferred to Step 7

The plan lists update as a Step 4 target. Its body is a ~140 line dispatch over keys like "temperature", "top_p", "enabled_tools", "enabled_mcp_servers", "max_output_tokens", "save_session", "compression_threshold", "rag_reranker_model", "rag_top_k", etc. — every branch calls into a set_* method on Config that the plan explicitly lists in Step 7:

  • set_temperature (Step 7)
  • set_top_p (Step 7)
  • set_enabled_tools (Step 7)
  • set_enabled_mcp_servers (Step 7)
  • set_max_output_tokens (Step 7)
  • set_save_session (Step 7)
  • set_compression_threshold (Step 7)
  • set_rag_reranker_model (Step 7)
  • set_rag_top_k (Step 7)

Migrating update before those would mean update calls Config::set_X (old) from inside AppConfig::update (new) — which crosses the type boundary awkwardly and leaves update's behavior split between the two types during the migration window. Not worth it.

Action: left Config::update intact. Step 7 picks it up along with the set_* methods it dispatches to. At that point all 10 dependencies will be on AppConfig/RequestContext and update can be moved cleanly.

load_functions not migrated (stays on Config)

The plan lists load_functions as a Step 4 target. Its body:

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(())
}

It writes to self.functions — a #[serde(skip)] runtime field that lives on RequestContext after Step 6 and inside ToolScope after Step 6.5. It also reads self.working_mode, another runtime field. This isn't a "global-write" method in the sense Step 4 targets — it's a runtime initialization method that will move to RequestContext when functions does.

Action: left Config::load_functions intact. It gets handled in Step 5 or Step 6 when runtime fields start moving. Not Step 4, not Step 7.

load_mcp_servers not migrated (stays on Config)

Same story as load_functions. Its body writes self.mcp_registry (a field slated for deletion in Step 6.5 per the architecture plan) and self.functions (runtime, moving in Step 5/6). Nothing about this method belongs on AppConfig.

Action: left Config::load_mcp_servers intact. It gets handled or deleted in Step 6.5 when McpFactory replaces the singleton registry entirely.

Verification

Compilation

  • cargo check — clean, zero warnings, zero errors
  • cargo clippy — clean

Tests

  • cargo test63 passed, 0 failed (unchanged from Steps 13)

Step 4 added no new tests because it's duplication. The existing test suite confirms:

  • The original Config methods still work (they weren't touched)
  • AppConfig still compiles, its Default impl is intact
  • The bridge's round-trip test still passes:
    • config::bridge::tests::round_trip_default_config
    • config::bridge::tests::round_trip_preserves_all_non_lossy_fields
    • config::bridge::tests::to_app_config_copies_every_serialized_field
    • config::bridge::tests::to_request_context_copies_every_runtime_field

Manual smoke test

Not applicable — no runtime behavior changed. CLI and REPL still call Config::set_wrap(), Config::update(), Config::load_envs(), etc. unchanged.

Handoff to next step

What Step 5 can rely on

Step 5 (migrate request-read methods to RequestContext) can rely on:

  • AppConfig now has 11 methods total: 7 reads from Step 3, 4 writes from Step 4
  • #[allow(dead_code)] on both impl AppConfig blocks — safe to leave as-is, goes away when callers migrate in Steps 8+
  • Config is unchanged for all 11 methods — originals still work for all current callers
  • The bridge from Step 1, the paths module from Step 2, the read methods from Step 3 are all unchanged and still working
  • setup_model, update, load_functions, load_mcp_servers are still on Config and must stay there:
    • setup_model → migrates in Step 7 with the set_* methods
    • update → migrates in Step 7 with the set_* methods
    • load_functions → migrates to RequestContext in Step 5 or Step 6 (whichever handles Functions)
    • load_mcp_servers → deleted/transformed in Step 6.5

What Step 5 should watch for

  • Step 5 targets are &self request-read methods that read runtime fields like self.session, self.role, self.agent, self.rag, etc. The plan's Step 5 table lists: state, messages_file, sessions_dir, session_file, rag_file, info, role_info, session_info, agent_info, agent_banner, rag_info, list_sessions, list_autoname_sessions, is_compressing_session, role_like_mut.
  • These migrate to RequestContext, not AppConfig, because they read per-request state.
  • Same duplication pattern applies. Add methods to RequestContext, leave originals on Config, no caller migration.
  • sessions_dir and messages_file already use paths:: functions internally (from Step 2's migration). They read self.agent to decide between the global and agent-scoped path. Those paths come from the paths module.
  • role_like_mut is interesting — it's the helper that returns a mutable reference to whichever of role/session/agent is on top. It's the foundation for every set_* method in Step 7. Migrate it to RequestContext in Step 5 so Step 7 has it ready.
  • list_sessions and list_autoname_sessions wrap paths::list_file_names with some filtering. They take &self to know the current agent context for path resolution.

What Step 5 should NOT do

  • Don't touch the Step 3/4 methods on AppConfig — they stay until Steps 8+ caller migration.
  • Don't try to migrate update, setup_model, load_functions, or load_mcp_servers — each has a specific later-step home.
  • Don't touch the bridge.rs conversions — still needed.
  • Don't touch paths.rs — still complete.
  • Don't migrate any caller of any method yet — callers stay on Config through the bridge window.

Files to re-read at the start of Step 5

  • docs/PHASE-1-IMPLEMENTATION-PLAN.md — Step 5 section has the full request-read method table
  • This notes file — specifically "Deviations from plan" and "What Step 5 should watch for"
  • src/config/request_context.rs — to see the current shape that Step 5 will extend
  • Current Config method bodies in src/config/mod.rs for each Step 5 target (search for pub fn state, pub fn messages_file, etc.)

Follow-up (not blocking Step 5)

1. load_envs is the biggest duplication so far

At ~140 lines, load_envs is the largest single duplication in the bridge. It's acceptable because it's self-contained and auto-deletes in Step 10, but it's worth flagging that if Phase 1 stalls anywhere between now and Step 10, this method's duplication becomes a maintenance burden. Env var changes would need to be made twice.

Mitigation during the bridge window: if someone adds a new env var during Steps 5-9, they MUST add it to both Config::load_envs and AppConfig::load_envs. Document this in the Step 5 notes if any env var changes ship during that interval.

2. AppConfig now has 11 methods across 2 impl blocks

Fine during Phase 1. Post-Phase 1 cleanup can consider whether to merge them or keep the read/write split. Not a blocker.

3. The read_env_value / read_env_bool helpers are accessed via super::

These are private module helpers in src/config/mod.rs. Step 4's migration means app_config.rs now calls them via super::, which works because app_config.rs is a sibling module. If Phase 2+ work moves these helpers anywhere else, the super:: references in app_config.rs will need updating.

References

  • Phase 1 plan: docs/PHASE-1-IMPLEMENTATION-PLAN.md
  • Step 3 notes: docs/implementation/PHASE-1-STEP-3-NOTES.md (for the duplication rationale)
  • Modified file: src/config/app_config.rs (new imports + new impl AppConfig block with 4 write methods)
  • Unchanged but referenced: src/config/mod.rs (original Config methods still exist, private helpers read_env_value / read_env_bool accessed via super::)