Files
wehub-resource-sync 4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:10:45 +08:00

279 lines
9.2 KiB
Python

"""Static tool descriptor index — metadata without importing executors (#3686).
The registry's ``_load_registry_snapshot`` imports every vendor tool module (and
its SDK) to read each tool's metadata, which costs ~1.5s at startup. This module
reads the same metadata (name, surfaces, source, display name, and the dotted
module to import for execution) by AST-scanning the tool source files — no
executor imports. Surface-scoped loads and lazy execution build on it.
Tools whose metadata cannot be read statically (re-exported ``RegisteredTool``
objects, runtime-registered packages) are not in the index; the registry imports
those the existing way. ``tests/tools/test_registry_index.py`` pins that gap.
"""
from __future__ import annotations
import ast
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from config.constants.paths import REPO_ROOT
from core.tool_framework.registry_metadata import normalize_surfaces
from tools.registry_discovery import INTEGRATION_TOOL_PACKAGES
_SKIP_FILE_SUFFIXES = ("_test.py",)
_TOOL_DECORATOR_NAME = "tool"
@dataclass(frozen=True, slots=True)
class ToolDescriptor:
"""Cheap tool metadata read without importing the executor module."""
name: str
surfaces: tuple[str, ...]
source: str | None
display_name: str | None
module: str
# Tools the AST scan cannot read: built via ``RegisteredTool(...)`` construction
# (the interactive-shell action tools) or with a non-literal ``surfaces=`` constant
# (slack/telegram). Pinned here so the index is complete; ``test_registry_index``
# asserts every entry matches the imported registry, so drift fails loudly.
def _fallback_descriptors() -> tuple[ToolDescriptor, ...]:
return (
ToolDescriptor(
"alert_sample",
("action",),
"interactive_shell",
None,
"tools.interactive_shell.actions.sample_alert",
),
ToolDescriptor(
"assistant_handoff",
("action",),
"interactive_shell",
None,
"tools.interactive_shell.actions.assistant_handoff",
),
ToolDescriptor(
"cli_exec",
("action",),
"interactive_shell",
None,
"tools.interactive_shell.actions.cli_command",
),
ToolDescriptor(
"code_implement",
("action",),
"interactive_shell",
None,
"tools.interactive_shell.actions.implementation",
),
ToolDescriptor(
"fix_sentry_issue_start",
("action",),
"interactive_shell",
None,
"tools.interactive_shell.actions.sentry_fix",
),
ToolDescriptor(
"investigation_start",
("action",),
"interactive_shell",
None,
"tools.interactive_shell.actions.investigation",
),
ToolDescriptor(
"llm_set_provider",
("action",),
"interactive_shell",
None,
"tools.interactive_shell.actions.llm_provider",
),
ToolDescriptor(
"shell_run",
("action",),
"interactive_shell",
None,
"tools.interactive_shell.actions.shell",
),
ToolDescriptor(
"slack_send_message",
("investigation", "chat", "action"),
"slack",
None,
"tools.slack_send_message_tool.tool",
),
ToolDescriptor(
"slash_invoke",
("action",),
"interactive_shell",
None,
"tools.interactive_shell.actions.slash",
),
ToolDescriptor(
"synthetic_run",
("action",),
"interactive_shell",
None,
"tools.interactive_shell.actions.synthetic",
),
ToolDescriptor(
"task_cancel",
("action",),
"interactive_shell",
None,
"tools.interactive_shell.actions.task_cancel",
),
ToolDescriptor(
"telegram_send_message",
("investigation", "chat", "action"),
"telegram",
None,
"integrations.telegram.tools.telegram_send_message_tool.tool",
),
)
def _string_constant(node: ast.expr | None) -> str | None:
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
return None
def _string_tuple(node: ast.expr | None) -> tuple[str, ...] | None:
if isinstance(node, ast.Tuple | ast.List):
values = [_string_constant(el) for el in node.elts]
if all(v is not None for v in values):
return tuple(v for v in values if v is not None)
return None
def _keyword(call: ast.Call, key: str) -> ast.expr | None:
for kw in call.keywords:
if kw.arg == key:
return kw.value
return None
def _is_tool_decorator(dec: ast.expr) -> bool:
call = dec.func if isinstance(dec, ast.Call) else dec
if isinstance(call, ast.Name):
return call.id == _TOOL_DECORATOR_NAME
if isinstance(call, ast.Attribute):
return call.attr == _TOOL_DECORATOR_NAME
return False
def _is_base_tool_class(cls: ast.ClassDef) -> bool:
for base in cls.bases:
if isinstance(base, ast.Name) and base.id == "BaseTool":
return True
if isinstance(base, ast.Attribute) and base.attr == "BaseTool":
return True
return False
def _class_attr(cls: ast.ClassDef, key: str) -> ast.expr | None:
for node in cls.body:
if isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
if node.target.id == key and node.value is not None:
return node.value
elif isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == key:
return node.value
return None
def _module_dotted(path: Path) -> str:
relative = path.relative_to(REPO_ROOT).with_suffix("")
parts = list(relative.parts)
if parts[-1] == "__init__":
parts = parts[:-1]
return ".".join(parts)
def _descriptors_in_file(path: Path) -> list[ToolDescriptor]:
try:
text = path.read_text(encoding="utf-8")
except (UnicodeDecodeError, OSError):
return []
# Skip the AST parse for files that can't declare a tool. The
# ``index == registry`` contract test guarantees this never drops a tool.
if "@tool" not in text and "BaseTool" not in text:
return []
try:
tree = ast.parse(text, filename=str(path))
except SyntaxError:
return []
module = _module_dotted(path)
descriptors: list[ToolDescriptor] = []
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
for dec in node.decorator_list:
if isinstance(dec, ast.Call) and _is_tool_decorator(dec):
name = _string_constant(_keyword(dec, "name")) or node.name
descriptors.append(
ToolDescriptor(
name=name,
surfaces=normalize_surfaces(_string_tuple(_keyword(dec, "surfaces"))),
source=_string_constant(_keyword(dec, "source")),
display_name=_string_constant(_keyword(dec, "display_name")),
module=module,
)
)
elif isinstance(node, ast.ClassDef) and _is_base_tool_class(node):
class_name = _string_constant(_class_attr(node, "name"))
if class_name is None:
continue
descriptors.append(
ToolDescriptor(
name=class_name,
surfaces=normalize_surfaces(_string_tuple(_class_attr(node, "surfaces"))),
source=_string_constant(_class_attr(node, "source")),
display_name=_string_constant(_class_attr(node, "display_name")),
module=module,
)
)
return descriptors
def _scan_roots() -> list[Path]:
roots = [REPO_ROOT / "tools"]
for dotted in INTEGRATION_TOOL_PACKAGES:
directory = REPO_ROOT / Path(*dotted.split("."))
if directory.is_dir():
roots.append(directory)
return roots
@lru_cache(maxsize=1)
def build_descriptor_index() -> dict[str, ToolDescriptor]:
"""Map tool name -> descriptor, first definition wins (registry order)."""
index: dict[str, ToolDescriptor] = {}
for root in _scan_roots():
for path in sorted(root.rglob("*.py")):
if "__pycache__" in path.parts or path.name.endswith(_SKIP_FILE_SUFFIXES):
continue
for descriptor in _descriptors_in_file(path):
index.setdefault(descriptor.name, descriptor)
# Pinned descriptors override (slack/telegram surfaces) or add (action tools).
for descriptor in _fallback_descriptors():
index[descriptor.name] = descriptor
return index
def clear_descriptor_index_cache() -> None:
build_descriptor_index.cache_clear()
__all__ = [
"ToolDescriptor",
"build_descriptor_index",
"clear_descriptor_index_cache",
]