butler/tools/tool_arg_normalize.py
Rama main · réplica de lectura del repositorio original.
"""Normalize and validate LLM tool args before dispatch."""
from __future__ import annotations
from typing import Any
_PATH_TOOLS = frozenset({"read_file", "write_file", "patch", "delete_file"})
_PATH_ALIASES = ("file", "file_path", "filepath", "filename")
_TOOL_REQUIRED: dict[str, tuple[str, ...]] = {
"read_file": ("path",),
"write_file": ("path", "content"),
"patch": ("path", "old_string", "new_string"),
"delete_file": ("path",),
"terminal": ("command",),
"dev_search_symbols": ("name",),
}
_NAME_ALIASES = ("query", "symbol")
# Keys that must be present and non-blank strings (empty new_string is allowed for patch).
_NON_EMPTY_STRING: dict[str, frozenset[str]] = {
"read_file": frozenset({"path"}),
"write_file": frozenset({"path"}),
"patch": frozenset({"path", "old_string"}),
"delete_file": frozenset({"path"}),
"terminal": frozenset({"command"}),
}
_TOOL_HINTS: dict[str, str] = {
"patch": "patch requires: path, old_string (exact match), new_string (may be empty to delete).",
"write_file": "write_file requires: path, content.",
"read_file": "read_file requires: path.",
"delete_file": "delete_file requires: path.",
"terminal": "terminal requires: command.",
"dev_search_symbols": "dev_search_symbols requires: name (function/class/variable).",
}
def normalize_tool_args(name: str, args: dict[str, Any] | None) -> dict[str, Any]:
"""Coerce alternate keys (e.g. ``file`` → ``path``) for path-scoped tools."""
out = dict(args or {})
if name == "dev_search_symbols" and not out.get("name"):
for alt in _NAME_ALIASES:
val = out.get(alt)
if val:
out["name"] = val
break
if name not in _PATH_TOOLS or out.get("path"):
return out
for alt in _PATH_ALIASES:
val = out.get(alt)
if val:
out["path"] = val
break
return out
def _missing_required_fields(name: str, args: dict[str, Any]) -> list[str]:
required = _TOOL_REQUIRED.get(name)
if not required:
return []
non_empty = _NON_EMPTY_STRING.get(name, frozenset())
missing: list[str] = []
for field in required:
if field not in args:
missing.append(field)
continue
val = args[field]
if val is None:
missing.append(field)
continue
if field in non_empty and isinstance(val, str) and not val.strip():
missing.append(field)
return missing
def validate_tool_args(name: str, args: dict[str, Any] | None) -> dict[str, Any] | None:
"""Return a structured error payload when required args are missing; else None."""
missing = _missing_required_fields(name, dict(args or {}))
if not missing:
return None
hint = _TOOL_HINTS.get(name, f"{name} missing: {', '.join(missing)}")
return {
"error": f"{name} missing required arguments: {', '.join(missing)}",
"code": "TOOL_ARGS_INVALID",
"tool": name,
"missing": missing,
"hint": hint,
}
__all__ = ["normalize_tool_args", "validate_tool_args"]