From 0de0fcec0e3063d1d4ce3d7eec1ace6c96d972e7 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Wed, 8 Jul 2026 02:03:00 +0200 Subject: [PATCH] Simplify relevant skill recall Search the raw user message when recalling relevant skills instead of the rendered history wrapper. Replace the stopword catalog with structural matching: names use normal terms, tags/triggers use long terms or phrase matches, and descriptions require phrase matches. --- .../message_loop_prompts_after/AGENTS.md | 1 + .../_63_recall_relevant_skills.py | 10 ++-- helpers/skills.py | 15 +++--- helpers/skills.py.dox.md | 1 + tests/test_skills_runtime.py | 21 ++++++++ tests/test_tool_action_contracts.py | 51 +++++++++++++++++++ 6 files changed, 90 insertions(+), 9 deletions(-) diff --git a/extensions/python/message_loop_prompts_after/AGENTS.md b/extensions/python/message_loop_prompts_after/AGENTS.md index cc80e0f34..a03541caf 100644 --- a/extensions/python/message_loop_prompts_after/AGENTS.md +++ b/extensions/python/message_loop_prompts_after/AGENTS.md @@ -16,6 +16,7 @@ - Keep injected content bounded and clearly attributed. - 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. ## 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 c4b2a45e8..a810cece4 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 @@ -8,9 +8,13 @@ class RecallRelevantSkills(Extension): if not self.agent or loop_data.iteration != 0: return - user_instruction = ( - loop_data.user_message.output_text() if loop_data.user_message else "" - ).strip() + content = loop_data.user_message.content if loop_data.user_message else "" + if isinstance(content, dict): + user_instruction = str(content.get("user_message") or "").strip() + else: + user_instruction = ( + loop_data.user_message.output_text() if loop_data.user_message else "" + ).strip() if len(user_instruction) < 8: return diff --git a/helpers/skills.py b/helpers/skills.py index 2e5d5c90a..1eeaa8504 100644 --- a/helpers/skills.py +++ b/helpers/skills.py @@ -543,11 +543,15 @@ def search_skills( if not q: return [] - raw_terms = [t for t in re.split(r"\s+", q) if t] + raw_terms = re.findall(r"[a-z0-9][a-z0-9_-]*", q) terms = [ t for t in raw_terms - if len(t) >= 3 or any(ch.isdigit() for ch in t) - ] or raw_terms + if len(t) >= 4 or any(ch.isdigit() for ch in t) + ] + long_terms = [ + t for t in raw_terms + if len(t) >= 6 or any(ch.isdigit() for ch in t) + ] candidates = list_skills(agent, include_hidden=include_hidden) scored: List[Tuple[int, Skill]] = [] @@ -568,14 +572,13 @@ def search_skills( score += 4 if any(q in tag for tag in tags): score += 3 - if any(q in trigger for trigger in triggers): + if any(q in trigger or trigger in q for trigger in triggers): score += 8 for term in terms: if term in name: score += 3 - if term in desc: - score += 2 + for term in long_terms: if any(term in tag for tag in tags): score += 1 if any(term in trigger for trigger in triggers): diff --git a/helpers/skills.py.dox.md b/helpers/skills.py.dox.md index 073997044..902caf3fc 100644 --- a/helpers/skills.py.dox.md +++ b/helpers/skills.py.dox.md @@ -58,6 +58,7 @@ - Loaded skill names are chat-wide context data under `CONTEXT_DATA_NAME_LOADED_SKILLS`; legacy agent-local `loaded_skills` lists are migrated into context data and cleared when read. - Loaded skill bodies live in chat history; hiding a skill changes catalog visibility but does not remove the loaded-skill ledger. - `build_active_skills_prompt()` returns empty because selected skills are loaded through history, not prompt protocol. +- `search_skills()` normalizes query words, scores normal terms against skill names, and scores only long terms against tags/triggers; descriptions match only full query phrases so generic prose does not produce irrelevant suggestions. - Invalid `SKILL.md` frontmatter emits a once-per-path scan warning with the skipped skill path/name and a line number when the parser can identify one directly. - Observed side-effect areas: filesystem reads, filesystem deletion, plugin state, settings/state persistence, context data, secret handling. - Imported dependency areas include: `__future__`, `dataclasses`, `helpers`, `os`, `pathlib`, `re`, `typing`. diff --git a/tests/test_skills_runtime.py b/tests/test_skills_runtime.py index a66567bed..8228e4c36 100644 --- a/tests/test_skills_runtime.py +++ b/tests/test_skills_runtime.py @@ -473,6 +473,27 @@ def test_browser_skills_rank_for_browser_trigger_phrases(monkeypatch): assert "browser-form-workflows" in form_results +def test_skill_search_does_not_score_description_terms_alone(monkeypatch): + browser_automation = runtime.Skill( + name="browser-automation", + description=( + "Use for browser automation, screenshots, forms, uploads, " + "and complex tool workflows." + ), + path=Path("/skills/browser-automation"), + skill_md_path=Path("/skills/browser-automation/SKILL.md"), + ) + monkeypatch.setattr(runtime, "list_skills", lambda *args, **kwargs: [browser_automation]) + + rendered_user_message = ( + 'user: {"user_message": "Please reply with exactly OK. Do not use tools.", ' + '"attachments": []}' + ) + + assert runtime.search_skills(rendered_user_message) == [] + assert runtime.search_skills("Open a browser screenshot?", limit=1) == [browser_automation] + + def test_host_computer_use_ranks_before_linux_desktop_for_host_screen_queries(monkeypatch): host_skill = runtime.skill_from_markdown( PROJECT_ROOT / "plugins" / "_a0_connector" / "skills" / "host-computer-use" / "SKILL.md" diff --git a/tests/test_tool_action_contracts.py b/tests/test_tool_action_contracts.py index ed21388cc..c85e83f69 100644 --- a/tests/test_tool_action_contracts.py +++ b/tests/test_tool_action_contracts.py @@ -140,6 +140,8 @@ def _load_skills_tool(monkeypatch, skill_root: Path): skills_stub.set_loaded_skill_names = _set_loaded_skill_names skills_stub.skill_instruction_name = _skill_instruction_name monkeypatch.setitem(sys.modules, "helpers.skills", skills_stub) + import helpers + monkeypatch.setattr(helpers, "skills", skills_stub, raising=False) print_style_stub = types.ModuleType("helpers.print_style") print_style_stub.PrintStyle = lambda *args, **kwargs: types.SimpleNamespace( @@ -213,6 +215,33 @@ def _load_loaded_skills_extension(monkeypatch, skill_root: Path): return importlib.import_module(module_name) +def _load_relevant_skills_extension(monkeypatch, queries: list[str]): + extension_stub = types.ModuleType("helpers.extension") + extension_stub.Extension = _FakeExtension + monkeypatch.setitem(sys.modules, "helpers.extension", extension_stub) + + agent_stub = types.ModuleType("agent") + agent_stub.LoopData = lambda **kwargs: types.SimpleNamespace(**kwargs) + monkeypatch.setitem(sys.modules, "agent", agent_stub) + + skills_stub = types.ModuleType("helpers.skills") + + def _search_skills(query, *args, **kwargs): + queries.append(query) + return [] + + skills_stub.search_skills = _search_skills + monkeypatch.setitem(sys.modules, "helpers.skills", skills_stub) + + import helpers + + monkeypatch.setattr(helpers, "skills", skills_stub, raising=False) + + module_name = "extensions.python.message_loop_prompts_after._63_recall_relevant_skills" + sys.modules.pop(module_name, None) + return importlib.import_module(module_name) + + def _load_computer_use_remote_tool(monkeypatch): _install_tool_stub(monkeypatch) @@ -471,6 +500,28 @@ def test_loaded_skills_extension_keeps_reattachments_under_budget( assert agent.added_tool_results == [] +def test_relevant_skill_recall_uses_raw_user_message(monkeypatch): + queries: list[str] = [] + module = _load_relevant_skills_extension(monkeypatch, queries) + agent = types.SimpleNamespace() + loop_data = types.SimpleNamespace( + iteration=0, + user_message=types.SimpleNamespace( + content={ + "system_message": [], + "user_message": "Open a browser and take a screenshot.", + "attachments": [], + }, + output_text=lambda: 'user: {"user_message": "wrapped"}', + ), + extras_temporary={}, + ) + + asyncio.run(module.RecallRelevantSkills(agent).execute(loop_data=loop_data)) + + assert queries == ["Open a browser and take a screenshot."] + + def test_skills_tool_read_file_action_reads_inside_skill_dir( monkeypatch, tmp_path: Path ):