ba05168493
Per the docs Refresh Protocol (conductor/workflow.md), after a
reference/analysis track ships, the affected guides must be updated
to reflect new module structure or new conventions. The nagent_review
track (9cc51ca9) produced a deep-dive + 10 actionable takeaways that
named 3 documentation gaps in /docs. This commit fills them.
3 new guides (1,122 lines total):
1. guide_discussions.md (353 lines) — The Discussion system
- 23-operation matrix: A1-A7 per-entry + B1-B11 discussion-level
+ C1-C5 undo/redo
- Take naming convention (<base>_take_<n>), branching, promotion
- User-managed role list (app.disc_roles)
- Per-role filter linked to MMA persona focus
- _disc_entries_lock thread-safety contract
- Hook API session endpoints
- Persistence: _flush_to_project, _flush_disc_entries_to_project,
context_snapshot
- 9 file:line refs into gui_2.py:3770-4260 + history.py
2. guide_state_lifecycle.md (375 lines) — Undo/redo + reset + state
delegation
- HistoryManager + UISnapshot (13 captured fields, 100-snapshot
capacity, debounced change-detection at render frame)
- _handle_reset_session (clears 30+ fields, replaces project,
preserves active_project_path per the 2026-06-08 regression fix)
- App.__getattr__/__setattr__ state delegation to Controller
- 4-thread access pattern with 7 lock-protected regions
- State persistence: in-memory vs project TOML vs config TOML
- Hot-reload integration
- Hook API registries (_predefined_callbacks, _gettable_fields)
- 14 file:line refs into gui_2.py:1140-1170, history.py,
app_controller.py:3286-3356
3. guide_context_aggregation.md (394 lines) — The aggregate.py
pipeline
- 3 aggregation strategies (auto, summarize, full)
- 7 per-file view modes (full, summary, skeleton, outline,
masked, custom, none)
- Full FileItem schema (9 fields + __post_init__ normalizer)
at models.py:510-559
- ContextPreset schema and ContextPresetManager
- Tier 3 worker variant (build_tier3_context with FuzzyAnchor
re-resolution and focus-file handling)
- force_full / auto_aggregate short-circuits
- Cache strategy (static prefix + dynamic history)
- 23 file:line refs into aggregate.py:36-518 + models.py:909-937
8 existing guides cross-linked to the 3 new guides and to the
nagent_review track:
- guide_gui_2.md (+ See Also entries for discussions,
state lifecycle, context aggregation,
nagent_review report)
- guide_app_controller.md (+ See Also entries for discussions,
state lifecycle, context aggregation,
nagent_review report)
- guide_context_curation.md (+ new See Also section pointing to
context aggregation + nagent_review)
- guide_architecture.md (+ new See Also section listing all 10
guides + nagent_review report)
- guide_ai_client.md (+ See Also entries for state lifecycle,
context aggregation, nagent_review
pitfalls #2 and #4)
- guide_mma.md (+ new See Also section pointing to
context aggregation, discussions,
nagent_review report §9 + takeaways §3/§10
for SubConversationRunner priority)
- guide_models.md (+ See Also entries for context
aggregation, discussions, nagent_review
report §6 on FileItem as strongest
curation dimension)
- Readme.md (+ 3 new guide entries in the index
table, with one-line summaries)
No code modified. This is documentation only.
Why these 3 guides specifically:
- guide_discussions.md: The discussion system is the user's most
edited surface. nagent_review's report §3 enumerated 23 operations
(A1-C5) that previously existed only as scattered file:line refs
across gui_2.py. A dedicated guide makes the operation matrix
discoverable.
- guide_state_lifecycle.md: The undo/redo + reset + state delegation
machinery is architecturally load-bearing but scattered across 4
files. After nagent_review identified the provider-side history
divergence as Pitfall #4, the relationship between Manual Slop's
state and the provider's state needs explicit documentation.
- guide_context_aggregation.md: aggregate.py (518 lines) is the
most-touched module after ai_client.py but had no dedicated
guide. nagent_review confirmed it's Manual Slop's strongest
curation dimension. A dedicated guide makes the 7 view modes
and 3 strategies discoverable.
The 3 new guides total 1,122 lines and follow the existing
per-source-file deep-dive style (architectural, data-oriented,
state-management-focused).
451 lines
18 KiB
Markdown
451 lines
18 KiB
Markdown
# `src/app_controller.py` — Headless Orchestrator & State Hub
|
|
|
|
[Top](../README.md) | [Architecture](guide_architecture.md) | [Discussions](guide_discussions.md) | [State Lifecycle](guide_state_lifecycle.md) | [Context Aggregation](guide_context_aggregation.md) | [MMA](guide_mma.md) | [Testing](guide_testing.md)
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
`src/app_controller.py` (~166KB) is the **headless controller** that owns the application's state and business logic. It decouples the GUI (`gui_2.py`) from the underlying subsystems (AI, presets, personas, RAG, history, MMA, paths, hot reload).
|
|
|
|
When `--enable-test-hooks` is passed, the controller also spins up the HookServer so external tests/scripts can drive the running app.
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
```
|
|
┌─────────────────────────────────────────────────┐
|
|
│ gui_2.py (App) │
|
|
│ - Pure immediate-mode UI │
|
|
│ - Reads app_state for rendering │
|
|
│ - Calls controller methods for mutations │
|
|
└─────────────────┬───────────────────────────────┘
|
|
│ delegates to
|
|
▼
|
|
┌─────────────────────────────────────────────────┐
|
|
│ app_controller.py: AppController │
|
|
│ - State container (AppState) │
|
|
│ - Subsystem coordination (presets, personas, ...) │
|
|
│ - Headless mode: skips GUI init, starts hook │
|
|
│ server on port 8999 │
|
|
│ - Provides _predefined_callbacks and │
|
|
│ _gettable_fields for the Hook API │
|
|
└─────────────────┬───────────────────────────────┘
|
|
│ owns/uses
|
|
▼
|
|
┌─────────────────────────────────────────────────┐
|
|
│ Subsystems │
|
|
│ - PresetManager (src/presets.py) │
|
|
│ - PersonaManager (src/personas.py) │
|
|
│ - ContextPresetManager (src/context_presets.py) │
|
|
│ - ToolPresetManager (src/tool_presets.py) │
|
|
│ - ToolBiasEngine (src/tool_bias.py) │
|
|
│ - RAGEngine (src/rag_engine.py) │
|
|
│ - HistoryManager (src/history.py) │
|
|
│ - WorkspaceManager (src/workspace_manager.py) │
|
|
│ - HookServer (src/api_hooks.py) │
|
|
│ - HotReloader (src/hot_reload.py) │
|
|
│ - PathManager (src/paths.py) │
|
|
└─────────────────────────────────────────────────┘
|
|
```
|
|
|
|
---
|
|
|
|
## The `AppController` Class
|
|
|
|
### `__init__(self, enable_test_hooks: bool = False)`
|
|
|
|
Initializes the controller. Key state:
|
|
|
|
```python
|
|
class AppController:
|
|
def __init__(self, enable_test_hooks: bool = False):
|
|
# 1. Path resolution (src/paths.py)
|
|
self.paths = PathManager()
|
|
|
|
# 2. State container
|
|
self.app_state = AppState()
|
|
|
|
# 3. Subsystem managers
|
|
self.presets = PresetManager(self.paths)
|
|
self.personas = PersonaManager(self.paths)
|
|
self.context_presets = ContextPresetManager(self.paths)
|
|
self.tool_presets = ToolPresetManager(self.paths)
|
|
self.tool_bias = ToolBiasEngine()
|
|
self.history = HistoryManager(self.paths)
|
|
self.workspace = WorkspaceManager(self.paths)
|
|
self.rag_engine = RAGEngine(self.paths) # Lazy
|
|
|
|
# 4. Hook API surface
|
|
self._predefined_callbacks: dict[str, Callable] = {}
|
|
self._gettable_fields: dict[str, str] = {}
|
|
|
|
# 5. AI client (lazy)
|
|
self.ai_client = None
|
|
|
|
# 6. MMA conductor (lazy)
|
|
self.mma_conductor = None
|
|
|
|
# 7. Sync event queue (daemon <-> UI bridge)
|
|
self.event_queue = SyncEventQueue()
|
|
|
|
# 8. Optional hook server
|
|
if enable_test_hooks:
|
|
self.hook_server = HookServer()
|
|
self.hook_server.start()
|
|
```
|
|
|
|
The `App` (in `gui_2.py`) then reads `controller.app_state`, `controller.presets`, etc. for rendering.
|
|
|
|
### `register_hooks(app: App)`
|
|
|
|
Called by `gui_2.py` after instantiation. The controller populates the predefined callbacks and gettable fields that the Hook API can invoke.
|
|
|
|
```python
|
|
def register_hooks(self, app: 'App') -> None:
|
|
"""Register App methods as predefined callbacks for the Hook API."""
|
|
self._predefined_callbacks['_toggle_command_palette'] = app._toggle_command_palette
|
|
self._predefined_callbacks['_open_command_palette'] = app._open_command_palette
|
|
# ... etc, many more ...
|
|
self._gettable_fields['show_command_palette'] = 'show_command_palette'
|
|
self._gettable_fields['current_provider'] = 'current_provider'
|
|
# ... etc ...
|
|
```
|
|
|
|
This is the **only** bridge between the GUI's app methods and the external Hook API. If a method is not in `_predefined_callbacks`, external callers cannot invoke it.
|
|
|
|
### Subsystem Coordination Methods
|
|
|
|
The controller has methods that span multiple subsystems:
|
|
|
|
- `reload_presets()` — re-reads preset TOML files from disk
|
|
- `reload_personas()` — same for personas
|
|
- `reload_context_presets()` — validates files exist
|
|
- `apply_persona(persona_name, target)` — switches model, system prompt, and tool weights for a target
|
|
- `dispatch_mma_track(track_id)` — kicks off a multi-agent track
|
|
- `reset_session()` — clears discussion history, resets UI state, etc.
|
|
- `save_state_to_disk()` / `load_state_from_disk()` — for historical session replay
|
|
- `inject_context_files(paths)` — adds files to the active context composition
|
|
|
|
---
|
|
|
|
## The `AppState` Dataclass
|
|
|
|
`app_state` is a flat dataclass holding all GUI-visible state. Examples:
|
|
|
|
```python
|
|
@dataclass
|
|
class AppState:
|
|
current_provider: str = "gemini"
|
|
current_model: str = "gemini-3-flash-preview"
|
|
temperature: float = 0.7
|
|
top_p: float = 0.95
|
|
max_output_tokens: int = 8192
|
|
system_prompt: str = ""
|
|
discussion_history: list[DiscussionEntry] = field(default_factory=list)
|
|
context_files: list[ContextFileEntry] = field(default_factory=list)
|
|
context_screenshots: list[str] = field(default_factory=list)
|
|
show_command_palette: bool = False
|
|
show_preset_manager: bool = False
|
|
show_persona_editor: bool = False
|
|
show_context_preview: bool = False
|
|
show_diagnostics: bool = False
|
|
show_mma_dashboard: bool = False
|
|
# ... many more
|
|
```
|
|
|
|
The `App` reads from `app_state` for rendering and writes back via setter methods. All setters are exposed to the Hook API via `_gettable_fields` and other "settable" registries.
|
|
|
|
---
|
|
|
|
## Preset & Persona Management
|
|
|
|
### `PresetManager` (in `src/presets.py`)
|
|
|
|
The controller delegates preset CRUD to `PresetManager`. The controller itself only coordinates when presets change (re-apply to active session, update system prompt, etc.).
|
|
|
|
```python
|
|
# In controller
|
|
def on_preset_changed(self, new_preset_name: str) -> None:
|
|
preset = self.presets.get(new_preset_name)
|
|
self.app_state.system_prompt = preset.full_text # Base + persona
|
|
self.app_state.temperature = preset.temperature
|
|
self.app_state.top_p = preset.top_p
|
|
self.app_state.max_output_tokens = preset.max_output_tokens
|
|
```
|
|
|
|
### `PersonaManager` (in `src/personas.py`)
|
|
|
|
Consolidates model settings + system prompt + tool weights into a single named entity.
|
|
|
|
```python
|
|
# In controller
|
|
def apply_persona(self, persona_name: str, target: str = "tier3") -> None:
|
|
persona = self.personas.get(persona_name)
|
|
if persona.model:
|
|
self.app_state.current_model = persona.model
|
|
if persona.system_prompt:
|
|
self.app_state.system_prompt = persona.system_prompt
|
|
if persona.tool_weights:
|
|
self.tool_bias.apply_weights(persona.tool_weights, target=target)
|
|
if persona.bias_profile:
|
|
self.tool_bias.apply_profile(persona.bias_profile)
|
|
```
|
|
|
|
`target` is the MMA tier ("tier1", "tier2", "tier3", "tier4"). This is how MMA agents get isolated cognitive load.
|
|
|
|
### `ContextPresetManager` (in `src/context_presets.py`)
|
|
|
|
Saves/loads complete context compositions (files, screenshots, view modes). Validates that referenced files still exist on load.
|
|
|
|
### `ToolPresetManager` (in `src/tool_presets.py`)
|
|
|
|
Manages tool enable/disable + weights. Persisted to `tool_presets.toml`.
|
|
|
|
### `ToolBiasEngine` (in `src/tool_bias.py`)
|
|
|
|
Applies weights and global bias profiles. Generates the **"Tooling Strategy"** section appended to system prompts.
|
|
|
|
---
|
|
|
|
## History Management
|
|
|
|
`HistoryManager` (in `src/history.py`) implements the **non-provider undo/redo** system.
|
|
|
|
```python
|
|
def on_ui_state_change(self) -> None:
|
|
"""Called when the UI changes (e.g., text input). Pushes a snapshot."""
|
|
snapshot = self.history.capture(self.app_state)
|
|
self.history.push(snapshot)
|
|
self.app_state.can_undo = self.history.can_undo()
|
|
self.app_state.can_redo = self.history.can_redo()
|
|
```
|
|
|
|
Snapshots include:
|
|
- All text inputs (system prompt, AI input, code blocks)
|
|
- Model parameters (Temperature, Top-P, Max Output Tokens)
|
|
- Context (files, screenshots)
|
|
- Discussion history (for discussion mutations)
|
|
|
|
Capacity is fixed (default: 50 snapshots). Older entries are evicted.
|
|
|
|
### Branching History ("Takes")
|
|
|
|
`HistoryManager` also tracks **timeline branching**. When the user reverts and then takes a new action, a new "take" is created. The full history graph is preserved for back-navigation.
|
|
|
|
---
|
|
|
|
## RAG Engine Integration
|
|
|
|
`RAGEngine` (in `src/rag_engine.py`) is owned by the controller but **lazy-loaded** on first use:
|
|
|
|
```python
|
|
def get_rag_engine(self) -> RAGEngine:
|
|
if self._rag_engine is None:
|
|
from src.rag_engine import RAGEngine
|
|
self._rag_engine = RAGEngine(self.paths)
|
|
return self._rag_engine
|
|
```
|
|
|
|
The GUI exposes a RAG settings panel that calls `controller.get_rag_engine().set_provider(...)`, `set_chunk_size(...)`, etc.
|
|
|
|
### RAG Lifecycle
|
|
|
|
1. **Indexing**: `controller.index_project()` walks the project workspace, chunks files, embeds them, writes to ChromaDB (or external MCP).
|
|
2. **Search**: `controller.search_context_files(query)` returns top-k fragments with source paths.
|
|
3. **Injection**: Fragments are prepended to the AI's prompt via `ai_client.send(...)`. The controller orchestrates the flow.
|
|
|
|
---
|
|
|
|
## MMA Conductor Integration
|
|
|
|
`MultiAgentConductor` (in `src/multi_agent_conductor.py`) is also lazy-loaded:
|
|
|
|
```python
|
|
def get_mma_conductor(self) -> 'MultiAgentConductor':
|
|
if self._mma_conductor is None:
|
|
from src.multi_agent_conductor import MultiAgentConductor
|
|
self._mma_conductor = MultiAgentConductor(self)
|
|
return self._mma_conductor
|
|
```
|
|
|
|
The controller passes itself into the conductor so workers can access presets/personas/RAG during execution.
|
|
|
|
### Dispatch Flow
|
|
|
|
```
|
|
controller.dispatch_mma_track(track_id)
|
|
-> conductor.load_track(track_id)
|
|
-> conductor.start_workers(track)
|
|
-> workers run in parallel via WorkerPool
|
|
-> workers call back into controller (presets, personas, etc.)
|
|
-> results pushed to controller.app_state.discussion_history
|
|
-> conductor emits events to controller.event_queue
|
|
```
|
|
|
|
The `event_queue` is consumed by the GUI on the main thread to update display.
|
|
|
|
---
|
|
|
|
## Hot Reload
|
|
|
|
The controller can hot-reload Python modules while preserving state. This is critical for GUI iteration:
|
|
|
|
```python
|
|
def hot_reload(self, module_name: str) -> None:
|
|
"""Reload a module and re-apply its render functions to the app."""
|
|
from src.hot_reload import HotReloader
|
|
reloader = HotReloader(self.app)
|
|
reloader.reload(module_name)
|
|
```
|
|
|
|
`gui_2.py` registers all its render functions with the reloader at startup. On reload, the reloader swaps the function references without losing app state.
|
|
|
|
See **[docs/guide_hot_reload.md](guide_hot_reload.md)** for the full mechanism.
|
|
|
|
---
|
|
|
|
## The `SyncEventQueue`
|
|
|
|
A `queue.Queue`-based bridge between the daemon threads (AI workers, MMA workers) and the GUI main thread.
|
|
|
|
```python
|
|
class SyncEventQueue:
|
|
def put(self, event: Event) -> None: ...
|
|
def get_nowait(self) -> Event | None: ...
|
|
def get_all(self) -> list[Event]: ...
|
|
```
|
|
|
|
The GUI polls `controller.event_queue.get_all()` once per frame and dispatches events to render functions.
|
|
|
|
### Event Types
|
|
|
|
- `MMA_TICKET_COMPLETED`
|
|
- `MMA_LOG_MESSAGE`
|
|
- `AI_RESPONSE_CHUNK`
|
|
- `AI_RESPONSE_COMPLETE`
|
|
- `TOOL_CALL_STARTED`
|
|
- `TOOL_CALL_COMPLETED`
|
|
- `PERSONA_APPLIED`
|
|
- `WORKSPACE_LOADED`
|
|
- `RAG_INDEX_COMPLETE`
|
|
- `HOOK_CALLBACK_RECEIVED`
|
|
|
|
The controller translates subsystem-specific events into these generic types.
|
|
|
|
---
|
|
|
|
## The Headless Mode
|
|
|
|
When `sloppy.py` is launched with `--headless`, the controller is instantiated without an `App`:
|
|
|
|
```python
|
|
# In sloppy.py --headless
|
|
controller = AppController(enable_test_hooks=True)
|
|
# ... run server-only logic ...
|
|
# No GUI, no ImGui context, but full subsystem access
|
|
```
|
|
|
|
This is the **Headless Backend Service** mode. The controller still listens on `:8999` and serves all Hook API endpoints. Tests and external scripts can drive the headless service.
|
|
|
|
---
|
|
|
|
## Hook API Surface (Defined Here)
|
|
|
|
The controller is the **single source of truth** for what the Hook API can do. Three registries:
|
|
|
|
### `_predefined_callbacks: dict[str, Callable]`
|
|
|
|
Maps hook name → App method. Populated by `register_hooks(app)`.
|
|
|
|
```python
|
|
self._predefined_callbacks['_toggle_command_palette'] = app._toggle_command_palette
|
|
self._predefined_callbacks['_open_command_palette'] = app._open_command_palette
|
|
self._predefined_callbacks['save_context_preset'] = app.save_context_preset
|
|
self._predefined_callbacks['load_context_preset'] = app.load_context_preset
|
|
# ... ~30 entries
|
|
```
|
|
|
|
### `_gettable_fields: dict[str, str]`
|
|
|
|
Maps hook name → AppState field name. Used by `get_value` Hook API action.
|
|
|
|
```python
|
|
self._gettable_fields['show_command_palette'] = 'show_command_palette'
|
|
self._gettable_fields['current_provider'] = 'current_provider'
|
|
self._gettable_fields['current_model'] = 'current_model'
|
|
# ... etc
|
|
```
|
|
|
|
### `_action_handlers: dict[str, Callable]`
|
|
|
|
Maps action name (e.g., `"click"`, `"set_value"`, `"custom_callback"`) → handler.
|
|
|
|
```python
|
|
self._action_handlers['click'] = self._handle_click
|
|
self._action_handlers['set_value'] = self._handle_set_value
|
|
self._action_handlers['custom_callback'] = self._handle_custom_callback
|
|
# ... etc
|
|
```
|
|
|
|
The HookServer in `src/api_hooks.py` consumes these registries to route incoming requests.
|
|
|
|
---
|
|
|
|
## Testing
|
|
|
|
The controller is tested via the `live_gui` fixture (full integration) and targeted unit tests.
|
|
|
|
### Unit Tests
|
|
|
|
- `tests/test_app_controller_init.py` — instantiation, subsystem wiring
|
|
- `tests/test_preset_manager.py` — preset CRUD
|
|
- `tests/test_persona_manager.py` — persona CRUD + application
|
|
- `tests/test_context_presets.py` — context preset CRUD + file validation
|
|
- `tests/test_history.py` — undo/redo
|
|
- `tests/test_workspace_manager.py` — workspace profile CRUD
|
|
|
|
### Integration Tests (live_gui)
|
|
|
|
Use the `ApiHookClient` to drive the controller and verify state mutations.
|
|
|
|
```python
|
|
def test_apply_persona(live_gui):
|
|
client = ApiHookClient()
|
|
client.push_event("custom_callback", {"callback": "apply_persona", "args": ["code-reviewer"]})
|
|
time.sleep(0.5)
|
|
model = client.get_value("current_model")
|
|
assert "code" in model.lower() or model == "code-reviewer-model"
|
|
```
|
|
|
|
---
|
|
|
|
## Common Pitfalls
|
|
|
|
1. **Don't instantiate `AppController` twice in the same process**: The singleton holds RAG engine, MMA conductor, hook server. A second instance would conflict.
|
|
2. **Don't read `app_state` from a daemon thread without locking**: Use `event_queue` for cross-thread communication.
|
|
3. **Always go through the controller for subsystem changes**: Don't call `self.presets.save(...)` from the GUI directly; call `controller.save_preset(...)` so the event is broadcast.
|
|
4. **When adding a new Hook API callback, register it in BOTH `_predefined_callbacks` AND `register_hooks`**: The latter is what populates the registry from an App instance.
|
|
|
|
---
|
|
|
|
## See Also
|
|
|
|
- **[guide_architecture.md](guide_architecture.md)** — Threading and event flow
|
|
- **[guide_mma.md](guide_mma.md)** — How MMA workers use the controller
|
|
- **[guide_ai_client.md](guide_ai_client.md)** — How `ai_client` integrates
|
|
- **[guide_api_hooks.md](guide_api_hooks.md)** — The Hook API the controller exposes
|
|
- **[guide_hot_reload.md](guide_hot_reload.md)** — How the controller supports state-preserving reloads
|
|
- **[guide_discussions.md](guide_discussions.md)** — The Discussion system (Takes, branching, `_switch_discussion`, `_branch_discussion`, `_rename_discussion`, `_delete_discussion`, `_flush_disc_entries_to_project`)
|
|
- **[guide_state_lifecycle.md](guide_state_lifecycle.md)** — The `_handle_reset_session` and `_handle_compress_discussion` flows, the `App.__getattr__`/`__setattr__` state delegation pattern, and the `HistoryManager` integration
|
|
- **[guide_context_aggregation.md](guide_context_aggregation.md)** — The `aggregate.py` pipeline that the controller calls per send (per-provider + Tier 3 worker)
|
|
- **`src/presets.py`, `src/personas.py`, `src/context_presets.py`, `src/tool_presets.py`, `src/tool_bias.py`** — Subsystem managers
|
|
- **`src/history.py`** — `HistoryManager`
|
|
- **`src/rag_engine.py`** — `RAGEngine`
|
|
- **`src/multi_agent_conductor.py`** — `MultiAgentConductor`
|
|
- **`src/hot_reload.py`** — `HotReloader`
|
|
- **`src/api_hooks.py`** — `HookServer` (uses the controller's registries)
|
|
- **`src/paths.py`** — `PathManager`
|
|
- **[conductor/tracks/nagent_review_20260608/report.md](../../conductor/tracks/nagent_review_20260608/report.md)** — Deep-dive analysis of the controller's per-provider history globals and other state patterns
|