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.
This commit is contained in:
Alessandro 2026-08-11 19:02:51 +02:00
parent a4f7a1352a
commit dbe87ee184
10 changed files with 120 additions and 8 deletions

View file

@ -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

View file

@ -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):

View file

@ -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

View file

@ -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:

View file

@ -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,

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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