mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-14 19:02:22 +02:00
feat(skills): remote-only tag hides skills from local endpoints (#6693)
Skills tagged remote-only in frontmatter are excluded from the injected skill index when the active model's endpoint is local (loopback/private/ tailscale per is_local_endpoint). Big on-demand reference lookups overwhelm small local contexts; local models never see the skill, while remote models and API/UI listings are unaffected. - SkillsManager.index_for gains optional keyword-only local_model flag (None = don't gate, same contract as active_toolsets=None) - _build_system_prompt/_build_base_prompt thread endpoint_url from _build_route_request_state; unknown endpoint fails open - tests: cover hidden-on-local, visible-on-remote/unknown, untagged skills never gated Refs: discussed for the local-model overwhelm problem Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
This commit is contained in:
co-authored by
Alexandre Teixeira
parent
f902be18f7
commit
86b886c611
@@ -674,6 +674,7 @@ class SkillsManager:
|
|||||||
*,
|
*,
|
||||||
active_toolsets: Optional[List[str]] = None,
|
active_toolsets: Optional[List[str]] = None,
|
||||||
platform: Optional[str] = None,
|
platform: Optional[str] = None,
|
||||||
|
local_model: Optional[bool] = None,
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
"""Return the `[{name, description, category, status}]` list the
|
"""Return the `[{name, description, category, status}]` list the
|
||||||
agent sees in its system prompt.
|
agent sees in its system prompt.
|
||||||
@@ -710,6 +711,11 @@ class SkillsManager:
|
|||||||
# Platform gating
|
# Platform gating
|
||||||
if platform and s.get("platforms") and platform not in s["platforms"]:
|
if platform and s.get("platforms") and platform not in s["platforms"]:
|
||||||
continue
|
continue
|
||||||
|
# remote-only tag: hide from local/private-endpoint models. Big
|
||||||
|
# on-demand reference lookups overwhelm small local contexts.
|
||||||
|
# local_model=None (API listings, UI) never gates.
|
||||||
|
if local_model and "remote-only" in (s.get("tags") or []):
|
||||||
|
continue
|
||||||
# requires_toolsets: hide unless every required toolset is active.
|
# requires_toolsets: hide unless every required toolset is active.
|
||||||
# active_toolsets=None means the caller doesn't know the active
|
# active_toolsets=None means the caller doesn't know the active
|
||||||
# set (API listings, chat preface) — don't gate in that case;
|
# set (API listings, chat preface) — don't gate in that case;
|
||||||
|
|||||||
+10
-1
@@ -14423,6 +14423,7 @@ def _build_system_prompt(
|
|||||||
suppress_skills: bool = False,
|
suppress_skills: bool = False,
|
||||||
active_email: Optional[Dict[str, str]] = None,
|
active_email: Optional[Dict[str, str]] = None,
|
||||||
workspace: Optional[str] = None,
|
workspace: Optional[str] = None,
|
||||||
|
endpoint_url: Optional[str] = None,
|
||||||
client_runtime_context: Optional[Dict[str, Any]] = None,
|
client_runtime_context: Optional[Dict[str, Any]] = None,
|
||||||
preserve_conversation: bool = False,
|
preserve_conversation: bool = False,
|
||||||
) -> List[Dict]:
|
) -> List[Dict]:
|
||||||
@@ -14524,6 +14525,7 @@ def _build_system_prompt(
|
|||||||
mcp_disabled_map=mcp_disabled_map, compact=compact, owner=owner,
|
mcp_disabled_map=mcp_disabled_map, compact=compact, owner=owner,
|
||||||
suppress_local_context=suppress_local_context,
|
suppress_local_context=suppress_local_context,
|
||||||
suppress_skills=suppress_skills,
|
suppress_skills=suppress_skills,
|
||||||
|
endpoint_url=endpoint_url,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
agent_prompt, _skill_index_block = _build_base_prompt(
|
agent_prompt, _skill_index_block = _build_base_prompt(
|
||||||
@@ -14536,6 +14538,7 @@ def _build_system_prompt(
|
|||||||
owner=owner,
|
owner=owner,
|
||||||
suppress_local_context=suppress_local_context,
|
suppress_local_context=suppress_local_context,
|
||||||
suppress_skills=suppress_skills,
|
suppress_skills=suppress_skills,
|
||||||
|
endpoint_url=endpoint_url,
|
||||||
)
|
)
|
||||||
if not active_document:
|
if not active_document:
|
||||||
_cached_base_prompt = agent_prompt
|
_cached_base_prompt = agent_prompt
|
||||||
@@ -15214,6 +15217,7 @@ def _build_base_prompt(
|
|||||||
owner: Optional[str] = None,
|
owner: Optional[str] = None,
|
||||||
suppress_local_context: bool = False,
|
suppress_local_context: bool = False,
|
||||||
suppress_skills: bool = False,
|
suppress_skills: bool = False,
|
||||||
|
endpoint_url: Optional[str] = None,
|
||||||
):
|
):
|
||||||
"""Build the agent prompt with only relevant tools included.
|
"""Build the agent prompt with only relevant tools included.
|
||||||
|
|
||||||
@@ -15272,7 +15276,11 @@ def _build_base_prompt(
|
|||||||
from src.constants import DATA_DIR
|
from src.constants import DATA_DIR
|
||||||
_sm = SkillsManager(DATA_DIR)
|
_sm = SkillsManager(DATA_DIR)
|
||||||
active_tools = list(set(TOOL_SECTIONS.keys()) - set(disabled or []))
|
active_tools = list(set(TOOL_SECTIONS.keys()) - set(disabled or []))
|
||||||
skill_idx = _sm.index_for(owner=owner, active_toolsets=active_tools)
|
skill_idx = _sm.index_for(
|
||||||
|
owner=owner,
|
||||||
|
active_toolsets=active_tools,
|
||||||
|
local_model=bool(endpoint_url) and is_local_endpoint(endpoint_url),
|
||||||
|
)
|
||||||
if skill_idx:
|
if skill_idx:
|
||||||
lines = ["## Available skills",
|
lines = ["## Available skills",
|
||||||
"Procedures the assistant should consult before doing domain work. "
|
"Procedures the assistant should consult before doing domain work. "
|
||||||
@@ -24303,6 +24311,7 @@ async def stream_agent_loop(
|
|||||||
active_email=active_email,
|
active_email=active_email,
|
||||||
workspace=workspace,
|
workspace=workspace,
|
||||||
client_runtime_context=client_runtime_context,
|
client_runtime_context=client_runtime_context,
|
||||||
|
endpoint_url=candidate_url,
|
||||||
)
|
)
|
||||||
# The request user turn is authoritative and must never disappear
|
# The request user turn is authoritative and must never disappear
|
||||||
# while rebuilding the route prompt. Some native terminal requests
|
# while rebuilding the route prompt. Some native terminal requests
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
"""index_for() local-model gating: a `remote-only` tag must hide a skill
|
||||||
|
from local/private-endpoint models while staying visible to remote API
|
||||||
|
models and to callers that don't know the endpoint at all.
|
||||||
|
|
||||||
|
Big on-demand reference lookups overwhelm small local contexts, so the
|
||||||
|
skill must vanish from the injected index there. local_model=None (API
|
||||||
|
listings, chat preface) means "don't gate" — same contract as
|
||||||
|
active_toolsets=None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
# ── module-load stubbing (matches other tests in this repo) ──────────
|
||||||
|
for _mod in ("sqlalchemy", "sqlalchemy.orm", "sqlalchemy.ext", "sqlalchemy.ext.declarative"):
|
||||||
|
if _mod not in sys.modules:
|
||||||
|
try:
|
||||||
|
__import__(_mod)
|
||||||
|
except ImportError:
|
||||||
|
sys.modules[_mod] = MagicMock()
|
||||||
|
|
||||||
|
from services.memory.skills import SkillsManager # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def _write_skill_md(skills_root: Path, name: str, *, tags: str = "[]",
|
||||||
|
owner: str = "admin") -> None:
|
||||||
|
skill_dir = skills_root / "general" / name
|
||||||
|
skill_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
fm = "\n".join([
|
||||||
|
"---",
|
||||||
|
f"name: {name}",
|
||||||
|
"description: test skill",
|
||||||
|
"version: 1.0.0",
|
||||||
|
"category: general",
|
||||||
|
f"tags: [{tags}]",
|
||||||
|
"status: published",
|
||||||
|
"confidence: 0.9",
|
||||||
|
"audit_verdict: pass",
|
||||||
|
"source: learned",
|
||||||
|
f"owner: {owner}",
|
||||||
|
"created: 2026-01-01T00:00:00Z",
|
||||||
|
"---",
|
||||||
|
"",
|
||||||
|
"## When to Use",
|
||||||
|
"- test",
|
||||||
|
"",
|
||||||
|
"## Procedure",
|
||||||
|
"1. step 1",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
(skill_dir / "SKILL.md").write_text(fm, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _names(idx):
|
||||||
|
return {s["name"] for s in idx}
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_only_hidden_from_local_model(tmp_path):
|
||||||
|
(tmp_path / "skills").mkdir()
|
||||||
|
_write_skill_md(tmp_path / "skills", "agents-map", tags="reference, remote-only")
|
||||||
|
sm = SkillsManager(str(tmp_path))
|
||||||
|
sm.set_audit("agents-map", "pass", owner="admin")
|
||||||
|
|
||||||
|
assert "agents-map" not in _names(
|
||||||
|
sm.index_for(owner="admin", local_model=True))
|
||||||
|
|
||||||
|
|
||||||
|
def test_remote_only_visible_to_remote_model_and_unknown_endpoint(tmp_path):
|
||||||
|
(tmp_path / "skills").mkdir()
|
||||||
|
_write_skill_md(tmp_path / "skills", "agents-map", tags="reference, remote-only")
|
||||||
|
sm = SkillsManager(str(tmp_path))
|
||||||
|
sm.set_audit("agents-map", "pass", owner="admin")
|
||||||
|
|
||||||
|
assert "agents-map" in _names(sm.index_for(owner="admin", local_model=False))
|
||||||
|
# Unknown endpoint (background callers without a URL) fails open.
|
||||||
|
assert "agents-map" in _names(sm.index_for(owner="admin", local_model=None))
|
||||||
|
assert "agents-map" in _names(sm.index_for(owner="admin"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_untagged_skills_are_never_gated(tmp_path):
|
||||||
|
(tmp_path / "skills").mkdir()
|
||||||
|
_write_skill_md(tmp_path / "skills", "plain-skill", tags="reference")
|
||||||
|
sm = SkillsManager(str(tmp_path))
|
||||||
|
sm.set_audit("plain-skill", "pass", owner="admin")
|
||||||
|
|
||||||
|
assert "plain-skill" in _names(sm.index_for(owner="admin", local_model=True))
|
||||||
|
assert "plain-skill" in _names(sm.index_for(owner="admin", local_model=False))
|
||||||
|
|
||||||
|
|
||||||
|
def test_tag_in_ui_listings_is_not_gated(tmp_path):
|
||||||
|
(tmp_path / "skills").mkdir()
|
||||||
|
_write_skill_md(tmp_path / "skills", "agents-map", tags="reference, remote-only")
|
||||||
|
sm = SkillsManager(str(tmp_path))
|
||||||
|
sm.set_audit("agents-map", "pass", owner="admin")
|
||||||
|
|
||||||
|
# API listings / UI pass no local_model → must still see the skill.
|
||||||
|
assert "agents-map" in _names(sm.index_for(owner="admin", local_model=None))
|
||||||
Reference in New Issue
Block a user