# 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, ...).await → AppState AppState (shared process state: vault, mcp_factory, rag_cache, mcp_registry, functions) ↓ RequestContext::new(Arc, 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`** absorbs: - Field-copy from `Config` (same as today's `to_app_config`) - `load_envs()` — env var overrides - `set_wrap()` — wrap string validation - `setup_document_loaders()` — default pdf/docx loaders - `setup_user_agent()` — resolve `"auto"` user agent - `resolve_model()` — logic from `Config::setup_model` (picks default model if `model_id` is 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(...)` (currently `Config::load_mcp_servers`) - `Functions::init(...)` (currently `Config::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:** ```rust impl AppConfig { pub fn from_config(config: Config) -> Result { 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` — add `from_config`, `resolve_model` - Also remove `#[allow(dead_code)]` from `load_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:** ```rust // 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()?;` and `Macro::install_macros()?;` calls from inside `Config::init` - Add `install_builtins()?;` to `main.rs` as 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 from `Config::init`, expose `install_builtins` as a module-level pub fn - `src/main.rs` — call `install_builtins()?;` at startup ## 16c — Vault → AppState Today `Config.vault: Arc` is a `#[serde(skip)]` runtime field populated by `Vault::init(config)`. Post-16c, the vault lives natively on `AppState`. **Current:** ```rust pub struct AppState { pub config: Arc, pub vault: GlobalVault, // Already here, sourced from config.vault ... } ``` Wait — `AppState.vault` already exists. The work in 16c is just: 1. Change `Vault::init(config: &Config)` → `Vault::init(config: &AppConfig)` - `Vault::init` only reads `config.vault_password_file()`, which is already a serde field on AppConfig. Rename the param. 2. Delete `Config.vault` field (no longer needed once 16e routes through AppState) 3. Update `main.rs` to call `Vault::init(&app_config)` instead of `cfg.vault.clone()` **Files modified (16c):** - `src/vault/mod.rs` — `Vault::init` takes `&AppConfig` - `src/config/mod.rs` — delete `Config.vault` field (after callers switch) ## 16d — AppState::init **Target signature:** ```rust impl AppState { pub async fn init( config: Arc, log_path: Option, start_mcp_servers: bool, abort_signal: AbortSignal, ) -> Result { 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` — 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` — add `init`, add `mcp_registry` + `functions` fields - `src/mcp/mod.rs` — `McpRegistry::init` takes `&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:** ```rust 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: 1. Load Config from YAML (also returns raw content) 2. Bootstrap Vault using Config's `vault_password_file` serde field 3. Interpolate secrets in raw content 4. If content changed, re-parse Config 5. Build AppConfig from final Config 6. 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 in `config/mod.rs` **Resulting `Config`:** ```rust #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(default)] pub struct Config { // Only serde-annotated fields — the YAML shape pub model_id: String, pub temperature: Option, // ... 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 { ... } } ``` A genuine POJO. No runtime state. No init logic. Just shape. ## Open questions (for execution) 1. **`Vault::init_bare`** — currently used as a fallback when no Config exists. Does it still need to exist? The default `vault_password_file` location is static. Might need `AppConfig::default().vault_password_file()` or a free function. 2. **Secret interpolation ownership** — does `AppConfig::from_config` handle it internally (takes raw YAML string and interpolates), or does `main.rs` orchestrate the two-pass explicitly? Leaning toward `main.rs` orchestration (cleaner separation). 3. **REPL-only `append_user_interaction_functions`** — moves to `RequestContext::new`? Or stays as a post-init append called explicitly? 4. **`Functions::init` + MCP meta functions** — today `load_mcp_servers` calls `self.functions.append_mcp_meta_functions(...)` after starting servers. In the new flow, `AppState::init` does this. Verify ordering is preserved. 5. **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 `Config` burdened 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.rs` entirely - Makes `Config` a true POJO - Makes `AppConfig`/`AppState` self-contained (initialize from YAML directly) - REST API path is trivial: `AppConfig::from_config(yaml_string)` - Test helpers can build `AppConfig`/`AppState` without Config - Each struct owns exactly its concerns ## Verification criteria for each sub-phase - 16a: `cargo check` + `cargo test` clean. `AppConfig::from_config` produces the same state as `Config::init` + `to_app_config()` for the same YAML input. - 16b: `install_builtins()` called once from `main.rs`; agents and macros still install on first startup. - 16c: `Vault::init` takes `&AppConfig`; `Config.vault` field deleted. - 16d: `AppState::init` builds a fully-wired `AppState` from `Arc` + 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. `Config` has only serde fields.