butler/tools/project_tools.py
Rama main · réplica de lectura del repositorio original.
"""Project-scoped tool allowlists from ``project.yaml``."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
from butler.tools.project_tools_ops import dev_engine_extra_tools
from butler.tools.project_tools_ops import mcp_tool_allowed
from butler.tools.project_tools_ops import workflow_step_tool_allowlist
from butler.tools.registry import get_tool_definitions
from butler.tools.project_tools_ops import optimize_tool_definitions_safe
from butler.execution_context import get_current_session_key
from butler.project.manager import get_project_manager
if TYPE_CHECKING:
from butler.project import Project
# Names in project.yaml may use legacy aliases from design docs.
_TOOL_NAME_ALIASES: dict[str, str] = {
"edit_file": "patch",
"search_code": "search_files",
"run_shell": "terminal",
"skill_list": "skills_list",
}
# Personal butler (no project): host read-only + recall; no workflow/delegate.
_PERSONAL_BUTLER_TOOLS = frozenset({
"read_file",
"search_files",
"list_directory",
"terminal",
"butler_recall",
"butler_remember",
"search_transcript",
"skills_list",
"skill_view",
"list_runtime_jobs",
})
# Butler orchestration tools always available when a project is selected.
_BUTLER_EXTRA_TOOLS = frozenset({
"delegate_task",
"list_workflows",
"search_project_knowledge",
"skills_list",
"skill_view",
"butler_remember",
"butler_recall",
"list_runtime_jobs",
"run_runtime_job",
"contact_add",
"contact_find",
"contact_update",
"contact_delete",
"contact_list",
"memo_add",
"memo_list",
"memo_search",
"memo_update",
"memo_delete",
"expense_add",
"expense_summary",
"expense_list",
"expense_update",
"expense_search",
"expense_delete",
"habit_create",
"habit_checkin",
"habit_stats",
"habit_list",
"habit_update",
"habit_delete",
"set_reminder",
"list_reminders",
"reminder_list_active",
"cancel_reminder",
})
# Project Lead: read-only + orchestration (no write/shell even if listed in project.yaml).
_LEAD_READ_TOOLS = frozenset({
"read_file",
"list_directory",
"search_files",
})
_PLAN_MODE_TOOLS = frozenset({
"read_file",
"list_directory",
"search_files",
"search_project_knowledge",
"search_transcript",
"skills_list",
"skill_view",
"butler_remember",
"butler_recall",
})
_LEAD_EXTRA_TOOLS = frozenset({
"delegate_task",
"run_workflow",
"list_workflows",
"skills_list",
"skill_view",
"butler_remember",
"butler_recall",
"list_runtime_jobs",
"run_runtime_job",
})
# Lead may opt in read-only network tools from project.yaml (no shell / no fetch by default).
_LEAD_NETWORK_TOOLS = frozenset({
"web_search",
})
_DEV_EXTRA_TOOLS = frozenset({
"dev_status",
"dev_verify",
"dev_review",
"dev_rollback",
"dev_search_symbols",
"run_pytest",
})
# Lead (厂长) 对话引擎:只读 + 编排;改盘仍走 delegate。
# Butler (管家) 对话引擎:allowed=None,不受 project.yaml / wechat_minimal 裁剪。
def _mcp_allowlist_from_mapped(mapped: set[str]) -> set[str]:
"""Preserve project.yaml MCP opt-in (``mcp_*`` or explicit ``mcp_`` names)."""
return {
name
for name in mapped
if name == "mcp_*" or (isinstance(name, str) and name.startswith("mcp_"))
}
def _lead_network_from_mapped(mapped: set[str]) -> set[str]:
"""Preserve project.yaml opt-in for read-only Lead network tools."""
return {name for name in mapped if name in _LEAD_NETWORK_TOOLS}
def _lead_allowed_tools(mapped: set[str]) -> set[str]:
"""Lead read-only core + orchestration; honor MCP opt-in from project.yaml."""
read_only = {n for n in mapped if n in _LEAD_READ_TOOLS}
if not read_only:
read_only = set(_LEAD_READ_TOOLS)
return (
read_only
| set(_LEAD_EXTRA_TOOLS)
| _mcp_allowlist_from_mapped(mapped)
| _lead_network_from_mapped(mapped)
)
def canonical_tool_name(name: str) -> str:
"""Map project.yaml tool name to registry tool name."""
key = str(name or "").strip()
if not key:
return ""
return _TOOL_NAME_ALIASES.get(key, key)
def allowed_tool_names_for_project(
project: "Project | None",
*,
role: str = "butler",
) -> set[str] | None:
"""Return allowed registry tool names, or ``None`` if unrestricted."""
norm_early = role.replace("_agent", "").strip().lower()
if norm_early == "plan":
return set(_PLAN_MODE_TOOLS)
if norm_early == "lead":
mapped: set[str] = set()
if project is not None:
raw = [canonical_tool_name(n) for n in (project.tools or [])]
mapped = {n for n in raw if n}
modes = getattr(project, "tool_modes", None) or {}
if isinstance(modes, dict) and modes:
mode_list = modes.get("lead") or modes.get(role) or modes.get(role.replace("_agent", ""))
if isinstance(mode_list, list) and mode_list:
mode_set = {canonical_tool_name(str(n)) for n in mode_list if str(n).strip()}
mode_set = {n for n in mode_set if n}
read_only = mode_set & _LEAD_READ_TOOLS if mode_set else set(_LEAD_READ_TOOLS)
return (
read_only
| set(_LEAD_EXTRA_TOOLS)
| _mcp_allowlist_from_mapped(mapped)
| _lead_network_from_mapped(mapped)
)
return _lead_allowed_tools(mapped)
if norm_early in {"butler", "default", ""} or role == "butler":
return None
if project is None:
return None
raw = [canonical_tool_name(n) for n in (project.tools or [])]
mapped = {n for n in raw if n}
if not mapped:
return None
norm = role.replace("_agent", "").strip().lower()
modes = getattr(project, "tool_modes", None) or {}
if isinstance(modes, dict) and modes:
mode_list = modes.get(norm) or modes.get(role) or modes.get(role.replace("_agent", ""))
if isinstance(mode_list, list) and mode_list:
mode_set = {canonical_tool_name(str(n)) for n in mode_list if str(n).strip()}
mode_set = {n for n in mode_set if n}
return mode_set if mode_set else None
if norm == "dev":
return mapped | set(dev_engine_extra_tools())
return mapped
def _tool_allowed(name: str, allowed: set[str]) -> bool:
if name in allowed:
return True
if "mcp_*" in allowed:
if mcp_tool_allowed(name):
return True
return False
def filter_tool_definitions(
tools: list[dict[str, Any]],
allowed: set[str] | None,
) -> list[dict[str, Any]]:
if allowed is None:
return list(tools)
return [
t for t in tools
if _tool_allowed(str(t.get("function", {}).get("name") or ""), allowed)
]
def _workflow_step_tool_allowlist(project: "Project | None") -> set[str] | None:
return cast(set[str] | None, workflow_step_tool_allowlist(project))
def intersect_allowed_names(
base: set[str] | None,
extra: set[str] | None,
) -> set[str] | None:
if extra is None:
return base
if base is None:
return set(extra)
return base & extra
def get_tool_definitions_for_project(
project: "Project | None" = None,
*,
role: str = "butler",
optimize_schema: bool = True,
) -> list[dict[str, Any]]:
norm = role.replace("_agent", "").strip().lower()
if norm in {"butler", "default", ""}:
from butler.tools.registry import get_tool_definitions_unfiltered
all_tools = get_tool_definitions_unfiltered()
else:
all_tools = get_tool_definitions()
allowed = allowed_tool_names_for_project(project, role=role)
allowed = intersect_allowed_names(allowed, _workflow_step_tool_allowlist(project))
filtered = filter_tool_definitions(all_tools, allowed)
if not optimize_schema:
return filtered
return cast(list[dict[str, Any]], optimize_tool_definitions_safe(filtered))
def get_current_project_tools(*, role: str = "butler") -> list[dict[str, Any]]:
pm = get_project_manager()
session_key = str(get_current_session_key() or "").strip()
project = pm.get_current(session_key=session_key)
return get_tool_definitions_for_project(project, role=role)
__all__ = [
"allowed_tool_names_for_project",
"canonical_tool_name",
"filter_tool_definitions",
"get_current_project_tools",
"get_tool_definitions_for_project",
"intersect_allowed_names",
]