From e249fa4557b577102bc7c9b8638562ecd57a7ead Mon Sep 17 00:00:00 2001 From: mist Date: Tue, 2 Jun 2026 14:32:20 +0300 Subject: [PATCH] Tools: match keyword hints on word boundaries `get_tools_for_query` force-includes whole tool families when the query mentions an intent keyword, but matched with a raw substring test (`kw in ql`). Short hints therefore fired inside unrelated words, bloating the tool set with irrelevant tools: - "fix" matched "prefix" -> document tools - "line" matched "deadline"/"online" -> document tools - "serve" matched "observe"/"reserve" -> cookbook serve tools - "reply" matched "replying" -> all email tools - "unread" matched "unreadable" -> all email tools Match each keyword on word boundaries instead (`re.search(rf"\b{re.escape(kw)}\b", ql)`), the same fix already applied to the keyword matcher in topic_analyzer.py. Genuine intent keywords ("reply to this email", "edit the document", "serve the model") still match. This only removes substring-inside-a-word matches; it does not change whole -word matches (so e.g. an unrelated whole word like "tell" is a separate keyword-choice question, left untouched here). Checks: python -m pytest tests/test_tool_index_keyword_boundaries.py (4 passed; 3 of them fail on the pre-fix substring code), python -m py_compile src/tool_index.py, git diff --check. --- src/tool_index.py | 8 +++- tests/test_tool_index_keyword_boundaries.py | 53 +++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 tests/test_tool_index_keyword_boundaries.py diff --git a/src/tool_index.py b/src/tool_index.py index f8e8fae..fa7ba31 100644 --- a/src/tool_index.py +++ b/src/tool_index.py @@ -431,10 +431,14 @@ class ToolIndex: base = set(always_include or ALWAYS_AVAILABLE) retrieved = self.retrieve(query, k=k) base.update(retrieved) - # Keyword-based force-include for common intents + # Keyword-based force-include for common intents. Match on word + # boundaries, not raw substrings, so short hints like "fix", "line", + # "serve", "reply" or "unread" don't fire inside unrelated words + # ("prefix", "deadline"/"online", "observe"/"reserve", "replying", + # "unreadable"). Same word-boundary matching used in topic_analyzer. ql = query.lower() for keywords, tools in self._KEYWORD_HINTS.items(): - if any(kw in ql for kw in keywords): + if any(re.search(rf"\b{re.escape(kw)}\b", ql) for kw in keywords): base.update(tools) # Structural scheduling-intent detection — typo-resilient (the literal # keyword "every day" misses "every dya"). Catches "every ", diff --git a/tests/test_tool_index_keyword_boundaries.py b/tests/test_tool_index_keyword_boundaries.py new file mode 100644 index 0000000..d1465e6 --- /dev/null +++ b/tests/test_tool_index_keyword_boundaries.py @@ -0,0 +1,53 @@ +"""Keyword-hint force-include must match on word boundaries, not substrings. + +`get_tools_for_query` force-includes whole tool families when a query mentions +an intent keyword. The match used a raw substring test (`kw in ql`), so short +hints fired inside unrelated words: "fix" in "prefix", "line" in "deadline"/ +"online", "serve" in "observe"/"reserve", "reply" in "replying", "unread" in +"unreadable". That bloated the tool set with irrelevant email/document/serve +tools for queries that have nothing to do with them. Same substring-vs-word +pitfall already fixed in topic_analyzer.py. + +`retrieve` (which needs a chroma collection) is stubbed out so these tests +exercise only the keyword-hint loop. +""" +from src.tool_index import ToolIndex + + +def _index(): + ti = ToolIndex.__new__(ToolIndex) + ti.retrieve = lambda query, k=8: [] # no chroma; isolate the keyword loop + return ti + + +def test_substring_inside_word_does_not_force_email_tools(): + ti = _index() + # "replying" contains "reply"; "unreadable" contains "unread". + for q in ("i am replying to your github comment", "this document is unreadable"): + tools = ti.get_tools_for_query(q) + assert "send_email" not in tools, q + assert "reply_to_email" not in tools, q + + +def test_substring_inside_word_does_not_force_document_tools(): + ti = _index() + # "prefix" contains "fix"; "deadline"/"online" contain "line". + for q in ("prefix the output with a label", "the deadline is online already"): + tools = ti.get_tools_for_query(q) + assert "edit_document" not in tools, q + assert "update_document" not in tools, q + + +def test_substring_inside_word_does_not_force_serve_tools(): + ti = _index() + # "observe"/"reserve" contain "serve". + tools = ti.get_tools_for_query("please observe the reserve levels") + assert "serve_model" not in tools + assert "serve_preset" not in tools + + +def test_genuine_keywords_still_force_include(): + ti = _index() + assert "reply_to_email" in ti.get_tools_for_query("reply to this email") + assert "edit_document" in ti.get_tools_for_query("edit the document") + assert "serve_model" in ti.get_tools_for_query("serve the model")