Ctrl K
ComunidadGuías…

butler/agents_md.py

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

Archivo original ↗Preguntar a KiBot
"""Load sub-agent definitions from ``.butler/agents/*.md`` (OpenHands subset)."""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

logger = logging.getLogger(__name__)

_FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n(.*)$", re.DOTALL)

# Sprint 22-4 PERF-21-B-3: mtime+size-keyed cache for agent md files.
# Mirrors hooks/loader (Sprint 21-3) + workflows/loader (Sprint 22-3) pattern.
# load_agent_md is called from merge_agent_md_into_context (line 91),
# which delegate_impl.py:324 invokes per message — without cache, each
# delegate incurs a disk read + frontmatter parse.
# Key: (str(workspace), str(name), str(path), mtime_ns, size) — auto-invalidates
# on file modification. Failed loads (OSError → None) are NOT cached to
# avoid poisoning subsequent reads.
_FILE_CACHE: dict[tuple[str, str, str, int, int], AgentMdDef] = {}


@dataclass
class AgentMdDef:
    name: str
    description: str = ""
    system_prompt: str = ""
    tools: list[str] | None = None
    triggers: list[str] = field(default_factory=list)
    permission_mode: str = ""


def _parse_frontmatter(raw: str) -> tuple[dict[str, Any], str]:
    text = str(raw or "").strip()
    m = _FRONTMATTER_RE.match(text)
    if not m:
        return {}, text
    meta: dict[str, Any] = {}
    from butler.agents_md_ops import parse_yaml_frontmatter_safe

    loaded = parse_yaml_frontmatter_safe(m.group(1))
    if loaded is not None:
        meta = loaded
    body = str(m.group(2) or "").strip()
    return meta, body


def load_agent_md(workspace: Path, name: str) -> AgentMdDef | None:
    key = str(name or "").strip().replace("_agent", "")
    if not key:
        return None
    base = Path(workspace).expanduser().resolve() / ".butler" / "agents"
    ws_key = str(workspace)
    for candidate in (f"{key}.md", f"{key}_agent.md"):
        path = base / candidate
        if not path.is_file():
            continue
        try:
            st = path.stat()
        except OSError as exc:
            logger.debug("agent md stat %s: %s", path, exc)
            continue
        cache_key = (ws_key, key, str(path), st.st_mtime_ns, st.st_size)
        cached = _FILE_CACHE.get(cache_key)
        if cached is not None:
            return cached
        try:
            raw = path.read_text(encoding="utf-8")
        except OSError as exc:
            logger.debug("agent md read %s: %s", path, exc)
            return None
        agent = _parse_agent_md(key, raw)
        if agent is not None:
            _FILE_CACHE[cache_key] = agent
        return agent
    return None


def _parse_agent_md(key: str, raw: str) -> AgentMdDef | None:
    """Parse a raw agent md body into AgentMdDef. No I/O, no cache."""
    meta, body = _parse_frontmatter(raw)
    tools_raw = meta.get("tools")
    tools = None
    if isinstance(tools_raw, list):
        tools = [str(t).strip() for t in tools_raw if str(t).strip()]
    triggers_raw = meta.get("triggers")
    triggers: list[str] = []
    if isinstance(triggers_raw, list):
        triggers = [str(t).strip() for t in triggers_raw if str(t).strip()]
    elif isinstance(triggers_raw, str) and triggers_raw.strip():
        triggers = [triggers_raw.strip()]
    return AgentMdDef(
        name=key,
        description=str(meta.get("description") or "")[:500],
        system_prompt=body,
        tools=tools,
        triggers=triggers,
        permission_mode=str(meta.get("permission_mode") or "")[:32],
    )


def list_agent_md_names(workspace: Path) -> list[str]:
    base = Path(workspace).expanduser().resolve() / ".butler" / "agents"
    if not base.is_dir():
        return []
    out: list[str] = []
    for path in sorted(base.glob("*.md")):
        if path.is_file():
            out.append(path.stem)
    return out


def merge_agent_md_into_context(
    workspace: Path | None,
    role: str,
    context: str,
) -> str:
    if workspace is None:
        return context
    agent = load_agent_md(workspace, role)
    if agent is None or not agent.system_prompt.strip():
        return context
    block = f"## 子代理定义(.butler/agents/{agent.name}.md)\n{agent.system_prompt.strip()}"
    if agent.description:
        block = f"## 子代理:{agent.description}\n\n{agent.system_prompt.strip()}"
    base = str(context or "").strip()
    if base:
        return f"{base}\n\n{block}"
    return block


__all__ = [
    "AgentMdDef",
    "list_agent_md_names",
    "load_agent_md",
    "merge_agent_md_into_context",
]