diff --git a/CHANGELOG.md b/CHANGELOG.md index b708fb8..5548aa4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,54 @@ --- +## [v0.26] Profile System Polish -- 10 Post-Sprint-23 Fixes +*April 3, 2026 | 426 tests* + +### Bug Fixes +- **Profile switch base dir bug.** When `HERMES_HOME` was mutated to a + `profiles/` subdir at startup, `switch_profile()` doubled the path + (e.g. `~/.hermes/profiles/X/profiles/X`). New `_resolve_base_hermes_home()` + detects profile subdirs and walks up to the actual base. +- **Cross-provider model routing.** Picking a model from a different provider + than the config's default now routes through OpenRouter instead of trying + a direct API call to a provider whose key may not exist. +- **Legacy sessions missing profile tag.** `all_sessions()` now backfills + `profile='default'` for pre-Sprint-22 sessions so the profile filter works. +- **Workspace list cleanup.** Stale paths, test artifacts, and cross-profile + entries are now cleaned on load. Legacy global workspace file migrated + once for the default profile. +- **API error messages.** `api()` helper now parses JSON error bodies and + surfaces the human-readable message instead of raw JSON. +- **Workspace dropdown moved to sidebar.** The workspace picker now opens + upward from the sidebar bottom instead of clipping behind the topbar. + +### Features +- **Rate limit error display.** Rate limit errors (429) now show a distinct + card with a rate limit icon and hint, instead of the generic error message. +- **SSE `apperror`/`warning` events.** Server can send typed error events + that the frontend handles with appropriate UX (rate limit card, fallback + notice, etc.). +- **Smart model resolver.** `_findModelInDropdown()` handles name mismatches + between config model IDs and dropdown values (e.g. `claude-sonnet-4-6` vs + `anthropic/claude-sonnet-4.6`). +- **Profile switch starts new session.** When the current session has messages, + switching profiles automatically starts a fresh session to prevent + cross-profile tagging. +- **Per-profile toolsets.** Agent now reads `platform_toolsets.cli` from the + active profile's config at call time, not the boot-time snapshot. +- **Per-profile fallback model.** `fallback_model` config is read from the + active profile and passed to AIAgent. + +### Architecture +- `api/profiles.py`: `_resolve_base_hermes_home()` replaces naive env var read. +- `api/workspace.py`: `_clean_workspace_list()`, `_migrate_global_workspaces()`. +- `api/streaming.py`: Per-profile toolsets and fallback model at call time. +- `api/models.py`: `all_sessions()` backfills `profile='default'`. +- `static/ui.js`: `_findModelInDropdown()`, `_applyModelToDropdown()`. +- `static/messages.js`: `apperror` and `warning` SSE event handlers. + +--- + ## [v0.25] Sprint 23 -- Profile/Workspace/Model Coherence *April 3, 2026 | 423 tests* @@ -829,4 +877,4 @@ Three-panel layout: sessions sidebar, chat area, workspace panel. --- -*Last updated: v0.25, April 3, 2026 | Tests: 423* +*Last updated: v0.26, April 3, 2026 | Tests: 426* diff --git a/SPRINTS.md b/SPRINTS.md index d3894a0..6d104c2 100644 --- a/SPRINTS.md +++ b/SPRINTS.md @@ -1,6 +1,6 @@ # Hermes Web UI -- Forward Sprint Plan -> Current state: v0.25 | 423 tests | Daily driver ready +> Current state: v0.26 | 426 tests | Daily driver ready > This document plans the path from here to two targets: > > Target A: 1:1 feature parity with the Hermes CLI (everything you can do from the @@ -663,5 +663,5 @@ and switchToProfile() didn't refresh workspaces or sessions. --- *Last updated: April 3, 2026* -*Current version: v0.25 | 423 tests* +*Current version: v0.26 | 426 tests* *Next sprint: Sprint 24 (Desktop Application)* diff --git a/api/config.py b/api/config.py index b961754..dc399b1 100644 --- a/api/config.py +++ b/api/config.py @@ -373,15 +373,16 @@ def resolve_model_provider(model_id: str): if '/' in model_id: prefix, bare = model_id.split('/', 1) - # If prefix matches config provider, strip it and use that provider directly + # If prefix matches config provider exactly, strip it and use that provider directly. + # e.g. config=anthropic, model=anthropic/claude-... → bare name to anthropic API if config_provider and prefix == config_provider: return bare, config_provider, config_base_url - # If the config provider is openrouter (or unset/None), pass the full - # provider/model string through -- OpenRouter uses this as its model ID. - # Only strip the prefix and switch to a direct-API provider when the - # config is explicitly set to that direct provider. - if config_provider and config_provider != 'openrouter' and prefix in _PROVIDER_MODELS: - return bare, prefix, None + # If prefix does NOT match config provider, the user picked a cross-provider model + # from the OpenRouter dropdown (e.g. config=anthropic but picked openai/gpt-5.4-mini). + # In this case always route through openrouter with the full provider/model string. + # Never strip the prefix and try a direct-API call to a provider whose key may not exist. + if prefix in _PROVIDER_MODELS and prefix != config_provider: + return model_id, 'openrouter', None return model_id, config_provider, config_base_url diff --git a/api/models.py b/api/models.py index ad9d807..be6b68e 100644 --- a/api/models.py +++ b/api/models.py @@ -90,6 +90,11 @@ def all_sessions(): result = sorted(index_map.values(), key=lambda s: (s.get('pinned', False), s['updated_at']), reverse=True) # Hide empty Untitled sessions from the UI (created by tests, page refreshes, etc.) result = [s for s in result if not (s.get('title','Untitled')=='Untitled' and s.get('message_count',0)==0)] + # Backfill: sessions created before Sprint 22 have no profile tag. + # Attribute them to 'default' so the client profile filter works correctly. + for s in result: + if not s.get('profile'): + s['profile'] = 'default' return result except Exception: pass # fall through to full scan @@ -105,7 +110,11 @@ def all_sessions(): for s in SESSIONS.values(): if all(s.session_id != x.session_id for x in out): out.append(s) out.sort(key=lambda s: (getattr(s, 'pinned', False), s.updated_at), reverse=True) - return [s.compact() for s in out if not (s.title=='Untitled' and len(s.messages)==0)] + result = [s.compact() for s in out if not (s.title=='Untitled' and len(s.messages)==0)] + for s in result: + if not s.get('profile'): + s['profile'] = 'default' + return result def title_from(messages, fallback='Untitled'): diff --git a/api/profiles.py b/api/profiles.py index 943abff..f74cd91 100644 --- a/api/profiles.py +++ b/api/profiles.py @@ -16,9 +16,44 @@ from pathlib import Path # ── Module state ──────────────────────────────────────────────────────────── _active_profile = 'default' _profile_lock = threading.Lock() -# Read from env var so test isolation (HERMES_HOME=TEST_STATE_DIR) is respected. -# Evaluated at import time; server restart picks up any env change. -_DEFAULT_HERMES_HOME = Path(os.getenv('HERMES_HOME', str(Path.home() / '.hermes'))) + +def _resolve_base_hermes_home() -> Path: + """Return the BASE ~/.hermes directory — the root that contains profiles/. + + This is intentionally distinct from HERMES_HOME, which tracks the *active + profile's* home and changes on every profile switch. The base dir must + always point to the top-level .hermes regardless of which profile is active. + + Resolution order: + 1. HERMES_BASE_HOME env var (set explicitly, highest priority) + 2. HERMES_HOME env var — but only if it does NOT look like a profile subdir + (i.e. its parent is not named 'profiles'). This handles test isolation + where HERMES_HOME is set to an isolated test state dir. + 3. ~/.hermes (always-correct default) + + The bug this prevents: if HERMES_HOME has already been mutated to + /home/user/.hermes/profiles/webui (by init_profile_state at startup), + reading it here would make _DEFAULT_HERMES_HOME point to that subdir, + causing switch_profile('webui') to look for + /home/user/.hermes/profiles/webui/profiles/webui — which doesn't exist. + """ + # Explicit override for tests or unusual setups + base_override = os.getenv('HERMES_BASE_HOME', '').strip() + if base_override: + return Path(base_override).expanduser() + + hermes_home = os.getenv('HERMES_HOME', '').strip() + if hermes_home: + p = Path(hermes_home).expanduser() + # If HERMES_HOME points to a profiles/ subdir, walk up two levels to the base + if p.parent.name == 'profiles': + return p.parent.parent + # Otherwise trust it (e.g. test isolation sets HERMES_HOME to TEST_STATE_DIR) + return p + + return Path.home() / '.hermes' + +_DEFAULT_HERMES_HOME = _resolve_base_hermes_home() def _read_active_profile_file() -> str: diff --git a/api/streaming.py b/api/streaming.py index 99bc531..d087fd0 100644 --- a/api/streaming.py +++ b/api/streaming.py @@ -112,13 +112,38 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta if AIAgent is None: raise ImportError("AIAgent not available -- check that hermes-agent is on sys.path") resolved_model, resolved_provider, resolved_base_url = resolve_model_provider(model) + + # Read per-profile config at call time (not module-level snapshot) + from api.config import get_config as _get_config + _cfg = _get_config() + + # Per-profile toolsets (fall back to module-level CLI_TOOLSETS) + _pt = _cfg.get('platform_toolsets', {}) + _toolsets = _pt.get('cli', CLI_TOOLSETS) if isinstance(_pt, dict) else CLI_TOOLSETS + + # Fallback model from profile config (e.g. for rate-limit recovery) + _fallback = _cfg.get('fallback_model') or None + if _fallback: + # Resolve the fallback through our provider logic too + fb_model = _fallback.get('model', '') + fb_provider = _fallback.get('provider', '') + fb_base_url = _fallback.get('base_url') + _fallback_resolved = { + 'model': fb_model, + 'provider': fb_provider, + 'base_url': fb_base_url, + } + else: + _fallback_resolved = None + agent = AIAgent( model=resolved_model, provider=resolved_provider, base_url=resolved_base_url, platform='cli', quiet_mode=True, - enabled_toolsets=CLI_TOOLSETS, + enabled_toolsets=_toolsets, + fallback_model=_fallback_resolved, session_id=session_id, stream_delta_callback=on_token, tool_progress_callback=on_tool, @@ -203,7 +228,18 @@ def _run_agent_streaming(session_id, msg_text, model, workspace, stream_id, atta except Exception as e: print('[webui] stream error:\n' + traceback.format_exc(), flush=True) - put('error', {'message': str(e)}) + err_str = str(e) + # Detect rate limit errors specifically so the client can show a helpful card + # rather than the generic "Connection lost" message + is_rate_limit = 'rate limit' in err_str.lower() or '429' in err_str or 'RateLimitError' in type(e).__name__ + if is_rate_limit: + put('apperror', { + 'message': err_str, + 'type': 'rate_limit', + 'hint': 'Rate limit reached. The fallback model (if configured) was also exhausted. Try again in a moment.', + }) + else: + put('apperror', {'message': err_str, 'type': 'error'}) finally: _clear_thread_env() # TD1: always clear thread-local context with STREAMS_LOCK: diff --git a/api/workspace.py b/api/workspace.py index 0db7b5b..757174b 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -53,17 +53,31 @@ def _last_workspace_file() -> Path: def _profile_default_workspace() -> str: """Read the profile's default workspace from its config.yaml. + Checks keys in priority order: + 1. 'workspace' — explicit webui workspace key + 2. 'default_workspace' — alternate explicit key + 3. 'terminal.cwd' — hermes-agent terminal working dir (most common) + Falls back to the boot-time DEFAULT_WORKSPACE constant. """ try: - from api.profiles import get_active_hermes_home from api.config import get_config cfg = get_config() - ws = cfg.get('default_workspace') - if ws: - p = Path(ws).expanduser().resolve() - if p.is_dir(): - return str(p) + # Explicit webui workspace keys first + for key in ('workspace', 'default_workspace'): + ws = cfg.get(key) + if ws: + p = Path(str(ws)).expanduser().resolve() + if p.is_dir(): + return str(p) + # Fall through to terminal.cwd — the agent's configured working directory + terminal_cfg = cfg.get('terminal', {}) + if isinstance(terminal_cfg, dict): + cwd = terminal_cfg.get('cwd', '') + if cwd and str(cwd) not in ('.', ''): + p = Path(str(cwd)).expanduser().resolve() + if p.is_dir(): + return str(p) except (ImportError, Exception): pass return str(_BOOT_DEFAULT_WORKSPACE) @@ -71,26 +85,94 @@ def _profile_default_workspace() -> str: # ── Public API ────────────────────────────────────────────────────────────── +def _clean_workspace_list(workspaces: list) -> list: + """Sanitize a workspace list: + - Remove entries whose paths no longer exist on disk. + - Remove entries that look like test artifacts (webui-mvp-test, test-workspace). + - Remove entries whose paths live inside another profile's directory + (e.g. ~/.hermes/profiles/X/... should not appear on a different profile). + - Rename any entry whose name is literally 'default' to 'Home' (avoids + confusion with the 'default' profile name). + Returns the cleaned list (may be empty). + """ + hermes_profiles = (Path.home() / '.hermes' / 'profiles').resolve() + result = [] + for w in workspaces: + path = w.get('path', '') + name = w.get('name', '') + p = Path(path).resolve() if path else Path('/') + # Skip test artifacts + if 'test-workspace' in path or 'webui-mvp-test' in path: + continue + # Skip paths that no longer exist + if not p.is_dir(): + continue + # Skip paths inside a named profile's directory (cross-profile leak) + try: + p.relative_to(hermes_profiles) + continue # it IS under profiles/ — remove it + except ValueError: + pass + # Rename confusing 'default' label to 'Home' + if name.lower() == 'default': + name = 'Home' + result.append({'path': str(p), 'name': name}) + return result + + +def _migrate_global_workspaces() -> list: + """Read the legacy global workspaces.json, clean it, and return the result. + + This is the migration path for users upgrading from a pre-profile version: + their global file may contain cross-profile entries, test artifacts, and + stale paths accumulated over time. We clean it in-place and rewrite it. + """ + if not _GLOBAL_WS_FILE.exists(): + return [] + try: + raw = json.loads(_GLOBAL_WS_FILE.read_text(encoding='utf-8')) + cleaned = _clean_workspace_list(raw) + if len(cleaned) != len(raw): + # Rewrite the cleaned version so future reads are already clean + _GLOBAL_WS_FILE.write_text( + json.dumps(cleaned, ensure_ascii=False, indent=2), encoding='utf-8' + ) + return cleaned + except Exception: + return [] + + def load_workspaces() -> list: ws_file = _workspaces_file() if ws_file.exists(): try: - return json.loads(ws_file.read_text(encoding='utf-8')) + raw = json.loads(ws_file.read_text(encoding='utf-8')) + cleaned = _clean_workspace_list(raw) + if len(cleaned) != len(raw): + # Persist the cleaned version so stale entries don't keep reappearing + try: + ws_file.write_text( + json.dumps(cleaned, ensure_ascii=False, indent=2), encoding='utf-8' + ) + except Exception: + pass + return cleaned or [{'path': _profile_default_workspace(), 'name': 'Home'}] except Exception: pass - # Fallback: for the DEFAULT profile only, migrate from the legacy global file. - # Named profiles should start with a clean list, not inherit another profile's workspaces. + # No profile-local file yet. + # For the DEFAULT profile: migrate from the legacy global file (one-time cleanup). + # For NAMED profiles: always start clean with just their own workspace. try: from api.profiles import get_active_profile_name is_default = get_active_profile_name() in ('default', None) except ImportError: is_default = True - if is_default and _GLOBAL_WS_FILE.exists(): - try: - return json.loads(_GLOBAL_WS_FILE.read_text(encoding='utf-8')) - except Exception: - pass - return [{'path': _profile_default_workspace(), 'name': 'default'}] + if is_default: + migrated = _migrate_global_workspaces() + if migrated: + return migrated + # Fresh start: single entry from the profile's configured workspace, labeled "Home" + return [{'path': _profile_default_workspace(), 'name': 'Home'}] def save_workspaces(workspaces: list): diff --git a/static/index.html b/static/index.html index a4f87ee..1bd22f9 100644 --- a/static/index.html +++ b/static/index.html @@ -13,7 +13,7 @@