From 86b886c611338282af6d4a717246ea4e256f58df Mon Sep 17 00:00:00 2001 From: zai184 Date: Wed, 14 Oct 2026 00:15:21 +0200 Subject: [PATCH] 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> --- services/memory/skills.py | 6 ++ src/agent_loop.py | 11 ++- tests/test_skill_index_local_model_gating.py | 98 ++++++++++++++++++++ 3 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 tests/test_skill_index_local_model_gating.py diff --git a/services/memory/skills.py b/services/memory/skills.py index 5bf583389..2d0ad7063 100644 --- a/services/memory/skills.py +++ b/services/memory/skills.py @@ -674,6 +674,7 @@ class SkillsManager: *, active_toolsets: Optional[List[str]] = None, platform: Optional[str] = None, + local_model: Optional[bool] = None, ) -> List[Dict]: """Return the `[{name, description, category, status}]` list the agent sees in its system prompt. @@ -710,6 +711,11 @@ class SkillsManager: # Platform gating if platform and s.get("platforms") and platform not in s["platforms"]: 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. # active_toolsets=None means the caller doesn't know the active # set (API listings, chat preface) — don't gate in that case; diff --git a/src/agent_loop.py b/src/agent_loop.py index c7d1f179c..c452c6c22 100644 --- a/src/agent_loop.py +++ b/src/agent_loop.py @@ -14423,6 +14423,7 @@ def _build_system_prompt( suppress_skills: bool = False, active_email: Optional[Dict[str, str]] = None, workspace: Optional[str] = None, + endpoint_url: Optional[str] = None, client_runtime_context: Optional[Dict[str, Any]] = None, preserve_conversation: bool = False, ) -> List[Dict]: @@ -14524,6 +14525,7 @@ def _build_system_prompt( mcp_disabled_map=mcp_disabled_map, compact=compact, owner=owner, suppress_local_context=suppress_local_context, suppress_skills=suppress_skills, + endpoint_url=endpoint_url, ) else: agent_prompt, _skill_index_block = _build_base_prompt( @@ -14536,6 +14538,7 @@ def _build_system_prompt( owner=owner, suppress_local_context=suppress_local_context, suppress_skills=suppress_skills, + endpoint_url=endpoint_url, ) if not active_document: _cached_base_prompt = agent_prompt @@ -15214,6 +15217,7 @@ def _build_base_prompt( owner: Optional[str] = None, suppress_local_context: bool = False, suppress_skills: bool = False, + endpoint_url: Optional[str] = None, ): """Build the agent prompt with only relevant tools included. @@ -15272,7 +15276,11 @@ def _build_base_prompt( from src.constants import DATA_DIR _sm = SkillsManager(DATA_DIR) 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: lines = ["## Available skills", "Procedures the assistant should consult before doing domain work. " @@ -24303,6 +24311,7 @@ async def stream_agent_loop( active_email=active_email, workspace=workspace, client_runtime_context=client_runtime_context, + endpoint_url=candidate_url, ) # The request user turn is authoritative and must never disappear # while rebuilding the route prompt. Some native terminal requests diff --git a/tests/test_skill_index_local_model_gating.py b/tests/test_skill_index_local_model_gating.py new file mode 100644 index 000000000..d84a48c2e --- /dev/null +++ b/tests/test_skill_index_local_model_gating.py @@ -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))