diff --git a/CONTEXT.md b/CONTEXT.md index 2fd0a8db6f..e4b26bc08b 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1662,7 +1662,13 @@ conflating `find_skill` (search, returns ids + descriptions) with `read_skill` **Skill Hub** (`skill_hub/`): A remote OpenAPI skill marketplace, configured via `skillForge.router.hub` (`endpoint` / -`api_key` / `timeout_s` / `min_safety`; `endpoint=None` disables it). `SkillHubClient` offers +`enabled` / `api_key` / `timeout_s` / `min_safety`; `endpoint=None` disables it, and +`enabled=false` switches it off without clearing the URL -- off is read live). Both +onboarding wizards seed the shared catalog URL and say what that sends; the settings page's +Skill Hub switch is `enabled`, drawn from `settings.get`'s answer rather than the raw file. +While on, text from the conversation (a turn's message, or a `find_skill` query) is sent as +the catalog query. +`SkillHubClient` offers progressive disclosure — `search()` (metadata-only discovery), `get()` (skill body), `install()` (download + safe extract); during routing `HubSkillSource` feeds metadata-only candidates into the weighted RRF (weight 0.85, below Local 0.96 and Everos 0.9), and the diff --git a/docs-site/docs/skills-and-extensions.md b/docs-site/docs/skills-and-extensions.md index a0057cf8e3..059d040a3a 100644 --- a/docs-site/docs/skills-and-extensions.md +++ b/docs-site/docs/skills-and-extensions.md @@ -38,6 +38,17 @@ configured memory backend, and remote Skill Hub candidates. Availability depends on configuration and service connectivity; a catalog result is not proof that a bundle is installed or its required tools exist. +Searching Skill Hub sends text off the machine. Both onboarding wizards write +the shared catalog URL (`https://skillhub.evermind.ai`) to +`skillForge.router.hub.endpoint` unless the config already names one, and +while the source is on, Raven sends text from the conversation (the message, +or a query the model writes from it) to that address whenever it looks for a +skill. Turn it off with the Skill Hub switch on the settings dialog's Skills +page, or set `skillForge.router.hub.enabled` to `false`; the URL is kept, so +turning it back on returns to the same catalog. A config with no endpoint never +searches the Hub. Switching it off applies to the next search; switching it on +in a gateway that started without an endpoint waits for a reload. + `skillForge.discovery` defaults to `pull`: the model receives a small name/description menu on eligible turns and uses `find_skill` to search and `read_skill` to fetch a body. `push` instead runs the selection/injection @@ -64,7 +75,8 @@ safety score is not a security guarantee, and missing scores are not a hard rejection in the current policy. Successful installs are audited under `/skills/hub/installs.jsonl`. -In the web UI, the settings dialog's Skills page switches skills on and off; +In the web UI, the settings dialog's Skills page switches skills, and Skill +Hub search, on and off; its **Add** button leads into the chat with the request half written, because a skill is installed by asking Raven, under the consent setting above. diff --git a/docs-site/docs/skills-and-extensions.zh.md b/docs-site/docs/skills-and-extensions.zh.md index 26038b6e2f..54dd3dfd32 100644 --- a/docs-site/docs/skills-and-extensions.zh.md +++ b/docs-site/docs/skills-and-extensions.zh.md @@ -34,6 +34,14 @@ SkillForge 有三个来源:本地文件、通过配置的记忆后端召回的 Skill Hub 候选。可用性取决于配置和服务连通性;目录搜索命中不证明 bundle 已安装, 也不证明所需工具存在。 +搜索 Skill Hub 会把文字发出本机。两个初始化向导都会把共享目录地址 +(`https://skillhub.evermind.ai`)写入 `skillForge.router.hub.endpoint`,配置里已有地址时 +保留原值;开启期间,Raven 每次查找技能都会把对话中的文字(消息本身,或模型据此写出的查询) +发送到这个地址。可以用设置弹窗「技能」页上的 Skill Hub 开关关闭,或把 +`skillForge.router.hub.enabled` 设为 `false`;地址会保留,重新打开时回到同一个目录。没有 +配置地址时不会搜索 Hub。关闭对下一次搜索即生效;网关启动时没有地址的话,打开后要等重新 +加载才开始搜索。 + `skillForge.discovery` 默认 `pull`:在符合条件的轮次中提供简短的名称/描述菜单, 模型用 `find_skill` 搜索、用 `read_skill` 读取正文。`push` 则运行选择/注入管线, 把选中正文放入上下文。Pull 避免 push 路径逐轮的重写与门控模型调用,但不意味着所有 @@ -55,7 +63,7 @@ Bundle 下载有独立的同意设置: 应使用 blocklist。执行前检查脚本;目录安全分数不是安全保证,当前策略也不会仅因缺少 分数就直接拒绝。成功安装记录在 `/skills/hub/installs.jsonl`。 -网页端设置弹窗的「技能」页只负责开关;页上的「新增」会把你带到对话里,并替你写好半句 +网页端设置弹窗的「技能」页负责技能和 Skill Hub 搜索的开关;页上的「新增」会把你带到对话里,并替你写好半句 请求——技能是让 Raven 在对话里安装的,受上面的同意设置约束。 ## 记忆与学习边界 { #memory-and-learning-boundaries } diff --git a/i18n/messages.json b/i18n/messages.json index f3547095e6..5a6bb79f5f 100644 --- a/i18n/messages.json +++ b/i18n/messages.json @@ -6777,6 +6777,10 @@ "en": "taking a skill off the list lets it be offered and installed again", "zh": "把技能移出清单后,它可以再次被推荐和安装" }, + "gui.confirm.cfg.why.skillForge.router.hub.enabled": { + "en": "turning it back on resumes sending the turn's message text to the Skill Hub", + "zh": "重新打开会把当轮对话内容发送到 Skill Hub" + }, "gui.confirm.cfg.why.subagents.*.description": { "en": "the dispatching model reads it every turn, so it can steer what is sent where", "zh": "调度模型每一轮都会读到它,因此能左右内容发往何处" @@ -8685,6 +8689,22 @@ "en": "Help me find a skill on Skill Hub and install it. My requirements are as follows:", "zh": "帮我在 Skill Hub 找一个技能并安装,我的要求如下:" }, + "gui.settings.skills.hub_card": { + "en": "Skill Hub", + "zh": "Skill Hub" + }, + "gui.settings.skills.hub_title": { + "en": "Use 110k+ skills from EverMind Skill Hub", + "zh": "使用 EverMind Skill Hub 的 11 万+ 技能" + }, + "gui.settings.skills.hub_sub": { + "en": "While on, Raven sends text from your conversation to the Skill Hub when it looks for a skill.", + "zh": "开启时,Raven 查找技能会把对话中的文字发送到 Skill Hub。" + }, + "gui.settings.skills.hub_sub_reload": { + "en": "While on, Raven sends text from your conversation to the Skill Hub when it looks for a skill. Search starts after Raven reloads.", + "zh": "开启时,Raven 查找技能会把对话中的文字发送到 Skill Hub。Raven 重新加载后才会开始搜索。" + }, "gui.settings.skills.builtin": { "en": "Built-in", "zh": "内置" diff --git a/raven/agent/loop/wiring.py b/raven/agent/loop/wiring.py index 7db547c23a..5a56c1b1f6 100644 --- a/raven/agent/loop/wiring.py +++ b/raven/agent/loop/wiring.py @@ -1420,6 +1420,7 @@ def picture_vendor() -> str: ) if skill_registry is not None or self._skill_hub_client is not None: from raven.agent.tools.skill_hub import FindSkillTool, ReadSkillTool, UseSkillTool + from raven.config.live import skill_hub_enabled self.tools.register( ReadSkillTool( @@ -1428,6 +1429,7 @@ def picture_vendor() -> str: min_safety=self._skill_min_safety, blocklist=self._skill_blocklist, blocklist_reader=self._skill_blocklist_reader, + enabled_reader=lambda: skill_hub_enabled(self._live_config), ), ) # Pull-mode discovery: search on the model's own terms through the @@ -1436,6 +1438,7 @@ def picture_vendor() -> str: FindSkillTool( lambda: getattr(self.context_engine, "skills_router", None), hub_wired=self._skill_hub_client is not None, + enabled_reader=lambda: skill_hub_enabled(self._live_config), min_safety=self._skill_min_safety, blocklist=self._skill_blocklist, blocklist_reader=self._skill_blocklist_reader, @@ -1448,6 +1451,7 @@ def picture_vendor() -> str: min_safety=self._skill_min_safety, blocklist=self._skill_blocklist, blocklist_reader=self._skill_blocklist_reader, + enabled_reader=lambda: skill_hub_enabled(self._live_config), auto_install=self._skill_auto_install, install_audit_path=( self.workspace / "skills" / "hub" / "installs.jsonl" @@ -2017,9 +2021,14 @@ def _build_skill_hub_client( workspace: Path, skill_forge_router_config: "SkillForgeRouterConfig | None", ) -> "SkillHubClient | None": - """Construct the shared Skill Hub client, or ``None`` when no Hub is - configured. Downloads land under ``/skills/hub`` so a - use_skill'd bundle is discoverable by the on-disk skill registry.""" + """Construct the shared Skill Hub client, or ``None`` when no Hub + endpoint is configured. Downloads land under ``/skills/hub`` + so a use_skill'd bundle is discoverable by the on-disk skill registry. + + Built while the switch is off too: the tools ask the live config + before each Hub call, so turning the source back on does not need the + loop rebuilt. Only the endpoint is fixed at construction. + """ hub_cfg = getattr(skill_forge_router_config, "hub", None) if hub_cfg is None or not getattr(hub_cfg, "endpoint", None): return None diff --git a/raven/agent/tools/raven_config.py b/raven/agent/tools/raven_config.py index 9fe6ad5e52..e9ecf6cb07 100644 --- a/raven/agent/tools/raven_config.py +++ b/raven/agent/tools/raven_config.py @@ -639,7 +639,7 @@ def _setting_lines( def _setting_line( self, raw: dict[str, Any], setting: Setting, path: str, roles: dict[str, Any] | None, *, detail: bool ) -> str: - value = self._value_view(raw, setting, path, roles) + value = self._stored_view(raw, setting, path, roles) if isinstance(value, dict) and set(value) == {"default"}: default = value["default"] if default in (None, "", [], {}) and setting.unset_means: @@ -650,6 +650,8 @@ def _setting_line( shown = f"{_compact(default)} (default)" else: shown = _compact(value) + if setting.in_effect is not None: + shown += f", in effect: {_compact(setting.in_effect(raw))}" line = f" {path} = {shown} [{self._meta(setting)}] {setting.summary}" if shown.startswith("not set") and (missing := _media_needs(raw, path)): line += f"; {missing}" @@ -882,6 +884,13 @@ async def _get(self, path: str, value: Any = None) -> str: return _dump({path: self._value_view(raw, found[0], path, roles)}) def _value_view(self, raw: dict[str, Any], setting: Setting, path: str, roles: dict[str, Any] | None = None) -> Any: + view = self._stored_view(raw, setting, path, roles) + if setting.in_effect is None: + return view + stored = view if isinstance(view, dict) and set(view) == {"default"} else {"value": view} + return stored | {"in_effect": setting.in_effect(raw)} + + def _stored_view(self, raw: dict[str, Any], setting: Setting, path: str, roles: dict[str, Any] | None) -> Any: if setting.session: now = self._conversation_model() return now if now is not None else "unknown outside a conversation" @@ -965,8 +974,8 @@ async def _set(self, path: str, value: Any) -> str: present, _ = surface.lookup(await asyncio.to_thread(surface.read_raw), instance) if not present: raise LookupError(f"{instance} is not configured; describe {setting.path.split('.*')[0]} first") - previous = await self._write(setting, path, bound, value) - return self._report(path, setting.effect, previous, value) + self._unknown_tools(path, value) + previous, warning = await self._write(setting, path, bound, value) + return self._report(path, setting.effect, previous, value, warning=warning) + self._unknown_tools(path, value) async def _set_many(self, changes: dict[str, Any]) -> str: """Several settings under the one confirmation the user already gave. @@ -1017,8 +1026,11 @@ async def _set_many(self, changes: dict[str, Any]) -> str: if setting.secret: lines.append(await self._secret_outcome(path, setting, value)) continue - previous = await self._write(setting, path, bound, value) - lines.append(self._report(path, setting.effect, previous, value) + self._unknown_tools(path, value)) + previous, warning = await self._write(setting, path, bound, value) + lines.append( + self._report(path, setting.effect, previous, value, warning=warning) + + self._unknown_tools(path, value) + ) for name, fields in channels.items(): lines.append(await self._set_channel_fields(name, fields)) for path, value in agents: @@ -1064,13 +1076,21 @@ def _conversation_model(self) -> str | None: model, own = self._session_model(conversation) return model if own else f"{model} (the default)" - async def _write(self, setting: Setting, path: str, bound: list[str], value: Any) -> Any: + async def _write(self, setting: Setting, path: str, bound: list[str], value: Any) -> tuple[Any, str]: + """Write through the setting's owner: ``(previous, warning)``. + + The warning is the owner's own sentence for a write that costs more than + the value -- a reload the serving process still owes -- and is empty + when it has nothing to add. + """ writer = setting.writer if writer == "raw": - return await asyncio.to_thread(surface.write_value, path, value) + return await asyncio.to_thread(surface.write_value, path, value), "" if writer == "settings": result = await self._rpc("settings.set", {"key": path, "value": value}) - return result.get("previous") if isinstance(result, dict) else None + previous = result.get("previous") if isinstance(result, dict) else None + warning = result.get("warning") if isinstance(result, dict) else None + return previous, str(warning) if warning else "" if writer == "config.model": model, provider = self._model_ref(value) if not provider: @@ -1082,7 +1102,7 @@ async def _write(self, setting: Setting, path: str, bound: list[str], value: Any raise ValueError("session.model needs a conversation; this call is not part of one") params |= {"scope": "session", "session_id": conversation} result = await self._rpc("config.set", params) - return result.get("previous") if isinstance(result, dict) else None + return (result.get("previous") if isinstance(result, dict) else None), "" if writer == "everos": model, provider = self._model_ref(value) if not provider: @@ -1091,11 +1111,11 @@ async def _write(self, setting: Setting, path: str, bound: list[str], value: Any await self._rpc( "settings.everos_set", {"section": path.rsplit(".", 1)[1], "model": model, "provider": provider} ) - return previous + return previous, "" if writer == "model.fields": result = await self._rpc("model.set_fields", {"slug": bound[0], "fields": {"api_base": value}}) previous = result.get("previous") if isinstance(result, dict) else None - return previous.get("api_base") if isinstance(previous, dict) else previous + return (previous.get("api_base") if isinstance(previous, dict) else previous), "" raise ValueError(f"{path} has no writer in this process") async def _unset(self, path: str) -> str: @@ -1368,7 +1388,7 @@ def _needed_restart(self) -> str: # -- helpers --------------------------------------------------------------- - def _report(self, path: str, effect: Effect, previous: Any, value: Any) -> str: + def _report(self, path: str, effect: Effect, previous: Any, value: Any, *, warning: str = "") -> str: line = ( f"Set {path}: {json.dumps(previous, ensure_ascii=False, default=str)} -> " f"{json.dumps(value, ensure_ascii=False, default=str)}. It {EFFECT_TEXT[effect]}." @@ -1379,6 +1399,8 @@ def _report(self, path: str, effect: Effect, previous: Any, value: Any) -> str: f" Pending until {'a restart' if effect is Effect.RESTART else 'a reload'}: " f"{sorted(self._pending)}. Batch further changes first, then call restart once." ) + if warning: + line += f" {warning}" return line def _unknown_tools(self, path: str, value: Any) -> str: diff --git a/raven/agent/tools/skill_hub.py b/raven/agent/tools/skill_hub.py index 01ab4fb48e..8d65c229ef 100644 --- a/raven/agent/tools/skill_hub.py +++ b/raven/agent/tools/skill_hub.py @@ -127,9 +127,11 @@ def __init__( min_safety: float = 0.7, blocklist: "Iterable[str] | None" = None, blocklist_reader: "Callable[[], frozenset[str]] | None" = None, + enabled_reader: "Callable[[], bool] | None" = None, ) -> None: self._client = client self._registry = registry + self._enabled_reader = enabled_reader self._policy = SkillPolicy.create(min_safety=min_safety, blocklist=blocklist, blocklist_reader=blocklist_reader) @property @@ -171,6 +173,8 @@ async def execute(self, skill_id: Any = None, **_: Any) -> str: return "Error: 'skill_id' is required — a skill's qualified id like 'local/' or 'hub/'." source, native = split_qualified_id(skill_id) + if source == "hub" and self._enabled_reader is not None and not self._enabled_reader(): + return "Error: Skill Hub retrieval is turned off in Settings." if is_blocked(self._policy.blocked_now(), native): return f"Error: skill {native!r} is on the operator blocklist (skillForge.blocklist) and cannot be read." @@ -243,11 +247,13 @@ def __init__( min_safety: float = 0.7, blocklist: "Iterable[str] | None" = None, blocklist_reader: "Callable[[], frozenset[str]] | None" = None, + enabled_reader: "Callable[[], bool] | None" = None, auto_install: str = "auto", install_audit_path: "Path | None" = None, ) -> None: self._client = client self._registry = registry + self._enabled_reader = enabled_reader self._policy = SkillPolicy.create( min_safety=min_safety, blocklist=blocklist, @@ -293,6 +299,8 @@ async def execute(self, skill_id: Any = None, **_: Any) -> str: return "Error: 'skill_id' is required — a skill's qualified id like 'hub/'." source, native = split_qualified_id(skill_id) + if source == "hub" and self._enabled_reader is not None and not self._enabled_reader(): + return "Error: Skill Hub retrieval is turned off in Settings." if is_blocked(self._policy.blocked_now(), native): return f"Error: skill {native!r} is on the operator blocklist (skillForge.blocklist) and cannot be used." @@ -407,25 +415,38 @@ def __init__( get_router: Callable[[], Any], *, hub_wired: bool = False, + enabled_reader: "Callable[[], bool] | None" = None, min_safety: float = 0.7, blocklist: "Iterable[str] | None" = None, blocklist_reader: "Callable[[], frozenset[str]] | None" = None, ) -> None: self._get_router = get_router self._hub_wired = hub_wired + self._enabled_reader = enabled_reader self._policy = SkillPolicy.create(min_safety=min_safety, blocklist=blocklist, blocklist_reader=blocklist_reader) @property def name(self) -> str: return "find_skill" + def to_schema(self) -> dict[str, Any]: + """Rendered per model call, so the Hub sentence follows the settings switch. + + Authored rather than inherited on purpose: ``ToolRegistry`` serves a + tool from the copy it took at registration unless the tool writes its + own ``to_schema``, and the Hub client is built while the switch is off, + so whether the Hub is searched is known only per call. + """ + return super().to_schema() + @property def description(self) -> str: - # The blurb must describe what is actually wired: without a Hub - # endpoint the search covers only the locally installed pool, and - # promising a 110k-skill library would send the model chasing - # results that cannot exist on this deployment. - scope = f"{_LIBRARY_BLURB} " if self._hub_wired else "It searches the locally installed skill pool. " + # The blurb must describe what is actually searched: without a Hub + # endpoint, or with the switch off, the search covers only the locally + # installed pool, and promising a 110k-skill library would send the + # model chasing results that cannot exist on this deployment. + searched = self._hub_wired and (self._enabled_reader is None or self._enabled_reader()) + scope = f"{_LIBRARY_BLURB} " if searched else "It searches the locally installed skill pool. " return ( f"Search the skill library for reusable methodology. {scope}" "Facing an unfamiliar kind of task, search before improvising -- " diff --git a/raven/cli/onboard_commands.py b/raven/cli/onboard_commands.py index 1e6e7de3cb..28f6a33e11 100644 --- a/raven/cli/onboard_commands.py +++ b/raven/cli/onboard_commands.py @@ -329,23 +329,23 @@ def _handle_existing_config(*, reset: bool, yes: bool, non_interactive: bool) -> def _bootstrap_empty_config() -> None: """Make sure ``~/.raven/config.json`` + workspace dir exist before we patch. - We seed the user-facing extension defaults (memory / plugins / skillForge), - including ``memory.backend = "everos"`` (the schema default). EverOS - degrades gracefully when its models aren't configured yet (empty recall + a - warning, never a crash), so an enabled-but-modelless install is safe. The - wizard's Step 4 — and its skip / non-interactive guard — resolve the backend - back to ``None`` when the user opts out or never configures the one required - model (``_memory_enabled`` gates on the llm role being present, not just the - backend name; embedding and rerank only cost recall quality). - - Seeding runs on EVERY onboard, not just a brand-new config: the writer is - ``setdefault``-based (non-clobbering), so it backfills these blocks into a - pre-existing config that predates them without touching any value the user - already set. The base ``Config()`` is only written when the file is absent — - overwriting an existing file there would clobber it. + The base config stays sparse: ``save_config`` dumps with + ``exclude_defaults``, so the memory / plugins / skillForge blocks are not + materialized and their readers answer from the declarations. The one + deliberate exception is the Skill Hub endpoint -- its schema default is + ``None`` so programmatic loads stay Hub-disabled, which makes onboarding + (the user's opt-in) the place that writes it by default; the settings + switch can change it later. + + Runs on every onboard: an existing endpoint, explicit null included, wins, + and every value the user already carries is left untouched. The base + ``Config()`` is only written when the file is absent — overwriting an + existing file there would clobber it. """ - from raven.config.loader import get_config_path, load_config, save_config + from raven.config.live import skill_hub_on + from raven.config.loader import get_config_path, load_config, read_raw_or_raise, save_config from raven.config.paths import get_workspace_path + from raven.config.update import initialize_skill_hub_endpoint from raven.utils.workspace import sync_workspace_templates path = get_config_path() @@ -353,6 +353,16 @@ def _bootstrap_empty_config() -> None: # Creates the file; save_config dumps with exclude_defaults, so a # fresh config is sparse -- it records what the user set, nothing else. save_config(load_config()) + seeded = initialize_skill_hub_endpoint(config_path=path) + if seeded is not None and skill_hub_on(read_raw_or_raise(path)): + console.print( + t( + " [dim]Skill Hub search is on: when Raven looks for a skill, it sends text from your " + "conversation to {endpoint}. Turn it off under Settings > Skills on the web page, or set " + "skillForge.router.hub.enabled to false.[/dim]", + endpoint=seeded, + ) + ) workspace = get_workspace_path() workspace.mkdir(parents=True, exist_ok=True) sync_workspace_templates(workspace, notify=lambda m: console.print(f" [dim]{m}[/dim]")) diff --git a/raven/config/live.py b/raven/config/live.py index bc3de7f523..a3fff14b5b 100644 --- a/raven/config/live.py +++ b/raven/config/live.py @@ -30,6 +30,7 @@ from __future__ import annotations +import copy import json import weakref from contextlib import contextmanager @@ -672,6 +673,79 @@ def skill_blocklist(live: LiveConfig) -> frozenset[str]: return normalize_blocklist(names) +def hub_source_on(hub: Any) -> bool: + """Whether a raw ``hub`` block leaves the conversational source on. + + Validated through the schema, so a hand-written ``0`` / ``"false"`` reads + the way the loader reads it instead of by Python truthiness, and an + endpoint is required -- ``enabled`` alone cannot turn the source on. A + block the loader would refuse answers off, which is the safe direction for + a switch that stops text leaving the machine. + """ + from raven.config.raven import HubSourceConfig + + if not isinstance(hub, dict): + return False + try: + cfg = HubSourceConfig.model_validate(hub) + except Exception: # noqa: BLE001 - a block the loader refuses is not consent + return False + return bool(cfg.endpoint) and cfg.enabled + + +def skill_hub_on(raw: Any) -> bool: + """Whether a config, as the file writes it, leaves the conversational Skill Hub source on. + + The loader's migrations run first, on a copy. Some of them are never written + back to the file -- a legacy top-level ``skillRouter`` nested under + ``skillForge``, the retired ``hub.prefetchBodies`` dropped -- so a file keeps + those shapes for good, and reading it without them turns off a Hub the + runtime built from the same bytes. + + Both top-level spellings are accepted, the way the writer resolves them; a + file carrying both non-null spellings is refused by the loader, so it is off + here too. + """ + from raven.config.loader import _migrate_config + + if not isinstance(raw, dict): + return False + try: + data = _migrate_config(copy.deepcopy(raw), pop_extension_keys=False) + except Exception: # noqa: BLE001 - a file the loader cannot walk is not consent + return False + camel = data.get("skillForge") + snake = data.get("skill_forge") + if camel is not None and snake is not None: + return False + block = camel if camel is not None else snake + if not isinstance(block, dict): + return False + router = block.get("router") + hub = router.get("hub") if isinstance(router, dict) else None + return hub_source_on(hub) + + +_HUB_ANSWERS: "weakref.WeakKeyDictionary[LiveConfig, tuple[dict[str, Any], bool]]" = weakref.WeakKeyDictionary() + + +def skill_hub_enabled(live: LiveConfig) -> bool: + """Whether the conversational Skill Hub source may run, read on every ask. + + Read live rather than captured at loop start so switching the source off + stops the next query, not the next process. The answer is kept per parsed + file, so the migration in :func:`skill_hub_on` runs once per change of the + bytes rather than once per search. + """ + raw = live.raw() + kept = _HUB_ANSWERS.get(live) + if kept is not None and kept[0] is raw: + return kept[1] + answer = skill_hub_on(raw) + _HUB_ANSWERS[live] = (raw, answer) + return answer + + _LAST_VALID_PERMISSIONS: "weakref.WeakKeyDictionary[LiveConfig, Any]" = weakref.WeakKeyDictionary() diff --git a/raven/config/raven.py b/raven/config/raven.py index 5bb99658cf..9a309e3e7b 100644 --- a/raven/config/raven.py +++ b/raven/config/raven.py @@ -1663,10 +1663,20 @@ class HubSourceConfig(_Base): skill's body / downloading its zip is driven by the ``read_skill`` / ``use_skill`` tools, which reuse this same config.""" + enabled: bool = True + """Whether the conversational Hub source is consulted. ``endpoint`` must + also be set -- ``None`` keeps the source off whatever this says, so a + config written before this field existed keeps its meaning. Turning this + off keeps the endpoint, so a custom URL survives the switch. While on, a + qualifying turn's message text is sent to the endpoint as the catalog + query.""" + endpoint: str | None = None """Skill Hub base URL, e.g. ``"https://mss.evermind.ai"``. ``None`` - disables the Hub source — SkillForgeRouter degrades to Local + Mass + - Everos.""" + disables the Hub source — SkillForgeRouter then runs on Local, plus the + memory backend when one is wired. Onboarding seeds this to the shared + catalog URL; ``apiKey`` is never seeded, because it is the user's own + Bearer token.""" api_key: str | None = None """Bearer token sent as ``Authorization: Bearer ``.""" diff --git a/raven/config/self_surface.py b/raven/config/self_surface.py index 2e14264653..ed4b423b6a 100644 --- a/raven/config/self_surface.py +++ b/raven/config/self_surface.py @@ -32,7 +32,7 @@ import json import re import string -from collections.abc import Iterable +from collections.abc import Callable, Iterable from dataclasses import dataclass, field from enum import StrEnum from functools import lru_cache @@ -113,6 +113,10 @@ class Setting: #: confirmation card words in the reader's language. unset_means: str = "" unset_to: str = "" + #: For a setting that acts only together with other keys (a switch that + #: also needs an address), what the runtime runs on, read from the whole + #: file: a read shows it beside the stored value, which alone misleads. + in_effect: "Callable[[dict[str, Any]], Any] | None" = None def describe(self) -> dict[str, Any]: out: dict[str, Any] = { @@ -155,6 +159,13 @@ class Section: _E = Effect + +def _skill_hub_in_effect(raw: dict[str, Any]) -> bool: + from raven.config.live import skill_hub_on + + return skill_hub_on(raw) + + SECTIONS: tuple[Section, ...] = ( Section( "model", @@ -546,6 +557,19 @@ class Section: note="the list replaces the stored one; read it first", ), Setting("skillForge.enabled", "Mount the extra local skill directories", "bool", _E.RELOAD), + Setting( + "skillForge.router.hub.enabled", + "Search the shared Skill Hub during conversations", + "bool", + _E.IMMEDIATE, + writer="settings", + sensitive=("turning it back on resumes sending the turn's message text to the Skill Hub"), + note=( + "on only while skillForge.router.hub.endpoint is set, which a read shows as in_effect; " + "switching it off applies at once; turning it on may wait for a reload, and the write says so" + ), + in_effect=_skill_hub_in_effect, + ), Setting( "skillForge.autoInstall", "Installing a Skill Hub skill the router picked", diff --git a/raven/config/update.py b/raven/config/update.py index d381528e18..5960e4fd58 100644 --- a/raven/config/update.py +++ b/raven/config/update.py @@ -12,6 +12,7 @@ import json import secrets +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -19,15 +20,16 @@ from pydantic.alias_generators import to_camel from raven.config.keys import key_as_written +from raven.config.live import skill_hub_on from raven.config.loader import get_config_path, read_raw_or_raise from raven.config.schema import CronConfig from raven.utils.atomic_io import atomic_update -# Shared Skill Hub endpoint seeded into a fresh config's skillForge.router.hub. -# Kept here (not as the HubSourceConfig schema default) so non-onboard / -# programmatic loads stay Hub-disabled until a config opts in, while an -# onboarded config shows the live endpoint. apiKey is NOT seeded — the user -# supplies their own Bearer token. +# Shared Skill Hub endpoint seeded into an onboarded config's +# skillForge.router.hub. Kept here (not as the HubSourceConfig schema default) +# so non-onboard / programmatic loads stay Hub-disabled until a config opts in, +# while an onboarded config shows the live endpoint. apiKey is NOT seeded — the +# user supplies their own Bearer token. _DEFAULT_SKILL_HUB_ENDPOINT = "https://skillhub.evermind.ai" @@ -273,6 +275,120 @@ def _apply(_text: str | None) -> tuple[str | None, tuple[list[str], bool]]: return current +def _skill_hub_block(data: dict[str, Any]) -> dict[str, Any]: + """The ``hub`` table the loader reads the Skill Hub from, creating missing levels. + + Every skill-hub writer goes through this so they agree on the two edge + cases: a null top-level block means "unset" (the loader drops it), while a + null ``router`` or ``hub`` is a schema violation and is refused rather + than repaired. A file spelling the block ``skill_forge`` is updated in + place rather than gaining a second camelCase block beside it. + + A legacy top-level ``skillRouter`` is the router while the ``skillForge`` + block has none of its own: the loader nests it there in memory and never + writes the move back, so creating ``skillForge.router`` instead would hide + it -- endpoint and key included -- from the next load. + """ + sf_key = key_as_written(data, "skillForge", top_level=True) + section = data.get(sf_key) + if section is not None and not isinstance(section, dict): + raise ValueError(f"{sf_key} must be an object or null") + legacy_key = key_as_written(data, "skillRouter", top_level=True) + if data.get(legacy_key) is not None and (section is None or "router" not in section): + router, where = data[legacy_key], legacy_key + else: + if section is None: + section = data[sf_key] = {} + if "router" not in section: + section["router"] = {} + router, where = section["router"], f"{sf_key}.router" + if not isinstance(router, dict): + raise ValueError(f"{where} must be an object") + if "hub" not in router: + router["hub"] = {} + hub = router["hub"] + if not isinstance(hub, dict): + raise ValueError(f"{where}.hub must be an object") + return hub + + +def initialize_skill_hub_endpoint(*, config_path: Path | None = None) -> str | None: + """Seed ``skillForge.router.hub.endpoint`` into an onboarded config. + + Returns the endpoint it wrote, or ``None`` when the config already names + one (an explicit null included — that is how a user keeps the Hub off + across a repeat onboard). + + The schema default is ``None`` on purpose, so a programmatic or + non-onboarded load stays Hub-disabled until a config opts in. Both + onboarding wizards (the CLI bootstrap and the web wizard's finish) are that + opt-in; the settings switch (:func:`set_skill_hub_enabled`) is the other + writer. ``apiKey`` is left absent for the user's own Bearer token. + """ + path = config_path or get_config_path() + + def _apply(_text: str | None) -> tuple[str | None, str | None]: + data = read_raw_or_raise(path) + hub = _skill_hub_block(data) + if "endpoint" in hub: + return None, None + hub["endpoint"] = _DEFAULT_SKILL_HUB_ENDPOINT + return json.dumps(data, indent=2, ensure_ascii=False), _DEFAULT_SKILL_HUB_ENDPOINT + + endpoint = atomic_update(path, _apply) + if endpoint is not None: + logger.info("config/update: seeded skillForge.router.hub.endpoint = {!r}", endpoint) + return endpoint + + +@dataclass(frozen=True) +class SkillHubSwitch: + """What a switch write changed. + + ``previous`` is the effective state before the write, as the runtime reads + it. ``seeded_endpoint`` says whether turning it on had to add the shared + URL; whether a running loop can search with it is that loop's question. + """ + + previous: bool + seeded_endpoint: bool + + +def set_skill_hub_enabled(enabled: bool, *, config_path: Path | None = None) -> SkillHubSwitch: + """Turn the conversational Skill Hub source on or off. + + On writes the shared endpoint when the block has none — an existing URL, + custom ones included, is kept, so the switch never discards an operator's + endpoint. Off records ``enabled: false`` and leaves the URL in place, so + turning it back on restores the same one. + + Both directions are read live by a running loop that started with an + endpoint; a loop that started without one has no Hub client until a + reload, and the RPC says so. + """ + path = config_path or get_config_path() + + def _apply(_text: str | None) -> tuple[str, SkillHubSwitch]: + data = read_raw_or_raise(path) + previous = skill_hub_on(data) + hub = _skill_hub_block(data) + endpoint = hub.get("endpoint") + seeded = enabled and not (isinstance(endpoint, str) and endpoint) + hub["enabled"] = enabled + if seeded: + hub["endpoint"] = _DEFAULT_SKILL_HUB_ENDPOINT + return json.dumps(data, indent=2, ensure_ascii=False), SkillHubSwitch(previous, seeded) + + change = atomic_update(path, _apply) + logger.info( + "config/update: skill hub source switched {} (was {}){}", + "on" if enabled else "off", + change.previous, + " and seeded the endpoint" if change.seeded_endpoint else "", + ) + return change + + def set_language( language: str, *, @@ -874,6 +990,9 @@ def _apply(_text: str | None) -> tuple[str, None]: "set_memory_backend", "set_skill_blocked", "set_playbook_disabled", + "initialize_skill_hub_endpoint", + "SkillHubSwitch", + "set_skill_hub_enabled", "initialize_a2a_server", "set_a2a_server_enabled", ] diff --git a/raven/context_engine/factory.py b/raven/context_engine/factory.py index 0e5a4fca5f..77f4ef6eb6 100644 --- a/raven/context_engine/factory.py +++ b/raven/context_engine/factory.py @@ -40,7 +40,7 @@ from loguru import logger from raven.agent.context import ContextBuilder -from raven.config.live import LiveConfig, curator_pin, skill_gate_pin +from raven.config.live import LiveConfig, curator_pin, skill_gate_pin, skill_hub_enabled from raven.context_engine import history as history_strategies from raven.context_engine.assembler import ContextAssembler from raven.context_engine.scent import ScentMenu @@ -117,18 +117,20 @@ def build_context_engine( if skill_forge_router_config is None: skill_forge_router_config = _SkillForgeRouterConfig() + # One reader for the pins and the Hub switch: the file is re-parsed only + # when its bytes change, so the subsystems asking per call cost one read + # between them. + live = LiveConfig() + router = _build_router( builder=builder, backend=backend, memory_config=memory_config, skill_forge_router_config=skill_forge_router_config, skill_hub_client=skill_hub_client, + hub_enabled_reader=lambda: skill_hub_enabled(live), ) - # One reader for both pins: the file is re-parsed only when its bytes - # change, so two subsystems asking per call cost one read between them. - live = LiveConfig() - discovery = str(getattr(skill_forge_config, "discovery", "pull") or "pull") if discovery == "push": rewriter, gate = _build_rewriter_and_gate( @@ -259,6 +261,7 @@ def _build_router( memory_config: "MemoryConfig", skill_forge_router_config: "SkillForgeRouterConfig", skill_hub_client: "SkillHubClient | None" = None, + hub_enabled_reader: "Callable[[], bool] | None" = None, ) -> "SkillForgeRouter": """Assemble the 1-to-3 source SkillForgeRouter for segment 5.""" from raven.memory_engine import ( @@ -294,6 +297,9 @@ def _build_router( sources.append(backend_source) # ── Source 3: Hub (conditional on remote endpoint) ────────────── + # Added while the switch is off too: the source is asked per search and + # gates itself on the live config, so turning the switch back on does not + # need the loop rebuilt. The endpoint is what cannot be added live. hub_cfg = skill_forge_router_config.hub if hub_cfg.endpoint: from raven.skill_hub import SkillHubClient @@ -311,6 +317,7 @@ def _build_router( client, weight=float(weights.get("hub", 0.85)), min_safety=hub_cfg.min_safety, + enabled_reader=hub_enabled_reader, ) sources.append(hub_source) diff --git a/raven/i18n/zh.py b/raven/i18n/zh.py index cd03893298..82b9557d6d 100644 --- a/raven/i18n/zh.py +++ b/raven/i18n/zh.py @@ -6,6 +6,11 @@ MESSAGES: dict[str, str] = { "Saved. Applies after the next gateway reload or restart.": "已保存。下次网关重载或重启后生效。", + # Skill Hub search: the reload wait, and what turning it on sends. + "Saved. Skill Hub search starts after Raven reloads.": "已保存。Raven 重新加载后才会开始搜索 Skill Hub。", + "Skill Hub search is on: when Raven looks for a skill, it sends text from your conversation to EverMind Skill Hub. You can turn it off in Settings > Skills.": "Skill Hub 搜索已开启:Raven 查找技能时,会把对话中的文字发送到 EverMind Skill Hub。可以在“设置 > 技能”中关闭。", + "Skill Hub search is on and starts after Raven reloads: when Raven looks for a skill, it sends text from your conversation to EverMind Skill Hub. You can turn it off in Settings > Skills.": "Skill Hub 搜索已开启,Raven 重新加载后生效:Raven 查找技能时,会把对话中的文字发送到 EverMind Skill Hub。可以在“设置 > 技能”中关闭。", + " [dim]Skill Hub search is on: when Raven looks for a skill, it sends text from your conversation to {endpoint}. Turn it off under Settings > Skills on the web page, or set skillForge.router.hub.enabled to false.[/dim]": " [dim]Skill Hub 搜索已开启:Raven 查找技能时,会把对话中的文字发送到 {endpoint}。可以在网页的“设置 > 技能”中关闭,或把 skillForge.router.hub.enabled 设为 false。[/dim]", # raven.playbook rounds: the word that ends a plan early, and who hears it. "You are the last role of this round, so what you write is what the stint reads when it decides whether to open another one. If nothing is left that is worth another round, write {marker} on a line of its own and the stint ends with this round. That is the only thing that ends it early: without those words it keeps going until its rounds are spent.": "你是这一轮的最后一个角色,所以计划在决定要不要再开一轮时,读的就是你写的东西。如果已经没有任何值得再开一轮的事情,就单独占一行写下 {marker},计划就到这一轮为止。能提前结束它的只有这一件事:没有这个词,它会一直跑到轮数用完。", # raven.playbook rounds: which directory a role is standing in. diff --git a/raven/memory_engine/skill_forge/hub_source.py b/raven/memory_engine/skill_forge/hub_source.py index 8506eadb77..e6d90fafc0 100644 --- a/raven/memory_engine/skill_forge/hub_source.py +++ b/raven/memory_engine/skill_forge/hub_source.py @@ -23,6 +23,8 @@ from raven.memory_engine.skill_forge.types import RouterHit if TYPE_CHECKING: + from collections.abc import Callable + from raven.skill_hub import SkillHubClient logger = logging.getLogger(__name__) @@ -40,10 +42,14 @@ def __init__( *, weight: float = 0.85, min_safety: float = 0.7, + enabled_reader: "Callable[[], bool] | None" = None, ) -> None: self._client = client self.weight = weight self._min_safety = min_safety + # Asked per search, not captured: the settings switch must stop the + # next query, and the loop that built this source keeps serving. + self._enabled_reader = enabled_reader async def search( self, @@ -52,6 +58,8 @@ async def search( k: int, ) -> list[RouterHit]: del history # Hub search takes only the query + limit. + if self._enabled_reader is not None and not self._enabled_reader(): + return [] items = await self._client.search(query, limit=max(1, k)) hits: list[RouterHit] = [] for it in items: diff --git a/raven/rpc/methods/config.py b/raven/rpc/methods/config.py index 3dec7a0341..122b8a5672 100644 --- a/raven/rpc/methods/config.py +++ b/raven/rpc/methods/config.py @@ -128,6 +128,21 @@ def _validate_language(value: Any) -> str: return value +def _apply_language(value: Any) -> None: + """Move the process language with the file. + + The sentences Raven composes itself -- the Skill Hub notice, the embedding + warning -- are rendered through ``raven.i18n`` from process state, and the + web picker and the onboarding wizard persist through this method while the + settings page goes through ``settings.set``. Only the latter moved that + state, so a page picked into Chinese could still be answered with English + prose. + """ + from raven import i18n + + i18n.set_language(str(value)) + + _VALIDATORS: dict[str, Callable[[Any], Any]] = { "agent.temperature": _validate_temperature, "tui.theme": _validate_theme, @@ -424,6 +439,8 @@ async def config_set( previous = _get_nested(payload, path) _set_nested(payload, path, validated) _save_config(payload) + if key == "language": + _apply_language(validated) return {"applied": True, "previous": previous} @@ -467,6 +484,8 @@ async def config_unset(params: dict) -> dict: previous = _unset_nested(payload, _STORAGE_PATHS[key]) if previous is not None: _save_config(payload) + if key == "language": + _apply_language(_DEFAULTS[key]) return {"removed": previous is not None, "previous": previous, "default": _DEFAULTS[key]} diff --git a/raven/rpc/methods/console.py b/raven/rpc/methods/console.py index 41a12e4745..ee47300d9f 100644 --- a/raven/rpc/methods/console.py +++ b/raven/rpc/methods/console.py @@ -615,6 +615,54 @@ def _iso_ms(v: Any) -> int | None: # --------------------------------------------------------------------------- +_SKILL_HUB_RELOAD_WARNING = "Saved. Skill Hub search starts after Raven reloads." +# Finishing the web wizard turns the source on, and no screen on the way says +# what that sends; the page shows the answer as a toast. +_SKILL_HUB_ON_NOTICE = ( + "Skill Hub search is on: when Raven looks for a skill, it sends text from your conversation " + "to EverMind Skill Hub. You can turn it off in Settings > Skills." +) +_SKILL_HUB_ON_RELOAD_NOTICE = ( + "Skill Hub search is on and starts after Raven reloads: when Raven looks for a skill, it sends " + "text from your conversation to EverMind Skill Hub. You can turn it off in Settings > Skills." +) + + +def _skill_hub_awaits_reload(on: bool, agent_loop_factory: Any) -> bool: + """Whether the source reads on while the loop serving now cannot search. + + A loop builds its Hub client from the endpoint it started with, and only + the endpoint is fixed there: ``enabled`` is read live. So the wait is a + property of that loop, not of the write that ended it -- a later switch-on + finds the endpoint already written and the loop still without a client. + No loop at all is no wait, because the first one is built from this file. + """ + if not on or agent_loop_factory is None: + return False + try: + loop = agent_loop_factory() + except Exception: # noqa: BLE001 - the stack raises its build error: nothing is serving + return False + return loop is not None and getattr(loop, "_skill_hub_client", None) is None + + +def _skill_hub_reply(previous: Any, agent_loop_factory: Any, *, seeded: bool = False) -> dict[str, Any]: + """A Skill Hub write's answer: what a seed that left the source on sends, + and whether the serving loop cannot search yet.""" + from raven.config.live import skill_hub_on + from raven.config.loader import get_config_path, read_raw_or_raise + from raven.i18n import t + + on = skill_hub_on(read_raw_or_raise(get_config_path())) + waits = _skill_hub_awaits_reload(on, agent_loop_factory) + reply: dict[str, Any] = {"applied": True, "previous": previous} + if seeded and on: + reply["warning"] = t(_SKILL_HUB_ON_RELOAD_NOTICE if waits else _SKILL_HUB_ON_NOTICE) + elif waits: + reply["warning"] = t(_SKILL_HUB_RELOAD_WARNING) + return reply + + def _mask_secrets(node: Any, key_hint: str = "") -> Any: if isinstance(node, dict): return {k: _mask_secrets(v, k) for k, v in node.items()} @@ -628,6 +676,7 @@ def _mask_secrets(node: Any, key_hint: str = "") -> Any: async def settings_get(params: dict, *, agent_loop_factory=None) -> dict: from importlib.metadata import version as pkg_version + from raven.config.live import skill_hub_on from raven.config.loader import get_config_path, read_raw_or_raise path = get_config_path() @@ -639,7 +688,17 @@ async def settings_get(params: dict, *, agent_loop_factory=None) -> dict: ver = pkg_version("raven") except Exception: ver = "unknown" - return {"settings": _mask_secrets(raw), "config_path": str(path), "raven_version": ver} + # Answered here rather than left to the page: the runtime reads the file + # through the loader's migrations and its boolean rules, and a copy of + # either in the page drifts from it. + hub_on = skill_hub_on(raw) + return { + "settings": _mask_secrets(raw), + "config_path": str(path), + "raven_version": ver, + "skill_hub_on": hub_on, + "skill_hub_awaiting_reload": _skill_hub_awaits_reload(hub_on, agent_loop_factory), + } async def settings_set(params: dict, *, agent_loop_factory=None) -> dict: @@ -702,6 +761,31 @@ async def settings_set(params: dict, *, agent_loop_factory=None) -> dict: disable_channel(name) return {"applied": True, "previous": not value} + if key == "skillForge.router.hub.enabled": + if not isinstance(value, bool): + raise ConfigValidationError("enabled must be a boolean") + from raven.config.update import set_skill_hub_enabled + + try: + change = set_skill_hub_enabled(value) + except ValueError as exc: + raise ConfigValidationError(str(exc)) from exc + return _skill_hub_reply(change.previous, agent_loop_factory) + + if key == "skillForge.router.hub.init": + # The web wizard's finish. It seeds the endpoint the way the CLI + # onboarding does -- never re-enabling a config that turned the source + # off -- because the wizard is the page's only opt-in moment. + if value is not True: + raise ConfigValidationError("init takes true") + from raven.config.update import initialize_skill_hub_endpoint + + try: + seeded = initialize_skill_hub_endpoint() + except ValueError as exc: + raise ConfigValidationError(str(exc)) from exc + return _skill_hub_reply(None, agent_loop_factory, seeded=seeded is not None) + if key in ("plugins.disabled", "tools.disabledTools", "skillForge.blocklist"): if not isinstance(value, list) or not all(isinstance(x, str) for x in value): raise ConfigValidationError(f"{key} must be a list of strings") diff --git a/raven/rpc/models.py b/raven/rpc/models.py index 0a965ebd70..b6b1841d5a 100644 --- a/raven/rpc/models.py +++ b/raven/rpc/models.py @@ -4241,6 +4241,20 @@ class SettingsGetResult(_Strict): settings: dict[str, JsonValue] = Field(..., description="Raw config.json with secret-looking values masked.") config_path: str raven_version: str + skill_hub_on: bool = Field( + ..., + description=( + "Whether conversational Skill Hub retrieval is on, read the way the runtime reads the config: " + "an endpoint is required, and enabled=false turns it off." + ), + ) + skill_hub_awaiting_reload: bool = Field( + ..., + description=( + "skill_hub_on, while the agent loop serving now was built without a Hub client: " + "retrieval starts after a reload." + ), + ) class SettingsSetParams(_Strict): diff --git a/rpc-schema/openrpc.json b/rpc-schema/openrpc.json index 383124ee6b..cbed12fc29 100644 --- a/rpc-schema/openrpc.json +++ b/rpc-schema/openrpc.json @@ -5283,12 +5283,22 @@ }, "raven_version": { "type": "string" + }, + "skill_hub_on": { + "description": "Whether conversational Skill Hub retrieval is on, read the way the runtime reads the config: an endpoint is required, and enabled=false turns it off.", + "type": "boolean" + }, + "skill_hub_awaiting_reload": { + "description": "skill_hub_on, while the agent loop serving now was built without a Hub client: retrieval starts after a reload.", + "type": "boolean" } }, "required": [ "settings", "config_path", - "raven_version" + "raven_version", + "skill_hub_on", + "skill_hub_awaiting_reload" ], "type": "object" } diff --git a/tests/test_agent_loop_skill_tools.py b/tests/test_agent_loop_skill_tools.py index 1de0f6e078..d04f1e2b55 100644 --- a/tests/test_agent_loop_skill_tools.py +++ b/tests/test_agent_loop_skill_tools.py @@ -9,6 +9,7 @@ from __future__ import annotations +import asyncio import re import tempfile from pathlib import Path @@ -17,6 +18,8 @@ from raven.agent.loop import AgentLoop from raven.agent.loop.bundles import TurnPolicy +from raven.agent.loop.wiring import WiringMixin +from raven.config.raven import HubSourceConfig, SkillForgeRouterConfig from raven.providers.base import LLMProvider, LLMResponse @@ -53,6 +56,58 @@ def test_both_skill_tools_register_without_a_hub(loop) -> None: assert loop.tools.has("use_skill") +def test_the_hub_client_is_built_while_switched_off(tmp_path: Path) -> None: + """The client is built whenever an endpoint exists, even with the switch + off, so turning the source back on needs no loop rebuild. The tools ask + the live config before each Hub call; only the endpoint needs a reload.""" + off = SkillForgeRouterConfig(hub=HubSourceConfig(endpoint="http://hub.test", enabled=False)) + + client = WiringMixin._build_skill_hub_client(tmp_path, off) + assert client is not None + asyncio.run(client.aclose()) + + assert ( + WiringMixin._build_skill_hub_client(tmp_path, SkillForgeRouterConfig(hub=HubSourceConfig(endpoint=None))) + is None + ) + + +def test_find_skill_offers_the_hub_library_only_while_the_switch_is_on(tmp_path: Path, monkeypatch) -> None: + """The schema the model receives, through the real registry: a loop built + with the switch off still holds a client, and turning the switch on or off + must move the sentence without a rebuild.""" + import json + + from raven import home as raven_home + from raven.agent.loop.bundles import EngineWiring + + config = tmp_path / "config.json" + hub = {"endpoint": "http://hub.test", "enabled": False} + config.write_text(json.dumps({"skillForge": {"router": {"hub": hub}}}), encoding="utf-8") + monkeypatch.setattr("raven.config.loader.get_config_path", lambda: config) + raven_home.set_config_path(config) + loop = AgentLoop( + provider=_StubProvider(), + workspace=tmp_path / "ws", + model="stub", + policy=TurnPolicy(max_iterations=2), + engine=EngineWiring(skill_forge_router_config=SkillForgeRouterConfig(hub=HubSourceConfig(**hub))), + ) + + def described() -> str: + return next( + d["function"]["description"] for d in loop.tools.get_definitions() if d["function"]["name"] == "find_skill" + ) + + try: + assert loop._skill_hub_client is not None + assert "110k" not in described() + config.write_text(json.dumps({"skillForge": {"router": {"hub": {**hub, "enabled": True}}}}), encoding="utf-8") + assert "110k" in described() + finally: + asyncio.run(loop._skill_hub_client.aclose()) + + def test_the_digest_names_a_tool_this_loop_registered(loop) -> None: """Pins the two halves together: a description-mode skill's body is in no other segment, so an unregistered tool in its hint is a dead end the agent diff --git a/tests/test_cli_onboard_commands.py b/tests/test_cli_onboard_commands.py index c94b345dd3..423a3c7435 100644 --- a/tests/test_cli_onboard_commands.py +++ b/tests/test_cli_onboard_commands.py @@ -3442,24 +3442,28 @@ def test_fresh_bootstrap_defaults_memory_backend_everos( assert load_raven_config().memory.backend == "everos" -def test_fresh_bootstrap_stays_sparse(tmp_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: - """Bootstrap records nothing the user did not set: a default that lands on - disk stops following its declaration, so the extension blocks are not - materialized -- their readers answer from the declared defaults.""" +def test_fresh_bootstrap_seeds_only_the_skill_hub_endpoint(tmp_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Bootstrap records nothing the user did not set, with one exception: the + Skill Hub endpoint has no schema default (programmatic loads stay + Hub-off), so onboarding is where a fresh install opts in.""" onboard_commands._bootstrap_empty_config() data = json.loads(tmp_env.read_text()) - for block in ("memory", "plugins", "skillForge"): - assert block not in data, f"fresh config must not seed {block}" + assert "memory" not in data + assert "plugins" not in data + assert data["skillForge"] == {"router": {"hub": {"endpoint": "https://skillhub.evermind.ai"}}} from raven.config.raven import load_raven_config rc = load_raven_config() assert rc.memory.backend == "everos" # from the declaration, not the file + assert rc.skill_forge.router.hub.endpoint == "https://skillhub.evermind.ai" def test_bootstrap_leaves_a_preexisting_config_alone(tmp_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: """A config that predates the extension blocks is not backfilled: what the user set stays byte-for-byte, and the keys they never set answer from the - declarations instead of being materialized.""" + declarations instead of being materialized. The Skill Hub endpoint is the + one documented exception -- onboarding is the opt-in for a schema default + that has to stay ``None``.""" tmp_env.write_text( json.dumps( { @@ -3477,13 +3481,77 @@ def test_bootstrap_leaves_a_preexisting_config_alone(tmp_env: Path, monkeypatch: assert data["memory"]["backend"] == "everos" assert data["memory"]["memoryTopK"] == 20 assert "userId" not in data["memory"] # not backfilled - assert "plugins" not in data and "skillForge" not in data + assert "plugins" not in data + assert data["skillForge"] == {"router": {"hub": {"endpoint": "https://skillhub.evermind.ai"}}} from raven.config.raven import load_raven_config rc = load_raven_config() assert rc.memory.memory_top_k == 20 # the user's value +def test_bootstrap_keeps_a_configured_hub_endpoint(tmp_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: + tmp_env.write_text( + json.dumps({"skillForge": {"router": {"hub": {"endpoint": "https://hub.internal.test", "apiKey": "k"}}}}) + ) + + onboard_commands._bootstrap_empty_config() + + hub = json.loads(tmp_env.read_text())["skillForge"]["router"]["hub"] + assert hub == {"endpoint": "https://hub.internal.test", "apiKey": "k"} + + +def test_bootstrap_honours_an_explicit_null_hub_endpoint(tmp_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """A null endpoint is the way a user keeps the Hub off across a repeat + onboard; seeding must not read it as "unset" and turn it back on.""" + tmp_env.write_text(json.dumps({"skillForge": {"router": {"hub": {"endpoint": None}}}})) + + onboard_commands._bootstrap_empty_config() + + assert json.loads(tmp_env.read_text())["skillForge"]["router"]["hub"]["endpoint"] is None + + +def test_bootstrap_does_not_re_enable_a_switched_off_hub(tmp_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """The seed is an endpoint write, not consent: a config that switched the + source off stays off after a repeat onboard.""" + tmp_env.write_text(json.dumps({"skillForge": {"router": {"hub": {"enabled": False}}}})) + + onboard_commands._bootstrap_empty_config() + + hub = json.loads(tmp_env.read_text())["skillForge"]["router"]["hub"] + assert hub == {"enabled": False, "endpoint": "https://skillhub.evermind.ai"} + from raven.config.raven import load_raven_config + + assert load_raven_config().skill_forge.router.hub.enabled is False + + +def test_bootstrap_says_what_turning_skill_hub_search_on_sends( + tmp_env: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + """Onboarding is the opt-in, so it is where the reader hears what the + opt-in sends, where to, and how to stop it.""" + onboard_commands._bootstrap_empty_config() + + out = " ".join(capsys.readouterr().out.split()) + assert "Skill Hub search is on" in out + assert "sends text from your conversation to https://skillhub.evermind.ai" in out + assert "skillForge.router.hub.enabled" in out + + +@pytest.mark.parametrize( + "hub", + [{"enabled": False}, {"endpoint": None}, {"endpoint": "https://hub.internal.test"}], + ids=["switched off", "explicit null", "already configured"], +) +def test_bootstrap_says_nothing_about_skill_hub_when_it_turned_nothing_on( + tmp_env: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], hub: dict +) -> None: + tmp_env.write_text(json.dumps({"skillForge": {"router": {"hub": hub}}})) + + onboard_commands._bootstrap_empty_config() + + assert "Skill Hub" not in capsys.readouterr().out + + def _onboard_once() -> Any: return runner.invoke( app, diff --git a/tests/test_config_live.py b/tests/test_config_live.py index ea7acadbfb..6bf48510eb 100644 --- a/tests/test_config_live.py +++ b/tests/test_config_live.py @@ -33,6 +33,7 @@ max_tool_iterations, reasoning_effort, skill_gate_pin, + skill_hub_enabled, ) @@ -234,6 +235,124 @@ def test_a_value_that_is_not_a_list_is_ignored(self, tmp_path: Path) -> None: assert disabled_tool_names(LiveConfig(path)) == frozenset() +class TestTheHubSwitch: + """The conversational Hub source is read live: its off switch must stop the + next query, not the next process, and a hand-written value must read the + way the loader reads it.""" + + def _live(self, tmp_path: Path, payload: dict) -> LiveConfig: + path = tmp_path / "config.json" + _write(path, payload) + return LiveConfig(path) + + def test_no_block_is_off(self, tmp_path: Path) -> None: + assert skill_hub_enabled(self._live(tmp_path, {})) is False + + def test_an_endpoint_is_on(self, tmp_path: Path) -> None: + live = self._live(tmp_path, {"skillForge": {"router": {"hub": {"endpoint": "https://hub.test"}}}}) + assert skill_hub_enabled(live) is True + + def test_an_explicit_off_is_off(self, tmp_path: Path) -> None: + live = self._live( + tmp_path, + {"skillForge": {"router": {"hub": {"endpoint": "https://hub.test", "enabled": False}}}}, + ) + assert skill_hub_enabled(live) is False + + @pytest.mark.parametrize("written", [0, "false", "off"]) + def test_a_hand_written_false_reads_like_the_loader(self, tmp_path: Path, written) -> None: + live = self._live( + tmp_path, + {"skillForge": {"router": {"hub": {"endpoint": "https://hub.test", "enabled": written}}}}, + ) + assert skill_hub_enabled(live) is False + + def test_the_snake_case_block_is_read(self, tmp_path: Path) -> None: + live = self._live(tmp_path, {"skill_forge": {"router": {"hub": {"endpoint": "https://hub.test"}}}}) + assert skill_hub_enabled(live) is True + + def test_both_spellings_are_off_like_the_loader(self, tmp_path: Path) -> None: + path = tmp_path / "config.json" + _write( + path, + { + "skillForge": {"router": {"hub": {"endpoint": "https://cam.test"}}}, + "skill_forge": {"router": {"hub": {"endpoint": "https://snake.test"}}}, + }, + ) + assert skill_hub_enabled(LiveConfig(path)) is False + + def test_a_switch_off_is_the_next_answer(self, tmp_path: Path) -> None: + path = tmp_path / "config.json" + _write(path, {"skillForge": {"router": {"hub": {"endpoint": "https://hub.test"}}}}) + live = LiveConfig(path) + assert skill_hub_enabled(live) is True + + _write(path, {"skillForge": {"router": {"hub": {"endpoint": "https://hub.test", "enabled": False}}}}) + assert skill_hub_enabled(live) is False + + def test_an_invalid_hub_block_is_off(self, tmp_path: Path) -> None: + live = self._live(tmp_path, {"skillForge": {"router": {"hub": {"endpoint": 7}}}}) + assert skill_hub_enabled(live) is False + + @pytest.mark.parametrize( + ("payload", "on"), + [ + ({"skillForge": {"router": {"hub": {"endpoint": "https://hub.test", "prefetchBodies": 1}}}}, True), + ({"skill_forge": {"router": {"hub": {"endpoint": "https://hub.test", "prefetch_bodies": 1}}}}, True), + ({"skillRouter": {"hub": {"endpoint": "https://legacy.test", "prefetchBodies": 1}}}, True), + ({"skill_router": {"hub": {"endpoint": "https://legacy.test"}}}, True), + ({"skillRouter": {"hub": {"endpoint": "https://legacy.test", "enabled": False}}}, False), + ( + { + "skillRouter": {"hub": {"endpoint": "https://legacy.test"}}, + "skillForge": {"router": {"hub": {"enabled": False}}}, + }, + False, + ), + ], + ) + def test_a_shape_the_loader_migrates_reads_like_the_loader(self, tmp_path: Path, payload: dict, on: bool) -> None: + """The loader rewrites these shapes in memory only -- ``_persist_migrations`` + never writes them back -- so the file keeps them for good, and a reader + of the raw file that skips the migration turns off a Hub the runtime built.""" + from raven.config.raven import load_raven_config + + path = tmp_path / "config.json" + _write(path, payload) + hub = load_raven_config(path).skill_forge.router.hub + + assert (bool(hub.endpoint) and hub.enabled) is on + assert skill_hub_enabled(LiveConfig(path)) is on + + def test_a_section_the_migration_cannot_walk_answers_off(self, tmp_path: Path) -> None: + """The migration walks the whole file for this one answer. A hand edit + that breaks an unrelated section is a file the loader refuses: it costs + the Hub, not every search that asks.""" + live = self._live(tmp_path, {"tools": [], "skillForge": {"router": {"hub": {"endpoint": "https://hub.test"}}}}) + + assert skill_hub_enabled(live) is False + + def test_the_migration_runs_once_per_change_of_the_file(self, tmp_path: Path, monkeypatch) -> None: + """Asked before every Hub search and tool call: the copy-and-migrate is + paid when the bytes change, not per ask.""" + import raven.config.loader as loader + + real = loader._migrate_config + calls: list[int] = [] + monkeypatch.setattr(loader, "_migrate_config", lambda data, **kw: calls.append(1) or real(data, **kw)) + path = tmp_path / "config.json" + _write(path, {"skillRouter": {"hub": {"endpoint": "https://legacy.test"}}}) + live = LiveConfig(path) + + assert [skill_hub_enabled(live) for _ in range(3)] == [True, True, True] + assert len(calls) == 1 + + _write(path, {"skillRouter": {"hub": {"endpoint": "https://legacy.test", "enabled": False}}}) + assert skill_hub_enabled(live) is False + assert len(calls) == 2 + + class TestThePlaybookSwitch: """``playbooks.disabled`` has one spelling and one reader, and is read live for the same reason the tool switch is: the next model call, not the next diff --git a/tests/test_config_raven_sections.py b/tests/test_config_raven_sections.py index ede314c356..7629cb7a24 100644 --- a/tests/test_config_raven_sections.py +++ b/tests/test_config_raven_sections.py @@ -56,8 +56,10 @@ def test_skill_router_defaults(self) -> None: assert c.top_k == 2 assert c.rrf_k == 10 # Hub is the remote source (replaces the retired Mass source); - # disabled until an endpoint is set. + # disabled until an endpoint is set, and the switch can turn it off + # while keeping the URL. assert isinstance(c.hub, HubSourceConfig) + assert c.hub.enabled is True assert c.hub.endpoint is None assert c.hub.api_key is None assert c.hub.timeout_s == pytest.approx(2.0) diff --git a/tests/test_config_update.py b/tests/test_config_update.py index 8412482e98..2fd07b6cca 100644 --- a/tests/test_config_update.py +++ b/tests/test_config_update.py @@ -13,8 +13,10 @@ import pytest from raven.config.update import ( + SkillHubSwitch, allow_exec_pattern, initialize_a2a_server, + initialize_skill_hub_endpoint, remove_exec_pattern, reset_cron_config, set_a2a_server_enabled, @@ -24,6 +26,7 @@ set_sandbox_backend, set_sentinel_nudge_quota, set_skill_blocked, + set_skill_hub_enabled, update_cron_config, ) @@ -622,6 +625,289 @@ def test_skill_unblock_removes_from_an_existing_snake_blocklist(cfg_path: Path) assert _read(cfg_path)["skill_forge"]["blocklist"] == ["beta"] +# --------------------------------------------------------------------------- +# initialize_skill_hub_endpoint +# --------------------------------------------------------------------------- + +_HUB_ENDPOINT = "https://skillhub.evermind.ai" + + +def test_skill_hub_endpoint_seeded_into_empty_config(cfg_path: Path) -> None: + assert initialize_skill_hub_endpoint(config_path=cfg_path) == _HUB_ENDPOINT + assert _read(cfg_path) == {"skillForge": {"router": {"hub": {"endpoint": _HUB_ENDPOINT}}}} + + +def test_skill_hub_endpoint_leaves_the_api_key_to_the_user(cfg_path: Path) -> None: + initialize_skill_hub_endpoint(config_path=cfg_path) + hub = _read(cfg_path)["skillForge"]["router"]["hub"] + assert "apiKey" not in hub + assert "api_key" not in hub + + +@pytest.mark.parametrize("existing", [_HUB_ENDPOINT, "https://hub.internal.test", None]) +def test_skill_hub_endpoint_never_overwrites_an_existing_value(cfg_path: Path, existing: str | None) -> None: + """An endpoint the config already names wins -- including an explicit null, + which is how a user keeps the Hub off across a repeat onboard.""" + cfg_path.write_text( + json.dumps({"skillForge": {"router": {"hub": {"endpoint": existing, "apiKey": "k"}}}}), + encoding="utf-8", + ) + before = cfg_path.read_bytes() + assert initialize_skill_hub_endpoint(config_path=cfg_path) is None + assert cfg_path.read_bytes() == before + + +def test_skill_hub_endpoint_respects_existing_snake_casing(cfg_path: Path) -> None: + cfg_path.write_text( + json.dumps({"skill_forge": {"router": {"hub": {"apiKey": "k"}}}}), + encoding="utf-8", + ) + assert initialize_skill_hub_endpoint(config_path=cfg_path) == _HUB_ENDPOINT + data = _read(cfg_path) + # a second block under the other spelling is what makes the file unloadable + assert "skillForge" not in data + assert data["skill_forge"]["router"]["hub"] == {"apiKey": "k", "endpoint": _HUB_ENDPOINT} + + +def test_skill_hub_endpoint_on_a_snake_config_leaves_it_loadable(cfg_path: Path) -> None: + from raven.config.raven import load_raven_config + + cfg_path.write_text( + json.dumps({"skill_forge": {"router": {"hub": {"apiKey": "k"}}}}), + encoding="utf-8", + ) + initialize_skill_hub_endpoint(config_path=cfg_path) + cfg = load_raven_config(cfg_path) + assert cfg.skill_forge.router.hub.endpoint == _HUB_ENDPOINT + assert cfg.skill_forge.router.hub.api_key == "k" + + +@pytest.mark.parametrize("spelling", ["skillForge", "skill_forge"]) +def test_skill_hub_endpoint_preserves_other_skill_settings(cfg_path: Path, spelling: str) -> None: + cfg_path.write_text( + json.dumps({spelling: {"blocklist": ["alpha"], "router": {"topK": 4}}}), + encoding="utf-8", + ) + initialize_skill_hub_endpoint(config_path=cfg_path) + section = _read(cfg_path)[spelling] + assert section["blocklist"] == ["alpha"] + assert section["router"] == {"topK": 4, "hub": {"endpoint": _HUB_ENDPOINT}} + + +@pytest.mark.parametrize("spelling", ["skillForge", "skill_forge"]) +@pytest.mark.parametrize("invalid", [[], 0, 1, "invalid", False]) +def test_skill_hub_endpoint_preserves_an_invalid_section(cfg_path, spelling, invalid) -> None: + cfg_path.write_text(json.dumps({spelling: invalid}), encoding="utf-8") + before = cfg_path.read_bytes() + with pytest.raises(ValueError, match="object or null"): + initialize_skill_hub_endpoint(config_path=cfg_path) + assert cfg_path.read_bytes() == before + + +@pytest.mark.parametrize( + "raw", + [ + {"skillForge": {"router": []}}, + {"skillForge": {"router": 0}}, + {"skillForge": {"router": "invalid"}}, + {"skillForge": {"router": None}}, + {"skillForge": {"router": {"hub": []}}}, + {"skillForge": {"router": {"hub": 0}}}, + {"skillForge": {"router": {"hub": "invalid"}}}, + {"skillForge": {"router": {"hub": None}}}, + ], +) +def test_skill_hub_endpoint_preserves_an_invalid_nested_block(cfg_path: Path, raw) -> None: + """A null nested block is a schema violation, unlike a null top-level one + (which the loader drops), so it is refused rather than repaired.""" + cfg_path.write_text(json.dumps(raw), encoding="utf-8") + before = cfg_path.read_bytes() + with pytest.raises(ValueError, match="must be an object"): + initialize_skill_hub_endpoint(config_path=cfg_path) + assert cfg_path.read_bytes() == before + + +# --------------------------------------------------------------------------- +# set_skill_hub_enabled -- the settings-page switch +# --------------------------------------------------------------------------- + + +def test_skill_hub_switch_on_seeds_the_endpoint(cfg_path: Path) -> None: + change = set_skill_hub_enabled(True, config_path=cfg_path) + assert change.previous is False and change.seeded_endpoint is True + assert _read(cfg_path)["skillForge"]["router"]["hub"] == { + "enabled": True, + "endpoint": _HUB_ENDPOINT, + } + + +def test_skill_hub_switch_on_keeps_a_custom_endpoint(cfg_path: Path) -> None: + cfg_path.write_text( + json.dumps({"skillForge": {"router": {"hub": {"endpoint": "https://hub.internal.test"}}}}), + encoding="utf-8", + ) + change = set_skill_hub_enabled(True, config_path=cfg_path) + assert change.previous is True and change.seeded_endpoint is False + assert _read(cfg_path)["skillForge"]["router"]["hub"] == { + "endpoint": "https://hub.internal.test", + "enabled": True, + } + + +def test_skill_hub_switch_on_replaces_an_explicit_off(cfg_path: Path) -> None: + cfg_path.write_text( + json.dumps({"skillForge": {"router": {"hub": {"endpoint": None, "enabled": False}}}}), + encoding="utf-8", + ) + change = set_skill_hub_enabled(True, config_path=cfg_path) + assert change.previous is False and change.seeded_endpoint is True + assert _read(cfg_path)["skillForge"]["router"]["hub"] == { + "enabled": True, + "endpoint": _HUB_ENDPOINT, + } + + +def test_skill_hub_switch_off_keeps_the_endpoint(cfg_path: Path) -> None: + from raven.config.raven import load_raven_config + + cfg_path.write_text( + json.dumps({"skillForge": {"router": {"hub": {"endpoint": "https://hub.internal.test"}}}}), + encoding="utf-8", + ) + change = set_skill_hub_enabled(False, config_path=cfg_path) + assert change.previous is True and change.seeded_endpoint is False + assert _read(cfg_path)["skillForge"]["router"]["hub"] == { + "endpoint": "https://hub.internal.test", + "enabled": False, + } + assert load_raven_config(cfg_path).skill_forge.router.hub.enabled is False + + +def test_skill_hub_switch_off_seeds_nothing(cfg_path: Path) -> None: + change = set_skill_hub_enabled(False, config_path=cfg_path) + assert change.previous is False and change.seeded_endpoint is False + assert _read(cfg_path) == {"skillForge": {"router": {"hub": {"enabled": False}}}} + + +def test_the_switch_off_survives_a_repeat_onboard(cfg_path: Path) -> None: + set_skill_hub_enabled(False, config_path=cfg_path) + initialize_skill_hub_endpoint(config_path=cfg_path) + hub = _read(cfg_path)["skillForge"]["router"]["hub"] + assert hub == {"enabled": False, "endpoint": _HUB_ENDPOINT} + + +def test_skill_hub_switch_respects_existing_snake_casing(cfg_path: Path) -> None: + cfg_path.write_text( + json.dumps({"skill_forge": {"router": {"hub": {"endpoint": "https://hub.internal.test"}}}}), + encoding="utf-8", + ) + set_skill_hub_enabled(False, config_path=cfg_path) + data = _read(cfg_path) + assert "skillForge" not in data + assert data["skill_forge"]["router"]["hub"] == { + "endpoint": "https://hub.internal.test", + "enabled": False, + } + + +def test_skill_hub_switch_refuses_an_invalid_section(cfg_path: Path) -> None: + cfg_path.write_text(json.dumps({"skillForge": []}), encoding="utf-8") + before = cfg_path.read_bytes() + with pytest.raises(ValueError, match="object or null"): + set_skill_hub_enabled(True, config_path=cfg_path) + assert cfg_path.read_bytes() == before + + +@pytest.mark.parametrize( + ("written", "previous"), + [(False, False), (0, False), ("false", False), (True, True), (1, True), ("true", True)], +) +def test_skill_hub_switch_previous_reads_like_the_loader(cfg_path: Path, written, previous: bool) -> None: + """The reply and the live reader agree with the schema on a hand-written + value, rather than with Python truthiness.""" + cfg_path.write_text( + json.dumps({"skillForge": {"router": {"hub": {"endpoint": "https://hub.internal.test", "enabled": written}}}}), + encoding="utf-8", + ) + + change = set_skill_hub_enabled(False, config_path=cfg_path) + + assert change.previous is previous + + +@pytest.mark.parametrize("legacy", ["skillRouter", "skill_router"]) +@pytest.mark.parametrize("enabled", [True, False]) +def test_skill_hub_switch_writes_the_legacy_router_the_loader_reads(cfg_path: Path, legacy: str, enabled: bool) -> None: + """A top-level router block is nested under ``skillForge`` by the loader + whenever that block has no router of its own. A switch write that created + one would hide the legacy block, endpoint and key included, behind the + shared URL on the next reload.""" + from raven.config.live import LiveConfig, skill_hub_enabled + from raven.config.raven import load_raven_config + + cfg_path.write_text( + json.dumps({legacy: {"hub": {"endpoint": "https://hub.internal.test", "apiKey": "k"}}}), + encoding="utf-8", + ) + + change = set_skill_hub_enabled(enabled, config_path=cfg_path) + + assert change == SkillHubSwitch(previous=True, seeded_endpoint=False) + data = _read(cfg_path) + assert "skillForge" not in data + assert data[legacy]["hub"] == {"endpoint": "https://hub.internal.test", "apiKey": "k", "enabled": enabled} + hub = load_raven_config(cfg_path).skill_forge.router.hub + assert (hub.endpoint, hub.api_key, hub.enabled) == ("https://hub.internal.test", "k", enabled) + assert skill_hub_enabled(LiveConfig(cfg_path)) is enabled + + +def test_skill_hub_switch_ignores_a_legacy_router_the_loader_ignores(cfg_path: Path) -> None: + """An explicit ``skillForge.router`` wins over the legacy block, so that is + the one the switch writes.""" + cfg_path.write_text( + json.dumps( + { + "skillRouter": {"hub": {"endpoint": "https://legacy.test"}}, + "skillForge": {"router": {"topK": 4}}, + } + ), + encoding="utf-8", + ) + + change = set_skill_hub_enabled(True, config_path=cfg_path) + + assert change == SkillHubSwitch(previous=False, seeded_endpoint=True) + data = _read(cfg_path) + assert data["skillRouter"] == {"hub": {"endpoint": "https://legacy.test"}} + assert data["skillForge"]["router"] == {"topK": 4, "hub": {"enabled": True, "endpoint": _HUB_ENDPOINT}} + + +def test_skill_hub_endpoint_keeps_a_legacy_router_endpoint(cfg_path: Path) -> None: + cfg_path.write_text( + json.dumps({"skillRouter": {"hub": {"endpoint": "https://hub.internal.test"}}}), encoding="utf-8" + ) + before = cfg_path.read_bytes() + + assert initialize_skill_hub_endpoint(config_path=cfg_path) is None + assert cfg_path.read_bytes() == before + + +def test_skill_hub_switch_previous_counts_a_retired_hub_key_like_the_loader(cfg_path: Path) -> None: + """``prefetchBodies`` is dropped by the loader in memory; the Hub it sits in + was on, and turning it off from the page has to report that.""" + from raven.config.live import LiveConfig, skill_hub_enabled + + cfg_path.write_text( + json.dumps({"skillForge": {"router": {"hub": {"endpoint": _HUB_ENDPOINT, "prefetchBodies": 1}}}}), + encoding="utf-8", + ) + + assert set_skill_hub_enabled(False, config_path=cfg_path).previous is True + assert skill_hub_enabled(LiveConfig(cfg_path)) is False + assert set_skill_hub_enabled(True, config_path=cfg_path).previous is False + assert skill_hub_enabled(LiveConfig(cfg_path)) is True + + class TestTheWidthGateIsEverosOnly: """The 1024-width requirement is the memory index's, so it applies only where an EverOS backend will read the pin: the plugin installed and the diff --git a/tests/test_context_engine_factory.py b/tests/test_context_engine_factory.py index 3b8f316f85..430e6c2564 100644 --- a/tests/test_context_engine_factory.py +++ b/tests/test_context_engine_factory.py @@ -226,6 +226,24 @@ def test_hub_source_present_when_endpoint_set(self, tmp_path: Path) -> None: types, _ = _router_sources(_build_engine(tmp_path, backend=_FakeBackend(), hub_endpoint="http://hub.test")) assert HubSkillSource in types + def test_hub_source_built_while_switched_off(self, tmp_path: Path, monkeypatch) -> None: + """The source exists while the switch is off -- so turning it back on + needs no loop rebuild -- and the live reader is what gates the call.""" + _point_live_config( + tmp_path, + monkeypatch, + {"skillForge": {"router": {"hub": {"endpoint": "http://hub.test", "enabled": False}}}}, + ) + + types, sources = _router_sources( + _build_engine(tmp_path, backend=_FakeBackend(), hub_endpoint="http://hub.test") + ) + + assert HubSkillSource in types + hub = next(source for source in sources if isinstance(source, HubSkillSource)) + assert hub._enabled_reader is not None + assert hub._enabled_reader() is False + def test_rrf_k_forwarded_from_config(self, tmp_path: Path) -> None: engine = _build_engine(tmp_path, backend=_FakeBackend(), rrf_k=25) skills = next(b for b in engine._builders if isinstance(b, SkillsSegmentBuilder)) diff --git a/tests/test_context_scent.py b/tests/test_context_scent.py index 2451d77991..c9674e590a 100644 --- a/tests/test_context_scent.py +++ b/tests/test_context_scent.py @@ -238,6 +238,18 @@ def test_find_skill_description_matches_what_is_wired(): assert "locally installed" in local_only.description +def test_find_skill_description_follows_the_hub_switch(): + """The client is built while the switch is off, so being wired is not + being searched: the library sentence follows the switch, per render.""" + state = {"on": False} + tool = FindSkillTool(lambda: None, hub_wired=True, enabled_reader=lambda: state["on"]) + + assert "110k" not in tool.to_schema()["function"]["description"] + assert "locally installed" in tool.description + state["on"] = True + assert "110k" in tool.to_schema()["function"]["description"] + + async def test_find_skill_enforces_blocklist_and_safety_bar(): hits = [_Hit("hub/blocked-one", "x"), _Hit("hub/low-safety", "y"), _Hit("hub/clean", "z")] hits[1].meta["score_safety"] = 0.1 diff --git a/tests/test_raven_config_tool.py b/tests/test_raven_config_tool.py index b07cff2fc0..054f64d8e2 100644 --- a/tests/test_raven_config_tool.py +++ b/tests/test_raven_config_tool.py @@ -95,6 +95,57 @@ async def test_get_reports_values_defaults_and_hides_secrets(config_file): assert json.loads(section)["providers.openrouter.apiKey"] == "set" +@pytest.mark.asyncio +async def test_the_skill_hub_switch_reads_with_what_is_in_effect(config_file): + """``enabled`` defaults to true, but the source runs only with an endpoint: + the flag alone tells the model the Hub is searched when it is not.""" + tool = RavenConfigTool() + path = "skillForge.router.hub.enabled" + + assert json.loads(await _run(tool, action="get", path=path)) == {path: {"default": True, "in_effect": False}} + + config_file.write_text(json.dumps({"skillForge": {"router": {"hub": {"endpoint": None, "enabled": True}}}})) + assert json.loads(await _run(tool, action="get", path=path)) == {path: {"value": True, "in_effect": False}} + + config_file.write_text(json.dumps({"skillRouter": {"hub": {"endpoint": "https://hub.internal.test"}}})) + assert json.loads(await _run(tool, action="get", path=path)) == {path: {"default": True, "in_effect": True}} + assert f" {path} = true (default), in effect: true [bool," in await _run(tool, action="describe") + config_file.write_text(json.dumps({"skillForge": {"router": {"hub": {"enabled": True}}}})) + assert f" {path} = true, in effect: false [bool," in await _run(tool, action="describe") + + +@pytest.mark.asyncio +async def test_the_skill_hub_switch_is_not_reported_as_waiting_for_a_reload(config_file): + """Switching the source off stops the next search and switching it on is + live too while the endpoint is present, so the write does not owe a reload; + only a first enable that had to add the endpoint can wait, and the settings + service says so in that write's own answer.""" + calls = Calls({"settings.set": {"applied": True, "previous": True}}) + tool = RavenConfigTool() + tool.set_rpc_caller(calls) + + reply = await _run(tool, action="set", path="skillForge.router.hub.enabled", value="false") + + assert "at once" in reply + assert "Pending" not in reply + assert _pending(await _run(tool, action="describe")) is None + + +@pytest.mark.asyncio +async def test_a_settings_write_that_waits_for_a_reload_reaches_the_model(config_file): + """The switch-on that had to seed the endpoint cannot be searched with + until a reload. That cost arrives as the settings service's ``warning``; a + tool that drops it tells the model a change is already serving.""" + warning = "Saved. Skill Hub search starts after Raven reloads." + calls = Calls({"settings.set": {"applied": True, "previous": False, "warning": warning}}) + tool = RavenConfigTool() + tool.set_rpc_caller(calls) + + reply = await _run(tool, action="set", path="skillForge.router.hub.enabled", value="true") + + assert warning in reply + + @pytest.mark.asyncio async def test_a_section_or_instance_read_expands_to_what_is_configured(config_file): """A wildcard entry is read once per configured instance; an empty answer reads as "none configured".""" diff --git a/tests/test_rpc_config.py b/tests/test_rpc_config.py index aeb0b22140..7ad72e9250 100644 --- a/tests/test_rpc_config.py +++ b/tests/test_rpc_config.py @@ -941,6 +941,27 @@ async def test_language_rejects_a_value_neither_front_end_can_render(fake_home: await config_set({"key": "language", "value": "fr"}) +async def test_a_language_write_moves_the_text_the_server_composes(fake_home: Path) -> None: + """The web picker and the onboarding wizard persist through ``config.set``, + and the sentences Raven composes itself (the Skill Hub notice) are rendered + in the process language. Only ``settings.set language`` moved it, so a + reader who picked Chinese there still got the notice in English. Both + entries now leave the file and the process answering alike, and an unset + returns to the default.""" + from raven import i18n + from raven.rpc.methods.config import config_set, config_unset + + i18n.set_language("en") + try: + await config_set({"key": "language", "value": "zh"}) + assert i18n.current_language() == "zh" + + await config_unset({"key": "language"}) + assert i18n.current_language() == "en" + finally: + i18n.set_language("en") + + # ---------------------------------------------------------------------------- # The write must leave a config that still starts # diff --git a/tests/test_rpc_settings.py b/tests/test_rpc_settings.py index a06d097355..854c66f73a 100644 --- a/tests/test_rpc_settings.py +++ b/tests/test_rpc_settings.py @@ -1276,6 +1276,279 @@ async def test_settings_set_blocklist_preserves_invalid_sections(cfg, spelling, assert cfg.read_bytes() == before +@pytest.mark.parametrize( + ("payload", "on"), + [ + ({}, False), + ({"skillForge": {"router": {"hub": {"endpoint": "https://hub.internal.test"}}}}, True), + ({"skillForge": {"router": {"hub": {"endpoint": None}}}}, False), + ({"skillForge": {"router": {"hub": {"endpoint": "https://hub.internal.test", "enabled": "y"}}}}, True), + ({"skillForge": {"router": {"hub": {"endpoint": "https://hub.internal.test", "enabled": " true "}}}}, False), + ({"skillRouter": {"hub": {"endpoint": "https://hub.internal.test", "prefetchBodies": 1}}}, True), + ], +) +async def test_settings_get_reports_the_hub_switch_as_the_runtime_reads_it(cfg, payload, on): + """The page draws the Skill Hub switch from this answer instead of from the + raw file: the loader migrates legacy shapes in memory and parses lax + booleans by its own rules, and a page-side copy of either drifts.""" + from raven.config.live import LiveConfig, skill_hub_enabled + + cfg.write_text(json.dumps(payload), encoding="utf-8") + + result = await rpc_console.settings_get({}) + + assert result["skill_hub_on"] is on + assert skill_hub_enabled(LiveConfig(cfg)) is on + + +class _ServingLoop: + """The one attribute the reload notice reads off the serving loop.""" + + def __init__(self, client: object | None) -> None: + self._skill_hub_client = client + + +async def test_settings_set_hub_switch_on_seeds_the_endpoint(cfg): + from raven.config.raven import load_raven_config + + result = await rpc_console.settings_set( + {"key": "skillForge.router.hub.enabled", "value": True}, + agent_loop_factory=lambda: _ServingLoop(None), + ) + + # The serving loop was built without an endpoint, so it has no Hub client + # to switch on until a reload. Off is live. + assert result == { + "applied": True, + "previous": False, + "warning": "Saved. Skill Hub search starts after Raven reloads.", + } + assert _read(cfg)["skillForge"]["router"]["hub"] == { + "enabled": True, + "endpoint": "https://skillhub.evermind.ai", + } + assert load_raven_config(config_path=cfg).skill_forge.router.hub.enabled is True + + +async def test_settings_set_hub_switch_on_with_an_endpoint_needs_no_reload(cfg): + cfg.write_text( + json.dumps({"skillForge": {"router": {"hub": {"endpoint": "https://hub.internal.test"}}}}), + encoding="utf-8", + ) + + result = await rpc_console.settings_set({"key": "skillForge.router.hub.enabled", "value": True}) + + assert result == {"applied": True, "previous": True} + assert _read(cfg)["skillForge"]["router"]["hub"] == { + "endpoint": "https://hub.internal.test", + "enabled": True, + } + + +async def test_settings_set_hub_switch_off_keeps_the_url(cfg): + cfg.write_text( + json.dumps({"skillForge": {"router": {"hub": {"endpoint": "https://hub.internal.test"}}}}), + encoding="utf-8", + ) + + result = await rpc_console.settings_set({"key": "skillForge.router.hub.enabled", "value": False}) + + assert result == {"applied": True, "previous": True} + assert _read(cfg)["skillForge"]["router"]["hub"] == { + "endpoint": "https://hub.internal.test", + "enabled": False, + } + + +async def test_settings_set_hub_init_seeds_without_re_enabling(cfg): + """The web wizard's finish. It must match the CLI onboarding: seed the + endpoint, keep an explicit off, and keep a custom endpoint.""" + cfg.write_text( + json.dumps({"skillForge": {"router": {"hub": {"enabled": False}}}}), + encoding="utf-8", + ) + + result = await rpc_console.settings_set( + {"key": "skillForge.router.hub.init", "value": True}, + agent_loop_factory=lambda: _ServingLoop(None), + ) + + # The source stays off, so nothing waits for a reload. + assert result == {"applied": True, "previous": None} + assert _read(cfg)["skillForge"]["router"]["hub"] == { + "enabled": False, + "endpoint": "https://skillhub.evermind.ai", + } + + await rpc_console.settings_set({"key": "skillForge.router.hub.init", "value": True}) + assert _read(cfg)["skillForge"]["router"]["hub"] == { + "enabled": False, + "endpoint": "https://skillhub.evermind.ai", + } + + +@pytest.mark.parametrize( + ("serving", "warned"), + [(None, False), (_ServingLoop(None), True), (_ServingLoop(object()), False)], + ids=["no loop yet", "loop without a client", "loop with a client"], +) +async def test_the_reload_notice_follows_the_serving_loop(cfg, serving, warned): + """The notice answers "can the loop serving now search?", not "did this + write seed the endpoint?": with no loop yet the first one is built from + this file, and a loop that started with an endpoint switches live.""" + result = await rpc_console.settings_set( + {"key": "skillForge.router.hub.enabled", "value": True}, agent_loop_factory=lambda: serving + ) + + assert ("warning" in result) is warned + got = await rpc_console.settings_get({}, agent_loop_factory=lambda: serving) + assert (got["skill_hub_on"], got["skill_hub_awaiting_reload"]) == (True, warned) + + +async def test_the_reload_notice_repeats_while_the_loop_still_cannot_search(cfg): + """A second switch-on finds the endpoint already written, and the loop is + still the one built without it: the wait has not ended, so neither has the + notice.""" + serving = _ServingLoop(None) + set_to = [ + await rpc_console.settings_set( + {"key": "skillForge.router.hub.enabled", "value": value}, agent_loop_factory=lambda: serving + ) + for value in (True, False, True) + ] + + assert [r.get("warning") for r in set_to] == [ + "Saved. Skill Hub search starts after Raven reloads.", + None, + "Saved. Skill Hub search starts after Raven reloads.", + ] + off = await rpc_console.settings_get({}, agent_loop_factory=lambda: serving) + assert off["skill_hub_awaiting_reload"] is True + + +_HUB_RELOAD = "Saved. Skill Hub search starts after Raven reloads." +_HUB_NOTICE = ( + "Skill Hub search is on: when Raven looks for a skill, it sends text from your conversation " + "to EverMind Skill Hub. You can turn it off in Settings > Skills." +) +_HUB_NOTICE_RELOAD = ( + "Skill Hub search is on and starts after Raven reloads: when Raven looks for a skill, it sends " + "text from your conversation to EverMind Skill Hub. You can turn it off in Settings > Skills." +) + + +@pytest.mark.parametrize( + ("serving", "notice"), + [(None, _HUB_NOTICE), (_ServingLoop(None), _HUB_NOTICE_RELOAD), (_ServingLoop(object()), _HUB_NOTICE)], + ids=["no loop yet", "loop without a client", "loop with a client"], +) +async def test_a_wizard_finish_that_turns_skill_hub_search_on_says_what_it_sends(cfg, serving, notice): + """Finishing the wizard is the opt-in, and the reader has not been told + what it sends anywhere else on the way: the answer says it, and how to stop.""" + result = await rpc_console.settings_set( + {"key": "skillForge.router.hub.init", "value": True}, agent_loop_factory=lambda: serving + ) + + assert result == {"applied": True, "previous": None, "warning": notice} + + +@pytest.mark.parametrize( + ("serving", "warning"), + [(None, None), (_ServingLoop(None), _HUB_RELOAD), (_ServingLoop(object()), None)], + ids=["no loop yet", "loop without a client", "loop with a client"], +) +async def test_a_wizard_finish_that_turned_nothing_on_reports_only_the_wait(cfg, serving, warning): + cfg.write_text( + json.dumps({"skillForge": {"router": {"hub": {"endpoint": "https://hub.internal.test"}}}}), + encoding="utf-8", + ) + + result = await rpc_console.settings_set( + {"key": "skillForge.router.hub.init", "value": True}, agent_loop_factory=lambda: serving + ) + + assert result.get("warning") == warning + + +async def test_the_skill_hub_notices_follow_the_reply_language(cfg, monkeypatch): + from raven import i18n + + monkeypatch.setattr(i18n, "_language", "zh") + serving = _ServingLoop(None) + + wizard = await rpc_console.settings_set( + {"key": "skillForge.router.hub.init", "value": True}, agent_loop_factory=lambda: serving + ) + switch = await rpc_console.settings_set( + {"key": "skillForge.router.hub.enabled", "value": True}, agent_loop_factory=lambda: serving + ) + + assert wizard["warning"] == i18n.t_in("zh", _HUB_NOTICE_RELOAD) != _HUB_NOTICE_RELOAD + assert switch["warning"] == i18n.t_in("zh", _HUB_RELOAD) != _HUB_RELOAD + assert i18n.t_in("zh", _HUB_NOTICE) != _HUB_NOTICE + + +async def test_a_loop_that_failed_to_build_leaves_the_settings_page_working(cfg): + """The stack's factory raises the build error when the loop could not be + built, which is when a reader most needs this page. Nothing is serving, so + nothing waits for a reload.""" + + def broken(): + raise RuntimeError("no model configured") + + result = await rpc_console.settings_set( + {"key": "skillForge.router.hub.enabled", "value": True}, agent_loop_factory=broken + ) + got = await rpc_console.settings_get({}, agent_loop_factory=broken) + + assert result == {"applied": True, "previous": False} + assert (got["skill_hub_on"], got["skill_hub_awaiting_reload"]) == (True, False) + + +async def test_settings_get_awaits_no_reload_while_the_source_is_off(cfg): + cfg.write_text( + json.dumps({"skillForge": {"router": {"hub": {"endpoint": "https://hub.internal.test", "enabled": False}}}}), + encoding="utf-8", + ) + + got = await rpc_console.settings_get({}, agent_loop_factory=lambda: _ServingLoop(None)) + + assert (got["skill_hub_on"], got["skill_hub_awaiting_reload"]) == (False, False) + + +async def test_settings_set_hub_init_keeps_a_custom_endpoint(cfg): + cfg.write_text( + json.dumps({"skillForge": {"router": {"hub": {"endpoint": "https://hub.internal.test"}}}}), + encoding="utf-8", + ) + + await rpc_console.settings_set({"key": "skillForge.router.hub.init", "value": True}) + + assert _read(cfg)["skillForge"]["router"]["hub"] == {"endpoint": "https://hub.internal.test"} + + +async def test_settings_set_hub_init_rejects_other_values(cfg): + before = cfg.read_bytes() + with pytest.raises(ConfigValidationError): + await rpc_console.settings_set({"key": "skillForge.router.hub.init", "value": False}) + assert cfg.read_bytes() == before + + +async def test_settings_set_hub_switch_rejects_a_non_bool(cfg): + with pytest.raises(ConfigValidationError): + await rpc_console.settings_set({"key": "skillForge.router.hub.enabled", "value": "on"}) + + +async def test_settings_set_hub_switch_refuses_a_broken_block(cfg): + cfg.write_text(json.dumps({"skillForge": []}), encoding="utf-8") + before = cfg.read_bytes() + + with pytest.raises(ConfigValidationError, match="object or null"): + await rpc_console.settings_set({"key": "skillForge.router.hub.enabled", "value": True}) + + assert cfg.read_bytes() == before + + @pytest.mark.parametrize("snake_active", [False, True]) @pytest.mark.parametrize( "camel,snake,field,previous,value", diff --git a/tests/test_skill_hub_source.py b/tests/test_skill_hub_source.py index e692dc5f24..4804d20c8b 100644 --- a/tests/test_skill_hub_source.py +++ b/tests/test_skill_hub_source.py @@ -242,3 +242,32 @@ def handler(req: httpx.Request) -> httpx.Response: src = HubSkillSource(_client(handler)) hits = await src.search("q", [], k=10) assert all(h.content == "" for h in hits) + + async def test_a_switched_off_source_makes_no_request(self) -> None: + calls: list[httpx.Request] = [] + + def handler(req: httpx.Request) -> httpx.Response: + calls.append(req) + return httpx.Response(200, json=_envelope({"items": [_item("id1", "A")]})) + + src = HubSkillSource(_client(handler), enabled_reader=lambda: False) + + assert await src.search("q", [], k=10) == [] + assert calls == [] + + async def test_the_switch_is_read_per_search(self) -> None: + """The source is built once per loop; the switch is not. Off must stop + the same instance's next query.""" + calls: list[httpx.Request] = [] + on = {"value": True} + + def handler(req: httpx.Request) -> httpx.Response: + calls.append(req) + return httpx.Response(200, json=_envelope({"items": [_item("id1", "A")]})) + + src = HubSkillSource(_client(handler), enabled_reader=lambda: on["value"]) + assert [h.name for h in await src.search("q", [], k=10)] == ["A"] + + on["value"] = False + assert await src.search("q", [], k=10) == [] + assert len(calls) == 1 diff --git a/tests/test_skill_hub_tools.py b/tests/test_skill_hub_tools.py index cfbf68e288..62dc02fe09 100644 --- a/tests/test_skill_hub_tools.py +++ b/tests/test_skill_hub_tools.py @@ -559,3 +559,49 @@ async def test_prompt_tty_confirmation_installs(self, monkeypatch) -> None: ) assert not out.startswith("Error") and not out.startswith("Skill install skipped") assert client.install_calls == ["foo"] + + +class TestTheHubSwitch: + """Off is checked per call, before any request leaves for the Hub; the + local sources are not affected.""" + + async def test_a_hub_read_is_refused_when_the_switch_is_off(self) -> None: + client = _FakeClient(get_result={"skill_md": "x", "name": "Foo"}) + + out = await ReadSkillTool(client=client, enabled_reader=lambda: False).execute(skill_id="hub/foo") + + assert out.startswith("Error") and "turned off" in out + assert client.get_calls == [] + + async def test_the_switch_is_read_per_read(self) -> None: + on = {"value": False} + client = _FakeClient(get_result={"skill_md": "do the thing", "name": "Foo"}) + tool = ReadSkillTool(client=client, enabled_reader=lambda: on["value"]) + + assert "turned off" in await tool.execute(skill_id="hub/foo") + on["value"] = True + out = await tool.execute(skill_id="hub/foo") + + assert "Foo" in out and client.get_calls == ["foo"] + + async def test_a_local_read_still_works_when_the_switch_is_off(self, tmp_path: Path) -> None: + reg = _registry_with(tmp_path, workspace=["x"]) + + out = await ReadSkillTool(registry=reg, enabled_reader=lambda: False).execute(skill_id="local/x") + + assert "body" in out + + async def test_a_hub_install_is_refused_when_the_switch_is_off(self) -> None: + client = _FakeClient(install_result={"slug": "foo", "version": "v1", "skill_md": "x"}) + + out = await UseSkillTool(client=client, enabled_reader=lambda: False).execute(skill_id="hub/foo") + + assert out.startswith("Error") and "turned off" in out + assert client.get_calls == [] and client.install_calls == [] + + async def test_a_local_use_still_works_when_the_switch_is_off(self, tmp_path: Path) -> None: + reg = _registry_with(tmp_path, workspace=["x"]) + + out = await UseSkillTool(registry=reg, enabled_reader=lambda: False).execute(skill_id="local/x") + + assert "body" in out diff --git a/ui-tui/src/i18n/messages.generated.ts b/ui-tui/src/i18n/messages.generated.ts index 7c7f124e2c..7414f9532e 100644 --- a/ui-tui/src/i18n/messages.generated.ts +++ b/ui-tui/src/i18n/messages.generated.ts @@ -2079,6 +2079,8 @@ export const UI_TEXT: Record> = { "sends this provider's API key, and every conversation on it, to the address given", 'gui.confirm.cfg.why.skillForge.autoInstall': "'auto' downloads and installs skills without asking", 'gui.confirm.cfg.why.skillForge.blocklist': 'taking a skill off the list lets it be offered and installed again', + 'gui.confirm.cfg.why.skillForge.router.hub.enabled': + "turning it back on resumes sending the turn's message text to the Skill Hub", 'gui.confirm.cfg.why.subagents.*.description': 'the dispatching model reads it every turn, so it can steer what is sent where', 'gui.confirm.cfg.why.subagents.*.lendKeys': @@ -2578,6 +2580,12 @@ export const UI_TEXT: Record> = { 'gui.settings.skills.add': 'Add', 'gui.settings.skills.add_prompt': 'Help me find a skill on Skill Hub and install it. My requirements are as follows:', + 'gui.settings.skills.hub_card': 'Skill Hub', + 'gui.settings.skills.hub_title': 'Use 110k+ skills from EverMind Skill Hub', + 'gui.settings.skills.hub_sub': + 'While on, Raven sends text from your conversation to the Skill Hub when it looks for a skill.', + 'gui.settings.skills.hub_sub_reload': + 'While on, Raven sends text from your conversation to the Skill Hub when it looks for a skill. Search starts after Raven reloads.', 'gui.settings.skills.builtin': 'Built-in', 'gui.settings.skills.workspace': 'Workspace', 'gui.settings.skills.on_of': '{on} of {total} on', @@ -5464,6 +5472,7 @@ export const UI_TEXT: Record> = { 'gui.confirm.cfg.why.providers.*.apiBase': '会把该提供商的 API 密钥及其上的全部对话发往所给地址', 'gui.confirm.cfg.why.skillForge.autoInstall': "'auto' 会在不询问你的情况下下载并安装技能", 'gui.confirm.cfg.why.skillForge.blocklist': '把技能移出清单后,它可以再次被推荐和安装', + 'gui.confirm.cfg.why.skillForge.router.hub.enabled': '重新打开会把当轮对话内容发送到 Skill Hub', 'gui.confirm.cfg.why.subagents.*.description': '调度模型每一轮都会读到它,因此能左右内容发往何处', 'gui.confirm.cfg.why.subagents.*.lendKeys': '把这些提供商的 Raven 密钥交给智能体使用;它的消耗都计在这把密钥上', 'gui.confirm.cfg.why.tools.connectionAdd': '允许智能体改写所有者的 SSH 配置', @@ -5942,6 +5951,11 @@ export const UI_TEXT: Record> = { 'gui.settings.skills.search': '搜索技能', 'gui.settings.skills.add': '新增', 'gui.settings.skills.add_prompt': '帮我在 Skill Hub 找一个技能并安装,我的要求如下:', + 'gui.settings.skills.hub_card': 'Skill Hub', + 'gui.settings.skills.hub_title': '使用 EverMind Skill Hub 的 11 万+ 技能', + 'gui.settings.skills.hub_sub': '开启时,Raven 查找技能会把对话中的文字发送到 Skill Hub。', + 'gui.settings.skills.hub_sub_reload': + '开启时,Raven 查找技能会把对话中的文字发送到 Skill Hub。Raven 重新加载后才会开始搜索。', 'gui.settings.skills.builtin': '内置', 'gui.settings.skills.workspace': '工作区', 'gui.settings.skills.on_of': '开 {on} / {total}', diff --git a/ui-tui/src/rpc/generated.ts b/ui-tui/src/rpc/generated.ts index 7c5c941126..2c512f9484 100644 --- a/ui-tui/src/rpc/generated.ts +++ b/ui-tui/src/rpc/generated.ts @@ -5520,6 +5520,14 @@ export interface SettingsGetResult { }; config_path: string; raven_version: string; + /** + * Whether conversational Skill Hub retrieval is on, read the way the runtime reads the config: an endpoint is required, and enabled=false turns it off. + */ + skill_hub_on: boolean; + /** + * skill_hub_on, while the agent loop serving now was built without a Hub client: retrieval starts after a reload. + */ + skill_hub_awaiting_reload: boolean; } /** * This interface was referenced by `RavenRpcRoot`'s JSON-Schema diff --git a/ui-web/src/features/onboard/OnboardPage.test.tsx b/ui-web/src/features/onboard/OnboardPage.test.tsx index 2d8bfae0a3..fc7ab1bc01 100644 --- a/ui-web/src/features/onboard/OnboardPage.test.tsx +++ b/ui-web/src/features/onboard/OnboardPage.test.tsx @@ -80,6 +80,7 @@ function install(scan: ImportScan = SCAN_NONE): Harness { } h.source = { providerConfigured: async () => h.configured, + seedSkillHub: async () => null, scan: async () => scan, startImport: async (platforms, tier) => { h.runs.push([platforms, tier]) diff --git a/ui-web/src/features/onboard/source.test.ts b/ui-web/src/features/onboard/source.test.ts new file mode 100644 index 0000000000..1569f8e77d --- /dev/null +++ b/ui-web/src/features/onboard/source.test.ts @@ -0,0 +1,25 @@ +import { expect, it, vi } from 'vitest' + +import { onboardSource } from './source' + +const calls: Array<[string, unknown]> = [] +let warning: string | null = null + +vi.mock('../../rpc/gateway', () => ({ + gateway: () => ({ + call: async (method: string, params: unknown) => { + calls.push([method, params]) + return { applied: true, previous: null, warning } + }, + }), +})) + +it('seeds the Skill Hub endpoint and returns the reload notice the server sends', async () => { + await onboardSource.seedSkillHub() + + expect(calls).toEqual([['settings.set', { key: 'skillForge.router.hub.init', value: true }]]) + expect(await onboardSource.seedSkillHub()).toBeNull() + + warning = 'Saved. Skill Hub search starts after Raven reloads.' + expect(await onboardSource.seedSkillHub()).toBe(warning) +}) diff --git a/ui-web/src/features/onboard/source.ts b/ui-web/src/features/onboard/source.ts index 1cee7c6e7f..7b46bb36f7 100644 --- a/ui-web/src/features/onboard/source.ts +++ b/ui-web/src/features/onboard/source.ts @@ -13,6 +13,10 @@ export const onboardSource: OnboardSource = { try { return (await gateway().call('setup.status', {})).provider_configured !== false } catch { return true } }, + seedSkillHub: async () => { + const r = await gateway().call('settings.set', { key: 'skillForge.router.hub.init', value: true }) + return r.warning ?? null + }, scan: () => gateway().call('import.scan', {}), startImport: (platforms, tier) => gateway().call('import.run', { platforms, tier }), } diff --git a/ui-web/src/features/onboard/store.test.ts b/ui-web/src/features/onboard/store.test.ts index f673458a0e..34cd06f4d5 100644 --- a/ui-web/src/features/onboard/store.test.ts +++ b/ui-web/src/features/onboard/store.test.ts @@ -6,6 +6,9 @@ import * as store from './store' import type { AgentsBody, FoundAgent, ImportScan, OnboardSource, StepBody } from './types' +const { toasts } = vi.hoisted(() => ({ toasts: [] as string[] })) +vi.mock('../../state/toast', () => ({ show: (text: string) => { toasts.push(text) } })) + const body = (done: boolean, found: FoundAgent[] = []): AgentsBody => ({ Body: () => null as never, load: async () => {}, @@ -17,6 +20,7 @@ const body = (done: boolean, found: FoundAgent[] = []): AgentsBody => ({ const source = (scan: ImportScan): OnboardSource => ({ providerConfigured: async () => true, + seedSkillHub: async () => null, scan: async () => scan, startImport: async () => ({ started: true, total: 0, detail: '' }), }) @@ -248,6 +252,7 @@ describe('the tier', () => { setSources({ onboard: { providerConfigured: async () => true, + seedSkillHub: async () => null, scan: async () => READY, startImport: async (platforms, tier) => { runs.push([platforms, tier]); return { started: true, total: 1, detail: '' } }, }, @@ -269,3 +274,40 @@ describe('the tier', () => { expect(runs).toEqual([[['hermes'], 'memory_files']]) }) }) + +describe('the Hub opt-in', () => { + beforeEach(() => { + store._showStepsForTests(store.STEPS) + toasts.length = 0 + }) + + const readyToFinish = async (seedSkillHub: () => Promise): Promise => { + setSources({ onboard: { ...source(READY), seedSkillHub } }) + store.setBodies({ model: body(true) as StepBody, search: body(true) as StepBody, agents: body(true), memory: body(true) }) + store.open() + await Promise.resolve() + await Promise.resolve() + } + + it('seeds the Skill Hub endpoint when the wizard finishes, not on an intermediate next', async () => { + const seed = vi.fn(async () => null) + await readyToFinish(seed) + + store.set({ step: 'model' }) + await store.finish() + expect(seed).not.toHaveBeenCalled() + + store.set({ step: store.visibleSteps().at(-1)! }) + await store.finish() + expect(seed).toHaveBeenCalledTimes(1) + }) + + it('shows the reload notice the server returns for a seeded endpoint', async () => { + await readyToFinish(async () => 'Saved. Skill Hub search starts after Raven reloads.') + + store.set({ step: store.visibleSteps().at(-1)! }) + await store.finish() + + expect(toasts).toEqual(['Saved. Skill Hub search starts after Raven reloads.']) + }) +}) diff --git a/ui-web/src/features/onboard/store.ts b/ui-web/src/features/onboard/store.ts index 0b6b301689..2ac9e87ca4 100644 --- a/ui-web/src/features/onboard/store.ts +++ b/ui-web/src/features/onboard/store.ts @@ -10,6 +10,7 @@ import { pick as pickLang } from '../../state/lang/pick' import { ds } from '../../state/sources' import { makeStore } from '../../state/store' +import { show as toast } from '../../state/toast' import { setupState } from '../model/source' import type { Lang } from '../../state/lang' @@ -206,6 +207,14 @@ export function toggleSync(platform: string): void { export async function finish(): Promise { await settleAfterAgents() if (!isLast(get().step)) { move(1); return } + /* Finishing is the page's opt-in for the Hub source, so seed the endpoint + the way the CLI onboarding does. A refusal here is not the wizard's gate: + the Skills page switch can still turn it on later. The server's notice + says what turning it on sends, and whether search waits for a reload. */ + try { + const warning = await source().seedSkillHub() + if (warning) toast(warning) + } catch { /* optional default */ } if (get().step === 'sync' && syncable().length) { const platforms = Object.entries(get().syncPick).filter(([, on]) => on).map(([id]) => id) set({ busy: true, error: '' }) diff --git a/ui-web/src/features/onboard/types.ts b/ui-web/src/features/onboard/types.ts index fe326388b7..f40178c4a8 100644 --- a/ui-web/src/features/onboard/types.ts +++ b/ui-web/src/features/onboard/types.ts @@ -37,6 +37,10 @@ export interface OnboardSource { /* Whether a provider is configured, re-read when the wizard closes so the page's first-run redirects stop once the reader has set one up. */ providerConfigured(): Promise + /* The wizard's opt-in for conversational Skill Hub retrieval: seeds the + shared endpoint the way the CLI onboarding does. Returns the server's + notice: what turning it on sends, and whether search waits for a reload. */ + seedSkillHub(): Promise scan(): Promise startImport(platforms: string[], tier: ImportTier): Promise } diff --git a/ui-web/src/features/settings/pages/Skills.test.tsx b/ui-web/src/features/settings/pages/Skills.test.tsx index 76cee706dd..b9c49bf366 100644 --- a/ui-web/src/features/settings/pages/Skills.test.tsx +++ b/ui-web/src/features/settings/pages/Skills.test.tsx @@ -5,10 +5,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import * as confirm from '../../../state/confirm' import * as settingsDialog from '../../../state/settings' import { resetSources, setSources } from '../../../state/sources' -import { install, modelSource, mount, source as settingsSource } from '../../../test/settingsHarness' +import { install, modelSource, mount, snap, source as settingsSource } from '../../../test/settingsHarness' import { askInChat } from '../../composer/startTaskWith' import * as store from '../store' -import { stripFrontmatter } from './Skills' +import { blocklist, stripFrontmatter } from './Skills' ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true @@ -31,7 +31,11 @@ describe('skills page', () => { install() await mount('skills') const cards = [...document.querySelectorAll('.settings-card .settings-t')].map((el) => el.textContent) - expect(cards).toEqual(['gui.settings.skills.builtin · 1', 'gui.settings.skills.workspace · gui.settings.skills.on_of {"on":1,"total":2}']) + expect(cards).toEqual([ + 'gui.settings.skills.hub_card', + 'gui.settings.skills.builtin · 1', + 'gui.settings.skills.workspace · gui.settings.skills.on_of {"on":1,"total":2}', + ]) await act(async () => { fireEvent.change(document.getElementById('skq')!, { target: { value: 'database' } }) }) expect(screen.queryByText('git-flow')).toBeNull() expect(screen.getByText('sql-style')).toBeTruthy() @@ -48,6 +52,80 @@ describe('skills page', () => { ]) }) + it('the Skill Hub switch writes the enabled flag and starts off without an endpoint', async () => { + const { calls } = install() + await mount('skills') + const hub = screen.getByRole('switch', { name: 'gui.settings.skills.hub_title' }) + expect(hub.getAttribute('aria-checked')).toBe('false') + await act(async () => { fireEvent.click(hub) }) + expect(calls).toEqual([['set', { key: 'skillForge.router.hub.enabled', value: true }]]) + }) + + it('the Skill Hub switch draws the server answer, not the raw file', async () => { + /* The raw file is no guide to what the runtime does: here it names an + endpoint the server reads as off (a hand-written value the loader + refuses), and below it names none while the loader nests a legacy router + under skillForge and searches it. */ + const off = snap({ skillHub: { on: false, awaitingReload: false } }) + off.raw.skillForge = { blocklist: [], router: { hub: { endpoint: 'https://hub.internal.test', enabled: ' true ' } } } + install(off) + await mount('skills') + expect(screen.getByRole('switch', { name: 'gui.settings.skills.hub_title' }).getAttribute('aria-checked')).toBe('false') + cleanup() + store._resetForTests() + + const on = snap({ skillHub: { on: true, awaitingReload: false } }) + on.raw.skillRouter = { hub: { endpoint: 'https://hub.internal.test' } } + const { calls } = install(on) + await mount('skills') + const hub = screen.getByRole('switch', { name: 'gui.settings.skills.hub_title' }) + expect(hub.getAttribute('aria-checked')).toBe('true') + /* Read as off, the switch would send true on every click and the page + could never turn the source off. */ + await act(async () => { fireEvent.click(hub) }) + expect(calls).toEqual([['set', { key: 'skillForge.router.hub.enabled', value: false }]]) + }) + + it('the Skill Hub row says what it sends, and when the serving loop cannot search yet', async () => { + /* The switch reads on as soon as the endpoint is written, but a loop that + started without one holds no Hub client until a reload; a toast is gone + before the reader looks at the switch again. What the switch sends is + said either way, on or off: it is what the reader is deciding. */ + install(snap({ skillHub: { on: true, awaitingReload: true } })) + await mount('skills') + expect(screen.getByText('gui.settings.skills.hub_sub_reload')).toBeTruthy() + expect(screen.queryByText('gui.settings.skills.hub_sub')).toBeNull() + cleanup() + store._resetForTests() + + install(snap({ skillHub: { on: false, awaitingReload: false } })) + await mount('skills') + expect(screen.getByText('gui.settings.skills.hub_sub')).toBeTruthy() + expect(screen.queryByText('gui.settings.skills.hub_sub_reload')).toBeNull() + }) + + it('reads the blocklist from a snake_case skill_forge block', async () => { + /* A null camel block is dropped by the loader, so the snake one is what + runs; the page has to read the first non-null spelling, not truthiness. */ + expect(blocklist({ skillForge: null, skill_forge: { blocklist: ['git-flow'] } })).toEqual(['git-flow']) + const data = snap() + delete data.raw.skillForge + data.raw.skill_forge = { blocklist: ['git-flow', 'notes'] } + expect(blocklist(data.raw)).toEqual(['git-flow', 'notes']) + const { calls } = install(data) + await mount('skills') + const cards = [...document.querySelectorAll('.settings-card .settings-t')].map((el) => el.textContent) + expect(cards).toEqual([ + 'gui.settings.skills.hub_card', + 'gui.settings.skills.builtin · gui.settings.skills.on_of {"on":0,"total":1}', + 'gui.settings.skills.workspace · gui.settings.skills.on_of {"on":1,"total":2}', + ]) + expect(screen.getByRole('switch', { name: 'git-flow' }).getAttribute('aria-checked')).toBe('false') + /* The write carries the list the page read, not [] beside the real one. */ + await act(async () => { fireEvent.click(screen.getByRole('switch', { name: 'git-flow' })) }) + expect(calls).toEqual([['set', { key: 'skillForge.blocklist', value: ['notes'] }]]) + }) + it('the detail inspects the skill, renders its body, lists its files and opens one on the host', async () => { const { calls } = install() await mount('skills') diff --git a/ui-web/src/features/settings/pages/Skills.tsx b/ui-web/src/features/settings/pages/Skills.tsx index 6fa193f6f3..ab0a311b06 100644 --- a/ui-web/src/features/settings/pages/Skills.tsx +++ b/ui-web/src/features/settings/pages/Skills.tsx @@ -4,16 +4,18 @@ import { t } from '../../../i18n/t' import { md } from '../../../lib/prose' import * as confirm from '../../../state/confirm' -import { Card, Crumb, Empty, GLYPH, Grow, IconBtn, Search, Switch, Tag } from '../Fields' +import { Card, Crumb, Empty, GLYPH, Grow, IconBtn, Row, Search, Switch, Tag } from '../Fields' import { SkillDetailWait } from '../Skeletons' +import { dig } from '../source' import * as store from '../store' import type { SkillDetail, SkillRow } from '../types' import type { JSX } from 'react' +/* The block as the file spells it: `dig` reads `skillForge` or `skill_forge`. */ export function blocklist(raw: Record): string[] { - const forge = raw.skillForge as { blocklist?: string[] } | undefined - return (forge && forge.blocklist) || [] + const block = dig(raw, 'skillForge') as { blocklist?: string[] } | undefined + return (block && block.blocklist) || [] } /* Hub-installed skills live under the workspace whichever route put them @@ -24,6 +26,22 @@ const MAIN_FILE = 'SKILL.md' /* `inspect` answers the SKILL.md path; the uninstall removes its directory. */ const skillDir = (path: unknown): string => String(path || '').replace(/[\\/]SKILL\.md$/, '') +const HUB_SWITCH = 'skillForge.router.hub.enabled' + +/* The switch draws settings.get's answer, not the raw file: the runtime reads + the file through the loader's migrations (a legacy top-level router, retired + keys) and its boolean rules, and a page-side copy of either drifts. */ +function HubCard({ on, awaitingReload }: { on: boolean; awaitingReload: boolean }): JSX.Element { + const label = t('gui.settings.skills.hub_title') + return ( + + + void store.write(HUB_SWITCH, next)} /> + + + ) +} + /* SKILL.md opens with a YAML block the registry reads; the person reads the prose under it. */ export function stripFrontmatter(body: string): string { @@ -69,6 +87,7 @@ function SkillList(): JSX.Element { {t('gui.settings.skills.add')} + {groups.map(([label, rows]) => { const onN = rows.filter((r) => !blocked.has(r.name)).length const count = onN === rows.length ? String(rows.length) : t('gui.settings.skills.on_of', { on: onN, total: rows.length }) diff --git a/ui-web/src/features/settings/providers/Roles.test.tsx b/ui-web/src/features/settings/providers/Roles.test.tsx index 1d03402ed0..f11f7879cf 100644 --- a/ui-web/src/features/settings/providers/Roles.test.tsx +++ b/ui-web/src/features/settings/providers/Roles.test.tsx @@ -99,6 +99,28 @@ describe('model roles', () => { ]) }) + it('reads a keyed role from whichever spelling the config file carries', async () => { + const camel = snap() + ;(camel.raw.skillForge as Record).llmGateModel = 'claude-haiku-4-5' + ;(camel.raw.skillForge as Record).llmGateProvider = 'anthropic' + expect(roleValue(role('gate'), camel)).toEqual({ model: 'claude-haiku-4-5', provider: 'anthropic' }) + + /* settings.get returns the file as written, so the harness's camel block + has to go or it answers for the snake one. */ + const snake = snap() + delete snake.raw.skillForge + snake.raw.skill_forge = { llmGateModel: 'claude-haiku-4-5', llmGateProvider: 'anthropic' } + expect(roleValue(role('gate'), snake)).toEqual({ model: 'claude-haiku-4-5', provider: 'anthropic' }) + + const titled = snap() + titled.raw.session_title = { model: 'claude-sonnet-4-5', provider: 'anthropic' } + expect(roleValue(role('title'), titled)).toEqual({ model: 'claude-sonnet-4-5', provider: 'anthropic' }) + + install(snake) + await mount('model') + expect(pill('gui.settings.roles.gate').textContent).toContain('claude-haiku-4-5') + }) + it('the chat role writes through pickModel and reloads', async () => { const { calls } = install() await mount('model') diff --git a/ui-web/src/features/settings/providers/Roles.tsx b/ui-web/src/features/settings/providers/Roles.tsx index 4d2142a892..d99ec71953 100644 --- a/ui-web/src/features/settings/providers/Roles.tsx +++ b/ui-web/src/features/settings/providers/Roles.tsx @@ -7,6 +7,7 @@ import { t } from '../../../i18n/t' import { openPicker } from '../../model/source' import { statedTags } from '../../model/types' import { Card, Row, Rov, Seg, Stepper } from '../Fields' +import { dig } from '../source' import * as store from '../store' import type { Kind, Offer } from '../../model/types' @@ -73,9 +74,6 @@ const MEDIA_PROVIDER = 'openrouter' const CTX_MIN = 1024 const ITER_RANGE: [number, number] = [1, 200] -const dig = (raw: Record, path: string): unknown => - path.split('.').reduce((o, k) => (o && typeof o === 'object' ? (o as Record)[k] : undefined), raw) - export const disabledTools = (raw: Record): string[] => (dig(raw, 'tools.disabledTools') as string[] | undefined) || [] export interface RoleValue { diff --git a/ui-web/src/features/settings/source.test.ts b/ui-web/src/features/settings/source.test.ts index 18bbc84db2..f82e561430 100644 --- a/ui-web/src/features/settings/source.test.ts +++ b/ui-web/src/features/settings/source.test.ts @@ -138,6 +138,29 @@ describe('settings source', () => { expect(chipWrites).toEqual([]) }) + it('the Skill Hub state is the server answer, not a reading of the raw file', async () => { + /* The raw file names no Hub here, yet the loader nests a legacy top-level + router under skillForge, so the runtime searches it: only the server can + say so, and the snapshot carries its word. */ + const settings = { skillRouter: { hub: { endpoint: 'https://hub.internal.test' } } } + const on = await load({ + 'settings.get': { settings, config_path: '/c.json', skill_hub_on: true, skill_hub_awaiting_reload: true }, + }) + await on.loadSettings() + expect(on.settingsSnapshot().skillHub).toEqual({ on: true, awaitingReload: true }) + + const off = await load({ + 'settings.get': { + settings: { skillForge: { router: { hub: { endpoint: 'https://h.test' } } } }, + config_path: '/c.json', + skill_hub_on: false, + skill_hub_awaiting_reload: false, + }, + }) + await off.loadSettings() + expect(off.settingsSnapshot().skillHub).toEqual({ on: false, awaitingReload: false }) + }) + it('a draft that picked a mode keeps it when the chip is re-read', async () => { /* With no session the read is default-scoped, so its answer is the configured default. Painting that over a staged pick made the chip claim diff --git a/ui-web/src/features/settings/source.ts b/ui-web/src/features/settings/source.ts index 319fceb8a0..c732d39c39 100644 --- a/ui-web/src/features/settings/source.ts +++ b/ui-web/src/features/settings/source.ts @@ -36,12 +36,14 @@ import type { ParamsOf, ResultOf } from '../../rpc/generated' import type { BannerSource } from '../../state/banner' import type { AuthField, ProviderOp, SettingsSnapshot, SettingsSource, SkillDetail } from './types' -/* The config settings.get returned. Keys arrive camelCased - (agents.defaults.reasoningEffort), one level per dot. Handed to the island - inside its snapshot, which is the only reader. */ +/* The config settings.get returned, as the file writes it: a top-level block + may sit in either spelling (`skillForge` or `skill_forge`), and `dig` below + resolves both. One level per dot. Handed to the island inside its snapshot, + which is the only reader. */ let RAW: Record = {} let configPathLive = '~/.raven/config.json' +let skillHub = { on: false, awaitingReload: false } let everosLive: ResultOf<'settings.everos'> | null = null let everosCloudLive: ResultOf<'settings.everosCloud'> | null = null let cloudProbe: Promise | null> = Promise.resolve(null) @@ -138,6 +140,7 @@ export async function loadSettings(): Promise { const r = await gateway().call('settings.get', {}) RAW = (r.settings || {}) as Record configPathLive = r.config_path || configPathLive + skillHub = { on: r.skill_hub_on === true, awaitingReload: r.skill_hub_awaiting_reload === true } drawBanner() const agents = RAW.agents as { defaults?: { model?: string; provider?: string } } | undefined const defaults = (agents && agents.defaults) || {} @@ -167,7 +170,7 @@ export async function loadSettingsWithProviders(): Promise { } export const settingsSnapshot = (): SettingsSnapshot => ({ - raw: RAW, configPath: configPathLive, everos: everosLive, everosCloud: everosCloudLive, + raw: RAW, configPath: configPathLive, skillHub, everos: everosLive, everosCloud: everosCloudLive, // Both default-scoped on purpose: the settings page describes what new // conversations start on, so pairing the default model with the visible // session's provider badged the wrong row whenever the two scopes differ. @@ -406,8 +409,21 @@ export const FETCH_KEYLESS = new Set(['jina', 'keenable']) /* Keenable searches without a key; every other search vendor needs one. */ export const SEARCH_KEYLESS = new Set(['keenable']) -const dig = (raw: Record, path: string): unknown => - path.split('.').reduce((o, k) => (o && typeof o === 'object' ? (o as Record)[k] : undefined), raw) +/* The snake twin pydantic's alias generator accepts for a camelCase name. */ +const toSnake = (name: string): string => name.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`) + +/* Reads a dotted path. The first segment resolves both top-level spellings the + loader accepts: a config file may carry `skillForge` or `skill_forge`, the + loader drops a null block and reads the first non-null spelling, and the + writer keeps the one already there -- so a read takes the camel spelling + unless it is null, then the snake one. Below the top level the path matches + the key exactly. */ +export const dig = (raw: Record, path: string): unknown => { + const [head, ...rest] = path.split('.') + let node: unknown = raw[head!] ?? raw[toSnake(head!)] + for (const key of rest) node = node && typeof node === 'object' ? (node as Record)[key] : undefined + return node +} export const str = (raw: Record, path: string): string => { const v = dig(raw, path) return typeof v === 'string' ? v : '' @@ -456,6 +472,7 @@ export const watchMcp = (onStatus: () => void): (() => void) => gateway().on('mc export function _resetForTests(): void { RAW = {} configPathLive = '~/.raven/config.json' + skillHub = { on: false, awaitingReload: false } everosLive = null everosCloudLive = null cloudProbe = Promise.resolve(null) diff --git a/ui-web/src/features/settings/store.test.ts b/ui-web/src/features/settings/store.test.ts index e3d161f2fb..08a81e1285 100644 --- a/ui-web/src/features/settings/store.test.ts +++ b/ui-web/src/features/settings/store.test.ts @@ -8,7 +8,7 @@ import * as store from './store' import type { SettingsSnapshot, SettingsSource } from './types' const snapOf = (model: string): SettingsSnapshot => ({ - raw: {}, configPath: '', everos: null, providers: [], curProvider: '', model, tools: [], skills: [], mcp: [], + raw: {}, configPath: '', skillHub: { on: false, awaitingReload: false }, everos: null, providers: [], curProvider: '', model, tools: [], skills: [], mcp: [], }) afterEach(() => { diff --git a/ui-web/src/features/settings/store.ts b/ui-web/src/features/settings/store.ts index 4af7d3f2b3..76e5858868 100644 --- a/ui-web/src/features/settings/store.ts +++ b/ui-web/src/features/settings/store.ts @@ -144,7 +144,7 @@ export interface SettingsState { } const emptySnap = (): SettingsSnapshot => ({ - raw: {}, configPath: '~/.raven/config.json', everos: null, everosCloud: null, providers: [], + raw: {}, configPath: '~/.raven/config.json', skillHub: { on: false, awaitingReload: false }, everos: null, everosCloud: null, providers: [], curProvider: '', model: '', tools: [], skills: [], mcp: [], }) diff --git a/ui-web/src/features/settings/types.ts b/ui-web/src/features/settings/types.ts index 57aa7c282c..49445a1672 100644 --- a/ui-web/src/features/settings/types.ts +++ b/ui-web/src/features/settings/types.ts @@ -144,12 +144,17 @@ export type EverosCloudInfo = ResultOf<'settings.everosCloud'> export type OauthStart = ResultOf<'model.oauth_login'> /* Everything the dialog draws from, in one read. `raw` is the config - settings.get returned (camelCased keys, one level per dot). The skill and - MCP rows are `ext.list`'s own, untouched, because the two pages read fields - the inventory's row shapes drop (`always`, `auth`, `credentialed`). */ + settings.get returned, as the file writes it (one level per dot; a + top-level block may be camelCase or snake_case). The skill and MCP rows are + `ext.list`'s own, untouched, because the two pages read fields the + inventory's row shapes drop (`always`, `auth`, `credentialed`). */ export interface SettingsSnapshot { raw: Record configPath: string + /* The Skill Hub source as the runtime reads it, answered by settings.get: + the loader's migrations and boolean rules decide it, not the raw file. + `awaitingReload` is on while the serving loop holds no Hub client. */ + skillHub: { on: boolean; awaitingReload: boolean } everos: EverosInfo | null everosCloud?: EverosCloudInfo | null providers: ProviderRow[] diff --git a/ui-web/src/rpc/fixtures/settings.ts b/ui-web/src/rpc/fixtures/settings.ts index 9f69aa8ea8..f450e7846a 100644 --- a/ui-web/src/rpc/fixtures/settings.ts +++ b/ui-web/src/rpc/fixtures/settings.ts @@ -67,6 +67,9 @@ function seed(ext: ExtFixture): Record { tools: { disabledTools: ext.disabledTools }, plugins: { disabled: ext.disabledPlugins }, permissions: { mode: 'ask' }, + /* An onboarded install: the settings page draws the Skill Hub switch on + and the row's off state is reachable by clicking it. */ + skillForge: { router: { hub: { endpoint: 'https://skillhub.evermind.ai' } } }, } } @@ -131,6 +134,13 @@ function pick(from: Record, key: string): Json | undefined { ), from) } +/* The server's Skill Hub answer for the canvas's own store, which only ever + holds what the page wrote: an endpoint, and `enabled` as a boolean. */ +const hubOn = (config: Record): boolean => { + const endpoint = pick(config, 'skillForge.router.hub.endpoint') + return typeof endpoint === 'string' && endpoint !== '' && pick(config, 'skillForge.router.hub.enabled') !== false +} + /* A ledger with nothing in it. The four `*_missing_calls` counts are part of the shape rather than decoration: they are how the panel says an answer is incomplete, and zero is the truthful value where nothing was spent. */ @@ -187,6 +197,8 @@ export function createSettings(env: FixtureEnv, ext: ExtFixture): SettingsFixtur settings: clone(config), config_path: '~/.raven/config.json', raven_version: '0.1.0', + skill_hub_on: hubOn(config), + skill_hub_awaiting_reload: false, }), 'settings.set': (p) => { put(config, p.key, p.value) diff --git a/ui-web/src/rpc/generated.ts b/ui-web/src/rpc/generated.ts index 96d6194af7..ee0a9d89ab 100644 --- a/ui-web/src/rpc/generated.ts +++ b/ui-web/src/rpc/generated.ts @@ -4264,6 +4264,14 @@ export interface SettingsGetResult { }; config_path: string; raven_version: string; + /** + * Whether conversational Skill Hub retrieval is on, read the way the runtime reads the config: an endpoint is required, and enabled=false turns it off. + */ + skill_hub_on: boolean; + /** + * skill_hub_on, while the agent loop serving now was built without a Hub client: retrieval starts after a reload. + */ + skill_hub_awaiting_reload: boolean; } export interface SettingsSetParams { /** diff --git a/ui-web/src/test/settingsHarness.ts b/ui-web/src/test/settingsHarness.ts index e58ee3d7f6..858a2d0329 100644 --- a/ui-web/src/test/settingsHarness.ts +++ b/ui-web/src/test/settingsHarness.ts @@ -85,6 +85,7 @@ export function snap(over: Partial = {}): SettingsSnapshot { providers: { anthropic: { modelOverlay: { 'claude-opus-4-5': { label: 'Opus', description: 'the big one' } } } }, }, configPath: '/home/me/.raven/config.json', + skillHub: { on: false, awaitingReload: false }, everos: { available: true, owned: true,