mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
feat(tools): add opt-in literal keyword discovery (#6659)
This commit is contained in:
parent
75ce6f4920
commit
128dafd0ce
@ -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`
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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:
|
||||
|
||||
82
backend/tests/test_deferred_keyword_search.py
Normal file
82
backend/tests/test_deferred_keyword_search.py
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user