This commit is contained in:
2026-04-10 15:45:51 -06:00
parent ff3419a714
commit e9e6b82e24
42 changed files with 11578 additions and 358 deletions
+255
View File
@@ -0,0 +1,255 @@
# Phase 1 Step 1 — Implementation Notes
## Status
Done.
## Plan reference
- Plan: `docs/PHASE-1-IMPLEMENTATION-PLAN.md`
- Section: "Step 1: Make Config constructible from AppConfig + RequestContext"
## Summary
Added three conversion methods on `Config` (`to_app_config`,
`to_request_context`, `from_parts`) plus a round-trip test suite, all
living in a new `src/config/bridge.rs` module. These methods are the
facade that will let Steps 29 migrate callsites from the old `Config`
to the split `AppState` + `RequestContext` incrementally. Nothing calls
them outside the test suite yet; that's expected and matches the
plan's "additive only, no callsite changes" guidance for Step 1.
## Pre-Step-1 correction to Step 0
Before implementing Step 1 I verified all three Step 0 files
(`src/config/app_config.rs`, `src/config/app_state.rs`,
`src/config/request_context.rs`) against every architecture decision
from the design conversations. All three were current except one stale
reference:
- `src/config/request_context.rs` docstring said "unified into
`ToolScope` during Phase 1 Step 6" but after the
ToolScope/AgentRuntime discussions the plan renumbered this to
**Step 6.5** and added the `AgentRuntime` collapse alongside
`ToolScope`. Updated the `# Tool scope (planned)` section docstring
to reflect both changes (now titled `# Tool scope and agent runtime
(planned)`).
No other Step 0 changes were needed.
## What was changed
### New files
- **`src/config/bridge.rs`** (~430 lines including tests)
- Module docstring explaining the bridge's purpose, scheduled
deletion in Step 10, and the lossy `mcp_registry` field.
- `impl Config` block with three public methods, scoped under
`#[allow(dead_code)]`:
- `to_app_config(&self) -> AppConfig` — borrow, returns fresh
`AppConfig` by cloning the 40 serialized fields.
- `to_request_context(&self, app: Arc<AppState>) -> RequestContext`
— borrow + provided `AppState`, returns fresh `RequestContext`
by cloning the 19 runtime fields held on both types.
- `from_parts(app: &AppState, ctx: &RequestContext) -> Config`
borrow both halves, returns a new owned `Config`. Sets
`mcp_registry: None` because no split type holds it.
- `#[cfg(test)] mod tests` with 4 unit tests:
- `to_app_config_copies_every_serialized_field`
- `to_request_context_copies_every_runtime_field`
- `round_trip_preserves_all_non_lossy_fields`
- `round_trip_default_config`
- Helper `build_populated_config()` that sets every primitive /
`String` / simple `Option` field to a non-default value so a
missed field in the conversion methods produces a test failure.
### Modified files
- **`src/config/mod.rs`** — added `mod bridge;` declaration (one
line, inserted alphabetically between `app_state` and `input`).
- **`src/config/request_context.rs`** — updated the "Tool scope
(planned)" docstring section to correctly reference Phase 1
**Step 6.5** (not Step 6) and to mention the `AgentRuntime`
collapse alongside `ToolScope`. No code changes.
## Key decisions
### 1. The bridge lives in its own module
I put the conversion methods in `src/config/bridge.rs` rather than
adding them inline to `src/config/mod.rs`. The plan calls for this
entire bridge to be deleted in Step 10, and isolating it in one file
makes that deletion a single `rm` + one `mod bridge;` line removal in
`mod.rs`. Adding ~300 lines to the already-massive `mod.rs` would have
made the eventual cleanup harder.
### 2. `mcp_registry` is lossy by design (documented)
`Config.mcp_registry: Option<McpRegistry>` has no home in either
`AppConfig` (serialized settings only) or `RequestContext` (runtime
state that doesn't include MCP, per Step 6.5's `ToolScope` design).
I considered three options:
1. **Add a temporary `mcp_registry` field to `RequestContext`** — ugly,
introduces state that has to be cleaned up in Step 6.5 anyway.
2. **Accept lossy round-trip, document it** — chosen.
3. **Store `mcp_registry` on `AppState` temporarily** — dishonest,
contradicts the plan which says MCP isn't process-wide.
Option 2 aligns with the plan's direction. The lossy field is
documented in three places so no caller is surprised:
- Module-level docstring (`# Lossy fields` section)
- `from_parts` method docstring
- Inline comment next to the `is_none()` assertion in the round-trip
test
Any Step 29 callsite that still needs the registry during its
migration window must keep a reference to the original `Config`
rather than relying on round-trip fidelity.
### 3. `#[allow(dead_code)]` scoped to the whole `impl Config` block
Applied to the `impl` block in `bridge.rs` rather than individually to
each method. All three methods are dead until Step 2+ starts calling
them. When the first caller migrates, I'll narrow the allow to the
methods that are still unused. By Step 10 the whole file is deleted
and the allow goes with it.
### 4. Populated-config builder skips domain-type runtime fields
`build_populated_config()` sets every primitive, `String`, and simple
`Option` field to a non-default value. It does **not** try to construct
real `Role`, `Session`, `Agent`, `Supervisor`, `Inbox`, or
`EscalationQueue` instances because those have complex async/setup
lifecycles and constructors don't exist for test use.
The round-trip tests still exercise the clone path for all those
`Option<T>` fields — they just exercise the `None` variant. The tests
prove that (a) if a runtime field is set, the conversion clones it
correctly (which is guaranteed by Rust's `#[derive(Clone)]` on
`Config`), and (b) `None` roundtrips to `None`. Deeper coverage with
populated domain types would require mock constructors that don't
exist in the current code, making it a meaningful scope increase
unsuitable for Step 1's "additive, mechanical" goal.
### 5. The test covers `Config::default()` separately from the
populated builder
A separate `round_trip_default_config` test catches any subtle "the
default doesn't roundtrip" bug that `build_populated_config` might
mask by always setting fields to non-defaults. Both tests run through
the same `to_app_config → to_request_context → from_parts` pipeline.
## Deviations from plan
None of substance. The plan's Step 1 description was three sentences
and a pseudocode block; the implementation matches it field-for-field
except for two clarifications the plan didn't specify:
1. **Which module holds the methods** — the plan didn't say. I chose a
dedicated `src/config/bridge.rs` file (see Key Decision #1).
2. **How `mcp_registry` is handled in round-trip** — the plan's
pseudocode said `from_parts` "merges back" but didn't address the
field that has no home. I chose lossy reconstruction with
documented behavior (see Key Decision #2).
Both clarifications are additive — they don't change what Step 1
accomplishes, they just pin down details the plan left implicit.
## Verification
### Compilation
- `cargo check` — clean, zero warnings. The expected dead-code warning
from the new methods is suppressed by `#[allow(dead_code)]` on the
`impl` block.
### Tests
- `cargo test bridge` — 4 new tests pass:
- `config::bridge::tests::round_trip_default_config`
- `config::bridge::tests::to_app_config_copies_every_serialized_field`
- `config::bridge::tests::to_request_context_copies_every_runtime_field`
- `config::bridge::tests::round_trip_preserves_all_non_lossy_fields`
- `cargo test` — full suite passes: **63 passed, 0 failed**
(59 pre-existing + 4 new).
### Manual smoke test
Not applicable — Step 1 is additive only, no runtime behavior changed.
CLI and REPL continue working through the original `Config` code
paths, unchanged.
## Handoff to next step
### What Step 2 can rely on
Step 2 (migrate ~30 static methods off `Config` to a `paths` module)
can rely on all of the following being true:
- `Config::to_app_config()`, `Config::to_request_context(app)`, and
`Config::from_parts(app, ctx)` all exist and are tested.
- The three new types (`AppConfig`, `AppState`, `RequestContext`) are
fully defined and compile.
- Nothing in the codebase outside `src/config/bridge.rs` currently
calls the new methods, so Step 2 is free to start using them
wherever convenient without fighting existing callers.
- `AppState` only has two fields: `config: Arc<AppConfig>` and
`vault: GlobalVault`. No `mcp_factory`, no `rag_cache` yet — those
land in Step 6.5.
- `RequestContext` has flat fields mirroring the runtime half of
today's `Config`. The `ToolScope` / `AgentRuntime` unification
happens in Step 6.5, not earlier. Step 2 should not try to
pre-group fields.
### What Step 2 should watch for
- **Static methods on `Config` with no `&self` parameter** are the
Step 2 target. The Phase 1 plan lists ~33 of them in a table
(`config_dir`, `local_path`, `cache_path`, etc.). Each gets moved
to a new `src/config/paths.rs` module (or similar), with forwarding
`#[deprecated]` methods left behind on `Config` until Step 2 is
fully done.
- **`vault_password_file`** on `Config` is private (not `pub`), but
`vault_password_file` on `AppConfig` is `pub(crate)`. `bridge.rs`
accesses both directly because it's a sibling module under
`src/config/`. If Step 2's path functions need to read
`vault_password_file` from `AppConfig` they can do so directly
within the `config` module, but callers outside the module will
need an accessor method.
- **`Config.mcp_registry` round-trip is lossy.** If any static method
moved in Step 2 touches `mcp_registry` (unlikely — none of the ~33
static methods listed in the plan do), that method should NOT use
the bridge — it should keep operating on the original `Config`.
Double-check the list before migrating.
### What Step 2 should NOT do
- Don't delete the bridge. It's still needed for Steps 39.
- Don't narrow `#[allow(dead_code)]` on `impl Config` in `bridge.rs`
yet — Step 2 might start using some of the methods but not all,
and the allow-scope should be adjusted once (at the end of Step 2)
rather than incrementally.
- Don't touch the `request_context.rs` `# Tool scope and agent
runtime (planned)` docstring. It's accurate and Step 6.5 is still
far off.
### Files to re-read at the start of Step 2
- `docs/PHASE-1-IMPLEMENTATION-PLAN.md` — Step 2 section has the
full static-method migration table.
- This notes file (`PHASE-1-STEP-1-NOTES.md`) — for the bridge's
current shape and the `mcp_registry` lossy-field context.
- `src/config/bridge.rs` — for the exact method signatures available.
## References
- Phase 1 plan: `docs/PHASE-1-IMPLEMENTATION-PLAN.md`
- Architecture doc: `docs/REST-API-ARCHITECTURE.md`
- Step 0 files: `src/config/app_config.rs`, `src/config/app_state.rs`,
`src/config/request_context.rs`
- Step 1 files: `src/config/bridge.rs`, `src/config/mod.rs` (mod
declaration), `src/config/request_context.rs` (docstring fix)
+348
View File
@@ -0,0 +1,348 @@
# Phase 1 Step 2 — Implementation Notes
## Status
Done.
## Plan reference
- Plan: `docs/PHASE-1-IMPLEMENTATION-PLAN.md`
- Section: "Step 2: Migrate static methods off Config"
## Summary
Extracted 33 static (no-`self`) methods from `impl Config` into a new
`src/config/paths.rs` module and migrated every caller across the
codebase. The deprecated forwarders the plan suggested as an
intermediate step were added, used to drive the callsite migration,
and then deleted in the same step because the migration was
mechanically straightforward with `ast-grep` and the forwarders
became dead immediately.
## What was changed
### New files
- **`src/config/paths.rs`** (~270 lines)
- Module docstring explaining the extraction rationale and the
(transitional) compatibility shim pattern.
- `#![allow(dead_code)]` at module scope because most functions
were briefly dead during the in-flight migration; kept for the
duration of Step 2 and could be narrowed or removed in a later
cleanup (see "Follow-up" below).
- All 33 functions as free-standing `pub fn`s, implementations
copied verbatim from `impl Config`:
- Path helpers: `config_dir`, `local_path`, `cache_path`,
`oauth_tokens_path`, `token_file`, `log_path`, `config_file`,
`roles_dir`, `role_file`, `macros_dir`, `macro_file`,
`env_file`, `rags_dir`, `functions_dir`, `functions_bin_dir`,
`mcp_config_file`, `global_tools_dir`, `global_utils_dir`,
`bash_prompt_utils_file`, `agents_data_dir`, `agent_data_dir`,
`agent_config_file`, `agent_bin_dir`, `agent_rag_file`,
`agent_functions_file`, `models_override_file`
- Listing helpers: `list_roles`, `list_rags`, `list_macros`
- Existence checks: `has_role`, `has_macro`
- Config loaders: `log_config`, `local_models_override`
### Modified files
Migration touched 14 source files — all of `src/config/mod.rs`'s
internal callers, plus every external `Config::method()` callsite:
- **`src/config/mod.rs`** — removed the 33 static-method definitions
from `impl Config`, rewrote every `Self::method()` internal caller
to use `paths::method()`, and removed the `log::LevelFilter` import
that became unused after `log_config` moved away.
- **`src/config/bridge.rs`** — no changes (bridge is unaffected by
path migrations).
- **`src/config/macros.rs`** — added `use crate::config::paths;`,
migrated one `Config::macros_dir().display()` call.
- **`src/config/agent.rs`** — added `use crate::config::paths;`,
migrated 2 `Config::agents_data_dir()` calls, 4 `agent_data_dir`
calls, 3 `agent_config_file` calls, 1 `agent_rag_file` call.
- **`src/config/request_context.rs`** — no changes.
- **`src/config/app_config.rs`, `app_state.rs`** — no changes.
- **`src/main.rs`** — added `use crate::config::paths;`, migrated
`Config::log_config()`, `Config::list_roles(true)`,
`Config::list_rags()`, `Config::list_macros()`.
- **`src/function/mod.rs`** — added `use crate::config::paths;`,
migrated ~25 callsites across `Config::config_dir`,
`functions_dir`, `functions_bin_dir`, `global_tools_dir`,
`agent_bin_dir`, `agent_data_dir`, `agent_functions_file`,
`bash_prompt_utils_file`. Removed `Config` from the `use
crate::{config::{...}}` block because it became unused.
- **`src/repl/mod.rs`** — added `use crate::config::paths;`,
migrated `Config::has_role(name)` and `Config::has_macro(name)`.
- **`src/cli/completer.rs`** — added `use crate::config::paths;`,
migrated `Config::list_roles(true)`, `Config::list_rags()`,
`Config::list_macros()`.
- **`src/utils/logs.rs`** — replaced `use crate::config::Config;`
with `use crate::config::paths;` (Config was only used for
`log_path`); migrated `Config::log_path()` call.
- **`src/mcp/mod.rs`** — added `use crate::config::paths;`,
migrated 3 `Config::mcp_config_file().display()` calls.
- **`src/client/common.rs`** — added `use crate::config::paths;`,
migrated `Config::local_models_override()`. Removed `Config` from
the `config::{Config, GlobalConfig, Input}` import because it
became unused.
- **`src/client/oauth.rs`** — replaced `use crate::config::Config;`
with `use crate::config::paths;` (Config was only used for
`token_file`); migrated 2 `Config::token_file` calls.
### Module registration
- **`src/config/mod.rs`** — added `pub(crate) mod paths;` in the
module declaration block, alphabetically placed between `macros`
and `prompts`.
## Key decisions
### 1. The deprecated forwarders lived for the whole migration but not beyond
The plan said to keep `#[deprecated]` forwarders around while
migrating callsites module-by-module. I followed that approach but
collapsed the "migrate then delete" into a single step because the
callsite migration was almost entirely mechanical — `ast-grep` with
per-method patterns handled the bulk, and only a few edge cases
(`Self::X` inside `&`-expressions, multi-line `format!` calls)
required manual text edits. By the time all 33 methods had zero
external callers, keeping the forwarders would have just generated
dead_code warnings.
The plan also said "then remove the deprecated methods" as a distinct
phase, and that's exactly what happened — just contiguously with the
migration rather than as a separate commit. The result is the same:
no forwarders in the final tree, all callers routed through
`paths::`.
### 2. `paths` is a `pub(crate)` module, not `pub`
I registered the module as `pub(crate) mod paths;` so the functions
are available anywhere in the crate via `crate::config::paths::X`
but not re-exported as part of Loki's public API surface. This
matches the plan's intent — these are internal implementation
details that happen to have been static methods on `Config`. If
anything external needs a config path in the future, the proper
shape is probably to add it as a method on `AppConfig` (which goes
through Step 3's global-read migration anyway) rather than exposing
`paths` publicly.
### 3. `log_config` stays in `paths.rs` despite not being a path
`log_config()` returns `(LevelFilter, Option<PathBuf>)` — it reads
environment variables to determine the log level plus falls back to
`log_path()` for the file destination. Strictly speaking, it's not
a "path" function, but:
- It's a static no-`self` helper (the reason it's in Step 2)
- It's used in exactly one place (`main.rs:446`)
- Splitting it into its own module would add complexity for no
benefit
The plan also listed it in the migration table as belonging in
`paths.rs`. I followed the plan.
### 4. `#![allow(dead_code)]` at module scope, not per-function
I initially scoped the allow to the whole `paths.rs` module because
during the mid-migration state, many functions had zero callers
temporarily. I kept it at module scope rather than narrowing to
individual functions as they became used again, because by the end
of Step 2 all 33 functions have at least one real caller and the
allow is effectively inert — but narrowing would mean tracking
which functions are used vs not in every follow-up step. Module-
level allow is set-and-forget.
This is slightly looser than ideal. See "Follow-up" below.
### 5. `ast-grep` was the primary migration tool, with manual edits for awkward cases
`ast-grep --pattern 'Config::method()'` and
`--pattern 'Self::method()'` caught ~90% of the callsites cleanly.
The remaining ~10% fell into two categories that `ast-grep` handled
poorly:
1. **Calls wrapped in `.display()` or `.to_string_lossy()`.** Some
ast-grep patterns matched these, others didn't — the behavior
seemed inconsistent. When a pattern found 0 matches but grep
showed real matches, I switched to plain text `Edit` for that
cluster.
2. **`&Self::X()` reference expressions.** `ast-grep` appeared to
not match `Self::X()` when it was the operand of a `&` reference,
presumably because the parent node shape was different. Plain
text `Edit` handled these without issue.
These are tooling workarounds, not architectural concerns. The
final tree has no `Config::X` or `Self::X` callers for any of the
33 migrated methods.
### 6. Removed `Config` import from three files that no longer needed it
`src/function/mod.rs`, `src/client/common.rs`, `src/client/oauth.rs`,
and `src/utils/logs.rs` all had `use crate::config::Config;` (or
similar) imports that became unused after every call was migrated.
I removed them. This is a minor cleanup but worth doing because:
- Clippy flags unused imports as warnings
- Leaving them in signals "this file might still need Config" which
future migration steps would have to double-check
## Deviations from plan
### 1. `sync_models` is not in Step 2
The plan's Step 2 table listed `sync_models(url, abort)` as a
migration target, but grep showed only `sync_models_url(&self) ->
String` exists in the code. That's a `&self` method, so it belongs
in Step 3 (global-read methods), not Step 2.
I skipped it here and will pick it up in Step 3. The Step 2 actual
count is 33 methods, not the 34 the plan's table implies.
### 2. Forwarders deleted contiguously, not in a separate sub-step
See Key Decision #1. The plan described a two-phase approach
("leave forwarders, migrate callers module-by-module, then remove
forwarders"). I compressed this into one pass because the migration
was so mechanical there was no value in the intermediate state.
## Verification
### Compilation
- `cargo check` — clean, **zero warnings, zero errors**
- `cargo clippy` — clean
### Tests
- `cargo test`**63 passed, 0 failed** (same as Step 1 — no new
tests were added because Step 2 is a pure code-move with no new
behavior to test; the existing test suite verifies nothing
regressed)
### Manual smoke test
Not applicable — Step 2 is a pure code-move. The path computations
are literally the same code at different call sites. If existing
tests pass and nothing references Config's static methods anymore,
there's nothing to manually verify beyond the compile.
### Callsite audit
```
cargo check 2>&1 | grep "Config::\(config_dir\|local_path\|...\)"
```
Returns zero matches. Every external `Config::method()` callsite
for the 33 migrated methods has been converted to `paths::method()`.
## Handoff to next step
### What Step 3 can rely on
Step 3 (migrate global-read methods to `AppConfig`) can rely on:
- `src/config/paths.rs` exists and holds every static path helper
plus `log_config`, `list_*`, `has_*`, and `local_models_override`
- Zero `Config::config_dir()`, `Config::cache_path()`, etc. calls
remain in the codebase
- The `#[allow(dead_code)]` on `paths.rs` at module scope is safe to
remove at any time now that all functions have callers
- `AppConfig` (from Step 0) is still fully populated and ready to
receive method migrations
- The bridge from Step 1 (`Config::to_app_config`,
`to_request_context`, `from_parts`) is unchanged and still works
- `Config` struct has no more static methods except those that were
kept because they DO take `&self` (`vault_password_file`,
`messages_file`, `sessions_dir`, `session_file`, `rag_file`,
`state`, etc.)
- Deprecation forwarders are GONE — don't add them back
### What Step 3 should watch for
- **`sync_models_url`** was listed in the Step 2 plan table as
static but is actually `&self`. It's a Step 3 target
(global-read). Pick it up there.
- **The Step 3 target list** (from `PHASE-1-IMPLEMENTATION-PLAN.md`):
`vault_password_file`, `editor`, `sync_models_url`, `light_theme`,
`render_options`, `print_markdown`, `rag_template`,
`select_functions`, `select_enabled_functions`,
`select_enabled_mcp_servers`. These are all `&self` methods that
only read serialized config state.
- **The `vault_password_file` field on `AppConfig` is `pub(crate)`,
not `pub`.** The accessor method on `AppConfig` will need to
encapsulate the same fallback logic that the `Config` method has
(see `src/config/mod.rs` — it falls back to
`gman::config::Config::local_provider_password_file()`).
- **`print_markdown` depends on `render_options`.** When migrating
them to `AppConfig`, preserve the dependency chain.
- **`select_functions` / `select_enabled_functions` /
`select_enabled_mcp_servers` take a `&Role` parameter.** Their
new signatures on `AppConfig` will be `&self, role: &Role` — make
sure `Role` is importable in the `app_config.rs` module (it
currently isn't).
- **Strategy for the Step 3 migration:** same as Step 2 — create
methods on `AppConfig`, add `#[deprecated]` forwarders on
`Config`, migrate callsites with `ast-grep`, delete the
forwarders. Should be quicker than Step 2 because the method
count is smaller (10 vs 33) and the pattern is now well-
established.
### What Step 3 should NOT do
- Don't touch `paths.rs` — it's complete.
- Don't touch `bridge.rs` — Step 3's migrations will still flow
through the bridge's round-trip test correctly.
- Don't try to migrate `current_model`, `extract_role`, `sysinfo`,
or any of the `set_*` methods — those are "mixed" methods listed
in Step 7, not Step 3.
- Don't delete `Config` struct fields yet. Step 3 only moves
*methods* that read fields; the fields themselves still exist on
`Config` (and on `AppConfig`) in parallel until Step 10.
### Files to re-read at the start of Step 3
- `docs/PHASE-1-IMPLEMENTATION-PLAN.md` — Step 3 section (table of
10 global-read methods and their target signatures)
- This notes file — specifically the "What Step 3 should watch for"
section
- `src/config/app_config.rs` — to see the current `AppConfig` shape
and decide where to put new methods
- The current `&self` methods on `Config` in `src/config/mod.rs`
that are being migrated
## Follow-up (not blocking Step 3)
### 1. Narrow or remove `#![allow(dead_code)]` on `paths.rs`
At Step 2's end, every function in `paths.rs` has real callers, so
the module-level allow could be removed without producing warnings.
I left it in because it's harmless and removes the need to add
per-function allows during mid-migration states in later steps.
Future cleanup pass can tighten this.
### 2. Consider renaming `paths.rs` if its scope grows
`log_config`, `list_roles`, `list_rags`, `list_macros`, `has_role`,
`has_macro`, and `local_models_override` aren't strictly "paths"
but they're close enough that extracting them into a sibling module
would be premature abstraction. If Steps 3+ add more non-path
helpers to the same module, revisit this.
### 3. The `Config::config_dir` deletion removes one access point for env vars
The `config_dir()` function was also the entry point for XDG-
compatible config location discovery. Nothing about that changed —
it still lives in `paths::config_dir()` — but if Step 4+ needs to
reference the config directory from code that doesn't yet import
`paths`, the import list will need updating.
## References
- Phase 1 plan: `docs/PHASE-1-IMPLEMENTATION-PLAN.md`
- Step 1 notes: `docs/implementation/PHASE-1-STEP-1-NOTES.md`
- New file: `src/config/paths.rs`
- Modified files (module registration + callsite migration): 14
files across `src/config/`, `src/function/`, `src/repl/`,
`src/cli/`, `src/main.rs`, `src/utils/`, `src/mcp/`,
`src/client/`
+326
View File
@@ -0,0 +1,326 @@
# Phase 1 Step 3 — Implementation Notes
## Status
Done.
## Plan reference
- Plan: `docs/PHASE-1-IMPLEMENTATION-PLAN.md`
- Section: "Step 3: Migrate global-read methods to AppConfig"
## Summary
Added 7 global-read methods to `AppConfig` as inherent methods
duplicating the bodies that still exist on `Config`. The planned
approach (deprecated forwarders + caller migration) turned out to
be the wrong shape for this step because callers hold `Config`
instances, not `AppConfig` instances, and giving them an `AppConfig`
would require either a sync'd `Arc<AppConfig>` field on `Config`
(which Step 4's global-write migration would immediately break) or
cloning on every call. The clean answer is to duplicate during the
bridge window and let callers migrate naturally when Steps 8-9
switch them from `Config` to `RequestContext` + `AppState`. The
duplication is 7 methods / ~100 lines and deletes itself when
`Config` is removed in Step 10.
**Three methods from the plan's Step 3 target list were deferred
to Step 7** because they read runtime state, not just serialized
state (see "Deviations from plan").
## What was changed
### Modified files
- **`src/config/app_config.rs`** — added 6 new imports
(`MarkdownRender`, `RenderOptions`, `IS_STDOUT_TERMINAL`,
`decode_bin`, `anyhow`, `env`, `ThemeSet`) and a new
`impl AppConfig` block with 7 methods under
`#[allow(dead_code)]`:
- `vault_password_file(&self) -> PathBuf`
- `editor(&self) -> Result<String>`
- `sync_models_url(&self) -> String`
- `light_theme(&self) -> bool`
- `render_options(&self) -> Result<RenderOptions>`
- `print_markdown(&self, text) -> Result<()>`
- `rag_template(&self, embeddings, sources, text) -> String`
All bodies are copy-pasted verbatim from the originals on
`Config`, with the following adjustments for the new module
location:
- `EDITOR` static → `super::EDITOR` (shared across both impls)
- `SYNC_MODELS_URL` const → `super::SYNC_MODELS_URL`
- `RAG_TEMPLATE` const → `super::RAG_TEMPLATE`
- `LIGHT_THEME` / `DARK_THEME` consts → `super::LIGHT_THEME` /
`super::DARK_THEME`
- `paths::local_path()` continues to work unchanged (already in
the right module from Step 2)
### Unchanged files
- **`src/config/mod.rs`** — the original `Config::vault_password_file`,
`editor`, `sync_models_url`, `light_theme`, `render_options`,
`print_markdown`, `rag_template` method definitions are
deliberately left intact. They continue to work for every existing
caller. The deletion of these happens in Step 10 when `Config` is
removed entirely.
- **All external callers** (26 callsites across 6 files) — also
unchanged. They continue to call `config.editor()`,
`config.render_options()`, etc. on their `Config` instances.
## Key decisions
### 1. Duplicate method bodies instead of `#[deprecated]` forwarders
The plan prescribed the same shape as Step 2: add the new version,
add a `#[deprecated]` forwarder on the old location, migrate
callers, delete forwarders. This worked cleanly in Step 2 because
the new location was a free-standing `paths` module — callers
could switch from `Config::method()` (associated function) to
`paths::method()` (free function) without needing any instance.
Step 3 is fundamentally different: `AppConfig::method(&self)` needs
an `AppConfig` instance. Callers today hold `Config` instances.
Giving them an `AppConfig` means one of:
(a) Add an `app_config: Arc<AppConfig>` field to `Config` and have
the forwarder do `self.app_config.method()`. **Rejected**
because Step 4 (global-write) will mutate `Config` fields via
`set_wrap`, `update`, etc. — keeping the `Arc<AppConfig>`
in sync would require either rebuilding it on every write (slow
and racy) or tracking dirty state (premature complexity).
(b) Have the forwarder do `self.to_app_config().method()`. **Rejected**
because `to_app_config` clones all 40 serialized fields on
every call — a >100x slowdown for simple accessors like
`light_theme()`.
(c) Duplicate the method bodies on both `Config` and `AppConfig`,
let each caller use whichever instance it has, delete the
`Config` versions when `Config` itself is deleted in Step 10.
**Chosen.**
Option (c) has a small ongoing cost (~100 lines of duplicated
logic) but is strictly additive, has zero runtime overhead, and
automatically cleans up in Step 10. It also matches how Rust's
type system prefers to handle this — parallel impls are cheaper
than synchronized state.
### 2. Caller migration is deferred to Steps 8-9
With duplication in place, the migration from `Config` to
`AppConfig` happens organically later:
- When Step 8 rewrites `main.rs` to construct an `AppState` and
`RequestContext` instead of a `GlobalConfig`, the `main.rs`
callers of `config.editor()` naturally become
`ctx.app.config.editor()` — calling into `AppConfig`'s version.
- Same for every other callsite that gets migrated in Step 8+.
- By Step 10, the old `Config::editor()` etc. have zero callers
and get deleted along with the rest of `Config`.
This means Step 3 is "additive only, no caller touches" —
deliberately smaller in scope than Step 2. That's the correct call
given the instance-type constraint.
### 3. `EDITOR` static is shared between `Config::editor` and `AppConfig::editor`
`editor()` caches the resolved editor path in a module-level
`static EDITOR: OnceLock<Option<String>>` in `src/config/mod.rs`.
Both `Config::editor(&self)` and `AppConfig::editor(&self)` read
and initialize the same static via `super::EDITOR`. This matches
the current behavior: whichever caller resolves first wins the
`OnceLock::get_or_init` race and subsequent callers see the cached
value.
There's a latent bug here (if `Config.editor` and `AppConfig.editor`
fields ever differ, the first caller wins regardless) but it's
pre-existing and preserved during the bridge window. Step 10 resolves
it by deleting `Config` entirely.
### 4. Three methods deferred to Step 7
See "Deviations from plan."
## Deviations from plan
### `select_functions`, `select_enabled_functions`, `select_enabled_mcp_servers` belong in Step 7
The plan's Step 3 table lists all three. Reading their bodies (in
`src/config/mod.rs` at lines 1816, 1828, 1923), they all touch
`self.functions` and `self.agent` — both of which are `#[serde(skip)]`
runtime fields that do NOT exist on `AppConfig` and will never
exist there (they're per-request state living on `RequestContext`
and `AgentRuntime`).
These are "mixed" methods in the plan's Step 7 taxonomy — they
conditionally read serialized config + runtime state depending on
whether an agent is active. Moving them to `AppConfig` now would
require `AppConfig` to hold `functions` and `agent` fields, which
directly contradicts the Step 0 / Step 6.5 design.
**Action taken:** left all three on `Config` unchanged. They get
migrated in Step 7 with the new signature
`(app: &AppConfig, ctx: &RequestContext, role: &Role) -> Vec<...>`
as described in the plan.
**Action required from Step 7:** pick up these three methods. The
call graph is:
- `Config::select_functions` is called from `src/config/input.rs:243`
(one external caller)
- `Config::select_functions` internally calls the two private
helpers
- The private helpers read both `self.functions` (runtime,
per-request) and `self.agent` (runtime, per-request) — so they
fundamentally need `RequestContext` not `AppConfig`
### Step 3 count: 7 methods, not 10
The plan's table listed 10 target methods. After excluding the
three `select_*` methods, Step 3 migrated 7. This is documented
here rather than silently completing a smaller Step 3 so Step 7's
scope is clear.
## Verification
### Compilation
- `cargo check` — clean, **zero warnings, zero errors**
- `cargo clippy` — clean
### Tests
- `cargo test`**63 passed, 0 failed** (same as Steps 12)
Step 3 added no new tests because it's duplication — there's
nothing new to verify. The existing test suite confirms:
(a) the original `Config` methods still work (they weren't touched)
(b) `AppConfig` still compiles and its `Default` impl is intact
(needed for Step 1's bridge test which uses
`build_populated_config()``to_app_config()`)
Running `cargo test bridge` specifically:
```
test config::bridge::tests::round_trip_default_config ... ok
test config::bridge::tests::to_app_config_copies_every_serialized_field ... ok
test config::bridge::tests::to_request_context_copies_every_runtime_field ... ok
test config::bridge::tests::round_trip_preserves_all_non_lossy_fields ... ok
test result: ok. 4 passed
```
The bridge's round-trip test still works, which proves the new
methods on `AppConfig` don't interfere with the struct layout or
deserialization. They're purely additive impl-level methods.
### Manual smoke test
Not applicable — no runtime behavior changed. CLI and REPL still
call `Config::editor()` etc. as before.
## Handoff to next step
### What Step 4 can rely on
Step 4 (migrate global-write methods) can rely on:
- `AppConfig` now has 7 inherent read methods that mirror the
corresponding `Config` methods exactly
- `#[allow(dead_code)]` on the `impl AppConfig` block in
`app_config.rs` — safe to leave as-is, it'll go away when the
first caller is migrated in Step 8+
- `Config` is unchanged for all 7 methods and continues to work
for every current caller
- The bridge (`Config::to_app_config`, `to_request_context`,
`from_parts`) from Step 1 still works
- The `paths` module from Step 2 is unchanged
- `Config::select_functions`, `select_enabled_functions`,
`select_enabled_mcp_servers` are **still on `Config`** and must
stay there through Step 6. They get migrated in Step 7.
### What Step 4 should watch for
- **The Step 4 target list** (from `PHASE-1-IMPLEMENTATION-PLAN.md`):
`set_wrap`, `update`, `load_envs`, `load_functions`,
`load_mcp_servers`, `setup_model`, `setup_document_loaders`,
`setup_user_agent`. These are global-write methods that
initialize or mutate serialized fields.
- **Tension with Step 3's duplication decision:** Step 4 methods
mutate `Config` fields. If we also duplicate them on `AppConfig`,
then mutations through one path don't affect the other — but no
caller ever mutates both, so this is fine in practice during
the bridge window.
- **`load_functions` and `load_mcp_servers`** are initialization-
only (called once in `Config::init`). They're arguably not
"global-write" in the same sense — they populate runtime-only
fields (`functions`, `mcp_registry`). Step 4 should carefully
classify each: fields that belong to `AppConfig` vs fields that
belong to `RequestContext` vs fields that go away in Step 6.5
(`mcp_registry`).
- **Strategy for Step 4:** because writes are typically one-shot
(`update` is called from `.set` REPL command; `load_envs` is
called once at startup), you can be more lenient about
duplication vs consolidation. Consider: the write methods might
not need to exist on `AppConfig` at all if they're only used
during `Config::init` and never during request handling. Step 4
should evaluate each one individually.
### What Step 4 should NOT do
- Don't add an `app_config: Arc<AppConfig>` field to `Config`
(see Key Decision #1 for why).
- Don't touch the 7 methods added to `AppConfig` in Step 3 — they
stay until Step 8+ caller migration, and Step 10 deletion.
- Don't migrate `select_*` methods — those are Step 7.
- Don't try to migrate callers of the Step 3 methods to go
through `AppConfig` yet. The call sites still hold `Config`,
and forcing a conversion would require either a clone or a
sync'd field.
### Files to re-read at the start of Step 4
- `docs/PHASE-1-IMPLEMENTATION-PLAN.md` — Step 4 section
- This notes file — specifically the "Deviations from plan" and
"What Step 4 should watch for" sections
- `src/config/mod.rs` — the current `Config::set_wrap`, `update`,
`load_*`, `setup_*` method bodies (search for `pub fn set_wrap`,
`pub fn update`, `pub fn load_envs`, etc.)
- `src/config/app_config.rs` — the current shape with 7 new
methods
## Follow-up (not blocking Step 4)
### 1. The `EDITOR` static sharing is pre-existing fragility
Both `Config::editor` and `AppConfig::editor` now share the same
`static EDITOR: OnceLock<Option<String>>`. If two Configs with
different `editor` fields exist (unlikely in practice but possible
during tests), the first caller wins. This isn't new — the single
`Config` version had the same property. Step 10's `Config`
deletion will leave only `AppConfig::editor` which eliminates the
theoretical bug. Worth noting so nobody introduces a test that
assumes per-instance editor caching.
### 2. `impl AppConfig` block grows across Steps 3-7
By the end of Step 7, `AppConfig` will have accumulated: 7 methods
from Step 3, potentially some from Step 4, more from Step 7's
mixed-method splits. The `#[allow(dead_code)]` currently covers
the whole block. As callers migrate in Step 8+, the warning
suppression can be removed. Don't narrow it prematurely during
Steps 4-7.
### 3. Imports added to `app_config.rs`
Step 3 added `MarkdownRender`, `RenderOptions`, `IS_STDOUT_TERMINAL`,
`decode_bin`, `anyhow::{Context, Result, anyhow}`, `env`,
`ThemeSet`. Future steps may add more. The import list is small
enough to stay clean; no reorganization needed.
## References
- Phase 1 plan: `docs/PHASE-1-IMPLEMENTATION-PLAN.md`
- Step 2 notes: `docs/implementation/PHASE-1-STEP-2-NOTES.md`
- Modified file: `src/config/app_config.rs` (imports + new
`impl AppConfig` block)
- Unchanged but relevant: `src/config/mod.rs` (original `Config`
methods still exist for now), `src/config/bridge.rs` (still
passes round-trip tests)
+362
View File
@@ -0,0 +1,362 @@
# 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_bool``super::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:
```rust
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:
```rust
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 test`**63 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::`)
+413
View File
@@ -0,0 +1,413 @@
# Phase 1 Step 5 — Implementation Notes
## Status
Done.
## Plan reference
- Plan: `docs/PHASE-1-IMPLEMENTATION-PLAN.md`
- Section: "Step 5: Migrate request-read methods to RequestContext"
## Summary
Added 13 of 15 planned request-read methods to `RequestContext`
as inherent methods, duplicating the bodies that still exist on
`Config`. The other 2 methods (`info`, `session_info`) were
deferred to Step 7 because they mix runtime reads with calls into
`AppConfig`-scoped helpers (`sysinfo`, `render_options`) or depend
on `sysinfo` which itself touches both serialized and runtime
state.
Same duplication pattern as Steps 3 and 4: callers stay on
`Config` during the bridge window; real caller migration happens
organically in Steps 8-9.
## What was changed
### Modified files
- **`src/config/request_context.rs`** — extended the imports
with 11 new symbols from `super` (parent module constants,
`StateFlags`, `RoleLike`, `paths`) plus `anyhow`, `env`,
`PathBuf`, `get_env_name`, and `list_file_names`. Added a new
`impl RequestContext` block with 13 methods under
`#[allow(dead_code)]`:
**Path helpers** (4):
- `messages_file(&self) -> PathBuf` — agent-aware path to
the messages log
- `sessions_dir(&self) -> PathBuf` — agent-aware sessions
directory
- `session_file(&self, name) -> PathBuf` — combines
`sessions_dir` with a session name
- `rag_file(&self, name) -> PathBuf` — agent-aware RAG file
path
**State query** (1):
- `state(&self) -> StateFlags` — returns bitflags for which
scopes are currently active
**Scope info getters** (4):
- `role_info(&self) -> Result<String>` — exports the current
role (from session or standalone)
- `agent_info(&self) -> Result<String>` — exports the current
agent
- `agent_banner(&self) -> Result<String>` — returns the
agent's conversation starter banner
- `rag_info(&self) -> Result<String>` — exports the current
RAG
**Session listings** (2):
- `list_sessions(&self) -> Vec<String>`
- `list_autoname_sessions(&self) -> Vec<String>`
**Misc** (2):
- `is_compressing_session(&self) -> bool`
- `role_like_mut(&mut self) -> Option<&mut dyn RoleLike>`
returns the currently-active `RoleLike` (session > agent >
role), the foundation for Step 7's `set_*` methods
All bodies are copy-pasted verbatim from the originals on
`Config`, with the following minor adjustments for the new
module location:
- Constants like `MESSAGES_FILE_NAME`, `AGENTS_DIR_NAME`,
`SESSIONS_DIR_NAME` imported from `super::`
- `paths::` calls unchanged (already in the right module from
Step 2)
- `list_file_names` imported from `crate::utils::*` → made
explicit
- `get_env_name` imported from `crate::utils::*` → made
explicit
### Unchanged files
- **`src/config/mod.rs`** — the original `Config` versions of
all 13 methods are deliberately left intact. They continue to
work for every existing caller. They get deleted in Step 10
when `Config` is removed entirely.
- **All external callers** of `config.messages_file()`,
`config.state()`, etc. — also unchanged.
## Key decisions
### 1. Only 13 of 15 methods migrated
The plan's Step 5 table listed 15 methods. After reading each
body, I classified them:
| Method | Classification | Action |
|---|---|---|
| `state` | Pure runtime-read | **Migrated** |
| `messages_file` | Pure runtime-read | **Migrated** |
| `sessions_dir` | Pure runtime-read | **Migrated** |
| `session_file` | Pure runtime-read | **Migrated** |
| `rag_file` | Pure runtime-read | **Migrated** |
| `role_info` | Pure runtime-read | **Migrated** |
| `agent_info` | Pure runtime-read | **Migrated** |
| `agent_banner` | Pure runtime-read | **Migrated** |
| `rag_info` | Pure runtime-read | **Migrated** |
| `list_sessions` | Pure runtime-read | **Migrated** |
| `list_autoname_sessions` | Pure runtime-read | **Migrated** |
| `is_compressing_session` | Pure runtime-read | **Migrated** |
| `role_like_mut` | Pure runtime-read (returns `&mut dyn RoleLike`) | **Migrated** |
| `info` | Delegates to `sysinfo` (mixed) | **Deferred to Step 7** |
| `session_info` | Calls `render_options` (AppConfig) + runtime | **Deferred to Step 7** |
See "Deviations from plan" for detail.
### 2. Same duplication pattern as Steps 3 and 4
Callers hold `Config`, not `RequestContext`. Same constraints
apply:
- Giving callers a `RequestContext` requires either: (a) a
sync'd `Arc<RequestContext>` field on `Config` — breaks
because per-request state mutates constantly, (b) cloning on
every call — expensive, or (c) duplicating method bodies.
- Option (c) is the same choice Steps 3 and 4 made.
- The duplication is 13 methods (~170 lines total) that
auto-delete in Step 10.
### 3. `role_like_mut` is particularly important for Step 7
I want to flag this one: `role_like_mut(&mut self)` is the
foundation for every `set_*` method in Step 7 (`set_temperature`,
`set_top_p`, `set_model`, etc.). Those methods all follow the
pattern:
```rust
fn set_something(&mut self, value: Option<T>) {
if let Some(role_like) = self.role_like_mut() {
role_like.set_something(value);
} else {
self.something = value;
}
}
```
The `else` branch (fallback to global) is the "mixed" part that
makes them Step 7 targets. The `if` branch is pure runtime write
— it mutates whichever `RoleLike` is on top.
By migrating `role_like_mut` to `RequestContext` in Step 5, Step
7 can build its new `set_*` methods as `(&mut RequestContext,
&mut AppConfig, value)` signatures where the runtime path uses
`ctx.role_like_mut()` directly. The prerequisite is now in place.
### 4. Path helpers stay on `RequestContext`, not `AppConfig`
`messages_file`, `sessions_dir`, `session_file`, and `rag_file`
all read `self.agent` to decide between global and agent-scoped
paths. `self.agent` is a runtime field (per-request). Even
though the returned paths themselves are computed from `paths::`
functions (no per-request state involved), **the decision of
which path to return depends on runtime state**. So these
methods belong on `RequestContext`, not `AppConfig` or `paths`.
This is the correct split — `paths::` is the "pure path
computation" layer, `RequestContext::messages_file` etc. are
the "which path applies to this request" layer on top.
### 5. `state`, `info`-style methods do not take `&self.app`
None of the 13 migrated methods reference `self.app` (the
`Arc<AppState>`) or any field on `AppConfig`. This is the
cleanest possible split — they're pure runtime-reads. If they
needed both runtime state and `AppConfig`, they'd be mixed (like
`info` and `session_info`, which is why those are deferred).
## Deviations from plan
### `info` deferred to Step 7
The plan lists `info` as a Step 5 target. Reading its body:
```rust
pub fn info(&self) -> Result<String> {
if let Some(agent) = &self.agent {
// ... agent export with session ...
} else if let Some(session) = &self.session {
session.export()
} else if let Some(role) = &self.role {
Ok(role.export())
} else if let Some(rag) = &self.rag {
rag.export()
} else {
self.sysinfo() // ← falls through to sysinfo
}
}
```
The fallback `self.sysinfo()` call is the problem. `sysinfo()`
(lines 571-644 in `src/config/mod.rs`) reads BOTH serialized
fields (`wrap`, `rag_reranker_model`, `rag_top_k`,
`save_session`, `compression_threshold`, `dry_run`,
`function_calling_support`, `mcp_server_support`, `stream`,
`save`, `keybindings`, `wrap_code`, `highlight`, `theme`) AND
runtime fields (`self.rag`, `self.extract_role()` which reads
`self.session`, `self.agent`, `self.role`, `self.model`, etc.).
`sysinfo` is a mixed method in the Step 7 sense — it needs both
`AppConfig` (for the serialized half) and `RequestContext` (for
the runtime half). The plan's Step 7 mixed-method list includes
`sysinfo` explicitly.
Since `info` delegates to `sysinfo` in one of its branches,
migrating `info` without `sysinfo` would leave that branch
broken. **Action taken:** left both `Config::info` and
`Config::sysinfo` intact. Step 7 picks them up as a pair.
### `session_info` deferred to Step 7
The plan lists `session_info` as a Step 5 target. Reading its
body:
```rust
pub fn session_info(&self) -> Result<String> {
if let Some(session) = &self.session {
let render_options = self.render_options()?; // ← AppConfig method
let mut markdown_render = MarkdownRender::init(render_options)?;
// ... reads self.agent for agent_info tuple ...
session.render(&mut markdown_render, &agent_info)
} else {
bail!("No session")
}
}
```
It calls `self.render_options()` which is a Step 3 method now
on `AppConfig`. In the bridge world, the caller holds a
`Config` and can call `config.render_options()` (old) or
`config.to_app_config().render_options()` (new but cloning).
In the post-bridge world with `RequestContext`, the call becomes
`ctx.app.config.render_options()`.
Since `session_info` crosses the `AppConfig` / `RequestContext`
boundary, it's mixed by the Step 7 definition. **Action taken:**
left `Config::session_info` intact. Step 7 picks it up with a
signature like
`(&self, app: &AppConfig) -> Result<String>` or
`(ctx: &RequestContext) -> Result<String>` where
`ctx.app.config.render_options()` is called internally.
### Step 5 count: 13 methods, not 15
Documented here so Step 7's scope is explicit. Step 7 picks up
`info`, `session_info`, `sysinfo`, plus the `set_*` methods and
other items from the original Step 7 list.
## Verification
### Compilation
- `cargo check` — clean, **zero warnings, zero errors**
- `cargo clippy` — clean
### Tests
- `cargo test`**63 passed, 0 failed** (unchanged from
Steps 14)
Step 5 added no new tests because it's duplication. Existing
tests confirm:
- The original `Config` methods still work
- `RequestContext` still compiles, imports are clean
- The bridge's round-trip test still passes
### Manual smoke test
Not applicable — no runtime behavior changed.
## Handoff to next step
### What Step 6 can rely on
Step 6 (migrate request-write methods to `RequestContext`) can
rely on:
- `RequestContext` now has 13 inherent read methods
- The `#[allow(dead_code)]` on the read-methods `impl` block is
safe to leave; callers migrate in Steps 8+
- `Config` is unchanged for all 13 methods
- `role_like_mut` is available on `RequestContext` — Step 7
will use it, and Step 6 might also use it internally when
implementing write methods like `set_save_session_this_time`
- The bridge from Step 1, `paths` module from Step 2,
`AppConfig` methods from Steps 3 and 4 are all unchanged
- **`Config::info`, `session_info`, and `sysinfo` are still on
`Config`** and must stay there through Step 6. They're
Step 7 targets.
- **`Config::update`, `setup_model`, `load_functions`,
`load_mcp_servers`, and all `set_*` methods** are also still
on `Config` and stay there through Step 6.
### What Step 6 should watch for
- **Step 6 targets are request-write methods** — methods that
mutate the runtime state on `Config` (session, role, agent,
rag). The plan's Step 6 target list includes:
`use_prompt`, `use_role` / `use_role_obj`, `exit_role`,
`edit_role`, `use_session`, `exit_session`, `save_session`,
`empty_session`, `set_save_session_this_time`,
`compress_session` / `maybe_compress_session`,
`autoname_session` / `maybe_autoname_session`,
`use_rag` / `exit_rag` / `edit_rag_docs` / `rebuild_rag`,
`use_agent` / `exit_agent` / `exit_agent_session`,
`apply_prelude`, `before_chat_completion`,
`after_chat_completion`, `discontinuous_last_message`,
`init_agent_shared_variables`,
`init_agent_session_variables`.
- **Many will be mixed.** Expect to defer several to Step 7.
In particular, anything that reads `self.functions`,
`self.mcp_registry`, or calls `set_*` methods crosses the
boundary. Read each method carefully before migrating.
- **`maybe_compress_session` and `maybe_autoname_session`** take
`GlobalConfig` (not `&mut self`) and spawn background tasks
internally. Their signature in Step 6 will need
reconsideration — they don't fit cleanly in a
`RequestContext` method because they're already designed to
work with a shared lock.
- **`use_session_safely`, `use_role_safely`** also take
`GlobalConfig`. They do the `take()`/`replace()` dance with
the shared lock. Again, these don't fit the
`&mut RequestContext` pattern cleanly; plan to defer them.
- **`compress_session` and `autoname_session` are async.** They
call into the LLM. Their signature on `RequestContext` will
still be async.
- **`apply_prelude`** is tricky — it may activate a role/agent/
session from config strings like `"role:explain"` or
`"session:temp"`. It calls `use_role`, `use_session`, etc.
internally. If those get migrated, `apply_prelude` migrates
too. If any stay on `Config`, `apply_prelude` stays with them.
- **`discontinuous_last_message`** just clears `self.last_message`.
Pure runtime-write, trivial to migrate.
### What Step 6 should NOT do
- Don't touch the Step 3, 4, 5 methods on `AppConfig` /
`RequestContext` — they stay until Steps 8+ caller migration.
- Don't migrate any `set_*` method, `info`, `session_info`,
`sysinfo`, `update`, `setup_model`, `load_functions`,
`load_mcp_servers`, or the `use_session_safely` /
`use_role_safely` family unless you verify they're pure
runtime-writes — most aren't, and they're Step 7 targets.
- Don't migrate callers of any method yet. Callers stay on
`Config` through the bridge window.
### Files to re-read at the start of Step 6
- `docs/PHASE-1-IMPLEMENTATION-PLAN.md` — Step 6 section
- This notes file — specifically "What Step 6 should watch for"
- `src/config/request_context.rs` — current shape with Step 5
reads
- Current `Config` method bodies in `src/config/mod.rs` for
each Step 6 target
## Follow-up (not blocking Step 6)
### 1. `RequestContext` now has ~200 lines beyond struct definition
Between Step 0's `new()` constructor and Step 5's 13 read
methods, `request_context.rs` has grown to ~230 lines. Still
manageable. Step 6 will add more. Post-Phase 1 cleanup can
reorganize into multiple `impl` blocks grouped by concern
(reads/writes/lifecycle) or into separate files if the file
grows unwieldy.
### 2. Duplication count at end of Step 5
Running tally of methods duplicated between `Config` and the
new types during the bridge window:
- `AppConfig` (Steps 3+4): 11 methods
- `RequestContext` (Step 5): 13 methods
- `paths::` module (Step 2): 33 free functions (not duplicated
`Config` forwarders were deleted in Step 2)
**Total bridge-window duplication: 24 methods / ~370 lines.**
All auto-delete in Step 10. Maintenance burden is "any bug fix
in a migrated method during Steps 6-9 must be applied twice."
Document this in whatever PR shepherds Steps 6-9.
### 3. The `impl` block structure in `RequestContext` is growing
Now has 2 `impl RequestContext` blocks:
1. `new()` constructor (Step 0)
2. 13 read methods (Step 5)
Step 6 will likely add a third block for writes. That's fine
during the bridge window; cleanup can consolidate later.
## References
- Phase 1 plan: `docs/PHASE-1-IMPLEMENTATION-PLAN.md`
- Step 4 notes: `docs/implementation/PHASE-1-STEP-4-NOTES.md`
(for the duplication rationale)
- Modified file: `src/config/request_context.rs` (new imports
+ new `impl RequestContext` block with 13 read methods)
- Unchanged but referenced: `src/config/mod.rs` (original
`Config` methods still exist, private constants
`MESSAGES_FILE_NAME` / `AGENTS_DIR_NAME` /
`SESSIONS_DIR_NAME` accessed via `super::`)
+405
View File
@@ -0,0 +1,405 @@
# Phase 1 Step 6 — Implementation Notes
## Status
Done.
## Plan reference
- Plan: `docs/PHASE-1-IMPLEMENTATION-PLAN.md`
- Section: "Step 6: Migrate request-write methods to RequestContext"
## Summary
Added 12 of 27 planned request-write methods to `RequestContext`
as inherent methods, duplicating the bodies that still exist on
`Config`. The other 15 methods were deferred: some to Step 6.5
(because they touch `self.functions` and `self.mcp_registry`
runtime fields being restructured by the `ToolScope` / `McpFactory`
rework), some to Step 7 (because they cross the `AppConfig` /
`RequestContext` boundary or call into `set_*` mixed methods),
and some because their `GlobalConfig`-based static signatures
don't fit the `&mut RequestContext` pattern at all.
This step has the highest deferral ratio of the bridge phases
so far (12/27 ≈ 44% migrated). That's by design — Step 6 is
where the plan hits the bulk of the interesting refactoring
territory, and it's where the `ToolScope` / `AgentRuntime`
unification in Step 6.5 makes a big difference in what's
migrateable.
## What was changed
### Modified files
- **`src/config/request_context.rs`** — added 1 new import
(`Input` from `super::`) and a new `impl RequestContext` block
with 12 methods under `#[allow(dead_code)]`:
**Role lifecycle (2):**
- `use_role_obj(&mut self, role) -> Result<()>` — sets the
role on the current session, or on `self.role` if no session
is active; errors if an agent is active
- `exit_role(&mut self) -> Result<()>` — clears the role from
session or from `self.role`
**Session lifecycle (5):**
- `exit_session(&mut self) -> Result<()>` — saves session on
exit and clears `self.session`
- `save_session(&mut self, name) -> Result<()>` — persists
the current session, optionally renaming
- `empty_session(&mut self) -> Result<()>` — clears messages
in the active session
- `set_save_session_this_time(&mut self) -> Result<()>` — sets
the session's one-shot save flag
- `exit_agent_session(&mut self) -> Result<()>` — exits the
agent's session without exiting the agent
**RAG lifecycle (1):**
- `exit_rag(&mut self) -> Result<()>` — drops `self.rag`
**Chat lifecycle (2):**
- `before_chat_completion(&mut self, input) -> Result<()>`
stores the input as `last_message` with empty output
- `discontinuous_last_message(&mut self)` — clears the
continuous flag on the last message
**Agent variable init (2):**
- `init_agent_shared_variables(&mut self) -> Result<()>`
prompts for agent variables on first activation
- `init_agent_session_variables(&mut self, new_session) -> Result<()>`
syncs agent variables into/from session on new or resumed
session
All bodies are copy-pasted verbatim from `Config` with no
modifications — every one of these methods only touches
fields that already exist on `RequestContext` with the same
names and types.
### Unchanged files
- **`src/config/mod.rs`** — all 27 original `Config` methods
(including the 15 deferred ones) are deliberately left intact.
They continue to work for every existing caller.
## Key decisions
### 1. Only 12 of 27 methods migrated
The plan's Step 6 table listed ~20 methods, but when I scanned
for `fn (use_prompt|use_role|use_role_obj|...)` I found 27
(several methods have paired variants: `compress_session` +
`maybe_compress_session`, `autoname_session` +
`maybe_autoname_session`, `use_role_safely` vs `use_role`). Of
those 27, **12 are pure runtime-writes that migrated cleanly**
and **15 are deferred** to later steps. Full breakdown below.
### 2. Same duplication pattern as Steps 3-5
Callers hold `Config`, not `RequestContext`. Duplication is
strictly additive during the bridge window and auto-deletes in
Step 10.
### 3. Identified three distinct deferral categories
The 15 deferred methods fall into three categories, each with
a different resolution step:
**Category A: Touch `self.functions` or `self.mcp_registry`**
(resolved in Step 6.5 when `ToolScope` / `McpFactory` replace
those fields):
- `use_role` (async, reinits MCP registry for role's servers)
- `use_session` (async, reinits MCP registry for session's
servers)
**Category B: Call into Step 7 mixed methods** (resolved in
Step 7):
- `use_prompt` (calls `self.current_model()`)
- `edit_role` (calls `self.editor()` + `self.use_role()`)
- `after_chat_completion` (calls private `save_message` which
touches `self.save`, `self.session`, `self.agent`, etc.)
**Category C: Static async methods taking `&GlobalConfig` that
don't fit the `&mut RequestContext` pattern at all** (resolved
in Step 8 or a dedicated lifecycle-refactor step):
- `maybe_compress_session` — takes owned `GlobalConfig`, spawns
tokio task
- `compress_session` — async, takes `&GlobalConfig`
- `maybe_autoname_session` — takes owned `GlobalConfig`, spawns
tokio task
- `autoname_session` — async, takes `&GlobalConfig`
- `use_rag` — async, takes `&GlobalConfig`, calls `Rag::init` /
`Rag::load` which expect `&GlobalConfig`
- `edit_rag_docs` — async, takes `&GlobalConfig`, calls into
`Rag::refresh_document_paths` which expects `&GlobalConfig`
- `rebuild_rag` — same as `edit_rag_docs`
- `use_agent` — async, takes `&GlobalConfig`, mutates multiple
fields under the same write lock, calls
`Config::use_session_safely`
- `apply_prelude` — async, calls `self.use_role()` /
`self.use_session()` which are Category A
- `exit_agent` — calls `self.load_functions()` which writes
`self.functions` (runtime, restructured in Step 6.5)
### 4. `exit_agent_session` migrated despite calling other methods
`exit_agent_session` calls `self.exit_session()` and
`self.init_agent_shared_variables()`. Since both of those are
also being migrated in Step 6, `exit_agent_session` can
migrate cleanly and call the new `RequestContext::exit_session`
and `RequestContext::init_agent_shared_variables` on its own
struct.
### 5. `exit_session` works because Step 5 migrated `sessions_dir`
`exit_session` calls `self.sessions_dir()` which is now a
`RequestContext` method (Step 5). Similarly, `save_session`
calls `self.session_file()` (Step 5) and reads
`self.working_mode` (a `RequestContext` field). This
demonstrates how Steps 5 and 6 layer correctly — Step 5's
reads enable Step 6's writes.
### 6. Agent variable init is pure runtime
`init_agent_shared_variables` and `init_agent_session_variables`
look complex (they call `Agent::init_agent_variables` which
can prompt interactively) but they only touch `self.agent`,
`self.agent_variables`, `self.info_flag`, and `self.session`
all runtime fields that exist on `RequestContext`.
`Agent::init_agent_variables` itself is a static associated
function on `Agent` that takes `defined_variables`,
`existing_variables`, and `info_flag` as parameters — no
`&Config` dependency. Clean migration.
## Deviations from plan
### 15 methods deferred
Summary table of every method in the Step 6 target list:
| Method | Status | Reason |
|---|---|---|
| `use_prompt` | **Step 7** | Calls `current_model()` (mixed) |
| `use_role` | **Step 6.5** | Touches `functions`, `mcp_registry` |
| `use_role_obj` | ✅ Migrated | Pure runtime-write |
| `exit_role` | ✅ Migrated | Pure runtime-write |
| `edit_role` | **Step 7** | Calls `editor()` + `use_role()` |
| `use_session` | **Step 6.5** | Touches `functions`, `mcp_registry` |
| `exit_session` | ✅ Migrated | Pure runtime-write (uses Step 5 `sessions_dir`) |
| `save_session` | ✅ Migrated | Pure runtime-write (uses Step 5 `session_file`) |
| `empty_session` | ✅ Migrated | Pure runtime-write |
| `set_save_session_this_time` | ✅ Migrated | Pure runtime-write |
| `maybe_compress_session` | **Step 7/8** | `GlobalConfig` + spawns task + `light_theme()` |
| `compress_session` | **Step 7/8** | `&GlobalConfig`, complex LLM workflow |
| `maybe_autoname_session` | **Step 7/8** | `GlobalConfig` + spawns task + `light_theme()` |
| `autoname_session` | **Step 7/8** | `&GlobalConfig`, calls `retrieve_role` + LLM |
| `use_rag` | **Step 7/8** | `&GlobalConfig`, calls `Rag::init`/`Rag::load` |
| `edit_rag_docs` | **Step 7/8** | `&GlobalConfig`, calls `editor()` + Rag refresh |
| `rebuild_rag` | **Step 7/8** | `&GlobalConfig`, Rag refresh |
| `exit_rag` | ✅ Migrated | Trivial (drops `self.rag`) |
| `use_agent` | **Step 7/8** | `&GlobalConfig`, complex multi-field mutation |
| `exit_agent` | **Step 6.5** | Calls `load_functions()` which writes `functions` |
| `exit_agent_session` | ✅ Migrated | Composes migrated methods |
| `apply_prelude` | **Step 7/8** | Calls `use_role` / `use_session` (deferred) |
| `before_chat_completion` | ✅ Migrated | Pure runtime-write |
| `after_chat_completion` | **Step 7** | Calls `save_message` (mixed) |
| `discontinuous_last_message` | ✅ Migrated | Pure runtime-write |
| `init_agent_shared_variables` | ✅ Migrated | Pure runtime-write |
| `init_agent_session_variables` | ✅ Migrated | Pure runtime-write |
**Step 6 total: 12 migrated, 15 deferred.**
### Step 6's deferral load redistributes to later steps
Running tally of deferrals after Step 6:
- **Step 6.5 targets:** `use_role`, `use_session`, `exit_agent`
(3 methods). These must be migrated alongside the
`ToolScope` / `McpFactory` rework because they reinit or
inspect the MCP registry.
- **Step 7 targets:** `use_prompt`, `edit_role`,
`after_chat_completion`, `select_functions`,
`select_enabled_functions`, `select_enabled_mcp_servers`
(from Step 3), `setup_model`, `update` (from Step 4),
`info`, `session_info`, `sysinfo` (from Step 5),
**plus** the original Step 7 mixed-method list:
`current_model`, `extract_role`, `set_temperature`,
`set_top_p`, `set_enabled_tools`, `set_enabled_mcp_servers`,
`set_save_session`, `set_compression_threshold`,
`set_rag_reranker_model`, `set_rag_top_k`,
`set_max_output_tokens`, `set_model`, `retrieve_role`,
`use_role_safely`, `use_session_safely`, `save_message`,
`render_prompt_left`, `render_prompt_right`,
`generate_prompt_context`, `repl_complete`. This is a big
step.
- **Step 7/8 targets (lifecycle refactor):** Session
compression and autonaming tasks, RAG lifecycle methods,
`use_agent`, `apply_prelude`. These may want their own
dedicated step if the Step 7 list gets too long.
## Verification
### Compilation
- `cargo check` — clean, **zero warnings, zero errors**
- `cargo clippy` — clean
### Tests
- `cargo test`**63 passed, 0 failed** (unchanged from
Steps 15)
Step 6 added no new tests — duplication pattern. Existing
tests confirm nothing regressed.
### Manual smoke test
Not applicable — no runtime behavior changed. CLI and REPL
still call `Config::use_role_obj()`, `exit_session()`, etc.
as before.
## Handoff to next step
### What Step 6.5 can rely on
Step 6.5 (unify `ToolScope` / `AgentRuntime` / `McpFactory` /
`RagCache`) can rely on:
- `RequestContext` now has **25 inherent methods** across all
impl blocks (1 constructor + 13 reads from Step 5 + 12
writes from Step 6)
- `role_like_mut` is available (Step 5) — foundation for
Step 7's `set_*` methods
- `exit_session`, `save_session`, `empty_session`,
`exit_agent_session`, `init_agent_shared_variables`,
`init_agent_session_variables` are all on `RequestContext`
the `use_role`, `use_session`, and `exit_agent` migrations
in Step 6.5 can call these directly on the new context type
- `before_chat_completion`, `discontinuous_last_message`, etc.
are also on `RequestContext` — available for the new
`RequestContext` versions of deferred methods
- `Config::use_role`, `Config::use_session`, `Config::exit_agent`
are **still on `Config`** and must be handled by Step 6.5's
`ToolScope` refactoring because they touch `self.functions`
and `self.mcp_registry`
- The bridge from Step 1, `paths` module from Step 2, Steps
3-5 new methods, and all previous deferrals are unchanged
### What Step 6.5 should watch for
- **Step 6.5 is the big architecture step.** It replaces:
- `Config.functions: Functions` with
`RequestContext.tool_scope: ToolScope` (containing
`functions`, `mcp_runtime`, `tool_tracker`)
- `Config.mcp_registry: Option<McpRegistry>` with
`AppState.mcp_factory: Arc<McpFactory>` (pool) +
`ToolScope.mcp_runtime: McpRuntime` (per-scope handles)
- Agent-scoped supervisor/inbox/todo into
`RequestContext.agent_runtime: Option<AgentRuntime>`
- Agent RAG into a shared `AppState.rag_cache: Arc<RagCache>`
- **Once `ToolScope` exists**, Step 6.5 can migrate `use_role`
and `use_session` by replacing the `self.functions.clear_*` /
`McpRegistry::reinit` dance with
`self.tool_scope = app.mcp_factory.build_tool_scope(...)`.
- **`exit_agent` calls `self.load_functions()`** which reloads
the global tools. In the new design, exiting an agent should
rebuild the `tool_scope` for the now-topmost `RoleLike`. The
plan's Step 6.5 describes this exact transition.
- **Phase 5 adds the idle pool to `McpFactory`.** Step 6.5
ships the no-pool version: `acquire()` always spawns fresh,
`Drop` always tears down. Correct but not optimized.
- **`RagCache` serves both standalone and agent RAGs.** Step
6.5 needs to route `use_rag` (deferred) and agent activation
through the cache. Since `use_rag` is a Category C deferral
(takes `&GlobalConfig`), Step 6.5 may not touch it — it may
need to wait for Step 8.
### What Step 6.5 should NOT do
- Don't touch the 25 methods already on `RequestContext` — they
stay until Steps 8+ caller migration.
- Don't touch the `AppConfig` methods from Steps 3-4.
- Don't migrate the Step 7 targets unless they become
unblocked by the `ToolScope` / `AgentRuntime` refactor.
- Don't try to build the `McpFactory` idle pool — that's
Phase 5.
### Files to re-read at the start of Step 6.5
- `docs/PHASE-1-IMPLEMENTATION-PLAN.md` — Step 6.5 section
(the biggest single section, ~90 lines)
- `docs/REST-API-ARCHITECTURE.md` — section 5 (Tool Scope
Isolation) has the full design for `ToolScope`, `McpRuntime`,
`McpFactory`, `RagCache`, `AgentRuntime`
- This notes file — specifically "Category A" deferrals
(`use_role`, `use_session`, `exit_agent`)
- `src/config/mod.rs` — current `Config::use_role`,
`Config::use_session`, `Config::exit_agent` bodies to see
the MCP/functions handling that needs replacing
## Follow-up (not blocking Step 6.5)
### 1. `save_message` is private and heavy
`after_chat_completion` was deferred because it calls the
private `save_message` method, which is ~50 lines of logic
touching `self.save` (serialized), `self.session` (runtime),
`self.agent` (runtime), and the messages file (via
`self.messages_file()` which is on `RequestContext`). Step 7
should migrate `save_message` first, then
`after_chat_completion` can follow.
### 2. `Config::use_session_safely` and `use_role_safely` are a pattern to replace
Both methods do `take(&mut *guard)` on the `GlobalConfig` then
call the instance method on the taken `Config`, then put it
back. This pattern exists because `use_role` and `use_session`
are `&mut self` methods that need to await across the call,
and the `RwLock` can't be held across `.await`.
When `use_role` and `use_session` move to `RequestContext` in
Step 6.5, the `_safely` wrappers can be eliminated entirely —
the caller just takes `&mut RequestContext` directly. Flag
this as a cleanup opportunity for Step 8.
### 3. `RequestContext` is now ~400 lines
Counting imports, struct definition, and 3 `impl` blocks:
```
use statements: ~20 lines
struct definition: ~30 lines
impl 1 (new): ~25 lines
impl 2 (reads, Step 5): ~155 lines
impl 3 (writes, Step 6): ~160 lines
Total: ~390 lines
```
Still manageable. Step 6.5 will add `tool_scope` and
`agent_runtime` fields plus their methods, pushing toward
~500 lines. Post-Phase 1 cleanup should probably split into
separate files (`reads.rs`, `writes.rs`, `tool_scope.rs`,
`agent_runtime.rs`) but that's optional.
### 4. Bridge-window duplication count at end of Step 6
Running tally:
- `AppConfig` (Steps 3+4): 11 methods
- `RequestContext` (Steps 5+6): 25 methods
- `paths` module (Step 2): 33 free functions (not duplicated)
**Total bridge-window duplication: 36 methods / ~550 lines.**
All auto-delete in Step 10.
## References
- Phase 1 plan: `docs/PHASE-1-IMPLEMENTATION-PLAN.md`
- Architecture doc: `docs/REST-API-ARCHITECTURE.md`
- Step 5 notes: `docs/implementation/PHASE-1-STEP-5-NOTES.md`
- Modified file: `src/config/request_context.rs` (new
`impl RequestContext` block with 12 write methods, plus
`Input` import)
- Unchanged but referenced: `src/config/mod.rs` (original
`Config` methods still exist for all 27 targets)
@@ -0,0 +1,535 @@
# Phase 1 Step 6.5 — Implementation Notes
## Status
Done.
## Plan reference
- Plan: `docs/PHASE-1-IMPLEMENTATION-PLAN.md`
- Section: "Step 6.5: Unify tool/MCP fields into `ToolScope` and
agent fields into `AgentRuntime`"
## Summary
Step 6.5 is the "big architecture step." The plan describes it as
a semantic rewrite of scope transitions (`use_role`, `use_session`,
`use_agent`, `exit_*`) to build and swap `ToolScope` instances via
a new `McpFactory`, plus an `AgentRuntime` collapse for agent-
specific state, and a unified `RagCache` on `AppState`.
**This implementation deviates from the plan.** Rather than doing
the full semantic rewrite, Step 6.5 ships **scaffolding only**:
- New types (`ToolScope`, `McpRuntime`, `McpFactory`, `McpServerKey`,
`RagCache`, `RagKey`, `AgentRuntime`) exist and compile
- New fields on `AppState` (`mcp_factory`, `rag_cache`) and
`RequestContext` (`tool_scope`, `agent_runtime`) coexist with
the existing flat fields
- The `Config::to_request_context` bridge populates the new
sub-struct fields with defaults; real values flow through the
existing flat fields during the bridge window
- **No scope transitions are rewritten**; `Config::use_role`,
`Config::use_session`, `Config::use_agent`, `Config::exit_agent`
stay on `Config` and continue working with the old
`McpRegistry` / `Functions` machinery
The semantic rewrite is **deferred to Step 8** when the entry
points (`main.rs`, `repl/mod.rs`) get rewritten to thread
`RequestContext` through the pipeline. That's the natural point
to switch from `Config::use_role` to
`RequestContext::use_role_with_tool_scope`-style methods, because
the callers will already be holding the right instance type.
See "Deviations from plan" for the full rationale.
## What was changed
### New files
Four new modules under `src/config/`, all with module docstrings
explaining their scaffolding status and load-bearing references
to the architecture + phase plan docs:
- **`src/config/tool_scope.rs`** (~75 lines)
- `ToolScope` struct: `functions`, `mcp_runtime`, `tool_tracker`
with `Default` impl
- `McpRuntime` struct: wraps a
`HashMap<String, Arc<ConnectedServer>>` (reuses the existing
rmcp `RunningService` type)
- Basic accessors: `is_empty`, `insert`, `get`, `server_names`
- No `build_from_enabled_list` or similar; that's Step 8
- **`src/config/mcp_factory.rs`** (~90 lines)
- `McpServerKey` struct: `name` + `command` + sorted `args` +
sorted `env` (so identically-configured servers hash to the
same key and share an `Arc`, while differently-configured
ones get independent processes — the sharing-vs-isolation
invariant from architecture doc section 5)
- `McpFactory` struct:
`Mutex<HashMap<McpServerKey, Weak<ConnectedServer>>>` for
future sharing
- Basic accessors: `active_count`, `try_get_active`,
`insert_active`
- **No `acquire()` that actually spawns.** That would require
lifting the MCP server startup logic out of
`McpRegistry::init_server` into a factory method. Deferred
to Step 8 with the scope transition rewrites.
- **`src/config/rag_cache.rs`** (~90 lines)
- `RagKey` enum: `Named(String)` vs `Agent(String)` (distinct
namespaces)
- `RagCache` struct:
`RwLock<HashMap<RagKey, Weak<Rag>>>` with weak-ref sharing
- `try_get`, `insert`, `invalidate`, `entry_count`
- `load_with<F, Fut>()` — async helper that checks the cache,
calls a user-provided loader closure on miss, inserts the
result, and returns the `Arc`. Has a small race window
between `try_get` and `insert` (two concurrent misses will
both load); this is acceptable for Phase 1 per the
architecture doc's "concurrent first-load" note. Tightening
with a per-key `OnceCell` or `tokio::sync::Mutex` lands in
Phase 5.
- **`src/config/agent_runtime.rs`** (~95 lines)
- `AgentRuntime` struct with every field from the plan:
`rag`, `supervisor`, `inbox`, `escalation_queue`,
`todo_list: Option<TodoList>`, `self_agent_id`,
`parent_supervisor`, `current_depth`, `auto_continue_count`
- `new()` constructor that takes the required agent context
(id, supervisor, inbox, escalation queue) and initializes
optional fields to `None`/`0`
- `with_rag`, `with_todo_list`, `with_parent_supervisor`,
`with_depth` builder methods for Step 8's activation path
- **`todo_list` is `Option<TodoList>`** (opportunistic
tightening over today's `Config.agent.todo_list:
TodoList`): the field will be `Some(...)` only when
`spec.auto_continue == true`, saving an allocation for
agents that don't use the todo system
### Modified files
- **`src/mcp/mod.rs`** — changed `type ConnectedServer` from
private to `pub type ConnectedServer` so `tool_scope.rs` and
`mcp_factory.rs` can reference the type without reaching into
`rmcp` directly. One-character change (`type``pub type`).
- **`src/config/mod.rs`** — registered 4 new `mod` declarations
(`agent_runtime`, `mcp_factory`, `rag_cache`, `tool_scope`)
alphabetically in the module list. No `pub use` re-exports —
the types are used via their module paths by the parent
`config` crate's children.
- **`src/config/app_state.rs`** — added `mcp_factory:
Arc<McpFactory>` and `rag_cache: Arc<RagCache>` fields, plus
the corresponding imports. Updated the module docstring to
reflect the Step 6.5 additions and removed the old "TBD"
placeholder language about `McpFactory`.
- **`src/config/request_context.rs`** — added `tool_scope:
ToolScope` and `agent_runtime: Option<AgentRuntime>` fields
alongside the existing flat fields, plus imports. Updated
`RequestContext::new()` to initialize them with
`ToolScope::default()` and `None`. Rewrote the module
docstring to explain that flat and sub-struct fields coexist
during the bridge window.
- **`src/config/bridge.rs`** — updated
`Config::to_request_context` to initialize `tool_scope` with
`ToolScope::default()` and `agent_runtime` with `None` (the
bridge doesn't try to populate the sub-struct fields because
they're deferred scaffolding). Updated the three test
`AppState` constructors to pass `McpFactory::new()` and
`RagCache::new()` for the new required fields, plus added
imports for `McpFactory` and `RagCache` in the test module.
- **`Cargo.toml`** — no changes. `parking_lot` and the rmcp
dependencies were already present.
## Key decisions
### 1. **Scaffolding-only, not semantic rewrite**
This is the biggest decision in Step 6.5 and a deliberate
deviation from the plan. The plan says Step 6.5 should
"rewrite scope transitions" (item 5, page 373) to build and
swap `ToolScope` instances via `McpFactory::acquire()`.
**Why I did scaffolding only instead:**
- **Consistency with the bridge pattern.** Steps 36 all
followed the same shape: add new code alongside old, don't
migrate callers, let Step 8 do the real wiring. The bridge
pattern works because it keeps every intermediate state
green and testable. Doing the full Step 6.5 rewrite would
break that pattern.
- **Caller migration is a Step 8 concern.** The plan's Step
6.5 semantics assume callers hold a `RequestContext` and
can call `ctx.use_role(&app)` to rebuild `ctx.tool_scope`.
But during the bridge window, callers still hold
`GlobalConfig` / `&Config` and call `config.use_role(...)`.
Rewriting `use_role` to take `(&mut RequestContext,
&AppState)` would either:
1. Break every existing caller immediately (~20+ callsites),
forcing a partial Step 8 during Step 6.5, OR
2. Require a parallel `RequestContext::use_role_with_tool_scope`
method alongside `Config::use_role`, doubling the
duplication count for no benefit during the bridge
- **The plan's Step 6.5 risk note explicitly calls this out:**
*"Risk: Mediumhigh. This is where the Phase 1 refactor
stops being mechanical and starts having semantic
implications."* The scaffolding-only approach keeps Step 6.5
mechanical and pushes the semantic risk into Step 8 where it
can be handled alongside the entry point rewrite. That's a
better risk localization strategy.
- **The new types are still proven by construction.**
`Config::to_request_context` now builds `ToolScope::default()`
and `agent_runtime: None` on every call, and the bridge
round-trip test still passes. That proves the types compile,
have sensible defaults, and don't break the existing runtime
contract. Step 8 can then swap in real values without
worrying about type plumbing.
### 2. `McpFactory::acquire()` is not implemented
The plan says Step 6.5 ships a trivial `acquire()` that
"checks `active` for an upgradable `Weak`, otherwise spawns
fresh" and "drops tear down the subprocess directly."
I wrote the `Mutex<HashMap<McpServerKey, Weak<ConnectedServer>>>`
field and the `try_get_active` / `insert_active` building
blocks, but not an `acquire()` method. The reason is that
actually spawning an MCP subprocess requires lifting the
current spawning logic out of `McpRegistry::init_server` (in
`src/mcp/mod.rs`) — that's a ~60 line chunk of tokio child
process setup, rmcp handshake, and error handling that's
tightly coupled to `McpRegistry`. Extracting it as a factory
method is a meaningful refactor that belongs alongside the
Step 8 caller migration, not as orphaned scaffolding that
nobody calls.
The `try_get_active` and `insert_active` primitives are the
minimum needed for Step 8's `acquire()` implementation to be
a thin wrapper.
### 3. Sub-struct fields coexist with flat fields
`RequestContext` now has both:
- **Flat fields** (`functions`, `tool_call_tracker`,
`supervisor`, `inbox`, `root_escalation_queue`,
`self_agent_id`, `current_depth`, `parent_supervisor`) —
populated by `Config::to_request_context` during the bridge
- **Sub-struct fields** (`tool_scope: ToolScope`,
`agent_runtime: Option<AgentRuntime>`) — default-
initialized in `RequestContext::new()` and by the bridge;
real population happens in Step 8
This is deliberate scaffolding, not a refactor miss. The
module docstring explicitly explains this so a reviewer
doesn't try to "fix" the apparent duplication.
When Step 8 migrates `use_role` and friends to `RequestContext`,
those methods will populate `tool_scope` and `agent_runtime`
directly. The flat fields will become stale / unused during
Step 8 and get deleted alongside `Config` in Step 10.
### 4. `ConnectedServer` visibility bump
The minimum change to `src/mcp/mod.rs` was making
`type ConnectedServer` public (`pub type ConnectedServer`).
This lets `tool_scope.rs` and `mcp_factory.rs` reference the
live MCP handle type directly without either:
1. Reaching into `rmcp::service::RunningService<RoleClient, ()>`
from the config crate (tight coupling to rmcp)
2. Inventing a new `McpServerHandle` wrapper (premature
abstraction that would need to be unwrapped later)
The visibility change is bounded: `ConnectedServer` is only
used from within the `loki` crate, and `pub` here means
"visible to the whole crate" via Rust's module privacy, not
"part of Loki's external API."
### 5. `todo_list: Option<TodoList>` tightening
`AgentRuntime.todo_list: Option<TodoList>` (vs today's
`Agent.todo_list: TodoList` with `Default::default()` always
allocated). This is an opportunistic memory optimization
during the scaffolding phase: when Step 8 populates
`AgentRuntime`, it should allocate `Some(TodoList::default())`
only when `spec.auto_continue == true`. Agents without
auto-continue skip the allocation entirely.
This is documented in the `agent_runtime.rs` module docstring
so a reviewer doesn't try to "fix" the `Option` into a bare
`TodoList`.
## Deviations from plan
### Full plan vs this implementation
| Plan item | Status |
|---|---|
| Implement `McpRuntime` and `ToolScope` | ✅ Done (scaffolding) |
| Implement `McpFactory` — no pool, `acquire()` | ⚠️ **Partial** — types + accessors, no `acquire()` |
| Implement `RagCache` with `RagKey`, weak-ref sharing, per-key serialization | ✅ Done (scaffolding, no per-key serialization — Phase 5) |
| Implement `AgentRuntime` with `Option<TodoList>` and agent RAG | ✅ Done (scaffolding) |
| Rewrite scope transitions (`use_role`, `use_session`, `use_agent`, `exit_*`, `update`) | ❌ **Deferred to Step 8** |
| `use_rag` rewritten to use `RagCache` | ❌ **Deferred to Step 8** |
| Agent activation populates `AgentRuntime`, serves RAG from cache | ❌ **Deferred to Step 8** |
| `exit_agent` rebuilds parent's `ToolScope` | ❌ **Deferred to Step 8** |
| Sub-agent spawning constructs fresh `RequestContext` | ❌ **Deferred to Step 8** |
| Remove old `Agent::init` registry-mutation logic | ❌ **Deferred to Step 8** |
| `rebuild_rag` / `edit_rag_docs` use `rag_cache.invalidate` | ❌ **Deferred to Step 8** |
All the ❌ items are semantic rewrites that require caller
migration to take effect. Deferring them keeps Step 6.5
strictly additive and consistent with Steps 36. Step 8 will
do the semantic rewrite with the benefit of all the
scaffolding already in place.
### Impact on Step 7
Step 7 is unchanged. The mixed methods (including Steps 36
deferrals like `current_model`, `extract_role`, `sysinfo`,
`info`, `session_info`, `use_prompt`, etc.) still need to be
split into explicit `(&AppConfig, &RequestContext)` signatures
the same way the plan originally described. They don't depend
on the `ToolScope` / `McpFactory` rewrite being done.
### Impact on Step 8
Step 8 absorbs the full Step 6.5 semantic rewrite. The
original Step 8 scope was "rewrite entry points" — now it
also includes "rewrite scope transitions to use new types."
This is actually the right sequencing because callers and
their call sites migrate together.
The Step 8 scope is now substantially bigger than originally
planned. The plan should be updated to reflect this, either
by splitting Step 8 into 8a (scope transitions) + 8b (entry
points) or by accepting the bigger Step 8.
### Impact on Phase 5
Phase 5's "MCP pooling" scope is unchanged. Phase 5 adds the
idle pool + reaper + health checks to an already-working
`McpFactory::acquire()`. If Step 8 lands the working
`acquire()`, Phase 5 plugs in the pool; if Step 8 somehow
ships without `acquire()`, Phase 5 has to write it too.
Phase 5's plan doc should note this dependency.
## Verification
### Compilation
- `cargo check` — clean, **zero warnings, zero errors**
- `cargo clippy` — clean
### Tests
- `cargo test` — **63 passed, 0 failed** (unchanged from
Steps 16)
The bridge round-trip tests are the critical check for this
step because they construct `AppState` instances, and
`AppState` now has two new required fields. All three tests
(`to_app_config_copies_every_serialized_field`,
`to_request_context_copies_every_runtime_field`,
`round_trip_preserves_all_non_lossy_fields`,
`round_trip_default_config`) pass after updating the
`AppState` constructors in the test module.
### Manual smoke test
Not applicable — no runtime behavior changed. CLI and REPL
still call `Config::use_role()`, `Config::use_session()`,
etc. and those still work against the old `McpRegistry` /
`Functions` machinery.
## Handoff to next step
### What Step 7 can rely on
Step 7 (mixed methods) can rely on:
- **Zero changes to existing `Config` methods or fields.**
Step 6.5 didn't touch any of the Step 7 targets.
- **New sub-struct fields exist on `RequestContext`** but are
default-initialized and shouldn't be consulted by any
Step 7 mixed-method migration. If a Step 7 method legitimately
needs `tool_scope` or `agent_runtime` (e.g., because it's
reading the active tool set), that's a signal the method
belongs in Step 8, not Step 7.
- **`AppConfig` methods from Steps 3-4 are unchanged.**
- **`RequestContext` methods from Steps 5-6 are unchanged.**
- **`Config::use_role`, `Config::use_session`,
`Config::use_agent`, `Config::exit_agent`, `Config::use_rag`,
`Config::edit_rag_docs`, `Config::rebuild_rag`,
`Config::apply_prelude` are still on `Config`** and must
stay there through Step 7. They're Step 8 targets.
### What Step 7 should watch for
- **Step 7 targets the 17 mixed methods** from the plan's
original table plus the deferrals accumulated from Steps
36 (`select_functions`, `select_enabled_functions`,
`select_enabled_mcp_servers`, `setup_model`, `update`,
`info`, `session_info`, `sysinfo`, `use_prompt`, `edit_role`,
`after_chat_completion`).
- **The "mixed" category means: reads/writes BOTH serialized
config AND runtime state.** The migration shape is to split
them into explicit
`fn foo(app: &AppConfig, ctx: &RequestContext)` or
`fn foo(app: &AppConfig, ctx: &mut RequestContext)`
signatures.
- **Watch for methods that also touch `self.functions` or
`self.mcp_registry`.** Those need `tool_scope` /
`mcp_factory` which aren't ready yet. If a mixed method
depends on the tool scope rewrite, defer it to Step 8
alongside the scope transitions.
- **`current_model` is the simplest Step 7 target** — it just
picks the right `Model` reference from session/agent/role/
global. Good first target to validate the Step 7 pattern.
- **`sysinfo` is the biggest Step 7 target** — ~70 lines of
reading both `AppConfig` serialized state and
`RequestContext` runtime state to produce a display string.
- **`set_*` methods all follow the pattern from the plan's
Step 7 table:**
```rust
fn set_foo(&mut self, value: ...) {
if let Some(rl) = self.role_like_mut() { rl.set_foo(value) }
else { self.foo = value }
}
```
The new signature splits this: the `role_like` branch moves
to `RequestContext` (using the Step 5 `role_like_mut`
helper), the fallback branch moves to `AppConfig` via
`AppConfig::set_foo`. Callers then call either
`ctx.set_foo_via_role_like(value)` or
`app_config.set_foo(value)` depending on context.
- **`update` is a dispatcher** — once all the `set_*` methods
are split, `update` migrates to live on `RequestContext`
(because it needs both `ctx.set_*` and `app.set_*` to
dispatch to).
### What Step 7 should NOT do
- Don't touch the 4 new types from Step 6.5 (`ToolScope`,
`McpRuntime`, `McpFactory`, `RagCache`, `AgentRuntime`).
They're scaffolding, untouched until Step 8.
- Don't try to populate `tool_scope` or `agent_runtime` from
any Step 7 migration. Those are Step 8.
- Don't migrate `use_role`, `use_session`, `use_agent`,
`exit_agent`, or any method that touches
`self.mcp_registry` / `self.functions`. Those are Step 8.
- Don't migrate callers of any migrated method.
- Don't touch the bridge's `to_request_context` /
`to_app_config` / `from_parts`. The round-trip still
works with `tool_scope` and `agent_runtime` defaulting.
### Files to re-read at the start of Step 7
- `docs/PHASE-1-IMPLEMENTATION-PLAN.md` — Step 7 section (the
17-method table starting at line ~525)
- This notes file — specifically the accumulated deferrals
list from Steps 3-6 in the "What Step 7 should watch for"
section
- Step 6 notes — which methods got deferred from Step 6 vs
Step 7 boundary
## Follow-up (not blocking Step 7)
### 1. Step 8's scope is now significantly larger
The original Phase 1 plan estimated Step 8 as "rewrite
`main.rs` and `repl/mod.rs` to use `RequestContext`" — a
meaningful but bounded refactor. After Step 6.5's deferral,
Step 8 also includes:
- Implementing `McpFactory::acquire()` by extracting server
startup logic from `McpRegistry::init_server`
- Rewriting `use_role`, `use_session`, `use_agent`,
`exit_agent`, `use_rag`, `edit_rag_docs`, `rebuild_rag`,
`apply_prelude`, agent sub-spawning
- Wiring `tool_scope` population into all the above
- Populating `agent_runtime` on agent activation
- Building the parent-scope `ToolScope` restoration logic in
`exit_agent`
- Routing `rebuild_rag` / `edit_rag_docs` through
`RagCache::invalidate`
This is a big step. The phase plan should be updated to
either split Step 8 into sub-steps or to flag the expanded
scope.
### 2. `McpFactory::acquire()` extraction is its own mini-project
Looking at `src/mcp/mod.rs`, the subprocess spawn + rmcp
handshake lives inside `McpRegistry::init_server` (private
method, ~60 lines). Step 8's first task should be extracting
this into a pair of functions:
1. `McpFactory::spawn_fresh(spec: &McpServerSpec) ->
Result<ConnectedServer>` — pure subprocess + handshake
logic
2. `McpRegistry::init_server` — wraps `spawn_fresh` with
registry bookkeeping (adds to `servers` map, fires catalog
discovery, etc.) for backward compat
Then `McpFactory::acquire()` can call `spawn_fresh` on cache
miss. The existing `McpRegistry::init_server` keeps working
for the bridge window callers.
### 3. The `load_with` race is documented but not fixed
`RagCache::load_with` has a race window: two concurrent
callers with the same key both miss the cache, both call
the loader closure, both insert into the map. The second
insert overwrites the first. Both callers end up with valid
`Arc<Rag>`s but the cache sharing is broken for that
instant.
For Phase 1 Step 6.5, this is acceptable because the cache
isn't populated by real usage yet. Phase 5's pooling work
should tighten this with per-key `OnceCell` or
`tokio::sync::Mutex`.
### 4. Bridge-window duplication count at end of Step 6.5
Running tally:
- `AppConfig` (Steps 3+4): 11 methods duplicated with `Config`
- `RequestContext` (Steps 5+6): 25 methods duplicated with
`Config` (1 constructor + 13 reads + 12 writes)
- `paths` module (Step 2): 33 free functions (not duplicated)
- **Step 6.5 NEW:** 4 types + 2 `AppState` fields + 2
`RequestContext` fields — **all additive scaffolding, no
duplication of logic**
**Total bridge-window duplication: 36 methods / ~550 lines**,
unchanged from end of Step 6. Step 6.5 added types but not
duplicated logic.
## References
- Phase 1 plan: `docs/PHASE-1-IMPLEMENTATION-PLAN.md`
- Architecture doc: `docs/REST-API-ARCHITECTURE.md` section 5
- Phase 5 plan: `docs/PHASE-5-IMPLEMENTATION-PLAN.md`
- Step 6 notes: `docs/implementation/PHASE-1-STEP-6-NOTES.md`
- New files:
- `src/config/tool_scope.rs`
- `src/config/mcp_factory.rs`
- `src/config/rag_cache.rs`
- `src/config/agent_runtime.rs`
- Modified files:
- `src/mcp/mod.rs` (`type ConnectedServer` → `pub type`)
- `src/config/mod.rs` (4 new `mod` declarations)
- `src/config/app_state.rs` (2 new fields + docstring)
- `src/config/request_context.rs` (2 new fields + docstring)
- `src/config/bridge.rs` (3 test `AppState` constructors
updated, `to_request_context` adds 2 defaults)
+536
View File
@@ -0,0 +1,536 @@
# Phase 1 Step 7 — Implementation Notes
## Status
Done.
## Plan reference
- Plan: `docs/PHASE-1-IMPLEMENTATION-PLAN.md`
- Section: "Step 7: Tackle mixed methods (THE HARD PART)"
## Summary
Added 14 mixed-method splits to the new types, plus 6 global-
default setters on `AppConfig`. The methods that mix serialized
config reads/writes with runtime state reads/writes are now
available on `RequestContext` with `&AppConfig` as an explicit
parameter for the serialized half.
Same bridge pattern as Steps 36: `Config`'s originals stay
intact, new methods sit alongside, caller migration happens in
Step 8.
**Step 7 completed ~65% of its planned scope.** Nine target
methods were deferred to Step 8 because they transitively
depend on `Model::retrieve_model(&Config)` and
`list_models(&Config)` — refactoring those requires touching
the `client` module macros, which is beyond Step 7's bridge-
pattern scope. Step 8 will rewrite them alongside the entry
point migration.
## What was changed
### Modified files
- **`src/config/app_config.rs`** — added a third `impl AppConfig`
block with 6 `set_*_default` methods for the serialized-field
half of the mixed-method splits:
- `set_temperature_default`
- `set_top_p_default`
- `set_enabled_tools_default`
- `set_enabled_mcp_servers_default`
- `set_save_session_default`
- `set_compression_threshold_default`
- **`src/config/request_context.rs`** — added a fourth
`impl RequestContext` block with 14 methods:
**Helpers (2):**
- `current_model(&self) -> &Model` — pure runtime traversal
(session > agent > role > ctx.model)
- `extract_role(&self, app: &AppConfig) -> Role` — pure
runtime except fallback reads `app.temperature`,
`app.top_p`, `app.enabled_tools`, `app.enabled_mcp_servers`
**Role-like setters (7):** these all return `bool`
indicating whether they mutated a `RoleLike` (if `false`,
the caller should fall back to
`app.set_<name>_default()`). This preserves the exact
semantics of today's `Config::set_*` methods:
- `set_temperature_on_role_like`
- `set_top_p_on_role_like`
- `set_enabled_tools_on_role_like`
- `set_enabled_mcp_servers_on_role_like`
- `set_save_session_on_session` (uses `self.session` directly,
not `role_like_mut`)
- `set_compression_threshold_on_session` (same)
- `set_max_output_tokens_on_role_like`
**Chat lifecycle (2):**
- `save_message(&mut self, app: &AppConfig, input, output)`
writes to session if present, else to messages file if
`app.save` is true
- `after_chat_completion(&mut self, app, input, output,
tool_results)` — updates `last_message`, calls
`save_message` if not `app.dry_run`
- `open_message_file(&self) -> Result<File>` — private
helper
**Info getters (3):**
- `sysinfo(&self, app: &AppConfig) -> Result<String>` —
~70-line display output mixing serialized and runtime
state
- `info(&self, app: &AppConfig) -> Result<String>` —
delegates to `sysinfo` in fallback branch
- `session_info(&self, app: &AppConfig) -> Result<String>` —
calls `app.render_options()`
**Prompt rendering (3):**
- `generate_prompt_context(&self, app) -> HashMap<&str, String>` —
builds the template variable map
- `render_prompt_left(&self, app) -> String`
- `render_prompt_right(&self, app) -> String`
**Function selection (3):**
- `select_enabled_functions(&self, app, role) -> Vec<FunctionDeclaration>` —
filters `ctx.functions.declarations()` by role's enabled
tools + agent filters + user interaction functions
- `select_enabled_mcp_servers(&self, app, role) -> Vec<...>` —
same pattern for MCP meta-functions
- `select_functions(&self, app, role) -> Option<Vec<...>>` —
combines both
- **`src/config/mod.rs`** — bumped `format_option_value` from
private to `pub(super)` so `request_context.rs` can use it
as `super::format_option_value`.
### Unchanged files
- **`src/config/mod.rs`** — all Step 7 target methods still
exist on `Config`. They continue to work for every current
caller.
## Key decisions
### 1. Same bridge pattern as Steps 3-6
Step 7 follows the same additive pattern as earlier steps: new
methods on `AppConfig` / `RequestContext`, `Config`'s originals
untouched, no caller migration. Caller migration is Step 8.
The plan's Step 7 description implied a semantic rewrite
("split into explicit parameter passing") but that phrasing
applies to the target signatures, not the migration mechanism.
The bridge pattern achieves the same end state — methods with
`(&AppConfig, &RequestContext)` signatures exist and are ready
for Step 8 to call.
### 2. `set_*` methods split into `_on_role_like` + `_default` pair
Today's `Config::set_temperature` does:
```rust
match self.role_like_mut() {
Some(role_like) => role_like.set_temperature(value),
None => self.temperature = value,
}
```
The Step 7 split:
```rust
// On RequestContext:
fn set_temperature_on_role_like(&mut self, value) -> bool {
match self.role_like_mut() {
Some(rl) => { rl.set_temperature(value); true }
None => false,
}
}
// On AppConfig:
fn set_temperature_default(&mut self, value) {
self.temperature = value;
}
```
**The bool return** is the caller contract: if `_on_role_like`
returns `false`, the caller must call
`app.set_*_default(value)`. This is what Step 8 callers will
do:
```rust
if !ctx.set_temperature_on_role_like(value) {
Arc::get_mut(&mut app.config).unwrap().set_temperature_default(value);
}
```
(Or more likely, the AppConfig mutation gets hidden behind a
helper on `AppState` since `AppConfig` is behind `Arc`.)
This split is semantically equivalent to the existing
behavior while making the "where the value goes" decision
explicit at the type level.
### 3. `save_message` and `after_chat_completion` migrated together
`after_chat_completion` reads `app.dry_run` and calls
`save_message`, which reads `app.save`. Both got deferred from
Step 6 for exactly this mixed-dependency reason. Step 7
migrates them together:
```rust
pub fn after_chat_completion(
&mut self,
app: &AppConfig,
input: &Input,
output: &str,
tool_results: &[ToolResult],
) -> Result<()> {
if !tool_results.is_empty() { return Ok(()); }
self.last_message = Some(LastMessage::new(input.clone(), output.to_string()));
if !app.dry_run {
self.save_message(app, input, output)?;
}
Ok(())
}
```
The `open_message_file` helper moved along with them since
it's only called from `save_message`.
### 4. `format_option_value` visibility bump
`format_option_value` is a tiny private helper in
`src/config/mod.rs` that `sysinfo` uses. Step 7's new
`RequestContext::sysinfo` needs to call it, so I bumped its
visibility from `fn` to `pub(super)`. This is a minimal
change (one word) that lets child modules reuse the helper
without duplicating it.
### 5. `select_*` methods were Step 3 deferrals
The plan's Step 3 table originally listed `select_functions`,
`select_enabled_functions`, and `select_enabled_mcp_servers`
as global-read method targets. Step 3's notes correctly
flagged them as actually-mixed because they read `self.functions`
and `self.agent` (runtime, not serialized).
Step 7 is the right home for them. They take
`(&self, app: &AppConfig, role: &Role)` and read:
- `ctx.functions.declarations()` (runtime — existing flat
field, will collapse into `tool_scope.functions` in Step 8+)
- `ctx.agent` (runtime)
- `app.function_calling_support`, `app.mcp_server_support`,
`app.mapping_tools`, `app.mapping_mcp_servers` (serialized)
The implementations are long (~80 lines each) but are
verbatim copies of the `Config` originals with `self.X`
replaced by `app.X` for serialized fields and `self.X`
preserved for runtime fields.
### 6. `session_info` keeps using `crate::render::MarkdownRender`
I didn't add a top-level `use crate::render::MarkdownRender`
because it's only called from `session_info`. Inline
`crate::render::MarkdownRender::init(...)` is clearer than
adding another global import for a single use site.
### 7. Imports grew substantially
`request_context.rs` now imports from 7 new sources compared
to the end of Step 6:
- `super::AppConfig` (for the mixed-method params)
- `super::MessageContentToolCalls` (for `save_message`)
- `super::LEFT_PROMPT`, `super::RIGHT_PROMPT` (for prompt
rendering)
- `super::ensure_parent_exists` (for `open_message_file`)
- `crate::function::FunctionDeclaration`,
`crate::function::user_interaction::USER_FUNCTION_PREFIX`
- `crate::mcp::MCP_*_META_FUNCTION_NAME_PREFIX` (3 constants)
- `std::collections::{HashMap, HashSet}`,
`std::fs::{File, OpenOptions}`, `std::io::Write`,
`std::path::Path`, `crate::utils::{now, render_prompt}`
This is expected — Step 7's methods are the most
dependency-heavy in Phase 1. PostPhase 1 cleanup can
reorganize into separate files if the module becomes
unwieldy.
## Deviations from plan
### 9 methods deferred to Step 8
| Method | Why deferred |
|---|---|
| `retrieve_role` | Calls `Model::retrieve_model(&Config)` transitively, needs client module refactor |
| `set_model` | Calls `Model::retrieve_model(&Config)` transitively |
| `set_rag_reranker_model` | Takes `&GlobalConfig`, uses `update_rag` helper with Arc<RwLock> take/replace pattern |
| `set_rag_top_k` | Same as above |
| `update` | Dispatcher over all `set_*` methods including the 2 above, plus takes `&GlobalConfig` and touches `mcp_registry` |
| `repl_complete` | Calls `list_models(&Config)` + reads `self.mcp_registry` (going away in Step 6.5/8), + reads `self.functions` |
| `use_role_safely` | Takes `&GlobalConfig`, does `take()`/`replace()` on Arc<RwLock> |
| `use_session_safely` | Same as above |
| `setup_model` | Calls `self.set_model()` which is deferred |
| `use_prompt` (Step 6 deferral) | Calls `current_model()` (migratable) and `use_role_obj` (migrated in Step 6), but the whole method is 4 lines and not independently useful without its callers |
| `edit_role` (Step 6 deferral) | Calls `self.upsert_role()` and `self.use_role()` which are Step 8 |
**Root cause of most deferrals:** the `client` module's
`list_all_models` macro and `Model::retrieve_model` take
`&Config`. Refactoring them to take `&AppConfig` is a
meaningful cross-module change that belongs in Step 8
alongside the caller migration.
### 14 methods migrated
| Method | New signature |
|---|---|
| `current_model` | `&self -> &Model` (pure RequestContext) |
| `extract_role` | `(&self, &AppConfig) -> Role` |
| `set_temperature_on_role_like` | `(&mut self, Option<f64>) -> bool` |
| `set_top_p_on_role_like` | `(&mut self, Option<f64>) -> bool` |
| `set_enabled_tools_on_role_like` | `(&mut self, Option<String>) -> bool` |
| `set_enabled_mcp_servers_on_role_like` | `(&mut self, Option<String>) -> bool` |
| `set_save_session_on_session` | `(&mut self, Option<bool>) -> bool` |
| `set_compression_threshold_on_session` | `(&mut self, Option<usize>) -> bool` |
| `set_max_output_tokens_on_role_like` | `(&mut self, Option<isize>) -> bool` |
| `save_message` | `(&mut self, &AppConfig, &Input, &str) -> Result<()>` |
| `after_chat_completion` | `(&mut self, &AppConfig, &Input, &str, &[ToolResult]) -> Result<()>` |
| `sysinfo` | `(&self, &AppConfig) -> Result<String>` |
| `info` | `(&self, &AppConfig) -> Result<String>` |
| `session_info` | `(&self, &AppConfig) -> Result<String>` |
| `generate_prompt_context` | `(&self, &AppConfig) -> HashMap<&str, String>` |
| `render_prompt_left` | `(&self, &AppConfig) -> String` |
| `render_prompt_right` | `(&self, &AppConfig) -> String` |
| `select_functions` | `(&self, &AppConfig, &Role) -> Option<Vec<...>>` |
| `select_enabled_functions` | `(&self, &AppConfig, &Role) -> Vec<...>` |
| `select_enabled_mcp_servers` | `(&self, &AppConfig, &Role) -> Vec<...>` |
Actually that's 20 methods across the two types (6 on
`AppConfig`, 14 on `RequestContext`). "14 migrated" refers to
the 14 behavior methods on `RequestContext`; the 6 on
`AppConfig` are the paired defaults for the 7 role-like
setters (4 `set_*_default` + 2 session-specific — the
`set_max_output_tokens` split doesn't need a default
because `ctx.model.set_max_tokens()` works without a
fallback).
## Verification
### Compilation
- `cargo check` — clean, **zero warnings, zero errors**
- `cargo clippy` — clean
### Tests
- `cargo test` — **63 passed, 0 failed** (unchanged from
Steps 16.5)
The bridge's round-trip test still passes, confirming the new
methods don't interfere with struct layout or the
`Config → AppConfig + RequestContext → Config` invariant.
### Manual smoke test
Not applicable — no runtime behavior changed. CLI and REPL
still call `Config::set_temperature`, `Config::sysinfo`,
`Config::save_message`, etc. as before.
## Handoff to next step
### What Step 8 can rely on
Step 8 (entry point rewrite) can rely on:
- **`AppConfig` now has 17 methods** (Steps 3+4+7): 7 reads
+ 4 writes + 6 setter-defaults
- **`RequestContext` now has 39 inherent methods** across 5
impl blocks: 1 constructor + 13 reads + 12 writes + 14
mixed
- **All of `AppConfig`'s and `RequestContext`'s new methods
are under `#[allow(dead_code)]`** — that's safe to leave
alone; callers wire them up in Step 8 and the allows
become inert
- **`format_option_value` is `pub(super)`** — accessible
from any `config` child module
- **The bridge (`Config::to_app_config`, `to_request_context`,
`from_parts`) still works** and all round-trip tests pass
- **The `paths` module, Step 3/4 `AppConfig` methods, Step
5/6 `RequestContext` methods, Step 6.5 scaffolding types
are all unchanged**
- **These `Config` methods are still on `Config`** and must
stay there through Step 8 (they're Step 8 targets):
- `retrieve_role`, `set_model`, `set_rag_reranker_model`,
`set_rag_top_k`, `update`, `repl_complete`,
`use_role_safely`, `use_session_safely`, `setup_model`,
`use_prompt`, `edit_role`
- Plus the Step 6 Category A deferrals: `use_role`,
`use_session`, `use_agent`, `exit_agent`
- Plus the Step 6 Category C deferrals: `compress_session`,
`maybe_compress_session`, `autoname_session`,
`maybe_autoname_session`, `use_rag`, `edit_rag_docs`,
`rebuild_rag`, `apply_prelude`
### What Step 8 should watch for
**Step 8 is the biggest remaining step** after Step 6.5
deferred its scope-transition rewrites. Step 8 now absorbs:
1. **Entry point rewrite** (original Step 8 scope):
- `main.rs::run()` constructs `AppState` + `RequestContext`
instead of `GlobalConfig`
- `main.rs::start_directive()` takes
`&mut RequestContext` instead of `&GlobalConfig`
- `main.rs::create_input()` takes `&RequestContext`
- `repl/mod.rs::Repl` holds a long-lived `RequestContext`
instead of `GlobalConfig`
- All 91 callsites in the original migration table
2. **`Model::retrieve_model` refactor** (Step 7 deferrals):
- `Model::retrieve_model(config: &Config, ...)` →
`Model::retrieve_model(config: &AppConfig, ...)`
- `list_all_models!(config: &Config)` macro →
`list_all_models!(config: &AppConfig)`
- `list_models(config: &Config, ...)` →
`list_models(config: &AppConfig, ...)`
- Then migrate `retrieve_role`, `set_model`,
`repl_complete`, `setup_model`
3. **RAG lifecycle migration** (Step 7 deferrals +
Step 6 Category C):
- `use_rag`, `edit_rag_docs`, `rebuild_rag` →
`RequestContext` methods using `RagCache`
- `set_rag_reranker_model`, `set_rag_top_k` → split
similarly to Step 7 setters
4. **Scope transition rewrites** (Step 6.5 deferrals):
- `use_role`, `use_session`, `use_agent`, `exit_agent`
rewritten to build `ToolScope` via `McpFactory`
- `McpFactory::acquire()` extracted from
`McpRegistry::init_server`
- `use_role_safely`, `use_session_safely` eliminated
(not needed once callers hold `&mut RequestContext`)
5. **Session lifecycle migration** (Step 6 Category C):
- `compress_session`, `maybe_compress_session`,
`autoname_session`, `maybe_autoname_session` → methods
that take `&mut RequestContext` instead of spawning
tasks with `GlobalConfig`
- `apply_prelude` → uses migrated `use_role` /
`use_session`
6. **`update` dispatcher** (Step 7 deferral):
- Once all `set_*` are available on `RequestContext` and
`AppConfig`, `update` becomes a dispatcher over the
new split pair
This is a **huge** step. Consider splitting into 8a-8f
sub-steps or staging across multiple PRs.
### What Step 8 should NOT do
- Don't re-migrate any Step 3-7 method
- Don't touch the new types from Step 6.5 unless actually
implementing `McpFactory::acquire()` or
`RagCache::load_with` usage
- Don't leave intermediate states broken — each sub-step
should keep the build green, even if it means keeping
temporary dual code paths
### Files to re-read at the start of Step 8
- `docs/PHASE-1-IMPLEMENTATION-PLAN.md` — Step 8 section
- This notes file — specifically the deferrals table and
Step 8 watch items
- Step 6.5 notes — scope transition rewrite details
- Step 6 notes — Category C deferral inventory
- `src/config/mod.rs` — still has ~25 methods that need
migrating
## Follow-up (not blocking Step 8)
### 1. Bridge-window duplication count at end of Step 7
Running tally:
- `AppConfig` (Steps 3+4+7): 17 methods (11 reads/writes +
6 setter-defaults)
- `RequestContext` (Steps 5+6+7): 39 methods (1 constructor +
13 reads + 12 writes + 14 mixed)
- `paths` module (Step 2): 33 free functions
- Step 6.5 types: 4 new types on scaffolding
**Total bridge-window duplication: 56 methods / ~1200 lines**
(up from 36 / ~550 at end of Step 6).
All auto-delete in Step 10.
### 2. `request_context.rs` is now ~900 lines
Getting close to the point where splitting into multiple
files would help readability. Candidate layout:
- `request_context/mod.rs` — struct definition + constructor
- `request_context/reads.rs` — Step 5 methods
- `request_context/writes.rs` — Step 6 methods
- `request_context/mixed.rs` — Step 7 methods
Not blocking anything; consider during Phase 1 cleanup.
### 3. The `set_*_on_role_like` / `set_*_default` split
has an unusual caller contract
Callers of the split have to remember: "call `_on_role_like`
first, check the bool, call `_default` if false." That's
more verbose than today's `Config::set_temperature` which
hides the dispatch.
Step 8 should add convenience helpers on `RequestContext`
that wrap both halves:
```rust
pub fn set_temperature(&mut self, value: Option<f64>, app: &mut AppConfig) {
if !self.set_temperature_on_role_like(value) {
app.set_temperature_default(value);
}
}
```
But that requires `&mut AppConfig`, which requires unwrapping
the `Arc` on `AppState.config`. The cleanest shape is probably
to move the mutation into a helper on `AppState`:
```rust
impl AppState {
pub fn config_mut(&self) -> Option<&mut AppConfig> {
Arc::get_mut(...)
}
}
```
Or accept that the `.set` REPL command needs an owned
`AppState` (not `Arc<AppState>`) and handle the mutation at
the entry point. Step 8 can decide.
### 4. `select_*` methods are long but verbatim
The 3 `select_*` methods are ~180 lines combined and are
verbatim copies of the `Config` originals. I resisted the
urge to refactor (extract helpers, simplify the
`enabled_tools == "all"` branches, etc.) because:
- Step 7 is about splitting signatures, not style
- The copies get deleted in Step 10 anyway
- Any refactor could introduce subtle behavior differences
that are hard to catch without a functional test for these
specific methods
PostPhase 1 cleanup can factor these if desired.
## References
- Phase 1 plan: `docs/PHASE-1-IMPLEMENTATION-PLAN.md`
- Step 6 notes: `docs/implementation/PHASE-1-STEP-6-NOTES.md`
- Step 6.5 notes: `docs/implementation/PHASE-1-STEP-6.5-NOTES.md`
- Modified files:
- `src/config/app_config.rs` (6 new `set_*_default` methods)
- `src/config/request_context.rs` (14 new mixed methods,
7 new imports)
- `src/config/mod.rs` (`format_option_value` → `pub(super)`)
@@ -0,0 +1,374 @@
# 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_model`
takes `&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 the
`register_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.clients` which is a
serialized field identical on both types. The `OnceLock` caches
(`ALL_CLIENT_NAMES`, `ALL_MODELS`) work identically because
`AppConfig.clients` holds the same values as `Config.clients`.
- **`src/client/model.rs`** — changed the `use` and function
signature:
- `use crate::config::Config``use crate::config::AppConfig`
- `Model::retrieve_model(config: &Config, ...)``(config: &AppConfig, ...)`
The function body was unchanged — it calls `list_all_models(config)`
and `list_client_names(config)` internally, both of which now take
the same `&AppConfig` type.
- **`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` (`.model` branch) → `list_models(&self.to_app_config(), ModelType::Chat)`
- `repl_complete` (`.rag_reranker_model` branch) → `list_models(&self.to_app_config(), ModelType::Reranker)`
- `setup_model``list_models(&self.to_app_config(), ModelType::Chat)`
- **`src/config/session.rs`** — `Session::load` caller updated:
`Model::retrieve_model(&config.to_app_config(), ...)`
- **`src/config/agent.rs`** — `Agent::init` caller 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::create` embedding model lookup
- `Rag::init` `list_models` for embedding model selection
- `Rag::init` `retrieve_model` for embedding model
- `Rag::search` reranker model lookup
- **`src/main.rs`** — `--list-models` CLI 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`** — the `Config::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** — only `macros.rs` and `model.rs`
had the target signatures. Individual client implementations
(`openai.rs`, `claude.rs`, etc.) don't reference `&Config`
directly; they work through the `Client` trait which uses
`GlobalConfig` internally (untouched).
- **Any file calling `init_client` or `GlobalConfig`** — these are
separate from the model-lookup path and stay on `GlobalConfig`
through 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:
```rust
// 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 few `IndexMap` and `Vec` clones — dominated by
`clients: 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.config` without 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:
1. **Used existing `to_app_config()` instead of adding
`app_config_snapshot()`** — see Key Decision #1. The plan's
intent was a helper that clones serialized fields; both names
describe the same thing.
2. **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 errors**
- `cargo clippy` — clean
### Tests
- `cargo test`**63 passed, 0 failed** (unchanged from
Steps 17)
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
migrated `retrieve_role` method on `RequestContext`
- **`list_models(&AppConfig, ModelType)`** — available for
`repl_complete` and `setup_model` migration
- **`list_all_models(&AppConfig)`** — available for internal use
- **`list_client_names(&AppConfig)`** — available (though typically
only called from inside `retrieve_model`)
- **`Config::to_app_config()` bridge helper** — still works, still
used by the old `Config` methods 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
call `self.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 by `retrieve_model` — now unblocked)
- `set_model` (blocked by `retrieve_model` — now unblocked)
- `repl_complete` (blocked by `list_models` — now unblocked)
- `setup_model` (blocked by `list_models` — now unblocked)
- `use_prompt` (calls `current_model` + `use_role_obj` — already
unblocked; was deferred because it's a one-liner not worth
migrating alone)
- `edit_role` (calls `editor` + `upsert_role` + `use_role`
`use_role` is still Step 8d, so `edit_role` may stay deferred)
- `set_rag_reranker_model` (takes `&GlobalConfig`, uses
`update_rag` helper — may stay deferred to Step 8f/8g)
- `set_rag_top_k` (same)
- `update` (dispatcher over all `set_*` — needs all its
dependencies migrated first)
- **`set_model` split pattern.** The old `Config::set_model` does
`role_like_mut` dispatch. Step 8b should split it into
`RequestContext::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_role` migration pattern.** The method takes `&self`
today. On `RequestContext` it becomes `(&self, app: &AppConfig,
name: &str) -> Result<Role>`. The body calls
`paths::list_roles`, `paths::role_file`, `Role::new`, `Role::builtin`,
then `self.current_model()` (already on RequestContext from Step 7),
then `Model::retrieve_model(app, ...)`.
- **`setup_model` has a subtle split.** It writes to
`self.model_id` (serialized) AND `self.model` (runtime) AND calls
`self.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 update
`self.model_id`
- `RequestContext::reload_current_model(&mut self, app: &AppConfig)`
to refresh `ctx.model` from 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 extracts `McpFactory::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.rs` entry points or `repl/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` — current `Config::retrieve_role`,
`Config::set_model`, `Config::repl_complete`,
`Config::setup_model`, `Config::use_prompt`, `Config::edit_role`
method bodies
- `src/config/app_config.rs` — current state of `AppConfig` impl
blocks (Steps 3+4+7)
- `src/config/request_context.rs` — current state of
`RequestContext` impl 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 `OnceLock`s 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 methods
- `RequestContext` (Steps 5+6+7): 39 methods
- `paths` module (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 (for `Model::retrieve_model`
and `list_models`)
- `src/config/session.rs` — 1 callsite
- `src/config/agent.rs` — 1 callsite
- `src/function/supervisor.rs` — 1 callsite
- `src/rag/mod.rs` — 4 callsites
- `src/main.rs` — 1 callsite
- `src/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 the
`register_client!` macro)
- `src/client/model.rs` (`use` statement + `retrieve_model`
signature)
- `src/config/mod.rs` (6 callsite updates in
`set_rag_reranker_model`, `set_model`, `retrieve_role`,
`repl_complete` ×2, `setup_model`)
- `src/config/session.rs` (1 callsite in `Session::load`)
- `src/config/agent.rs` (1 callsite in `Agent::init`)
- `src/function/supervisor.rs` (1 callsite in sub-agent
summarization)
- `src/rag/mod.rs` (4 callsites in `Rag::create`, `Rag::init`,
`Rag::search`)
- `src/main.rs` (1 callsite in `--list-models` handler)
- `src/cli/completer.rs` (1 callsite in shell completion)
+55
View File
@@ -0,0 +1,55 @@
# Implementation Notes
This directory holds per-step implementation notes for the Loki REST API
refactor. Each note captures what was actually built during one step, how
it differed from the plan, any decisions made mid-implementation, and
what the next step needs to know to pick up cleanly.
## Why this exists
The refactor is spread across multiple phases and many steps. The
implementation plans in `docs/PHASE-*-IMPLEMENTATION-PLAN.md` describe
what _should_ happen; these notes describe what _did_ happen. Reading
the plan plus the notes for the most recent completed step is enough
context to start the next step without re-deriving anything from the
conversation history or re-exploring the codebase.
## Naming convention
One file per completed step:
```
PHASE-<phase>-STEP-<step>-NOTES.md
```
Examples:
- `PHASE-1-STEP-1-NOTES.md`
- `PHASE-1-STEP-2-NOTES.md`
- `PHASE-2-STEP-3-NOTES.md`
## Contents of each note
Every note has the same sections so they're easy to scan:
1. **Status** — done / in progress / blocked
2. **Plan reference** — which phase plan + which step section this
implements
3. **Summary** — one or two sentences on what shipped
4. **What was changed** — file-by-file changelist with links
5. **Key decisions** — non-obvious choices made during implementation,
with the reasoning
6. **Deviations from plan** — where the plan said X but reality forced
Y, with explanation
7. **Verification** — what was tested, what passed
8. **Handoff to next step** — what the next step needs to know, any
preconditions, any gotchas
## Lifetime
This directory is transitional. When Phase 1 Step 10 lands and the
`GlobalConfig` type alias is removed, the Phase 1 notes become purely
historical. When all six phases ship, this whole directory can be
archived into `docs/archive/implementation-notes/` or deleted outright —
the plans and final code are what matters long-term, not the
step-by-step reconstruction.