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