mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 13:26:40 +00:00
refactor(setup): delete profile role; the setup profile is a marked, plain profile
The setup toolset was reserved for `role: setup` in profile.yaml, resolved by toolsets.profile_role_toolsets, and stripped from every other profile in model_tools._select_tool_names. Onboarding also wrote that role into a user's existing unmarked hermes-setup profile and adopted it, and cloned the literal default profile. Now there is no role anywhere. The setup profile carries a dot-file marker (.setup-profile.json) at its root, and onboarding finds it by that marker. A user's own unmarked hermes-setup is never touched: onboarding makes hermes-setup-<word> instead. The new profile is cloned from the launch profile, and its own config enables the setup toolset in platform_toolsets.cli. The session resolver keeps the toolset only for desktop sessions (toolsets.TOOLSET_SESSION_PLATFORMS). Copies (clone-all, import, distribution install) drop the marker, as they dropped the role. The role field also leaves ProfileInfo, the profiles.list and dashboard rows, the onboarding.ensure_setup_profile result, the gateway contracts, and the desktop ProfileInfo type. The desktop handoff recovery check no longer gates on the roster role; the guide handoff receipt keyed by the stored session already identifies the guide session. Linear NS-1014.
This commit is contained in:
1 parent
bddd22be7c
commit
c3f85bc54f
18 files changed
+68
-140
No files matched your search
@@ -26,7 +26,7 @@ import { dismissNotification, notify } from '@/store/notifications'
|
||||
import { $onboardingAnswers } from '@/store/onboarding-answers'
|
||||
import { beginOnboardingHandoff, completeOnboardingFlow } from '@/store/onboarding-gate'
|
||||
import { watchPluginOutcomes } from '@/store/onboarding-plugin-outcomes'
|
||||
import { $activeGatewayProfile, $newChatProfile, $newChatRoute, $profiles, ensureGatewayAgent } from '@/store/profile'
|
||||
import { $activeGatewayProfile, $newChatProfile, $newChatRoute, ensureGatewayAgent } from '@/store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$selectedStoredSessionId,
|
||||
@@ -65,12 +65,7 @@ export function useOnboardingHandoff({
|
||||
useEffect(() => watchPluginOutcomes(() => $setupSession.get()?.runtimeId), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isOnboardingEnabled() ||
|
||||
$setupHandoff.get() ||
|
||||
!selectedStoredId ||
|
||||
$profiles.get().find(p => p.name === $activeGatewayProfile.get())?.role !== 'setup'
|
||||
) {
|
||||
if (!isOnboardingEnabled() || $setupHandoff.get() || !selectedStoredId) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1101,8 +1101,6 @@ export interface ProfileInfo {
|
||||
name: string
|
||||
path: string
|
||||
provider: null | string
|
||||
/** Backend-assigned role from profile.yaml; `setup` marks the onboarding guide's profile. */
|
||||
role?: 'setup' | null
|
||||
skill_count: number
|
||||
}
|
||||
|
||||
|
||||
@@ -1907,7 +1907,6 @@ export interface ProfileRow {
|
||||
display_name?: string
|
||||
skill_count?: number
|
||||
previous_names?: string[]
|
||||
role?: 'setup' | null
|
||||
last_session?: ProfileSessionPreview | null
|
||||
worker_session?: ProfileWorkerSession | null
|
||||
canonical_session?: ProfileCanonicalSession | null
|
||||
@@ -2100,7 +2099,6 @@ export interface OnboardingEnsureSetupProfileResult {
|
||||
name: string
|
||||
path: string
|
||||
created: boolean
|
||||
role?: 'setup'
|
||||
}
|
||||
export interface OnboardingResetSetupProfileResult {
|
||||
name: string
|
||||
@@ -5083,7 +5081,7 @@ export interface RpcMethods {
|
||||
'model.options': { params: ModelOptionsParams; result: ModelOptionsResult }
|
||||
/** Save an API key for a provider and return its refreshed inventory row. */
|
||||
'model.save_key': { params: ModelSaveKeyParams; result: ModelSaveKeyResult }
|
||||
/** Create-or-read the backend-owned setup profile; the backend picks the name and finds it by role. */
|
||||
/** Create-or-read the backend-owned setup profile; the backend picks the name. */
|
||||
'onboarding.ensure_setup_profile': { params: Params; result: OnboardingEnsureSetupProfileResult }
|
||||
/** Restore the setup profile to its created state in place (soul, memories, skills, sessions). */
|
||||
'onboarding.reset_setup_profile': { params: Params; result: OnboardingResetSetupProfileResult }
|
||||
|
||||
@@ -1952,7 +1952,7 @@
|
||||
},
|
||||
{
|
||||
"name": "onboarding.ensure_setup_profile",
|
||||
"summary": "Create-or-read the backend-owned setup profile; the backend picks the name and finds it by role.",
|
||||
"summary": "Create-or-read the backend-owned setup profile; the backend picks the name.",
|
||||
"params": [
|
||||
{
|
||||
"name": "params",
|
||||
@@ -19638,12 +19638,6 @@
|
||||
"created": {
|
||||
"title": "Created",
|
||||
"type": "boolean"
|
||||
},
|
||||
"role": {
|
||||
"const": "setup",
|
||||
"default": "setup",
|
||||
"title": "Role",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -23467,19 +23461,6 @@
|
||||
"title": "Previous Names",
|
||||
"type": "array"
|
||||
},
|
||||
"role": {
|
||||
"anyOf": [
|
||||
{
|
||||
"const": "setup",
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Role"
|
||||
},
|
||||
"last_session": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
@@ -22,7 +22,7 @@ import hermes_yaml as yaml
|
||||
|
||||
from hermes_cli._subprocess_compat import noninteractive_git_env
|
||||
from hermes_cli.archive_safe import normalize_archive_parts
|
||||
from hermes_cli.profiles import DEFAULT_EXPORT_EXCLUDE_ROOT
|
||||
from hermes_cli.profiles import DEFAULT_EXPORT_EXCLUDE_ROOT, SETUP_PROFILE_MARKER
|
||||
from utils import rmtree_readonly
|
||||
|
||||
|
||||
@@ -547,10 +547,8 @@ def _copy_dist_payload(staged: Path, target: Path, manifest: DistributionManifes
|
||||
|
||||
# Make sure the manifest on disk reflects resolved name + source
|
||||
write_manifest(target, manifest)
|
||||
# A shipped profile.yaml must not carry a backend-assigned role.
|
||||
if any(rel_parts == ("profile.yaml",) for _, rel_parts in entries):
|
||||
from hermes_cli.profiles import drop_profile_role
|
||||
drop_profile_role(target)
|
||||
if any(rel_parts == (SETUP_PROFILE_MARKER,) for _, rel_parts in entries):
|
||||
(target / SETUP_PROFILE_MARKER).unlink()
|
||||
|
||||
|
||||
def _bootstrap_user_dirs(target: Path) -> None:
|
||||
|
||||
+7
-31
@@ -90,11 +90,7 @@ _CLONE_ALL_HISTORY_EXCLUDE_ROOT: frozenset[str] = frozenset({
|
||||
# seed_profile_skills() callers (fresh-create, `hermes update` all-profile sync, the
|
||||
# dashboard) skip bundled-skill seeding. Delete the file to opt back in.
|
||||
NO_BUNDLED_SKILLS_MARKER = ".no-bundled-skills"
|
||||
|
||||
# ``profile.yaml`` ``role`` values. A role grants backend capabilities (the setup toolset), so
|
||||
# only the backend writes one, and a copy of a profile (clone-all, import) never inherits it.
|
||||
SETUP_ROLE = "setup"
|
||||
PROFILE_ROLES = frozenset({SETUP_ROLE})
|
||||
SETUP_PROFILE_MARKER = ".setup-profile.json"
|
||||
|
||||
# Header seeded into a profile's empty .env so it owns a credentials file from day one.
|
||||
_PLACEHOLDER_ENV = (
|
||||
@@ -614,8 +610,6 @@ class ProfileInfo:
|
||||
# appends here). Lets Bot Mode group chats re-link persisted member
|
||||
# descriptors to the renamed live profile (#110200).
|
||||
previous_names: List[str] = field(default_factory=list)
|
||||
# Backend-assigned role (``SETUP_ROLE`` or None). Only ``hermes_cli.setup_profile`` writes it.
|
||||
role: Optional[str] = None
|
||||
|
||||
|
||||
def _load_yaml_dict(path: Path) -> Optional[dict]:
|
||||
@@ -848,7 +842,7 @@ def _cached_skill_count(profile_dir: Path) -> int:
|
||||
return cached[2] if cached is not None else 0
|
||||
|
||||
|
||||
# profile.yaml — per-profile metadata (description, role, etc.)
|
||||
# profile.yaml — per-profile metadata (description, etc.)
|
||||
# Deliberately tiny and separate from ``config.yaml`` (user-facing Hermes config, ~5000
|
||||
# lines of defaults): this is metadata ABOUT the profile. Missing file -> empty defaults,
|
||||
# never an error; the kanban decomposer falls back to the profile name.
|
||||
@@ -872,7 +866,6 @@ def read_profile_meta(profile_dir: Path) -> dict:
|
||||
"display_name": str(data.get("display_name") or "").strip(),
|
||||
"bot_title": bot_title,
|
||||
"previous_names": _clean_previous_names(data.get("previous_names")),
|
||||
"role": data.get("role") if data.get("role") in PROFILE_ROLES else None,
|
||||
}
|
||||
|
||||
# A copy per caller (list included): the cached value is shared, and a caller that mutates
|
||||
@@ -900,19 +893,13 @@ def _clean_previous_names(raw) -> List[str]:
|
||||
def write_profile_meta(
|
||||
profile_dir: Path, *, description: Optional[str] = None, description_auto: Optional[bool] = None,
|
||||
display_name: Optional[str] = None, previous_names: Optional[List[str]] = None,
|
||||
role: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Update ``profile.yaml`` in place: only passed fields are overwritten; the file is
|
||||
created if missing. The profile directory itself must exist. ``role`` grants backend
|
||||
capabilities, so no client-facing writer passes it through."""
|
||||
created if missing. The profile directory itself must exist."""
|
||||
if not profile_dir.is_dir():
|
||||
raise FileNotFoundError(f"profile directory does not exist: {profile_dir}")
|
||||
if role is not None and role not in PROFILE_ROLES:
|
||||
raise ValueError(f"unknown profile role: {role!r}")
|
||||
path = profile_dir / "profile.yaml"
|
||||
existing: dict = _load_yaml_dict(path) or {}
|
||||
if role is not None:
|
||||
existing["role"] = role
|
||||
if description is not None:
|
||||
existing["description"] = description.strip()
|
||||
if description_auto is not None:
|
||||
@@ -938,17 +925,6 @@ def write_profile_meta(
|
||||
atomic_yaml_write(path, existing, sort_keys=False)
|
||||
|
||||
|
||||
def drop_profile_role(profile_dir: Path) -> None:
|
||||
"""Remove ``role`` from a copied ``profile.yaml``: a copy is an ordinary profile."""
|
||||
path = profile_dir / "profile.yaml"
|
||||
existing = _load_yaml_dict(path)
|
||||
if not existing or "role" not in existing:
|
||||
return
|
||||
existing.pop("role")
|
||||
from utils import atomic_yaml_write
|
||||
atomic_yaml_write(path, existing, sort_keys=False)
|
||||
|
||||
|
||||
def format_profile_label(name: str, display_name: Optional[str]) -> str:
|
||||
"""``display_name (canonical_id)``, or the bare id when no display name is set (or it
|
||||
equals the id) — byte-for-byte the pre-feature rendering."""
|
||||
@@ -1224,10 +1200,10 @@ def _copytree_keep_junctions(src: Path, dst: Path, ignore, dirs_exist_ok: bool =
|
||||
|
||||
|
||||
def _clone_all_into(source_dir: Path, profile_dir: Path, canon: str) -> None:
|
||||
"""--clone-all: full copytree minus infrastructure/history, then strip runtime files,
|
||||
the backend-assigned role, and cloned single-use OAuth grants."""
|
||||
"""--clone-all: full copytree minus infrastructure/history, then strip runtime files
|
||||
and cloned single-use OAuth grants."""
|
||||
_copytree_keep_junctions(source_dir, profile_dir, _clone_all_copytree_ignore(source_dir))
|
||||
drop_profile_role(profile_dir)
|
||||
(profile_dir / SETUP_PROFILE_MARKER).unlink(missing_ok=True)
|
||||
materialized = _materialize_symlinked_files(profile_dir)
|
||||
if materialized:
|
||||
logger.info("profile %s: materialized symlinked %s so the clone never writes through to %s",
|
||||
@@ -2249,7 +2225,7 @@ def import_profile(archive_path: str, name: Optional[str] = None) -> Path:
|
||||
shutil.rmtree(child)
|
||||
else:
|
||||
child.unlink()
|
||||
drop_profile_role(final_source)
|
||||
(final_source / SETUP_PROFILE_MARKER).unlink(missing_ok=True)
|
||||
shutil.move(str(final_source), str(profile_dir))
|
||||
return profile_dir
|
||||
|
||||
|
||||
+31
-11
@@ -1,11 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple, Optional
|
||||
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -44,9 +46,9 @@ class SetupProfile(NamedTuple):
|
||||
|
||||
def find_setup_profile() -> Optional[tuple[str, Path]]:
|
||||
found = [(p.name, Path(p.path)) for p in profiles_mod.list_profiles(lazy_skill_count=True)
|
||||
if p.role == profiles_mod.SETUP_ROLE]
|
||||
if (Path(p.path) / profiles_mod.SETUP_PROFILE_MARKER).is_file()]
|
||||
if len(found) > 1:
|
||||
logger.warning("several profiles carry role: setup (%s); using %s",
|
||||
logger.warning("several profiles carry the setup marker (%s); using %s",
|
||||
", ".join(name for name, _ in found), found[0][0])
|
||||
return found[0] if found else None
|
||||
|
||||
@@ -55,15 +57,12 @@ def ensure_setup_profile() -> SetupProfile:
|
||||
found = find_setup_profile()
|
||||
if found is not None:
|
||||
return SetupProfile(found[0], found[1], created=False)
|
||||
if profiles_mod.profile_exists(SETUP_PROFILE_NAME):
|
||||
path = profiles_mod.get_profile_dir(SETUP_PROFILE_NAME)
|
||||
profiles_mod.write_profile_meta(path, role=profiles_mod.SETUP_ROLE)
|
||||
return SetupProfile(SETUP_PROFILE_NAME, path, created=False)
|
||||
path = profiles_mod.create_profile(SETUP_PROFILE_NAME, clone_from="default", clone_config=True, no_alias=True,
|
||||
description=SETUP_PROFILE_DESCRIPTION)
|
||||
name = _free_setup_profile_name()
|
||||
path = profiles_mod.create_profile(name, clone_config=True, no_alias=True, description=SETUP_PROFILE_DESCRIPTION)
|
||||
_write_soul(path)
|
||||
profiles_mod.write_profile_meta(path, role=profiles_mod.SETUP_ROLE)
|
||||
return SetupProfile(SETUP_PROFILE_NAME, path, created=True)
|
||||
_enable_setup_toolset(path)
|
||||
(path / profiles_mod.SETUP_PROFILE_MARKER).write_text("{}\n", encoding="utf-8")
|
||||
return SetupProfile(name, path, created=True)
|
||||
|
||||
|
||||
def reset_setup_profile() -> SetupProfile:
|
||||
@@ -71,7 +70,7 @@ def reset_setup_profile() -> SetupProfile:
|
||||
if found is None:
|
||||
raise LookupError("no setup profile to reset")
|
||||
name, path = found
|
||||
source = profiles_mod.get_profile_dir("default")
|
||||
source = get_hermes_home()
|
||||
_write_soul(path)
|
||||
_replace_dir(path / "memories")
|
||||
for relpath in profiles_mod._CLONE_SUBDIR_FILES:
|
||||
@@ -83,6 +82,27 @@ def reset_setup_profile() -> SetupProfile:
|
||||
return SetupProfile(name, path, created=False)
|
||||
|
||||
|
||||
def _free_setup_profile_name() -> str:
|
||||
from hermes_cli.dashboard_register import _NAME_NOUNS
|
||||
name = SETUP_PROFILE_NAME
|
||||
while profiles_mod.get_profile_dir(name).exists():
|
||||
name = f"{SETUP_PROFILE_NAME}-{random.choice(_NAME_NOUNS)}"
|
||||
return name
|
||||
|
||||
|
||||
def _enable_setup_toolset(path: Path) -> None:
|
||||
from hermes_cli.config import atomic_config_write, read_user_config_raw
|
||||
from hermes_cli.tools_config import _coerce_platform_toolsets_value, _platform_default_toolset
|
||||
config_path = path / "config.yaml"
|
||||
config = read_user_config_raw(config_path)
|
||||
platform_toolsets = config.get("platform_toolsets") or {}
|
||||
cli = _coerce_platform_toolsets_value(platform_toolsets.get("cli"), "cli")
|
||||
if not isinstance(cli, list):
|
||||
cli = [_platform_default_toolset("cli")]
|
||||
config["platform_toolsets"] = {**platform_toolsets, "cli": list(dict.fromkeys([*cli, "setup"]))}
|
||||
atomic_config_write(config_path, config)
|
||||
|
||||
|
||||
def _write_soul(path: Path) -> None:
|
||||
from utils import atomic_write_bytes
|
||||
atomic_write_bytes(path / "SOUL.md", SETUP_SOUL.encode("utf-8"))
|
||||
|
||||
@@ -96,7 +96,7 @@ def _profile_to_dict(info) -> Dict[str, Any]:
|
||||
"distribution_name": attr("distribution_name", None),
|
||||
"distribution_version": attr("distribution_version", None),
|
||||
"distribution_source": attr("distribution_source", None),
|
||||
"has_alias": attr("alias_path", None) is not None, "role": attr("role", None)}
|
||||
"has_alias": attr("alias_path", None) is not None}
|
||||
|
||||
|
||||
def _profile_setup_command(name: str) -> str:
|
||||
|
||||
@@ -327,11 +327,6 @@ def _select_tool_names(enabled_toolsets: Optional[List[str]], disabled_toolsets:
|
||||
from toolsets import get_all_toolsets
|
||||
for ts_name in get_all_toolsets():
|
||||
tools.update(resolve_toolset(ts_name))
|
||||
# A role-reserved toolset (``setup``) reaches only a profile carrying that role, whatever the config,
|
||||
# CLI flag, env pin or "all" asked for; this is the one point every surface's selection passes.
|
||||
from toolsets import profile_role_toolsets
|
||||
for ts_name in profile_role_toolsets()[1]:
|
||||
tools.difference_update(resolve_toolset(ts_name))
|
||||
# Disabled toolsets are always subtracted LAST, so a tool in a disabled
|
||||
# toolset is stripped even when a composite (hermes-cli) re-enables it.
|
||||
# This ensures that even if a composite toolset (like hermes-cli) is enabled, any tools belonging to a
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""``manage_catalog`` (the setup profile's catalog install through the connection card).
|
||||
|
||||
Contracts:
|
||||
- the tool reaches only a session of the ``role: setup`` profile, and only a desktop chat draws it
|
||||
- only a desktop chat draws it
|
||||
- the model sends catalog ids and an action; every other key is refused before anything runs
|
||||
- an id the catalog does not know, or a plugin this OS cannot run, is drawn failed and never installed
|
||||
- an approved row installs into ``default`` (or the profile the Advanced modal named) and reports
|
||||
@@ -101,25 +101,14 @@ def _approve(env=None):
|
||||
for t in payload["targets"] if t["state"] == "pending"]}
|
||||
|
||||
|
||||
def test_only_a_setup_profile_session_selects_the_tool(tmp_path, monkeypatch):
|
||||
def test_only_a_desktop_session_keeps_the_setup_toolset():
|
||||
import model_tools
|
||||
from hermes_cli.profiles import write_profile_meta
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
from tui_gateway import server
|
||||
|
||||
homes = {}
|
||||
for name, role in (("default", None), ("hermes-setup", "setup")):
|
||||
homes[name] = tmp_path / name
|
||||
homes[name].mkdir()
|
||||
if role:
|
||||
write_profile_meta(homes[name], role=role)
|
||||
for name, expected in (("default", False), ("hermes-setup", True)):
|
||||
token = set_hermes_home_override(str(homes[name]))
|
||||
try:
|
||||
# Even an explicit request for the toolset does not reach a profile without the role.
|
||||
names = model_tools._select_tool_names(["setup", "web"], None, quiet_mode=True)
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
assert ("manage_catalog" in names) is expected, name
|
||||
for platform, expected in (("desktop", True), ("tui", False)):
|
||||
selection = server._with_session_toolsets(["setup", "web"], platform)
|
||||
names = model_tools._select_tool_names(selection, None, quiet_mode=True)
|
||||
assert ("manage_catalog" in names) is expected, platform
|
||||
# The setup guide's one tool is sent directly, never hidden behind tool_search.
|
||||
from tools.tool_search import is_deferrable_tool_name
|
||||
assert not is_deferrable_tool_name("manage_catalog")
|
||||
@@ -163,12 +152,11 @@ def test_unknown_and_unsupported_ids_are_drawn_failed_and_never_installed():
|
||||
|
||||
|
||||
def test_an_approved_row_installs_into_default_and_lists_the_live_tools(tmp_path):
|
||||
from hermes_cli.profiles import get_profile_dir, write_profile_meta
|
||||
from hermes_cli.profiles import get_profile_dir
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
|
||||
setup_home = tmp_path / "hermes-setup"
|
||||
setup_home.mkdir()
|
||||
write_profile_meta(setup_home, role="setup")
|
||||
installer = FakeInstaller([_entry("blender")])
|
||||
card = _card(_approve(), profile_home=str(setup_home))
|
||||
token = set_hermes_home_override(str(setup_home)) # the call comes from the setup chat
|
||||
|
||||
@@ -145,8 +145,8 @@ registry.register(
|
||||
emoji="🔗",
|
||||
)
|
||||
|
||||
# The setup profile's catalog install. Reachable only through the ``setup`` toolset, which the
|
||||
# profile's role grants; registry dispatch has no card callback, so it answers with the CLI pointer.
|
||||
# The setup profile's catalog install. Reachable only through the ``setup`` toolset; registry dispatch
|
||||
# has no card callback, so it answers with the CLI pointer.
|
||||
registry.register(
|
||||
name="manage_catalog",
|
||||
toolset="setup",
|
||||
|
||||
+2
-18
@@ -1,6 +1,5 @@
|
||||
"""Toolset helpers: get/resolve/validate named tool groups (static TOOLSETS + registry-registered)."""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Any, Set, Optional, Tuple
|
||||
|
||||
|
||||
@@ -73,6 +72,8 @@ _CODING_TOOLS = _core_without("image_generate", "text_to_speech", "cronjob_manag
|
||||
# config: another surface lacking them made no configuration choice.
|
||||
CLIENT_SURFACE_TOOLSETS = frozenset({"project", "desktop_ui"})
|
||||
|
||||
TOOLSET_SESSION_PLATFORMS = {"setup": frozenset({"desktop"})}
|
||||
|
||||
# Core toolset definitions: individual tools or references to other toolsets.
|
||||
TOOLSETS = {
|
||||
# Basic toolsets - individual tool categories
|
||||
@@ -146,15 +147,10 @@ TOOLSETS = {
|
||||
"annotate_preview", "read_window_below", "focus_pane", "react_to_message",
|
||||
"gui_tour", "show_tip"],
|
||||
),
|
||||
# Enabled per SESSION whose PROFILE carries ``role: setup`` in its backend-written
|
||||
# profile.yaml (tui_gateway/server.py::_load_enabled_toolsets); stripped from every
|
||||
# other profile's selection whatever the config, env pin or client asked for
|
||||
# (model_tools._select_tool_names). Never configurable, never in `hermes tools`.
|
||||
"setup": _ts(
|
||||
"Onboarding-only surface for the setup profile: catalog plugin/skill install "
|
||||
"requests through the approval card",
|
||||
["manage_catalog"],
|
||||
role="setup",
|
||||
),
|
||||
"clarify": _ts("Ask the user clarifying questions (multiple-choice or open-ended)", ["clarify"]),
|
||||
"code_execution": _ts("Run Python scripts that call tools programmatically (reduces LLM round trips)", ["execute_code"]),
|
||||
@@ -456,18 +452,6 @@ def get_toolset_names() -> List[str]:
|
||||
return sorted(set(TOOLSETS.keys()) | set(_plugin_display_names()))
|
||||
|
||||
|
||||
def profile_role_toolsets(profile_home: Optional[Path] = None) -> Tuple[Set[str], Set[str]]:
|
||||
"""``(granted, denied)`` for the profile at *profile_home* (default: the in-scope home; a session's
|
||||
home override, when bound, IS its profile dir): toolsets reserved for the role in its backend-written
|
||||
``profile.yaml``, and toolsets reserved for any other role. An ordinary profile is granted none."""
|
||||
from hermes_cli.profiles import read_profile_meta
|
||||
from hermes_constants import get_hermes_home
|
||||
role = read_profile_meta(Path(profile_home or get_hermes_home())).get("role")
|
||||
granted = {name for name, spec in TOOLSETS.items() if role is not None and spec.get("role") == role}
|
||||
denied = {name for name, spec in TOOLSETS.items() if spec.get("role") not in (None, role)}
|
||||
return granted, denied
|
||||
|
||||
|
||||
def validate_toolset(name: str) -> bool:
|
||||
return (name in {"all", "*"} or name in TOOLSETS
|
||||
or name in _get_plugin_toolset_names() or name in _get_registry_toolset_aliases())
|
||||
|
||||
@@ -158,7 +158,6 @@ class ProfileRow(Result):
|
||||
display_name: str = ""
|
||||
skill_count: int = 0
|
||||
previous_names: list[str] = Field(default_factory=list)
|
||||
role: Literal["setup"] | None = None
|
||||
last_session: ProfileSessionPreview | None = None
|
||||
worker_session: ProfileWorkerSession | None = None
|
||||
canonical_session: ProfileCanonicalSession | None = None
|
||||
@@ -388,11 +387,10 @@ class OnboardingEnsureSetupProfileResult(Result):
|
||||
name: str
|
||||
path: str
|
||||
created: bool
|
||||
role: Literal["setup"] = "setup"
|
||||
|
||||
|
||||
method("onboarding.ensure_setup_profile", params=Params, result=OnboardingEnsureSetupProfileResult,
|
||||
doc="Create-or-read the backend-owned setup profile; the backend picks the name and finds it by role.")
|
||||
doc="Create-or-read the backend-owned setup profile; the backend picks the name.")
|
||||
|
||||
|
||||
class OnboardingResetSetupProfileResult(Result):
|
||||
|
||||
@@ -13,7 +13,7 @@ def _(rid, params: dict) -> dict:
|
||||
_mirror_launch_credentials(setup.path, {"share_auth": True})
|
||||
except Exception as e:
|
||||
return _err(rid, 5073, str(e))
|
||||
return _ok(rid, {"name": setup.name, "path": str(setup.path), "created": setup.created, "role": "setup"})
|
||||
return _ok(rid, {"name": setup.name, "path": str(setup.path), "created": setup.created})
|
||||
|
||||
|
||||
@method("onboarding.reset_setup_profile")
|
||||
|
||||
@@ -276,7 +276,7 @@ def _(rid, params: dict) -> dict:
|
||||
row = {"name": p.name, "path": str(p.path), "is_default": bool(p.is_default), "model": p.model,
|
||||
"provider": p.provider, "description": p.description or "",
|
||||
"display_name": p.display_name or "", "skill_count": p.skill_count or 0,
|
||||
"previous_names": list(p.previous_names or []), "role": p.role}
|
||||
"previous_names": list(p.previous_names or [])}
|
||||
if include_sessions:
|
||||
_profile_session_fields(row, p.path)
|
||||
_profile_ui_meta_fields(row, Path(str(p.path)))
|
||||
|
||||
@@ -1940,19 +1940,17 @@ def _gui_surface_toolsets(platform: str) -> set[str]:
|
||||
|
||||
def _with_session_toolsets(selection, platform: str | None) -> list[str]:
|
||||
"""*selection* plus what the session carries whatever its config says (the client surface's
|
||||
toolsets when *platform* is given; the ones its PROFILE's role reserves, from the backend-written
|
||||
profile.yaml under the session's home override), minus toolsets reserved for another role.
|
||||
toolsets when *platform* is given).
|
||||
|
||||
The fold-in happens after ``_get_platform_tools`` already subtracted ``agent.disabled_toolsets``,
|
||||
so the same subtraction is applied to the fold-in itself — otherwise ``disabled_toolsets:
|
||||
[project]`` is a no-op on desktop/TUI, the only surfaces where the client toolsets exist
|
||||
(#54433). ``desktop_ui`` is kept regardless: it is the client's own control surface, not a
|
||||
model toolset."""
|
||||
from toolsets import profile_role_toolsets
|
||||
granted, denied = profile_role_toolsets()
|
||||
from toolsets import TOOLSET_SESSION_PLATFORMS
|
||||
surface = _gui_surface_toolsets(platform) if platform is not None else set()
|
||||
kept = [name for name in selection if name not in denied]
|
||||
fold_in = (surface | granted) - set(kept)
|
||||
kept = [name for name in selection if platform in TOOLSET_SESSION_PLATFORMS.get(name, {platform})]
|
||||
fold_in = surface - set(kept)
|
||||
disabled = set(_load_disabled_toolsets() or [])
|
||||
if disabled:
|
||||
fold_in -= disabled - {"desktop_ui"}
|
||||
@@ -2025,8 +2023,7 @@ def _load_enabled_toolsets(platform: str | None = None) -> list[str] | None:
|
||||
if explicit and validate_toolset is not None:
|
||||
resolved = _resolve_explicit_toolsets(explicit, validate_toolset)
|
||||
if resolved is not False:
|
||||
# An operator pin replaces the surface fold-in but never strips the profile's own role toolsets.
|
||||
return resolved if resolved is None else _with_session_toolsets(resolved, None)
|
||||
return resolved
|
||||
fallback_notice = "[tui] no valid HERMES_TUI_TOOLSETS entries; using configured CLI toolsets"
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
@@ -182,7 +182,7 @@ Tools for driving desktop [Projects](../user-guide/cli.md) — named, multi-fold
|
||||
|
||||
## `setup` toolset
|
||||
|
||||
Granted only to sessions of the desktop setup profile (`role: setup` in its `profile.yaml`); never configurable.
|
||||
Enabled by the desktop setup profile's own config (`platform_toolsets.cli`); only desktop sessions keep it. Not listed by `hermes tools`.
|
||||
|
||||
| Tool | Description | Requires environment |
|
||||
|------|-------------|----------------------|
|
||||
|
||||
@@ -77,7 +77,7 @@ Or in-session:
|
||||
| `safe` | `image_generate`, `vision_analyze`, `web_extract`, `web_search` (via `includes`) | Read-only research + media generation. No file writes, no terminal, no code execution. |
|
||||
| `search` | `web_search` | Web search only (without extract). |
|
||||
| `session_search` | `session_search` | Search past conversation sessions. |
|
||||
| `setup` | `manage_catalog` | Onboarding-only surface of the desktop setup profile: search the plugin catalog and skills hub, and install items through the approval card. Granted by the backend to sessions whose profile carries `role: setup` in its `profile.yaml`; stripped from every other profile even when a config list, `HERMES_TUI_TOOLSETS`, or `all` names it. Not configurable and not listed by `hermes tools`. |
|
||||
| `setup` | `manage_catalog` | Onboarding-only surface of the desktop setup profile: search the plugin catalog and skills hub, and install items through the approval card. The setup profile's own config enables it (`platform_toolsets.cli`), and only desktop sessions keep it. Not listed by `hermes tools`. |
|
||||
| `skills` | `skill_manage`, `skill_view`, `skills_list` | Skill CRUD and browsing. |
|
||||
| `spotify` | `spotify_albums`, `spotify_devices`, `spotify_library`, `spotify_playback`, `spotify_playlists`, `spotify_queue`, `spotify_search` | Native Spotify control (playback, queue, search, playlists, albums, library). Registered by the bundled `spotify` plugin. |
|
||||
| `terminal` | `process_manage`, `terminal` | Shell command execution and background process management. |
|
||||
|
||||
Reference in new issue
Block a user