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.
This commit is contained in:
Alessandro 2026-07-08 02:03:00 +02:00
parent dea64ddad0
commit 0de0fcec0e
6 changed files with 90 additions and 9 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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