Files
loki/docs/implementation/PHASE-1-STEP-16-NOTES.md

444 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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'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 16a16d 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<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` — 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<GlobalVault>` 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<AppConfig>,
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<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` — 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<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)
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<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. `Config` has only serde fields.