Keep capability discovery aligned with runtime

Humanize MCP catalog labels with their server names while preserving canonical IDs. Suppress skill discovery prompts when skills_tool is unavailable and move the behaviour_adjustment prompt under the Memory plugin so disabling its implementation also removes its instructions.

Alessandro committed Aug 11, 2026 at 19:02 UTC dbe87ee18452fb2a3031f6012674e12ec088ce4c
10 files changed +120 -8
extensions/python/message_loop_prompts_after/AGENTS.md
+2
@@ -17,6 +17,8 @@
17 - Preserve ordering where later prompt extras depend on earlier recall or load results.
18 - Do not expose secrets or private files from workdir extras.
19 - Relevant-skill recall should search the raw user message when available, not the rendered history wrapper.
20 +- Relevant-skill hints must not advertise loading when profile policy blocks
21 + `skills_tool`.
22
23 ## Work Guidance
24
extensions/python/message_loop_prompts_after/_63_recall_relevant_skills.py
+3 -1
@@ -1,12 +1,14 @@
1 from agent import LoopData
2 from helpers.extension import Extension
3 -from helpers import skills as skills_helper
3 +from helpers import skills as skills_helper, tool_policy
4
5
6 class RecallRelevantSkills(Extension):
7 async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
8 if not self.agent or loop_data.iteration != 0:
9 return
10 + if not tool_policy.resolve_tool(self.agent, "skills_tool").allowed:
11 + return
12
13 content = loop_data.user_message.content if loop_data.user_message else ""
14 if isinstance(content, dict):
extensions/python/system_prompt/AGENTS.md
+2
@@ -16,6 +16,8 @@
16 - Prompt additions must be bounded and compatible with tool-call contracts.
17 - Discover local tool prompts through `helpers.subagents.get_paths` and apply
18 `helpers.tool_policy` before including their text.
19 +- Omit the discoverable-skills catalog when profile policy blocks
20 + `skills_tool`; loaded skill history remains independent.
21
22 ## Work Guidance
23
extensions/python/system_prompt/_13_skills_prompt.py
+4 -1
@@ -1,7 +1,7 @@
1 from typing import Any
2
3 from helpers.extension import Extension, extensible
4 -from helpers import skills as skills_helper
4 +from helpers import skills as skills_helper, tool_policy
5 from agent import Agent, LoopData
6
7
@@ -22,6 +22,9 @@ class SkillsPrompt(Extension):
22
23 @extensible
24 async def build_prompt(agent: Agent) -> str:
25 + if not tool_policy.resolve_tool(agent, "skills_tool").allowed:
26 + return ""
27 +
28 available = skills_helper.list_skills(agent=agent)
29 result: list[str] = []
30 for skill in available:
helpers/tool_policy.py
+9 -1
@@ -92,12 +92,20 @@ def get_tool_catalog(agent: Any) -> list[dict[str, Any]]:
92 tool_id = canonical_mcp_id(qualified)
93 if tool_id in seen:
94 continue
95 + server_name, _, tool_name = qualified.partition(".")
96 seen.add(tool_id)
97 catalog.append(
98 {
99 "id": tool_id,
100 "name": qualified,
100 - "label": str(tool.get("name") or qualified),
101 + "label": " · ".join(
102 + part.replace("_", " ").strip().title()
103 + for part in (
104 + server_name,
105 + str(tool.get("title") or tool.get("name") or tool_name),
106 + )
107 + if part
108 + ),
109 "description": str(tool.get("description") or ""),
110 "origin": f"MCP · {str(tool.get('server') or '').strip()}",
111 "available": True,
helpers/tool_policy.py.dox.md
+2
@@ -49,6 +49,8 @@
49 - Catalog descriptions call the supplied agent's prompt loader instead of
50 opening prompt files through a parallel path; the editor agent intentionally
51 keeps its existing raw, no-processor implementation.
52 +- MCP catalog labels include a human-readable server and tool name while
53 + canonical IDs retain the exact transport-qualified spelling.
54 - Unknown policy IDs remain in the catalog as unavailable entries.
55 - Resolution performs no model calls and logs no secrets.
56
plugins/_memory/AGENTS.md
+1 -1
@@ -10,7 +10,7 @@
10 - `helpers/knowledge_import.py` and `helpers/memory_consolidation.py` own import and consolidation behavior.
11 - `tools/` owns memory save/load/delete/forget and behavior adjustment tools.
12 - `api/` and `webui/` own memory dashboard and knowledge reindex/import flows.
13 -- `prompts/`, `default_config.yaml`, and `plugin.yaml` own memory prompts, defaults, and metadata.
13 +- `prompts/`, `default_config.yaml`, and `plugin.yaml` own memory and behavior-tool prompts, defaults, and metadata.
14
15 ## Local Contracts
16
plugins/_memory/prompts/agent.system.tool.behaviour.md renamed
tests/test_tool_action_contracts.py
+37 -3
@@ -229,7 +229,9 @@ def _load_loaded_skills_extension(monkeypatch, skill_root: Path):
229 return importlib.import_module(module_name)
230
231
232 -def _load_relevant_skills_extension(monkeypatch, queries: list[str]):
232 +def _load_relevant_skills_extension(
233 + monkeypatch, queries: list[str], *, skills_tool_allowed: bool = True
234 +):
235 extension_stub = types.ModuleType("helpers.extension")
236 extension_stub.Extension = _FakeExtension
237 monkeypatch.setitem(sys.modules, "helpers.extension", extension_stub)
@@ -247,9 +249,16 @@ def _load_relevant_skills_extension(monkeypatch, queries: list[str]):
249 skills_stub.search_skills = _search_skills
250 monkeypatch.setitem(sys.modules, "helpers.skills", skills_stub)
251
252 + tool_policy_stub = types.ModuleType("helpers.tool_policy")
253 + tool_policy_stub.resolve_tool = lambda *args, **kwargs: types.SimpleNamespace(
254 + allowed=skills_tool_allowed
255 + )
256 + monkeypatch.setitem(sys.modules, "helpers.tool_policy", tool_policy_stub)
257 +
258 import helpers
259
260 monkeypatch.setattr(helpers, "skills", skills_stub, raising=False)
261 + monkeypatch.setattr(helpers, "tool_policy", tool_policy_stub, raising=False)
262
263 module_name = "extensions.python.message_loop_prompts_after._63_recall_relevant_skills"
264 sys.modules.pop(module_name, None)
@@ -556,6 +565,29 @@ def test_relevant_skill_recall_uses_raw_user_message(monkeypatch):
565 assert queries == ["Open a browser and take a screenshot."]
566
567
568 +def test_relevant_skill_recall_skips_blocked_skills_tool(monkeypatch):
569 + queries: list[str] = []
570 + module = _load_relevant_skills_extension(
571 + monkeypatch, queries, skills_tool_allowed=False
572 + )
573 + loop_data = types.SimpleNamespace(
574 + iteration=0,
575 + user_message=types.SimpleNamespace(
576 + content="Open a browser and take a screenshot.",
577 + output_text=lambda: "Open a browser and take a screenshot.",
578 + ),
579 + extras_temporary={},
580 + )
581 +
582 + asyncio.run(
583 + module.RecallRelevantSkills(types.SimpleNamespace()).execute(
584 + loop_data=loop_data
585 + )
586 + )
587 +
588 + assert queries == []
589 +
590 +
591 def test_skills_tool_read_file_action_reads_inside_skill_dir(
592 monkeypatch, tmp_path: Path
593 ):
@@ -712,9 +744,10 @@ def test_behaviour_adjustment_normalizes_duplicate_rules(monkeypatch):
744
745
746 def test_behaviour_prompts_preserve_exact_rules_and_avoid_promptinclude():
715 - behaviour_prompt = Path("prompts/agent.system.tool.behaviour.md").read_text(
716 - encoding="utf-8"
747 + behaviour_prompt_path = Path(
748 + "plugins/_memory/prompts/agent.system.tool.behaviour.md"
749 )
750 + behaviour_prompt = behaviour_prompt_path.read_text(encoding="utf-8")
751 merge_prompt = Path("prompts/behaviour.merge.sys.md").read_text(
752 encoding="utf-8"
753 )
@@ -724,6 +757,7 @@ def test_behaviour_prompts_preserve_exact_rules_and_avoid_promptinclude():
757
758 assert "exact-response rules" in behaviour_prompt
759 assert "preserve it verbatim" in behaviour_prompt
760 + assert not Path("prompts/agent.system.tool.behaviour.md").exists()
761 assert "respond exactly with a phrase" in merge_prompt
762 assert "use behaviour_adjustment, not promptinclude files" in promptinclude_prompt
763
tests/test_tool_policy.py
+60 -1
@@ -7,7 +7,7 @@ from types import SimpleNamespace
7
8 import pytest
9
10 -from extensions.python.system_prompt import _11_tools_prompt
10 +from extensions.python.system_prompt import _11_tools_prompt, _13_skills_prompt
11 from helpers import mcp_handler, responses_tools, tool_policy
12 from helpers.errors import RepairableException
13 from plugins._tool_access.extensions.python.tool_execute_before._10_enforce_tool_policy import (
@@ -260,6 +260,65 @@ def test_catalog_keeps_installed_remote_tools_without_live_connector(
260 assert [item["name"] for item in catalog] == ["code_execution_remote", "shell"]
261
262
263 +def test_mcp_catalog_labels_include_humanized_server_and_tool(
264 + monkeypatch, tmp_path: Path
265 +) -> None:
266 + class MCPTools:
267 + def get_tools(self):
268 + return [
269 + {
270 + "deep_wiki.ask_question": {
271 + "name": "ask_question",
272 + "description": "Ask DeepWiki",
273 + "server": "deep_wiki",
274 + }
275 + }
276 + ]
277 +
278 + monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args, **kwargs: [])
279 + monkeypatch.setattr(mcp_handler.MCPConfig, "get_for_agent", lambda agent: MCPTools())
280 + monkeypatch.setattr(
281 + tool_policy,
282 + "get_policy",
283 + lambda agent: {
284 + "mode": "inherit",
285 + "default": "allow",
286 + "allowed": [],
287 + "blocked": [],
288 + },
289 + )
290 +
291 + assert tool_policy.get_tool_catalog(_Agent(tmp_path)) == [
292 + {
293 + "id": "mcp:deep_wiki:ask_question",
294 + "name": "deep_wiki.ask_question",
295 + "label": "Deep Wiki · Ask Question",
296 + "description": "Ask DeepWiki",
297 + "origin": "MCP · deep_wiki",
298 + "available": True,
299 + }
300 + ]
301 +
302 +
303 +@pytest.mark.asyncio
304 +async def test_skills_catalog_prompt_is_absent_when_skills_tool_is_blocked(
305 + monkeypatch, tmp_path: Path
306 +) -> None:
307 + monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args, **kwargs: [])
308 + monkeypatch.setattr(
309 + tool_policy,
310 + "get_policy",
311 + lambda agent: _custom_policy(default="allow", blocked=["local:skills_tool"]),
312 + )
313 + monkeypatch.setattr(
314 + _13_skills_prompt.skills_helper,
315 + "list_skills",
316 + lambda **kwargs: pytest.fail("blocked skill discovery ran"),
317 + )
318 +
319 + assert await _13_skills_prompt.build_prompt(_Agent(tmp_path)) == ""
320 +
321 +
322 def test_tool_prompt_description_skips_fenced_examples() -> None:
323 prompt = """### example
324 ~~~json