16 KiB
Phase 1 Step 16 — Implementation Notes
Status
Pending. Architecture plan approved; ready for sub-phase execution.
Plan reference
- Plan:
docs/PHASE-1-IMPLEMENTATION-PLAN.md - Section: "Step 16: Complete Config → AppConfig Migration (Post-QA)"
Problem
The current startup flow mutates Config during Config::init(),
then converts it to AppConfig via bridge.rs::to_app_config(). This
design was transitional — it let us build the new structs alongside
the old one without a big-bang migration.
Now that the transition is nearly done, we want Config to be a
genuine serde POJO: no runtime state, no init logic, nothing that
couldn't round-trip through YAML. The structs that actually represent
runtime state (AppConfig, AppState, RequestContext) should own
their own initialization logic.
Target architecture
Instead of migrating mutations incrementally through the bridge, we pivot the initialization direction. Each struct owns its own init.
YAML file
↓ Config::load_from_file (serde only — no init logic)
Config (pure POJO)
↓ AppConfig::from_config(config) → AppConfig
AppConfig (immutable app-wide settings, self-initializing)
↓ AppState::init(Arc<AppConfig>, ...).await → AppState
AppState (shared process state: vault, mcp_factory, rag_cache, mcp_registry, functions)
↓ RequestContext::new(Arc<AppState>, working_mode)
RequestContext (per-request mutable state, unchanged)
Struct responsibilities (post-16)
Config — trivial serde POJO:
- Only
#[serde(...)]fields (no#[serde(skip)]) - Only method:
load_from_file(path) -> Result<(Config, String)>(returns parsed Config + raw YAML content for secret interpolation) - Can be round-tripped via YAML
AppConfig::from_config(config) -> Result<AppConfig> absorbs:
- Field-copy from
Config(same as today'sto_app_config) load_envs()— env var overridesset_wrap()— wrap string validationsetup_document_loaders()— default pdf/docx loaderssetup_user_agent()— resolve"auto"user agentresolve_model()— logic fromConfig::setup_model(picks default model ifmodel_idis empty)
AppState::init(app_config, log_path, start_mcp_servers, abort_signal) absorbs:
Vault::init(&app_config)(vault moves from Config to AppState)McpRegistry::init(...)(currentlyConfig::load_mcp_servers)Functions::init(...)(currentlyConfig::load_functions)- Returns fully-wired
AppState
install_builtins() — top-level free function (replaces
Agent::install_builtin_agents() + Macro::install_macros() being
called inside Config::init). Called once from main.rs before any
config loading. Config-independent — just copies embedded assets.
bridge.rs — deleted. No more to_app_config() /
to_request_context().
Sub-phase layout
| Sub-phase | Scope |
|---|---|
| 16a | Build AppConfig::from_config absorbing env/wrap/docs/user-agent/model-resolution logic |
| 16b | Extract install_builtins() as top-level function |
| 16c | Migrate Vault onto AppState |
| 16d | Build AppState::init absorbing MCP-registry/functions logic |
| 16e | Update main.rs + audit all 15 Config::init() callers, switch to new flow |
| 16f | Delete Config runtime fields, bridge.rs, Config::init, duplicated methods |
Sub-phases 16a–16d can largely proceed in parallel (each adds new entry points without removing the old ones). 16e switches callers. 16f is the final cleanup.
16a — AppConfig::from_config
Target signature:
impl AppConfig {
pub fn from_config(config: Config) -> Result<Self> {
let mut app_config = Self {
// Copy all serde fields from config
};
app_config.load_envs();
if let Some(wrap) = app_config.wrap.clone() {
app_config.set_wrap(&wrap)?;
}
app_config.setup_document_loaders();
app_config.setup_user_agent();
app_config.resolve_model()?;
Ok(app_config)
}
fn resolve_model(&mut self) -> Result<()> {
if self.model_id.is_empty() {
let models = crate::client::list_models(self, ModelType::Chat);
if models.is_empty() {
bail!("No available model");
}
self.model_id = models[0].id();
}
Ok(())
}
}
New method: AppConfig::resolve_model() — moves logic from
Config::setup_model. Ensures model_id is a valid, non-empty
concrete model reference.
Note on Model vs model_id: Model (the resolved runtime
handle) stays on RequestContext. AppConfig owns model_id: String
(the config default). RequestContext.model is built by calling
Model::retrieve_model(&app_config, &model_id, ModelType::Chat)
during context construction. They're different types for a reason.
Files modified (16a):
src/config/app_config.rs— addfrom_config,resolve_model- Also remove
#[allow(dead_code)]fromload_envs,set_wrap,setup_document_loaders,setup_user_agent,set_*_default,ensure_default_model_id(they all become reachable)
Bridge still exists after 16a. Config::init still calls its own
mutations for now. 16a just introduces the new entry point.
16b — install_builtins()
Target signature:
// In src/config/mod.rs or a new module
pub fn install_builtins() -> Result<()> {
Agent::install_builtin_agents()?;
Macro::install_macros()?;
Ok(())
}
Changes:
- Remove
Agent::install_builtin_agents()?;andMacro::install_macros()?;calls from insideConfig::init - Add
install_builtins()?;tomain.rsas the first step before any config loading
Both functions are Config-independent (they just copy embedded assets to the config directory), so this is a straightforward extraction.
Files modified (16b):
src/config/mod.rs— remove calls fromConfig::init, exposeinstall_builtinsas a module-level pub fnsrc/main.rs— callinstall_builtins()?;at startup
16c — Vault → AppState
Today Config.vault: Arc<GlobalVault> is a #[serde(skip)] runtime
field populated by Vault::init(config). Post-16c, the vault lives
natively on AppState.
Current:
pub struct AppState {
pub config: Arc<AppConfig>,
pub vault: GlobalVault, // Already here, sourced from config.vault
...
}
Wait — AppState.vault already exists. The work in 16c is just:
- Change
Vault::init(config: &Config)→Vault::init(config: &AppConfig)Vault::initonly readsconfig.vault_password_file(), which is already a serde field on AppConfig. Rename the param.
- Delete
Config.vaultfield (no longer needed once 16e routes through AppState) - Update
main.rsto callVault::init(&app_config)instead ofcfg.vault.clone()
Files modified (16c):
src/vault/mod.rs—Vault::inittakes&AppConfigsrc/config/mod.rs— deleteConfig.vaultfield (after callers switch)
16d — AppState::init
Target signature:
impl AppState {
pub async fn init(
config: Arc<AppConfig>,
log_path: Option<PathBuf>,
start_mcp_servers: bool,
abort_signal: AbortSignal,
) -> Result<Self> {
let vault = Vault::init(&config);
let functions = {
let mut fns = Functions::init(
config.visible_tools.as_ref().unwrap_or(&Vec::new())
)?;
// REPL-specific fns appended by RequestContext, not here
fns
};
let mcp_registry = McpRegistry::init(
log_path.clone(),
start_mcp_servers,
config.enabled_mcp_servers.clone(),
abort_signal,
&config, // new signature: &AppConfig
).await?;
let (mcp_config, mcp_log_path) = (
mcp_registry.mcp_config().cloned(),
mcp_registry.log_path().cloned(),
);
Ok(Self {
config,
vault,
mcp_factory: Default::default(),
rag_cache: Default::default(),
mcp_config,
mcp_log_path,
mcp_registry: Some(mcp_registry), // NEW field
functions, // NEW field
})
}
}
New AppState fields:
mcp_registry: Option<McpRegistry>— the live registry of started MCP servers (currently on Config)functions: Functions— the global function declarations (currently on Config)
These become the "source of truth" that ToolScope copies from when
a scope transition happens.
McpRegistry::init signature change: today takes &Config,
needs to take &AppConfig. Only reads serialized fields.
Files modified (16d):
src/config/app_state.rs— addinit, addmcp_registry+functionsfieldssrc/mcp/mod.rs—McpRegistry::inittakes&AppConfig
Important: Functions.append_user_interaction_functions() is
currently called inside Config::load_functions when in REPL mode.
That logic is working-mode-dependent and belongs on RequestContext
(which knows its mode), not AppState. The migration moves that
append step to RequestContext::new or similar.
16e — Switch main.rs and 15 callers
New main.rs flow:
install_builtins()?;
let (config, raw_yaml) = Config::load_from_file(&paths::config_file())?;
// Secret interpolation (two-pass)
let bootstrap_vault = Vault::init_from_password_file(&config.vault_password_file());
let interpolated = interpolate_secrets_or_err(&raw_yaml, &bootstrap_vault, info_flag)?;
let final_config = if interpolated != raw_yaml {
Config::load_from_str(&interpolated)?
} else {
config
};
let app_config = Arc::new(AppConfig::from_config(final_config)?);
let app_state = Arc::new(
AppState::init(
app_config.clone(),
log_path,
start_mcp_servers,
abort_signal.clone(),
).await?
);
let ctx = RequestContext::new(app_state.clone(), working_mode);
Secret interpolation complication: Today's Config::init does a
two-pass YAML parse — load, init vault, interpolate secrets into raw
content, re-parse if content changed. In the new flow:
- Load Config from YAML (also returns raw content)
- Bootstrap Vault using Config's
vault_password_fileserde field - Interpolate secrets in raw content
- If content changed, re-parse Config
- Build AppConfig from final Config
- Build AppState (creates the full Vault via
Vault::init(&app_config))
Step 2 and step 6 create the vault twice — once bootstrap (to decrypt secrets in raw YAML), once full (for AppState). This matches current behavior, just made explicit.
15 callers of Config::init() — audit required. Discovery
happens during 16e execution. Open questions flagged for user input
as discovered.
| File | Expected Action |
|---|---|
main.rs |
Use new flow |
client/common.rs |
Probably needs AppConfig only |
vault/mod.rs |
Already uses Config::vault_password_file; switch to AppConfig |
config/request_context.rs |
Test helper — use AppState::test_default() or build directly |
config/session.rs |
Test helper — same |
rag/mod.rs |
Probably AppConfig |
function/supervisor.rs |
Test helper |
utils/request.rs |
Probably AppConfig |
config/role.rs |
Test helper |
utils/clipboard.rs |
Probably AppConfig |
supervisor/mod.rs |
Test helper |
repl/mod.rs |
Test helper |
parsers/common.rs |
Probably AppConfig |
utils/abort_signal.rs |
Probably AppConfig |
utils/spinner.rs |
Probably AppConfig |
Files modified (16e):
src/main.rs- Any of the 15 files above that aren't trivial — may need
test_default()helpers added
16f — Final cleanup
Delete from Config:
- All
#[serde(skip)]fields: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 Config::init(whole function)Config::load_envs,Config::load_functions,Config::load_mcp_servers,Config::setup_model,Config::set_model,Config::role_like_mut,Config::sessions_dir,Config::set_wrap,Config::setup_document_loaders,Config::setup_user_agent,Config::vault_password_file(redundant with AppConfig's)
Delete:
src/config/bridge.rs(entire file)mod bridge;declaration inconfig/mod.rs
Resulting Config:
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct Config {
// Only serde-annotated fields — the YAML shape
pub model_id: String,
pub temperature: Option<f64>,
// ... all the other serde fields
}
impl Config {
pub fn load_from_file(path: &Path) -> Result<(Config, String)> { ... }
pub fn load_from_str(content: &str) -> Result<Config> { ... }
}
A genuine POJO. No runtime state. No init logic. Just shape.
Open questions (for execution)
Vault::init_bare— currently used as a fallback when no Config exists. Does it still need to exist? The defaultvault_password_filelocation is static. Might needAppConfig::default().vault_password_file()or a free function.- Secret interpolation ownership — does
AppConfig::from_confighandle it internally (takes raw YAML string and interpolates), or doesmain.rsorchestrate the two-pass explicitly? Leaning towardmain.rsorchestration (cleaner separation). - REPL-only
append_user_interaction_functions— moves toRequestContext::new? Or stays as a post-init append called explicitly? Functions::init+ MCP meta functions — todayload_mcp_serverscallsself.functions.append_mcp_meta_functions(...)after starting servers. In the new flow,AppState::initdoes this. Verify ordering is preserved.- Testing strategy — User said don't worry unless trivial. If
test helpers need refactoring to work with new flow, prefer
adding
test_default()methods gated by#[cfg(test)]over rewriting tests.
Dependencies between sub-phases
16a ──┐
16b ──┤
16c ──┼──→ 16d ──→ 16e ──→ 16f
│
16b, 16c, 16a independent and can run in any order
16d depends on 16c (vault on AppConfig)
16e depends on 16a, 16d (needs the new entry points)
16f depends on 16e (needs all callers switched)
Rationale for this architecture
The original Step 16 plan migrated mutations piecewise through the
existing to_app_config() bridge. That works but:
- Leaves the bridge in place indefinitely
- Keeps
Configburdened with both YAML shape AND runtime state - Requires careful ordering to avoid breaking downstream consumers
like
load_functions/load_mcp_servers/setup_model - Creates transitional states where some mutations live on Config, some on AppConfig
The new approach:
- Eliminates
bridge.rsentirely - Makes
Configa true POJO - Makes
AppConfig/AppStateself-contained (initialize from YAML directly) - REST API path is trivial:
AppConfig::from_config(yaml_string) - Test helpers can build
AppConfig/AppStatewithout Config - Each struct owns exactly its concerns
Verification criteria for each sub-phase
- 16a:
cargo check+cargo testclean.AppConfig::from_configproduces the same state asConfig::init+to_app_config()for the same YAML input. - 16b:
install_builtins()called once frommain.rs; agents and macros still install on first startup. - 16c:
Vault::inittakes&AppConfig;Config.vaultfield deleted. - 16d:
AppState::initbuilds a fully-wiredAppStatefromArc<AppConfig>+ startup context. MCP servers start; functions load. - 16e: REPL starts, all CLI flags work, all env vars honored, all existing tests pass.
- 16f: Grep for
Config {/Config::init(/bridge::to_shows zero non-test hits.Confighas only serde fields.