Explorar el Código

治理(工具): 强化注册冲突检测并合并 Glob 实现

为 ToolRegistry 增加幂等重绑、Host 等能力覆盖和重复名称拒绝规则;统一 Glob 到 file.glob 并保留旧 glob_tool 导入路径,同时收紧可选工具的导入副作用并补充注册快照与兼容测试。
SamLee hace 1 día
padre
commit
12712fa18e

+ 18 - 28
agent/agent/tools/builtin/__init__.py

@@ -1,23 +1,25 @@
-"""
-内置基础工具 - 参考 opencode 实现
+"""Builtin tool registration and compatibility exports."""
 
-这些工具参考 vendor/opencode/packages/opencode/src/tool/ 的设计,
-在 Python 中重新实现核心功能。
-
-参考版本:opencode main branch (2025-01)
-"""
+import importlib.util
 
 from agent.tools.builtin.file.read import read_file
 from agent.tools.builtin.file.read_images import read_images
 from agent.tools.builtin.file.edit import edit_file
 from agent.tools.builtin.file.write import write_file
-from agent.tools.builtin.glob_tool import glob_files
+from agent.tools.builtin.file.glob import glob_files
 from agent.tools.builtin.file.grep import grep_content
 from agent.tools.builtin.bash import bash_command
 from agent.tools.builtin.skill import skill, list_skills
 from agent.tools.builtin.subagent import agent, evaluate
-# sandbox 工具已废弃(2026-04);search.py / crawler.py 已重构为 content/ 工具族(2026-04)
-from agent.tools.builtin.knowledge import(knowledge_search,knowledge_save,knowledge_save_pending,knowledge_list,knowledge_update,knowledge_batch_update,knowledge_slim)
+from agent.tools.builtin.knowledge import (
+    knowledge_batch_update as knowledge_batch_update,
+    knowledge_list as knowledge_list,
+    knowledge_save as knowledge_save,
+    knowledge_save_pending,
+    knowledge_search as knowledge_search,
+    knowledge_slim as knowledge_slim,
+    knowledge_update as knowledge_update,
+)
 # Memory / Dream(见 agent/docs/memory.md)
 from agent.tools.builtin.memory import dream
 # 知识上传/查询已统一到 agent 工具:
@@ -40,18 +42,14 @@ from agent.tools.builtin.orchestration import (
     submit_attempt,
     submit_validation,
 )
-# 导入浏览器工具以触发注册 (因 P1 流水线不需要,且加载缓慢,暂时全局屏蔽)
+# Browser tools are opt-in because importing browser-use is comparatively heavy.
 # import agent.tools.builtin.browser  # noqa: F401
 
-try:
+if importlib.util.find_spec("lark_oapi") is not None:
     import agent.tools.builtin.feishu
-except ImportError:
-    pass  # optional feishu extra
 
-try:
+if importlib.util.find_spec("websockets") is not None:
     import agent.tools.builtin.im
-except ImportError:
-    pass  # optional IM/server dependencies
 
 __all__ = [
     # 文件操作
@@ -64,18 +62,10 @@ __all__ = [
     # 系统工具
     "bash_command",
     "skill",
-    # 知识管理:统一通过 agent(agent_type="remote_librarian" / "remote_librarian_ingest" / "remote_research")
-    # 知识管理(旧架构 - 直接 HTTP API,仅供 Knowledge Manager 内部使用)
-    # "knowledge_search",
-    # "knowledge_save",
-    # "knowledge_list",
-    # "knowledge_update",
-    # "knowledge_batch_update",
-    # "knowledge_slim",
     "list_skills",
     "agent",
     "evaluate",
-    # 内容工具族(重构自 search.py + crawler.py)
+    # 内容工具族
     "content_platforms",
     "content_search",
     "content_detail",
@@ -103,6 +93,6 @@ __all__ = [
     "submit_attempt",
     "submit_validation",
     # Memory & Knowledge 提取审核
-    "knowledge_save_pending",  # 反思侧分支暂存(core 组默认可见)
-    "dream",                    # memory-bearing Agent 整理长期记忆(memory 组)
+    "knowledge_save_pending",
+    "dream",
 ]

+ 7 - 2
agent/agent/tools/builtin/file/glob.py

@@ -19,7 +19,12 @@ from agent.tools import tool, ToolResult, ToolContext
 LIMIT = 100  # 最大返回数量(参考 opencode glob.ts:35)
 
 
-@tool(description="使用 glob 模式匹配文件", hidden_params=["context"], capabilities=["read"])
+@tool(
+    description="使用 glob 模式匹配文件",
+    hidden_params=["context"],
+    groups=["core"],
+    capabilities=["read"],
+)
 async def glob_files(
     pattern: str,
     path: Optional[str] = None,
@@ -87,7 +92,7 @@ async def glob_files(
             output = "\n".join(file_paths)
 
             if truncated:
-                output += f"\n\n(结果已截断。考虑使用更具体的路径或模式。)"
+                output += "\n\n(结果已截断。考虑使用更具体的路径或模式。)"
 
         return ToolResult(
             title=f"匹配: {pattern}",

+ 3 - 106
agent/agent/tools/builtin/glob_tool.py

@@ -1,108 +1,5 @@
-"""
-Glob Tool - 文件模式匹配工具
+"""Backward-compatible import path for the canonical file glob tool."""
 
-参考:vendor/opencode/packages/opencode/src/tool/glob.ts
+from agent.tools.builtin.file.glob import LIMIT, glob_files
 
-核心功能:
-- 使用 glob 模式匹配文件
-- 按修改时间排序
-- 限制返回数量
-"""
-
-import glob as glob_module
-from pathlib import Path
-from typing import Optional
-
-from agent.tools import tool, ToolResult, ToolContext
-
-# 常量
-LIMIT = 100  # 最大返回数量(参考 opencode glob.ts:35)
-
-
-@tool(description="使用 glob 模式匹配文件", hidden_params=["context"], groups=["core"], capabilities=["read"])
-async def glob_files(
-    pattern: str,
-    path: Optional[str] = None,
-    context: Optional[ToolContext] = None
-) -> ToolResult:
-    """
-    使用 glob 模式匹配文件
-
-    参考 OpenCode 实现
-
-    Args:
-        pattern: glob 模式(如 "*.py", "src/**/*.ts")
-        path: 搜索目录(默认当前目录)
-        context: 工具上下文
-
-    Returns:
-        ToolResult: 匹配的文件列表
-    """
-    # 确定搜索路径
-    search_path = Path(path) if path else Path.cwd()
-    if not search_path.is_absolute():
-        search_path = Path.cwd() / search_path
-
-    if not search_path.exists():
-        return ToolResult(
-            title="目录不存在",
-            output=f"搜索目录不存在: {path}",
-            error="Directory not found"
-        )
-
-    # 执行 glob 搜索
-    try:
-        # 使用 pathlib 的 glob(支持 ** 递归)
-        if "**" in pattern:
-            matches = list(search_path.glob(pattern))
-        else:
-            # 使用标准 glob(更快)
-            pattern_path = search_path / pattern
-            matches = [Path(p) for p in glob_module.glob(str(pattern_path))]
-
-        # 过滤掉目录,只保留文件
-        file_matches = [m for m in matches if m.is_file()]
-
-        # 按修改时间排序(参考 opencode:47-56)
-        file_matches_with_mtime = []
-        for file_path in file_matches:
-            try:
-                mtime = file_path.stat().st_mtime
-                file_matches_with_mtime.append((file_path, mtime))
-            except Exception:
-                file_matches_with_mtime.append((file_path, 0))
-
-        # 按修改时间降序排序(最新的在前)
-        file_matches_with_mtime.sort(key=lambda x: x[1], reverse=True)
-
-        # 限制数量
-        truncated = len(file_matches_with_mtime) > LIMIT
-        file_matches_with_mtime = file_matches_with_mtime[:LIMIT]
-
-        # 格式化输出
-        if not file_matches_with_mtime:
-            output = "未找到匹配的文件"
-        else:
-            file_paths = [str(f[0]) for f in file_matches_with_mtime]
-            output = "\n".join(file_paths)
-
-            if truncated:
-                output += f"\n\n(结果已截断。考虑使用更具体的路径或模式。)"
-
-        return ToolResult(
-            title=f"匹配: {pattern}",
-            output=output,
-            metadata={
-                "count": len(file_matches_with_mtime),
-                "truncated": truncated,
-                "pattern": pattern,
-                "search_path": str(search_path)
-            }
-        )
-
-    except Exception as e:
-        return ToolResult(
-            title="Glob 错误",
-            output=f"glob 匹配失败: {str(e)}",
-            error=str(e)
-        )
+__all__ = ["LIMIT", "glob_files"]

+ 129 - 1
agent/agent/tools/registry.py

@@ -56,6 +56,15 @@ class ToolStats:
 
 class ToolRegistry:
 	"""工具注册表"""
+	_REGISTRATION_FIELDS = (
+		"schema",
+		"url_patterns",
+		"hidden_params",
+		"inject_params",
+		"groups",
+		"capabilities",
+		"ui_metadata",
+	)
 
 	def __init__(self):
 		self._tools: Dict[str, Dict[str, Any]] = {}
@@ -100,7 +109,7 @@ class ToolRegistry:
 				logger.error(f"Failed to generate schema for {func_name}: {e}")
 				raise
 
-		self._tools[func_name] = {
+		registration = {
 			"func": func,
 			"schema": schema,
 			"url_patterns": url_patterns,
@@ -115,6 +124,62 @@ class ToolRegistry:
 			}
 		}
 
+		existing = self._tools.get(func_name)
+		if existing is not None:
+			same_func = existing["func"] is func
+			same_definition = self._callable_origin(existing["func"]) == self._callable_origin(func)
+			same_metadata = all(
+				existing.get(field) == registration.get(field)
+				for field in self._REGISTRATION_FIELDS
+			)
+			if same_func and same_metadata:
+				# 重复 import 或装配代码可能对同一函数重复注册。这是安全的
+				# 幂等操作,且不应重置已累计的调用统计。
+				logger.debug("[ToolRegistry] Already registered (idempotent): %s", func_name)
+				return
+			if same_definition and same_metadata:
+				# Host composition can recreate a closure from the same factory
+				# definition to bind a fresh adapter/gateway. Replace only the
+				# callable and preserve accumulated statistics.
+				existing["func"] = func
+				logger.debug(
+					"[ToolRegistry] Rebound same-definition tool: %s (%s)",
+					func_name,
+					self._describe_callable(func),
+				)
+				return
+			if self._is_host_layer_override(existing, registration):
+				# A Host may deliberately replace a framework builtin with a
+				# capability-equivalent adapter. This preserves the historical
+				# layering contract while still rejecting builtin/builtin clashes,
+				# capability escalation, and competing Host implementations.
+				self._tools[func_name] = registration
+				logger.warning(
+					"[ToolRegistry] Host replaced builtin tool: %s (%s -> %s)",
+					func_name,
+					self._describe_callable(existing["func"]),
+					self._describe_callable(func),
+				)
+				return
+
+			existing_source = self._describe_callable(existing["func"])
+			attempted_source = self._describe_callable(func)
+			if same_func:
+				changed_fields = [
+					field
+					for field in self._REGISTRATION_FIELDS
+					if existing.get(field) != registration.get(field)
+				]
+				detail = f"registration metadata differs: {', '.join(changed_fields)}"
+			else:
+				detail = "a different callable already owns this name"
+			raise ValueError(
+				f"Duplicate tool registration for '{func_name}': {detail}; "
+				f"existing={existing_source}, attempted={attempted_source}"
+			)
+
+		self._tools[func_name] = registration
+
 		# 初始化统计
 		self._stats[func_name] = ToolStats()
 
@@ -125,6 +190,45 @@ class ToolRegistry:
 			f"url_patterns={url_patterns or 'none'})"
 		)
 
+	@staticmethod
+	def _describe_callable(func: Callable) -> str:
+		"""返回可用于定位重复注册来源的稳定描述。"""
+		module = getattr(func, "__module__", type(func).__module__)
+		qualname = getattr(func, "__qualname__", getattr(func, "__name__", type(func).__qualname__))
+		code = getattr(func, "__code__", None)
+		if code is None:
+			return f"{module}.{qualname}"
+		return f"{module}.{qualname} ({code.co_filename}:{code.co_firstlineno})"
+
+	@staticmethod
+	def _callable_origin(func: Callable) -> tuple[str, str, Optional[str], Optional[int]]:
+		"""Identify one source definition while allowing fresh closure instances."""
+		module = getattr(func, "__module__", type(func).__module__)
+		qualname = getattr(
+			func,
+			"__qualname__",
+			getattr(func, "__name__", type(func).__qualname__),
+		)
+		code = getattr(func, "__code__", None)
+		if code is None:
+			return module, qualname, None, None
+		return module, qualname, code.co_filename, code.co_firstlineno
+
+	@staticmethod
+	def _is_host_layer_override(
+		existing: Dict[str, Any],
+		registration: Dict[str, Any],
+	) -> bool:
+		existing_module = getattr(existing["func"], "__module__", "")
+		attempted_module = getattr(registration["func"], "__module__", "")
+		existing_capabilities = existing.get("capabilities", frozenset())
+		return (
+			existing_module.startswith(("agent.tools.builtin.", "agent.trace."))
+			and not attempted_module.startswith("agent.")
+			and bool(existing_capabilities)
+			and registration.get("capabilities") == existing_capabilities
+		)
+
 	@staticmethod
 	def _resolve_key_path(context: Dict[str, Any], key_path: str) -> Any:
 		"""
@@ -251,6 +355,7 @@ class ToolRegistry:
 			uid: 用户ID(自动注入)
 			context: 额外上下文
 			sensitive_data: 敏感数据字典(用于替换 <secret> 占位符)
+			inject_values: 兼容注入值;仅在调用参数未提供时生效
 
 		Returns:
 			JSON 字符串格式的结果
@@ -288,6 +393,12 @@ class ToolRegistry:
 			if "context" in hidden_params and "context" in sig.parameters:
 				kwargs["context"] = context
 
+			# 旧调用方可以在执行时提供默认注入值。模型明确给出的
+			# 参数优先,且仍只允许函数签名中存在的字段。
+			for param_name, value in (inject_values or {}).items():
+				if param_name in sig.parameters and param_name not in kwargs:
+					kwargs[param_name] = value
+
 			# 注入参数(inject_params)
 			inject_params = tool_info.get("inject_params", {})
 			for param_name, rule in inject_params.items():
@@ -569,9 +680,26 @@ def tool(
 			...
 	"""
 	def decorator(func: Callable) -> Callable:
+		schema = None
+		if description is not None or param_descriptions:
+			from agent.tools.schema import SchemaGenerator
+
+			schema = SchemaGenerator.generate(
+				func,
+				hidden_params=hidden_params or [],
+			)
+			function_schema = schema["function"]
+			if description is not None:
+				function_schema["description"] = description
+			properties = function_schema["parameters"].get("properties", {})
+			for param_name, param_description in (param_descriptions or {}).items():
+				if param_name in properties:
+					properties[param_name]["description"] = param_description
+
 		# 注册到全局 registry
 		_global_registry.register(
 			func,
+			schema=schema,
 			requires_confirmation=requires_confirmation,
 			editable_params=editable_params,
 			display=display,

+ 76 - 0
agent/tests/test_builtin_cleanup_compatibility.py

@@ -0,0 +1,76 @@
+from __future__ import annotations
+
+import ast
+from pathlib import Path
+
+import pytest
+
+
+def test_legacy_glob_module_reexports_the_canonical_tool() -> None:
+    from agent.tools.builtin.file.glob import LIMIT as canonical_limit
+    from agent.tools.builtin.file.glob import glob_files as canonical_glob
+    from agent.tools.builtin.glob_tool import LIMIT as legacy_limit
+    from agent.tools.builtin.glob_tool import glob_files as legacy_glob
+
+    assert legacy_glob is canonical_glob
+    assert legacy_limit == canonical_limit == 100
+    assert canonical_glob.__module__ == "agent.tools.builtin.file.glob"
+
+
+def test_trace_tree_dump_imports_remain_compatible() -> None:
+    from agent.debug.tree_dump import dump_tree as canonical_dump_tree
+    from agent.trace import dump_tree as package_dump_tree
+    from agent.trace.tree_dump import DEFAULT_DUMP_PATH, dump_tree as legacy_dump_tree
+
+    assert legacy_dump_tree is canonical_dump_tree
+    assert package_dump_tree is canonical_dump_tree
+    assert DEFAULT_DUMP_PATH == ".trace/tree.txt"
+
+
+@pytest.mark.asyncio
+async def test_canonical_glob_still_lists_matching_files(tmp_path: Path) -> None:
+    from agent.tools.builtin.file.glob import glob_files
+
+    expected = tmp_path / "example.py"
+    expected.write_text("pass\n", encoding="utf-8")
+    (tmp_path / "ignored.txt").write_text("ignored\n", encoding="utf-8")
+
+    result = await glob_files("*.py", path=str(tmp_path))
+
+    assert result.error is None
+    assert result.metadata["count"] == 1
+    assert result.output == str(expected)
+
+
+def test_browser_download_uses_stdlib_url_decode_and_time() -> None:
+    source_path = (
+        Path(__file__).parents[1]
+        / "agent"
+        / "tools"
+        / "builtin"
+        / "browser"
+        / "downloads.py"
+    )
+    tree = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path))
+
+    imported_names = {
+        alias.asname or alias.name
+        for node in tree.body
+        if isinstance(node, ast.Import)
+        for alias in node.names
+    }
+    urllib_imports = {
+        alias.asname or alias.name
+        for node in tree.body
+        if isinstance(node, ast.ImportFrom) and node.module == "urllib.parse"
+        for alias in node.names
+    }
+
+    assert "time" in imported_names
+    assert "unquote" in urllib_imports
+    assert "unquote" not in {
+        alias.name
+        for node in ast.walk(tree)
+        if isinstance(node, ast.Import)
+        for alias in node.names
+    }

+ 53 - 0
agent/tests/test_import_side_effects.py

@@ -0,0 +1,53 @@
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+
+PROJECT_ROOT = Path(__file__).parents[1]
+
+
+def _run_isolated(code: str) -> dict:
+    result = subprocess.run(
+        [sys.executable, "-c", code],
+        cwd=PROJECT_ROOT,
+        check=True,
+        capture_output=True,
+        text=True,
+    )
+    return json.loads(result.stdout)
+
+
+def test_import_agent_does_not_configure_logging_or_mutate_sys_path() -> None:
+    result = _run_isolated(
+        """
+import json
+import logging
+import sys
+
+handlers_before = tuple(logging.getLogger().handlers)
+path_before = tuple(sys.path)
+import agent
+print(json.dumps({
+    "handlers_unchanged": tuple(logging.getLogger().handlers) == handlers_before,
+    "path_unchanged": tuple(sys.path) == path_before,
+}))
+"""
+    )
+
+    assert result == {"handlers_unchanged": True, "path_unchanged": True}
+
+
+def test_packaged_im_client_is_the_builtin_runtime() -> None:
+    result = _run_isolated(
+        """
+import json
+from agent.im_client import IMClient
+from agent.tools.builtin.im.chat import IMClient as BuiltinIMClient
+print(json.dumps({"same_client": IMClient is BuiltinIMClient}))
+"""
+    )
+
+    assert result == {"same_client": True}

+ 311 - 0
agent/tests/test_tool_registry_contract.py

@@ -0,0 +1,311 @@
+from __future__ import annotations
+
+import json
+
+import pytest
+
+from agent.tools.models import ToolCapability
+from agent.tools.registry import ToolRegistry, get_tool_registry, tool
+
+
+def _schema(name: str) -> dict:
+    return {
+        "type": "function",
+        "function": {
+            "name": name,
+            "description": name,
+            "parameters": {"type": "object", "properties": {}},
+        },
+    }
+
+
+@pytest.mark.asyncio
+async def test_registering_the_same_tool_twice_is_idempotent() -> None:
+    registry = ToolRegistry()
+
+    async def sample() -> str:
+        return "ok"
+
+    registry.register(
+        sample,
+        schema=_schema("sample"),
+        groups=["test"],
+        capabilities=[ToolCapability.READ],
+    )
+    assert await registry.execute("sample", {}) == "ok"
+
+    registry.register(
+        sample,
+        schema=_schema("sample"),
+        groups=["test"],
+        capabilities=[ToolCapability.READ],
+    )
+
+    assert registry.get_tool_names() == ["sample"]
+    assert registry.get_stats("sample")["sample"]["call_count"] == 1
+
+
+@pytest.mark.asyncio
+async def test_same_factory_definition_rebinds_a_fresh_host_adapter() -> None:
+    registry = ToolRegistry()
+
+    class Gateway:
+        def __init__(self, value: str) -> None:
+            self.value = value
+
+    def make_tool(gateway: Gateway):
+        async def host_tool() -> str:
+            return gateway.value
+
+        return host_tool
+
+    registry.register(
+        make_tool(Gateway("first")),
+        schema=_schema("host_tool"),
+        capabilities=[ToolCapability.READ],
+    )
+    assert await registry.execute("host_tool", {}) == "first"
+
+    registry.register(
+        make_tool(Gateway("second")),
+        schema=_schema("host_tool"),
+        capabilities=[ToolCapability.READ],
+    )
+
+    assert await registry.execute("host_tool", {}) == "second"
+    assert registry.get_stats("host_tool")["host_tool"]["call_count"] == 2
+
+
+def test_same_name_from_a_different_callable_is_rejected() -> None:
+    registry = ToolRegistry()
+
+    async def original() -> str:
+        return "original"
+
+    async def replacement() -> str:
+        return "replacement"
+
+    replacement.__name__ = original.__name__
+    registry.register(original, schema=_schema("original"))
+
+    with pytest.raises(
+        ValueError,
+        match=r"Duplicate tool registration for 'original'.*different callable",
+    ) as exc_info:
+        registry.register(replacement, schema=_schema("original"))
+
+    message = str(exc_info.value)
+    assert "original" in message
+    assert "replacement" in message
+    assert registry._tools["original"]["func"] is original
+
+
+def test_reregistering_a_tool_with_different_metadata_is_rejected() -> None:
+    registry = ToolRegistry()
+
+    async def sample() -> str:
+        return "ok"
+
+    registry.register(sample, schema=_schema("sample"), groups=["read"])
+
+    with pytest.raises(
+        ValueError,
+        match=r"Duplicate tool registration for 'sample'.*metadata differs: groups",
+    ):
+        registry.register(sample, schema=_schema("sample"), groups=["write"])
+
+
+@pytest.mark.asyncio
+async def test_legacy_execute_inject_values_are_defaults_not_overrides() -> None:
+    registry = ToolRegistry()
+
+    async def sample(value: str) -> str:
+        return value
+
+    registry.register(sample, schema=_schema("sample"))
+
+    assert await registry.execute(
+        "sample",
+        {},
+        inject_values={"value": "injected", "unknown": "ignored"},
+    ) == "injected"
+    assert await registry.execute(
+        "sample",
+        {"value": "explicit"},
+        inject_values={"value": "injected"},
+    ) == "explicit"
+
+
+@pytest.mark.asyncio
+async def test_host_can_replace_a_capability_matched_submission_protocol() -> None:
+    registry = ToolRegistry()
+
+    async def submit_attempt() -> str:
+        return "framework"
+
+    submit_attempt.__module__ = "agent.tools.builtin.example"
+
+    registry.register(
+        submit_attempt,
+        schema=_schema("submit_attempt"),
+        groups=["orchestration_worker"],
+        capabilities=[ToolCapability.ATTEMPT_SUBMIT],
+    )
+    assert await registry.execute("submit_attempt", {}) == "framework"
+
+    async def submit_attempt() -> str:  # noqa: F811
+        return "host"
+
+    submit_attempt.__module__ = "host.tools"
+
+    registry.register(
+        submit_attempt,
+        schema={
+            **_schema("submit_attempt"),
+            "x-host-contract": True,
+        },
+        groups=["host"],
+        capabilities=[ToolCapability.ATTEMPT_SUBMIT],
+    )
+
+    assert await registry.execute("submit_attempt", {}) == "host"
+    assert registry.get_stats("submit_attempt")["submit_attempt"]["call_count"] == 2
+
+    async def competing_host_tool() -> str:
+        return "competing host"
+
+    competing_host_tool.__name__ = "submit_attempt"
+    competing_host_tool.__module__ = "another_host.tools"
+    with pytest.raises(ValueError, match="different callable"):
+        registry.register(
+            competing_host_tool,
+            schema=_schema("submit_attempt"),
+            groups=["another_host"],
+            capabilities=[ToolCapability.ATTEMPT_SUBMIT],
+        )
+
+
+def test_host_cannot_replace_a_builtin_with_different_capabilities() -> None:
+    registry = ToolRegistry()
+
+    async def read_contract() -> str:
+        return "framework"
+
+    read_contract.__module__ = "agent.tools.builtin.example"
+    registry.register(
+        read_contract,
+        schema=_schema("read_contract"),
+        capabilities=[ToolCapability.READ],
+    )
+
+    async def read_contract() -> str:  # noqa: F811
+        return "host"
+
+    read_contract.__module__ = "host.tools"
+    with pytest.raises(ValueError, match="different callable"):
+        registry.register(
+            read_contract,
+            schema=_schema("read_contract"),
+            capabilities=[ToolCapability.WRITE],
+        )
+
+
+def test_builtin_tool_registration_contract() -> None:
+    """Guard the security-relevant metadata without snapshotting optional tools."""
+    registry = get_tool_registry()
+    schemas = registry.get_schemas()
+    schema_names = [schema["function"]["name"] for schema in schemas]
+
+    assert len(schema_names) == len(set(schema_names))
+    assert set(schema_names) == set(registry.get_tool_names())
+
+    expected = {
+        "read_file": (
+            "agent.tools.builtin.file.read",
+            {"core"},
+            {ToolCapability.READ},
+        ),
+        "write_file": (
+            "agent.tools.builtin.file.write",
+            {"core"},
+            {ToolCapability.WRITE},
+        ),
+        "glob_files": (
+            "agent.tools.builtin.file.glob",
+            {"core"},
+            {ToolCapability.READ},
+        ),
+        "bash_command": (
+            "agent.tools.builtin.bash",
+            {"system"},
+            {ToolCapability.WRITE},
+        ),
+        "agent": (
+            "agent.tools.builtin.subagent",
+            {"core"},
+            {ToolCapability.AGENT_SPAWN},
+        ),
+        "task_plan": (
+            "agent.tools.builtin.orchestration",
+            {"orchestration_planner"},
+            {ToolCapability.TASK_CONTROL},
+        ),
+        "submit_attempt": (
+            "agent.tools.builtin.orchestration",
+            {"orchestration_worker"},
+            {ToolCapability.ATTEMPT_SUBMIT},
+        ),
+        "submit_validation": (
+            "agent.tools.builtin.orchestration",
+            {"orchestration_validator"},
+            {ToolCapability.VALIDATION_SUBMIT},
+        ),
+    }
+
+    actual = {
+        name: (
+            registry._tools[name]["func"].__module__,
+            set(registry._tools[name]["groups"]),
+            set(registry.get_capabilities(name)),
+        )
+        for name in expected
+    }
+    assert actual == expected, json.dumps(
+        {
+            name: {
+                "module": module,
+                "groups": sorted(groups),
+                "capabilities": sorted(map(str, capabilities)),
+            }
+            for name, (module, groups, capabilities) in actual.items()
+        },
+        ensure_ascii=False,
+        indent=2,
+    )
+
+
+def test_tool_decorator_applies_explicit_schema_descriptions() -> None:
+    registry = get_tool_registry()
+
+    try:
+        @tool(
+            description="Explicit tool description",
+            param_descriptions={"value": "Explicit value description"},
+        )
+        async def governance_description_probe(value: str) -> str:
+            """Docstring description.
+
+            Args:
+                value: Docstring value description.
+            """
+            return value
+
+        schema = registry.get_schemas(["governance_description_probe"])[0]["function"]
+        assert schema["description"] == "Explicit tool description"
+        assert (
+            schema["parameters"]["properties"]["value"]["description"]
+            == "Explicit value description"
+        )
+    finally:
+        registry._tools.pop("governance_description_probe", None)
+        registry._stats.pop("governance_description_probe", None)