diff --git a/README.md b/README.md index d5fb98efca..601e2a261a 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/backend/packages/harness/deerflow/tools/AGENTS.md b/backend/packages/harness/deerflow/tools/AGENTS.md index 5d2720dfc4..5358ac55f1 100644 --- a/backend/packages/harness/deerflow/tools/AGENTS.md +++ b/backend/packages/harness/deerflow/tools/AGENTS.md @@ -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. diff --git a/backend/packages/harness/deerflow/tools/builtins/tool_search.py b/backend/packages/harness/deerflow/tools/builtins/tool_search.py index 656ade5f06..bd95516547 100644 --- a/backend/packages/harness/deerflow/tools/builtins/tool_search.py +++ b/backend/packages/harness/deerflow/tools/builtins/tool_search.py @@ -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: diff --git a/backend/tests/test_deferred_keyword_search.py b/backend/tests/test_deferred_keyword_search.py new file mode 100644 index 0000000000..0c4848146a --- /dev/null +++ b/backend/tests/test_deferred_keyword_search.py @@ -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