Private
Public Access
0
0
Files
manual_slop/docs/guide_app_controller.md
T
conductor-tier2 ba05168493 docs(refresh): 3 new guides + cross-links from nagent_review
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).
2026-06-08 19:26:08 -04:00

18 KiB

src/app_controller.py — Headless Orchestrator & State Hub

Top | Architecture | Discussions | State Lifecycle | Context Aggregation | MMA | Testing


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:

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.

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:

@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.).

# 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.

# 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.

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:

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:

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:

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 for the full mechanism.


The SyncEventQueue

A queue.Queue-based bridge between the daemon threads (AI workers, MMA workers) and the GUI main thread.

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:

# 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).

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.

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.

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.

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 — Threading and event flow
  • guide_mma.md — How MMA workers use the controller
  • guide_ai_client.md — How ai_client integrates
  • guide_api_hooks.md — The Hook API the controller exposes
  • guide_hot_reload.md — How the controller supports state-preserving reloads
  • 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 — The _handle_reset_session and _handle_compress_discussion flows, the App.__getattr__/__setattr__ state delegation pattern, and the HistoryManager integration
  • 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.pyHistoryManager
  • src/rag_engine.pyRAGEngine
  • src/multi_agent_conductor.pyMultiAgentConductor
  • src/hot_reload.pyHotReloader
  • src/api_hooks.pyHookServer (uses the controller's registries)
  • src/paths.pyPathManager
  • conductor/tracks/nagent_review_20260608/report.md — Deep-dive analysis of the controller's per-provider history globals and other state patterns