Ctrl K
ComunidadGuías…

butler/tools/tool_arg_normalize.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""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"]