Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions raven/agent/loop/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -648,7 +648,6 @@ def _register_default_tools(self) -> None:
from raven.agent.tools.tool_search import (
DEFAULT_ALWAYS_VISIBLE,
ToolCallTool,
ToolDescribeTool,
ToolSearchController,
ToolSearchStrategy,
ToolSearchTool,
Expand All @@ -661,7 +660,6 @@ def _register_default_tools(self) -> None:
search_result_limit=cfg.search_result_limit,
)
self.tools.register(ToolSearchTool(self.tool_search_controller))
self.tools.register(ToolDescribeTool(self.tool_search_controller))
self.tools.register(ToolCallTool(self.tool_search_controller))
# ``first=True``: filter the tool list before CacheOptimizer marks
# the final tool with ``cache_control`` (else the marked tool may be
Expand Down
102 changes: 77 additions & 25 deletions raven/agent/tools/tool_index.py
Original file line number Diff line number Diff line change
@@ -1,36 +1,48 @@
"""BM25 keyword index over the agent's tool catalog.

Backs ``tool_search``: ranks tools by a query over their name and description,
reusing the shared Okapi BM25 in :mod:`raven.utils.bm25` (CJK-aware, so
Chinese queries match). The name is repeated in the indexed text so a hit on
the tool name outranks an incidental description hit — the same field-weighting
idea as the skill ``LocalPool``.

The index rebuilds only when the catalog's ``(name, description)`` set changes
(startup + each MCP connect is one burst); steady-state ``search`` is one
query-side tokenize plus a BM25 dot product over precomputed ``doc_freqs``.
Backs ``tool_search``: ranks tools by a query over each tool's name,
description and parameter schema, reusing the shared Okapi BM25 in
:mod:`raven.utils.bm25` (CJK-aware, so Chinese queries match). The name is
repeated in the indexed text so a hit on the tool name outranks an incidental
body hit — the same field-weighting idea as the skill ``LocalPool``. Parameter
names, their descriptions and enum values are folded into the indexed body:
many tools carry the discriminating keywords (a repo owner, a channel id, an
image size) in the schema rather than the one-line description, so indexing the
schema lifts recall without touching the schema the model sees.

The index rebuilds only when the catalog's ``(name, description, parameters)``
set changes (startup + each MCP connect is one burst); steady-state ``search``
is one query-side tokenize plus a BM25 dot product over precomputed
``doc_freqs``. Each ``ensure`` flattens every tool's text to compute the
signature (cheap string work); the expensive BM25 rebuild is skipped on a hit.

A single process-level slot caches the most recently built index keyed by that
same signature, so the many short-lived agent loops a resident process spins up
over one fixed tool set reuse one prebuilt BM25 rather than each rebuilding an
identical one. A single slot suffices because only the main loop feeds this
index (subagents run a separate minimal tool set), so one signature dominates;
keying on description (not just name) also forces a rebuild when a tool's
description changes under hot-reload.
keying on the full indexed text (not just the name) also forces a rebuild when
a tool's description or parameters change under hot-reload.
"""

from __future__ import annotations

import threading
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from raven.utils.bm25 import BM25Okapi, tokenize

if TYPE_CHECKING:
from raven.agent.tools.base import Tool

# Catalog signature: the exact inputs to the BM25 build (name + description).
# Catalog signature: one (name, indexed-text) pair per tool. The indexed text
# embeds description + parameter schema, so the signature changes whenever any
# indexed field does — the exact trigger for a rebuild.
_Signature = frozenset[tuple[str, str]]

# Cap schema-walk recursion so a pathologically nested MCP schema can't blow the
# stack or balloon the indexed text; tool schemas are shallow in practice.
_MAX_SCHEMA_DEPTH = 6
# A built index: the names list paired with the BM25 built from it (same
# ordering). Read-only for searchers, so safe to share across loops.
_BuiltIndex = tuple[list[str], BM25Okapi]
Expand All @@ -44,30 +56,69 @@
_cached_index: _BuiltIndex | None = None


def _schema_text(parameters: "dict[str, Any] | None") -> str:
"""Natural-language keywords drawn from a JSON-Schema parameter block.

Collects property names, their ``description`` strings and enum values,
recursing through nested objects and array items. Types and structural
keywords are skipped — they carry no query signal.
"""
if not isinstance(parameters, dict):
return ""
parts: list[str] = []

def walk(node: Any, depth: int) -> None:
if depth > _MAX_SCHEMA_DEPTH or not isinstance(node, dict):
return
props = node.get("properties")
if isinstance(props, dict):
for key, sub in props.items():
parts.append(str(key))
walk(sub, depth + 1)
desc = node.get("description")
if isinstance(desc, str):
parts.append(desc)
enum = node.get("enum")
if isinstance(enum, list):
parts.extend(str(v) for v in enum if isinstance(v, (str, int, float)))
items = node.get("items")
if isinstance(items, dict):
walk(items, depth + 1)

walk(parameters, 0)
return " ".join(parts)


def _format_tool_text(tool: "Tool") -> str:
"""Indexed text for one tool. name ×3, description ×1 — TF-based field
weighting so a query term on the name dominates a description hit."""
"""Indexed text for one tool: name ×3, then description + parameter-schema
keywords ×1 — TF-based field weighting so a query term on the name
dominates a hit in the body."""
name = tool.name
desc = tool.description or ""
return f"{name} {name} {name} {desc}"
body = f"{tool.description or ''} {_schema_text(tool.parameters)}".strip()
return f"{name} {name} {name} {body}".rstrip()


def _get_or_build(sig: _Signature, tools: "list[Tool]") -> _BuiltIndex:
"""Return the shared prebuilt index for ``sig``, building it once on miss."""
def _get_or_build(sig: _Signature, items: list[tuple[str, str]]) -> _BuiltIndex:
"""Return the shared prebuilt index for ``sig``, building it once on miss.

``items`` are ``(name, indexed-text)`` pairs already flattened by the
caller, so the text is computed once per ``ensure`` (for the signature) and
reused here rather than recomputed.
"""
global _cached_sig, _cached_index
with _cache_lock:
if sig == _cached_sig and _cached_index is not None:
return _cached_index
names = [t.name for t in tools]
corpus = [tokenize(_format_tool_text(t)) for t in tools]
names = [name for name, _ in items]
corpus = [tokenize(text) for _, text in items]
built: _BuiltIndex = (names, BM25Okapi(corpus))
with _cache_lock:
_cached_sig, _cached_index = sig, built
return built


class ToolIndex:
"""Prebuilt BM25 over a tool catalog, rebuilt on (name, description) change.
"""Prebuilt BM25 over a tool catalog, rebuilt on (name, description, parameters) change.

Thread-safety: a lock guards the ``(names, BM25Okapi)`` pair. The expensive
build is delegated to the shared ``_get_or_build`` slot; the lock here is
Expand All @@ -81,12 +132,13 @@ def __init__(self) -> None:
self._bm25: BM25Okapi | None = None

def ensure(self, tools: "list[Tool]") -> None:
"""Adopt the shared index iff the (name, description) set changed."""
sig: _Signature = frozenset((t.name, t.description or "") for t in tools)
"""Adopt the shared index iff the (name, description, parameters) set changed."""
items = [(t.name, _format_tool_text(t)) for t in tools]
sig: _Signature = frozenset(items)
with self._lock:
if sig == self._sig and self._bm25 is not None:
return
names, bm25 = _get_or_build(sig, tools)
names, bm25 = _get_or_build(sig, items)
with self._lock:
self._names, self._bm25, self._sig = names, bm25, sig

Expand Down
85 changes: 24 additions & 61 deletions raven/agent/tools/tool_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@

When built-ins + plugins + MCP push the tool count past a threshold, injecting
every schema into each request burns context that scales with tool count. This
module withholds most schemas and exposes three meta-tools instead:
module withholds most schemas and exposes two meta-tools instead:

- ``tool_search`` — BM25 keyword search over the hidden catalog; compact
hits (name + description, no parameters).
- ``tool_describe`` — one tool's full parameter schema (so the model knows
the argument shape before calling it).
- ``tool_call`` — invoke a cataloged tool by name; forwards through the
registry, which still validates arguments.
- ``tool_search`` — BM25 keyword search over the hidden catalog; each hit
carries name + description + parameter schema, enough to
call the tool without a second lookup.
- ``tool_call`` — invoke a cataloged tool by name; forwards through the
registry, which validates arguments and returns a
correctable error when they don't fit the schema.

The tool list sent to the model never changes turn-to-turn (always the core
set + these three meta-tools), so the prompt cache stays stable across the
set + these two meta-tools), so the prompt cache stays stable across the
whole session — tools sit ahead of system+messages in the cached prefix, so a
changing tool list would invalidate everything after it. The cost is that
cataloged tools are invoked through ``tool_call`` rather than native
Expand Down Expand Up @@ -56,7 +56,7 @@

TOOL_CALL_NAME: str = "tool_call"
# The meta-tools: always registered when the feature is on, never cataloged.
META_TOOL_NAMES: frozenset[str] = frozenset({"tool_search", "tool_describe", TOOL_CALL_NAME})
META_TOOL_NAMES: frozenset[str] = frozenset({"tool_search", TOOL_CALL_NAME})


class ToolSearchController:
Expand Down Expand Up @@ -98,23 +98,23 @@ def visible_names(self) -> set[str]:
return self.always_visible

def search(self, query: str, limit: int | None = None) -> list[dict[str, Any]]:
"""Compact hits: name + description, never parameters."""
"""Hits carry name + description + parameter schema, so the model can go
straight to tool_call without a separate describe round-trip."""
names = self._index.search(query, limit or self.search_result_limit)
hits = []
for name in names:
tool = self._registry.get(name)
if tool is None:
continue
hits.append({"name": name, "description": tool.description})
hits.append(
{
"name": name,
"description": tool.description,
"parameters": tool.parameters,
}
)
return hits

def describe(self, name: str) -> dict[str, Any] | None:
"""Return the tool's full function schema (for argument shape)."""
tool = self._registry.get(name)
if tool is None or name in META_TOOL_NAMES:
return None
return tool.to_schema()["function"]

async def call(self, name: str, arguments: dict[str, Any] | None) -> str:
"""Invoke a cataloged tool: forward to the registry (validates args).

Expand Down Expand Up @@ -147,10 +147,9 @@ def name(self) -> str:
def description(self) -> str:
return (
"Search the catalog of additional tools that are available but not "
"currently loaded. Returns matching tool names with a short "
"description (no parameters). Use tool_describe to see a tool's "
"arguments, then tool_call to invoke it. Query with task keywords, "
"e.g. 'create github issue' or '生成图片'."
"currently loaded. Returns matching tools with their description and "
"parameter schema, ready to invoke with tool_call. Query with task "
"keywords, e.g. 'create github issue' or '生成图片'."
)

@property
Expand All @@ -166,7 +165,7 @@ def parameters(self) -> dict[str, Any]:
"type": "integer",
"description": "Max number of results.",
"minimum": 1,
"maximum": 25,
"maximum": 10,
},
},
"required": ["query"],
Expand All @@ -179,43 +178,6 @@ async def execute(self, query: str, limit: int | None = None) -> str:
return json.dumps(hits, ensure_ascii=False)


class ToolDescribeTool(Tool):
"""Return a cataloged tool's full parameter schema."""

def __init__(self, controller: ToolSearchController) -> None:
self._ctrl = controller

@property
def name(self) -> str:
return "tool_describe"

@property
def description(self) -> str:
return (
"Show the full parameter schema of a tool found via tool_search, so "
"you know its arguments. Invoke the tool afterwards with tool_call."
)

@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Exact tool name from a tool_search result.",
},
},
"required": ["name"],
}

async def execute(self, name: str) -> str:
schema = self._ctrl.describe(name)
if schema is None:
return f"Tool '{name}' not found. Use tool_search to find available tools."
return json.dumps(schema, ensure_ascii=False)


class ToolCallTool(Tool):
"""Invoke a cataloged tool by name. Arguments are validated by the registry."""

Expand All @@ -230,7 +192,8 @@ def name(self) -> str:
def description(self) -> str:
return (
"Invoke a tool found via tool_search by name, passing its arguments. "
"Use tool_describe first to learn the argument shape."
"If the arguments don't fit the tool's schema the registry returns a "
"validation error describing the fix; adjust and call again."
)

@property
Expand Down
9 changes: 4 additions & 5 deletions raven/config/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,11 +504,10 @@ class ToolSearchConfig(Base):

When the live tool catalog (built-ins + plugins + MCP) grows past
``compaction_threshold``, most tool schemas are withheld from each request and reached
on demand through the ``tool_search`` / ``tool_describe`` / ``tool_call``
meta-tools, so context cost stops scaling with tool count and the per-turn
tool list (and thus the prompt cache) stays stable. At or below the
threshold every tool is exposed directly (unchanged behavior) and the
meta-tools are omitted.
on demand through the ``tool_search`` / ``tool_call`` meta-tools, so context
cost stops scaling with tool count and the per-turn tool list (and thus the
prompt cache) stays stable. At or below the threshold every tool is exposed
directly (unchanged behavior) and the meta-tools are omitted.
"""

enabled: bool = False
Expand Down
6 changes: 3 additions & 3 deletions tests/test_agent_loop_tool_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ def _make_loop(workspace: Path, cfg, strategies=None) -> AgentLoop:

def test_enabled_registers_meta_tools(workspace) -> None:
loop = _make_loop(workspace, ToolSearchConfig(enabled=True))
for name in ("tool_search", "tool_describe", "tool_call"):
for name in ("tool_search", "tool_call"):
assert loop.tools.has(name), f"{name} should be registered"
assert loop.strategies.get("tool_search") is not None

Expand All @@ -83,7 +83,7 @@ def test_strategy_registered_first(workspace) -> None:

def test_disabled_registers_nothing(workspace) -> None:
loop = _make_loop(workspace, ToolSearchConfig(enabled=False))
for name in ("tool_search", "tool_describe", "tool_call"):
for name in ("tool_search", "tool_call"):
assert not loop.tools.has(name)
assert loop.strategies.get("tool_search") is None

Expand All @@ -106,5 +106,5 @@ async def test_enabled_loop_keeps_interaction_primitives_visible(workspace) -> N
_, out, _ = await loop.strategies.before_llm_call([], tools, "stub")
names = {t["function"]["name"] for t in out}
assert {"read_file", "message", "ask_user", "spawn"} <= names, "primitives must stay visible"
assert {"tool_search", "tool_describe", "tool_call"} <= names, "meta-tools must stay visible"
assert {"tool_search", "tool_call"} <= names, "meta-tools must stay visible"
assert "web_search" not in names, "cataloged domain tools are withheld above threshold"
Loading
Loading