feat(tools): add opt-in literal keyword discovery (#6659)

This commit is contained in:
ZJPex 2026-10-11 11:48:57 +08:00 committed by GitHub
parent 75ce6f4920
commit 128dafd0ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 126 additions and 2 deletions

View File

@ -839,6 +839,9 @@ The admin MCP cache reset advances a durable generation marker in the writable c
The parsed extensions configuration and its recorded content digest come from the same read, so a racing edit followed by a timestamp-preserving backup restore cannot leave a different revision cached indefinitely.
`extensions_config.json` accepts UTF-8 with or without a leading byte-order mark (BOM), including files saved as UTF-8 with BOM by an editor.
When deferred MCP tool discovery is enabled, `tool_search` supports opt-in literal keyword queries such as `keywords:notebook jupyter`. Keywords match names and descriptions in any order, ranked by distinct term coverage and then name hits; ties retain catalog order. This mode uses the first 256 characters after the prefix and at most 16 unique whitespace-separated terms, returning up to five tools. Punctuation stays literal. Bare queries and `+required` ranking keep their existing regular-expression semantics; `select:tool_a,tool_b` still fetches all exact, case-sensitive name matches.
MCP routing hints can also prefer a specific MCP tool for matching requests without forbidding other tools. When `tool_search` defers MCP schemas, matching routing metadata can auto-promote up to `tool_search.auto_promote_top_k` deferred schemas before the model call.
OpenViking users can register the official Streamable HTTP endpoint at `/mcp`

View File

@ -101,3 +101,10 @@ from captured child tool messages only when the final/partial report cites those
opaque source links. This preserves retrieval evidence across the delegation
boundary without placing provider IDs in model-visible text. Never reconstruct
source records from the child's prose or replace them with fresh provider reads.
`DeferredToolCatalog.search` reserves `keywords:` for opt-in literal, casefolded,
whitespace-separated terms (256 query characters, 16 unique terms). Rank by term
coverage across name/description, then name hits; ties preserve catalog order and
results stay capped at five. Keyword matching never invokes the regex helpers.
Bare queries and `+required` keep regex semantics; `select:` stays exact and
uncapped. Keep query grammar and examples in the `tool_search` description aligned.

View File

@ -40,6 +40,8 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
MAX_RESULTS = 5 # Max tools returned per search
MAX_KEYWORD_QUERY_CHARS = 256
MAX_KEYWORD_QUERY_TERMS = 16
def _compile_catalog_regex(pattern: str) -> re.Pattern[str]:
@ -89,6 +91,9 @@ class DeferredToolCatalog:
wanted = {n.strip() for n in query[7:].split(",")}
return [t for t in self.tools if t.name in wanted]
if query.startswith("keywords:"):
return _rank_catalog_keywords(query[len("keywords:") :], self.tools)
if query.startswith("+"):
parts = query[1:].split(None, 1)
if not parts:
@ -109,6 +114,28 @@ class DeferredToolCatalog:
return [t for _, t in scored][:MAX_RESULTS]
def _rank_catalog_keywords(query: str, tools: tuple[BaseTool, ...]) -> list[BaseTool]:
"""Rank literal terms by coverage, then name hits, preserving catalog ties.
Bound the query before case folding or splitting. Unlike skill intent
search, punctuation stays literal so tool names such as ``c++`` and dotted
namespaces remain searchable without interpreting model text as regex.
"""
terms = tuple(dict.fromkeys(query[:MAX_KEYWORD_QUERY_CHARS].casefold().split()))[:MAX_KEYWORD_QUERY_TERMS]
if not terms:
return []
scored: list[tuple[tuple[int, int], BaseTool]] = []
for candidate in tools:
name = candidate.name.casefold()
description = (candidate.description or "").casefold()
name_hits = sum(term in name for term in terms)
coverage = sum(term in name or term in description for term in terms)
if coverage:
scored.append(((coverage, name_hits), candidate))
scored.sort(key=lambda item: item[0], reverse=True)
return [candidate for _, candidate in scored[:MAX_RESULTS]]
def _catalog_regex_score(pattern: str, t: BaseTool) -> int:
regex = _compile_catalog_regex(pattern)
return len(regex.findall(f"{t.name} {t.description or ''}"))
@ -153,8 +180,13 @@ def build_tool_search_tool(catalog: DeferredToolCatalog) -> BaseTool:
Query forms:
- "select:Read,Edit" -- fetch these exact tools by name
- "notebook jupyter" -- keyword search, up to max_results best matches
- "+slack send" -- require "slack" in the name, rank by remaining terms
- "keywords:notebook jupyter" -- literal terms in any order; rank by
term coverage, then name hits; up to 5 matches. Uses the first 256
characters after the prefix and at most 16 unique whitespace-separated
terms, case-insensitively. Punctuation is literal; ties keep catalog order.
- "jupyter.*notebook" -- regex on name and description, name matches first
- "+slack send" -- require "slack" in the name, rank by the remaining regex
Queries without a prefix keep regex semantics (invalid regex is literal).
"""
matched = catalog.search(query)
if not matched:

View File

@ -0,0 +1,82 @@
"""Opt-in literal intent search for deferred tools."""
import pytest
from langchain_core.tools import StructuredTool
from deerflow.tools.builtins.tool_search import DeferredToolCatalog, build_tool_search_tool
def make_tool(name, description="A deferred tool."):
return StructuredTool.from_function(lambda query="": query, name=name, description=description)
@pytest.mark.parametrize("query", ["keywords:notebook jupyter", "keywords:JUPYTER notebook", "keywords: notebook\tJupyter notebook "])
def test_keywords_find_reordered_notebook_intent(query):
notebook = make_tool("jupyter_notebook", "Create and edit notebooks in Jupyter")
catalog = DeferredToolCatalog((make_tool("unrelated"), notebook))
assert catalog.search(query) == [notebook]
def test_keywords_rank_coverage_then_name_hits_with_stable_ties():
partial = make_tool("notebook")
description = make_tool("editor", "Jupyter notebook")
mixed = make_tool("jupyter", "Edit a notebook")
first = make_tool("jupyter_notebook")
second = make_tool("notebook_jupyter")
catalog = DeferredToolCatalog((partial, description, mixed, first, second))
expected = [first, second, mixed, description, partial]
assert catalog.search("keywords:notebook jupyter") == expected
assert catalog.search("keywords:jupyter notebook") == expected
assert catalog.search("keywords:jupyter jupyter notebook") == expected
@pytest.mark.parametrize("query", ["keywords:", "keywords: ", "keywords:unmatched"])
def test_keywords_empty_and_unmatched_return_empty(query):
assert DeferredToolCatalog((make_tool("jupyter_notebook"),)).search(query) == []
def test_keywords_treat_regex_metacharacters_literally():
literal = make_tool("literal", "Use a+b and [draft]")
other = make_tool("aaab", "draft")
catalog = DeferredToolCatalog((other, literal))
assert catalog.search("keywords:a+b [draft]") == [literal]
assert catalog.search("keywords:.*") == []
assert catalog.search("a+b") == [other]
def test_keywords_casefold_unicode():
tool = make_tool("strasse", "笔记本")
assert DeferredToolCatalog((tool,)).search("keywords:STRAẞE 笔记本") == [tool]
def test_keywords_cap_preserves_catalog_order():
tools = tuple(make_tool(f"notebook_{i}") for i in range(8))
catalog = DeferredToolCatalog(tools)
assert catalog.search("keywords:notebook") == list(tools[:5])
assert catalog.search("select:" + ",".join(t.name for t in tools)) == list(tools)
assert catalog.search("select:NOTEBOOK_0") == []
def test_keywords_bound_query_characters_and_unique_terms():
target = make_tool("target")
catalog = DeferredToolCatalog((target,))
assert catalog.search("keywords:" + "x" * 256 + " target") == []
assert catalog.search("keywords:" + " ".join(f"x{i}" for i in range(16)) + " target") == []
assert catalog.search("keywords:" + "x " * 16 + "target") == [target]
def test_keywords_do_not_change_legacy_required_name_or_regex():
tool = make_tool("jupyter_notebook", "Create and edit notebooks in Jupyter")
catalog = DeferredToolCatalog((tool,))
assert catalog.search("notebook jupyter") == []
assert catalog.search("jupyter.*notebook") == [tool]
assert catalog.search("+jupyter notebook") == [tool]
def test_keywords_promote_full_schema_through_tool_call():
notebook = make_tool("jupyter_notebook", "Create and edit notebooks in Jupyter")
catalog = DeferredToolCatalog((notebook,))
search = build_tool_search_tool(catalog)
result = search.invoke({"type": "tool_call", "id": "keyword-call", "name": "tool_search", "args": {"query": "keywords:notebook jupyter"}})
assert result.update["promoted"] == {"catalog_hash": catalog.hash, "names": [notebook.name]}
assert '"name": "jupyter_notebook"' in result.update["messages"][0].content