import asyncio
import difflib
import json
import os
import re
import socket
import ssl
import tempfile
import urllib.error
import urllib.request
from collections.abc import Awaitable, Callable, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo

from otto.agent import Tool
from otto.config import OTTO_HOME
from otto.lint import auto_lint
from otto.log import get_logger
from otto.memory import Memory
from otto.sandbox import is_available as sandbox_is_available
from otto.sandbox import sandboxed_exec
from otto.skills import BUILTIN_SKILLS_DIR
from otto.subagents import SubagentProfile, load_subagent_profiles

_EXA_ENDPOINT = "https://mcp.exa.ai/mcp?tools=web_search_exa,get_code_context_exa,crawling_exa"

_log = get_logger("otto.tools")
_WORKSPACE_MODE_ENV = "OTTO_WORKSPACE_MODE"
_WORKSPACE_ROOT_ENV = "OTTO_WORKSPACE_ROOT"

_TOOL_TIMEOUTS: dict[str, int] = {
    "bash": 300,
    "read_file": 30,
    "write_file": 30,
    "edit_file": 60,
    "view_image": 30,
    "send_file": 30,
    "fetch": 30,
    "web_search": 30,
    "code_search": 30,
    "delegate_task": 30,
}


@dataclass(frozen=True)
class _WorkspaceContext:
    strict_mode: bool
    root: Path | None = None
    error: str | None = None
    sandbox: str = "none"
    sandbox_network: bool = True
    lsp_pool: Any = None
    chat_id: str | None = None


# === Tool Registry ===
# Format: (name, description, schema_fn, execute_fn)
def _tool_registry(
    workspace: _WorkspaceContext,
) -> list[tuple[str, str, Callable[[], dict[str, Any]], Callable[..., Any]]]:
    edit_failures: dict[str, int] = {}

    async def _read_file(
        path: str,
        offset: int = 0,
        limit: int = 2000,
        raw: bool = False,
        tail: int = 0,
        grep: str = "",
        count: bool = False,
    ) -> str:
        return await _read_file_execute(
            path,
            offset=offset,
            limit=limit,
            raw=raw,
            tail=tail,
            grep=grep,
            count=count,
            workspace=workspace,
        )

    async def _write_file(path: str, content: str) -> str:
        return await _write_file_execute(path, content, workspace=workspace)

    async def _edit_file(
        path: str,
        old_text: str = "",
        new_text: str = "",
        replace_all: bool = False,
        diff: str = "",
    ) -> str:
        return await _edit_file_execute(
            path,
            old_text,
            new_text,
            replace_all=replace_all,
            diff=diff,
            workspace=workspace,
            edit_failures=edit_failures,
        )

    async def _bash(command: str, timeout: int = 120) -> str:
        return await _bash_execute(command, timeout=timeout, workspace=workspace)

    async def _view_image(path: str, max_dimension: int = 1568) -> dict | str:
        return await _view_image_execute(path, max_dimension=max_dimension, workspace=workspace)

    async def _browse(
        url: str,
        action: str = "screenshot",
        selector: str | None = None,
        value: str | None = None,
        wait: int = 2,
        full_page: bool = False,
        save_path: str | None = None,
    ) -> dict[str, Any] | str:
        return await _browse_execute(
            url,
            action=action,
            selector=selector,
            value=value,
            wait=wait,
            full_page=full_page,
            save_path=save_path,
            workspace=workspace,
        )

    return [
        # File tools
        (
            "read_file",
            "Read file contents with optional line offset/limit.",
            _read_file_schema,
            _read_file,
        ),
        (
            "write_file",
            (
                "Write text to a file (creates parent dirs). Use ONLY for new files or"
                " as a last resort after edit_file fails. Prefer edit_file for modifying"
                " existing files."
            ),
            _write_file_schema,
            _write_file,
        ),
        (
            "edit_file",
            (
                "Edit a file. Preferred mode: pass a unified diff string."
                " Fallback: pass old_text/new_text for exact string replacement."
                " Always read_file first before editing."
            ),
            _edit_file_schema,
            _edit_file,
        ),
        # System tools
        ("bash", "Run a shell command and return combined output.", _bash_schema, _bash),
        # Web tools
        ("web_search", "Search the web via Exa MCP.", _web_search_schema, _web_search_execute),
        (
            "code_search",
            "Search for code examples, documentation, and programming solutions.",
            _code_search_schema,
            _code_search_execute,
        ),
        ("fetch", "Fetch content from a URL.", _fetch_schema, _fetch_execute),
        # Vision tools
        (
            "view_image",
            "View an image file. Returns the image so you can see and describe its contents.",
            _view_image_schema,
            _view_image,
        ),
        # Browser tools
        (
            "browse",
            (
                "Open a web page in a headless browser. Can take screenshots, extract text,"
                " click elements, or fill forms. Returns screenshots as viewable images."
            ),
            _browse_schema,
            _browse,
        ),
    ]


# === Registry Builder ===
def _resolve_skill_dirs(skills_dirs: Sequence[str | Path] | None = None) -> list[Path]:
    raw_dirs = skills_dirs if skills_dirs is not None else [OTTO_HOME / "skills"]
    if not raw_dirs:
        raw_dirs = [OTTO_HOME / "skills"]
    resolved: list[Path] = []
    seen: set[Path] = set()

    for raw_dir in raw_dirs:
        path = Path(str(raw_dir)).expanduser()
        try:
            canonical = path.resolve(strict=False)
        except Exception:
            canonical = path
        if canonical in seen:
            continue
        seen.add(canonical)
        resolved.append(canonical)

    # Built-in skills ship with Otto and are always available at lowest priority.
    builtin = BUILTIN_SKILLS_DIR.resolve(strict=False)
    if builtin not in seen:
        resolved.append(builtin)

    return resolved


def create_tools(
    *,
    memory: Memory | None = None,
    workspace_mode: str | None = None,
    workspace_root: str | Path | None = None,
    workspace_sandbox: str | None = None,
    workspace_sandbox_network: bool | None = None,
    skills_dirs: list[str | Path] | None = None,
    lsp_pool: Any = None,
    chat_id: str | None = None,
) -> list[Tool]:
    """Return all built-in tools."""
    workspace = _workspace_context(
        workspace_mode=workspace_mode,
        workspace_root=workspace_root,
        workspace_sandbox=workspace_sandbox,
        workspace_sandbox_network=workspace_sandbox_network,
        lsp_pool=lsp_pool,
        chat_id=chat_id,
    )
    resolved_skill_dirs = _resolve_skill_dirs(skills_dirs)
    tools = [
        Tool(name, desc, schema_fn(), exec_fn, timeout_seconds=_TOOL_TIMEOUTS.get(name))
        for name, desc, schema_fn, exec_fn in _tool_registry(workspace)
    ]
    tools.extend(_create_skill_tools(resolved_skill_dirs, sandboxed=workspace.sandbox != "none"))
    if memory is not None:
        tools.extend(_create_memory_tools(memory))
    tools.extend(_create_list_skills_tool(tools, resolved_skill_dirs))
    return tools


def _resolve_timezone(timezone: str | None) -> ZoneInfo | None:
    if timezone is None or timezone == "UTC":
        return None
    try:
        return ZoneInfo(timezone)
    except (KeyError, ValueError):
        return None


def create_session_tools(
    *,
    chat_id: str,
    bot_id: str | None = None,
    pulse_runner: Any | None = None,
    notify: Callable[[str], Awaitable[None]] | None = None,
    delegate_notify: Callable[[str], Awaitable[None]] | None = None,
    send_file: Callable[[str, str | None], Awaitable[None]] | None = None,
    timezone: str | None = None,
    workspace_mode: str | None = None,
    workspace_root: str | Path | None = None,
    workspace_sandbox: str | None = None,
    workspace_sandbox_network: bool | None = None,
    base_tools: list[Tool] | None = None,
    session_model: str | None = None,
    auth_storage: Any | None = None,
    output_format: str = "plain",
    subagent_profiles: list[SubagentProfile] | None = None,
) -> list[Tool]:
    """Create tools that need per-conversation context.

    Args:
        chat_id:          The session identifier.
        bot_id:           Optional bot alias for persisting delegation run context.
        pulse_runner:     PulseRunner instance for time-based tasks.
        notify:           Async callback for proactive send_message tool.
                          Only set for background tasks to avoid duplicate delivery.
        delegate_notify:  Async callback for delegation job completion notifications.
                          Always set — delegation needs to notify when jobs finish.
        send_file:        Async callback to push files to the user.
        timezone:         User's timezone string (e.g. "America/New_York").
        workspace_mode:   "strict" to restrict filesystem access.
        workspace_root:   Root path for strict workspace mode.
        workspace_sandbox: Filesystem sandbox backend (none|bubblewrap).
        workspace_sandbox_network: Allow network inside sandbox backend.
        base_tools:       All tools available to the session (passed through to
                          delegation so sub-agents get the same capabilities).
        session_model:    Current model for this session (fallback for delegation).
        auth_storage:     Auth credentials for LLM provider resolution.
        output_format:    Channel output format (telegram_html, markdown, plain).
        subagent_profiles: Optional preloaded subagent profiles. When omitted,
                          profiles are auto-loaded from ~/.otto/agents/*.md.
    """
    tz = _resolve_timezone(timezone)
    workspace = _workspace_context(
        workspace_mode=workspace_mode,
        workspace_root=workspace_root,
        workspace_sandbox=workspace_sandbox,
        workspace_sandbox_network=workspace_sandbox_network,
    )
    tools: list[Tool] = []
    if pulse_runner is not None:
        tools.extend(_create_scheduler_tools(chat_id, pulse_runner, tz=tz))
    if notify is not None:
        tools.extend(_create_notify_tools(notify))
    if delegate_notify is not None:
        tools.extend(
            _create_delegation_tools(
                chat_id,
                delegate_notify,
                base_tools or [],
                bot_id=bot_id,
                session_model=session_model,
                auth_storage=auth_storage,
                output_format=output_format,
                subagent_profiles=subagent_profiles,
            )
        )
    if send_file is not None:
        tools.extend(_create_send_file_tools(send_file, workspace=workspace))
    return tools


def _create_memory_tools(memory: Memory) -> list[Tool]:
    async def _memory_search(query: str, limit: int = 5) -> str:
        return await _memory_search_execute(memory, query, limit=limit)

    async def _memory_store(key: str, content: str) -> str:
        return await _memory_store_execute(memory, key, content)

    async def _memory_get(key: str) -> str:
        return await _memory_get_execute(memory, key)

    async def _memory_delete(key: str) -> str:
        return await _memory_delete_execute(memory, key)

    return [
        Tool(
            "memory_search",
            "Search memory for relevant context",
            _memory_search_schema(),
            _memory_search,
        ),
        Tool(
            "memory_store",
            "Store a fact or note in memory (returns previous value if overwriting)",
            _memory_store_schema(),
            _memory_store,
        ),
        Tool(
            "memory_get",
            "Retrieve a memory entry by exact key",
            _memory_get_schema(),
            _memory_get,
        ),
        Tool(
            "memory_delete",
            "Delete a memory entry by key",
            _memory_delete_schema(),
            _memory_delete,
        ),
    ]


def _create_skill_tools(skills_dirs: list[Path], *, sandboxed: bool = False) -> list[Tool]:
    async def _create_skill(
        name: str,
        description: str,
        content: str | None = None,
        license: str | None = None,
        compatibility: str | None = None,
        scripts: dict[str, Any] | None = None,
        scope: str = "local",
    ) -> str:
        return await _create_skill_execute(
            name,
            description,
            content=content,
            license=license,
            compatibility=compatibility,
            scripts=scripts,
            skills_dirs=skills_dirs,
            scope=scope,
            sandboxed=sandboxed,
        )

    async def _use_skill(name: str) -> str:
        return await _use_skill_execute(name, skills_dirs=skills_dirs)

    return [
        Tool(
            "create_skill",
            "Create a new agent skill with SKILL.md and optional scripts",
            _create_skill_schema(),
            _create_skill,
        ),
        Tool(
            "use_skill",
            "Load a skill's full instructions into context (includes skill path metadata)",
            _use_skill_schema(),
            _use_skill,
        ),
    ]


# === List Skills Tool ===
def _list_skills_schema() -> dict[str, Any]:
    return _schema(
        {
            "filter": {
                "type": "string",
                "description": "Optional keyword to filter tools/skills by name, description, or path",
                "default": "",
            },
        },
        [],
    )


def _skill_scripts_summary(skill_path: Path) -> str | None:
    """Return a compact summary of files inside a skill directory.

    Checks scripts/ first; if absent, falls back to any non-SKILL.md files in the root.
    Returns None when there is nothing interesting beyond SKILL.md.
    """
    scripts_dir = skill_path / "scripts"
    if scripts_dir.is_dir():
        files = sorted(f.name for f in scripts_dir.iterdir() if f.is_file())
        if files:
            return f"scripts/: {', '.join(files)}"
        return "scripts/: (empty)"

    # Fallback: extra files at the skill root other than SKILL.md
    extras = sorted(f.name for f in skill_path.iterdir() if f.is_file() and f.name != "SKILL.md")
    if extras:
        return ", ".join(extras)
    return None


def _format_installed_skill_line(skill: Any) -> str:
    skill_md = skill.path / "SKILL.md"
    summary = _skill_scripts_summary(skill.path)
    if summary:
        return f"{skill.name} — {skill.description} (SKILL.md: {skill_md}; {summary})"
    return f"{skill.name} — {skill.description} (SKILL.md: {skill_md})"


def _list_skills_execute(tools: list[Tool], skills_dirs: list[Path], *, filter: str = "") -> str:
    from otto.skills import discover_many

    keyword = filter.strip().lower()
    tool_lines: list[str] = []
    for tool in tools:
        if keyword and keyword not in tool.name.lower() and keyword not in tool.description.lower():
            continue
        tool_lines.append(f"{tool.name} — {tool.description}")

    skill_lines: list[str] = []
    for skill in discover_many(skills_dirs):
        haystack = f"{skill.name} {skill.description} {skill.path}".lower()
        if keyword and keyword not in haystack:
            continue
        skill_lines.append(_format_installed_skill_line(skill))

    if not tool_lines and not skill_lines:
        return (
            f"No tools or skills matching '{filter}'" if keyword else "No tools or skills available"
        )

    lines: list[str] = []
    if tool_lines:
        lines.append("Tools:")
        lines.extend(tool_lines)

    if skill_lines:
        if lines:
            lines.append("")
        lines.append("Installed skills:")
        lines.extend(skill_lines)

    return "\n".join(lines)


def _create_list_skills_tool(tools: list[Tool], skills_dirs: list[Path]) -> list[Tool]:
    async def _list_skills(filter: str = "") -> str:
        return _list_skills_execute(tools, skills_dirs, filter=filter)

    return [
        Tool(
            "list_skills",
            "List available tools and installed skills (with SKILL.md/script locations).",
            _list_skills_schema(),
            _list_skills,
        )
    ]


# === Scheduler Tools ===
def _create_scheduler_tools(
    chat_id: str, pulse_runner: Any, *, tz: ZoneInfo | None = None
) -> list[Tool]:
    from otto.autonomy import PulseRunner

    runner: PulseRunner = pulse_runner

    async def _set_reminder(message: str, delay: str) -> str:
        return await _set_reminder_execute(chat_id, runner, message, delay, tz=tz)

    async def _schedule(
        cron: str,
        prompt: str | None = None,
        mode: str | None = None,
        skill_name: str | None = None,
        skill_entrypoint: str | None = None,
        timeout_seconds: int | None = None,
    ) -> str:
        return await _schedule_execute(
            chat_id,
            runner,
            cron,
            prompt=prompt,
            mode=mode,
            skill_name=skill_name,
            skill_entrypoint=skill_entrypoint,
            timeout_seconds=timeout_seconds,
            tz=tz,
        )

    async def _list_schedules() -> str:
        return await _list_schedules_execute(chat_id, runner, tz=tz)

    async def _cancel_schedule(task_id: str) -> str:
        return await _cancel_schedule_execute(runner, task_id)

    return [
        Tool(
            "set_reminder",
            "Set a reminder. IMPORTANT: You must call this tool to actually create the "
            "reminder — confirming to the user without calling this tool does NOT create anything.",
            _set_reminder_schema(),
            _set_reminder,
        ),
        Tool(
            "schedule",
            "Create a recurring scheduled task using cron syntax. IMPORTANT: You must call "
            "this tool to actually create the schedule.",
            _schedule_schema(),
            _schedule,
        ),
        Tool(
            "list_schedules",
            "List your scheduled tasks and reminders",
            _list_schedules_schema(),
            _list_schedules,
        ),
        Tool(
            "cancel_schedule",
            "Cancel a scheduled task or reminder",
            _cancel_schedule_schema(),
            _cancel_schedule,
        ),
    ]


def _set_reminder_schema() -> dict[str, Any]:
    return _schema(
        {
            "message": {
                "type": "string",
                "description": "The reminder message the user will receive when the time is up",
            },
            "delay": {
                "type": "string",
                "description": (
                    "How long from now to send the reminder. "
                    "Format: number + unit, e.g. '30m', '2h', '1d', '90s'"
                ),
            },
        },
        ["message", "delay"],
    )


def _parse_delay(delay: str) -> timedelta | None:
    match = re.fullmatch(r"(\d+)([smhd])", delay.strip().lower())
    if match is None:
        return None
    amount = int(match.group(1))
    unit = match.group(2)
    if unit == "s":
        return timedelta(seconds=amount)
    if unit == "m":
        return timedelta(minutes=amount)
    if unit == "h":
        return timedelta(hours=amount)
    if unit == "d":
        return timedelta(days=amount)
    return None


def _humanize_datetime(value: datetime | None, tz: ZoneInfo | None = None) -> str:
    if value is None:
        return "unknown"
    target = tz if tz is not None else UTC
    converted = value.astimezone(target)
    label = converted.strftime("%Z") or str(target)
    return converted.strftime(f"%Y-%m-%d %H:%M:%S {label}")


def _utc_now() -> datetime:
    return datetime.now(UTC)


async def _set_reminder_execute(
    chat_id: str,
    runner: Any,
    message: str,
    delay: str,
    *,
    tz: ZoneInfo | None = None,
) -> str:
    from otto.autonomy import create_pulse_task

    delta = _parse_delay(delay)
    if delta is None:
        return 'Invalid delay. Use format like "30m", "2h", or "1d".'
    due_at = _utc_now() + delta
    try:
        task = create_pulse_task(
            runner.board,
            title=f"__notify__:{message}",
            due_at=due_at,
            chat_id=chat_id,
            logs=runner.logs,
        )
    except Exception as exc:
        return f"Failed to set reminder: {exc}"
    return f"Reminder set for {_humanize_datetime(task.due_at, tz=tz)}."


def _schedule_schema() -> dict[str, Any]:
    return _schema(
        {
            "cron": {
                "type": "string",
                "description": (
                    "Standard cron expression for the recurring schedule, e.g. "
                    "'0 9 * * *' for daily at 9am, '*/30 * * * *' for every 30 minutes"
                ),
            },
            "prompt": {
                "type": "string",
                "description": (
                    "Prompt/task text for prompt mode. Required when mode='prompt' (default)."
                ),
            },
            "mode": {
                "type": "string",
                "description": "Execution mode: 'prompt' (default) or 'skill'.",
                "enum": ["prompt", "skill"],
            },
            "skill_name": {
                "type": "string",
                "description": "Heartbeat skill name to resolve when mode='skill'.",
            },
            "skill_entrypoint": {
                "type": "string",
                "description": "Deterministic script path for mode='skill' (absolute or relative).",
            },
            "timeout_seconds": {
                "type": "integer",
                "description": "Optional heartbeat skill timeout in seconds.",
                "minimum": 5,
                "maximum": 300,
            },
        },
        ["cron"],
    )


async def _schedule_execute(
    chat_id: str,
    runner: Any,
    cron: str,
    *,
    prompt: str | None = None,
    mode: str | None = None,
    skill_name: str | None = None,
    skill_entrypoint: str | None = None,
    timeout_seconds: int | None = None,
    tz: ZoneInfo | None = None,
) -> str:
    from croniter import croniter

    from otto.autonomy import create_pulse_task

    normalized_mode = str(mode or "prompt").strip().lower()
    if normalized_mode not in {"prompt", "skill"}:
        return "Failed to schedule task: mode must be 'prompt' or 'skill'."

    prompt_text = (prompt or "").strip()
    if normalized_mode == "prompt" and not prompt_text:
        return "Failed to schedule task: prompt is required when mode='prompt'."

    if normalized_mode == "skill" and not (skill_name or skill_entrypoint):
        return "Failed to schedule task: provide skill_name or skill_entrypoint when mode='skill'."

    if normalized_mode == "skill" and not prompt_text:
        base = (skill_name or skill_entrypoint or "heartbeat").strip()
        prompt_text = f"Heartbeat skill: {base}"

    try:
        next_due = croniter(cron, _utc_now()).get_next(datetime)
        if next_due.tzinfo is None:
            next_due = next_due.replace(tzinfo=UTC)
        task = create_pulse_task(
            runner.board,
            title=prompt_text,
            due_at=next_due,
            chat_id=chat_id,
            repeat=cron,
            logs=runner.logs,
            mode=normalized_mode,
            skill_name=skill_name,
            skill_entrypoint=skill_entrypoint,
            timeout_seconds=timeout_seconds,
        )
    except Exception as exc:
        return f"Failed to schedule task: {exc}"
    return f"Scheduled task {task.id}. Next run: {_humanize_datetime(task.due_at, tz=tz)}"


def _list_schedules_schema() -> dict[str, Any]:
    return _schema({}, [])


async def _list_schedules_execute(chat_id: str, runner: Any, *, tz: ZoneInfo | None = None) -> str:
    from otto.autonomy import list_pulse_tasks

    try:
        tasks = list_pulse_tasks(runner.board, chat_id=chat_id)
    except Exception as exc:
        return f"Failed to list schedules: {exc}"

    lines = ["ID | Type | Next Run | Prompt"]
    for task in tasks:
        task_type = task.repeat if task.repeat is not None else "(one-shot)"
        lines.append(
            f"{task.id} | {task_type} | {_humanize_datetime(task.due_at, tz=tz)} | {task.title}"
        )

    return "\n".join(lines)


def _cancel_schedule_schema() -> dict[str, Any]:
    return _schema({"task_id": {"type": "string", "description": "Task ID to cancel"}}, ["task_id"])


async def _cancel_schedule_execute(runner: Any, task_id: str) -> str:
    from otto.autonomy import cancel_pulse_task

    try:
        removed = cancel_pulse_task(runner.board, task_id, logs=runner.logs)
    except Exception as exc:
        return f"Failed to cancel schedule: {exc}"
    if removed:
        return f"Cancelled schedule: {task_id}"
    return f"Schedule not found: {task_id}"


# === Notify Tools ===
def _create_notify_tools(notify: Callable[[str], Awaitable[None]]) -> list[Tool]:
    async def _send_message(text: str) -> str:
        return await _send_message_execute(notify, text)

    return [
        Tool(
            "send_message",
            "Send a message to the user proactively",
            _send_message_schema(),
            _send_message,
        )
    ]


def _send_message_schema() -> dict[str, Any]:
    return _schema({"text": {"type": "string", "description": "Message text"}}, ["text"])


async def _send_message_execute(notify: Callable[[str], Awaitable[None]], text: str) -> str:
    await notify(text)
    return "Message sent."


# === Delegation Tools ===


def _create_delegation_tools(
    chat_id: str,
    notify: Callable[[str], Awaitable[None]],
    base_tools: list[Tool],
    bot_id: str | None = None,
    session_model: str | None = None,
    auth_storage: Any | None = None,
    output_format: str = "plain",
    subagent_profiles: list[SubagentProfile] | None = None,
) -> list[Tool]:
    """Create the three delegation tools: delegate_task, list_jobs, cancel_job.

    These tools give Otto the ability to spawn sub-agents, inspect their status,
    and cancel them. Sub-agents receive the same tool set as the main session.
    """
    from otto.delegation import DelegationContract
    from otto.orchestrator import get_orchestrator

    orchestrator = get_orchestrator()
    profiles = subagent_profiles if subagent_profiles is not None else load_subagent_profiles()
    profiles_by_name = {profile.name: profile for profile in profiles}

    def _resolve_subagent_name(raw_name: str | None) -> tuple[str | None, str | None]:
        if raw_name is None:
            return None, None

        normalized = raw_name.strip()
        if not normalized or normalized == "main":
            return None, None

        if normalized in profiles_by_name:
            return normalized, None

        available = ", ".join(sorted(profiles_by_name)) or "none"
        return None, f"Unknown subagent '{normalized}'. Available: {available}."

    def _delegation_success_payload(
        text: str,
        *,
        job_id: str | None = None,
        parent_job_id: str | None = None,
        child_job_ids: list[str] | None = None,
    ) -> dict[str, Any]:
        details: dict[str, Any] = {"handoff": True, "kind": "delegation"}
        if job_id:
            details["job_id"] = job_id
        if parent_job_id:
            details["parent_job_id"] = parent_job_id
            details["kind"] = "delegation_fanin"
        if child_job_ids:
            details["child_job_ids"] = child_job_ids
        return {"text": text, "details": details}

    async def _delegate_task(
        task: str,
        deliverables: list[str] | None = None,
        constraints: list[str] | None = None,
        out_of_scope: list[str] | None = None,
        validation_cmds: list[str] | None = None,
        context_files: list[str] | None = None,
        subagent: str | None = None,
        timeout: int | None = None,
        depends_on: list[str] | None = None,
        children: list[dict[str, Any]] | None = None,
    ) -> str | dict[str, Any]:
        subagent_name, resolution_error = _resolve_subagent_name(subagent)
        if resolution_error is not None:
            return resolution_error

        # Fan-in pattern: create parent + child tasks automatically
        if children:
            return await _delegate_with_children(
                parent_task=task,
                children_specs=children,
                deliverables=deliverables,
                constraints=constraints,
                out_of_scope=out_of_scope,
                validation_cmds=validation_cmds,
                context_files=context_files,
                timeout=timeout,
                subagent_name=subagent_name,
            )

        contract = DelegationContract(
            task=task,
            deliverables=deliverables or [],
            constraints=constraints or [],
            out_of_scope=out_of_scope or [],
            validation_cmds=validation_cmds or [],
            context_files=context_files or [],
            timeout=timeout,
        )
        try:
            job_id = await orchestrator.delegate(
                contract,
                chat_id,
                notify=notify,
                tools=base_tools,
                subagent_name=subagent_name,
                session_model=session_model,
                bot_id=bot_id,
                auth_storage=auth_storage,
                output_format=output_format,
                depends_on=depends_on,
            )
        except RuntimeError as exc:
            return f"Delegation failed: {exc}"
        dep_note = ""
        if depends_on:
            dep_note = f"\nWaiting on: {', '.join(depends_on)}"
        route_note = ""
        if subagent_name:
            route_note = f"\nSubagent: {subagent_name}"
        text = (
            f"✓ Delegated to sub-agent — job ID: {job_id}\n"
            f"Task: {task[:120]}{dep_note}{route_note}\n"
            "I'll notify you when it's done. You can keep chatting."
        )
        return _delegation_success_payload(text, job_id=job_id)

    def _resolve_child_dep_ref(ref: str, child_ids: list[str]) -> str:
        """Resolve a placeholder child reference to an actual task ID.

        LLMs specify inter-child depends_on as "Child 1", "child_2",
        "child-3", etc. since actual task IDs aren't known at call time.
        Maps 1-indexed ordinal references to already-created child IDs.
        Passes through anything that already looks like a task ID.
        """
        import re

        m = re.match(r"(?i)child[\s_-]*(\d+)", ref.strip())
        if m:
            idx = int(m.group(1)) - 1  # 1-indexed → 0-indexed
            if 0 <= idx < len(child_ids):
                return child_ids[idx]
        return ref

    async def _delegate_with_children(
        parent_task: str,
        children_specs: list[dict[str, Any]],
        *,
        deliverables: list[str] | None = None,
        constraints: list[str] | None = None,
        out_of_scope: list[str] | None = None,
        validation_cmds: list[str] | None = None,
        context_files: list[str] | None = None,
        timeout: int | None = None,
        subagent_name: str | None = None,
    ) -> str | dict[str, Any]:
        """Create N child tasks + 1 parent synthesis task wired together.

        Children run in parallel (unless they have inter-child depends_on).
        Parent waits for all children, receives their results, synthesizes.
        Only the parent notifies the user.
        """
        child_ids: list[str] = []

        # Create child tasks first
        for spec in children_specs:
            child_task = spec.get("task", "")
            raw_depends = spec.get("depends_on") or []
            # Resolve placeholder refs ("Child 1") to actual IDs
            child_depends = [_resolve_child_dep_ref(ref, child_ids) for ref in raw_depends]
            child_subagent_name, child_error = _resolve_subagent_name(spec.get("subagent"))
            if child_error is not None:
                return child_error

            child_contract = DelegationContract(
                task=child_task,
                deliverables=spec.get("deliverables") or [],
                constraints=spec.get("constraints") or [],
                context_files=spec.get("context_files") or [],
                timeout=spec.get("timeout") or timeout,
            )
            try:
                child_id = await orchestrator.delegate(
                    child_contract,
                    chat_id,
                    notify=notify,
                    tools=base_tools,
                    subagent_name=child_subagent_name,
                    session_model=session_model,
                    bot_id=bot_id,
                    auth_storage=auth_storage,
                    output_format=output_format,
                    depends_on=child_depends,
                )
            except RuntimeError as exc:
                return f"Delegation failed while creating child task: {exc}"
            # Set placeholder parent immediately to suppress child notifications.
            await orchestrator.set_job_parent(child_id, "pending")
            child_ids.append(child_id)

        # Create parent synthesis task — depends_on all children
        parent_contract = DelegationContract(
            task=parent_task,
            deliverables=deliverables or [],
            constraints=constraints or [],
            out_of_scope=out_of_scope or [],
            validation_cmds=validation_cmds or [],
            context_files=context_files or [],
            timeout=timeout,
        )
        try:
            parent_id = await orchestrator.delegate(
                parent_contract,
                chat_id,
                notify=notify,
                tools=base_tools,
                subagent_name=subagent_name,
                session_model=session_model,
                bot_id=bot_id,
                auth_storage=auth_storage,
                output_format=output_format,
                depends_on=child_ids,
            )
        except RuntimeError as exc:
            return f"Delegation failed while creating parent task: {exc}"

        # Wire parent_id on children and children list on parent.
        for cid in child_ids:
            await orchestrator.link_child_to_parent(cid, parent_id)

        child_summaries = [
            f"  - {cid}: {children_specs[i].get('task', '')[:60]}"
            for i, cid in enumerate(child_ids)
        ]
        text = (
            f"✓ Delegated fan-in job — parent: {parent_id}, "
            f"{len(child_ids)} child task(s)\n"
            f"Children (run in parallel):\n" + "\n".join(child_summaries) + "\n"
            f"Parent {parent_id} will synthesize results when all children complete.\n"
            "You'll get one notification when everything is done."
        )
        return _delegation_success_payload(
            text,
            parent_job_id=parent_id,
            child_job_ids=child_ids,
        )

    async def _list_jobs() -> str:
        jobs = await orchestrator.list_jobs()
        if not jobs:
            return "No delegated jobs found."
        # Group: show parent jobs with children indented, hide children from top level
        child_ids = set()
        for j in jobs:
            for cid in j.get("children", []):
                child_ids.add(cid)
        lines = []
        for j in jobs:
            # Skip children at top level — they'll be shown under their parent
            if j["id"] in child_ids:
                continue
            lines.append(_format_job_line(j))
            # Show children indented under parent
            for cid in j.get("children", []):
                child = next((c for c in jobs if c["id"] == cid), None)
                if child:
                    lines.append("  " + _format_job_line(child))
        return "\n".join(lines)

    def _format_job_line(j: dict) -> str:
        status = j["status"].upper()
        task_text = j.get("description", j.get("title", ""))
        task_preview = task_text[:60]
        if len(task_text) > 60:
            task_preview += "..."
        metrics = j.get("contract", {}).get("metrics", {})
        parts = [f"[{j['id']}] {status} — {task_preview}"]
        duration = metrics.get("duration")
        if duration is not None:
            if duration < 60:
                parts.append(f"{duration:.0f}s")
            else:
                parts.append(f"{duration / 60:.1f}m")
        otto_ext = j.get("x-otto", {})
        if otto_ext.get("attempts", 0) > 1:
            parts.append(f"{otto_ext['attempts']} attempts")
        deps = j.get("blockedBy", [])
        if deps:
            parts.append(f"depends: {','.join(deps)}")
        children = j.get("children", [])
        if children:
            parts.append(f"children: {len(children)}")
        return " | ".join(parts)

    async def _cancel_job(job_id: str) -> str:
        cancelled = await orchestrator.cancel(job_id)
        if cancelled:
            return f"Cancellation requested for job {job_id}."
        return f"Job {job_id} not found or already finished."

    async def _retry_job(job_id: str, feedback: str | None = None) -> str:
        retried = await orchestrator.retry(
            job_id,
            notify=notify,
            tools=base_tools,
            auth_storage=auth_storage,
            output_format=output_format,
            feedback=feedback,
        )
        if retried:
            msg = f"✓ Retrying job {job_id}."
            if feedback:
                msg += f" New instructions: {feedback[:80]}"
            return msg
        return f"Job {job_id} not found or not in a failed state."

    async def _redirect_job(
        job_id: str,
        feedback: str,
        subagent: str | None = None,
    ) -> str:
        subagent_name, resolution_error = _resolve_subagent_name(subagent)
        if resolution_error is not None:
            return resolution_error

        redirected = await orchestrator.redirect(
            job_id,
            feedback,
            notify=notify,
            tools=base_tools,
            auth_storage=auth_storage,
            output_format=output_format,
            subagent_name=subagent_name,
            session_model=session_model,
        )
        if redirected:
            route_note = f" using subagent {subagent_name}" if subagent_name else ""
            return f"✓ Redirected job {job_id}{route_note} with feedback: {feedback[:80]}"
        return f"Job {job_id} not found or not in a running/failed state."

    return [
        Tool(
            "delegate_task",
            (
                "Delegate a task to an async sub-agent. The sub-agent runs in the background"
                " and will notify the user when done. Use for long-running, complex, or"
                " parallelisable tasks. You can keep handling other requests while it works."
            ),
            _delegate_task_schema(profiles),
            _delegate_task,
        ),
        Tool(
            "list_jobs",
            "List all delegated sub-agent jobs and their current status.",
            _list_jobs_schema(),
            _list_jobs,
        ),
        Tool(
            "cancel_job",
            "Cancel a running delegated sub-agent job by its job ID.",
            _cancel_job_schema(),
            _cancel_job,
        ),
        Tool(
            "retry_job",
            (
                "Retry a failed delegated job. Optionally provide new instructions"
                " that will be injected as feedback for the next attempt."
            ),
            _retry_job_schema(),
            _retry_job,
        ),
        Tool(
            "redirect_job",
            (
                "Redirect a running or failed job: cancels the current execution"
                " and re-launches with new feedback/instructions. Same job ID."
                " Use when a sub-agent is going off-track or needs course correction."
            ),
            _redirect_job_schema(profiles),
            _redirect_job,
        ),
    ]


def _delegate_task_schema(profiles: list[SubagentProfile]) -> dict[str, Any]:
    properties: dict[str, Any] = {
        "task": {
            "type": "string",
            "description": "Natural language description of the task to delegate.",
        },
        "deliverables": {
            "type": "array",
            "items": {"type": "string"},
            "description": "Specific outputs expected (e.g. file paths, data formats).",
        },
        "constraints": {
            "type": "array",
            "items": {"type": "string"},
            "description": "Rules the sub-agent MUST follow.",
        },
        "out_of_scope": {
            "type": "array",
            "items": {"type": "string"},
            "description": "Things the sub-agent must NOT do.",
        },
        "validation_cmds": {
            "type": "array",
            "items": {"type": "string"},
            "description": "Shell commands to verify the result (exit 0 = pass).",
        },
        "context_files": {
            "type": "array",
            "items": {"type": "string"},
            "description": "File paths the sub-agent should read as context.",
        },
        "timeout": {
            "type": "integer",
            "description": "Max seconds to wait for the sub-agent. Defaults to 1800 (30 minutes).",
        },
        "depends_on": {
            "type": "array",
            "items": {"type": "string"},
            "description": (
                "Job IDs this task depends on. The sub-agent won't start until"
                " all dependencies are DONE. Use for sequential chains."
            ),
        },
    }

    child_properties: dict[str, Any] = {
        "task": {
            "type": "string",
            "description": "Task description for the child sub-agent.",
        },
        "deliverables": {
            "type": "array",
            "items": {"type": "string"},
        },
        "constraints": {
            "type": "array",
            "items": {"type": "string"},
        },
        "context_files": {
            "type": "array",
            "items": {"type": "string"},
        },
        "timeout": {"type": "integer"},
        "depends_on": {
            "type": "array",
            "items": {"type": "string"},
            "description": (
                "Other children this child depends on, using 1-indexed"
                " references like 'Child 1', 'Child 2' etc."
                " These are resolved to actual task IDs automatically."
                " Use for serial pipeline steps within the fan-out."
            ),
        },
    }

    if profiles:
        names = [profile.name for profile in profiles]
        profile_descriptions = "\n".join(
            f"- {profile.name}: {profile.description}" for profile in profiles
        )
        properties["subagent"] = {
            "type": "string",
            "enum": names,
            "description": (
                "Optional subagent profile to route this task."
                " If omitted, Otto uses the active session model.\n"
                f"Available profiles:\n{profile_descriptions}"
            ),
        }
        child_properties["subagent"] = {
            "type": "string",
            "enum": names,
            "description": "Optional child-specific subagent profile.",
        }

    properties["children"] = {
        "type": "array",
        "items": {
            "type": "object",
            "properties": child_properties,
            "required": ["task"],
        },
        "description": (
            "Fan-in pattern: list of child tasks to run in parallel."
            " When provided, the main 'task' becomes the parent synthesis"
            " task that runs after ALL children complete. The parent receives"
            " all child results and sends one consolidated notification."
            " Children can have inter-child depends_on for complex workflows."
        ),
    }

    return _schema(properties, ["task"])


def _list_jobs_schema() -> dict[str, Any]:
    return _schema({}, [])


def _cancel_job_schema() -> dict[str, Any]:
    return _schema(
        {"job_id": {"type": "string", "description": "Job ID to cancel (8 hex chars)."}},
        ["job_id"],
    )


def _retry_job_schema() -> dict[str, Any]:
    return _schema(
        {
            "job_id": {"type": "string", "description": "Job ID to retry (8 hex chars)."},
            "feedback": {
                "type": "string",
                "description": "Optional new instructions or corrections for the retry.",
            },
        },
        ["job_id"],
    )


def _redirect_job_schema(profiles: list[SubagentProfile]) -> dict[str, Any]:
    subagent_field: dict[str, Any] = {
        "type": "string",
        "description": (
            "Optional subagent profile name for reroute."
            " Use 'main' or omit to keep main-session routing."
        ),
    }
    if profiles:
        subagent_field["enum"] = [*sorted(profile.name for profile in profiles), "main"]

    return _schema(
        {
            "job_id": {"type": "string", "description": "Job ID to redirect (8 hex chars)."},
            "feedback": {
                "type": "string",
                "description": (
                    "Course correction instructions. The job will be cancelled"
                    " and re-launched with this feedback injected."
                ),
            },
            "subagent": subagent_field,
        },
        ["job_id", "feedback"],
    )


# === Send File Tools ===
def _create_send_file_tools(
    send_file: Callable[[str, str | None], Awaitable[None]],
    *,
    workspace: _WorkspaceContext,
) -> list[Tool]:
    async def _send_file(path: str, caption: str = "") -> str:
        return await _send_file_execute(send_file, path, caption or None, workspace=workspace)

    return [
        Tool(
            "send_file",
            "Send a file to the user (documents, images, PDFs, etc.)",
            _send_file_schema(),
            _send_file,
        )
    ]


def _send_file_schema() -> dict[str, Any]:
    return _schema(
        {
            "path": {"type": "string", "description": "Absolute path to the file to send"},
            "caption": {"type": "string", "description": "Optional caption for the file"},
        },
        ["path"],
    )


async def _send_file_execute(
    send_file: Callable[[str, str | None], Awaitable[None]],
    path: str,
    caption: str | None,
    *,
    workspace: _WorkspaceContext | None = None,
) -> str:
    workspace_ctx = _workspace_or_default(workspace)
    file_path, denial = _resolve_tool_path(path, workspace=workspace_ctx)
    if denial is not None:
        return denial
    if file_path is None:
        return f"File not found: {path}"

    if not file_path.is_file():
        return f"File not found: {path}"
    await send_file(str(file_path), caption)
    return f"Sent file: {file_path.name}"


# === Implementation ===


# === Schema Helpers ===
def _schema(properties: dict[str, Any], required: list[str]) -> dict[str, Any]:
    return {"type": "object", "properties": properties, "required": required}


# === File Tools ===
def _workspace_context(
    *,
    workspace_mode: str | None = None,
    workspace_root: str | Path | None = None,
    workspace_sandbox: str | None = None,
    workspace_sandbox_network: bool | None = None,
    lsp_pool: Any = None,
    chat_id: str | None = None,
) -> _WorkspaceContext:
    raw_mode = (
        workspace_mode if workspace_mode is not None else os.getenv(_WORKSPACE_MODE_ENV, "default")
    )
    strict_mode = str(raw_mode).strip().lower() == "strict"

    raw_sandbox = "none" if workspace_sandbox is None else str(workspace_sandbox).strip().lower()
    sandbox = raw_sandbox if raw_sandbox in {"none", "bubblewrap"} else "none"
    sandbox_network = True if workspace_sandbox_network is None else bool(workspace_sandbox_network)

    # Common kwargs threaded into every _WorkspaceContext.
    extra = {"lsp_pool": lsp_pool, "chat_id": chat_id}

    configured_root = (
        workspace_root if workspace_root is not None else os.getenv(_WORKSPACE_ROOT_ENV)
    )
    if not strict_mode:
        if configured_root is None or not str(configured_root).strip():
            return _WorkspaceContext(
                strict_mode=False,
                sandbox=sandbox,
                sandbox_network=sandbox_network,
                **extra,
            )
        root = Path(str(configured_root)).expanduser()
        try:
            return _WorkspaceContext(
                strict_mode=False,
                root=root.resolve(strict=False),
                sandbox=sandbox,
                sandbox_network=sandbox_network,
                **extra,
            )
        except Exception:
            return _WorkspaceContext(
                strict_mode=False,
                root=root,
                sandbox=sandbox,
                sandbox_network=sandbox_network,
                **extra,
            )

    if configured_root is None or not str(configured_root).strip():
        return _WorkspaceContext(
            strict_mode=True,
            error=(f"{_WORKSPACE_ROOT_ENV} is required when {_WORKSPACE_MODE_ENV}=strict"),
            sandbox=sandbox,
            sandbox_network=sandbox_network,
            **extra,
        )

    root = Path(str(configured_root)).expanduser()
    try:
        resolved_root = root.resolve(strict=True)
    except Exception as exc:
        return _WorkspaceContext(
            strict_mode=True,
            error=f"workspace root '{root}' is invalid: {exc}",
            sandbox=sandbox,
            sandbox_network=sandbox_network,
            **extra,
        )

    if not resolved_root.is_dir():
        return _WorkspaceContext(
            strict_mode=True,
            error=f"workspace root '{resolved_root}' is not a directory",
            sandbox=sandbox,
            sandbox_network=sandbox_network,
            **extra,
        )
    return _WorkspaceContext(
        strict_mode=True,
        root=resolved_root,
        sandbox=sandbox,
        sandbox_network=sandbox_network,
        **extra,
    )


def _strict_denial(reason: str) -> str:
    return f"Strict workspace mode denied: {reason}"


def _is_within_workspace(path: Path, root: Path) -> bool:
    try:
        path.relative_to(root)
        return True
    except ValueError:
        return False


def _resolve_tool_path(
    raw_path: str | None,
    *,
    workspace: _WorkspaceContext,
    default_path: str = ".",
) -> tuple[Path | None, str | None]:
    candidate_text = default_path if raw_path is None else str(raw_path)
    if candidate_text == "":
        candidate_text = default_path

    candidate = Path(candidate_text).expanduser()
    if not workspace.strict_mode:
        # In default mode, scope relative paths to workspace root so each bot
        # operates in its own configured directory instead of the process CWD.
        if not candidate.is_absolute() and workspace.root is not None:
            return workspace.root / candidate, None
        return candidate, None

    if workspace.error:
        return None, _strict_denial(workspace.error)

    root = workspace.root
    if root is None:
        return None, _strict_denial("workspace root is not configured")

    scoped = candidate if candidate.is_absolute() else root / candidate
    try:
        resolved = scoped.resolve(strict=False)
    except Exception as exc:
        return None, _strict_denial(f"path '{candidate_text}' could not be resolved: {exc}")

    if not _is_within_workspace(resolved, root):
        return (
            None,
            _strict_denial(f"path '{candidate_text}' resolves outside workspace root '{root}'"),
        )
    return resolved, None


def _workspace_or_default(workspace: _WorkspaceContext | None) -> _WorkspaceContext:
    return workspace if workspace is not None else _WorkspaceContext(strict_mode=False)


def _bash_disabled_message(warning: str | None = None) -> str:
    message = "bash is disabled in strict workspace mode (v0)."
    if warning:
        return f"{message}\nWarning: {warning}"
    return message


def _read_file_schema() -> dict[str, Any]:
    return _schema(
        {
            "path": {"type": "string", "description": "Path to file to read"},
            "offset": {"type": "integer", "description": "Zero-based line offset"},
            "limit": {"type": "integer", "description": "Maximum lines to read (max 2000)"},
            "raw": {
                "type": "boolean",
                "description": "Output without line-number prefixes (for copy-paste into diffs)",
                "default": False,
            },
            "tail": {
                "type": "integer",
                "description": "Read the last N lines of the file. Overrides offset/limit.",
                "default": 0,
            },
            "grep": {
                "type": "string",
                "description": (
                    "Filter output to lines matching this regex pattern (case-insensitive)."
                    " Original line numbers are preserved."
                ),
                "default": "",
            },
            "count": {
                "type": "boolean",
                "description": "If true, return only the total line count and byte size of the file.",
                "default": False,
            },
        },
        ["path"],
    )


async def _read_file_execute(
    path: str,
    offset: int = 0,
    limit: int = 2000,
    raw: bool = False,
    tail: int = 0,
    grep: str = "",
    count: bool = False,
    *,
    workspace: _WorkspaceContext | None = None,
) -> str:
    workspace_ctx = _workspace_or_default(workspace)
    file_path, denial = _resolve_tool_path(path, workspace=workspace_ctx)
    if denial is not None:
        return denial
    if file_path is None:
        return f"File not found: {path}"

    if not file_path.exists():
        return f"File not found: {path}"
    if not file_path.is_file():
        return f"Not a file: {path}"

    try:
        content = await asyncio.to_thread(file_path.read_text, encoding="utf-8", errors="replace")
    except Exception as exc:
        return f"Failed to read file: {exc}"

    all_lines = content.splitlines()
    total_lines = len(all_lines)

    # -- count mode: return stats only, no content --
    if count:
        byte_size = len(content.encode("utf-8"))
        return f"Lines: {total_lines}\nBytes: {byte_size}\nPath: {path}"

    # -- grep mode: filter to matching lines, preserving original 1-indexed line numbers --
    if grep:
        try:
            pattern = re.compile(grep, re.IGNORECASE)
        except re.error as exc:
            return f"grep: invalid regex pattern: {exc}"
        matched_pairs: list[tuple[int, str]] = [
            (i + 1, line) for i, line in enumerate(all_lines) if pattern.search(line)
        ]
        total_matches = len(matched_pairs)
        if not matched_pairs:
            return f"grep: no lines matching {grep!r} in {path}"
        # Cap at _MAX_LINES before byte truncation
        matched_pairs = matched_pairs[:_MAX_LINES]
        rendered: list[str] = (
            [line for _, line in matched_pairs]
            if raw
            else [f"{line_no:>3}| {line}" for line_no, line in matched_pairs]
        )
        kept_lines: list[str] = []
        kept_bytes = 0
        kept_count = 0
        for line in rendered:
            line_bytes = len(line.encode("utf-8", errors="replace")) + (1 if kept_lines else 0)
            if kept_bytes + line_bytes > _MAX_BYTES:
                break
            kept_lines.append(line)
            kept_bytes += line_bytes
            kept_count += 1
        if not kept_lines and rendered:
            first_bytes = rendered[0].encode("utf-8", errors="replace")
            snippet = first_bytes[:_MAX_BYTES].decode("utf-8", errors="ignore")
            kept_lines = [f"{snippet} ... (truncated line)"]
            kept_count = 1
        output = "\n".join(kept_lines)
        if kept_count < total_matches:
            output += (
                f"\n\n[{total_matches} matches found, showing first {kept_count} (hit limit).]"
            )
        return output

    # -- tail mode: last N lines --
    if tail and int(tail) > 0:
        t = min(int(tail), _MAX_LINES)
        tail_start = max(0, total_lines - t)
        selected = all_lines[tail_start:]
        rendered = (
            selected
            if raw
            else [f"{line_no:>3}| {line}" for line_no, line in enumerate(selected, tail_start + 1)]
        )
        kept_lines = []
        kept_bytes = 0
        kept_count = 0
        for line in rendered:
            line_bytes = len(line.encode("utf-8", errors="replace")) + (1 if kept_lines else 0)
            if kept_bytes + line_bytes > _MAX_BYTES:
                break
            kept_lines.append(line)
            kept_bytes += line_bytes
            kept_count += 1
        if not kept_lines and rendered:
            first_bytes = rendered[0].encode("utf-8", errors="replace")
            snippet = first_bytes[:_MAX_BYTES].decode("utf-8", errors="ignore")
            kept_lines = [f"{snippet} ... (truncated line)"]
            kept_count = 1
        output = "\n".join(kept_lines)
        if kept_count < len(selected):
            output += f"\n\n[Showing last {kept_count} of {t} requested lines (hit byte limit).]"
        return output

    # -- normal mode: offset + limit --
    start = max(0, int(offset or 0))
    max_lines = max(1, min(int(limit or _MAX_LINES), _MAX_LINES))
    selected = all_lines[start : start + max_lines]

    rendered = (
        selected
        if raw
        else [f"{line_no:>3}| {line}" for line_no, line in enumerate(selected, start + 1)]
    )

    kept_lines = []
    kept_bytes = 0
    kept_count = 0
    for line in rendered:
        line_bytes = len(line.encode("utf-8", errors="replace")) + (1 if kept_lines else 0)
        if kept_bytes + line_bytes > _MAX_BYTES:
            break
        kept_lines.append(line)
        kept_bytes += line_bytes
        kept_count += 1

    if not kept_lines and rendered:
        first_bytes = rendered[0].encode("utf-8", errors="replace")
        snippet = first_bytes[:_MAX_BYTES].decode("utf-8", errors="ignore")
        kept_lines = [f"{snippet} ... (truncated line)"]
        kept_count = 1

    output = "\n".join(kept_lines)

    line_truncated = kept_count < len(selected)
    file_truncated = (start + len(selected)) < total_lines
    should_show_hint = line_truncated or (file_truncated and max_lines == _MAX_LINES)
    if should_show_hint:
        start_line = start + 1
        end_line = start + kept_count
        next_offset = start + kept_count
        output += (
            "\n\n"
            f"[Showing lines {start_line}-{end_line} of {total_lines}. "
            f"Use offset={next_offset} to continue.]"
        )

    return output


def _write_file_schema() -> dict[str, Any]:
    return _schema(
        {
            "path": {"type": "string", "description": "Path to file to write"},
            "content": {"type": "string", "description": "Content to write"},
        },
        ["path", "content"],
    )


async def _write_file_execute(
    path: str,
    content: str,
    *,
    workspace: _WorkspaceContext | None = None,
) -> str:
    workspace_ctx = _workspace_or_default(workspace)
    file_path, denial = _resolve_tool_path(path, workspace=workspace_ctx)
    if denial is not None:
        return denial
    if file_path is None:
        return f"Failed to write file: invalid path '{path}'"

    try:
        await asyncio.to_thread(file_path.parent.mkdir, parents=True, exist_ok=True)
        await asyncio.to_thread(file_path.write_text, content, encoding="utf-8")
    except Exception as exc:
        return f"Failed to write file: {exc}"
    return await _append_lint(
        file_path,
        f"Wrote {len(content)} bytes to {path}",
        lsp_pool=workspace_ctx.lsp_pool,
        chat_id=workspace_ctx.chat_id,
    )


def _edit_file_schema() -> dict[str, Any]:
    return _schema(
        {
            "path": {"type": "string", "description": "Path to file to edit"},
            "old_text": {"type": "string", "description": "Exact text to replace"},
            "new_text": {"type": "string", "description": "Replacement text"},
            "replace_all": {"type": "boolean", "description": "Replace every occurrence"},
            "diff": {
                "type": "string",
                "description": "Unified diff to apply (preferred over old_text/new_text)",
            },
        },
        [],
    )


_LINE_PREFIX_RE = re.compile(r"^\s*\d+\| ")


def _strip_line_prefix(line: str) -> str:
    return _LINE_PREFIX_RE.sub("", line)


def _apply_diff(content: str, diff_text: str) -> tuple[str | None, str | None]:
    lines = content.splitlines(keepends=True)
    if lines and not lines[-1].endswith("\n"):
        lines[-1] += "\n"

    hunks: list[tuple[int, int, list[str]]] = []
    current_start = 0
    current_count = 0
    current_ops: list[str] = []
    in_hunk = False

    for raw_line in diff_text.splitlines():
        hunk_match = re.match(r"^@@ -(\d+)(?:,(\d+))? \+\d+(?:,\d+)? @@", raw_line)
        if hunk_match:
            if in_hunk:
                hunks.append((current_start, current_count, current_ops))
            current_start = int(hunk_match.group(1)) - 1
            current_count = int(hunk_match.group(2)) if hunk_match.group(2) else 1
            current_ops = []
            in_hunk = True
            continue

        if not in_hunk:
            continue

        if raw_line.startswith("---") or raw_line.startswith("+++"):
            continue

        current_ops.append(raw_line)

    if in_hunk:
        hunks.append((current_start, current_count, current_ops))

    if not hunks:
        return None, "No valid hunks found in diff"

    hunks.sort(key=lambda h: h[0], reverse=True)

    for start, _count, ops in hunks:
        ctx_idx = start
        new_lines: list[str] = []
        for op in ops:
            if op.startswith("+"):
                payload = op[1:]
                new_lines.append(payload + "\n")
            elif op.startswith("-"):
                payload = _strip_line_prefix(op[1:])
                if ctx_idx >= len(lines):
                    return None, _diff_context_error(lines, ctx_idx, payload)
                actual = lines[ctx_idx].rstrip("\n")
                if actual != payload:
                    return None, _diff_context_error(lines, ctx_idx, payload)
                ctx_idx += 1
            else:
                payload = _strip_line_prefix(op[1:] if op.startswith(" ") else op)
                if ctx_idx >= len(lines):
                    return None, _diff_context_error(lines, ctx_idx, payload)
                actual = lines[ctx_idx].rstrip("\n")
                if actual != payload:
                    return None, _diff_context_error(lines, ctx_idx, payload)
                new_lines.append(lines[ctx_idx])
                ctx_idx += 1

        lines[start:ctx_idx] = new_lines

    result = "".join(lines)
    if content and not content.endswith("\n"):
        result = result.rstrip("\n")
    return result, None


def _diff_context_error(lines: list[str], idx: int, expected: str) -> str:
    total = len(lines)
    start = max(0, idx - 3)
    end = min(total, idx + 4)
    context_lines = []
    for i in range(start, end):
        marker = ">>>" if i == idx else "   "
        context_lines.append(f"{marker} {i + 1:>3}| {lines[i].rstrip(chr(10))}")
    context = "\n".join(context_lines)
    actual = lines[idx].rstrip("\n") if idx < total else "(past end of file)"
    return (
        f"Diff context mismatch at line {idx + 1}:\n"
        f"  expected: {expected}\n"
        f"  actual:   {actual}\n"
        f"Nearby content:\n{context}"
    )


def _find_closest_match(content: str, old_text: str) -> str | None:
    old_lines = old_text.splitlines()
    content_lines = content.splitlines()
    if not old_lines or not content_lines:
        return None

    best_ratio = 0.0
    best_start = 0
    window = len(old_lines)

    for i in range(max(1, len(content_lines) - window + 1)):
        candidate = content_lines[i : i + window]
        ratio = difflib.SequenceMatcher(None, old_lines, candidate).ratio()
        if ratio > best_ratio:
            best_ratio = ratio
            best_start = i

    if best_ratio < 0.6:
        return None

    candidate_lines = content_lines[best_start : best_start + window]
    diff_lines = list(difflib.unified_diff(old_lines, candidate_lines, lineterm="", n=1))
    if not diff_lines:
        return None

    location = f"lines {best_start + 1}-{best_start + window}"
    return f"Closest match ({best_ratio:.0%} similar) at {location}:\n" + "\n".join(diff_lines[2:])


def _has_line_prefix_pattern(text: str) -> bool:
    return bool(re.search(r"^\s*\d+\| ", text, re.MULTILINE))


@dataclass(frozen=True)
class _FuzzyMatch:
    """Result of a whitespace-normalized fuzzy match."""

    ratio: float
    start: int  # char offset in original content
    end: int  # char offset in original content
    start_line: int  # 1-indexed
    end_line: int  # 1-indexed
    matched_text: str


def _normalize_ws(line: str) -> str:
    """Collapse trailing whitespace and normalize tabs to spaces."""
    return line.replace("\t", "    ").rstrip()


def _fuzzy_find(content: str, old_text: str) -> _FuzzyMatch | None:
    """Find the best whitespace-normalized match for *old_text* in *content*.

    Returns a ``_FuzzyMatch`` with the similarity ratio and positions in the
    original content, or ``None`` if no match exceeds the 0.60 floor.
    """
    old_lines = old_text.splitlines()
    content_lines = content.splitlines()
    if not old_lines or not content_lines:
        return None

    norm_old = [_normalize_ws(ln) for ln in old_lines]
    norm_content = [_normalize_ws(ln) for ln in content_lines]
    window = len(norm_old)

    best_ratio = 0.0
    best_start = 0

    for i in range(max(1, len(norm_content) - window + 1)):
        candidate = norm_content[i : i + window]
        ratio = difflib.SequenceMatcher(None, norm_old, candidate).ratio()
        if ratio > best_ratio:
            best_ratio = ratio
            best_start = i

    if best_ratio < 0.6:
        return None

    # Map back to character offsets in the original content.
    lines_before = content_lines[:best_start]
    char_start = sum(len(ln) + 1 for ln in lines_before)  # +1 for newline
    matched_lines = content_lines[best_start : best_start + window]
    matched_text = "\n".join(matched_lines)
    char_end = char_start + len(matched_text)

    return _FuzzyMatch(
        ratio=best_ratio,
        start=char_start,
        end=char_end,
        start_line=best_start + 1,
        end_line=best_start + window,
        matched_text=matched_text,
    )


async def _edit_file_execute(
    path: str,
    old_text: str = "",
    new_text: str = "",
    replace_all: bool = False,
    diff: str = "",
    *,
    workspace: _WorkspaceContext | None = None,
    edit_failures: dict[str, int] | None = None,
) -> str:
    has_diff = bool(diff and diff.strip())
    has_old_new = bool(old_text)

    if not has_diff and not has_old_new:
        return "Provide either 'diff' or 'old_text'+'new_text'"

    workspace_ctx = _workspace_or_default(workspace)
    file_path, denial = _resolve_tool_path(path, workspace=workspace_ctx)
    if denial is not None:
        return denial
    if file_path is None:
        return f"File not found: {path}"

    if not file_path.exists():
        return f"File not found: {path}"
    if not file_path.is_file():
        return f"Not a file: {path}"

    try:
        content = await asyncio.to_thread(file_path.read_text, encoding="utf-8", errors="replace")
    except Exception as exc:
        return f"Failed to read file: {exc}"

    failures = edit_failures if edit_failures is not None else {}

    if has_diff:
        result, error = _apply_diff(content, diff)
        if error:
            failures[path] = failures.get(path, 0) + 1
            fail_count = failures[path]
            msg = f"Diff failed: {error}"
            if fail_count >= 3:
                msg += "\nConsider using write_file to replace the full file content."
            return msg
        assert result is not None

        try:
            await asyncio.to_thread(file_path.write_text, result, encoding="utf-8")
        except Exception as exc:
            return f"Failed to write file: {exc}"

        failures.pop(path, None)
        return await _append_lint(
            file_path,
            f"Applied diff to {path}",
            lsp_pool=workspace_ctx.lsp_pool,
            chat_id=workspace_ctx.chat_id,
        )

    count = content.count(old_text)
    if count == 0:
        # ---- Tiered fuzzy matching ----
        fuzzy = _fuzzy_find(content, old_text)
        if fuzzy and fuzzy.ratio >= 0.98:
            # Auto-apply — almost certainly whitespace-only mismatch.
            updated = content[: fuzzy.start] + new_text + content[fuzzy.end :]
            try:
                await asyncio.to_thread(file_path.write_text, updated, encoding="utf-8")
            except Exception as exc:
                return f"Failed to write file: {exc}"
            failures.pop(path, None)
            return await _append_lint(
                file_path,
                f"Replaced 1 occurrence in {path}"
                f" (fuzzy match, {fuzzy.ratio:.0%} similar — whitespace normalized)",
                lsp_pool=workspace_ctx.lsp_pool,
                chat_id=workspace_ctx.chat_id,
            )

        failures[path] = failures.get(path, 0) + 1
        fail_count = failures[path]

        parts = ["old_text not found in file"]

        if _has_line_prefix_pattern(old_text):
            parts.append(
                "Hint: old_text contains line number prefixes from read_file"
                " — strip them and retry."
            )

        if fuzzy and fuzzy.ratio >= 0.90:
            parts.append(
                f"Fuzzy match ({fuzzy.ratio:.0%} similar) found at "
                f"lines {fuzzy.start_line}-{fuzzy.end_line}. "
                f"Retry with the exact text from the file."
            )
        else:
            closest = _find_closest_match(content, old_text)
            if closest:
                parts.append(closest)

        if fail_count >= 3:
            lines = content.splitlines()
            preview = "\n".join(f"{i + 1:>3}| {line}" for i, line in enumerate(lines[:20]))
            parts.append(f"File content (first 20 lines):\n{preview}")
            parts.append("Consider using write_file to replace the full file content.")

        return "\n".join(parts)

    if count > 1 and not replace_all:
        return (
            f"old_text appears {count} times. "
            "Set replace_all=true or provide more specific old_text."
        )

    replacement_count = count if replace_all else 1
    updated = content.replace(old_text, new_text, count if replace_all else 1)

    try:
        await asyncio.to_thread(file_path.write_text, updated, encoding="utf-8")
    except Exception as exc:
        return f"Failed to write file: {exc}"

    failures.pop(path, None)
    return await _append_lint(
        file_path,
        f"Replaced {replacement_count} occurrence(s) in {path}",
        lsp_pool=workspace_ctx.lsp_pool,
        chat_id=workspace_ctx.chat_id,
    )


async def _append_lint(
    file_path: Path,
    result_msg: str,
    *,
    lsp_pool: Any = None,
    chat_id: str | None = None,
) -> str:
    """Append lint + LSP diagnostics to a success message."""
    # Standard linter
    try:
        lint_output = await auto_lint(file_path)
    except Exception:
        lint_output = None
    if lint_output:
        result_msg = f"{result_msg}\n\nLint errors detected:\n{lint_output}"

    # LSP diagnostics (passive quality gate)
    if lsp_pool is not None:
        try:
            from otto.lsp.diagnostics import auto_diagnostics

            lsp_output = await auto_diagnostics(lsp_pool, file_path)
            if lsp_output:
                result_msg = f"{result_msg}\n\nLSP errors detected:\n{lsp_output}"
        except Exception:
            pass

        # Passive symbol context: build on first write/edit in a workspace
        if chat_id is not None:
            try:
                from otto.lsp.symbols import build_workspace_context

                root = file_path.parent
                await build_workspace_context(lsp_pool, root, chat_id)
            except Exception:
                pass

    return result_msg


# === Truncation Helpers ===
_MAX_LINES = 2000
_MAX_BYTES = 50 * 1024  # 50 KB


def _truncate_head(text: str, *, max_chars: int = _MAX_BYTES) -> str:
    """Simple head truncation for web/browse content."""
    if len(text) <= max_chars:
        return text
    return text[:max_chars] + "\n... (truncated)"


def _truncate_tail(
    text: str,
    *,
    max_lines: int = _MAX_LINES,
    max_bytes: int = _MAX_BYTES,
) -> tuple[str, bool, int, int]:
    """Tail truncation: keep last N lines / max_bytes (whichever hits first).

    Returns (content, was_truncated, total_lines, total_bytes).
    """
    total_bytes = len(text.encode("utf-8", errors="replace"))
    lines = text.split("\n")
    total_lines = len(lines)

    if total_lines <= max_lines and total_bytes <= max_bytes:
        return text, False, total_lines, total_bytes

    # Work backwards from end, collecting lines that fit.
    kept: list[str] = []
    kept_bytes = 0
    for line in reversed(lines):
        line_bytes = len(line.encode("utf-8", errors="replace")) + (1 if kept else 0)
        if len(kept) >= max_lines:
            break
        if kept_bytes + line_bytes > max_bytes:
            if not kept:
                line_bytes_raw = line.encode("utf-8", errors="replace")
                tail = line_bytes_raw[-max_bytes:].decode("utf-8", errors="ignore")
                kept.append(tail)
            break
        kept.append(line)
        kept_bytes += line_bytes

    kept.reverse()
    return "\n".join(kept), True, total_lines, total_bytes


def _spill_to_temp(output: str) -> str:
    """Write full bash output to a temp file, return the path."""
    fd, path = tempfile.mkstemp(prefix="otto-bash-", suffix=".log")
    try:
        os.write(fd, output.encode("utf-8", errors="replace"))
    finally:
        os.close(fd)
    return path


def _truncate_long_lines(text: str, max_chars: int = 500) -> tuple[str, bool]:
    """Cap per-line length for grep-like outputs."""
    lines = text.splitlines()
    clipped = False
    out_lines: list[str] = []
    for line in lines:
        if len(line) > max_chars:
            clipped = True
            out_lines.append(line[:max_chars] + "... [truncated]")
        else:
            out_lines.append(line)
    return "\n".join(out_lines), clipped


# === System Tools ===
def _bash_schema() -> dict[str, Any]:
    return _schema(
        {
            "command": {"type": "string", "description": "Shell command to execute"},
            "timeout": {"type": "integer", "description": "Timeout in seconds (default 120)"},
        },
        ["command"],
    )


async def _bash_execute(
    command: str,
    timeout: int = 120,
    *,
    workspace: _WorkspaceContext | None = None,
) -> str:
    workspace_ctx = _workspace_or_default(workspace)
    effective_timeout = max(1, int(timeout or 120))

    full_output = ""
    return_code = 0

    if workspace_ctx.strict_mode:
        if workspace_ctx.sandbox == "bubblewrap":
            if not sandbox_is_available():
                return _bash_disabled_message("bubblewrap sandbox requested but unavailable")
            if workspace_ctx.root is None:
                return _bash_disabled_message("workspace root is not configured")
            full_output, return_code = await sandboxed_exec(
                command,
                workspace_ctx.root,
                timeout=effective_timeout,
                network=workspace_ctx.sandbox_network,
            )
        else:
            return _bash_disabled_message()
    else:
        # Run in the bot's workspace root so each bot operates in its own directory.
        cwd: str | None = None
        if workspace_ctx.root is not None:
            cwd = str(workspace_ctx.root)

        try:
            proc = await asyncio.create_subprocess_shell(
                command,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
                cwd=cwd,
            )
        except Exception as exc:
            return f"Failed to start command: {exc}"

        try:
            stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=effective_timeout)
        except TimeoutError:
            proc.kill()
            await proc.wait()
            return f"Command timed out after {timeout} seconds"

        full_output = ((stdout or b"") + (stderr or b"")).decode("utf-8", errors="replace")
        return_code = int(proc.returncode or 0)

    output, truncated, total_lines, _total_bytes = _truncate_tail(full_output)
    output = output or "(no output)"

    line_notice = ""
    if re.search(r"(^|\s)rg(\s|$)", command):
        output, had_long_lines = _truncate_long_lines(output)
        if had_long_lines:
            line_notice = "\n[Some lines truncated to 500 chars]"

    if truncated:
        full_output_path = _spill_to_temp(full_output)
        shown_lines = len(output.splitlines()) if output and output != "(no output)" else 0
        start_line = max(1, total_lines - shown_lines + 1)
        output += (
            "\n\n"
            f"[Showing lines {start_line}-{total_lines} of {total_lines}. "
            f"Full output: {full_output_path}]"
        )

    if line_notice:
        output += line_notice

    if return_code != 0:
        return f"{output}\nCommand exited with code {return_code}".strip()
    return output


# === Web Tools ===


# --- Shared Exa MCP helper ---


def _parse_jsonrpc_result(raw_response: str) -> tuple[Any | None, str | None]:
    def _extract(obj: Any) -> tuple[Any | None, str | None]:
        if not isinstance(obj, dict):
            return None, "Could not parse Exa response"
        if "error" in obj:
            error = obj["error"]
            return None, str(error.get("message", error) if isinstance(error, dict) else error)
        if "result" in obj:
            return obj["result"], None
        return None, "Exa response missing result"

    for line in raw_response.splitlines():
        line = line.strip()
        if not line.startswith("data:"):
            continue
        payload = line[5:].strip()
        if not payload or payload == "[DONE]":
            continue
        try:
            return _extract(json.loads(payload))
        except json.JSONDecodeError:
            continue
    try:
        return _extract(json.loads(raw_response))
    except json.JSONDecodeError:
        return None, "Could not parse Exa response"


def _extract_mcp_text(result: Any) -> str:
    if isinstance(result, dict) and isinstance(result.get("content"), list):
        chunks = [
            item.get("text", "")
            for item in result["content"]
            if isinstance(item, dict) and item.get("type") == "text"
        ]
        if chunks:
            return "\n".join(chunk for chunk in chunks if chunk)
    return "" if result is None else str(result)


def _exa_call(tool_name: str, arguments: dict[str, Any], timeout: int = 30) -> str:
    """Call an Exa MCP tool via JSON-RPC over HTTP.

    Returns the raw HTTP response body. Raises on any network/HTTP error.
    """
    payload = {
        "jsonrpc": "2.0",
        "id": 1,
        "method": "tools/call",
        "params": {
            "name": tool_name,
            "arguments": arguments,
        },
    }
    headers = {"Content-Type": "application/json", "Accept": "application/json, text/event-stream"}
    if api_key := os.getenv("EXA_API_KEY"):
        headers["Authorization"] = f"Bearer {api_key}"

    request = urllib.request.Request(
        _EXA_ENDPOINT, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST"
    )
    with urllib.request.urlopen(request, timeout=timeout) as response:
        return response.read().decode("utf-8", errors="replace")


# --- web_search ---


def _web_search_schema() -> dict[str, Any]:
    return _schema(
        {
            "query": {"type": "string", "description": "Web search query"},
            "num_results": {"type": "integer", "description": "Number of results to return (1-20)"},
        },
        ["query"],
    )


async def _web_search_execute(query: str, num_results: int = 5) -> str:
    text_query = query.strip()
    if not text_query:
        return "query is required"

    count = max(1, min(20, int(num_results or 5)))

    try:
        raw_response = await asyncio.to_thread(
            _exa_call, "web_search_exa", {"query": text_query, "numResults": count}
        )
    except urllib.error.HTTPError as exc:
        body = exc.read().decode("utf-8", errors="replace") if hasattr(exc, "read") else ""
        return f"web_search HTTP {exc.code}: {body[:200] or exc.reason}"
    except urllib.error.URLError as exc:
        return f"web_search request failed: {exc.reason}"
    except Exception as exc:
        return f"web_search failed: {exc}"

    result, error = _parse_jsonrpc_result(raw_response)
    if error:
        return f"web_search error: {error}"

    formatted = _extract_mcp_text(result).strip()
    return (
        f"Web search results for: {text_query}\n\n{formatted}"
        if formatted
        else f"No search results for: {text_query}"
    )


# --- code_search ---


def _code_search_schema() -> dict[str, Any]:
    return _schema(
        {
            "query": {
                "type": "string",
                "description": "Code or programming search query",
            },
            "num_results": {
                "type": "integer",
                "description": "Number of results to return (1-10, default 5)",
            },
        },
        ["query"],
    )


async def _code_search_execute(query: str, num_results: int = 5) -> str:
    text_query = query.strip()
    if not text_query:
        return "query is required"

    count = max(1, min(10, int(num_results or 5)))

    try:
        raw_response = await asyncio.to_thread(
            _exa_call, "get_code_context_exa", {"query": text_query, "numResults": count}
        )
    except urllib.error.HTTPError as exc:
        body = exc.read().decode("utf-8", errors="replace") if hasattr(exc, "read") else ""
        return f"code_search HTTP {exc.code}: {body[:200] or exc.reason}"
    except urllib.error.URLError as exc:
        return f"code_search request failed: {exc.reason}"
    except Exception as exc:
        return f"code_search failed: {exc}"

    result, error = _parse_jsonrpc_result(raw_response)
    if error:
        return f"code_search error: {error}"

    formatted = _extract_mcp_text(result).strip()
    return (
        f"Code search results for: {text_query}\n\n{formatted}"
        if formatted
        else f"No code results for: {text_query}"
    )


# --- fetch ---


def _fetch_schema() -> dict[str, Any]:
    return _schema(
        {
            "url": {"type": "string", "description": "URL to fetch"},
            "timeout": {
                "type": "integer",
                "description": "Timeout in seconds (default 30, max 60)",
            },
        },
        ["url"],
    )


_UNWANTED_SELECTORS = [
    "script",
    "style",
    "nav",
    "header",
    "footer",
    "aside",
    "noscript",
    ".advertisement",
    ".ad",
    ".ads",
    ".sidebar",
    ".navigation",
    ".menu",
    ".navbar",
    ".header",
    ".footer",
    ".social-media",
    ".comments",
    ".comment-section",
    ".related-posts",
    ".share",
    ".popup",
    ".modal",
    ".overlay",
    ".cookie-notice",
    ".banner",
    '[role="banner"]',
    '[role="navigation"]',
    '[role="complementary"]',
    ".breadcrumb",
    ".pagination",
]

_MAIN_CONTENT_SELECTORS = [
    "main",
    "article",
    ".main-content",
    ".content",
    ".post-content",
    ".entry-content",
    ".article-content",
    "#content",
    "#main",
    ".page-content",
]


def _html_to_markdown(html: str) -> str:
    """Convert HTML to clean markdown using BeautifulSoup + markdownify."""
    from bs4 import BeautifulSoup
    from markdownify import markdownify

    soup = BeautifulSoup(html, "html.parser")

    # Remove unwanted elements.
    for selector in _UNWANTED_SELECTORS:
        for element in soup.select(selector):
            element.decompose()

    # Try to find main content area.
    main_content = None
    for selector in _MAIN_CONTENT_SELECTORS:
        main_content = soup.select_one(selector)
        if main_content:
            break

    if not main_content:
        main_content = soup.find("body")
        if main_content:
            for element in main_content.find_all(["header", "nav", "footer", "aside"]):
                element.decompose()

    if not main_content:
        main_content = soup

    # Strip non-essential attributes to reduce noise.
    _attrs_keep = {"href", "src", "alt", "title"}
    for element in main_content.find_all():
        attrs = dict(element.attrs)
        for attr in attrs:
            if attr not in _attrs_keep:
                del element.attrs[attr]

    md_text = markdownify(str(main_content), heading_style="ATX", bullets="-")

    # Post-process: collapse excessive blank lines, strip trailing whitespace.
    md_text = re.sub(r"\n\s*\n\s*\n", "\n\n", md_text)
    md_text = re.sub(r"[ \t]+\n", "\n", md_text)
    return md_text.strip()


_FETCH_MAX_READ_BYTES = 10 * 1024 * 1024  # 10 MB


def _do_fetch(url: str, timeout: int) -> str:
    """Fetch a URL and return formatted content (markdown for HTML, raw otherwise)."""
    headers = {
        "User-Agent": "Otto/1.0",
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    }
    request = urllib.request.Request(url, headers=headers, method="GET")
    with urllib.request.urlopen(request, timeout=timeout) as response:
        raw_bytes = response.read(_FETCH_MAX_READ_BYTES + 1)
        truncated = len(raw_bytes) > _FETCH_MAX_READ_BYTES
        if truncated:
            raw_bytes = raw_bytes[:_FETCH_MAX_READ_BYTES]
        raw = raw_bytes.decode("utf-8", errors="replace")
        content_type = response.headers.get("Content-Type", "")
        status = response.status
        final_url = response.url

        if "html" in content_type.lower():
            content = _html_to_markdown(raw)
        else:
            content = raw

        content = _truncate_head(content, max_chars=50000)

        meta = f"URL: {url}\n"
        if final_url and final_url != url:
            meta += f"Final-URL: {final_url}\n"
        meta += f"Status: {status}\nContent-Type: {content_type}"
        if truncated:
            meta += "\nNote: Response truncated at 10 MB"
        return f"{meta}\n\n{content}"


async def _fetch_execute(url: str, timeout: int = 30) -> str:
    if not url:
        return "url is required"

    effective_timeout = max(1, min(int(timeout or 30), 60))

    try:
        return await asyncio.to_thread(_do_fetch, url, effective_timeout)
    except urllib.error.HTTPError as exc:
        return f"fetch HTTP {exc.code}: {exc.reason}"
    except urllib.error.URLError as exc:
        reason = exc.reason
        if isinstance(reason, socket.timeout):
            return f"fetch timed out after {effective_timeout}s"
        if isinstance(reason, ssl.SSLError):
            return f"fetch SSL error: {reason}"
        if isinstance(reason, OSError) and reason.errno == 111:
            return f"fetch connection refused: {url}"
        if isinstance(reason, socket.gaierror):
            from urllib.parse import urlparse

            host = urlparse(url).hostname or url
            return f"fetch DNS resolution failed for {host}"
        return f"fetch request failed: {reason}"
    except TimeoutError:
        return f"fetch timed out after {effective_timeout}s"
    except Exception as exc:
        return f"fetch failed: {exc}"


# === Vision Tools ===
_VIEW_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
_VIEW_IMAGE_MAX_BYTES = 10 * 1024 * 1024  # 10 MB
_VIEW_IMAGE_MIME = {
    ".png": "image/png",
    ".jpg": "image/jpeg",
    ".jpeg": "image/jpeg",
    ".gif": "image/gif",
    ".webp": "image/webp",
}


def _view_image_schema() -> dict[str, Any]:
    return _schema(
        {
            "path": {"type": "string", "description": "Path to the image file"},
            "max_dimension": {
                "type": "integer",
                "description": "Max width/height to resize to (default 1568)",
                "default": 1568,
            },
        },
        ["path"],
    )


async def _view_image_execute(
    path: str,
    max_dimension: int = 1568,
    *,
    workspace: _WorkspaceContext | None = None,
) -> dict | str:
    import base64

    workspace_ctx = _workspace_or_default(workspace)
    file_path, denial = _resolve_tool_path(path, workspace=workspace_ctx)
    if denial is not None:
        return denial
    if file_path is None:
        return f"File not found: {path}"

    if not file_path.exists():
        return f"File not found: {path}"
    if not file_path.is_file():
        return f"Not a file: {path}"

    ext = file_path.suffix.lower()
    if ext not in _VIEW_IMAGE_EXTENSIONS:
        return f"Unsupported image format: {ext}. Supported: {', '.join(sorted(_VIEW_IMAGE_EXTENSIONS))}"

    try:
        file_size = file_path.stat().st_size
    except OSError as exc:
        return f"Cannot read file: {exc}"

    if file_size > _VIEW_IMAGE_MAX_BYTES:
        mb = file_size / (1024 * 1024)
        return f"File too large: {mb:.1f}MB (max 10MB)"

    mime = _VIEW_IMAGE_MIME[ext]
    max_dim = max(1, int(max_dimension or 1568))

    def _process() -> tuple[bytes, int, int, str]:
        raw = file_path.read_bytes()
        try:
            from PIL import Image

            img = Image.open(file_path)
            w, h = img.size
            if w > max_dim or h > max_dim:
                img.thumbnail((max_dim, max_dim))
                w, h = img.size
                import io

                buf = io.BytesIO()
                out_format = (
                    "PNG"
                    if ext == ".png"
                    else "JPEG"
                    if ext in (".jpg", ".jpeg")
                    else ext.lstrip(".").upper()
                )
                if out_format == "WEBP":
                    out_format = "WEBP"
                img.save(buf, format=out_format)
                raw = buf.getvalue()
            return raw, w, h, mime
        except ImportError:
            # Pillow not installed — send raw bytes, dimensions unknown
            return raw, 0, 0, mime

    try:
        data, width, height, out_mime = await asyncio.to_thread(_process)
    except Exception as exc:
        return f"Failed to process image: {exc}"

    encoded = base64.b64encode(data).decode("ascii")
    dim_str = f" ({width}x{height})" if width and height else ""
    return {
        "text": f"Image: {file_path.name}{dim_str}",
        "images": [{"mime": out_mime, "data": encoded}],
    }


# === Browser Tools ===
_BROWSE_ACTIONS = {"screenshot", "text", "click", "fill", "pdf"}
_BROWSE_USER_AGENT = (
    "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
    " (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
_browser_instance: Any = None
_browser_lock: asyncio.Lock | None = None


def _get_browser_lock() -> asyncio.Lock:
    global _browser_lock
    if _browser_lock is None:
        _browser_lock = asyncio.Lock()
    return _browser_lock


async def _get_browser() -> Any | str:
    """Returns browser instance, or an error string if setup is needed."""
    global _browser_instance
    lock = _get_browser_lock()
    async with lock:
        if _browser_instance is not None and _browser_instance.is_connected():
            return _browser_instance
        try:
            from playwright.async_api import Error as PlaywrightError
            from playwright.async_api import async_playwright
        except ImportError:
            return (
                "Playwright is not installed. "
                "Run: pip install 'otto-agent[browser]' then playwright install chromium"
            )

        pw = await async_playwright().start()
        try:
            _browser_instance = await pw.chromium.launch(headless=True)
        except PlaywrightError:
            return (
                "Playwright is installed but Chromium browser is not. "
                "Run: playwright install chromium"
            )
        return _browser_instance


def _browse_schema() -> dict[str, Any]:
    return _schema(
        {
            "url": {"type": "string", "description": "URL to navigate to"},
            "action": {
                "type": "string",
                "description": 'Action: "screenshot" (default), "text", "click", "fill", "pdf"',
                "default": "screenshot",
            },
            "selector": {
                "type": "string",
                "description": "CSS selector for click/fill actions",
            },
            "value": {
                "type": "string",
                "description": "Value for fill action",
            },
            "wait": {
                "type": "integer",
                "description": "Seconds to wait after navigation/action (default 2)",
                "default": 2,
            },
            "full_page": {
                "type": "boolean",
                "description": "Capture full page screenshot vs viewport (default false)",
                "default": False,
            },
            "save_path": {
                "type": "string",
                "description": "Optional file path to save the screenshot PNG to disk",
            },
        },
        ["url"],
    )


async def _browse_execute(
    url: str,
    action: str = "screenshot",
    selector: str | None = None,
    value: str | None = None,
    wait: int = 2,
    full_page: bool = False,
    save_path: str | None = None,
    workspace: _WorkspaceContext | None = None,
) -> dict[str, Any] | str:
    import base64

    if not url:
        return "url is required"

    action = (action or "screenshot").strip().lower()
    if action not in _BROWSE_ACTIONS:
        return f"Invalid action: {action}. Must be one of: {', '.join(sorted(_BROWSE_ACTIONS))}"

    if action == "click" and not selector:
        return "selector is required for click action"
    if action == "fill" and (not selector or not value):
        return "selector and value are required for fill action"

    workspace_ctx = _workspace_or_default(workspace)
    save_target: Path | None = None
    if save_path:
        if workspace_ctx.strict_mode:
            save_target, denial = _resolve_tool_path(save_path, workspace=workspace_ctx)
            if denial is not None:
                return denial
        else:
            save_target = Path(save_path).expanduser()
            if workspace_ctx.root is not None and not save_target.is_absolute():
                save_target = workspace_ctx.root / save_target
            _log.warning(
                "browse save_path is unrestricted outside strict workspace mode",
                save_path=save_path,
            )

    browser = await _get_browser()
    if browser is None or isinstance(browser, str):
        return browser or ("Browser unavailable. Run: playwright install chromium")

    wait_ms = max(0, int(wait or 2)) * 1000

    try:
        page = await browser.new_page(
            viewport={"width": 1280, "height": 720},
            user_agent=_BROWSE_USER_AGENT,
        )
        # Patch common bot-detection signals
        await page.add_init_script("""
            Object.defineProperty(navigator, 'webdriver', {get: () => undefined});
            window.chrome = { runtime: {} };
            Object.defineProperty(navigator, 'permissions', {
                get: () => ({ query: () => Promise.resolve({ state: 'granted' }) })
            });
        """)
        page.set_default_navigation_timeout(30000)
        page.set_default_timeout(10000)

        try:
            await page.goto(url, wait_until="domcontentloaded")
        except Exception as exc:
            await page.close()
            return f"Failed to navigate to {url}: {exc}"

        if wait_ms > 0:
            await page.wait_for_timeout(wait_ms)

        if action == "text":
            try:
                text = await page.inner_text("body")
            except Exception as exc:
                await page.close()
                return f"Failed to extract text: {exc}"
            await page.close()
            return _truncate_head(text.strip(), max_chars=50000)

        if action == "click":
            try:
                await page.click(selector)
            except Exception as exc:
                await page.close()
                return f"Failed to click '{selector}': {exc}"
            if wait_ms > 0:
                await page.wait_for_timeout(wait_ms)

        if action == "fill":
            try:
                await page.fill(selector, value)
            except Exception as exc:
                await page.close()
                return f"Failed to fill '{selector}': {exc}"
            if wait_ms > 0:
                await page.wait_for_timeout(wait_ms)

        if action == "pdf":
            import tempfile

            try:
                pdf_bytes = await page.pdf()
            except Exception as exc:
                await page.close()
                return f"Failed to generate PDF: {exc}"
            await page.close()
            fd, tmp_path = tempfile.mkstemp(suffix=".pdf")
            os.close(fd)
            Path(tmp_path).write_bytes(pdf_bytes)
            return f"PDF saved to {tmp_path}"

        # screenshot (default), or screenshot after click/fill
        try:
            png_bytes = await page.screenshot(full_page=full_page)
            viewport = page.viewport_size or {"width": 1280, "height": 720}
            w, h = viewport["width"], viewport["height"]
        except Exception as exc:
            await page.close()
            return f"Failed to take screenshot: {exc}"
        await page.close()

        if save_target is not None:
            save_target.parent.mkdir(parents=True, exist_ok=True)
            save_target.write_bytes(png_bytes)

        encoded = base64.b64encode(png_bytes).decode("ascii")
        text = f"Screenshot of {url} ({w}x{h})"
        if save_path:
            text += f" — saved to {save_path}"
        return {
            "text": text,
            "images": [{"mime": "image/png", "data": encoded}],
        }

    except Exception as exc:
        return f"Browser error: {exc}"


# === Skill Tools ===
def _create_skill_schema() -> dict[str, Any]:
    return _schema(
        {
            "name": {"type": "string", "description": "Skill directory name"},
            "description": {"type": "string", "description": "Skill description"},
            "content": {
                "type": "string",
                "description": "Markdown body for SKILL.md (after frontmatter). Omit when only adding/updating scripts on an existing skill.",
            },
            "license": {"type": "string", "description": "Optional skill license"},
            "compatibility": {"type": "string", "description": "Optional compatibility metadata"},
            "scripts": {
                "type": "object",
                "description": "Optional map of scripts/<filename> to file content",
                "additionalProperties": {"type": "string"},
            },
            "scope": {
                "type": "string",
                "enum": ["local", "global"],
                "description": (
                    "Where to install the skill: 'local' (workspace, default) or"
                    " 'global' (~/.otto/skills). Sandboxed bots are restricted to 'local'."
                ),
                "default": "local",
            },
        },
        ["name", "description"],
    )


async def _create_skill_execute(
    name: str,
    description: str,
    content: str | None = None,
    license: str | None = None,
    compatibility: str | None = None,
    scripts: dict[str, Any] | None = None,
    skills_dirs: list[Path] | None = None,
    scope: str = "local",
    sandboxed: bool = False,
) -> str:
    import yaml

    from otto.skills import read_skill, validate_frontmatter

    script_map = scripts if scripts is not None else {}
    if not isinstance(script_map, dict):
        return "scripts must be an object mapping filename to content"

    for filename, script_content in script_map.items():
        if not isinstance(filename, str) or not filename.strip():
            return "scripts keys must be non-empty strings"
        if not isinstance(script_content, str):
            return f"scripts entry '{filename}' must be a string"
        script_path = Path(filename)
        if script_path.is_absolute() or ".." in script_path.parts:
            return f"Invalid script filename: {filename}"

    if scope not in {"local", "global"}:
        return f"Invalid scope '{scope}'. Must be 'local' or 'global'."
    if scope == "global" and sandboxed:
        return (
            "Cannot create a global skill from a sandboxed bot. "
            "Use scope='local' to install into the workspace skills directory."
        )

    resolved_dirs = _resolve_skill_dirs(skills_dirs)
    if scope == "global":
        target_root = OTTO_HOME / "skills"
    else:
        target_root = resolved_dirs[0]

    # Built-in names are reserved for shipped skills.
    if read_skill(BUILTIN_SKILLS_DIR, name) is not None:
        return f"Cannot overwrite built-in skill '{name}'. Choose a different skill name."

    skill_dir = target_root / name
    skill_file = skill_dir / "SKILL.md"

    # Determine mode: update-scripts-only vs full create/overwrite
    scripts_only = content is None
    if scripts_only and not script_map:
        return (
            "Provide 'content' to create/update SKILL.md, 'scripts' to add/update scripts, or both."
        )

    frontmatter: dict[str, Any] = {}
    if not scripts_only:
        frontmatter = {"name": name, "description": description}
        if license is not None:
            frontmatter["license"] = license
        if compatibility is not None:
            frontmatter["compatibility"] = compatibility

        errors = validate_frontmatter(frontmatter)
        if errors:
            return "\n".join(errors)

    try:
        await asyncio.to_thread(skill_dir.mkdir, parents=True, exist_ok=True)

        if not scripts_only:
            frontmatter_text = yaml.dump(frontmatter, sort_keys=False).strip()
            skill_text = f"---\n{frontmatter_text}\n---\n\n{content}"
            await asyncio.to_thread(skill_file.write_text, skill_text, encoding="utf-8")

        if script_map:
            scripts_dir = skill_dir / "scripts"
            await asyncio.to_thread(scripts_dir.mkdir, parents=True, exist_ok=True)
            for filename, script_content in script_map.items():
                target = scripts_dir / filename
                await asyncio.to_thread(target.parent.mkdir, parents=True, exist_ok=True)
                await asyncio.to_thread(target.write_text, script_content, encoding="utf-8")
    except Exception as exc:
        return f"Failed to write skill: {exc}"

    if scripts_only:
        written = ", ".join(script_map.keys())
        return f"Updated scripts for skill '{name}': {written}"
    return f"Created skill: {skill_file}"


def _use_skill_schema() -> dict[str, Any]:
    return _schema({"name": {"type": "string", "description": "Skill name to load"}}, ["name"])


_SKILL_CONTENT_PREFIX = "__skill_content__:"


def _skill_tree(skill_dir: Path) -> str:
    """Build a compact, two-level file-tree string for a skill directory."""
    lines: list[str] = [f"{skill_dir.name}/"]
    for entry in sorted(skill_dir.iterdir(), key=lambda e: (e.is_file(), e.name)):
        if entry.is_dir():
            children = sorted(f.name for f in entry.iterdir() if f.is_file())
            lines.append(f"  {entry.name}/")
            for child in children:
                lines.append(f"    {child}")
        else:
            lines.append(f"  {entry.name}")
    return "\n".join(lines)


async def _use_skill_execute(name: str, *, skills_dirs: list[Path] | None = None) -> str:
    from otto.skills import read_skill_from_dirs

    resolved_dirs = _resolve_skill_dirs(skills_dirs)

    try:
        loaded = read_skill_from_dirs(resolved_dirs, name)
    except Exception as exc:
        return f"Failed to load skill: {exc}"
    if loaded is None:
        searched = ", ".join(str(path) for path in resolved_dirs)
        return f"Skill not found: {name} (searched: {searched})"

    content, skill_file = loaded
    skill_dir = skill_file.parent
    scripts_dir = skill_dir / "scripts"
    metadata = [
        f"__skill_location__:{skill_file}",
        f"__skill_dir__:{skill_dir}",
    ]
    if scripts_dir.is_dir():
        metadata.append(f"__skill_scripts__:{scripts_dir}")
    metadata.append(f"__skill_tree__:\n{_skill_tree(skill_dir)}")

    return f"{_SKILL_CONTENT_PREFIX}{name}\n" + "\n".join(metadata) + "\n\n" + content


# === Memory Tools ===
def _memory_search_schema() -> dict[str, Any]:
    return _schema(
        {
            "query": {"type": "string", "description": "Search query"},
            "limit": {
                "type": "integer",
                "description": "Maximum number of matches to return",
                "default": 5,
            },
        },
        ["query"],
    )


def _parse_boolean_query(query: str) -> dict[str, Any] | None:
    """Parse a query with AND/OR/NOT operators into a structured representation.

    Returns None if no boolean operators are present (plain query).
    Otherwise returns {"must": [...], "should": [...], "must_not": [...]}.
    """
    tokens = query.split()
    operators = {"AND", "OR", "NOT"}
    has_operators = any(t in operators for t in tokens)
    if not has_operators:
        return None

    must: list[str] = []
    should: list[str] = []
    must_not: list[str] = []

    i = 0
    # Default mode: terms before any operator are AND (must match)
    mode = "AND"
    negate = False
    # Track the last non-operator term index for OR retroactive reclassification
    last_term_target: str | None = None

    while i < len(tokens):
        token = tokens[i]
        if token == "AND":
            mode = "AND"
            negate = False
            i += 1
            continue
        if token == "OR":
            mode = "OR"
            negate = False
            # Retroactively move the previous term from must to should
            if last_term_target == "must" and must:
                should.append(must.pop())
            i += 1
            continue
        if token == "NOT":
            negate = True
            i += 1
            continue

        term = token
        if negate:
            must_not.append(term)
            negate = False
            last_term_target = "must_not"
        elif mode == "OR":
            should.append(term)
            last_term_target = "should"
        else:
            must.append(term)
            last_term_target = "must"

        i += 1

    if not must and not should and not must_not:
        return None
    return {"must": must, "should": should, "must_not": must_not}


def _result_matches_term(item: dict[str, str], term: str) -> bool:
    """Check if a memory result's key or snippet contains the term (case-insensitive)."""
    lower_term = term.lower()
    key = str(item.get("key", "")).lower()
    snippet = str(item.get("snippet", "")).lower()
    return lower_term in key or lower_term in snippet


async def _memory_search_execute(memory: Memory, query: str, limit: int = 5) -> str:
    effective_limit = int(limit if limit is not None else 5)
    parsed = _parse_boolean_query(query)

    if parsed is None:
        results = memory.search(query, limit=effective_limit)
        return "\n".join(
            f"{str(item.get('key', ''))}: {str(item.get('snippet', ''))}" for item in results
        )

    # Collect candidate results from all positive terms
    all_terms = parsed["must"] + parsed["should"]
    if not all_terms:
        # Only NOT terms — search with first NOT term to get candidates, then filter
        all_terms = parsed["must_not"][:1]

    seen_keys: set[str] = set()
    candidates: list[dict[str, str]] = []
    # Fetch more than needed to allow for filtering
    fetch_limit = effective_limit * 3

    for term in all_terms:
        for item in memory.search(term, limit=fetch_limit):
            key = str(item.get("key", ""))
            if key not in seen_keys:
                seen_keys.add(key)
                candidates.append(item)

    # Apply boolean filters
    filtered: list[dict[str, str]] = []
    for item in candidates:
        # must_not: exclude if any NOT term matches
        if any(_result_matches_term(item, t) for t in parsed["must_not"]):
            continue

        # must: all AND terms must match
        if parsed["must"] and not all(_result_matches_term(item, t) for t in parsed["must"]):
            continue

        # should: if there are OR terms (and no must terms), at least one must match
        if parsed["should"] and not parsed["must"]:
            if not any(_result_matches_term(item, t) for t in parsed["should"]):
                continue

        filtered.append(item)
        if len(filtered) >= effective_limit:
            break

    return "\n".join(
        f"{str(item.get('key', ''))}: {str(item.get('snippet', ''))}" for item in filtered
    )


def _memory_store_schema() -> dict[str, Any]:
    return _schema(
        {
            "key": {"type": "string", "description": "Unique memory key"},
            "content": {"type": "string", "description": "Text content to store"},
        },
        ["key", "content"],
    )


async def _memory_store_execute(memory: Memory, key: str, content: str) -> str:
    previous = memory.get(key)
    memory.store(key, content)
    if previous is not None:
        return f"Updated memory: {key}\n\nPrevious value:\n{previous}\n\nNew value:\n{content}"
    return f"Stored memory entry: {key}"


def _memory_get_schema() -> dict[str, Any]:
    return _schema(
        {"key": {"type": "string", "description": "Exact memory key to retrieve"}},
        ["key"],
    )


async def _memory_get_execute(memory: Memory, key: str) -> str:
    value = memory.get(key)
    if value is None:
        return f"No memory entry found for key: {key}"
    return f"{key}: {value}"


def _memory_delete_schema() -> dict[str, Any]:
    return _schema(
        {"key": {"type": "string", "description": "Memory key to delete"}},
        ["key"],
    )


async def _memory_delete_execute(memory: Memory, key: str) -> str:
    existing = memory.get(key)
    if existing is None:
        return f"No memory entry found for key: {key}"
    memory.delete(key)
    return f"Deleted memory entry: {key}"
