Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion docs-site/docs/skills-and-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
`<workspace>/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.

Expand Down
10 changes: 9 additions & 1 deletion docs-site/docs/skills-and-extensions.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 路径逐轮的重写与门控模型调用,但不意味着所有
Expand All @@ -55,7 +63,7 @@ Bundle 下载有独立的同意设置:
应使用 blocklist。执行前检查脚本;目录安全分数不是安全保证,当前策略也不会仅因缺少
分数就直接拒绝。成功安装记录在 `<workspace>/skills/hub/installs.jsonl`。

网页端设置弹窗的「技能」页只负责开关;页上的「新增」会把你带到对话里,并替你写好半句
网页端设置弹窗的「技能」页负责技能和 Skill Hub 搜索的开关;页上的「新增」会把你带到对话里,并替你写好半句
请求——技能是让 Raven 在对话里安装的,受上面的同意设置约束。

## 记忆与学习边界 { #memory-and-learning-boundaries }
Expand Down
20 changes: 20 additions & 0 deletions i18n/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": "调度模型每一轮都会读到它,因此能左右内容发往何处"
Expand Down Expand Up @@ -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": "内置"
Expand Down
15 changes: 12 additions & 3 deletions raven/agent/loop/wiring.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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"
Expand Down Expand Up @@ -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 ``<workspace>/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 ``<workspace>/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
Expand Down
46 changes: 34 additions & 12 deletions raven/agent/tools/raven_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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}"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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]}."
Expand All @@ -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:
Expand Down
Loading
Loading