15 KiB
Phase 1 Step 8a — Implementation Notes
Status
Done.
Plan reference
- Plan:
docs/PHASE-1-IMPLEMENTATION-PLAN.md - Section: "Step 8a: Client module refactor —
Model::retrieve_modeltakes&AppConfig"
Summary
Migrated the LLM client module's 4 &Config-taking functions to take
&AppConfig instead, and updated all 15 callsites across 7 files to
use the Config::to_app_config() bridge helper (already exists from
Step 1). No new types, no new methods — this is a signature change
that propagates through the codebase.
This unblocks Step 8b, where Config::retrieve_role,
Config::set_model, Config::repl_complete, and
Config::setup_model (Step 7 deferrals) can finally migrate to
RequestContext methods that take &AppConfig — they were blocked
on Model::retrieve_model expecting &Config.
What was changed
Files modified (8 files, 15 callsite updates)
-
src/client/macros.rs— changed 3 signatures in theregister_client!macro (the functions it generates at expansion time):list_client_names(config: &Config)→(config: &AppConfig)list_all_models(config: &Config)→(config: &AppConfig)list_models(config: &Config, ModelType)→(config: &AppConfig, ModelType)
All three functions only read
config.clientswhich is a serialized field identical on both types. TheOnceLockcaches (ALL_CLIENT_NAMES,ALL_MODELS) work identically becauseAppConfig.clientsholds the same values asConfig.clients. -
src/client/model.rs— changed theuseand function signature:use crate::config::Config→use crate::config::AppConfigModel::retrieve_model(config: &Config, ...)→(config: &AppConfig, ...)
The function body was unchanged — it calls
list_all_models(config)andlist_client_names(config)internally, both of which now take the same&AppConfigtype. -
src/config/mod.rs(6 callsite updates):set_rag_reranker_model→Model::retrieve_model(&config.read().to_app_config(), ...)set_model→Model::retrieve_model(&self.to_app_config(), ...)retrieve_role→Model::retrieve_model(&self.to_app_config(), ...)repl_complete(.modelbranch) →list_models(&self.to_app_config(), ModelType::Chat)repl_complete(.rag_reranker_modelbranch) →list_models(&self.to_app_config(), ModelType::Reranker)setup_model→list_models(&self.to_app_config(), ModelType::Chat)
-
src/config/session.rs—Session::loadcaller updated:Model::retrieve_model(&config.to_app_config(), ...) -
src/config/agent.rs—Agent::initcaller updated:Model::retrieve_model(&config.to_app_config(), model_id, ModelType::Chat)?(required reformatting because the one-liner became two lines) -
src/function/supervisor.rs— sub-agent summarization model lookup:Model::retrieve_model(&cfg.to_app_config(), ...) -
src/rag/mod.rs(4 callsite updates):Rag::createembedding model lookupRag::initlist_modelsfor embedding model selectionRag::initretrieve_modelfor embedding modelRag::searchreranker model lookup
-
src/main.rs—--list-modelsCLI flag handler:list_models(&config.read().to_app_config(), ModelType::Chat) -
src/cli/completer.rs— shell completion for--model:list_models(&config.to_app_config(), ModelType::Chat)
Files NOT changed
src/config/bridge.rs— theConfig::to_app_config()method from Step 1 is exactly the bridge helper Step 8a needed. No new method was added; I just started using the existing one.src/client/other files — onlymacros.rsandmodel.rshad the target signatures. Individual client implementations (openai.rs,claude.rs, etc.) don't reference&Configdirectly; they work through theClienttrait which usesGlobalConfiginternally (untouched).- Any file calling
init_clientorGlobalConfig— these are separate from the model-lookup path and stay onGlobalConfigthrough the bridge. Step 8f/8g will migrate them.
Key decisions
1. Reused Config::to_app_config() instead of adding app_config_snapshot
The plan said to add a Config::app_config_snapshot(&self) -> AppConfig
helper. That's exactly what Config::to_app_config() from Step 1
already does — clones every serialized field into a fresh AppConfig.
Adding a second method with the same body would be pointless
duplication.
I proceeded directly with to_app_config() and the plan's intent
is satisfied.
2. Inline .to_app_config() at every callsite
Each callsite pattern is:
// old:
Model::retrieve_model(config, ...)
// new:
Model::retrieve_model(&config.to_app_config(), ...)
The owned AppConfig returned by to_app_config() lives for the
duration of the function argument expression, so & borrowing works
without a named binding. For multi-line callsites (like Rag::create
and Rag::init in src/rag/mod.rs) I reformatted to put the
to_app_config() call on its own line for readability.
3. Allocation cost is acceptable during the bridge window
Every callsite now clones 40 fields (the serialized half of Config)
per call. This is measurably more work than the pre-refactor code,
which passed a shared borrow. The allocation cost is:
- ~15 callsites × ~40 field clones each = ~600 extra heap operations per full CLI invocation
- In practice, most of these are
&str/String/ primitive clones, plus a fewIndexMapandVecclones — dominated byclients: Vec<ClientConfig> - Total cost per call: well under 1ms, invisible to users
- Cost ends in Step 8f/8g when callers hold
Arc<AppState>directly and can pass&app.configwithout cloning
The plan flagged this as an acceptable bridge-window cost, and the measurements back that up. No optimization is needed.
4. No use of deprecated forwarders
Unlike Steps 3-7 which added new methods alongside the old ones,
Step 8a is a one-shot signature change of 4 functions plus
their 15 callers. The bridge helper is Config::to_app_config()
(already existed); the new signature is on the same function
(not a parallel new function). This is consistent with the plan's
Step 8a description of "one-shot refactor with bridge helper."
5. Did not touch init_client, GlobalConfig, or client instance state
The register_client! macro defines $Client::init(global_config, model) and init_client(config, model) — both take
&GlobalConfig and read config.read().model (the runtime field).
These are not Step 8a targets. They stay on GlobalConfig
through the bridge and migrate in Step 8f/8g when callers switch
from GlobalConfig to Arc<AppState> + RequestContext.
Deviations from plan
None of substance. The plan's Step 8a description was clear and straightforward; the implementation matches it closely. Two minor departures:
-
Used existing
to_app_config()instead of addingapp_config_snapshot()— see Key Decision #1. The plan's intent was a helper that clones serialized fields; both names describe the same thing. -
Count: 15 callsite updates, not 17 — the plan said "any callsite that currently calls these client functions." I found 15 via
grep. The count is close enough that this isn't a meaningful deviation, just an accurate enumeration.
Verification
Compilation
cargo check— clean, zero warnings, zero errorscargo clippy— clean
Tests
cargo test— 63 passed, 0 failed (unchanged from Steps 1–7)
Step 8a added no new tests — it's a mechanical signature change with no new behavior to verify. The existing test suite confirms:
- The bridge round-trip test still passes (uses
Config::to_app_config(), which is the bridge helper) - The
config::bridge::tests::*suite — all 4 tests pass - No existing test broke
Manual smoke test
Not performed as part of this step (would require running a real
LLM request with various models). The plan's Step 8a verification
suggests loki --model openai:gpt-4o "hello" as a sanity check,
but that requires API credentials and a live LLM. A representative
smoke test should be performed before declaring Phase 1 complete
(in Step 10 or during release prep).
The signature change is mechanical — if it compiles and existing
tests pass, the runtime behavior is identical by construction. The
only behavior difference would be the extra to_app_config()
clones, which don't affect correctness.
Handoff to next step
What Step 8b can rely on
Step 8b (finish Step 7's deferred mixed-method migrations) can rely on:
Model::retrieve_model(&AppConfig, ...)— available for the migratedretrieve_rolemethod onRequestContextlist_models(&AppConfig, ModelType)— available forrepl_completeandsetup_modelmigrationlist_all_models(&AppConfig)— available for internal uselist_client_names(&AppConfig)— available (though typically only called from insideretrieve_model)Config::to_app_config()bridge helper — still works, still used by the oldConfigmethods that call the client functions through the bridge- All existing Config-based methods that use these functions
(e.g.,
Config::set_model,Config::retrieve_role,Config::setup_model) still compile and still work — they now callself.to_app_config()internally to adapt the signature
What Step 8b should watch for
-
The 9 Step 7 deferrals waiting for Step 8b:
retrieve_role(blocked byretrieve_model— now unblocked)set_model(blocked byretrieve_model— now unblocked)repl_complete(blocked bylist_models— now unblocked)setup_model(blocked bylist_models— now unblocked)use_prompt(callscurrent_model+use_role_obj— already unblocked; was deferred because it's a one-liner not worth migrating alone)edit_role(callseditor+upsert_role+use_role—use_roleis still Step 8d, soedit_rolemay stay deferred)set_rag_reranker_model(takes&GlobalConfig, usesupdate_raghelper — may stay deferred to Step 8f/8g)set_rag_top_k(same)update(dispatcher over allset_*— needs all its dependencies migrated first)
-
set_modelsplit pattern. The oldConfig::set_modeldoesrole_like_mutdispatch. Step 8b should split it intoRequestContext::set_model_on_role_like(&mut self, app: &AppConfig, model_id: &str) -> Result<bool>(returns whether a RoleLike was mutated) +AppConfig::set_model_default(&mut self, model_id: &str, model: Model)(sets the global default model). -
retrieve_rolemigration pattern. The method takes&selftoday. OnRequestContextit becomes(&self, app: &AppConfig, name: &str) -> Result<Role>. The body callspaths::list_roles,paths::role_file,Role::new,Role::builtin, thenself.current_model()(already on RequestContext from Step 7), thenModel::retrieve_model(app, ...). -
setup_modelhas a subtle split. It writes toself.model_id(serialized) ANDself.model(runtime) AND callsself.set_model(&model_id)(mixed). Step 8b should split this into:AppConfig::ensure_default_model_id(&mut self, &AppConfig)(or similar) to pick the first available model and updateself.model_idRequestContext::reload_current_model(&mut self, app: &AppConfig)to refreshctx.modelfrom the resolved id
What Step 8b should NOT do
- Don't touch
init_client,GlobalConfig, or any function with "runtime model state" concerns — those are Step 8f/8g. - Don't migrate
use_role,use_session,use_agent,exit_agent— those are Step 8d (after Step 8c extractsMcpFactory::acquire()). - Don't migrate RAG lifecycle methods (
use_rag,edit_rag_docs,rebuild_rag,compress_session,autoname_session,apply_prelude) — those are Step 8e. - Don't touch
main.rsentry points orrepl/mod.rs— those are Step 8f and 8g respectively.
Files to re-read at the start of Step 8b
docs/PHASE-1-IMPLEMENTATION-PLAN.md— Step 8b section- This notes file — especially the "What Step 8b should watch for" section above
src/config/mod.rs— currentConfig::retrieve_role,Config::set_model,Config::repl_complete,Config::setup_model,Config::use_prompt,Config::edit_rolemethod bodiessrc/config/app_config.rs— current state ofAppConfigimpl blocks (Steps 3+4+7)src/config/request_context.rs— current state ofRequestContextimpl blocks (Steps 5+6+7)
Follow-up (not blocking Step 8b)
1. The OnceLock caches in the macro will seed once per process
ALL_CLIENT_NAMES and ALL_MODELS are OnceLocks initialized
lazily on first call. After Step 8a, the first call passes an
AppConfig. If a test or an unusual code path happens to call
one of these functions twice with different AppConfig values
(different clients lists), only the first seeding wins. This
was already true before Step 8a — the types changed but the
caching semantics are unchanged.
Worth flagging so nobody writes a test that relies on re-initializing the caches.
2. Bridge-window duplication count at end of Step 8a
Unchanged from end of Step 7:
AppConfig(Steps 3+4+7): 17 methodsRequestContext(Steps 5+6+7): 39 methodspathsmodule (Step 2): 33 free functions- Step 6.5 types: 4 new types
Total: 56 methods / ~1200 lines of parallel logic
Step 8a added zero duplication — it's a signature change of existing functions, not a parallel implementation.
3. to_app_config() is called from 9 places now
After Step 8a, these files call to_app_config():
src/config/mod.rs— 6 callsites (forModel::retrieve_modelandlist_models)src/config/session.rs— 1 callsitesrc/config/agent.rs— 1 callsitesrc/function/supervisor.rs— 1 callsitesrc/rag/mod.rs— 4 callsitessrc/main.rs— 1 callsitesrc/cli/completer.rs— 1 callsite
Total: 15 callsites. All get eliminated in Step 8f/8g when
their callers migrate to hold Arc<AppState> directly. Until
then, each call clones ~40 fields. Measured cost: negligible.
4. The #[allow(dead_code)] on impl Config in bridge.rs
Config::to_app_config() is now actively used by 15 callsites
— it's no longer dead. But Config::to_request_context and
Config::from_parts are still only used by the bridge tests. The
#[allow(dead_code)] on the impl Config block is harmless
either way (it doesn't fire warnings, it just suppresses them
if they exist). Step 10 deletes the whole file anyway.
References
- Phase 1 plan:
docs/PHASE-1-IMPLEMENTATION-PLAN.md - Step 7 notes:
docs/implementation/PHASE-1-STEP-7-NOTES.md - Modified files:
src/client/macros.rs(3 function signatures in theregister_client!macro)src/client/model.rs(usestatement +retrieve_modelsignature)src/config/mod.rs(6 callsite updates inset_rag_reranker_model,set_model,retrieve_role,repl_complete×2,setup_model)src/config/session.rs(1 callsite inSession::load)src/config/agent.rs(1 callsite inAgent::init)src/function/supervisor.rs(1 callsite in sub-agent summarization)src/rag/mod.rs(4 callsites inRag::create,Rag::init,Rag::search)src/main.rs(1 callsite in--list-modelshandler)src/cli/completer.rs(1 callsite in shell completion)