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