diff --git a/extensions/python/message_loop_prompts_after/AGENTS.md b/extensions/python/message_loop_prompts_after/AGENTS.md index d18b1c5a1..1a183d994 100644 --- a/extensions/python/message_loop_prompts_after/AGENTS.md +++ b/extensions/python/message_loop_prompts_after/AGENTS.md @@ -17,6 +17,8 @@ - Preserve ordering where later prompt extras depend on earlier recall or load results. - Do not expose secrets or private files from workdir extras. - Relevant-skill recall should search the raw user message when available, not the rendered history wrapper. +- Relevant-skill hints must not advertise loading when profile policy blocks + `skills_tool`. ## Work Guidance diff --git a/extensions/python/message_loop_prompts_after/_63_recall_relevant_skills.py b/extensions/python/message_loop_prompts_after/_63_recall_relevant_skills.py index a810cece4..ccfd6f42c 100644 --- a/extensions/python/message_loop_prompts_after/_63_recall_relevant_skills.py +++ b/extensions/python/message_loop_prompts_after/_63_recall_relevant_skills.py @@ -1,12 +1,14 @@ from agent import LoopData from helpers.extension import Extension -from helpers import skills as skills_helper +from helpers import skills as skills_helper, tool_policy class RecallRelevantSkills(Extension): async def execute(self, loop_data: LoopData = LoopData(), **kwargs): if not self.agent or loop_data.iteration != 0: return + if not tool_policy.resolve_tool(self.agent, "skills_tool").allowed: + return content = loop_data.user_message.content if loop_data.user_message else "" if isinstance(content, dict): diff --git a/extensions/python/system_prompt/AGENTS.md b/extensions/python/system_prompt/AGENTS.md index 63f8904df..2edb213eb 100644 --- a/extensions/python/system_prompt/AGENTS.md +++ b/extensions/python/system_prompt/AGENTS.md @@ -16,6 +16,8 @@ - Prompt additions must be bounded and compatible with tool-call contracts. - Discover local tool prompts through `helpers.subagents.get_paths` and apply `helpers.tool_policy` before including their text. +- Omit the discoverable-skills catalog when profile policy blocks + `skills_tool`; loaded skill history remains independent. ## Work Guidance diff --git a/extensions/python/system_prompt/_13_skills_prompt.py b/extensions/python/system_prompt/_13_skills_prompt.py index f52b08dec..ba6e6e94d 100644 --- a/extensions/python/system_prompt/_13_skills_prompt.py +++ b/extensions/python/system_prompt/_13_skills_prompt.py @@ -1,7 +1,7 @@ from typing import Any from helpers.extension import Extension, extensible -from helpers import skills as skills_helper +from helpers import skills as skills_helper, tool_policy from agent import Agent, LoopData @@ -22,6 +22,9 @@ class SkillsPrompt(Extension): @extensible async def build_prompt(agent: Agent) -> str: + if not tool_policy.resolve_tool(agent, "skills_tool").allowed: + return "" + available = skills_helper.list_skills(agent=agent) result: list[str] = [] for skill in available: diff --git a/helpers/tool_policy.py b/helpers/tool_policy.py index 1b98a22c0..2e57eabc2 100644 --- a/helpers/tool_policy.py +++ b/helpers/tool_policy.py @@ -92,12 +92,20 @@ def get_tool_catalog(agent: Any) -> list[dict[str, Any]]: tool_id = canonical_mcp_id(qualified) if tool_id in seen: continue + server_name, _, tool_name = qualified.partition(".") seen.add(tool_id) catalog.append( { "id": tool_id, "name": qualified, - "label": str(tool.get("name") or qualified), + "label": " · ".join( + part.replace("_", " ").strip().title() + for part in ( + server_name, + str(tool.get("title") or tool.get("name") or tool_name), + ) + if part + ), "description": str(tool.get("description") or ""), "origin": f"MCP · {str(tool.get('server') or '').strip()}", "available": True, diff --git a/helpers/tool_policy.py.dox.md b/helpers/tool_policy.py.dox.md index 1d5729df1..ef3e8de80 100644 --- a/helpers/tool_policy.py.dox.md +++ b/helpers/tool_policy.py.dox.md @@ -49,6 +49,8 @@ - Catalog descriptions call the supplied agent's prompt loader instead of opening prompt files through a parallel path; the editor agent intentionally keeps its existing raw, no-processor implementation. +- MCP catalog labels include a human-readable server and tool name while + canonical IDs retain the exact transport-qualified spelling. - Unknown policy IDs remain in the catalog as unavailable entries. - Resolution performs no model calls and logs no secrets. diff --git a/plugins/_memory/AGENTS.md b/plugins/_memory/AGENTS.md index 061b4a463..ed8b56d11 100644 --- a/plugins/_memory/AGENTS.md +++ b/plugins/_memory/AGENTS.md @@ -10,7 +10,7 @@ - `helpers/knowledge_import.py` and `helpers/memory_consolidation.py` own import and consolidation behavior. - `tools/` owns memory save/load/delete/forget and behavior adjustment tools. - `api/` and `webui/` own memory dashboard and knowledge reindex/import flows. -- `prompts/`, `default_config.yaml`, and `plugin.yaml` own memory prompts, defaults, and metadata. +- `prompts/`, `default_config.yaml`, and `plugin.yaml` own memory and behavior-tool prompts, defaults, and metadata. ## Local Contracts diff --git a/prompts/agent.system.tool.behaviour.md b/plugins/_memory/prompts/agent.system.tool.behaviour.md similarity index 100% rename from prompts/agent.system.tool.behaviour.md rename to plugins/_memory/prompts/agent.system.tool.behaviour.md diff --git a/tests/test_tool_action_contracts.py b/tests/test_tool_action_contracts.py index 3203c4d45..d5af1c53e 100644 --- a/tests/test_tool_action_contracts.py +++ b/tests/test_tool_action_contracts.py @@ -229,7 +229,9 @@ def _load_loaded_skills_extension(monkeypatch, skill_root: Path): return importlib.import_module(module_name) -def _load_relevant_skills_extension(monkeypatch, queries: list[str]): +def _load_relevant_skills_extension( + monkeypatch, queries: list[str], *, skills_tool_allowed: bool = True +): extension_stub = types.ModuleType("helpers.extension") extension_stub.Extension = _FakeExtension monkeypatch.setitem(sys.modules, "helpers.extension", extension_stub) @@ -247,9 +249,16 @@ def _load_relevant_skills_extension(monkeypatch, queries: list[str]): skills_stub.search_skills = _search_skills monkeypatch.setitem(sys.modules, "helpers.skills", skills_stub) + tool_policy_stub = types.ModuleType("helpers.tool_policy") + tool_policy_stub.resolve_tool = lambda *args, **kwargs: types.SimpleNamespace( + allowed=skills_tool_allowed + ) + monkeypatch.setitem(sys.modules, "helpers.tool_policy", tool_policy_stub) + import helpers monkeypatch.setattr(helpers, "skills", skills_stub, raising=False) + monkeypatch.setattr(helpers, "tool_policy", tool_policy_stub, raising=False) module_name = "extensions.python.message_loop_prompts_after._63_recall_relevant_skills" sys.modules.pop(module_name, None) @@ -556,6 +565,29 @@ def test_relevant_skill_recall_uses_raw_user_message(monkeypatch): assert queries == ["Open a browser and take a screenshot."] +def test_relevant_skill_recall_skips_blocked_skills_tool(monkeypatch): + queries: list[str] = [] + module = _load_relevant_skills_extension( + monkeypatch, queries, skills_tool_allowed=False + ) + loop_data = types.SimpleNamespace( + iteration=0, + user_message=types.SimpleNamespace( + content="Open a browser and take a screenshot.", + output_text=lambda: "Open a browser and take a screenshot.", + ), + extras_temporary={}, + ) + + asyncio.run( + module.RecallRelevantSkills(types.SimpleNamespace()).execute( + loop_data=loop_data + ) + ) + + assert queries == [] + + def test_skills_tool_read_file_action_reads_inside_skill_dir( monkeypatch, tmp_path: Path ): @@ -712,9 +744,10 @@ def test_behaviour_adjustment_normalizes_duplicate_rules(monkeypatch): def test_behaviour_prompts_preserve_exact_rules_and_avoid_promptinclude(): - behaviour_prompt = Path("prompts/agent.system.tool.behaviour.md").read_text( - encoding="utf-8" + behaviour_prompt_path = Path( + "plugins/_memory/prompts/agent.system.tool.behaviour.md" ) + behaviour_prompt = behaviour_prompt_path.read_text(encoding="utf-8") merge_prompt = Path("prompts/behaviour.merge.sys.md").read_text( encoding="utf-8" ) @@ -724,6 +757,7 @@ def test_behaviour_prompts_preserve_exact_rules_and_avoid_promptinclude(): assert "exact-response rules" in behaviour_prompt assert "preserve it verbatim" in behaviour_prompt + assert not Path("prompts/agent.system.tool.behaviour.md").exists() assert "respond exactly with a phrase" in merge_prompt assert "use behaviour_adjustment, not promptinclude files" in promptinclude_prompt diff --git a/tests/test_tool_policy.py b/tests/test_tool_policy.py index ad2740989..d59317538 100644 --- a/tests/test_tool_policy.py +++ b/tests/test_tool_policy.py @@ -7,7 +7,7 @@ from types import SimpleNamespace import pytest -from extensions.python.system_prompt import _11_tools_prompt +from extensions.python.system_prompt import _11_tools_prompt, _13_skills_prompt from helpers import mcp_handler, responses_tools, tool_policy from helpers.errors import RepairableException 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( assert [item["name"] for item in catalog] == ["code_execution_remote", "shell"] +def test_mcp_catalog_labels_include_humanized_server_and_tool( + monkeypatch, tmp_path: Path +) -> None: + class MCPTools: + def get_tools(self): + return [ + { + "deep_wiki.ask_question": { + "name": "ask_question", + "description": "Ask DeepWiki", + "server": "deep_wiki", + } + } + ] + + monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args, **kwargs: []) + monkeypatch.setattr(mcp_handler.MCPConfig, "get_for_agent", lambda agent: MCPTools()) + monkeypatch.setattr( + tool_policy, + "get_policy", + lambda agent: { + "mode": "inherit", + "default": "allow", + "allowed": [], + "blocked": [], + }, + ) + + assert tool_policy.get_tool_catalog(_Agent(tmp_path)) == [ + { + "id": "mcp:deep_wiki:ask_question", + "name": "deep_wiki.ask_question", + "label": "Deep Wiki · Ask Question", + "description": "Ask DeepWiki", + "origin": "MCP · deep_wiki", + "available": True, + } + ] + + +@pytest.mark.asyncio +async def test_skills_catalog_prompt_is_absent_when_skills_tool_is_blocked( + monkeypatch, tmp_path: Path +) -> None: + monkeypatch.setattr(tool_policy.subagents, "get_paths", lambda *args, **kwargs: []) + monkeypatch.setattr( + tool_policy, + "get_policy", + lambda agent: _custom_policy(default="allow", blocked=["local:skills_tool"]), + ) + monkeypatch.setattr( + _13_skills_prompt.skills_helper, + "list_skills", + lambda **kwargs: pytest.fail("blocked skill discovery ran"), + ) + + assert await _13_skills_prompt.build_prompt(_Agent(tmp_path)) == "" + + def test_tool_prompt_description_skips_fenced_examples() -> None: prompt = """### example ~~~json