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.

Alessandro committed Jul 8, 2026 at 02:03 UTC 0de0fcec0e3063d1d4ce3d7eec1ace6c96d972e7
6 files changed +90 -9
extensions/python/message_loop_prompts_after/AGENTS.md
+1
@@ -16,6 +16,7 @@
16 - Keep injected content bounded and clearly attributed.
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
21 ## Work Guidance
22
extensions/python/message_loop_prompts_after/_63_recall_relevant_skills.py
+7 -3
@@ -8,9 +8,13 @@ class RecallRelevantSkills(Extension):
8 if not self.agent or loop_data.iteration != 0:
9 return
10
11 - user_instruction = (
12 - loop_data.user_message.output_text() if loop_data.user_message else ""
13 - ).strip()
11 + content = loop_data.user_message.content if loop_data.user_message else ""
12 + if isinstance(content, dict):
13 + user_instruction = str(content.get("user_message") or "").strip()
14 + else:
15 + user_instruction = (
16 + loop_data.user_message.output_text() if loop_data.user_message else ""
17 + ).strip()
18 if len(user_instruction) < 8:
19 return
20
helpers/skills.py
+9 -6
@@ -543,11 +543,15 @@ def search_skills(
543 if not q:
544 return []
545
546 - raw_terms = [t for t in re.split(r"\s+", q) if t]
546 + raw_terms = re.findall(r"[a-z0-9][a-z0-9_-]*", q)
547 terms = [
548 t for t in raw_terms
549 - if len(t) >= 3 or any(ch.isdigit() for ch in t)
550 - ] or raw_terms
549 + if len(t) >= 4 or any(ch.isdigit() for ch in t)
550 + ]
551 + long_terms = [
552 + t for t in raw_terms
553 + if len(t) >= 6 or any(ch.isdigit() for ch in t)
554 + ]
555 candidates = list_skills(agent, include_hidden=include_hidden)
556
557 scored: List[Tuple[int, Skill]] = []
@@ -568,14 +572,13 @@ def search_skills(
572 score += 4
573 if any(q in tag for tag in tags):
574 score += 3
571 - if any(q in trigger for trigger in triggers):
575 + if any(q in trigger or trigger in q for trigger in triggers):
576 score += 8
577
578 for term in terms:
579 if term in name:
580 score += 3
577 - if term in desc:
578 - score += 2
581 + for term in long_terms:
582 if any(term in tag for tag in tags):
583 score += 1
584 if any(term in trigger for trigger in triggers):
helpers/skills.py.dox.md
+1
@@ -58,6 +58,7 @@
58 - 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.
59 - Loaded skill bodies live in chat history; hiding a skill changes catalog visibility but does not remove the loaded-skill ledger.
60 - `build_active_skills_prompt()` returns empty because selected skills are loaded through history, not prompt protocol.
61 +- `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.
62 - 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.
63 - Observed side-effect areas: filesystem reads, filesystem deletion, plugin state, settings/state persistence, context data, secret handling.
64 - Imported dependency areas include: `__future__`, `dataclasses`, `helpers`, `os`, `pathlib`, `re`, `typing`.
tests/test_skills_runtime.py
+21
@@ -473,6 +473,27 @@ def test_browser_skills_rank_for_browser_trigger_phrases(monkeypatch):
473 assert "browser-form-workflows" in form_results
474
475
476 +def test_skill_search_does_not_score_description_terms_alone(monkeypatch):
477 + browser_automation = runtime.Skill(
478 + name="browser-automation",
479 + description=(
480 + "Use for browser automation, screenshots, forms, uploads, "
481 + "and complex tool workflows."
482 + ),
483 + path=Path("/skills/browser-automation"),
484 + skill_md_path=Path("/skills/browser-automation/SKILL.md"),
485 + )
486 + monkeypatch.setattr(runtime, "list_skills", lambda *args, **kwargs: [browser_automation])
487 +
488 + rendered_user_message = (
489 + 'user: {"user_message": "Please reply with exactly OK. Do not use tools.", '
490 + '"attachments": []}'
491 + )
492 +
493 + assert runtime.search_skills(rendered_user_message) == []
494 + assert runtime.search_skills("Open a browser screenshot?", limit=1) == [browser_automation]
495 +
496 +
497 def test_host_computer_use_ranks_before_linux_desktop_for_host_screen_queries(monkeypatch):
498 host_skill = runtime.skill_from_markdown(
499 PROJECT_ROOT / "plugins" / "_a0_connector" / "skills" / "host-computer-use" / "SKILL.md"
tests/test_tool_action_contracts.py
+51
@@ -140,6 +140,8 @@ def _load_skills_tool(monkeypatch, skill_root: Path):
140 skills_stub.set_loaded_skill_names = _set_loaded_skill_names
141 skills_stub.skill_instruction_name = _skill_instruction_name
142 monkeypatch.setitem(sys.modules, "helpers.skills", skills_stub)
143 + import helpers
144 + monkeypatch.setattr(helpers, "skills", skills_stub, raising=False)
145
146 print_style_stub = types.ModuleType("helpers.print_style")
147 print_style_stub.PrintStyle = lambda *args, **kwargs: types.SimpleNamespace(
@@ -213,6 +215,33 @@ def _load_loaded_skills_extension(monkeypatch, skill_root: Path):
215 return importlib.import_module(module_name)
216
217
218 +def _load_relevant_skills_extension(monkeypatch, queries: list[str]):
219 + extension_stub = types.ModuleType("helpers.extension")
220 + extension_stub.Extension = _FakeExtension
221 + monkeypatch.setitem(sys.modules, "helpers.extension", extension_stub)
222 +
223 + agent_stub = types.ModuleType("agent")
224 + agent_stub.LoopData = lambda **kwargs: types.SimpleNamespace(**kwargs)
225 + monkeypatch.setitem(sys.modules, "agent", agent_stub)
226 +
227 + skills_stub = types.ModuleType("helpers.skills")
228 +
229 + def _search_skills(query, *args, **kwargs):
230 + queries.append(query)
231 + return []
232 +
233 + skills_stub.search_skills = _search_skills
234 + monkeypatch.setitem(sys.modules, "helpers.skills", skills_stub)
235 +
236 + import helpers
237 +
238 + monkeypatch.setattr(helpers, "skills", skills_stub, raising=False)
239 +
240 + module_name = "extensions.python.message_loop_prompts_after._63_recall_relevant_skills"
241 + sys.modules.pop(module_name, None)
242 + return importlib.import_module(module_name)
243 +
244 +
245 def _load_computer_use_remote_tool(monkeypatch):
246 _install_tool_stub(monkeypatch)
247
@@ -471,6 +500,28 @@ def test_loaded_skills_extension_keeps_reattachments_under_budget(
500 assert agent.added_tool_results == []
501
502
503 +def test_relevant_skill_recall_uses_raw_user_message(monkeypatch):
504 + queries: list[str] = []
505 + module = _load_relevant_skills_extension(monkeypatch, queries)
506 + agent = types.SimpleNamespace()
507 + loop_data = types.SimpleNamespace(
508 + iteration=0,
509 + user_message=types.SimpleNamespace(
510 + content={
511 + "system_message": [],
512 + "user_message": "Open a browser and take a screenshot.",
513 + "attachments": [],
514 + },
515 + output_text=lambda: 'user: {"user_message": "wrapped"}',
516 + ),
517 + extras_temporary={},
518 + )
519 +
520 + asyncio.run(module.RecallRelevantSkills(agent).execute(loop_data=loop_data))
521 +
522 + assert queries == ["Open a browser and take a screenshot."]
523 +
524 +
525 def test_skills_tool_read_file_action_reads_inside_skill_dir(
526 monkeypatch, tmp_path: Path
527 ):