fix: promote deferred tools after tool_search returns schema (#1570)

* fix: promote matched tools from deferred registry after tool_search returns schema

After tool_search returns a tool's full schema, the tool is promoted
(removed from the deferred registry) so DeferredToolFilterMiddleware
stops filtering it from bind_tools on subsequent LLM calls.

Without this, deferred tools are permanently filtered — the LLM gets
the schema from tool_search but can never invoke the tool because
the middleware keeps stripping it.

Fixes #1554

* test: add promote() and tool_search promotion tests

Tests cover:
- promote removes tools from registry
- promote nonexistent/empty is no-op
- search returns nothing after promote
- middleware passes promoted tools through
- tool_search auto-promotes matched tools (select + keyword)

* fix: address review — lint blank line + empty registry guard

- Add missing blank line between FakeRequest methods (E301)
- Use 'if not registry' to handle empty registries consistently

---------

Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
d 🔹
2026-03-30 11:23:15 +08:00
committed by GitHub
parent ef58bb8d3c
commit 9bcdba6038
2 changed files with 137 additions and 1 deletions
@@ -51,6 +51,21 @@ class DeferredToolRegistry:
)
)
def promote(self, names: set[str]) -> None:
"""Remove tools from the deferred registry so they pass through the filter.
Called after tool_search returns a tool's schema — the LLM now knows
the full definition, so the DeferredToolFilterMiddleware should stop
stripping it from bind_tools on subsequent calls.
"""
if not names:
return
before = len(self._entries)
self._entries = [e for e in self._entries if e.name not in names]
promoted = before - len(self._entries)
if promoted:
logger.debug(f"Promoted {promoted} tool(s) from deferred to active: {names}")
def search(self, query: str) -> list[BaseTool]:
"""Search deferred tools by regex pattern against name + description.
@@ -160,7 +175,7 @@ def tool_search(query: str) -> str:
Matched tool definitions as JSON array.
"""
registry = get_deferred_registry()
if registry is None:
if not registry:
return "No deferred tools available."
matched_tools = registry.search(query)
@@ -171,4 +186,8 @@ def tool_search(query: str) -> str:
# This is model-agnostic: all LLMs understand this standard schema.
tool_defs = [convert_to_openai_function(t) for t in matched_tools[:MAX_RESULTS]]
# Promote matched tools so the DeferredToolFilterMiddleware stops filtering
# them from bind_tools — the LLM now has the full schema and can invoke them.
registry.promote({t.name for t in matched_tools[:MAX_RESULTS]})
return json.dumps(tool_defs, indent=2, ensure_ascii=False)