## Motivation When frequently switching between models, it's tedious to search through the full model list to find ones you've used before. A "Recent" tab provides quick access to previously launched models. ## Changes - **New store** (`dashboard/src/lib/stores/recents.svelte.ts`): `RecentsStore` class persisting recently launched model IDs with timestamps to localStorage (key: `exo-recent-models`). Caps at 20 entries, deduplicates on re-launch (moves to top). - **FamilySidebar**: Added "Recent" tab between Favorites and Hub, conditionally shown when there are recent models. - **FamilyLogos**: Added clock/history icon for the recents tab. - **ModelPickerModal**: Added `recentModelIds`/`hasRecents` props. Derives single-variant `ModelGroup[]` from recent IDs and renders them using the same `ModelPickerGroup` component as all other tabs — consistent styling, memory grey-out, favorites, info button, download indicators. - **+page.svelte**: Calls `recordRecentLaunch(modelId)` after successful instance launch. Passes reactive recent state to the modal. ## Why It Works Follows the exact same pattern as the existing Favorites feature (localStorage persistence, conditional tab display, reactive Svelte 5 `$state`/`$derived`). Recent models are wrapped as single-variant `ModelGroup` objects so they reuse `ModelPickerGroup` for identical row rendering across all tabs. ## Test Plan ### Manual Testing <!-- Hardware: MacBook Pro --> - Launch a model instance → reopen model picker → "Recent" tab appears with the launched model - Launch a second model → it appears at top of the Recent list - Re-launch the first model → it moves back to top - Search within the Recent tab filters the list - Models that don't fit in memory are greyed out (same as All tab) - Close/reopen browser → recents persist from localStorage ### Automated Testing - Dashboard builds successfully (`npm run build`) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: rltakashige <rl.takashige@gmail.com>
6.4 KiB
AGENTS.md
This file provides guidance to AI coding agents when working with code in this repository.
Project Overview
exo is a distributed AI inference system that connects multiple devices into a cluster. It enables running large language models across multiple machines using MLX as the inference backend and libp2p for peer-to-peer networking.
Build & Run Commands
# Build the dashboard (required before running exo)
cd dashboard && npm install && npm run build && cd ..
# Run exo (starts both master and worker with API at http://localhost:52415)
uv run exo
# Run with verbose logging
uv run exo -v # or -vv for more verbose
# Run tests (excludes slow tests by default)
uv run pytest
# Run all tests including slow tests
uv run pytest -m ""
# Run a specific test file
uv run pytest src/exo/shared/tests/test_election.py
# Run a specific test function
uv run pytest src/exo/shared/tests/test_election.py::test_function_name
# Type checking (strict mode)
uv run basedpyright
# Linting
uv run ruff check
# Format code (using nix)
nix fmt
Pre-Commit Checks (REQUIRED)
IMPORTANT: Always run these checks before committing code. CI will fail if these don't pass.
# 1. Type checking - MUST pass with 0 errors
uv run basedpyright
# 2. Linting - MUST pass
uv run ruff check
# 3. Formatting - MUST be applied
nix fmt
# 4. Tests - MUST pass
uv run pytest
Run all checks in sequence:
uv run basedpyright && uv run ruff check && nix fmt && uv run pytest
If nix fmt changes any files, stage them before committing. The CI runs nix flake check which verifies formatting, linting, and runs Rust tests.
Architecture
Node Composition
A single exo Node (src/exo/main.py) runs multiple components:
- Router: libp2p-based pub/sub messaging via Rust bindings (exo_pyo3_bindings)
- Worker: Handles inference tasks, downloads models, manages runner processes
- Master: Coordinates cluster state, places model instances across nodes
- Election: Bully algorithm for master election
- API: FastAPI server for OpenAI-compatible chat completions
Message Flow
Components communicate via typed pub/sub topics (src/exo/routing/topics.py):
GLOBAL_EVENTS: Master broadcasts indexed events to all workersLOCAL_EVENTS: Workers send events to master for indexingCOMMANDS: Workers/API send commands to masterELECTION_MESSAGES: Election protocol messagesCONNECTION_MESSAGES: libp2p connection updates
Event Sourcing
The system uses event sourcing for state management:
State(src/exo/shared/types/state.py): Immutable state objectapply()(src/exo/shared/apply.py): Pure function that applies events to state- Master indexes events and broadcasts; workers apply indexed events
Key Type Hierarchy
src/exo/shared/types/: Pydantic models for all shared typesevents.py: Event types (discriminated union)commands.py: Command typestasks.py: Task types for worker executionstate.py: Cluster state model
Rust Components
Rust code in rust/ provides:
networking: libp2p networking (gossipsub, peer discovery)exo_pyo3_bindings: PyO3 bindings exposing Rust to Pythonsystem_custodian: System-level operations
Dashboard
Svelte 5 + TypeScript frontend in dashboard/. Build output goes to dashboard/build/ and is served by the API.
Code Style Requirements
From .cursorrules:
- Strict, exhaustive typing - never bypass the type-checker
- Use
Literal[...]for enum-like sets,typing.NewTypefor primitives - Pydantic models with
frozen=Trueandstrict=True - Pure functions with injectable effect handlers for side-effects
- Descriptive names - no abbreviations or 3-letter acronyms
- Catch exceptions only where you can handle them meaningfully
- Use
@finaland immutability wherever applicable
Testing
Tests use pytest-asyncio with asyncio_mode = "auto". Tests are in tests/ subdirectories alongside the code they test. The EXO_TESTS=1 env var is set during tests.
Dashboard UI Testing & Screenshots
Building and Running the Dashboard
# Build the dashboard (must be done before running exo)
cd dashboard && npm install && npm run build && cd ..
# Start exo (serves the dashboard at http://localhost:52415)
uv run exo &
sleep 8 # Wait for server to start
Taking Headless Screenshots with Playwright
Use Playwright with headless Chromium for programmatic screenshots — no manual browser interaction needed.
Setup (one-time):
npx --yes playwright install chromium
cd /tmp && npm init -y && npm install playwright
Taking screenshots:
// Run from /tmp where playwright is installed: cd /tmp && node -e "..."
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
await page.goto('http://localhost:52415', { waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
// Inject test data into localStorage if needed (e.g., recent models)
await page.evaluate(() => {
localStorage.setItem('exo-recent-models', JSON.stringify([
{ modelId: 'mlx-community/Qwen3-30B-A3B-4bit', launchedAt: Date.now() },
]));
});
await page.reload({ waitUntil: 'networkidle' });
await page.waitForTimeout(2000);
// Interact with UI elements
await page.locator('text=SELECT MODEL').click();
await page.waitForTimeout(1000);
// Take screenshot
await page.screenshot({ path: '/tmp/screenshot.png', fullPage: false });
await browser.close();
})();
Uploading Images to GitHub PRs
GitHub's API doesn't support direct image upload for PR comments. Workaround:
-
Commit images to the branch (temporarily):
cp /tmp/screenshot.png . git add screenshot.png git commit -m "temp: add screenshots for PR" git push origin <branch> COMMIT_SHA=$(git rev-parse HEAD) -
Post PR comment referencing the raw image URL (uses permanent commit SHA so images survive deletion):
gh pr comment <PR_NUMBER> --body "" -
Remove the images from the branch:
git rm screenshot.png git commit -m "chore: remove temporary screenshot files" git push origin <branch>The images still render in the PR comment because they reference the permanent commit SHA.