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:
zai184
2026-10-13 23:15:21 +01:00
committed by GitHub
co-authored by Alexandre Teixeira
parent f902be18f7
commit 86b886c611
3 changed files with 114 additions and 1 deletions
+6
View File
@@ -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;
+10 -1
View File
@@ -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
@@ -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))